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 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;
|
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 catalogId = addObj(null);
|
||||||
const pagesId = addObj(null);
|
const pagesId = addObj(null);
|
||||||
const fontId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>');
|
const fontId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier /Encoding /WinAnsiEncoding >>');
|
||||||
const fontBoldId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier-Bold >>');
|
const fontBoldId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier-Bold /Encoding /WinAnsiEncoding >>');
|
||||||
|
|
||||||
let logoXObjId = null;
|
let logoXObjId = null;
|
||||||
if (logoBuf && logoDims) {
|
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`);
|
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 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);
|
const cid = addObj(stream);
|
||||||
contentIds.push(cid);
|
contentIds.push(cid);
|
||||||
const pid = addObj(null);
|
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 isMutual = (relationship) => !!(relationship && relationship.following && relationship.followsMe);
|
||||||
|
|
||||||
const isRecipientAllowed = ({ pmVisibility, viewerId, recipientId, relationship } = {}) => {
|
const isRecipientAllowed = ({ pmVisibility, viewerId, recipientId, relationship } = {}) => {
|
||||||
|
|
@ -6,4 +10,41 @@ const isRecipientAllowed = ({ pmVisibility, viewerId, recipientId, relationship
|
||||||
return isMutual(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 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 wrap = (txt, max = 82) => {
|
||||||
const out = [];
|
const out = [];
|
||||||
|
|
@ -131,8 +131,8 @@ function buildSmartContractPdf({ transfer, block, viewerId }) {
|
||||||
|
|
||||||
const catalogId = addObj(null);
|
const catalogId = addObj(null);
|
||||||
const pagesId = addObj(null);
|
const pagesId = addObj(null);
|
||||||
const fontId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>');
|
const fontId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier /Encoding /WinAnsiEncoding >>');
|
||||||
const fontBoldId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier-Bold >>');
|
const fontBoldId = addObj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier-Bold /Encoding /WinAnsiEncoding >>');
|
||||||
let logoXObjId = null;
|
let logoXObjId = null;
|
||||||
if (logoBuf && logoDims) {
|
if (logoBuf && logoDims) {
|
||||||
const cs = logoDims.c === 1 ? '/DeviceGray' : '/DeviceRGB';
|
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`);
|
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 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);
|
const cid = addObj(stream);
|
||||||
contentIds.push(cid);
|
contentIds.push(cid);
|
||||||
const pid = addObj(null);
|
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
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 _lastActivity = null;
|
||||||
let _maxBlockBytes = 0;
|
let _maxBlockBytes = 0;
|
||||||
let _inhabitantCount = 0;
|
let _inhabitantCount = 0;
|
||||||
|
let _mentionsCount = 0;
|
||||||
|
let _bestMatch = null;
|
||||||
|
let _dismissedSuggestion = null;
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getInboxCount: () => _inboxCount,
|
getInboxCount: () => _inboxCount,
|
||||||
setInboxCount: (n) => { _inboxCount = n; },
|
setInboxCount: (n) => { _inboxCount = n; },
|
||||||
|
|
@ -30,6 +33,12 @@ module.exports = {
|
||||||
setLastActivity: (a) => { _lastActivity = a; },
|
setLastActivity: (a) => { _lastActivity = a; },
|
||||||
getMaxBlockBytes: () => _maxBlockBytes,
|
getMaxBlockBytes: () => _maxBlockBytes,
|
||||||
setMaxBlockBytes: (n) => { if (Number(n) > _maxBlockBytes) _maxBlockBytes = Number(n); },
|
setMaxBlockBytes: (n) => { if (Number(n) > _maxBlockBytes) _maxBlockBytes = Number(n); },
|
||||||
|
getMentionsCount: () => _mentionsCount,
|
||||||
|
setMentionsCount: (n) => { _mentionsCount = Math.max(0, Number(n) || 0); },
|
||||||
getInhabitantCount: () => _inhabitantCount,
|
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()
|
im = Image.open(sys.argv[2]).copy()
|
||||||
draw = ImageDraw.Draw(im)
|
draw = ImageDraw.Draw(im)
|
||||||
|
|
||||||
for p in pins:
|
def draw_pin(draw, x, y, main):
|
||||||
x, y, main = p['x'], p['y'], p.get('main', False)
|
sw = 7 if main else 5
|
||||||
sw = 3 if main else 2
|
sh = 34 if main else 26
|
||||||
sh = 18 if main else 13
|
head = 11 if main else 8
|
||||||
clr = '#e74c3c' if main else '#3498db'
|
clr = '#e74c3c' if main else '#3498db'
|
||||||
dark = '#c0392b' if main else '#2980b9'
|
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)
|
cy = y - sh
|
||||||
draw.ellipse([x - sw - 1, y - sh - sw, x + sw + 1, y - sh + sw], fill=dark, outline='white', width=1)
|
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)
|
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)
|
result = cropped.resize((1024, 1024), Image.LANCZOS)
|
||||||
|
|
||||||
draw = ImageDraw.Draw(result)
|
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:
|
for p in pins:
|
||||||
px, py, main = p['px'], p['py'], p.get('main', False)
|
px, py, main = p['px'], p['py'], p.get('main', False)
|
||||||
if -20 <= px <= 1044 and -20 <= py <= 1044:
|
if -40 <= px <= 1064 and -40 <= py <= 1064:
|
||||||
sw = 3 if main else 2
|
draw_pin(draw, px, py, main)
|
||||||
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)
|
|
||||||
|
|
||||||
result.save(out_file, optimize=True)
|
result.save(out_file, optimize=True)
|
||||||
`;
|
`;
|
||||||
|
|
|
||||||
|
|
@ -145,7 +145,6 @@ module.exports = ({ cooler }) => {
|
||||||
updatedAt: c.updatedAt || null,
|
updatedAt: c.updatedAt || null,
|
||||||
lastVisit: c.lastVisit || null,
|
lastVisit: c.lastVisit || null,
|
||||||
tags: safeArr(c.tags),
|
tags: safeArr(c.tags),
|
||||||
category: c.category || "",
|
|
||||||
description: c.description || "",
|
description: c.description || "",
|
||||||
opinions,
|
opinions,
|
||||||
opinions_inhabitants: voters,
|
opinions_inhabitants: voters,
|
||||||
|
|
@ -182,7 +181,7 @@ module.exports = ({ cooler }) => {
|
||||||
return root;
|
return root;
|
||||||
},
|
},
|
||||||
|
|
||||||
async createBookmark(url, tagsRaw, description, category, lastVisit) {
|
async createBookmark(url, tagsRaw, description, lastVisit) {
|
||||||
const ssbClient = await openSsb();
|
const ssbClient = await openSsb();
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
|
@ -195,7 +194,6 @@ module.exports = ({ cooler }) => {
|
||||||
url: u,
|
url: u,
|
||||||
tags: normalizeTags(tagsRaw),
|
tags: normalizeTags(tagsRaw),
|
||||||
description: description || "",
|
description: description || "",
|
||||||
category: category || "",
|
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
lastVisit: coerceLastVisit(lastVisit),
|
lastVisit: coerceLastVisit(lastVisit),
|
||||||
|
|
@ -231,7 +229,6 @@ module.exports = ({ cooler }) => {
|
||||||
url,
|
url,
|
||||||
tags: updatedData.tags !== undefined ? normalizeTags(updatedData.tags) : safeArr(oldMsg.content.tags),
|
tags: updatedData.tags !== undefined ? normalizeTags(updatedData.tags) : safeArr(oldMsg.content.tags),
|
||||||
description: updatedData.description !== undefined ? updatedData.description || "" : oldMsg.content.description || "",
|
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),
|
lastVisit: coerceLastVisit(updatedData.lastVisit || oldMsg.content.lastVisit),
|
||||||
createdAt: oldMsg.content.createdAt,
|
createdAt: oldMsg.content.createdAt,
|
||||||
updatedAt: now
|
updatedAt: now
|
||||||
|
|
@ -296,11 +293,10 @@ module.exports = ({ cooler }) => {
|
||||||
if (q) {
|
if (q) {
|
||||||
list = list.filter((b) => {
|
list = list.filter((b) => {
|
||||||
const url = String(b.url || "").toLowerCase();
|
const url = String(b.url || "").toLowerCase();
|
||||||
const cat = String(b.category || "").toLowerCase();
|
|
||||||
const desc = String(b.description || "").toLowerCase();
|
const desc = String(b.description || "").toLowerCase();
|
||||||
const tags = safeArr(b.tags).join(" ").toLowerCase();
|
const tags = safeArr(b.tags).join(" ").toLowerCase();
|
||||||
const author = String(b.author || "").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)
|
return String(raw).split(",").map(t => t.trim()).filter(Boolean)
|
||||||
}
|
}
|
||||||
const hasAnyInterval = (w, m, y) => !!(w || m || y)
|
const hasAnyInterval = (w, m, y) => !!(w || m || y)
|
||||||
const expandRecurrence = (firstDate, deadline, weekly, monthly, yearly) => {
|
const { expandRecurrence } = require('./recurrence')
|
||||||
const start = new Date(firstDate)
|
|
||||||
const out = [start]
|
const ts = (v) => {
|
||||||
if (!deadline || !hasAnyInterval(weekly, monthly, yearly)) return out
|
const t = new Date(v).getTime()
|
||||||
const end = new Date(deadline).getTime()
|
return Number.isFinite(t) ? t : null
|
||||||
const seen = new Set([start.getTime()])
|
}
|
||||||
const walk = (mutate) => {
|
|
||||||
const n = new Date(start)
|
const assertCalendarDates = ({ deadline, date, until }) => {
|
||||||
mutate(n)
|
const now = Date.now()
|
||||||
while (n.getTime() <= end) {
|
const dl = deadline ? ts(deadline) : null
|
||||||
const t = n.getTime()
|
const dt = date ? ts(date) : null
|
||||||
if (!seen.has(t)) { seen.add(t); out.push(new Date(n)) }
|
const un = until ? ts(until) : null
|
||||||
mutate(n)
|
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 (weekly) walk((d) => d.setDate(d.getDate() + 7))
|
if (dl !== null && dl <= now) throw new Error("Deadline must be in the future")
|
||||||
if (monthly) walk((d) => d.setMonth(d.getMonth() + 1))
|
if (dt !== null && dt <= now) throw new Error("Date must be in the future")
|
||||||
if (yearly) walk((d) => d.setFullYear(d.getFullYear() + 1))
|
if (dt !== null && dl !== null && dt > dl) throw new Error("Date cannot be later than the deadline")
|
||||||
return out.sort((a, b) => a.getTime() - b.getTime())
|
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 }) => {
|
module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel }) => {
|
||||||
|
|
@ -108,6 +109,12 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
|
||||||
return tribeCrypto.encryptContent(content, [key], true)
|
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) => {
|
const decryptCalendarRoot = (content, rootId) => {
|
||||||
if (!content || !content.encryptedPayload) return content
|
if (!content || !content.encryptedPayload) return content
|
||||||
if (!tribeCrypto) return content
|
if (!tribeCrypto) return content
|
||||||
|
|
@ -259,6 +266,7 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
|
||||||
status: c.status || "OPEN",
|
status: c.status || "OPEN",
|
||||||
deadline: undec ? "" : (c.deadline || ""),
|
deadline: undec ? "" : (c.deadline || ""),
|
||||||
tags: Array.isArray(c.tags) ? c.tags : [],
|
tags: Array.isArray(c.tags) ? c.tags : [],
|
||||||
|
mapUrl: typeof c.mapUrl === "string" ? c.mapUrl : "",
|
||||||
author: c.author || node.author,
|
author: c.author || node.author,
|
||||||
participants: Array.isArray(participants) ? participants : (Array.isArray(c.participants) ? c.participants : []),
|
participants: Array.isArray(participants) ? participants : (Array.isArray(c.participants) ? c.participants : []),
|
||||||
invites: Array.isArray(c.invites) ? c.invites : [],
|
invites: Array.isArray(c.invites) ? c.invites : [],
|
||||||
|
|
@ -339,14 +347,18 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
|
||||||
return tip
|
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 ssbClient = await openSsb()
|
||||||
const userId = ssbClient.id
|
const userId = ssbClient.id
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
const validStatus = ["OPEN", "CLOSED"].includes(String(status).toUpperCase()) ? String(status).toUpperCase() : "OPEN"
|
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) throw new Error("First date must be in the future")
|
||||||
if (!firstDate || new Date(firstDate).getTime() <= Date.now()) throw new Error("First date must be in the future")
|
assertCalendarDates({
|
||||||
|
deadline,
|
||||||
|
date: firstDate,
|
||||||
|
until: hasAnyInterval(intervalWeekly, intervalMonthly, intervalYearly) ? intervalDeadline : ""
|
||||||
|
})
|
||||||
|
|
||||||
let plainContent = {
|
let plainContent = {
|
||||||
type: "calendar",
|
type: "calendar",
|
||||||
|
|
@ -354,6 +366,7 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
|
||||||
status: validStatus,
|
status: validStatus,
|
||||||
deadline: deadline || "",
|
deadline: deadline || "",
|
||||||
tags: normalizeTags(tags),
|
tags: normalizeTags(tags),
|
||||||
|
mapUrl: safeText(mapUrl),
|
||||||
author: userId,
|
author: userId,
|
||||||
participants: [userId],
|
participants: [userId],
|
||||||
invites: [],
|
invites: [],
|
||||||
|
|
@ -400,6 +413,7 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
|
||||||
status: validStatus,
|
status: validStatus,
|
||||||
deadline: dec.deadline || "",
|
deadline: dec.deadline || "",
|
||||||
tags: Array.isArray(dec.tags) ? dec.tags : [],
|
tags: Array.isArray(dec.tags) ? dec.tags : [],
|
||||||
|
mapUrl: dec.mapUrl || "",
|
||||||
author: userId,
|
author: userId,
|
||||||
participants: [userId],
|
participants: [userId],
|
||||||
invites: [{ code: pubCode, ek, salt: inviteSalt, gen: 1, public: true }],
|
invites: [{ code: pubCode, ek, salt: inviteSalt, gen: 1, public: true }],
|
||||||
|
|
@ -424,7 +438,7 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
|
||||||
...(intervalWeekly ? { intervalWeekly: true } : {}),
|
...(intervalWeekly ? { intervalWeekly: true } : {}),
|
||||||
...(intervalMonthly ? { intervalMonthly: true } : {}),
|
...(intervalMonthly ? { intervalMonthly: true } : {}),
|
||||||
...(intervalYearly ? { intervalYearly: true } : {}),
|
...(intervalYearly ? { intervalYearly: true } : {}),
|
||||||
...(deadline && hasAnyInterval(intervalWeekly, intervalMonthly, intervalYearly) ? { intervalDeadline: deadline } : {}),
|
...(hasAnyInterval(intervalWeekly, intervalMonthly, intervalYearly) && (intervalDeadline || deadline) ? { intervalDeadline: intervalDeadline || deadline } : {}),
|
||||||
...(tribeId ? { tribeId } : {})
|
...(tribeId ? { tribeId } : {})
|
||||||
}
|
}
|
||||||
if (tribeId) dateContent = await encryptIfTribe(dateContent)
|
if (tribeId) dateContent = await encryptIfTribe(dateContent)
|
||||||
|
|
@ -467,12 +481,15 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
|
||||||
: decryptCalendarRoot(item.content, rootId)
|
: decryptCalendarRoot(item.content, rootId)
|
||||||
assertReadable(oldDec, "Calendar")
|
assertReadable(oldDec, "Calendar")
|
||||||
if ((oldDec.author || item.content.author) !== userId) throw new Error("Not the author")
|
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 = {
|
let updated = {
|
||||||
type: "calendar",
|
type: "calendar",
|
||||||
title: data.title !== undefined ? safeText(data.title) : (oldDec.title || ""),
|
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"),
|
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 : []),
|
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,
|
author: oldDec.author || userId,
|
||||||
participants: oldDec.participants || [userId],
|
participants: oldDec.participants || [userId],
|
||||||
invites: Array.isArray(oldDec.invites) ? oldDec.invites : [],
|
invites: Array.isArray(oldDec.invites) ? oldDec.invites : [],
|
||||||
|
|
@ -495,7 +512,8 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
|
||||||
const userId = ssbClient.id
|
const userId = ssbClient.id
|
||||||
const item = await new Promise((resolve, reject) => ssbClient.get(tipId, (e, it) => e ? reject(e) : resolve(it)))
|
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")
|
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")
|
assertReadable(dec, "Calendar")
|
||||||
const contentAuthor = (dec && dec.author) || (typeof item.content === 'object' && item.content.author)
|
const contentAuthor = (dec && dec.author) || (typeof item.content === 'object' && item.content.author)
|
||||||
if (contentAuthor !== userId) throw new Error("Not the 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)
|
const cal = await this.getCalendarById(rootId)
|
||||||
if (!cal) throw new Error("Calendar not found")
|
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 (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)
|
const hasInterval = hasAnyInterval(intervalWeekly, intervalMonthly, intervalYearly)
|
||||||
|
assertCalendarDates({
|
||||||
|
deadline: cal.deadline || "",
|
||||||
|
date,
|
||||||
|
until: hasInterval ? intervalDeadline : ""
|
||||||
|
})
|
||||||
|
|
||||||
const ruleDeadline = hasInterval ? (intervalDeadline || cal.deadline || "") : ""
|
const ruleDeadline = hasInterval ? (intervalDeadline || cal.deadline || "") : ""
|
||||||
let dateContent = {
|
let dateContent = {
|
||||||
type: "calendarDate",
|
type: "calendarDate",
|
||||||
|
|
@ -748,8 +771,9 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
|
||||||
const userId = ssbClient.id
|
const userId = ssbClient.id
|
||||||
const item = await new Promise((resolve, reject) => ssbClient.get(noteId, (e, it) => e ? reject(e) : resolve(it)))
|
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")
|
if (!item || !item.content) throw new Error("Note not found")
|
||||||
const dec = await decryptIfTribe(item.content)
|
const noteRoot = item.content.calendarId || null
|
||||||
if ((dec.author || item.content.author) !== userId) throw new Error("Not the author")
|
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) => {
|
return new Promise((resolve, reject) => {
|
||||||
ssbClient.publish({ type: "tombstone", target: noteId, deletedAt: new Date().toISOString(), author: userId }, (e, msg) => e ? reject(e) : resolve(msg))
|
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 (tombstoned.has(m.key)) continue
|
||||||
if (c.calendarId !== rootId || c.dateId !== dateId) continue
|
if (c.calendarId !== rootId || c.dateId !== dateId) continue
|
||||||
let dec = c
|
let dec = c
|
||||||
if (c.encryptedPayload && tribeCrypto && tribesModel) {
|
if (c.encryptedPayload) {
|
||||||
const r = await tribeCrypto.decryptFromTribe(c, tribesModel)
|
const r = await decryptScoped(c, rootId)
|
||||||
if (r && !r._undecryptable) dec = r
|
if (r && !r._undecryptable) dec = r
|
||||||
else continue
|
else continue
|
||||||
}
|
}
|
||||||
|
|
@ -929,6 +953,7 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
|
||||||
status: dec.status || "OPEN",
|
status: dec.status || "OPEN",
|
||||||
deadline: dec.deadline || "",
|
deadline: dec.deadline || "",
|
||||||
tags: Array.isArray(dec.tags) ? dec.tags : [],
|
tags: Array.isArray(dec.tags) ? dec.tags : [],
|
||||||
|
mapUrl: dec.mapUrl || "",
|
||||||
author: dec.author,
|
author: dec.author,
|
||||||
participants: Array.isArray(dec.participants) ? dec.participants : [userId],
|
participants: Array.isArray(dec.participants) ? dec.participants : [userId],
|
||||||
invites,
|
invites,
|
||||||
|
|
@ -975,6 +1000,7 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
|
||||||
status: dec.status || "OPEN",
|
status: dec.status || "OPEN",
|
||||||
deadline: dec.deadline || "",
|
deadline: dec.deadline || "",
|
||||||
tags: Array.isArray(dec.tags) ? dec.tags : [],
|
tags: Array.isArray(dec.tags) ? dec.tags : [],
|
||||||
|
mapUrl: dec.mapUrl || "",
|
||||||
author: dec.author,
|
author: dec.author,
|
||||||
participants: Array.isArray(dec.participants) ? dec.participants : [userId],
|
participants: Array.isArray(dec.participants) ? dec.participants : [userId],
|
||||||
invites,
|
invites,
|
||||||
|
|
@ -1009,7 +1035,7 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
|
||||||
let calKey = null
|
let calKey = null
|
||||||
if (tribeCrypto && typeof matchedInvite === "object") {
|
if (tribeCrypto && typeof matchedInvite === "object") {
|
||||||
if (matchedInvite.ekChain) {
|
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) {
|
if (Array.isArray(chain) && chain.length) {
|
||||||
for (const entry of chain) {
|
for (const entry of chain) {
|
||||||
if (Array.isArray(entry.keys) && entry.keys.length) {
|
if (Array.isArray(entry.keys) && entry.keys.length) {
|
||||||
|
|
@ -1043,6 +1069,7 @@ module.exports = ({ cooler, pmModel, tribeCrypto, calendarCrypto, tribesModel })
|
||||||
status: dec.status || "OPEN",
|
status: dec.status || "OPEN",
|
||||||
deadline: dec.deadline || "",
|
deadline: dec.deadline || "",
|
||||||
tags: Array.isArray(dec.tags) ? dec.tags : [],
|
tags: Array.isArray(dec.tags) ? dec.tags : [],
|
||||||
|
mapUrl: dec.mapUrl || "",
|
||||||
author: dec.author,
|
author: dec.author,
|
||||||
participants: [...(Array.isArray(dec.participants) ? dec.participants : []), userId],
|
participants: [...(Array.isArray(dec.participants) ? dec.participants : []), userId],
|
||||||
invites,
|
invites,
|
||||||
|
|
|
||||||
|
|
@ -223,14 +223,27 @@ module.exports = (configPath, namespace = 'tribes') => {
|
||||||
return decryptWithKey(encryptedKey, derived.toString('hex'), inviteAad(inviteCode, salt));
|
return decryptWithKey(encryptedKey, derived.toString('hex'), inviteAad(inviteCode, salt));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const INVITE_ENCRYPT_ATTEMPTS = 3;
|
||||||
|
|
||||||
const encryptChainForInvite = (ancestryRootIds, code, salt) => {
|
const encryptChainForInvite = (ancestryRootIds, code, salt) => {
|
||||||
const chain = ancestryRootIds.map(rid => ({ rootId: rid, keys: getKeys(rid), gen: getGen(rid) }));
|
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;
|
if (chain.some(e => !Array.isArray(e.keys) || !e.keys.length)) return null;
|
||||||
const k = deriveInviteKey(code, salt);
|
const plain = JSON.stringify(chain);
|
||||||
return encryptWithKey(JSON.stringify(chain), k.toString('hex'), inviteAad(code, salt));
|
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);
|
const k = deriveInviteKey(code, salt);
|
||||||
try {
|
try {
|
||||||
const json = decryptWithKey(encryptedPayload, k.toString('hex'), inviteAad(code, salt));
|
const json = decryptWithKey(encryptedPayload, k.toString('hex'), inviteAad(code, salt));
|
||||||
|
|
@ -247,6 +260,15 @@ module.exports = (configPath, namespace = 'tribes') => {
|
||||||
return null;
|
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) => {
|
const inviteMatchesCode = (inv, code) => {
|
||||||
if (!inv || typeof inv !== 'object' || !inv.codeHash) return false;
|
if (!inv || typeof inv !== 'object' || !inv.codeHash) return false;
|
||||||
return inv.codeHash === hashInviteCode(code, inv.salt);
|
return inv.codeHash === hashInviteCode(code, inv.salt);
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,8 @@ module.exports = ({ cooler }) => {
|
||||||
status: data.status || 'LOOKING FOR WORK',
|
status: data.status || 'LOOKING FOR WORK',
|
||||||
preferences: data.preferences || 'REMOTE WORKING',
|
preferences: data.preferences || 'REMOTE WORKING',
|
||||||
visibility: String(data.visibility || 'PUBLIC').toUpperCase() === 'HIDDEN' ? 'HIDDEN' : 'PUBLIC',
|
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()
|
createdAt: new Date().toISOString()
|
||||||
};
|
};
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
|
@ -85,7 +87,7 @@ module.exports = ({ cooler }) => {
|
||||||
author: userId,
|
author: userId,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
description: data.description,
|
description: data.description,
|
||||||
photo: extractBlobId(photoBlobId) || null,
|
photo: extractBlobId(photoBlobId) || old.content.photo || null,
|
||||||
contact: userId,
|
contact: userId,
|
||||||
personalSkills: parseCSV(data.personalSkills),
|
personalSkills: parseCSV(data.personalSkills),
|
||||||
personalExperiences: data.personalExperiences || '',
|
personalExperiences: data.personalExperiences || '',
|
||||||
|
|
@ -102,6 +104,12 @@ module.exports = ({ cooler }) => {
|
||||||
visibility: data.visibility !== undefined
|
visibility: data.visibility !== undefined
|
||||||
? (String(data.visibility).toUpperCase() === 'HIDDEN' ? 'HIDDEN' : 'PUBLIC')
|
? (String(data.visibility).toUpperCase() === 'HIDDEN' ? 'HIDDEN' : 'PUBLIC')
|
||||||
: (old.content.visibility || '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,
|
createdAt: old.content.createdAt,
|
||||||
updatedAt: new Date().toISOString()
|
updatedAt: new Date().toISOString()
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -109,12 +109,9 @@ const MODULE_ALIASES = {
|
||||||
ai: { model: 'src/AI/ai_service.mjs', view: 'src/views/AI_view.js' },
|
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'] },
|
aiNav: { model: 'src/AI/routes_index.js', view: 'src/views/AI_view.js', paths: ['/ai/ask'] },
|
||||||
docs: { paths: ['/documents'] },
|
docs: { paths: ['/documents'] },
|
||||||
latest: { model: 'src/models/main_models.js', view: 'src/views/main_views.js', paths: ['/public/latest'] },
|
blogs: { model: 'src/models/blog_model.js', view: 'src/views/blog_view.js', paths: ['/blogs'] },
|
||||||
popular: { model: 'src/models/main_models.js', view: 'src/views/main_views.js', paths: ['/public/popular'] },
|
polls: { model: 'src/models/polls_model.js', view: 'src/views/polls_view.js', paths: ['/polls'] },
|
||||||
topics: { model: 'src/models/main_models.js', view: 'src/views/main_views.js', paths: ['/public/latest/topics'] },
|
data: { model: 'src/models/data_model.js', view: 'src/views/data_view.js', paths: ['/data'] },
|
||||||
summaries: { model: 'src/models/main_models.js', view: 'src/views/main_views.js', paths: ['/public/latest/summaries'] },
|
|
||||||
threads: { model: 'src/models/main_models.js', view: 'src/views/main_views.js', paths: ['/public/latest/threads'] },
|
|
||||||
multiverse: { model: 'src/models/main_models.js', view: 'src/views/main_views.js', paths: ['/public/latest/extended'] },
|
|
||||||
invites: { model: 'src/models/main_models.js', view: 'src/views/invites_view.js' },
|
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' }
|
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 categories = require("../backend/opinion_categories");
|
||||||
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
|
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
|
||||||
const { dedupeBy, norm } = require('../backend/dedupe');
|
const { dedupeBy, norm } = require('../backend/dedupe');
|
||||||
const mediaFavorites = require("../backend/media-favorites");
|
const contentFavorites = require("../backend/content_favorites");
|
||||||
|
|
||||||
const logLimit = getConfig().ssbLogStream?.limit || 1000;
|
const logLimit = getConfig().ssbLogStream?.limit || 1000;
|
||||||
|
|
||||||
|
|
@ -154,7 +154,7 @@ module.exports = ({ cooler }) => {
|
||||||
|
|
||||||
const favoritesSetForDocuments = async () => {
|
const favoritesSetForDocuments = async () => {
|
||||||
try {
|
try {
|
||||||
return await mediaFavorites.getFavoriteSet("documents");
|
return await contentFavorites.getFavoriteSet("documents");
|
||||||
} catch {
|
} catch {
|
||||||
return new Set();
|
return new Set();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
const pull = require('../server/node_modules/pull-stream');
|
const pull = require('../server/node_modules/pull-stream');
|
||||||
const moment = require('../server/node_modules/moment');
|
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 crypto = require('crypto');
|
||||||
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
|
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
|
||||||
const { getConfig } = require('../configs/config-manager.js');
|
const { getConfig } = require('../configs/config-manager.js');
|
||||||
|
|
@ -72,8 +74,22 @@ module.exports = ({ cooler, tribeCrypto, eventCrypto, tribesModel }) => {
|
||||||
return m.toISOString();
|
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 deriveStatus = (c) => {
|
||||||
const dateM = moment(c.date);
|
const dateM = moment(effectiveDate(c));
|
||||||
let status = String(c.status || 'OPEN').toUpperCase();
|
let status = String(c.status || 'OPEN').toUpperCase();
|
||||||
if (dateM.isValid() && dateM.isBefore(moment())) status = 'CLOSED';
|
if (dateM.isValid() && dateM.isBefore(moment())) status = 'CLOSED';
|
||||||
if (status !== 'OPEN' && status !== 'CLOSED') status = 'OPEN';
|
if (status !== 'OPEN' && status !== 'CLOSED') status = 'OPEN';
|
||||||
|
|
@ -171,7 +187,7 @@ module.exports = ({ cooler, tribeCrypto, eventCrypto, tribesModel }) => {
|
||||||
return idx.rootOf(id);
|
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 ssbClient = await openSsb();
|
||||||
const userId = await me();
|
const userId = await me();
|
||||||
|
|
||||||
|
|
@ -203,6 +219,12 @@ module.exports = ({ cooler, tribeCrypto, eventCrypto, tribesModel }) => {
|
||||||
isPublic: visibility,
|
isPublic: visibility,
|
||||||
mapUrl: String(mapUrl || "").trim(),
|
mapUrl: String(mapUrl || "").trim(),
|
||||||
clearnetPublic: clearnetPublic === true || clearnetPublic === 'true' || clearnetPublic === 'on',
|
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: {},
|
||||||
opinions_inhabitants: []
|
opinions_inhabitants: []
|
||||||
};
|
};
|
||||||
|
|
@ -461,6 +483,15 @@ module.exports = ({ cooler, tribeCrypto, eventCrypto, tribesModel }) => {
|
||||||
status,
|
status,
|
||||||
isPublic: normalizePrivacy(c.isPublic),
|
isPublic: normalizePrivacy(c.isPublic),
|
||||||
mapUrl: c.mapUrl || "",
|
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,
|
clearnetPublic: !!c.clearnetPublic,
|
||||||
encrypted: normalizePrivacy(c.isPublic) === 'private',
|
encrypted: normalizePrivacy(c.isPublic) === 'private',
|
||||||
opinions: agg.opinions,
|
opinions: agg.opinions,
|
||||||
|
|
@ -503,6 +534,12 @@ module.exports = ({ cooler, tribeCrypto, eventCrypto, tribesModel }) => {
|
||||||
isPublic: updatedData.isPublic !== undefined ? normalizePrivacy(updatedData.isPublic) : normalizePrivacy(c.isPublic),
|
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,
|
clearnetPublic: updatedData.clearnetPublic !== undefined ? (updatedData.clearnetPublic === true || updatedData.clearnetPublic === 'true' || updatedData.clearnetPublic === 'on') : !!c.clearnetPublic,
|
||||||
attendees: uniq(Array.isArray(c.attendees) ? c.attendees : []),
|
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(),
|
updatedAt: new Date().toISOString(),
|
||||||
replaces: eventId
|
replaces: eventId
|
||||||
};
|
};
|
||||||
|
|
@ -610,6 +647,14 @@ module.exports = ({ cooler, tribeCrypto, eventCrypto, tribesModel }) => {
|
||||||
status,
|
status,
|
||||||
isPublic: normalizePrivacy(c.isPublic),
|
isPublic: normalizePrivacy(c.isPublic),
|
||||||
mapUrl: c.mapUrl || "",
|
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',
|
encrypted: normalizePrivacy(c.isPublic) === 'private',
|
||||||
opinions: agg.opinions,
|
opinions: agg.opinions,
|
||||||
opinions_inhabitants: agg.opinions_inhabitants
|
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 safeArr = (v) => (Array.isArray(v) ? v : []);
|
||||||
const safeText = (v) => String(v || "").trim();
|
const safeText = (v) => String(v || "").trim();
|
||||||
|
|
@ -15,7 +15,7 @@ const toTs = (d) => {
|
||||||
return Number.isFinite(t) ? t : 0;
|
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 = {
|
const kindConfig = {
|
||||||
audios: {
|
audios: {
|
||||||
base: "/audios/",
|
base: "/audios/",
|
||||||
|
|
@ -64,10 +64,58 @@ module.exports = ({ audiosModel, bookmarksModel, documentsModel, imagesModel, vi
|
||||||
shopProducts: {
|
shopProducts: {
|
||||||
base: "/shops/product/",
|
base: "/shops/product/",
|
||||||
getById: getFn(shopsModel, ["getProductById"])
|
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 hydrateKind = async (kind, ids) => {
|
||||||
const cfg = kindConfig[kind];
|
const cfg = kindConfig[kind];
|
||||||
|
|
@ -79,9 +127,11 @@ module.exports = ({ audiosModel, bookmarksModel, documentsModel, imagesModel, vi
|
||||||
if (!id) return null;
|
if (!id) return null;
|
||||||
try {
|
try {
|
||||||
const obj = await cfg.getById(id);
|
const obj = await cfg.getById(id);
|
||||||
|
if (!obj || typeof obj !== "object") return null;
|
||||||
const viewId = safeText(obj?.key || obj?.id || id);
|
const viewId = safeText(obj?.key || obj?.id || id);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
content: obj,
|
||||||
kind,
|
kind,
|
||||||
favId: id,
|
favId: id,
|
||||||
viewHref: `${cfg.base}${encodeURIComponent(viewId)}`,
|
viewHref: `${cfg.base}${encodeURIComponent(viewId)}`,
|
||||||
|
|
@ -104,7 +154,7 @@ module.exports = ({ audiosModel, bookmarksModel, documentsModel, imagesModel, vi
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadAll = async () => {
|
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 = {};
|
const idsByKind = {};
|
||||||
kindOrder.forEach((k, i) => {
|
kindOrder.forEach((k, i) => {
|
||||||
idsByKind[k] = Array.from(sets[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 flat = kindOrder.flatMap((k) => byKind[k]);
|
||||||
|
|
||||||
const counts = {
|
const counts = { all: flat.length };
|
||||||
audios: byKind.audios.length,
|
for (const k of kindOrder) counts[k] = (byKind[k] || []).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 recentFlat = flat
|
const recentFlat = flat
|
||||||
.slice()
|
.slice()
|
||||||
|
|
@ -167,11 +204,13 @@ module.exports = ({ audiosModel, bookmarksModel, documentsModel, imagesModel, vi
|
||||||
return { items: grouped, counts };
|
return { items: grouped, counts };
|
||||||
},
|
},
|
||||||
|
|
||||||
|
kinds: kindOrder.slice(),
|
||||||
|
|
||||||
async removeFavorite(kind, id) {
|
async removeFavorite(kind, id) {
|
||||||
const k = safeText(kind);
|
const k = safeText(kind);
|
||||||
const favId = safeText(id);
|
const favId = safeText(id);
|
||||||
if (!k || !favId) return;
|
if (!k || !favId) return;
|
||||||
await mediaFavorites.removeFavorite(k, favId);
|
await contentFavorites.removeFavorite(k, favId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -142,7 +142,6 @@ module.exports = ({ cooler }) => {
|
||||||
title: c.title || "",
|
title: c.title || "",
|
||||||
description: c.description || "",
|
description: c.description || "",
|
||||||
mapUrl: c.mapUrl || "",
|
mapUrl: c.mapUrl || "",
|
||||||
meme: !!c.meme,
|
|
||||||
opinions: agg ? agg.opinions : (c.opinions || {}),
|
opinions: agg ? agg.opinions : (c.opinions || {}),
|
||||||
opinions_inhabitants: voters,
|
opinions_inhabitants: voters,
|
||||||
hasVoted: viewerId ? voters.includes(viewerId) : false,
|
hasVoted: viewerId ? voters.includes(viewerId) : false,
|
||||||
|
|
@ -177,7 +176,7 @@ module.exports = ({ cooler }) => {
|
||||||
return root;
|
return root;
|
||||||
},
|
},
|
||||||
|
|
||||||
async createImage(blobMarkdown, tagsRaw, title, description, memeBool, mapUrl) {
|
async createImage(blobMarkdown, tagsRaw, title, description, mapUrl) {
|
||||||
const ssbClient = await openSsb();
|
const ssbClient = await openSsb();
|
||||||
const blobId = parseBlobId(blobMarkdown);
|
const blobId = parseBlobId(blobMarkdown);
|
||||||
const tags = normalizeTags(tagsRaw) || [];
|
const tags = normalizeTags(tagsRaw) || [];
|
||||||
|
|
@ -193,7 +192,6 @@ module.exports = ({ cooler }) => {
|
||||||
title: title || "",
|
title: title || "",
|
||||||
description: description || "",
|
description: description || "",
|
||||||
mapUrl: mapUrl || "",
|
mapUrl: mapUrl || "",
|
||||||
meme: !!memeBool,
|
|
||||||
opinions: {},
|
opinions: {},
|
||||||
opinions_inhabitants: []
|
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 ssbClient = await openSsb();
|
||||||
const userId = ssbClient.id;
|
const userId = ssbClient.id;
|
||||||
const tipId = await this.resolveCurrentId(id);
|
const tipId = await this.resolveCurrentId(id);
|
||||||
|
|
@ -226,7 +224,6 @@ module.exports = ({ cooler }) => {
|
||||||
title: title !== undefined ? title || "" : oldMsg.content.title || "",
|
title: title !== undefined ? title || "" : oldMsg.content.title || "",
|
||||||
description: description !== undefined ? description || "" : oldMsg.content.description || "",
|
description: description !== undefined ? description || "" : oldMsg.content.description || "",
|
||||||
mapUrl: mapUrl !== undefined ? mapUrl || "" : oldMsg.content.mapUrl || "",
|
mapUrl: mapUrl !== undefined ? mapUrl || "" : oldMsg.content.mapUrl || "",
|
||||||
meme: typeof memeBool === "boolean" ? memeBool : !!oldMsg.content.meme,
|
|
||||||
createdAt: oldMsg.content.createdAt,
|
createdAt: oldMsg.content.createdAt,
|
||||||
updatedAt: now
|
updatedAt: now
|
||||||
};
|
};
|
||||||
|
|
@ -280,7 +277,6 @@ module.exports = ({ cooler }) => {
|
||||||
|
|
||||||
if (filter === "mine") list = list.filter((im) => String(im.author) === String(viewerId));
|
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 === "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") {
|
else if (filter === "top") {
|
||||||
list = list
|
list = list
|
||||||
.slice()
|
.slice()
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ const { buildValidatedTombstoneSet } = require('./tombstone_validator');
|
||||||
|
|
||||||
const logLimit = getConfig().ssbLogStream?.limit || 1000;
|
const logLimit = getConfig().ssbLogStream?.limit || 1000;
|
||||||
|
|
||||||
|
|
||||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
const WEEK_MS = 7 * DAY_MS;
|
const WEEK_MS = 7 * DAY_MS;
|
||||||
const MONTH_MS = 30 * DAY_MS;
|
const MONTH_MS = 30 * DAY_MS;
|
||||||
|
|
@ -311,7 +312,16 @@ module.exports = ({ cooler }) => {
|
||||||
ref: c.ref || null
|
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));
|
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);
|
survivors.sort((a, b) => b.ts - a.ts);
|
||||||
return survivors;
|
return survivors;
|
||||||
}
|
}
|
||||||
|
|
@ -332,11 +342,14 @@ module.exports = ({ cooler }) => {
|
||||||
async function updateLog(id, { text, label, mode }) {
|
async function updateLog(id, { text, label, mode }) {
|
||||||
const current = await getLogById(id);
|
const current = await getLogById(id);
|
||||||
if (!current) return { status: 'not_found' };
|
if (!current) return { status: 'not_found' };
|
||||||
await republishLog({
|
const next = {
|
||||||
replaces: current.key,
|
|
||||||
text: text !== undefined ? text : current.text,
|
text: text !== undefined ? text : current.text,
|
||||||
label: label !== undefined ? label : current.label,
|
label: label !== undefined ? label : current.label,
|
||||||
mode: mode || current.mode,
|
mode: mode || current.mode
|
||||||
|
};
|
||||||
|
await republishLog({
|
||||||
|
replaces: current.key,
|
||||||
|
...next,
|
||||||
createdAt: current.createdAt
|
createdAt: current.createdAt
|
||||||
});
|
});
|
||||||
return { status: 'ok' };
|
return { status: 'ok' };
|
||||||
|
|
|
||||||
|
|
@ -501,6 +501,31 @@ async function checkLocalBlob(blobId) {
|
||||||
}
|
}
|
||||||
|
|
||||||
models.blob = {
|
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 }) => {
|
getResolved: async ({ blobId, timeout = 30000 }) => {
|
||||||
let buf = await checkLocalBlob(blobId);
|
let buf = await checkLocalBlob(blobId);
|
||||||
if (buf) return buf;
|
if (buf) return buf;
|
||||||
|
|
|
||||||
|
|
@ -864,7 +864,7 @@ module.exports = ({ cooler, tribeCrypto, mapCrypto, tribesModel }) => {
|
||||||
let mapKey = null;
|
let mapKey = null;
|
||||||
if (tribeCrypto && typeof matchedInvite === "object") {
|
if (tribeCrypto && typeof matchedInvite === "object") {
|
||||||
if (matchedInvite.ekChain) {
|
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) {
|
if (Array.isArray(chain) && chain.length) {
|
||||||
for (const entry of chain) {
|
for (const entry of chain) {
|
||||||
if (Array.isArray(entry.keys) && entry.keys.length) {
|
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 path = require('path');
|
||||||
const pull = require('../server/node_modules/pull-stream');
|
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 FLAG = 'oasis-first-contact';
|
||||||
const PENDING = 'welcome=pending';
|
const PENDING = 'welcome=pending';
|
||||||
const DISMISSED = 'welcome=dismissed';
|
const DISMISSED = 'welcome=dismissed';
|
||||||
|
|
@ -122,7 +122,7 @@ module.exports = ({ cooler, ssbPath } = {}) => {
|
||||||
|
|
||||||
markStep(step) {
|
markStep(step) {
|
||||||
if (!STEPS.includes(step)) return false;
|
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;
|
if (!isPending(dir())) return true;
|
||||||
return appendMarker(dir(), `step=${step}`);
|
return appendMarker(dir(), `step=${step}`);
|
||||||
},
|
},
|
||||||
|
|
@ -147,6 +147,7 @@ module.exports = ({ cooler, ssbPath } = {}) => {
|
||||||
profile: hasProfile(mine, messages),
|
profile: hasProfile(mine, messages),
|
||||||
federation: hasFederated(mine, messages),
|
federation: hasFederated(mine, messages),
|
||||||
larp: hasJoinedLarp(messages),
|
larp: hasJoinedLarp(messages),
|
||||||
|
ux: flag.markers.has('step=ux'),
|
||||||
backup: flag.markers.has('step=backup'),
|
backup: flag.markers.has('step=backup'),
|
||||||
greeting: hasGreeted(messages)
|
greeting: hasGreeted(messages)
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,20 @@ const QUORUM_RATIO = 0.25;
|
||||||
const VOTE_METHODS = new Set(['DEMOCRACY', 'ANARCHY', 'MAJORITY', 'MINORITY']);
|
const VOTE_METHODS = new Set(['DEMOCRACY', 'ANARCHY', 'MAJORITY', 'MINORITY']);
|
||||||
const METHODS = ['DEMOCRACY', 'MAJORITY', 'MINORITY', 'DICTATORSHIP', 'KARMATOCRACY'];
|
const METHODS = ['DEMOCRACY', 'MAJORITY', 'MINORITY', 'DICTATORSHIP', 'KARMATOCRACY'];
|
||||||
const FEED_ID_RE = /^@.+\.ed25519$/;
|
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 = {} }) => {
|
module.exports = ({ cooler, services = {} }) => {
|
||||||
let ssb;
|
let ssb;
|
||||||
|
|
@ -251,7 +265,13 @@ if (c.type === type) {
|
||||||
|
|
||||||
async function listCandidaturesOpenRaw() {
|
async function listCandidaturesOpenRaw() {
|
||||||
const all = await listByType('parliamentCandidature');
|
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));
|
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;
|
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) {
|
function currentCycleStart(term) {
|
||||||
return term ? term.startAt : moment().subtract(TERM_DAYS, 'days').toISOString();
|
return term ? term.startAt : moment().subtract(TERM_DAYS, 'days').toISOString();
|
||||||
}
|
}
|
||||||
|
|
@ -352,6 +394,7 @@ if (c.type === type) {
|
||||||
async function archiveAllCandidatures() {
|
async function archiveAllCandidatures() {
|
||||||
const all = await listCandidaturesOpenRaw();
|
const all = await listCandidaturesOpenRaw();
|
||||||
for (const c of all) {
|
for (const c of all) {
|
||||||
|
if (String(c.proposer || '') !== String(userId)) continue;
|
||||||
const updated = { ...stripId(c), replaces: c.id, status: 'DISCARDED', updatedAt: nowISO() };
|
const updated = { ...stripId(c), replaces: c.id, status: 'DISCARDED', updatedAt: nowISO() };
|
||||||
await publishMsg(updated);
|
await publishMsg(updated);
|
||||||
}
|
}
|
||||||
|
|
@ -713,10 +756,7 @@ if (c.type === type) {
|
||||||
|
|
||||||
async function createProposal({ title, description }) {
|
async function createProposal({ title, description }) {
|
||||||
let term = await getCurrentTermBase();
|
let term = await getCurrentTermBase();
|
||||||
if (!term) {
|
if (!term) term = await resolveElection();
|
||||||
await resolveElection();
|
|
||||||
term = await getCurrentTermBase();
|
|
||||||
}
|
|
||||||
if (!term) throw new Error('No active government');
|
if (!term) throw new Error('No active government');
|
||||||
const allowed = await canPropose();
|
const allowed = await canPropose();
|
||||||
if (!allowed) throw new Error('You are not in the goverment, yet.');
|
if (!allowed) throw new Error('You are not in the goverment, yet.');
|
||||||
|
|
@ -833,6 +873,11 @@ if (c.type === type) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getCurrentTerm() {
|
async function getCurrentTerm() {
|
||||||
|
const published = await getCurrentTermBase();
|
||||||
|
return published || virtualAnarchyTerm();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPublishedTerm() {
|
||||||
return await getCurrentTermBase();
|
return await getCurrentTermBase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1135,44 +1180,20 @@ if (c.type === type) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const startAt = now.toISOString();
|
const window = termWindowFor(now.valueOf());
|
||||||
const endAt = moment(startAt).add(TERM_DAYS, 'days').toISOString();
|
const startAt = window.startAt;
|
||||||
|
const endAt = window.endAt;
|
||||||
|
const population = await inhabitantsCount();
|
||||||
|
|
||||||
if (!chosen) {
|
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();
|
await archiveAllCandidatures();
|
||||||
return resAnarchy;
|
return virtualAnarchyTerm(window);
|
||||||
}
|
}
|
||||||
|
|
||||||
const term = {
|
const term = {
|
||||||
type: 'parliamentTerm',
|
type: 'parliamentTerm',
|
||||||
|
cycle: window.cycle,
|
||||||
|
population,
|
||||||
method: chosen.method,
|
method: chosen.method,
|
||||||
powerType: chosen.targetType,
|
powerType: chosen.targetType,
|
||||||
powerId: chosen.targetId,
|
powerId: chosen.targetId,
|
||||||
|
|
@ -1223,21 +1244,16 @@ if (c.type === type) {
|
||||||
const term = await getLatestTermAny();
|
const term = await getLatestTermAny();
|
||||||
if (!term) return null;
|
if (!term) return null;
|
||||||
const card = await computeGovernmentCard({ ...term, id: term.id || term.startAt });
|
const card = await computeGovernmentCard({ ...term, id: term.id || term.startAt });
|
||||||
const now = moment();
|
const current = termWindowFor(Date.now());
|
||||||
let start = moment(term.startAt);
|
const termEnd = term.endAt ? new Date(term.endAt).getTime() : NaN;
|
||||||
let end = term.endAt ? moment(term.endAt) : start.clone().add(TERM_DAYS, 'days');
|
const rolled = Number.isFinite(termEnd) && termEnd <= Date.now();
|
||||||
let rolled = false;
|
|
||||||
while (start.isValid() && end.isValid() && end.isSameOrBefore(now)) {
|
|
||||||
start = end.clone();
|
|
||||||
end = end.clone().add(TERM_DAYS, 'days');
|
|
||||||
rolled = true;
|
|
||||||
}
|
|
||||||
if (rolled) {
|
if (rolled) {
|
||||||
return {
|
return {
|
||||||
...card,
|
...card,
|
||||||
id: term.id || term.startAt,
|
id: term.id || term.startAt,
|
||||||
since: start.toISOString(),
|
cycle: current.cycle,
|
||||||
end: end.toISOString(),
|
since: current.startAt,
|
||||||
|
end: current.endAt,
|
||||||
method: 'ANARCHY',
|
method: 'ANARCHY',
|
||||||
powerType: 'none',
|
powerType: 'none',
|
||||||
powerId: null,
|
powerId: null,
|
||||||
|
|
@ -1513,6 +1529,7 @@ if (c.type === type) {
|
||||||
listCandidatures,
|
listCandidatures,
|
||||||
listTerms,
|
listTerms,
|
||||||
getCurrentTerm,
|
getCurrentTerm,
|
||||||
|
getPublishedTerm,
|
||||||
getLatestTerm,
|
getLatestTerm,
|
||||||
getLatestGovernmentCard,
|
getLatestGovernmentCard,
|
||||||
listLeaders,
|
listLeaders,
|
||||||
|
|
@ -1543,6 +1560,29 @@ if (c.type === type) {
|
||||||
|
|
||||||
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
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 = []) {
|
function collapseOverlappingTerms(terms = []) {
|
||||||
if (!terms.length) return [];
|
if (!terms.length) return [];
|
||||||
const sorted = [...terms].sort((a, b) => new Date(a.startAt) - new Date(b.startAt));
|
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 });
|
if (!placed) groups.push({ items: [t], minStart: tStart, maxEnd: tEnd });
|
||||||
}
|
}
|
||||||
const winners = groups.map(g => {
|
const winners = groups.map(g => {
|
||||||
g.items.sort((a, b) => {
|
g.items.sort(compareTermsForWindow);
|
||||||
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 || ''));
|
|
||||||
});
|
|
||||||
return g.items[0];
|
return g.items[0];
|
||||||
});
|
});
|
||||||
return winners.sort((a, b) => new Date(b.startAt) - new Date(a.startAt));
|
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 pull = require('../server/node_modules/pull-stream');
|
||||||
|
const { normalizeImages, normalizeVideo } = require('./media_gallery');
|
||||||
const { getConfig } = require('../configs/config-manager.js');
|
const { getConfig } = require('../configs/config-manager.js');
|
||||||
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
|
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
|
||||||
const { dedupeBy, norm } = require('../backend/dedupe');
|
const { dedupeBy, norm } = require('../backend/dedupe');
|
||||||
|
|
@ -39,7 +40,7 @@ const normalizeTemplate = (category, tpl) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cat === 'CONTENT') {
|
if (cat === 'CONTENT') {
|
||||||
return pick(['contentLocation', 'whyInappropriate', 'requestedAction', 'evidenceLinks']);
|
return pick(['contentLocation', 'whyInappropriate', 'evidenceLinks']);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {};
|
return {};
|
||||||
|
|
@ -164,6 +165,8 @@ module.exports = ({ cooler }) => {
|
||||||
...c,
|
...c,
|
||||||
category: cat,
|
category: cat,
|
||||||
status: normalizeStatus(c.status || 'OPEN'),
|
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',
|
severity: normalizeSeverity(c.severity) || 'low',
|
||||||
confirmations,
|
confirmations,
|
||||||
tags: ensureArray(c.tags),
|
tags: ensureArray(c.tags),
|
||||||
|
|
@ -174,14 +177,13 @@ module.exports = ({ cooler }) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
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 ssb = await openSsb();
|
||||||
const userId = ssb.id;
|
const userId = ssb.id;
|
||||||
|
|
||||||
let blobId = null;
|
const images = normalizeImages((media && media.images && media.images.length) ? media.images : image);
|
||||||
if (image) {
|
const blobId = images[0] || null;
|
||||||
blobId = String(image).trim() || null;
|
const clip = normalizeVideo(media && media.video);
|
||||||
}
|
|
||||||
|
|
||||||
const tags = Array.isArray(tagsRaw)
|
const tags = Array.isArray(tagsRaw)
|
||||||
? tagsRaw.filter(Boolean)
|
? tagsRaw.filter(Boolean)
|
||||||
|
|
@ -196,6 +198,8 @@ module.exports = ({ cooler }) => {
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
author: userId,
|
author: userId,
|
||||||
image: blobId,
|
image: blobId,
|
||||||
|
images,
|
||||||
|
video: clip,
|
||||||
tags,
|
tags,
|
||||||
confirmations: [],
|
confirmations: [],
|
||||||
severity: normalizeSeverity(severity) || 'low',
|
severity: normalizeSeverity(severity) || 'low',
|
||||||
|
|
@ -227,10 +231,15 @@ module.exports = ({ cooler }) => {
|
||||||
? String(updatedContent.tags || '').split(',').map(t => t.trim()).filter(Boolean)
|
? String(updatedContent.tags || '').split(',').map(t => t.trim()).filter(Boolean)
|
||||||
: ensureArray(report.content.tags);
|
: ensureArray(report.content.tags);
|
||||||
|
|
||||||
let blobId = report.content.image || null;
|
const nextImages = normalizeImages(
|
||||||
if (updatedContent.image) {
|
Object.prototype.hasOwnProperty.call(updatedContent, 'images')
|
||||||
blobId = String(updatedContent.image).trim() || null;
|
? 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')
|
const nextStatus = Object.prototype.hasOwnProperty.call(updatedContent, 'status')
|
||||||
? normalizeStatus(updatedContent.status)
|
? normalizeStatus(updatedContent.status)
|
||||||
|
|
@ -258,6 +267,8 @@ module.exports = ({ cooler }) => {
|
||||||
type: 'report',
|
type: 'report',
|
||||||
replaces: tipId,
|
replaces: tipId,
|
||||||
image: blobId,
|
image: blobId,
|
||||||
|
images: nextImages,
|
||||||
|
video: nextVideo,
|
||||||
tags,
|
tags,
|
||||||
confirmations,
|
confirmations,
|
||||||
opinions: agg.opinions,
|
opinions: agg.opinions,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
const pull = require('../server/node_modules/pull-stream');
|
const pull = require('../server/node_modules/pull-stream');
|
||||||
const moment = require('../server/node_modules/moment');
|
const moment = require('../server/node_modules/moment');
|
||||||
|
const { normalizeImages, normalizeVideo } = require('./media_gallery');
|
||||||
const { getConfig } = require('../configs/config-manager.js');
|
const { getConfig } = require('../configs/config-manager.js');
|
||||||
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
|
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
|
||||||
const { dedupeBy, norm } = require('../backend/dedupe');
|
const { dedupeBy, norm } = require('../backend/dedupe');
|
||||||
|
|
@ -131,6 +132,8 @@ module.exports = ({ cooler, pmModel }) => {
|
||||||
id: node.key,
|
id: node.key,
|
||||||
rootId,
|
rootId,
|
||||||
...c,
|
...c,
|
||||||
|
images: normalizeImages(c.images),
|
||||||
|
video: normalizeVideo(c.video),
|
||||||
assignees,
|
assignees,
|
||||||
opinions,
|
opinions,
|
||||||
opinions_inhabitants: voters,
|
opinions_inhabitants: voters,
|
||||||
|
|
@ -139,7 +142,7 @@ module.exports = ({ cooler, pmModel }) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
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 ssb = await openSsb();
|
||||||
const userId = ssb.id;
|
const userId = ssb.id;
|
||||||
|
|
||||||
|
|
@ -166,6 +169,8 @@ module.exports = ({ cooler, pmModel }) => {
|
||||||
location,
|
location,
|
||||||
tags,
|
tags,
|
||||||
isPublic: visibility,
|
isPublic: visibility,
|
||||||
|
images: normalizeImages(media && media.images),
|
||||||
|
video: normalizeVideo(media && media.video),
|
||||||
assignees: [userId],
|
assignees: [userId],
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
status: 'OPEN',
|
status: 'OPEN',
|
||||||
|
|
@ -253,6 +258,8 @@ module.exports = ({ cooler, pmModel }) => {
|
||||||
tags: newTags,
|
tags: newTags,
|
||||||
isPublic: newVisibility,
|
isPublic: newVisibility,
|
||||||
status: newStatus,
|
status: newStatus,
|
||||||
|
images: normalizeImages(updatedData.images !== undefined ? updatedData.images : c.images),
|
||||||
|
video: updatedData.video !== undefined ? normalizeVideo(updatedData.video) : normalizeVideo(c.video),
|
||||||
assignees: nextAssignees,
|
assignees: nextAssignees,
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
replaces: taskId
|
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 (typeof c.ch !== 'string' || typeof c.s !== 'string' || typeof c.ek !== 'string') continue;
|
||||||
if (inviteTombstoned.has(m.key)) continue;
|
if (inviteTombstoned.has(m.key)) continue;
|
||||||
if (tribeCrypto.hashInviteCode(code, c.s) !== c.ch) 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) {
|
if (Array.isArray(chain) && chain.length) {
|
||||||
matched = { msgKey: m.key, codeHash: c.ch, chain, multi: c.multi === 1 || c.multi === true };
|
matched = { msgKey: m.key, codeHash: c.ch, chain, multi: c.multi === 1 || c.multi === true };
|
||||||
break;
|
break;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@krakenslab/oasis",
|
"name": "@krakenslab/oasis",
|
||||||
"version": "0.9.1",
|
"version": "0.9.5",
|
||||||
"description": "Oasis - Social Networking Utopia",
|
"description": "Oasis - Social Networking Utopia",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|
|
||||||
|
|
@ -23,4 +23,9 @@ const megabyte = Math.pow(2, 20);
|
||||||
config.blobs = config.blobs || {};
|
config.blobs = config.blobs || {};
|
||||||
config.blobs.max = 50 * megabyte;
|
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;
|
module.exports = config;
|
||||||
|
|
|
||||||
|
|
@ -9,74 +9,34 @@ exports.aiView = (history = [], userPrompt = '') => {
|
||||||
div({ class: "tags-header" },
|
div({ class: "tags-header" },
|
||||||
h2(i18n.aiTitle),
|
h2(i18n.aiTitle),
|
||||||
p(i18n.aiDescription),
|
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'}: `,
|
`${i18n.aiPromptUsed || 'System Prompt'}: `,
|
||||||
span({ style: 'font-style: italic;' }, `"${userPrompt}"`)
|
span({ class: 'user-prompt-text' }, `"${userPrompt}"`)
|
||||||
) : null,
|
) : 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 }),
|
textarea({ name: 'input', rows: 4, placeholder: i18n.aiInputPlaceholder, required: true }),
|
||||||
br(),
|
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)
|
button({ type: 'submit' }, i18n.aiSubmitButton)
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
div({ style: "display: flex; justify-content: flex-end; margin-bottom: 2em;" },
|
div({ class: 'ai-clear-row' },
|
||||||
form({ method: 'POST', action: '/ai/clear', style: "display: inline;" },
|
form({ method: 'POST', action: '/ai/clear', class: 'ai-clear-form' },
|
||||||
button({
|
button({ type: 'submit', class: 'ai-clear-btn' }, i18n.aiClearHistory)
|
||||||
type: 'submit',
|
|
||||||
style: `
|
|
||||||
background: #b80c09;
|
|
||||||
color: #fff;
|
|
||||||
border: none;
|
|
||||||
padding: 0.4em 1.2em;
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 1em;
|
|
||||||
margin-left: 1em;
|
|
||||||
`
|
|
||||||
}, i18n.aiClearHistory)
|
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
br(),
|
br(),
|
||||||
...history.map(entry =>
|
...history.map(entry =>
|
||||||
div({
|
div({ class: 'chat-entry' },
|
||||||
class: 'chat-entry',
|
entry.timestamp
|
||||||
style: `
|
? span({ class: 'chat-entry-timestamp' }, new Date(entry.timestamp).toLocaleString())
|
||||||
margin-bottom: 2em;
|
: null,
|
||||||
position: relative;
|
|
||||||
background: #191919;
|
|
||||||
border-radius: 10px;
|
|
||||||
box-shadow: 0 0 8px #0004;
|
|
||||||
padding-top: 1.8em;
|
|
||||||
`
|
|
||||||
},
|
|
||||||
entry.timestamp ? span({
|
|
||||||
style: `
|
|
||||||
position: absolute;
|
|
||||||
top: 0.5em;
|
|
||||||
right: 1.3em;
|
|
||||||
font-size: 0.92em;
|
|
||||||
color: #888;
|
|
||||||
`
|
|
||||||
}, new Date(entry.timestamp).toLocaleString()) : null,
|
|
||||||
br(), br(),
|
br(), br(),
|
||||||
div({ class: 'user-question', style: 'margin-bottom: 0.75em;' },
|
div({ class: 'user-question' },
|
||||||
h2(`${i18n.aiUserQuestion}:`),
|
h2(`${i18n.aiUserQuestion}:`),
|
||||||
p(...renderUrl(entry.question))
|
p(...renderUrl(entry.question))
|
||||||
),
|
),
|
||||||
div({
|
div({ class: 'ai-response' },
|
||||||
class: 'ai-response',
|
|
||||||
style: `
|
|
||||||
max-width: 800px;
|
|
||||||
margin: auto;
|
|
||||||
background: #111;
|
|
||||||
padding: 1.25em;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-family: sans-serif;
|
|
||||||
line-height: 1.6;
|
|
||||||
color: #ffcc00;
|
|
||||||
`
|
|
||||||
},
|
|
||||||
h2(`${i18n.aiResponseTitle}:`),
|
h2(`${i18n.aiResponseTitle}:`),
|
||||||
...String(entry.answer || '')
|
...String(entry.answer || '')
|
||||||
.split('\n\n')
|
.split('\n\n')
|
||||||
|
|
@ -84,31 +44,19 @@ exports.aiView = (history = [], userPrompt = '') => {
|
||||||
paragraph
|
paragraph
|
||||||
.split('\n')
|
.split('\n')
|
||||||
.map(line =>
|
.map(line =>
|
||||||
p({ style: "margin-bottom: 1.2em;" }, ...renderUrl(line.trim()))
|
p(...renderUrl(line.trim()))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
div({
|
div({ class: 'ai-train-bar' },
|
||||||
class: 'ai-train-bar',
|
|
||||||
style: `
|
|
||||||
display:flex;
|
|
||||||
align-items:center;
|
|
||||||
gap:12px;
|
|
||||||
margin: 12px auto 8px auto;
|
|
||||||
max-width: 800px;
|
|
||||||
padding: 8px 0;
|
|
||||||
border-top: 1px solid #2a2a2a;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
`
|
|
||||||
},
|
|
||||||
Array.isArray(entry.snippets) && entry.snippets.length
|
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,
|
: null,
|
||||||
h2(`${i18n.statsAITraining}:`),
|
h2(`${i18n.statsAITraining}:`),
|
||||||
entry.trainStatus === 'approved'
|
entry.trainStatus === 'approved'
|
||||||
? span({ style: 'color:#5ad25a; font-weight:600;' }, i18n.aiTrainApproved)
|
? span({ class: 'ai-train-approved' }, i18n.aiTrainApproved)
|
||||||
: entry.trainStatus === 'rejected'
|
: entry.trainStatus === 'rejected'
|
||||||
? span({ style: 'color:#ff6b6b; font-weight:600;' }, i18n.aiTrainRejected)
|
? span({ class: 'ai-train-rejected' }, i18n.aiTrainRejected)
|
||||||
: null,
|
: null,
|
||||||
entry.trainStatus === 'approved' || entry.trainStatus === 'rejected'
|
entry.trainStatus === 'approved' || entry.trainStatus === 'rejected'
|
||||||
? null
|
? 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 score = Number(s.userEngagementScore || 0);
|
||||||
const pool = Number(s.pool || 0);
|
const pool = Number(s.pool || 0);
|
||||||
const W = Math.max(1, Number(s.weightsSum || 1));
|
const W = Math.max(1, Number(s.weightsSum || 1));
|
||||||
|
|
@ -315,16 +316,16 @@ const renderOverviewSummaryTable = (s, rules, userEcoinTax) => {
|
||||||
tbody(
|
tbody(
|
||||||
kvRow(i18n.bankUserBalance, `${Number(s.userBalance || 0).toFixed(6)} ECO`),
|
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.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,
|
s.pubId ? kvRow(i18n.pubIdLabel, userLink(s.pubId)) : null,
|
||||||
kvRow(i18n.bankEpoch, String(s.epochId || "-")),
|
kvRow(i18n.bankEpoch, String(s.epochId || "-")),
|
||||||
kvRow(i18n.bankPool, `${pool.toFixed(6)} ECO`),
|
kvRow(i18n.bankPool, `${pool.toFixed(6)} ECO`),
|
||||||
kvRow(i18n.bankWeightsSum, String(W.toFixed(6))),
|
kvRow(i18n.bankWeightsSum, String(W.toFixed(6))),
|
||||||
kvRow(i18n.bankingUserEngagementScore, String(score)),
|
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.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.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"),
|
generateFilterButtons(["overview","exchange","taxes","mine","pending","closed","claimed","expired","epochs","rules","addresses"], filter, "/banking"),
|
||||||
filter === "overview"
|
filter === "overview"
|
||||||
? div(
|
? 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),
|
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)
|
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' },
|
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-chip pm-exposition-fediverse' },
|
||||||
span({ class: 'pm-exposition-icon' }, '🐘'),
|
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)
|
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" },
|
form({ method: "GET", action: "/reports" },
|
||||||
input({ type: "hidden", name: "filter", value: "create" }),
|
input({ type: "hidden", name: "filter", value: "create" }),
|
||||||
input({ type: "hidden", name: "category", value: "BUGS" }),
|
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 }),
|
input({ type: "hidden", name: "description", value: reportDescription }),
|
||||||
button({ type: "submit", class: "filter-btn" }, i18n.devReportBug || "Report Bug")
|
button({ type: "submit", class: "filter-btn" }, i18n.devReportBug || "Report Bug")
|
||||||
),
|
),
|
||||||
form({ method: "GET", action: "/tasks" },
|
form({ method: "GET", action: "/tasks" },
|
||||||
input({ type: "hidden", name: "filter", value: "create" }),
|
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 }),
|
input({ type: "hidden", name: "description", value: reportDescription }),
|
||||||
button({ type: "submit", class: "filter-btn" }, i18n.devCreateTask || "Create Task")
|
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 safeArr = (v) => (Array.isArray(v) ? v : []);
|
||||||
const safeText = (v) => String(v || "").trim();
|
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 buildReturnTo = (filter) => {
|
||||||
const f = safeText(filter || "all");
|
const f = safeText(filter || "all");
|
||||||
return `/favorites?filter=${encodeURIComponent(f)}`;
|
return `/favorites?filter=${encodeURIComponent(f)}`;
|
||||||
|
|
@ -25,6 +49,47 @@ const renderTags = (tags) => {
|
||||||
: null;
|
: 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) => {
|
const renderBookmarkUrl = (item) => {
|
||||||
if (item.kind !== "bookmarks") return null;
|
if (item.kind !== "bookmarks") return null;
|
||||||
if (!item.url) 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 title = safeText(item.title) || safeText(item.name) || safeText(item.category) || safeText(item.url) || "";
|
||||||
|
|
||||||
const ts = item.updatedAt || item.createdAt;
|
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);
|
const isOwn = item.author && String(item.author) === String(userId);
|
||||||
return div(
|
return div(
|
||||||
|
|
@ -95,6 +160,7 @@ const renderFavoriteCard = (item, filter) => {
|
||||||
renderImagePreview(item),
|
renderImagePreview(item),
|
||||||
renderBookmarkUrl(item),
|
renderBookmarkUrl(item),
|
||||||
safeText(item.description) ? p(...renderUrl(item.description)) : null,
|
safeText(item.description) ? p(...renderUrl(item.description)) : null,
|
||||||
|
renderDetailFields(item),
|
||||||
renderTags(item.tags),
|
renderTags(item.tags),
|
||||||
p(
|
p(
|
||||||
{ class: "card-footer" },
|
{ 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 c = counts || {};
|
||||||
const total = typeof c.all === "number" ? c.all : safeArr(items).length;
|
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" },
|
{ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" },
|
||||||
`${i18n.favoritesFilterRecent} (${total})`
|
`${i18n.favoritesFilterRecent} (${total})`
|
||||||
),
|
),
|
||||||
button(
|
...FILTER_KINDS.map((k) =>
|
||||||
{ type: "submit", name: "filter", value: "audios", class: filter === "audios" ? "filter-btn active" : "filter-btn" },
|
button(
|
||||||
`${i18n.favoritesFilterAudios} (${c.audios || 0})`
|
{ type: "submit", name: "filter", value: k.value, class: filter === k.value ? "filter-btn active" : "filter-btn" },
|
||||||
),
|
`${k.label()} (${c[k.value] || 0})`
|
||||||
button(
|
)
|
||||||
{ type: "submit", name: "filter", value: "bookmarks", class: filter === "bookmarks" ? "filter-btn active" : "filter-btn" },
|
)
|
||||||
`${i18n.favoritesFilterBookmarks} (${c.bookmarks || 0})`
|
)
|
||||||
),
|
),
|
||||||
button(
|
div(
|
||||||
{ type: "submit", name: "filter", value: "documents", class: filter === "documents" ? "filter-btn active" : "filter-btn" },
|
{ class: "filters" },
|
||||||
`${i18n.favoritesFilterDocuments} (${c.documents || 0})`
|
form(
|
||||||
),
|
{ method: "GET", action: "/favorites", class: "filter-box" },
|
||||||
button(
|
input({ type: "hidden", name: "filter", value: filter }),
|
||||||
{ type: "submit", name: "filter", value: "images", class: filter === "images" ? "filter-btn active" : "filter-btn" },
|
input({ type: "text", name: "q", value: q, placeholder: i18n.favoritesSearchPlaceholder, class: "filter-box__input" }),
|
||||||
`${i18n.favoritesFilterImages} (${c.images || 0})`
|
div({ class: "filter-box__controls" },
|
||||||
),
|
button({ type: "submit", class: "filter-box__button" }, i18n.searchButton)
|
||||||
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})`
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ const moment = require("../server/node_modules/moment");
|
||||||
const fmtDate = (ts) => {
|
const fmtDate = (ts) => {
|
||||||
if (!ts) return "";
|
if (!ts) return "";
|
||||||
const m = moment(ts);
|
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) => {
|
const renderMedia = (m) => {
|
||||||
|
|
@ -111,7 +111,7 @@ const mastodonBox = (account, stats, actions, showLabel) => {
|
||||||
|
|
||||||
const disconnectedBlock = () => {
|
const disconnectedBlock = () => {
|
||||||
const text = String(i18n.fediverseDisconnected || "");
|
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 parts = text.split("%LINK%");
|
||||||
const para = parts.length > 1
|
const para = parts.length > 1
|
||||||
? p(parts[0], settingsLink, parts.slice(1).join("%LINK%"))
|
? 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 { 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 { config } = require("../server/SSB_server.js");
|
||||||
const { renderTextWithStyles } = require("../backend/renderTextWithStyles");
|
const { renderTextWithStyles } = require("../backend/renderTextWithStyles");
|
||||||
const moment = require("../server/node_modules/moment");
|
const moment = require("../server/node_modules/moment");
|
||||||
|
|
@ -56,7 +57,7 @@ const generateFilterButtons = (filters, currentFilter, action, extra = {}) => {
|
||||||
{ method: "GET", action },
|
{ method: "GET", action },
|
||||||
input({ type: "hidden", name: "filter", value: mode }),
|
input({ type: "hidden", name: "filter", value: mode }),
|
||||||
...hiddenInputs(extra),
|
...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 renderFeedCommentsSection = (feedKey, comments = []) => {
|
||||||
const list = Array.isArray(comments) ? comments : [];
|
return renderSharedCommentsSection({
|
||||||
const commentsCount = list.length;
|
action: `/feed/${encodeURIComponent(feedKey)}/comments`,
|
||||||
|
comments: comments,
|
||||||
return div(
|
returnTo: null
|
||||||
{ class: "vote-comments-section" },
|
});
|
||||||
div(
|
|
||||||
{ class: "comments-count" },
|
|
||||||
span({ class: "card-label" }, i18n.voteCommentsLabel + ": "),
|
|
||||||
span({ class: "card-value" }, String(commentsCount))
|
|
||||||
),
|
|
||||||
div(
|
|
||||||
{ class: "comment-form-wrapper" },
|
|
||||||
h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel || i18n.feedPostComment || "Post a comment"),
|
|
||||||
form(
|
|
||||||
{ method: "POST", action: `/feed/${encodeURIComponent(feedKey)}/comments`, class: "comment-form", enctype: "multipart/form-data" },
|
|
||||||
textarea({
|
|
||||||
id: "comment-text",
|
|
||||||
name: "text",
|
|
||||||
rows: 4,
|
|
||||||
class: "comment-textarea",
|
|
||||||
placeholder: i18n.voteNewCommentPlaceholder || ""
|
|
||||||
}),
|
|
||||||
div({ class: "comment-file-upload" }, label(i18n.uploadMedia || "Upload media"), input({ type: "file", name: "blob" })),
|
|
||||||
br(),
|
|
||||||
button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton || i18n.feedPostComment || "Send")
|
|
||||||
)
|
|
||||||
),
|
|
||||||
list.length
|
|
||||||
? div(
|
|
||||||
{ class: "comments-list" },
|
|
||||||
list.map((c) => {
|
|
||||||
const author = c?.value?.author || "";
|
|
||||||
const ts = c?.value?.timestamp || c?.timestamp;
|
|
||||||
const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : "";
|
|
||||||
const relDate = ts ? moment(ts).fromNow() : "";
|
|
||||||
|
|
||||||
const content = c?.value?.content || {};
|
|
||||||
const text = content.text || c?.value?.text || "";
|
|
||||||
const threadRoot = content.fork || content.root || null;
|
|
||||||
|
|
||||||
return div(
|
|
||||||
{ class: "votations-comment-card" },
|
|
||||||
span(
|
|
||||||
{ class: "created-at" },
|
|
||||||
span(i18n.createdBy),
|
|
||||||
author ? userLink(author) : span("(unknown)"),
|
|
||||||
absDate ? span(" | ") : "",
|
|
||||||
absDate ? span({ class: "votations-comment-date" }, absDate) : "",
|
|
||||||
relDate ? span({ class: "votations-comment-date" }, " | ", i18n.sendTime) : "",
|
|
||||||
relDate && threadRoot
|
|
||||||
? a({ href: `/thread/${encodeURIComponent(threadRoot)}#${encodeURIComponent(c.key)}` }, relDate)
|
|
||||||
: ""
|
|
||||||
),
|
|
||||||
p({ class: "votations-comment-text" }, ...renderUrl(text))
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)
|
|
||||||
: p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet || i18n.noComments || "")
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderFeedCard = (feed) => {
|
const renderFeedCard = (feed) => {
|
||||||
|
|
@ -163,7 +110,7 @@ const renderFeedCard = (feed) => {
|
||||||
div(
|
div(
|
||||||
{ class: "card-header activity-card-header" },
|
{ class: "card-header activity-card-header" },
|
||||||
span(),
|
span(),
|
||||||
renderContentActions(feed.key, `/feed/${encodeURIComponent(feed.key)}`)
|
renderContentActions(feed.key, `/feed/${encodeURIComponent(feed.key)}`, { author: authorId, reportTitle: safeText })
|
||||||
),
|
),
|
||||||
div(
|
div(
|
||||||
{ class: "card-section feed-card-body" },
|
{ class: "card-section feed-card-body" },
|
||||||
|
|
@ -189,8 +136,8 @@ const renderFeedCard = (feed) => {
|
||||||
const maxVal = Math.max(...entries.map(([, v]) => Number(v)));
|
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);
|
const dominant = entries.filter(([, v]) => Number(v) === maxVal).map(([k]) => i18n['vote' + k.charAt(0).toUpperCase() + k.slice(1)] || k);
|
||||||
return [
|
return [
|
||||||
span({ style: 'margin:0 8px;opacity:0.5;' }, '|'),
|
span({ class: 'feed-vote-sep' }, '|'),
|
||||||
span({ style: 'font-weight:700;' }, `${i18n.moreVoted || 'More Voted'}: ${dominant.join(' + ')}`)
|
span({ class: 'feed-vote-dominant' }, `${i18n.moreVoted || 'More Voted'}: ${dominant.join(' + ')}`)
|
||||||
];
|
];
|
||||||
})()
|
})()
|
||||||
),
|
),
|
||||||
|
|
@ -202,25 +149,9 @@ const renderFeedCard = (feed) => {
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
div(
|
renderEngagement(feed.key,
|
||||||
{ class: "card-comments-summary" },
|
renderOpinionsVoting('/feed/opinions', feed.key, content.opinions, null, content.opinions_inhabitants),
|
||||||
span({ class: "card-label" }, `${i18n.voteCommentsLabel || "Comments"}:`),
|
renderCommentsLink({ href: `/feed/${encodeURIComponent(feed.key)}`, count: commentCount })
|
||||||
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)
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
@ -264,11 +195,13 @@ exports.feedView = (feeds, opts = "ALL") => {
|
||||||
div(
|
div(
|
||||||
{ class: "feed-tools-row" },
|
{ class: "feed-tools-row" },
|
||||||
form(
|
form(
|
||||||
{ method: "GET", action: "/feed", class: "feed-search-form" },
|
{ method: "GET", action: "/feed", class: "filter-box" },
|
||||||
input({ type: "hidden", name: "filter", value: filter }),
|
input({ type: "hidden", name: "filter", value: filter }),
|
||||||
tag ? input({ type: "hidden", name: "tag", value: tag }) : null,
|
tag ? input({ type: "hidden", name: "tag", value: tag }) : null,
|
||||||
input({ type: "text", name: "q", value: q, placeholder: i18n.searchPlaceholder || "Search", class: "feed-search-input" }),
|
input({ type: "text", name: "q", value: q, placeholder: i18n.searchPlaceholder || "Search", class: "filter-box__input" }),
|
||||||
button({ type: "submit", class: "filter-btn feed-search-btn" }, i18n.searchButton || "Search")
|
div({ class: "filter-box__controls" },
|
||||||
|
button({ type: "submit", class: "filter-box__button" }, i18n.searchButton || "Search")
|
||||||
|
)
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
section(
|
section(
|
||||||
|
|
@ -321,7 +254,7 @@ exports.feedCreateView = (opts = {}) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.singleFeedView = (feed, comments = []) => {
|
exports.singleFeedView = (feed, comments = [], params = {}) => {
|
||||||
const content = feed.value?.content || {};
|
const content = feed.value?.content || {};
|
||||||
const rawText = typeof content.text === "string" ? content.text : "";
|
const rawText = typeof content.text === "string" ? content.text : "";
|
||||||
const safeText = rawText.trim();
|
const safeText = rawText.trim();
|
||||||
|
|
@ -335,6 +268,7 @@ exports.singleFeedView = (feed, comments = []) => {
|
||||||
|
|
||||||
return template(
|
return template(
|
||||||
i18n.feedDetailTitle || "Feed",
|
i18n.feedDetailTitle || "Feed",
|
||||||
|
section(div({ class: "tags-header" }, h2(i18n.feedTitle), p(i18n.FeedshareYourOpinions))),
|
||||||
section(
|
section(
|
||||||
div(
|
div(
|
||||||
{ class: "filters" },
|
{ class: "filters" },
|
||||||
|
|
@ -349,6 +283,10 @@ exports.singleFeedView = (feed, comments = []) => {
|
||||||
),
|
),
|
||||||
div(
|
div(
|
||||||
{ class: "bookmark-item card feed-detail-card" },
|
{ 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,
|
br,
|
||||||
div(
|
div(
|
||||||
{ class: "feed-row" },
|
{ class: "feed-row" },
|
||||||
|
|
@ -380,15 +318,9 @@ exports.singleFeedView = (feed, comments = []) => {
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
renderFeedCommentsSection(feed.key, comments),
|
renderEngagement(feed.key,
|
||||||
div(
|
renderOpinionsVoting('/feed/opinions', feed.key, content.opinions, null, content.opinions_inhabitants),
|
||||||
{ class: "card-comments-summary feed-opinions-section" },
|
renderFeedCommentsSection(feed.key, comments)
|
||||||
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)
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
|
||||||
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' },
|
form({ method: 'GET', action: '/games' },
|
||||||
input({ type: 'hidden', name: 'filter', value: 'all' }),
|
input({ type: 'hidden', name: 'filter', value: 'all' }),
|
||||||
button({ type: 'submit', class: filter === 'all' ? 'filter-btn active' : 'filter-btn' },
|
button({ type: 'submit', class: filter === 'all' ? 'filter-btn active' : 'filter-btn' },
|
||||||
i18n.gamesFilterAll
|
String(i18n.gamesFilterAll).toUpperCase()
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
form({ method: 'GET', action: '/games' },
|
form({ method: 'GET', action: '/games' },
|
||||||
input({ type: 'hidden', name: 'filter', value: 'scoring' }),
|
input({ type: 'hidden', name: 'filter', value: 'scoring' }),
|
||||||
button({ type: 'submit', class: filter === 'scoring' ? 'filter-btn active' : 'filter-btn' },
|
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>`;
|
+ `</svg>`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const kpi = (label, value) => div({ class: 'stats-kpi' },
|
const kpi = (label, value) => div({ class: 'stats-kpi stats-kpi-inline' },
|
||||||
div({ class: 'stats-kpi-label' }, label),
|
span({ class: 'stats-kpi-label' }, `${String(label).toUpperCase()}: `),
|
||||||
div({ class: 'stats-kpi-value' }, String(value))
|
span({ class: 'stats-kpi-value' }, String(value))
|
||||||
);
|
);
|
||||||
|
|
||||||
const legendItem = (kind, label) =>
|
const legendItem = (kind, label) =>
|
||||||
|
|
@ -126,27 +126,27 @@ exports.graphosView = ({ filter, me, peers, kpis, focus = null, focusId = null,
|
||||||
modes.map(m =>
|
modes.map(m =>
|
||||||
form({ method: 'GET', action: '/graphos' },
|
form({ method: 'GET', action: '/graphos' },
|
||||||
input({ type: 'hidden', name: 'filter', value: m }),
|
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
|
capped
|
||||||
? p({ class: 'graphos-cap-note' }, `${i18n.graphosShowing || 'Showing'} ${shown} / ${total}`)
|
? p({ class: 'graphos-cap-note' }, `${i18n.graphosShowing || 'Showing'} ${shown} / ${total}`)
|
||||||
: null,
|
: null,
|
||||||
div({ class: 'graphos-legend' },
|
div({ class: 'stats-block graphos-stats' },
|
||||||
legendItem('me', i18n.graphosYou || 'You'),
|
|
||||||
legendItem('online', i18n.online || 'Online'),
|
|
||||||
filter !== 'MINE' ? legendItem('discovered', i18n.discovered || 'Discovered') : null,
|
|
||||||
filter !== 'MINE' ? legendItem('unknown', i18n.unknown || 'Unknown') : null
|
|
||||||
),
|
|
||||||
div({ class: 'graphos-canvas', innerHTML: buildGraphSvg(me, peers) }),
|
|
||||||
div({ class: 'stats-block' },
|
|
||||||
div({ class: 'stats-grid' },
|
div({ class: 'stats-grid' },
|
||||||
kpi(i18n.graphosTotalNodes || 'Total nodes', kpis.total),
|
kpi(i18n.graphosTotalNodes || 'Total nodes', kpis.total),
|
||||||
kpi(i18n.online || 'Online', kpis.online),
|
kpi(i18n.online || 'Online', kpis.online),
|
||||||
filter !== 'MINE' ? kpi(i18n.discovered || 'Discovered', kpis.discovered) : null,
|
filter !== 'MINE' ? kpi(i18n.discovered || 'Discovered', kpis.discovered) : null,
|
||||||
filter !== 'MINE' ? kpi(i18n.unknown || 'Unknown', kpis.unknown) : 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 { div, h2, p, section, button, form, img, a, textarea, input, span, strong } = require("../server/node_modules/hyperaxe");
|
||||||
const { template, i18n, userLink, renderUserSensors, renderContentActions } = require('./main_views');
|
const { template, i18n, userLink, renderUserSensors, renderContentActions, renderRelationshipBlock } = require('./main_views');
|
||||||
|
const { renderZoomableImage } = require('./gallery_view');
|
||||||
const { renderContentStats } = require('./clearnet_view');
|
const { renderContentStats } = require('./clearnet_view');
|
||||||
const { renderUrl } = require('../backend/renderUrl');
|
const { renderUrl } = require('../backend/renderUrl');
|
||||||
const { getConfig } = require('../configs/config-manager');
|
const { getConfig } = require('../configs/config-manager');
|
||||||
|
|
@ -48,20 +49,40 @@ function resolvePhoto(photoField, size = 256) {
|
||||||
if (photoField.startsWith('/blob/')) return photoField;
|
if (photoField.startsWith('/blob/')) return photoField;
|
||||||
if (photoField.startsWith('/image/')) {
|
if (photoField.startsWith('/image/')) {
|
||||||
if (isDefaultImageId(photoField)) return '/assets/images/default-avatar.png';
|
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 =>
|
filters.map(mode =>
|
||||||
form({ method: 'GET', action: '/inhabitants' },
|
form({ method: 'GET', action: '/inhabitants' },
|
||||||
input({ type: 'hidden', name: 'filter', value: mode }),
|
input({ type: 'hidden', name: 'filter', value: mode }),
|
||||||
button({
|
button({
|
||||||
type: 'submit',
|
type: 'submit',
|
||||||
class: currentFilter === mode ? 'filter-btn active' : 'filter-btn'
|
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 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 renderInhabitantCard = (user, filter, currentUserId, fediverseConfigured) => {
|
||||||
const isMe = user.id === currentUserId;
|
const isMe = user.id === currentUserId;
|
||||||
const raw = user.visibilityPrefs || {};
|
const raw = user.visibilityPrefs || {};
|
||||||
|
|
@ -102,7 +144,7 @@ const renderInhabitantCard = (user, filter, currentUserId, fediverseConfigured)
|
||||||
wallet: raw.wallet === true,
|
wallet: raw.wallet === true,
|
||||||
ecoTax: raw.ecoTax !== false,
|
ecoTax: raw.ecoTax !== false,
|
||||||
larpSign: raw.larpSign === true,
|
larpSign: raw.larpSign === true,
|
||||||
gpg: raw.gpg !== false,
|
gpg: raw.gpg === true,
|
||||||
clearnet: hasClearnet,
|
clearnet: hasClearnet,
|
||||||
fediverse: raw.fediverse === true,
|
fediverse: raw.fediverse === true,
|
||||||
fediverseHandle: typeof raw.fediverseHandle === 'string' ? raw.fediverseHandle : ''
|
fediverseHandle: typeof raw.fediverseHandle === 'string' ? raw.fediverseHandle : ''
|
||||||
|
|
@ -111,7 +153,7 @@ const renderInhabitantCard = (user, filter, currentUserId, fediverseConfigured)
|
||||||
div(
|
div(
|
||||||
{ class: 'card-header activity-card-header' },
|
{ class: 'card-header activity-card-header' },
|
||||||
span(),
|
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: 'card-section inhabitants-card-body' },
|
||||||
div({ class: 'inhabitant-card' },
|
div({ class: 'inhabitant-card' },
|
||||||
|
|
@ -128,26 +170,30 @@ const renderInhabitantCard = (user, filter, currentUserId, fediverseConfigured)
|
||||||
estimatedUBI: user.estimatedUBI, lastClaimedDate: user.lastClaimedDate, totalClaimed: user.totalClaimed,
|
estimatedUBI: user.estimatedUBI, lastClaimedDate: user.lastClaimedDate, totalClaimed: user.totalClaimed,
|
||||||
larpHouse: user.larpHouse, stats: user.stats
|
larpHouse: user.larpHouse, stats: user.stats
|
||||||
}, { relationshipNode: isMe ? span({ class: 'status you' }, i18n.relationshipYou) : null, excludeContent: true }),
|
}, { 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
|
!isMe
|
||||||
? div(
|
? div(
|
||||||
{ class: 'cv-actions' },
|
{ class: 'inhabitant-user-actions' },
|
||||||
form(
|
div({ class: 'inhabitant-relationship' }, renderRelationshipBlock(user.relationship || {}, user.id))
|
||||||
{ method: 'GET', action: '/pm' },
|
|
||||||
input({ type: 'hidden', name: 'recipients', value: user.id }),
|
|
||||||
button({ type: 'submit', class: 'btn' }, i18n.pmCreateButton)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
: null
|
: null
|
||||||
),
|
),
|
||||||
div({ class: 'inhabitant-details' },
|
div({ class: 'inhabitant-details' },
|
||||||
h2(user.name || 'Anonymous'),
|
h2(user.name || 'Anonymous'),
|
||||||
user.description ? p(...renderUrl(user.description)) : null,
|
user.description ? p(...renderUrl(user.description)) : null,
|
||||||
filter === 'MATCHSKILLS' && user.commonSkills?.length
|
filter === 'CVs' ? renderCvFields(user) : null,
|
||||||
? div({ class: 'matchskills' },
|
|
||||||
p(`${i18n.commonSkills}: ${user.commonSkills.join(', ')}`),
|
|
||||||
p(`${i18n.matchScore}: ${Math.round((user.matchScore || 0) * 100)}%`)
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
filter === 'SUGGESTED' && (user.followsYou || user.commonSkills?.length || user.mutualCount)
|
filter === 'SUGGESTED' && (user.followsYou || user.commonSkills?.length || user.mutualCount)
|
||||||
? div({ class: 'suggested-meta' },
|
? div({ class: 'suggested-meta' },
|
||||||
user.followsYou ? span({ class: 'suggested-badge' }, i18n.suggestedFollowsYou || 'Follows you') : null,
|
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
|
filter === 'blocked' && user.isBlocked
|
||||||
? p(i18n.blockedLabel) : null,
|
? p(i18n.blockedLabel) : null,
|
||||||
p(userLink(user.id)),
|
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)
|
renderContentStats(user.stats, i18n)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -229,19 +250,11 @@ function msgIdOf(m) {
|
||||||
}
|
}
|
||||||
|
|
||||||
exports.inhabitantsView = (inhabitants, filter, query, currentUserId, fediverseConfigured) => {
|
exports.inhabitantsView = (inhabitants, filter, query, currentUserId, fediverseConfigured) => {
|
||||||
const title = filter === 'contacts' ? i18n.yourContacts
|
const title = i18n.allInhabitants;
|
||||||
: filter === 'CVs' ? i18n.allCVs
|
|
||||||
: filter === 'MATCHSKILLS' ? i18n.matchSkills
|
|
||||||
: filter === 'SUGGESTED' ? i18n.suggestedSectionTitle
|
|
||||||
: filter === 'blocked' ? i18n.blockedSectionTitle
|
|
||||||
: filter === 'GALLERY' ? i18n.gallerySectionTitle
|
|
||||||
: filter === 'TOP KARMA' ? i18n.topkarmaSectionTitle
|
|
||||||
: filter === 'TOP ECO' ? (i18n.topecoSectionTitle || 'Top Eco')
|
|
||||||
: filter === 'TOP ACTIVITY' ? i18n.topactivitySectionTitle
|
|
||||||
: i18n.allInhabitants;
|
|
||||||
|
|
||||||
const showCVFilters = filter === 'CVs' || filter === 'MATCHSKILLS';
|
const showCVFilters = filter === 'CVs';
|
||||||
const filters = ['all', 'TOP ACTIVITY', 'TOP KARMA', 'TOP ECO', 'contacts', 'SUGGESTED', 'blocked', 'CVs', 'MATCHSKILLS', 'GALLERY'];
|
const TOP_FILTERS = ['TOP ACTIVITY', 'TOP INACTIVITY', 'TOP KARMA', 'TOP ECO'];
|
||||||
|
const isTop = TOP_FILTERS.includes(filter);
|
||||||
|
|
||||||
return template(
|
return template(
|
||||||
title,
|
title,
|
||||||
|
|
@ -251,28 +264,33 @@ exports.inhabitantsView = (inhabitants, filter, query, currentUserId, fediverseC
|
||||||
p(i18n.discoverPeople)
|
p(i18n.discoverPeople)
|
||||||
),
|
),
|
||||||
div({ class: 'filters' },
|
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: 'hidden', name: 'filter', value: filter }),
|
||||||
input({
|
input({
|
||||||
type: 'text',
|
type: 'text',
|
||||||
name: 'search',
|
name: 'search',
|
||||||
placeholder: i18n.searchInhabitantsPlaceholder,
|
placeholder: i18n.searchInhabitantsPlaceholder,
|
||||||
value: (query && query.search) || ''
|
value: (query && query.search) || '',
|
||||||
|
class: 'filter-box__input'
|
||||||
}),
|
}),
|
||||||
showCVFilters
|
div({ class: 'filter-box__controls' },
|
||||||
? [
|
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: 'location', placeholder: i18n.filterLocation, value: (query && query.location) || '', class: 'filter-box__number' }),
|
||||||
input({ type: 'text', name: 'skills', placeholder: i18n.filterSkills, value: (query && query.skills) || '' })
|
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,
|
]
|
||||||
br(),
|
: null,
|
||||||
button({ type: 'submit' }, i18n.applyFilters)
|
button({ type: 'submit', class: 'filter-box__button' }, i18n.searchButton)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
div({ class: 'inhabitant-action' },
|
renderMainFilters(filter, isTop),
|
||||||
...generateFilterButtons(filters, filter)
|
isTop
|
||||||
),
|
? div({ class: 'inhabitant-action inhabitant-subfilters' },
|
||||||
|
...generateFilterButtons(TOP_FILTERS, filter, (mode) => filterLabel(mode).replace(/^TOP\s+/, ''))
|
||||||
|
)
|
||||||
|
: null,
|
||||||
filter === 'GALLERY'
|
filter === 'GALLERY'
|
||||||
? renderGalleryInhabitants(inhabitants)
|
? renderGalleryInhabitants(inhabitants)
|
||||||
: div({ class: 'inhabitants-list' },
|
: div({ class: 'inhabitants-list' },
|
||||||
|
|
@ -338,7 +356,7 @@ exports.inhabitantsProfileView = (payload, currentUserId, fediverseConfigured) =
|
||||||
wallet: rawPrefs.wallet === true,
|
wallet: rawPrefs.wallet === true,
|
||||||
ecoTax: rawPrefs.ecoTax !== false,
|
ecoTax: rawPrefs.ecoTax !== false,
|
||||||
larpSign: rawPrefs.larpSign === true,
|
larpSign: rawPrefs.larpSign === true,
|
||||||
gpg: rawPrefs.gpg !== false,
|
gpg: rawPrefs.gpg === true,
|
||||||
clearnet: rawPrefs.clearnet === true || clearnetSubKeys.some(k => rawPrefs[k] === true),
|
clearnet: rawPrefs.clearnet === true || clearnetSubKeys.some(k => rawPrefs[k] === true),
|
||||||
fediverse: rawPrefs.fediverse === true,
|
fediverse: rawPrefs.fediverse === true,
|
||||||
fediverseHandle: typeof rawPrefs.fediverseHandle === 'string' ? rawPrefs.fediverseHandle : ''
|
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 providedBucket = typeof safe.lastActivityBucket === 'string' ? safe.lastActivityBucket : null;
|
||||||
const dotClass = providedBucket === 'green' ? 'green' : providedBucket === 'orange' ? 'orange' : 'red';
|
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 = [
|
const detailNodes = [
|
||||||
description ? p(...renderUrl(description)) : null,
|
description ? p(...renderUrl(description)) : null,
|
||||||
location ? p(`${i18n.locationLabel}: ${location}`) : null,
|
fieldNodes.length ? div({ class: 'cv-card-fields' }, ...fieldNodes) : null
|
||||||
languages.length ? p(`${i18n.languagesLabel}: ${languages.join(', ').toUpperCase()}`) : null,
|
|
||||||
skills.length ? p(`${i18n.skillsLabel}: ${skills.join(', ')}`) : null,
|
|
||||||
status ? p(`${i18n.statusLabel || 'Status'}: ${status}`) : null,
|
|
||||||
preferences ? p(`${i18n.preferencesLabel || 'Preferences'}: ${preferences}`) : null,
|
|
||||||
createdAt ? p(`${i18n.createdAtLabel || 'Created at'}: ${createdAt}`) : null
|
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
|
|
||||||
return template(
|
return template(
|
||||||
|
|
@ -367,9 +389,7 @@ exports.inhabitantsProfileView = (payload, currentUserId, fediverseConfigured) =
|
||||||
h2(title),
|
h2(title),
|
||||||
p(i18n.discoverPeople)
|
p(i18n.discoverPeople)
|
||||||
),
|
),
|
||||||
div({ class: 'mode-buttons' },
|
renderMainFilters('all', false),
|
||||||
...generateFilterButtons(['all', 'TOP ACTIVITY', 'TOP KARMA', 'TOP ECO', 'contacts', 'SUGGESTED', 'blocked', 'CVs', 'MATCHSKILLS', 'GALLERY'], 'all')
|
|
||||||
),
|
|
||||||
div({ class: 'inhabitant-card' },
|
div({ class: 'inhabitant-card' },
|
||||||
div({ class: 'inhabitant-left' },
|
div({ class: 'inhabitant-left' },
|
||||||
img({ class: 'inhabitant-photo-details', src: image, alt: name || 'Anonymous' }),
|
img({ class: 'inhabitant-photo-details', src: image, alt: name || 'Anonymous' }),
|
||||||
|
|
@ -382,11 +402,16 @@ exports.inhabitantsProfileView = (payload, currentUserId, fediverseConfigured) =
|
||||||
larpHouse, stats: safe.stats
|
larpHouse, stats: safe.stats
|
||||||
}, { excludeContent: true }),
|
}, { excludeContent: true }),
|
||||||
(!isMe && (id || viewedId))
|
(!isMe && (id || viewedId))
|
||||||
? form(
|
? div({ class: 'cv-actions doc-export-actions' },
|
||||||
{ method: 'GET', action: '/pm' },
|
form(
|
||||||
input({ type: 'hidden', name: 'recipients', value: id || viewedId }),
|
{ method: 'GET', action: '/pm' },
|
||||||
button({ type: 'submit', class: 'btn' }, i18n.pmCreateButton)
|
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
|
: null
|
||||||
),
|
),
|
||||||
(() => {
|
(() => {
|
||||||
|
|
@ -405,7 +430,7 @@ exports.inhabitantsProfileView = (payload, currentUserId, fediverseConfigured) =
|
||||||
return div({ class: 'post' },
|
return div({ class: 'post' },
|
||||||
visitBtn,
|
visitBtn,
|
||||||
parts.clean && parts.clean.trim() ? p(...renderUrl(parts.clean)) : null,
|
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.lastActivityBadge = lastActivityBadge;
|
||||||
|
exports.resolvePhoto = resolvePhoto;
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,7 @@ const invitesView = ({ invitesEnabled, flash }) => {
|
||||||
section(
|
section(
|
||||||
div({ class: 'pubs-section' },
|
div({ class: 'pubs-section' },
|
||||||
h2(i18n.invitesPubsTitle),
|
h2(i18n.invitesPubsTitle),
|
||||||
|
p(i18n.invitesPubsHint),
|
||||||
form(
|
form(
|
||||||
{ action: '/settings/invite/accept', method: 'post' },
|
{ action: '/settings/invite/accept', method: 'post' },
|
||||||
input({ name: 'invite', type: 'text', placeholder: i18n.invitesPubInviteCodePlaceholder, autofocus: true, required: true }),
|
input({ name: 'invite', type: 'text', placeholder: i18n.invitesPubInviteCodePlaceholder, autofocus: true, required: true }),
|
||||||
|
|
@ -129,6 +130,7 @@ const invitesView = ({ invitesEnabled, flash }) => {
|
||||||
section(
|
section(
|
||||||
div({ class: 'federations-section' },
|
div({ class: 'federations-section' },
|
||||||
h2(i18n.invitesFederationsTitle || 'Federations'),
|
h2(i18n.invitesFederationsTitle || 'Federations'),
|
||||||
|
p(i18n.invitesFederationsHint),
|
||||||
div({ class: 'conn-actions invites-pubs-actions' },
|
div({ class: 'conn-actions invites-pubs-actions' },
|
||||||
form({ action: '/invites/refresh-pubs', method: 'post' }, button({ type: 'submit' }, i18n.invitesPubsRefresh || 'Refresh')),
|
form({ action: '/invites/refresh-pubs', method: 'post' }, button({ type: 'submit' }, i18n.invitesPubsRefresh || 'Refresh')),
|
||||||
form({ action: '/invites/clear-unreachable', method: 'post' }, button({ type: 'submit' }, i18n.invitesPubsClearUnreachable || 'Remove unreachable')),
|
form({ action: '/invites/clear-unreachable', method: 'post' }, button({ type: 'submit' }, i18n.invitesPubsClearUnreachable || 'Remove unreachable')),
|
||||||
|
|
@ -180,6 +182,7 @@ const invitesView = ({ invitesEnabled, flash }) => {
|
||||||
section(
|
section(
|
||||||
div({ class: 'invites-houses', id: 'invites-houses' },
|
div({ class: 'invites-houses', id: 'invites-houses' },
|
||||||
h2(i18n.invitesHousesTitle || 'Houses'),
|
h2(i18n.invitesHousesTitle || 'Houses'),
|
||||||
|
p(i18n.invitesHousesHint),
|
||||||
form(
|
form(
|
||||||
{ action: '/larp/invite/redeem', method: 'post' },
|
{ action: '/larp/invite/redeem', method: 'post' },
|
||||||
input({ type: 'hidden', name: 'returnTo', value: '/invites' }),
|
input({ type: 'hidden', name: 'returnTo', value: '/invites' }),
|
||||||
|
|
@ -192,6 +195,7 @@ const invitesView = ({ invitesEnabled, flash }) => {
|
||||||
section(
|
section(
|
||||||
div({ class: 'invites-tribes', id: 'invites-tribes' },
|
div({ class: 'invites-tribes', id: 'invites-tribes' },
|
||||||
h2(i18n.invitesTribesTitle),
|
h2(i18n.invitesTribesTitle),
|
||||||
|
p(i18n.invitesTribesHint),
|
||||||
form(
|
form(
|
||||||
{ action: '/tribes/join-code', method: 'post' },
|
{ action: '/tribes/join-code', method: 'post' },
|
||||||
input({ name: 'inviteCode', type: 'text', placeholder: i18n.invitesTribeInviteCodePlaceholder, required: true }),
|
input({ name: 'inviteCode', type: 'text', placeholder: i18n.invitesTribeInviteCodePlaceholder, required: true }),
|
||||||
|
|
@ -203,6 +207,7 @@ const invitesView = ({ invitesEnabled, flash }) => {
|
||||||
section(
|
section(
|
||||||
div({ class: 'invites-chats', id: 'invites-chats' },
|
div({ class: 'invites-chats', id: 'invites-chats' },
|
||||||
h2(i18n.invitesChatsTitle || 'Chats'),
|
h2(i18n.invitesChatsTitle || 'Chats'),
|
||||||
|
p(i18n.invitesChatsHint),
|
||||||
form(
|
form(
|
||||||
{ action: '/chats/join-code', method: 'post' },
|
{ action: '/chats/join-code', method: 'post' },
|
||||||
input({ name: 'code', type: 'text', placeholder: i18n.invitesChatInviteCodePlaceholder || 'Enter chat invite code', required: true }),
|
input({ name: 'code', type: 'text', placeholder: i18n.invitesChatInviteCodePlaceholder || 'Enter chat invite code', required: true }),
|
||||||
|
|
@ -214,6 +219,7 @@ const invitesView = ({ invitesEnabled, flash }) => {
|
||||||
section(
|
section(
|
||||||
div({ class: 'invites-pads', id: 'invites-pads' },
|
div({ class: 'invites-pads', id: 'invites-pads' },
|
||||||
h2(i18n.invitesPadsTitle || 'Pads'),
|
h2(i18n.invitesPadsTitle || 'Pads'),
|
||||||
|
p(i18n.invitesPadsHint),
|
||||||
form(
|
form(
|
||||||
{ action: '/pads/join-code', method: 'post' },
|
{ action: '/pads/join-code', method: 'post' },
|
||||||
input({ name: 'code', type: 'text', placeholder: i18n.invitesPadInviteCodePlaceholder || 'Enter pad invite code', required: true }),
|
input({ name: 'code', type: 'text', placeholder: i18n.invitesPadInviteCodePlaceholder || 'Enter pad invite code', required: true }),
|
||||||
|
|
@ -225,6 +231,7 @@ const invitesView = ({ invitesEnabled, flash }) => {
|
||||||
section(
|
section(
|
||||||
div({ class: 'invites-calendars', id: 'invites-calendars' },
|
div({ class: 'invites-calendars', id: 'invites-calendars' },
|
||||||
h2(i18n.invitesCalendarsTitle || 'Calendars'),
|
h2(i18n.invitesCalendarsTitle || 'Calendars'),
|
||||||
|
p(i18n.invitesCalendarsHint),
|
||||||
form(
|
form(
|
||||||
{ action: '/calendars/join-code', method: 'post' },
|
{ action: '/calendars/join-code', method: 'post' },
|
||||||
input({ name: 'code', type: 'text', placeholder: i18n.invitesCalendarInviteCodePlaceholder || 'Enter calendar invite code', required: true }),
|
input({ name: 'code', type: 'text', placeholder: i18n.invitesCalendarInviteCodePlaceholder || 'Enter calendar invite code', required: true }),
|
||||||
|
|
@ -236,6 +243,7 @@ const invitesView = ({ invitesEnabled, flash }) => {
|
||||||
section(
|
section(
|
||||||
div({ class: 'invites-events', id: 'invites-events' },
|
div({ class: 'invites-events', id: 'invites-events' },
|
||||||
h2(i18n.invitesEventsTitle || 'Events'),
|
h2(i18n.invitesEventsTitle || 'Events'),
|
||||||
|
p(i18n.invitesEventsHint),
|
||||||
form(
|
form(
|
||||||
{ action: '/events/join-code', method: 'post' },
|
{ action: '/events/join-code', method: 'post' },
|
||||||
input({ name: 'code', type: 'text', placeholder: i18n.invitesEventInviteCodePlaceholder || 'Enter event invite code', required: true }),
|
input({ name: 'code', type: 'text', placeholder: i18n.invitesEventInviteCodePlaceholder || 'Enter event invite code', required: true }),
|
||||||
|
|
@ -247,6 +255,7 @@ const invitesView = ({ invitesEnabled, flash }) => {
|
||||||
section(
|
section(
|
||||||
div({ class: 'invites-forums', id: 'invites-forums' },
|
div({ class: 'invites-forums', id: 'invites-forums' },
|
||||||
h2(i18n.invitesForumsTitle || 'Forums'),
|
h2(i18n.invitesForumsTitle || 'Forums'),
|
||||||
|
p(i18n.invitesForumsHint),
|
||||||
form(
|
form(
|
||||||
{ action: '/forum/join-code', method: 'post' },
|
{ action: '/forum/join-code', method: 'post' },
|
||||||
input({ name: 'code', type: 'text', placeholder: i18n.invitesForumInviteCodePlaceholder || 'Enter forum invite code', required: true }),
|
input({ name: 'code', type: 'text', placeholder: i18n.invitesForumInviteCodePlaceholder || 'Enter forum invite code', required: true }),
|
||||||
|
|
@ -258,6 +267,7 @@ const invitesView = ({ invitesEnabled, flash }) => {
|
||||||
section(
|
section(
|
||||||
div({ class: 'invites-maps', id: 'invites-maps' },
|
div({ class: 'invites-maps', id: 'invites-maps' },
|
||||||
h2(i18n.invitesMapsTitle || 'Maps'),
|
h2(i18n.invitesMapsTitle || 'Maps'),
|
||||||
|
p(i18n.invitesMapsHint),
|
||||||
form(
|
form(
|
||||||
{ action: '/maps/join-code', method: 'post' },
|
{ action: '/maps/join-code', method: 'post' },
|
||||||
input({ name: 'code', type: 'text', placeholder: i18n.invitesMapInviteCodePlaceholder || 'Enter map invite code', required: true }),
|
input({ name: 'code', type: 'text', placeholder: i18n.invitesMapInviteCodePlaceholder || 'Enter map invite code', required: true }),
|
||||||
|
|
@ -269,6 +279,7 @@ const invitesView = ({ invitesEnabled, flash }) => {
|
||||||
section(
|
section(
|
||||||
div({ class: 'invites-shops', id: 'invites-shops' },
|
div({ class: 'invites-shops', id: 'invites-shops' },
|
||||||
h2(i18n.invitesShopsTitle || 'Shops'),
|
h2(i18n.invitesShopsTitle || 'Shops'),
|
||||||
|
p(i18n.invitesShopsHint),
|
||||||
form(
|
form(
|
||||||
{ action: '/shops/join-code', method: 'post' },
|
{ action: '/shops/join-code', method: 'post' },
|
||||||
input({ name: 'code', type: 'text', placeholder: i18n.invitesShopInviteCodePlaceholder || 'Enter shop invite code', required: true }),
|
input({ name: 'code', type: 'text', placeholder: i18n.invitesShopInviteCodePlaceholder || 'Enter shop invite code', required: true }),
|
||||||
|
|
@ -280,6 +291,7 @@ const invitesView = ({ invitesEnabled, flash }) => {
|
||||||
section(
|
section(
|
||||||
div({ class: 'invites-inhabitants', id: 'invites-inhabitants' },
|
div({ class: 'invites-inhabitants', id: 'invites-inhabitants' },
|
||||||
h2(i18n.invitesInhabitantsTitle || 'Inhabitants'),
|
h2(i18n.invitesInhabitantsTitle || 'Inhabitants'),
|
||||||
|
p(i18n.invitesInhabitantsHint),
|
||||||
form(
|
form(
|
||||||
{ action: '/invites/inhabitant/follow', method: 'post' },
|
{ 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 }),
|
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 { 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 moment = require("../server/node_modules/moment");
|
||||||
|
|
||||||
const fmtCycle = (c) => c && c.formatted ? c.formatted : '';
|
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))
|
span({ class: 'larp-cycle-value' }, fmtCycle(cycle))
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderHouseBadges = ({ myHouse, governingHouse }) => div({ class: 'larp-house-badges' },
|
const renderHouseBadges = ({ myHouse, governingHouse, houses }) => {
|
||||||
myHouse ? div({ class: 'larp-my-house' },
|
const academia = Array.isArray(houses) ? houses.find(h => h.key === 'academia') : null;
|
||||||
span({ class: 'larp-my-house-label' }, i18n.larpMyHouse || 'My House'),
|
const isNewcomer = !myHouse;
|
||||||
a({ href: `/larp/${myHouse.key}`, class: 'larp-my-house-link' }, myHouse.name)
|
return div({ class: 'larp-house-badges' },
|
||||||
) : null,
|
myHouse ? div({ class: 'larp-my-house' },
|
||||||
governingHouse ? div({ class: 'larp-my-house' },
|
span({ class: 'larp-my-house-label' }, i18n.larpMyHouse || 'My House'),
|
||||||
span({ class: 'larp-my-house-label' }, i18n.larpGoverning || 'Governing'),
|
a({ href: `/larp/${myHouse.key}`, class: 'larp-my-house-link' }, myHouse.name)
|
||||||
a({ href: `/larp/${governingHouse.key}`, class: 'larp-my-house-link' }, governingHouse.name)
|
) : null,
|
||||||
) : 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' },
|
const renderHouseNav = (houses, currentKey) => div({ class: 'larp-house-nav' },
|
||||||
span({ class: 'larp-house-nav-label' }, i18n.larpAllHouses || 'Houses:'),
|
span({ class: 'larp-house-nav-label' }, i18n.larpAllHouses || 'Houses:'),
|
||||||
|
|
@ -31,7 +42,7 @@ const renderHouseNav = (houses, currentKey) => div({ class: 'larp-house-nav' },
|
||||||
}, h.name))
|
}, 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 { renderEncryptedChip } = require('./clearnet_view');
|
||||||
const isInHouse = viewerHouseKey === house.key;
|
const isInHouse = viewerHouseKey === house.key;
|
||||||
const isInLarp = viewerHouseKey !== null && viewerHouseKey !== undefined;
|
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),
|
p({ class: 'larp-detail-description' }, house.description),
|
||||||
div({ class: 'larp-actions' },
|
div({ class: 'larp-actions' },
|
||||||
|
isAcademia && isInHouse && canTakeTest
|
||||||
|
? a({ href: '/larp/test', class: 'filter-btn larp-start-journey' }, i18n.larpStartJourney)
|
||||||
|
: null,
|
||||||
isInHouse
|
isInHouse
|
||||||
? a({ href: `/larp/tribe/${encodeURIComponent(house.key)}`, class: 'filter-btn' }, i18n.larpVisitTribe || 'Visit Tribe')
|
? a({ href: `/larp/tribe/${encodeURIComponent(house.key)}`, class: 'filter-btn' }, i18n.larpVisitTribe || 'Visit Tribe')
|
||||||
: null,
|
: null,
|
||||||
|
|
@ -162,54 +176,63 @@ const renderAcademiaJoinPanel = (allHouses, testStatus, housesById, questions, m
|
||||||
div({ class: 'larp-academia-grid' },
|
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' },
|
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' }),
|
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'),
|
const renderPostsBlock = (posts, house, canPost) => {
|
||||||
canPost
|
const stream = (Array.isArray(posts) ? posts : [])
|
||||||
? form({ method: 'POST', action: '/larp/post', class: 'larp-post-form' },
|
.map(post => ({ ts: Date.parse(post.createdAt) || post.ts || 0, post }))
|
||||||
input({ type: 'hidden', name: 'house', value: house.key }),
|
.sort((a, b) => b.ts - a.ts);
|
||||||
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')
|
return div({ class: 'larp-posts-block' },
|
||||||
)
|
h2(i18n.larpPostsTitle || 'Wall'),
|
||||||
: null,
|
canPost
|
||||||
posts.length === 0
|
? form({ method: 'POST', action: '/larp/post', class: 'larp-post-form' },
|
||||||
? p({ class: 'empty' }, i18n.larpPostsEmpty || 'No posts yet.')
|
input({ type: 'hidden', name: 'house', value: house.key }),
|
||||||
: div({ class: 'larp-posts-list' },
|
textarea({ id: 'larp_post_text', name: 'text', rows: '3', maxlength: '4000', placeholder: i18n.larpPostPlaceholder || 'What does this house need to say?' }),
|
||||||
posts.map(post => div({ class: 'larp-post' },
|
button({ type: 'submit', class: 'filter-btn larp-post-submit' }, i18n.larpPostSubmit || 'Publish')
|
||||||
div({ class: 'larp-post-head' },
|
)
|
||||||
userLink(post.author),
|
: null,
|
||||||
span({ class: 'larp-post-time' }, moment(post.createdAt).format('YYYY-MM-DD HH:mm'))
|
stream.length === 0
|
||||||
),
|
? p({ class: 'empty' }, i18n.larpPostsEmpty || 'No posts yet.')
|
||||||
p({ class: 'larp-post-text' }, post.text)
|
: 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' },
|
const renderModeButtons = (filter) => div({ class: 'mode-buttons stats-mode-row' },
|
||||||
['ruling', 'houses', 'rules'].map(m =>
|
['ruling', 'houses', 'rules'].map(m =>
|
||||||
form({ method: 'GET', action: '/larp' },
|
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' },
|
const renderRules = () => {
|
||||||
h2(i18n.larpRulesTitle || 'F.A.Q.'),
|
const points = [
|
||||||
div({ class: 'larp-rules-section' },
|
i18n.larpRulesEntryText,
|
||||||
h3(i18n.larpRulesEntryTitle || 'Starting house'),
|
i18n.larpRulesOneHouseText,
|
||||||
p(i18n.larpRulesEntryText || 'Every inhabitant begins in ACADEMIA by default.')
|
i18n.larpRulesTestEntryText,
|
||||||
),
|
i18n.larpRulesInviteEntryText,
|
||||||
div({ class: 'larp-rules-section' },
|
i18n.larpRulesGovernanceText,
|
||||||
h3(i18n.larpRulesOneHouseTitle || 'One house at a time'),
|
i18n.larpRulesWallText,
|
||||||
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.')
|
i18n.larpRulesSignText
|
||||||
),
|
];
|
||||||
div({ class: 'larp-rules-section' },
|
return div(
|
||||||
h3(i18n.larpRulesTestEntry || 'WILL test'),
|
{ class: 'card' },
|
||||||
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.')
|
h2(i18n.larpRulesTitle || 'F.A.Q.'),
|
||||||
),
|
div({ class: 'rules-points' },
|
||||||
div({ class: 'larp-rules-section' },
|
points.filter(Boolean).map((text, i) =>
|
||||||
h3(i18n.larpRulesInviteEntry || 'Invitation code'),
|
div({ class: 'rules-point' },
|
||||||
p(i18n.larpRulesInviteEntryText || 'House members can generate invitation codes that grant direct access to the house.')
|
span({ class: 'rules-point-num' }, String(i + 1)),
|
||||||
),
|
span({ class: 'rules-point-text' }, text)
|
||||||
div({ class: 'larp-rules-section' },
|
)
|
||||||
h3(i18n.larpRulesGovernanceTitle || 'Governance Wall'),
|
)
|
||||||
p(i18n.larpRulesGovernanceText || 'During its cycle, the governing house wears the "Ruling" badge and is the only one whose Wall is surfaced publicly under the RULING tab.')
|
)
|
||||||
),
|
);
|
||||||
div({ class: 'larp-rules-section' },
|
};
|
||||||
h3(i18n.larpRulesSignTitle || 'L.A.R.P. Emblem'),
|
|
||||||
p(i18n.larpRulesSignText || 'Inhabitants can choose whether to add a sensor on their avatar with the emblem of their current house.')
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderHousesGrid = (houses, myHouseKey, governingKey) => {
|
const renderHousesGrid = (houses, myHouseKey, governingKey) => {
|
||||||
const inLarp = !!myHouseKey;
|
const inLarp = !!myHouseKey;
|
||||||
|
|
@ -277,7 +296,9 @@ const renderHousesGrid = (houses, myHouseKey, governingKey) => {
|
||||||
),
|
),
|
||||||
p({ class: 'larp-card-motto' }, '“' + h.motto + '”'),
|
p({ class: 'larp-card-motto' }, '“' + h.motto + '”'),
|
||||||
p({ class: 'larp-card-roles' }, h.roles),
|
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
|
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 title = i18n.larpTitle || 'L.A.R.P.';
|
||||||
const description = i18n.larpDescription || 'A live action role-playing layer for collaborative experimentation.';
|
const description = i18n.larpDescription || 'A live action role-playing layer for collaborative experimentation.';
|
||||||
const myHouse = houses.find(h => h.key === myHouseKey) || null;
|
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(
|
return template(
|
||||||
title,
|
title,
|
||||||
|
|
@ -299,10 +322,13 @@ exports.larpListView = ({ filter, houses, myHouseKey, cycle, governingKey, gover
|
||||||
p(description)
|
p(description)
|
||||||
),
|
),
|
||||||
renderCycleBanner(cycle),
|
renderCycleBanner(cycle),
|
||||||
renderHouseBadges({ myHouse, governingHouse }),
|
renderHouseBadges({ myHouse, governingHouse, houses }),
|
||||||
renderModeButtons(mode),
|
renderModeButtons(mode),
|
||||||
|
renderHouseSearch(search),
|
||||||
mode === 'houses'
|
mode === 'houses'
|
||||||
? renderHousesGrid(houses, myHouseKey, governingKey)
|
? (matched.length
|
||||||
|
? renderHousesGrid(matched, myHouseKey, governingKey)
|
||||||
|
: p({ class: 'no-content' }, i18n.larpNoHousesMatch))
|
||||||
: mode === 'rules'
|
: mode === 'rules'
|
||||||
? renderRules()
|
? renderRules()
|
||||||
: [
|
: [
|
||||||
|
|
@ -330,7 +356,8 @@ exports.larpHouseView = ({ house, members, myHouseKey, cycle, governingKey, hous
|
||||||
const isAcademia = house.key === 'academia';
|
const isAcademia = house.key === 'academia';
|
||||||
const viewerInAcademia = myHouseKey === 'academia';
|
const viewerInAcademia = myHouseKey === 'academia';
|
||||||
const viewerIsMember = myHouseKey === house.key && house.key !== '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 cycles = computeCyclesUntilRuling(house, governingKey);
|
||||||
const housesById = Object.fromEntries(houses.map(h => [h.key, h]));
|
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 + '”')
|
p({ class: 'larp-detail-motto' }, '“' + house.motto + '”')
|
||||||
),
|
),
|
||||||
renderCycleBanner(cycle),
|
renderCycleBanner(cycle),
|
||||||
renderHouseBadges({ myHouse, governingHouse }),
|
renderHouseBadges({ myHouse, governingHouse, houses }),
|
||||||
renderHouseNav(houses, house.key),
|
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
|
inviteCode
|
||||||
? div({ class: 'larp-invite-banner' },
|
? div({ class: 'larp-invite-banner' },
|
||||||
p({ class: 'larp-invite-banner-title' }, strong(i18n.larpInviteBannerTitle || 'New invitation code')),
|
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)
|
p({ class: 'larp-invite-banner-code' }, inviteCode)
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
|
isAcademia && viewerInAcademia ? renderAcademiaJoinPanel(houses, testStatus, housesById, questions, myHouseKey) : null,
|
||||||
showWall
|
showWall
|
||||||
? renderPostsBlock(posts, house, viewerIsMember)
|
? renderPostsBlock(posts, house, canWriteWall)
|
||||||
: null,
|
: null
|
||||||
isAcademia && viewerInAcademia ? renderAcademiaJoinPanel(houses, testStatus, housesById, questions, myHouseKey) : null
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -363,17 +395,19 @@ exports.larpHouseView = ({ house, members, myHouseKey, cycle, governingKey, hous
|
||||||
exports.larpTestView = ({ questions, cycle, houses, myHouseKey, governingKey, testStatus }) => {
|
exports.larpTestView = ({ questions, cycle, houses, myHouseKey, governingKey, testStatus }) => {
|
||||||
const myHouse = houses.find(h => h.key === myHouseKey) || null;
|
const myHouse = houses.find(h => h.key === myHouseKey) || null;
|
||||||
const governingHouse = houses.find(h => h.key === governingKey) || 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(
|
return template(
|
||||||
titleText,
|
title,
|
||||||
section(
|
section(
|
||||||
div({ class: 'tags-header' },
|
div({ class: 'tags-header' },
|
||||||
h1(titleText),
|
h1(title),
|
||||||
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.')
|
p(description)
|
||||||
),
|
),
|
||||||
renderCycleBanner(cycle),
|
renderCycleBanner(cycle),
|
||||||
renderHouseBadges({ myHouse, governingHouse }),
|
renderHouseBadges({ myHouse, governingHouse, houses }),
|
||||||
|
renderModeButtons(''),
|
||||||
testStatus && !testStatus.allowed
|
testStatus && !testStatus.allowed
|
||||||
? div({ class: 'larp-test-cooldown' },
|
? div({ class: 'larp-test-cooldown' },
|
||||||
p((i18n.larpTestCooldownActive || 'Next test available in') + ' ' + formatCooldown(testStatus.nextAt - Date.now()) + ' ' + (i18n.larpTestCooldownDays || 'cycles')),
|
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(
|
section(
|
||||||
div({ class: 'tags-header' }, h1(titleText)),
|
div({ class: 'tags-header' }, h1(titleText)),
|
||||||
renderCycleBanner(cycle),
|
renderCycleBanner(cycle),
|
||||||
renderHouseBadges({ myHouse, governingHouse }),
|
renderHouseBadges({ myHouse, governingHouse, houses }),
|
||||||
div({ class: 'larp-test-result' },
|
div({ class: 'larp-test-result' },
|
||||||
ranking.length
|
ranking.length
|
||||||
? div({ class: 'larp-test-ranking' },
|
? 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 { template, i18n } = require("./main_views");
|
||||||
const moment = require("../server/node_modules/moment");
|
const moment = require("../server/node_modules/moment");
|
||||||
|
const { config } = require("../server/SSB_server.js");
|
||||||
const { renderUrl } = require("../backend/renderUrl");
|
const { renderUrl } = require("../backend/renderUrl");
|
||||||
|
|
||||||
|
const userId = config.keys.id;
|
||||||
|
|
||||||
const safeArr = v => Array.isArray(v) ? v : [];
|
const safeArr = v => Array.isArray(v) ? v : [];
|
||||||
|
|
||||||
const FILTERS = ["today", "week", "month", "year", "always"];
|
const FILTERS = ["today", "week", "month", "year", "always"];
|
||||||
|
|
@ -18,22 +21,22 @@ const filterLabel = (f) => {
|
||||||
return map[f] || f.toUpperCase();
|
return map[f] || f.toUpperCase();
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderFilterBar = (current, hasItems = false) =>
|
const renderFilterBar = (current, hasItems = true) =>
|
||||||
div({ class: "logs-toolbar" },
|
div({ class: "activity-sub-filter" },
|
||||||
form({ method: "GET", action: "/logs", class: "logs-toolbar-inline" },
|
form({ method: "GET", action: "/logs", class: "sub-filter-form" },
|
||||||
FILTERS.map(f =>
|
FILTERS.map(f =>
|
||||||
button({
|
button({
|
||||||
type: "submit", name: "filter", value: f,
|
type: "submit", name: "filter", value: f,
|
||||||
class: current === f ? "filter-btn active" : "filter-btn"
|
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" }),
|
input({ type: "hidden", name: "view", value: "create" }),
|
||||||
button({ type: "submit", class: "create-button" }, i18n.logsCreate || 'Create Log')
|
button({ type: "submit", class: "create-button" }, i18n.logsCreate || 'Create Log')
|
||||||
),
|
),
|
||||||
hasItems
|
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')
|
button({ type: "submit", class: "create-button" }, i18n.logsExport || 'Export Logs')
|
||||||
)
|
)
|
||||||
: null
|
: null
|
||||||
|
|
@ -71,12 +74,9 @@ const renderToolbar = (current, search, hasItems) =>
|
||||||
renderSearchBox(current, search)
|
renderSearchBox(current, search)
|
||||||
);
|
);
|
||||||
|
|
||||||
const MAX_PREVIEW = 140;
|
const truncate = (value, max = 160) => {
|
||||||
|
const text = String(value == null ? '' : value).trim();
|
||||||
const truncate = (str) => {
|
return text.length > max ? `${text.slice(0, max)}\u2026` : text;
|
||||||
const s = String(str || '');
|
|
||||||
if (s.length <= MAX_PREVIEW) return s;
|
|
||||||
return s.slice(0, MAX_PREVIEW).replace(/\s+\S*$/, '') + '…';
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderLogPreview = (item) => {
|
const renderLogPreview = (item) => {
|
||||||
|
|
@ -91,9 +91,8 @@ const renderTable = (items) => {
|
||||||
tr(
|
tr(
|
||||||
th(i18n.logsColumnDate || 'Date'),
|
th(i18n.logsColumnDate || 'Date'),
|
||||||
th(i18n.logsColumnType || 'Type'),
|
th(i18n.logsColumnType || 'Type'),
|
||||||
th(i18n.logsColumnLog || 'Log'),
|
th({ class: "logs-col-log" }, i18n.logsColumnLog || 'Log'),
|
||||||
th(''),
|
th({ class: "logs-col-actions" }, '')
|
||||||
th('')
|
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
tbody(
|
tbody(
|
||||||
|
|
@ -114,13 +113,13 @@ const renderTable = (items) => {
|
||||||
renderLogPreview(item)
|
renderLogPreview(item)
|
||||||
),
|
),
|
||||||
td({ class: "logs-col-actions" },
|
td({ class: "logs-col-actions" },
|
||||||
form({ method: "GET", action: `/logs/view/${encodeURIComponent(item.key)}` },
|
div({ class: "logs-row-actions" },
|
||||||
button({ type: "submit", class: "filter-btn" }, i18n.logsViewDetails || 'View Details')
|
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)}` },
|
||||||
form({ method: "GET", action: `/logs/export/${encodeURIComponent(item.key)}` },
|
button({ type: "submit", class: "filter-btn" }, i18n.logsExportOne || 'Export')
|
||||||
button({ type: "submit", class: "filter-btn" }, i18n.logsExportOne || 'Export')
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -161,7 +160,6 @@ const renderCreateForm = (mode, aiModOn) => {
|
||||||
: div({ class: "div-center audio-form" },
|
: div({ class: "div-center audio-form" },
|
||||||
form({ method: "POST", action: "/logs/create" },
|
form({ method: "POST", action: "/logs/create" },
|
||||||
input({ type: "hidden", name: "mode", value: "manual" }),
|
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...' }),
|
textarea({ name: "text", rows: "8", required: true, placeholder: i18n.logsTextPlaceholder || 'Describe your experiences...' }),
|
||||||
br(), br(),
|
br(), br(),
|
||||||
button({ type: "submit", class: "create-button" }, i18n.logsWriteButton || 'Write')
|
button({ type: "submit", class: "create-button" }, i18n.logsWriteButton || 'Write')
|
||||||
|
|
@ -172,9 +170,8 @@ const renderCreateForm = (mode, aiModOn) => {
|
||||||
|
|
||||||
const renderEditForm = (entry) => {
|
const renderEditForm = (entry) => {
|
||||||
return div({ class: "div-center audio-form" },
|
return div({ class: "div-center audio-form" },
|
||||||
h2(i18n.logsEditTitle || 'Edit Log'),
|
h2(i18n.logsEditTitle || 'Update Log'),
|
||||||
form({ method: "POST", action: `/logs/edit/${encodeURIComponent(entry.key)}` },
|
form({ method: "POST", action: `/logs/update/${encodeURIComponent(entry.key)}` },
|
||||||
span(i18n.logsManualPrompt || 'Write your log...'), br(),
|
|
||||||
textarea({ name: "text", rows: "8", required: true }, entry.text || ''),
|
textarea({ name: "text", rows: "8", required: true }, entry.text || ''),
|
||||||
input({ type: "hidden", name: "label", value: entry.label || '' }),
|
input({ type: "hidden", name: "label", value: entry.label || '' }),
|
||||||
br(), br(),
|
br(), br(),
|
||||||
|
|
@ -189,25 +186,24 @@ const renderDetail = (entry) => {
|
||||||
h2(headerLine),
|
h2(headerLine),
|
||||||
entry.label ? div({ class: "logs-entry-label" }, entry.label) : null,
|
entry.label ? div({ class: "logs-entry-label" }, entry.label) : null,
|
||||||
div({ class: "logs-detail-text" }, ...renderUrl(String(entry.text || ''))),
|
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" },
|
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)}` },
|
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" },
|
form({ method: "GET", action: `/logs/edit/${encodeURIComponent(entry.key)}` },
|
||||||
input({ type: "hidden", name: "view", value: "edit" }),
|
button({ type: "submit", class: "tribe-action-btn" }, i18n.logsEdit || 'Update')
|
||||||
input({ type: "hidden", name: "id", value: entry.key }),
|
|
||||||
button({ type: "submit", class: "filter-btn" }, i18n.logsEdit || 'Edit')
|
|
||||||
),
|
),
|
||||||
form({ method: "POST", action: `/logs/delete/${encodeURIComponent(entry.key)}` },
|
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 = {}) => {
|
exports.logsView = (items, filter, mode, opts = {}) => {
|
||||||
const listTitle = i18n.logsTitle || 'Logs';
|
const listTitle = i18n.logsTitle || 'Logs';
|
||||||
const description = i18n.logsDescription || 'Record your experience in the network.';
|
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 aiModOn = !!opts.aiModOn;
|
||||||
const hasItems = Array.isArray(items) && items.length > 0;
|
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') {
|
if (view === 'create') {
|
||||||
const h = i18n.logsCreateTitle || 'Create Log';
|
return screen(renderCreateForm(mode, aiModOn));
|
||||||
const body = section(
|
|
||||||
div({ class: "tags-header" }, h2(h), p(description)),
|
|
||||||
renderFilterBar(filter, hasItems),
|
|
||||||
renderCreateForm(mode, aiModOn)
|
|
||||||
);
|
|
||||||
return template(h, body);
|
|
||||||
}
|
}
|
||||||
if (view === 'edit' && opts.entry) {
|
if (view === 'edit' && opts.entry) {
|
||||||
const h = i18n.logsEditTitle || 'Edit Log';
|
return screen(renderEditForm(opts.entry));
|
||||||
const body = section(
|
|
||||||
div({ class: "tags-header" }, h2(h), p(description)),
|
|
||||||
renderEditForm(opts.entry)
|
|
||||||
);
|
|
||||||
return template(h, body);
|
|
||||||
}
|
}
|
||||||
if (view === 'detail' && opts.entry) {
|
if (view === 'detail' && opts.entry) {
|
||||||
const h = i18n.logsViewTitle || 'Log';
|
return screen(renderDetail(opts.entry));
|
||||||
const body = section(
|
|
||||||
div({ class: "tags-header" }, h2(h), p(description)),
|
|
||||||
renderDetail(opts.entry)
|
|
||||||
);
|
|
||||||
return template(h, body);
|
|
||||||
}
|
}
|
||||||
const body = section(
|
const body = section(
|
||||||
div({ class: "tags-header" }, h2(listTitle), p(description)),
|
div({ class: "tags-header" }, h2(listTitle), p(description)),
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,7 @@ const renderBcsList = (bcsAudios) => {
|
||||||
span({ class: "card-label" }, `${i18n.melodyByLabel || "By"}: `),
|
span({ class: "card-label" }, `${i18n.melodyByLabel || "By"}: `),
|
||||||
userLink(a.author),
|
userLink(a.author),
|
||||||
span({ class: "melody-meta-sep" }, " · "),
|
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
|
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 { 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 { renderEncryptedChip } = require("./clearnet_view")
|
||||||
const moment = require("../server/node_modules/moment")
|
const moment = require("../server/node_modules/moment")
|
||||||
const { config } = require("../server/SSB_server.js")
|
const { config } = require("../server/SSB_server.js")
|
||||||
|
|
@ -105,6 +105,10 @@ const renderPadCard = (pad, filter, spreadInfo) => {
|
||||||
renderLifespanChip(pad.lifetime, i18n)
|
renderLifespanChip(pad.lifetime, i18n)
|
||||||
].filter(Boolean)
|
].filter(Boolean)
|
||||||
return div({ class: "tribe-card" },
|
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: "tribe-card-body" },
|
||||||
div({ class: "shop-title-row" },
|
div({ class: "shop-title-row" },
|
||||||
h2({ class: "tribe-card-title" },
|
h2({ class: "tribe-card-title" },
|
||||||
|
|
@ -113,17 +117,10 @@ const renderPadCard = (pad, filter, spreadInfo) => {
|
||||||
),
|
),
|
||||||
chips.length ? div({ class: "card-chips-row" }, ...chips) : null,
|
chips.length ? div({ class: "card-chips-row" }, ...chips) : null,
|
||||||
pad.deadline
|
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,
|
: null,
|
||||||
div({ class: "tribe-card-members" },
|
div({ class: "tribe-card-members" },
|
||||||
span({ class: "tribe-members-count" }, `${i18n.padMembersLabel || "Members"}: ${pad.members.length}`)
|
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,
|
tribeId ? input({ type: "hidden", name: "tribeId", value: tribeId }) : null,
|
||||||
span(i18n.padTitleLabel || "Title"), require("../server/node_modules/hyperaxe").br(),
|
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(),
|
require("../server/node_modules/hyperaxe").br(), require("../server/node_modules/hyperaxe").br(),
|
||||||
span(i18n.padStatusLabel || "Status"), require("../server/node_modules/hyperaxe").br(),
|
span(i18n.padStatusLabel || "Status"), require("../server/node_modules/hyperaxe").br(),
|
||||||
select({ name: "status" },
|
select({ name: "status" },
|
||||||
|
|
@ -178,14 +175,7 @@ exports.renderPadInvitePage = (code) => {
|
||||||
exports.padsView = async (pads, filter, padToEdit, params) => {
|
exports.padsView = async (pads, filter, padToEdit, params) => {
|
||||||
const q = String((params && params.q) || "").trim()
|
const q = String((params && params.q) || "").trim()
|
||||||
const isForm = filter === "create" || filter === "edit"
|
const isForm = filter === "create" || filter === "edit"
|
||||||
const headerMap = {
|
const headerText = i18n.padsTitle
|
||||||
all: i18n.padAllSectionTitle || "Pads",
|
|
||||||
mine: i18n.padMineSectionTitle || "Your Pads",
|
|
||||||
recent: i18n.padRecentSectionTitle || "Recent Pads",
|
|
||||||
open: i18n.padOpenSectionTitle || "Open Pads",
|
|
||||||
closed: i18n.padClosedSectionTitle || "Closed Pads"
|
|
||||||
}
|
|
||||||
const headerText = headerMap[filter] || headerMap.all
|
|
||||||
|
|
||||||
const filteredPads = q
|
const filteredPads = q
|
||||||
? pads.filter(pd => String(pd.title || "").toLowerCase().includes(q.toLowerCase()))
|
? pads.filter(pd => String(pd.title || "").toLowerCase().includes(q.toLowerCase()))
|
||||||
|
|
@ -199,12 +189,12 @@ exports.padsView = async (pads, filter, padToEdit, params) => {
|
||||||
renderModeButtons(filter),
|
renderModeButtons(filter),
|
||||||
!isForm
|
!isForm
|
||||||
? div({ class: "filters" },
|
? 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: "hidden", name: "filter", value: filter }),
|
||||||
input({ type: "text", name: "q", placeholder: i18n.padSearchPlaceholder || "Search pads...", value: q }),
|
input({ type: "text", name: "q", placeholder: i18n.padSearchPlaceholder || "Search pads...", value: q, class: "filter-box__input" }),
|
||||||
br(),
|
div({ class: "filter-box__controls" },
|
||||||
button({ type: "submit" }, i18n.search),
|
button({ type: "submit", class: "filter-box__button" }, i18n.searchButton)
|
||||||
br()
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
|
|
@ -213,7 +203,7 @@ exports.padsView = async (pads, filter, padToEdit, params) => {
|
||||||
: div(
|
: div(
|
||||||
filteredPads.length === 0
|
filteredPads.length === 0
|
||||||
? p({ class: "no-content" }, i18n.padsNoItems || "No pads found.")
|
? 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 isMember = pad.members.includes(userId) || (!!pad.tribeId && !!pad.isTribeMember)
|
||||||
const padClosed = pad.isClosed
|
const padClosed = pad.isClosed
|
||||||
const returnTo = `/pads/${encodeURIComponent(pad.rootId)}`
|
const returnTo = `/pads/${encodeURIComponent(pad.rootId)}`
|
||||||
const shareUrl = `/pads/${encodeURIComponent(pad.rootId)}`
|
|
||||||
const isRestrictedInviteOnly = !isMember && !isAuthor && pad.status === "INVITE-ONLY"
|
const isRestrictedInviteOnly = !isMember && !isAuthor && pad.status === "INVITE-ONLY"
|
||||||
|
|
||||||
const tags = !isRestrictedInviteOnly && Array.isArray(pad.tags) && pad.tags.length > 0
|
const tags = !isRestrictedInviteOnly && Array.isArray(pad.tags) && pad.tags.length > 0
|
||||||
|
|
@ -237,76 +226,65 @@ exports.singlePadView = async (pad, entries, params) => {
|
||||||
renderEncryptedChip(i18n),
|
renderEncryptedChip(i18n),
|
||||||
renderLifespanChip(pad.lifetime, i18n)
|
renderLifespanChip(pad.lifetime, i18n)
|
||||||
].filter(Boolean)
|
].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" },
|
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" },
|
div({ class: "shop-title-row" },
|
||||||
h2({ class: "tribe-card-title" }, pad.title || "\u2014")
|
h2({ class: "tribe-card-title" }, pad.title || "\u2014")
|
||||||
),
|
),
|
||||||
detailChips.length ? div({ class: "card-chips-row" }, ...detailChips) : null,
|
detailChips.length ? div({ class: "card-chips-row" }, ...detailChips) : null,
|
||||||
div({ class: "shop-share" },
|
table({ class: "tribe-info-table jobs-info-table" },
|
||||||
span({ class: "tribe-info-label" }, i18n.padShareUrl || "Share URL"),
|
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"))),
|
||||||
input({ type: "text", readonly: true, value: shareUrl, class: "shop-share-input" })
|
(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)))
|
||||||
),
|
),
|
||||||
(() => {
|
tags,
|
||||||
const btn = renderSpreadButton(pad.rootId);
|
|
||||||
return btn ? div({ class: "card-spread-centered" }, btn) : null;
|
|
||||||
})(),
|
|
||||||
div({ class: "tribe-card-members" },
|
div({ class: "tribe-card-members" },
|
||||||
span({ class: "tribe-members-count" }, `${i18n.padMembersLabel || "Members"}: ${pad.members.length}`)
|
span({ class: "tribe-members-count" }, `${i18n.padMembersLabel || "Members"}: ${pad.members.length}`)
|
||||||
),
|
),
|
||||||
table({ class: "tribe-info-table" },
|
isRestrictedInviteOnly ? null : div({ class: "tribe-side-actions housing-status-row" },
|
||||||
tr(td({ class: "tribe-info-label" }, i18n.padCreated || "Created"), td({ class: "tribe-info-value", colspan: "3" }, moment(pad.createdAt).format("YYYY-MM-DD"))),
|
span({ class: "card-label" }, `${i18n.padStatusLabel || "Status"}: `),
|
||||||
isRestrictedInviteOnly ? null : tr(td({ class: "tribe-info-value pad-author-cell", colspan: "4" }, userLink(pad.author))),
|
renderPadStatusChip(pad.status, padClosed),
|
||||||
isRestrictedInviteOnly ? null : tr(td({ class: "tribe-info-label" }, i18n.padDeadlineLabel || "Deadline"), td({ class: "tribe-info-value", colspan: "3" }, pad.deadline ? moment(pad.deadline).format("YYYY-MM-DD HH:mm") : "\u2014"))
|
|
||||||
),
|
|
||||||
isRestrictedInviteOnly ? null : div({ class: "tribe-side-actions" },
|
|
||||||
isAuthor && pad.status === "INVITE-ONLY"
|
|
||||||
? form({ method: "POST", action: `/pads/generate-invite/${encodeURIComponent(pad.rootId)}` },
|
|
||||||
button({ type: "submit", class: "tribe-action-btn" }, i18n.tribeGenerateInvite)
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
(() => {
|
|
||||||
if (!(isAuthor && pad.status === "INVITE-ONLY")) return null
|
|
||||||
const invs = Array.isArray(pad.invites) ? pad.invites : []
|
|
||||||
const openInvite = invs.find(inv => typeof inv === "object" && inv && inv.public === true && inv.code)
|
|
||||||
if (openInvite) return [
|
|
||||||
div({ class: "tribe-open-invite" },
|
|
||||||
span({ class: "card-label" }, i18n.tribeInviteCodeText),
|
|
||||||
span({ class: "tribe-open-invite-code" }, openInvite.code)
|
|
||||||
),
|
|
||||||
form({ method: "POST", action: `/pads/open-invite/remove/${encodeURIComponent(pad.rootId)}` },
|
|
||||||
button({ type: "submit", class: "tribe-action-btn danger-btn" }, i18n.tribeRemoveInvitation)
|
|
||||||
)
|
|
||||||
]
|
|
||||||
return form({ method: "POST", action: `/pads/open-invite/create/${encodeURIComponent(pad.rootId)}` },
|
|
||||||
button({ type: "submit", class: "tribe-action-btn" }, i18n.tribeOpenInvitation)
|
|
||||||
)
|
|
||||||
})(),
|
|
||||||
form(
|
|
||||||
{ method: "POST", action: pad.isFavorite ? `/pads/favorites/remove/${encodeURIComponent(pad.key)}` : `/pads/favorites/add/${encodeURIComponent(pad.key)}` },
|
|
||||||
returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
|
|
||||||
button({ type: "submit", class: "tribe-action-btn" }, pad.isFavorite ? (i18n.padRemoveFavorite || "Remove Favorite") : (i18n.padAddFavorite || "Add Favorite"))
|
|
||||||
),
|
|
||||||
!isAuthor
|
|
||||||
? a({ href: `/pm?to=${encodeURIComponent(pad.author)}`, class: "tribe-action-btn" }, "PM")
|
|
||||||
: null,
|
|
||||||
isAuthor
|
|
||||||
? form({ method: "GET", action: "/pads" },
|
|
||||||
input({ type: "hidden", name: "filter", value: "edit" }),
|
|
||||||
input({ type: "hidden", name: "id", value: pad.rootId }),
|
|
||||||
button({ type: "submit", class: "tribe-action-btn" }, i18n.padUpdate || "Update")
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
isAuthor && pad.status !== "CLOSED" && !padClosed
|
isAuthor && pad.status !== "CLOSED" && !padClosed
|
||||||
? form({ method: "POST", action: `/pads/close/${encodeURIComponent(pad.rootId)}` },
|
? form({ method: "POST", action: `/pads/close/${encodeURIComponent(pad.rootId)}` },
|
||||||
button({ type: "submit", class: "tribe-action-btn" }, i18n.padClose || "Close Pad")
|
button({ type: "submit", class: "status-btn project-control-btn" }, i18n.padClose || "Close")
|
||||||
)
|
|
||||||
: null,
|
|
||||||
isAuthor
|
|
||||||
? form({ method: "POST", action: `/pads/delete/${encodeURIComponent(pad.rootId)}` },
|
|
||||||
button({ type: "submit", class: "tribe-action-btn danger-btn" }, i18n.padDelete || "Delete")
|
|
||||||
)
|
)
|
||||||
: null
|
: null
|
||||||
),
|
),
|
||||||
|
(isRestrictedInviteOnly || !inviteActions.length) ? null : div({ class: "tribe-side-actions" }, ...inviteActions),
|
||||||
!isAuthor && pad.status === "INVITE-ONLY" && !isMember
|
!isAuthor && pad.status === "INVITE-ONLY" && !isMember
|
||||||
? div({ class: "pad-invite-section" },
|
? div({ class: "pad-invite-section" },
|
||||||
a({ class: "tribe-action-btn", href: "/invites#invites-pads" }, i18n.tribeEnterInvite)
|
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!")
|
button({ type: "submit", class: "create-button" }, i18n.padStartEditing || "START EDITING!")
|
||||||
)
|
)
|
||||||
: null,
|
: 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
|
let canonicalEntries = entries
|
||||||
|
|
@ -343,7 +330,7 @@ exports.singlePadView = async (pad, entries, params) => {
|
||||||
h4(i18n.padVersionHistory || "Version History"),
|
h4(i18n.padVersionHistory || "Version History"),
|
||||||
...visibleEntries.slice().reverse().map((e, idx) =>
|
...visibleEntries.slice().reverse().map((e, idx) =>
|
||||||
div({ class: "pad-version-item" },
|
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-version-author" },
|
||||||
span({ class: "pad-author-swatch " + memberColorClass(pad.members, e.author) }),
|
span({ class: "pad-author-swatch " + memberColorClass(pad.members, e.author) }),
|
||||||
userLink(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 }),
|
input({ type: "number", id: "y", name: "y", min: 1, max: gridHeight, required: true }),
|
||||||
label({ for: "color" }, i18n.colorLabel),
|
label({ for: "color" }, i18n.colorLabel),
|
||||||
select({ id: "color", name: "color", required: true },
|
select({ id: "color", name: "color", required: true },
|
||||||
option({ value: "#000000", style: "background-color:#000000;" }, "Black"),
|
option({ value: "#000000", class: "pixelia-swatch pixelia-swatch-000000" }, "Black"),
|
||||||
option({ value: "#ffffff", style: "background-color:#ffffff;" }, "White"),
|
option({ value: "#ffffff", class: "pixelia-swatch pixelia-swatch-ffffff" }, "White"),
|
||||||
option({ value: "#17f018", style: "background-color:#17f018;" }, "Green"),
|
option({ value: "#17f018", class: "pixelia-swatch pixelia-swatch-17f018" }, "Green"),
|
||||||
option({ value: "#ffbb00", style: "background-color:#ffbb00;" }, "Yellow"),
|
option({ value: "#ffbb00", class: "pixelia-swatch pixelia-swatch-ffbb00" }, "Yellow"),
|
||||||
option({ value: "#ff0000", style: "background-color:#ff0000;" }, "Red"),
|
option({ value: "#ff0000", class: "pixelia-swatch pixelia-swatch-ff0000" }, "Red"),
|
||||||
option({ value: "#0000ff", style: "background-color:#0000ff;" }, "Blue"),
|
option({ value: "#0000ff", class: "pixelia-swatch pixelia-swatch-0000ff" }, "Blue"),
|
||||||
option({ value: "#ffff00", style: "background-color:#ffff00;" }, "Lime"),
|
option({ value: "#ffff00", class: "pixelia-swatch pixelia-swatch-ffff00" }, "Lime"),
|
||||||
option({ value: "#00ff00", style: "background-color:#00ff00;" }, "Spring Green"),
|
option({ value: "#00ff00", class: "pixelia-swatch pixelia-swatch-00ff00" }, "Spring Green"),
|
||||||
option({ value: "#00ffff", style: "background-color:#00ffff;" }, "Aqua"),
|
option({ value: "#00ffff", class: "pixelia-swatch pixelia-swatch-00ffff" }, "Aqua"),
|
||||||
option({ value: "#ff00ff", style: "background-color:#ff00ff;" }, "Fuchsia"),
|
option({ value: "#ff00ff", class: "pixelia-swatch pixelia-swatch-ff00ff" }, "Fuchsia"),
|
||||||
option({ value: "#a52a2a", style: "background-color:#a52a2a;" }, "Brown"),
|
option({ value: "#a52a2a", class: "pixelia-swatch pixelia-swatch-a52a2a" }, "Brown"),
|
||||||
option({ value: "#800080", style: "background-color:#800080;" }, "Purple"),
|
option({ value: "#800080", class: "pixelia-swatch pixelia-swatch-800080" }, "Purple"),
|
||||||
option({ value: "#808000", style: "background-color:#808000;" }, "Olive"),
|
option({ value: "#808000", class: "pixelia-swatch pixelia-swatch-808000" }, "Olive"),
|
||||||
option({ value: "#00bfff", style: "background-color:#00bfff;" }, "Deep Sky Blue"),
|
option({ value: "#00bfff", class: "pixelia-swatch pixelia-swatch-00bfff" }, "Deep Sky Blue"),
|
||||||
option({ value: "#d3d3d3", style: "background-color:#d3d3d3;" }, "Light Grey"),
|
option({ value: "#d3d3d3", class: "pixelia-swatch pixelia-swatch-d3d3d3" }, "Light Grey"),
|
||||||
option({ value: "#ff6347", style: "background-color:#ff6347;" }, "Tomato")
|
option({ value: "#ff6347", class: "pixelia-swatch pixelia-swatch-ff6347" }, "Tomato")
|
||||||
),
|
),
|
||||||
button({ type: "submit" }, i18n.paintButton)
|
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')}`)
|
span({ class: "pm-fileshare-meta" }, `${fileSharePreview.sizeLabel || ''} · ${String(fileSharePreview.mime || 'application/octet-stream')}`)
|
||||||
),
|
),
|
||||||
form({ method: "POST", action: "/pm/file", class: "pm-fileshare-send-form" },
|
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: "subject", value: fileSharePreview.subject || '' }),
|
||||||
input({ type: "hidden", name: "manifestBlobId", value: fileSharePreview.manifestBlobId }),
|
input({ type: "hidden", name: "manifestBlobId", value: fileSharePreview.manifestBlobId }),
|
||||||
input({ type: "hidden", name: "keyHex", value: fileSharePreview.keyHex }),
|
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;
|
const pct = networkCO2 > 0 ? Math.min(100, (userCO2 / networkCO2) * 100) : 0;
|
||||||
return div({ class: 'carbon-chart' },
|
return div({ class: 'carbon-chart' },
|
||||||
div({ class: 'carbon-bar-label' },
|
div({ class: 'carbon-bar-label' },
|
||||||
span(i18n.statsCarbonUser || 'Your footprint'),
|
span(`HcT — ${i18n.statsCarbonNetwork || 'Network total'}`),
|
||||||
span(`${userCO2} g CO₂`)
|
|
||||||
),
|
|
||||||
div({ class: 'carbon-bar-track' },
|
|
||||||
div({ class: `carbon-bar-fill carbon-bar-mine ${wClass(pct)}` })
|
|
||||||
),
|
|
||||||
div({ class: 'carbon-bar-label' },
|
|
||||||
span(i18n.statsCarbonNetwork || 'Network total'),
|
|
||||||
span(`${networkCO2} g CO₂`)
|
span(`${networkCO2} g CO₂`)
|
||||||
),
|
),
|
||||||
div({ class: 'carbon-bar-track' },
|
div({ class: 'carbon-bar-track' },
|
||||||
div({ class: 'carbon-bar-fill carbon-bar-network stats-w-100' })
|
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-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)'))
|
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 tombCO2 = parseFloat((tombMB * kWhPerMB * gCO2PerKWh).toFixed(4));
|
||||||
const tombPct = networkCO2 > 0 ? Math.min(100, (tombCO2 / networkCO2) * 100) : 0;
|
const tombPct = networkCO2 > 0 ? Math.min(100, (tombCO2 / networkCO2) * 100) : 0;
|
||||||
return div({ class: 'carbon-chart' },
|
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' },
|
div({ class: 'carbon-bar-label' },
|
||||||
span(i18n.statsCarbonNetwork || 'Network total'),
|
span(i18n.statsCarbonNetwork || 'Network total'),
|
||||||
span(`${networkCO2} g CO₂`)
|
span(`${networkCO2} g CO₂`)
|
||||||
|
|
@ -215,62 +208,59 @@ exports.statsView = (stats, filter) => {
|
||||||
div({ class: 'carbon-bar-track' },
|
div({ class: 'carbon-bar-track' },
|
||||||
div({ class: 'carbon-bar-fill carbon-bar-network stats-w-100' })
|
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-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)'))
|
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 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' },
|
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' },
|
div({ class: 'carbon-bar-label' },
|
||||||
span(i18n.statsCarbonMaxAnnual || 'Annual max estimate'),
|
span(i18n.statsCarbonMaxAnnual || 'Annual max estimate'),
|
||||||
span(`${maxAnnualCO2} g CO₂`)
|
span(`${maxAnnualCO2} g CO₂`)
|
||||||
),
|
),
|
||||||
div({ class: 'carbon-bar-track' },
|
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)'))
|
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' },
|
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.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.statsBlockchainSize), td({ class: 'card-value' }, stats.statsBlockchainSize)),
|
||||||
tr(td({ class: 'card-label' }, i18n.statsSize), td({ class: 'card-value' }, stats.folderSize))
|
tr(td({ class: 'card-label' }, i18n.statsSize), td({ class: 'card-value' }, stats.folderSize))
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const totalInhabitants = stats.usersKPIs?.totalInhabitants || stats.inhabitants || 0;
|
const accountCard = div({ class: 'stats-card' },
|
||||||
const networkKPIs = stats.networkKPIs || {};
|
h3({ class: 'stats-section-h' }, i18n.statsAccountTitle || 'Account'),
|
||||||
|
|
||||||
const topStrip = div({ class: 'stats-block' },
|
|
||||||
kpiGrid(
|
|
||||||
kpi(i18n.bankingUserEngagementScore, C(stats, 'karmaScore')),
|
|
||||||
kpi(i18n.statsUsersTitle, totalInhabitants),
|
|
||||||
kpi(i18n.statsTotalMsgs || 'Total messages', networkKPIs.totalMsgs || 0),
|
|
||||||
kpi(i18n.statsLogsTitle || 'Logs', stats?.logsCount || 0),
|
|
||||||
kpi(i18n.statsAITraining, C(stats, 'aiExchange') || 0),
|
|
||||||
kpi(i18n.statsPUBs, stats.pubsCount || 0)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const carbonCard = div({ class: 'stats-card' },
|
|
||||||
h3({ class: 'stats-section-h' }, i18n.statsCarbonFootprintTitle || 'Carbon Footprint'),
|
|
||||||
carbonChart
|
|
||||||
);
|
|
||||||
|
|
||||||
const bankingCard = div({ class: 'stats-card' },
|
|
||||||
table({ class: 'block-info-table' },
|
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.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' },
|
tr(td({ class: 'card-label' }, i18n.profileGpgChip || 'GPG'), td({ class: 'card-value' },
|
||||||
stats?.gpgFingerprint
|
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' },
|
const networkBlock = div({ class: 'stats-block' },
|
||||||
|
h3({ class: 'stats-section-h' }, i18n.statsAveragesTitle || 'Averages'),
|
||||||
kpiGrid(
|
kpiGrid(
|
||||||
filter === 'MINE'
|
filter === 'MINE'
|
||||||
? kpi(i18n.statsMyShare || 'Your share of the network', `${fmtNum(networkKPIs.myShare || 0)}%`)
|
? 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' }, [
|
return div({ class: 'stats-container' }, [
|
||||||
div({ class: 'stats-block' },
|
div({ class: 'stats-block' },
|
||||||
|
h3({ class: 'stats-section-h' }, i18n.TOMBSTONEButton),
|
||||||
kpiGrid(
|
kpiGrid(
|
||||||
kpi(i18n.TOMBSTONEButton, userTomb),
|
kpi(i18n.TOMBSTONEButton, userTomb),
|
||||||
kpi(i18n.statsTombstoneRatio, `${(stats.tombstoneKPIs?.ratio || 0).toFixed(2)}%`)
|
kpi(i18n.statsTombstoneRatio, `${(stats.tombstoneKPIs?.ratio || 0).toFixed(2)}%`)
|
||||||
|
|
@ -466,6 +477,7 @@ exports.statsView = (stats, filter) => {
|
||||||
const ets = stats.ecoTaxStats || null;
|
const ets = stats.ecoTaxStats || null;
|
||||||
const userTax = Number(stats.userEcoinTax || 0);
|
const userTax = Number(stats.userEcoinTax || 0);
|
||||||
const ecoTaxBlock = ets ? div({ class: 'stats-card' },
|
const ecoTaxBlock = ets ? div({ class: 'stats-card' },
|
||||||
|
h3({ class: 'stats-section-h' }, i18n.statsEcoTaxTitle || 'ECO Tax'),
|
||||||
table({ class: 'block-info-table' },
|
table({ class: 'block-info-table' },
|
||||||
filter === 'MINE'
|
filter === 'MINE'
|
||||||
? tr(td({ class: 'card-label' }, i18n.bankTaxesUserAmount || 'Your ECO tax'), td({ class: 'card-value' }, `${userTax.toFixed(6)} ECO`))
|
? 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 =>
|
modes.map(m =>
|
||||||
form({ method: 'GET', action: '/stats' },
|
form({ method: 'GET', action: '/stats' },
|
||||||
input({ type: 'hidden', name: 'filter', value: m }),
|
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(
|
section(
|
||||||
topStrip,
|
filter === 'ALL' ? networkStrip : null,
|
||||||
headerCard,
|
filter === 'MINE' ? accountCard : null,
|
||||||
|
filter === 'ALL' ? storageCard : null,
|
||||||
|
tombMode,
|
||||||
|
carbonCard,
|
||||||
filter !== 'TOMBSTONE' ? ecoTaxBlock : null,
|
filter !== 'TOMBSTONE' ? ecoTaxBlock : null,
|
||||||
bankingCard,
|
|
||||||
allMode,
|
allMode,
|
||||||
mineMode,
|
mineMode
|
||||||
tombMode
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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 { 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 moment = require("../server/node_modules/moment")
|
||||||
const { config } = require("../server/SSB_server.js")
|
const { config } = require("../server/SSB_server.js")
|
||||||
|
|
||||||
|
|
@ -130,7 +131,7 @@ const renderUpdatedLabel = (createdAt, updatedAt) => {
|
||||||
const updatedTs = updatedAt ? new Date(updatedAt).getTime() : NaN
|
const updatedTs = updatedAt ? new Date(updatedAt).getTime() : NaN
|
||||||
const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs)
|
const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs)
|
||||||
return showUpdated
|
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
|
: null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -174,7 +175,7 @@ const generateTransferCard = (transfer, filter, params = {}) => {
|
||||||
div(
|
div(
|
||||||
{ class: "card-header activity-card-header" },
|
{ class: "card-header activity-card-header" },
|
||||||
span(),
|
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: "card-section transfer-card-body" },
|
||||||
div({ class: "shop-title-row" },
|
div({ class: "shop-title-row" },
|
||||||
|
|
@ -184,24 +185,14 @@ const generateTransferCard = (transfer, filter, params = {}) => {
|
||||||
),
|
),
|
||||||
chips.length ? div({ class: "card-chips-row" }, ...chips) : null,
|
chips.length ? div({ class: "card-chips-row" }, ...chips) : null,
|
||||||
!isUbi && dl && dl.isValid()
|
!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,
|
: null,
|
||||||
cat !== "TRUST"
|
cat !== "TRUST"
|
||||||
? div({ class: "job-price-line card-salary" }, fmtAmountWithUnit(transfer))
|
? div({ class: "job-price-line card-salary transfer-amount-centered" }, fmtAmountWithUnit(transfer))
|
||||||
: null,
|
: null,
|
||||||
div({ class: "tribe-card-members" },
|
div({ class: "tribe-card-members" },
|
||||||
span({ class: "tribe-members-count" }, `${i18n.transfersConfirmations}: ${confirmedCount}/${required}`)
|
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 = {}) => {
|
exports.transferView = async (transfers, filter, transferId, params = {}) => {
|
||||||
const normalizedFilter = filter === "favs" ? "all" : (filter || "all")
|
const normalizedFilter = filter === "favs" ? "all" : (filter || "all")
|
||||||
|
|
||||||
const title =
|
const title = i18n.transfersTitle
|
||||||
normalizedFilter === "mine" ? i18n.transfersMineSectionTitle :
|
|
||||||
normalizedFilter === "ubi" ? i18n.transfersUBISectionTitle :
|
|
||||||
normalizedFilter === "pending" ? i18n.transfersPendingSectionTitle :
|
|
||||||
normalizedFilter === "top" ? i18n.transfersTopSectionTitle :
|
|
||||||
normalizedFilter === "unconfirmed" ? i18n.transfersUnconfirmedSectionTitle :
|
|
||||||
normalizedFilter === "closed" ? i18n.transfersClosedSectionTitle :
|
|
||||||
normalizedFilter === "discarded" ? i18n.transfersDiscardedSectionTitle :
|
|
||||||
normalizedFilter === "create" ? i18n.transfersCreateSectionTitle :
|
|
||||||
normalizedFilter === "edit" ? i18n.transfersUpdateSectionTitle :
|
|
||||||
normalizedFilter === "economic" ? (i18n.transfersEconomicSectionTitle || (i18n.transfersCategoryEconomic + " " + i18n.transfersTitle)) :
|
|
||||||
normalizedFilter === "time" ? (i18n.transfersTimeSectionTitle || (i18n.transfersCategoryTime + " " + i18n.transfersTitle)) :
|
|
||||||
normalizedFilter === "trust" ? (i18n.transfersTrustSectionTitle || (i18n.transfersCategoryTrust + " " + i18n.transfersTitle)) :
|
|
||||||
i18n.transfersAllSectionTitle
|
|
||||||
|
|
||||||
const q = safeText(params.q || "")
|
const q = safeText(params.q || "")
|
||||||
const minAmountRaw = params.minAmount ?? ""
|
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: "minAmount", value: String(minAmountRaw ?? "") }),
|
||||||
input({ type: "hidden", name: "maxAmount", value: String(maxAmountRaw ?? "") }),
|
input({ type: "hidden", name: "maxAmount", value: String(maxAmountRaw ?? "") }),
|
||||||
input({ type: "hidden", name: "sort", value: sort }),
|
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: "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" }, i18n.transfersFilterMine),
|
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" }, i18n.transfersFilterUBI),
|
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: "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: "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: "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: "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" }, i18n.transfersFilterPending),
|
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" }, i18n.transfersFilterUnconfirmed),
|
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" }, i18n.transfersFilterClosed),
|
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" }, i18n.transfersFilterDiscarded),
|
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" }, i18n.transfersFilterTop),
|
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)
|
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)
|
button({ type: "submit", class: "delete-btn" }, i18n.transfersDeleteButton)
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
sideActions.push(form({ method: "GET", action: `/transfers/contract/${encodeURIComponent(transfer.id)}`, class: "transfer-contract-form" },
|
const exportActions = div({ class: "doc-export-actions" },
|
||||||
button({ type: "submit", class: "filter-btn" }, i18n.transfersExportContract || 'Create Contract')
|
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 infoRows = []
|
||||||
const pushRow = (labelText, valueNode) =>
|
const pushRow = (labelText, valueNode) =>
|
||||||
|
|
@ -477,15 +460,24 @@ exports.singleTransferView = async (transfer, filter, params = {}) => {
|
||||||
pushRow(i18n.transfersTo, userLink(transfer.to))
|
pushRow(i18n.transfersTo, userLink(transfer.to))
|
||||||
if (cat !== "TRUST") pushRow(i18n.transfersAmount, fmtAmountWithUnit(transfer))
|
if (cat !== "TRUST") pushRow(i18n.transfersAmount, fmtAmountWithUnit(transfer))
|
||||||
pushRow(i18n.transfersCategory || "Category", categoryLabel(cat))
|
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 || ""))
|
pushRow(i18n.transfersStatus, i18n[statusKey(transfer.status)] || String(transfer.status || ""))
|
||||||
|
|
||||||
const transferSide = div({ class: "tribe-side" },
|
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" },
|
div({ class: "shop-title-row" },
|
||||||
h2({ class: "tribe-card-title" }, transfer.concept || i18n.transfersTitle)
|
h2({ class: "tribe-card-title" }, transfer.concept || i18n.transfersTitle)
|
||||||
),
|
),
|
||||||
chips.length ? div({ class: "card-chips-row" }, ...chips) : null,
|
chips.length ? div({ class: "card-chips-row" }, ...chips) : null,
|
||||||
div({ class: "card-spread-centered" }, renderSpreadButton(transfer.id, params.spreads)),
|
|
||||||
tagsNode,
|
tagsNode,
|
||||||
div({ class: "tribe-card-members" },
|
div({ class: "tribe-card-members" },
|
||||||
span({ class: "tribe-members-count" }, `${i18n.transfersConfirmations}: ${confirmedCount}/${required}`)
|
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))
|
span({ class: "card-value" }, a({ class: "user-link", href: `/blockexplorer/block/${encodeURIComponent(params.block.id)}` }, params.block.id))
|
||||||
)
|
)
|
||||||
: null,
|
: 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" },
|
const transferMain = div({ class: "tribe-main" },
|
||||||
div({ class: "job-section" },
|
div({ class: "job-section" },
|
||||||
div({ class: "card-field" },
|
h2({ class: "job-section-title" }, i18n.transfersConcept),
|
||||||
span({ class: "card-label" }, `${i18n.transfersFrom}: `),
|
p({ class: "tribe-side-description" }, transfer.concept || "")
|
||||||
span({ class: "card-value" }, userLink(transfer.from))
|
),
|
||||||
),
|
div({ class: "job-section" },
|
||||||
div({ class: "card-field" },
|
h2({ class: "job-section-title" }, i18n.transfersConfirmations),
|
||||||
span({ class: "card-label" }, `${i18n.transfersTo}: `),
|
p({ class: "tribe-side-description" }, `${confirmedCount}/${required}`),
|
||||||
span({ class: "card-value" }, userLink(transfer.to))
|
confirmedList.length
|
||||||
),
|
? div({ class: "card-assigned-list" }, ...confirmedList.map(id => userLink(id)))
|
||||||
cat !== "TRUST" ? div({ class: "card-field" },
|
: null
|
||||||
span({ class: "card-label" }, `${i18n.transfersAmount}: `),
|
|
||||||
span({ class: "card-value card-salary" }, fmtAmountWithUnit(transfer))
|
|
||||||
) : null,
|
|
||||||
div({ class: "card-field" },
|
|
||||||
span({ class: "card-label" }, `${i18n.transfersCategory || "Category"}: `),
|
|
||||||
span({ class: "card-value" }, categoryLabel(cat))
|
|
||||||
),
|
|
||||||
div({ class: "card-field" },
|
|
||||||
span({ class: "card-label" }, `${i18n.transfersConcept}: `),
|
|
||||||
span({ class: "card-value" }, transfer.concept || "")
|
|
||||||
),
|
|
||||||
!isUbi && dl && dl.isValid() ? div({ class: "card-field" },
|
|
||||||
span({ class: "card-label" }, `${i18n.transfersDeadline}: `),
|
|
||||||
span({ class: "card-value" }, dl.format("YYYY-MM-DD HH:mm"))
|
|
||||||
) : null,
|
|
||||||
div({ class: "card-field" },
|
|
||||||
span({ class: "card-label" }, `${i18n.transfersStatus}: `),
|
|
||||||
span({ class: "card-value" }, i18n[statusKey(transfer.status)] || String(transfer.status || ""))
|
|
||||||
)
|
|
||||||
),
|
),
|
||||||
p({ class: "card-footer" },
|
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),
|
userLink(transfer.from),
|
||||||
renderUpdatedLabel(transfer.createdAt, transfer.updatedAt)
|
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(
|
return template(
|
||||||
transfer.concept,
|
transfer.concept,
|
||||||
|
section(div({ class: "tags-header" }, h2(i18n.transfersTitle), p(i18n.transfersDescription))),
|
||||||
section(
|
section(
|
||||||
div(
|
div(
|
||||||
{ class: "filters" },
|
{ 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: "minAmount", value: String(params.minAmount ?? "") }),
|
||||||
input({ type: "hidden", name: "maxAmount", value: String(params.maxAmount ?? "") }),
|
input({ type: "hidden", name: "maxAmount", value: String(params.maxAmount ?? "") }),
|
||||||
input({ type: "hidden", name: "sort", value: sort }),
|
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: "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" }, i18n.transfersFilterMine),
|
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" }, i18n.transfersFilterUBI),
|
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: "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: "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: "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: "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" }, i18n.transfersFilterPending),
|
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" }, i18n.transfersFilterUnconfirmed),
|
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" }, i18n.transfersFilterClosed),
|
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" }, i18n.transfersFilterDiscarded),
|
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" }, i18n.transfersFilterTop),
|
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)
|
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 moment = require("../server/node_modules/moment");
|
||||||
const { template, i18n, userLink, renderStateChip, renderPrivacyChip, renderLifespanChip, renderModeChip, renderInviteQrCard, renderContentActions } = require('./main_views');
|
const { template, i18n, userLink, renderStateChip, renderPrivacyChip, renderLifespanChip, renderModeChip, renderInviteQrCard, renderContentActions } = require('./main_views');
|
||||||
const { renderEncryptedChip: renderTribeEncryptedChip } = require('./clearnet_view');
|
const { renderEncryptedChip: renderTribeEncryptedChip } = require('./clearnet_view');
|
||||||
|
const { renderResults: renderPollResults, renderBallot: renderPollBallot } = require('./polls_view');
|
||||||
const { config } = require('../server/SSB_server.js');
|
const { config } = require('../server/SSB_server.js');
|
||||||
const { renderUrl } = require('../backend/renderUrl');
|
const { renderUrl } = require('../backend/renderUrl');
|
||||||
const { renderMapLocationUrl, renderMapLocationGrid, renderMapLocationVisitLabel } = require("./maps_view");
|
const { renderMapLocationUrl, renderMapLocationGrid, renderMapLocationVisitLabel } = require("./maps_view");
|
||||||
|
|
@ -166,25 +167,17 @@ exports.tribesView = async (tribes, filter, tribeId, query = {}, allTribes = nul
|
||||||
return cb - ca;
|
return cb - ca;
|
||||||
});
|
});
|
||||||
|
|
||||||
const title =
|
const title = i18n.tribesTitle;
|
||||||
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 header = div({ class: 'tags-header' }, h2(title), p(i18n.tribeDescription));
|
const header = div({ class: 'tags-header' }, h2(title), p(i18n.tribeDescription));
|
||||||
|
|
||||||
const filters = div({ class: 'filters' },
|
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: 'hidden', name: 'filter', value: filter }),
|
||||||
input({ type: 'text', name: 'search', placeholder: i18n.searchTribesPlaceholder, value: query.search || '' }),
|
input({ type: 'text', name: 'search', placeholder: i18n.searchTribesPlaceholder, value: query.search || '', class: 'filter-box__input' }),
|
||||||
br(),
|
div({ class: 'filter-box__controls' },
|
||||||
button({ type: 'submit' }, i18n.applyFilters),
|
button({ type: 'submit', class: 'filter-box__button' }, i18n.searchButton)
|
||||||
br()
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -214,7 +207,7 @@ exports.tribesView = async (tribes, filter, tribeId, query = {}, allTribes = nul
|
||||||
},
|
},
|
||||||
label({ for: 'title' }, i18n.tribeTitleLabel),
|
label({ for: 'title' }, i18n.tribeTitleLabel),
|
||||||
br,
|
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(),
|
br(),
|
||||||
label({ for: 'description' }, i18n.tribeDescriptionLabel),
|
label({ for: 'description' }, i18n.tribeDescriptionLabel),
|
||||||
br,
|
br,
|
||||||
|
|
@ -265,7 +258,7 @@ exports.tribesView = async (tribes, filter, tribeId, query = {}, allTribes = nul
|
||||||
return div({ class: 'trending-card tribes-card' + (isOwn ? ' own-content' : '') },
|
return div({ class: 'trending-card tribes-card' + (isOwn ? ' own-content' : '') },
|
||||||
div({ class: 'card-header activity-card-header' },
|
div({ class: 'card-header activity-card-header' },
|
||||||
span(),
|
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: 'card-section tribes-card-body' },
|
||||||
div({ class: 'tribe-card-image-wrapper' },
|
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)}` },
|
form({ method: 'GET', action: `/tribe/${encodeURIComponent(tribe.id)}` },
|
||||||
input({ type: 'hidden', name: 'section', value: 'feed' }),
|
input({ type: 'hidden', name: 'section', value: 'feed' }),
|
||||||
input({ type: 'hidden', name: 'feedFilter', value: f }),
|
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' });
|
if (!tribe.parentTribeId) firstGroup.push({ key: 'governance', label: i18n.tribeSectionGovernance || 'GOVERNANCE' });
|
||||||
const sections = [
|
const sections = [
|
||||||
{ items: firstGroup },
|
{ 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: '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: '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 }] },
|
{ items: [{ key: 'tags', label: i18n.tribeSectionTags || 'TAGS' }, { key: 'search', label: i18n.tribeSectionSearch }] },
|
||||||
|
|
@ -476,12 +469,12 @@ const contentTypeVerb = (ct) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const contentTypeName = (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;
|
return map[ct] || ct;
|
||||||
};
|
};
|
||||||
|
|
||||||
const activitySectionMap = {
|
const activitySectionMap = {
|
||||||
event: 'events', task: 'tasks', votation: 'votations',
|
event: 'events', task: 'tasks', votation: 'votations', poll: 'polls',
|
||||||
forum: 'forum', 'forum-reply': 'forum',
|
forum: 'forum', 'forum-reply': 'forum',
|
||||||
feed: 'feed',
|
feed: 'feed',
|
||||||
pad: 'pads', chat: 'chats', calendar: 'calendars', map: 'maps', torrent: 'torrents'
|
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.title ? div({ class: 'card-field' }, span({ class: 'card-value' }, item.title)) : null,
|
||||||
item.description ? p(String(item.description).substring(0, 200)) : null,
|
item.description ? p(String(item.description).substring(0, 200)) : null,
|
||||||
replies.map(r => div({ class: 'thread-reply-item' },
|
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' },
|
div({ class: 'card-footer thread-reply-footer' },
|
||||||
span({ class: 'date-link' }, r.createdAt ? new Date(r.createdAt).toLocaleString() : ''),
|
span({ class: 'date-link' }, r.createdAt ? new Date(r.createdAt).toLocaleString() : ''),
|
||||||
userLink(r.author, '')
|
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 renderVotationsSection = (tribe, items, query) => {
|
||||||
const votations = Array.isArray(items) ? items : [];
|
const votations = Array.isArray(items) ? items : [];
|
||||||
const action = query.action;
|
const action = query.action;
|
||||||
|
|
@ -918,7 +977,7 @@ const renderVotationsSection = (tribe, items, query) => {
|
||||||
return div({ class: 'create-tribe-form' },
|
return div({ class: 'create-tribe-form' },
|
||||||
form({ method: 'POST', action: `/tribe/${encodeURIComponent(tribe.id)}/votations/create` },
|
form({ method: 'POST', action: `/tribe/${encodeURIComponent(tribe.id)}/votations/create` },
|
||||||
label({ for: 'title' }, i18n.tribeVotationTitle), br,
|
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,
|
label({ for: 'description' }, i18n.tribeVotationDescription), br,
|
||||||
textarea({ name: 'description', id: 'description', rows: 3, placeholder: i18n.tribeVotationDescription }, ''), br(),
|
textarea({ name: 'description', id: 'description', rows: 3, placeholder: i18n.tribeVotationDescription }, ''), br(),
|
||||||
label({ for: 'deadline' }, i18n.tribeVotationDeadline), 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,
|
t.description ? div({ class: 'forum-body' }, ...renderUrl((t.description || '').substring(0, 200))) : null,
|
||||||
div({ class: 'forum-meta' },
|
div({ class: 'forum-meta' },
|
||||||
span({ class: 'forum-positive-votes' }, `▲: ${t.refeeds || 0}`),
|
span({ class: 'forum-positive-votes' }, `▲: ${t.refeeds || 0}`),
|
||||||
span({ class: 'forum-messages' }, `${(i18n.forumMessages || i18n.tribeForumReplies || 'MESSAGES').toUpperCase()}: ${replyCount}`),
|
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')
|
|
||||||
)
|
|
||||||
),
|
),
|
||||||
div({ class: 'forum-footer' },
|
div({ class: 'forum-footer' },
|
||||||
span({ class: 'date-link' }, `${new Date(t.createdAt).toLocaleString()} ${i18n.performed || ''}`),
|
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: 'mediaType', value: 'bookmark' }),
|
||||||
input({ type: 'hidden', name: 'returnSection', value: sectionKey }),
|
input({ type: 'hidden', name: 'returnSection', value: sectionKey }),
|
||||||
label({ for: 'title' }, i18n.tribeMediaTitle), br,
|
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,
|
label({ for: 'url' }, i18n.bookmarkUrlLabel || 'URL'), br,
|
||||||
input({ type: 'url', name: 'url', id: 'url', required: true, placeholder: 'https://' }), br(),br(),
|
input({ type: 'url', name: 'url', id: 'url', required: true, placeholder: 'https://' }), br(),br(),
|
||||||
label({ for: 'description' }, i18n.tribeMediaDescription), 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: 'mediaType', value: mediaType }),
|
||||||
input({ type: 'hidden', name: 'returnSection', value: sectionKey }),
|
input({ type: 'hidden', name: 'returnSection', value: sectionKey }),
|
||||||
label({ for: 'title' }, i18n.tribeMediaTitle), br,
|
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,
|
label({ for: 'description' }, i18n.tribeMediaDescription), br,
|
||||||
textarea({ name: 'description', id: 'description', rows: 3, placeholder: i18n.tribeMediaDescription }, ''), br(),
|
textarea({ name: 'description', id: 'description', rows: 3, placeholder: i18n.tribeMediaDescription }, ''), br(),
|
||||||
label({ for: 'media' }, i18n.tribeMediaUpload), 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 'events': sectionContent = renderEventsSection(tribe, sectionData, query); break;
|
||||||
case 'tasks': sectionContent = renderTasksSection(tribe, sectionData, query); break;
|
case 'tasks': sectionContent = renderTasksSection(tribe, sectionData, query); break;
|
||||||
case 'votations': sectionContent = renderVotationsSection(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 'forum': sectionContent = renderForumSection(tribe, sectionData, query); break;
|
||||||
case 'subtribes': sectionContent = renderSubTribesSection(tribe, sectionData, query); break;
|
case 'subtribes': sectionContent = renderSubTribesSection(tribe, sectionData, query); break;
|
||||||
case 'search': sectionContent = renderTribeSearchSection(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)
|
? a({ class: 'tribe-action-btn', href: inviteHref }, i18n.tribeEnterInvite)
|
||||||
: null
|
: null
|
||||||
) : 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' },
|
(hasOpenInvite && canManageOpenInvite) ? div({ class: 'tribe-open-invite-panel' },
|
||||||
div({ class: 'tribe-open-invite' },
|
div({ class: 'tribe-open-invite' },
|
||||||
span({ class: 'tribe-members-count' }, i18n.tribeInviteCodeText),
|
span({ class: 'tribe-members-count' }, i18n.tribeInviteCodeText),
|
||||||
|
|
@ -1868,7 +1932,7 @@ const rulesBlock = (tribe, rules, isCreator) => div({},
|
||||||
h3(i18n.tribeGovernanceAddRule || 'Add rule'),
|
h3(i18n.tribeGovernanceAddRule || 'Add rule'),
|
||||||
form({ method: 'POST', action: `/tribe/${encodeURIComponent(tribe.id)}/governance/rule/add` },
|
form({ method: 'POST', action: `/tribe/${encodeURIComponent(tribe.id)}/governance/rule/add` },
|
||||||
label({}, i18n.tribeGovRuleTitle || 'Title'), br(),
|
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(),
|
label({}, i18n.tribeGovRuleBody || 'Body'), br(),
|
||||||
textarea({ name: 'body', rows: 4 }), br(), br(),
|
textarea({ name: 'body', rows: 4 }), br(), br(),
|
||||||
button({ type: 'submit', class: 'create-button' }, i18n.save || 'Save')
|
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 {
|
if (key === "backup") return {
|
||||||
title: i18n.welcomeStepBackupTitle || "Backup your ID",
|
title: i18n.welcomeStepBackupTitle || "Backup your ID",
|
||||||
text: i18n.welcomeStepBackupText || "Your identity is a key file on this device.",
|
text: i18n.welcomeStepBackupText || "Your identity is a key file on this device.",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue