backend: backend.js a 0.9.5, con lo que faltaba para los modulos nuevos

backend.js pasa a la version de 0.9.5 y baja de 1549 a 16 lineas de divergencia.
Sin esto los modulos que el dev ha anadido estaban portados pero inaccesibles: no
tenian rutas. Ahora /polls, /blogs y /mentions responden.

Reinyectado de la rama: el enganche de fork_routes, los tres gates de movil (no
abrir navegador al arrancar, no matar el sbot al borrar la cadena), el selector de
chats de la barra lateral movil, y los globals de la interfaz — el filtro
Personal/Community, la visibilidad de los hexagonos y el avatar de la topbar.
Esos globals no aparecian al clasificar el diff y su perdida dejaba la home sin
hexagonos; detectado al comparar el HTML servido.

bottomBarPickerView (la pantalla de personalizar la barra) vivia en main_views y se
perdia al adoptar 0.9.5. Pasa a fork/hive_nav.js, junto al resto de la interfaz de
la rama, y main_views la reexporta en una linea.

housing se porta pero apagado (housingMod off). Mantenerlo fuera obligaba a editar
83 referencias de backend.js en cada release; asi el fichero queda igual que
upstream y el modulo no aparece en la interfaz. Se portan tambien contentPdf,
housing_model y housing_view para cerrar los requires.

Verificado con la app en marcha: 29 rutas OK, hexagonos 10/47 con los filtros
Personal y Community dando 5 y 5, topbar con avatar, 7 accesos rapidos, orden de
hojas correcto, y Karvan creando sala y sirviendo el panel de videollamada.
This commit is contained in:
SITO 2026-08-18 22:57:33 +02:00
parent 7eeac8b004
commit 7b90fa8211
8 changed files with 2579 additions and 347 deletions

File diff suppressed because it is too large Load diff

280
src/backend/contentPdf.js Normal file
View file

@ -0,0 +1,280 @@
const { buildDocumentPdf } = require('./pdfDocument');
const fmtDate = v => {
if (!v) return '';
const d = new Date(v);
return isNaN(d.getTime()) ? String(v) : d.toISOString().replace('T', ' ').slice(0, 19) + ' UTC';
};
const fmtDay = v => {
if (!v) return '';
const d = new Date(v);
return isNaN(d.getTime()) ? String(v) : d.toISOString().slice(0, 10);
};
const asList = v => (Array.isArray(v) ? v : []);
const txt = v => (v == null ? '' : String(v));
const humanLabel = (key) => String(key || '')
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/^./, c => c.toUpperCase());
const pushMeta = (out, item) => {
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'METADATA' });
if (item.id) out.push({ kind: 'kv', label: 'Content ID', value: item.id });
const author = item.author || item.organizer || item.createdBy || item.from || '';
if (author) out.push({ kind: 'kv', label: 'Author', value: author });
if (item.createdAt) out.push({ kind: 'kv', label: 'Created At', value: fmtDate(item.createdAt) });
if (item.updatedAt) out.push({ kind: 'kv', label: 'Updated At', value: fmtDate(item.updatedAt) });
};
const pushOpinions = (out, item) => {
const op = item.opinions && typeof item.opinions === 'object' ? item.opinions : {};
const entries = Object.entries(op).filter(([, n]) => Number(n) > 0);
if (!entries.length) return;
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'OPINIONS' });
for (const [cat, n] of entries) out.push({ kind: 'kv', label: humanLabel(cat), value: String(n) });
};
const pushTags = (out, item) => {
const tags = asList(item.tags).filter(Boolean);
if (tags.length) out.push({ kind: 'kv', label: 'Tags', value: tags.join(', ') });
};
const reportSections = (report) => {
const out = [];
out.push({ kind: 'title', text: `Report: ${txt(report.title) || '-'}` });
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'CLASSIFICATION' });
out.push({ kind: 'kv', label: 'Category', value: txt(report.category).toUpperCase() });
out.push({ kind: 'kv', label: 'Severity', value: txt(report.severity).toUpperCase() });
out.push({ kind: 'kv', label: 'Status', value: txt(report.status).toUpperCase() });
pushTags(out, report);
if (txt(report.description).trim()) {
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'DESCRIPTION' });
out.push({ kind: 'text', text: report.description });
}
const tpl = report.template && typeof report.template === 'object' ? report.template : {};
const tplEntries = Object.entries(tpl).filter(([, v]) => txt(v).trim());
if (tplEntries.length) {
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'DETAILS' });
for (const [k, v] of tplEntries) out.push({ kind: 'kv', label: humanLabel(k), value: v });
}
const confirmations = asList(report.confirmations);
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'CONFIRMATIONS' });
out.push({ kind: 'kv', label: 'Confirmed', value: String(confirmations.length) });
for (const c of confirmations) out.push({ kind: 'kv', label: 'Confirmed by', value: c });
pushOpinions(out, report);
pushMeta(out, report);
return out;
};
const voteSections = (vote) => {
const out = [];
out.push({ kind: 'title', text: `Votation: ${txt(vote.question) || '-'}` });
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'TERMS' });
out.push({ kind: 'kv', label: 'Status', value: txt(vote.status).toUpperCase() });
if (vote.deadline) out.push({ kind: 'kv', label: 'Deadline', value: fmtDate(vote.deadline) });
pushTags(out, vote);
const votes = vote.votes && typeof vote.votes === 'object' ? vote.votes : {};
const total = Number(vote.totalVotes) || asList(vote.voters).length;
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'RESULTS' });
out.push({ kind: 'kv', label: 'Total votes', value: String(total) });
for (const [choice, n] of Object.entries(votes)) {
const count = Number(n) || 0;
const pct = total > 0 ? Math.round((count / total) * 100) : 0;
out.push({ kind: 'kv', label: choice, value: `${count} (${pct}%)` });
}
pushOpinions(out, vote);
pushMeta(out, vote);
return out;
};
const eventSections = (event) => {
const out = [];
out.push({ kind: 'title', text: `Event: ${txt(event.title) || '-'}` });
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'SCHEDULE' });
out.push({ kind: 'kv', label: 'Date', value: fmtDate(event.date) });
out.push({ kind: 'kv', label: 'Status', value: txt(event.status).toUpperCase() });
if (event.location) out.push({ kind: 'kv', label: 'Location', value: event.location });
if (event.mapUrl) out.push({ kind: 'kv', label: 'Map', value: event.mapUrl });
if (Number(event.price) > 0) out.push({ kind: 'kv', label: 'Price', value: `${Number(event.price)} ECO` });
out.push({ kind: 'kv', label: 'Privacy', value: txt(event.isPublic).toUpperCase() });
if (event.url) out.push({ kind: 'kv', label: 'Url', value: event.url });
pushTags(out, event);
if (txt(event.description).trim()) {
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'DESCRIPTION' });
out.push({ kind: 'text', text: event.description });
}
const attendees = asList(event.attendees);
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'ATTENDEES' });
out.push({ kind: 'kv', label: 'Total', value: String(attendees.length) });
for (const a of attendees) out.push({ kind: 'kv', label: 'Attendee', value: a });
pushOpinions(out, event);
pushMeta(out, event);
return out;
};
const taskSections = (task) => {
const out = [];
out.push({ kind: 'title', text: `Task: ${txt(task.title) || '-'}` });
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'SCHEDULE' });
out.push({ kind: 'kv', label: 'Status', value: txt(task.status).toUpperCase() });
out.push({ kind: 'kv', label: 'Priority', value: txt(task.priority).toUpperCase() });
if (task.startTime) out.push({ kind: 'kv', label: 'Starts', value: fmtDate(task.startTime) });
if (task.endTime) out.push({ kind: 'kv', label: 'Ends', value: fmtDate(task.endTime) });
if (task.location) out.push({ kind: 'kv', label: 'Location', value: task.location });
out.push({ kind: 'kv', label: 'Privacy', value: txt(task.isPublic).toUpperCase() });
pushTags(out, task);
if (txt(task.description).trim()) {
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'DESCRIPTION' });
out.push({ kind: 'text', text: task.description });
}
const assignees = asList(task.assignees);
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'ASSIGNEES' });
out.push({ kind: 'kv', label: 'Total', value: String(assignees.length) });
for (const a of assignees) out.push({ kind: 'kv', label: 'Assignee', value: a });
pushOpinions(out, task);
pushMeta(out, task);
return out;
};
const calendarSections = (calendar, extra = {}) => {
const dates = asList(extra.dates);
const notesByDate = extra.notesByDate && typeof extra.notesByDate === 'object' ? extra.notesByDate : {};
const out = [];
out.push({ kind: 'title', text: `Calendar: ${txt(calendar.title) || '-'}` });
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'SUMMARY' });
out.push({ kind: 'kv', label: 'Status', value: calendar.isClosed ? 'CLOSED' : txt(calendar.status).toUpperCase() });
if (calendar.deadline) out.push({ kind: 'kv', label: 'Deadline', value: fmtDate(calendar.deadline) });
if (calendar.mapUrl) out.push({ kind: 'kv', label: 'Map', value: calendar.mapUrl });
out.push({ kind: 'kv', label: 'Participants', value: String(asList(calendar.participants).length) });
out.push({ kind: 'kv', label: 'Dates', value: String(dates.length) });
const noteTotal = Object.values(notesByDate).reduce((n, arr) => n + asList(arr).length, 0);
out.push({ kind: 'kv', label: 'Notes', value: String(noteTotal) });
pushTags(out, calendar);
const participants = asList(calendar.participants);
if (participants.length) {
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'PARTICIPANTS' });
for (const p of participants) out.push({ kind: 'kv', label: 'Participant', value: p });
}
if (dates.length) {
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'DATES' });
for (const d of dates) {
const day = fmtDay(d && d.date) || txt(d && d.date);
out.push({ kind: 'kv', label: day, value: txt(d && d.label) });
for (const note of asList(notesByDate[d && d.key])) {
const noteText = txt(note && note.text);
if (noteText) out.push({ kind: 'text', text: ` - ${noteText}` });
}
}
}
pushMeta(out, calendar);
return out;
};
const cvSections = (cv) => {
const out = [];
out.push({ kind: 'title', text: `Curriculum: ${txt(cv.name) || txt(cv.author) || '-'}` });
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'PROFILE' });
if (cv.location) out.push({ kind: 'kv', label: 'Location', value: cv.location });
if (cv.status) out.push({ kind: 'kv', label: 'Status', value: txt(cv.status).toUpperCase() });
if (cv.preferences) out.push({ kind: 'kv', label: 'Preferences', value: txt(cv.preferences).toUpperCase() });
if (cv.languages) out.push({ kind: 'kv', label: 'Languages', value: cv.languages });
if (txt(cv.description).trim()) {
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: 'DESCRIPTION' });
out.push({ kind: 'text', text: cv.description });
}
const blocks = [
['PERSONAL', cv.personalExperiences, cv.personalSkills],
['EDUCATION', cv.educationExperiences, cv.educationalSkills],
['PROFESSIONAL', cv.professionalExperiences, cv.professionalSkills],
['OASIS', cv.oasisExperiences, cv.oasisSkills]
];
for (const [heading, experiences, skills] of blocks) {
const skillList = asList(skills).filter(Boolean);
const body = txt(experiences).trim();
if (!body && !skillList.length) continue;
out.push({ kind: 'blank' });
out.push({ kind: 'section', text: heading });
if (body) out.push({ kind: 'text', text: body });
if (skillList.length) out.push({ kind: 'kv', label: 'Skills', value: skillList.join(', ') });
}
pushMeta(out, cv);
return out;
};
const BUILDERS = {
reports: { title: 'OASIS - Report', sections: reportSections, name: item => item.title },
votes: { title: 'OASIS - Votation', sections: voteSections, name: item => item.question },
events: { title: 'OASIS - Event', sections: eventSections, name: item => item.title },
tasks: { title: 'OASIS - Task', sections: taskSections, name: item => item.title },
calendars: { title: 'OASIS - Calendar', sections: calendarSections, name: item => item.title },
cv: { title: 'OASIS - Curriculum', sections: cvSections, name: item => item.name || item.author }
};
const isSupported = kind => Object.prototype.hasOwnProperty.call(BUILDERS, kind);
const pdfFilename = (kind, item) => {
const b = BUILDERS[kind];
const raw = b ? txt(b.name(item || {})) : '';
const slug = raw.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40);
return `oasis-${kind}-${slug || 'document'}.pdf`;
};
const buildContentPdf = (kind, item, extra = {}, viewerId = null) => {
const b = BUILDERS[kind];
if (!b) throw new Error('Unsupported pdf kind');
return buildDocumentPdf({
title: b.title,
issuedToLabel: 'Issued to',
issuedTo: viewerId || null,
sections: b.sections(item || {}, extra)
});
};
module.exports = { buildContentPdf, pdfFilename, isSupported };

View file

@ -13,6 +13,7 @@ if (!fs.existsSync(configFilePath)) {
},
"modules": {
"blogsMod": "on",
"housingMod": "off",
"karvanMod": "on",
"popularMod": "on",
"topicsMod": "on",

View file

@ -53,7 +53,8 @@
"chatsMod": "on",
"torrentsMod": "off",
"graphosMod": "on",
"larpMod": "on"
"larpMod": "on",
"housingMod": "off"
},
"wallet": {
"url": "http://localhost:7474",

554
src/models/housing_model.js Normal file
View file

@ -0,0 +1,554 @@
const pull = require("../server/node_modules/pull-stream")
const moment = require("../server/node_modules/moment")
const categories = require("../backend/opinion_categories")
const { getConfig } = require("../configs/config-manager.js")
const { dedupeByPreferring } = require('../backend/dedupe')
const logLimit = getConfig().ssbLogStream?.limit || 1000
const HOUSING_TYPES = ["sale", "rent", "couchsurfing"]
const PROPERTY_TYPES = ["apartment", "house", "room", "land", "other"]
const norm = (s) => String(s || "").trim().toLowerCase()
const safeArr = (v) => (Array.isArray(v) ? v : [])
const toNum = (v) => {
const n = parseFloat(String(v ?? "").replace(",", "."))
return Number.isFinite(n) ? n : NaN
}
const toInt = (v, fallback = 0) => {
const n = parseInt(String(v ?? ""), 10)
return Number.isFinite(n) ? n : fallback
}
const nonNeg = (v) => {
const n = toNum(v)
return Number.isFinite(n) && n >= 0 ? n : 0
}
const safeDate = (v) => {
const s = String(v || "").trim()
if (!s) return ""
const d = new Date(s)
return Number.isFinite(d.getTime()) ? s : ""
}
const normalizeTags = (raw) => {
if (raw === undefined || raw === null) return []
if (Array.isArray(raw)) return raw.map(t => String(t || "").trim()).filter(Boolean)
return String(raw).split(",").map(t => t.trim()).filter(Boolean)
}
const safeType = (v) => {
const t = norm(v)
return HOUSING_TYPES.includes(t) ? t : ""
}
const safeProperty = (v) => {
const t = norm(v)
return PROPERTY_TYPES.includes(t) ? t : "other"
}
const { MAX_IMAGES, normalizeImages, normalizeVideo } = require('./media_gallery')
const matchSearch = (item, q) => {
const qq = norm(q)
if (!qq) return true
const hay = [
item.title,
item.description,
item.place,
item.rules,
item.housing_type,
item.property_type,
safeArr(item.tags).join(" ")
].map(x => norm(x)).join(" ")
return hay.includes(qq)
}
module.exports = ({ cooler, tribeCrypto }) => {
let ssb
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb }
const isEncrypted = (c) => !!(c && c.encryptedPayload)
const keysForRoot = (rootId) => {
if (!tribeCrypto || !rootId) return []
const ks = (tribeCrypto.getKeys && tribeCrypto.getKeys(rootId)) || []
if (ks.length) return ks
const k = tribeCrypto.getKey ? tribeCrypto.getKey(rootId) : null
return k ? [k] : []
}
const decryptHousing = (c, rootId) => {
if (!isEncrypted(c)) return c
if (!tribeCrypto) return { ...c, _undecryptable: true }
const keys = keysForRoot(rootId)
if (!keys.length) return { ...c, _undecryptable: true }
return tribeCrypto.decryptContent(c, keys.map(k => [k]))
}
const publishHousing = (ssbClient, content, rootId) => new Promise((resolve, reject) => {
const hidden = String(content.visibility || "PUBLIC").toUpperCase() === "HIDDEN"
if (!hidden || !tribeCrypto) {
ssbClient.publish(content, (err, msg) => {
if (err) return reject(err)
if (msg && msg.key && tribeCrypto && !rootId) {
try { tribeCrypto.setKey(msg.key, tribeCrypto.generateTribeKey(), 1) } catch (_) {}
}
resolve(msg)
})
return
}
let key = rootId ? (keysForRoot(rootId)[0] || null) : null
if (!key) key = tribeCrypto.generateTribeKey()
let envelope
try { envelope = tribeCrypto.encryptContent(content, [key], true) } catch (err) { return reject(err) }
ssbClient.publish(envelope, (err, msg) => {
if (err) return reject(err)
try { tribeCrypto.setKey(rootId || (msg && msg.key), key, 1) } catch (_) {}
resolve(msg)
})
})
const readAll = async (ssbClient) =>
new Promise((resolve, reject) =>
pull(
ssbClient.createLogStream({ limit: logLimit }),
pull.collect((err, msgs) => err ? reject(err) : resolve(msgs))
)
)
const buildIndex = (messages, ssbClient) => {
const tomb = new Set()
const nodes = new Map()
const parent = new Map()
const child = new Map()
const naiveReplaces = new Map()
const requestLatest = new Map()
const opinionMsgs = []
for (const m of messages) {
const key = m.key
const v = m.value || {}
const c = v.content
if (!c || typeof c !== "object") continue
if (c.type === "tombstone" && c.target) { tomb.add(c.target); continue }
if (c.type === "housing") {
nodes.set(key, { key, ts: v.timestamp || m.timestamp || 0, c, author: v.author })
if (c.replaces) naiveReplaces.set(key, c.replaces)
continue
}
if (c.type === "housingOpinion" && c.target) {
opinionMsgs.push({ target: c.target, author: v.author, category: c.category })
continue
}
if (c.type === "housingRequest" && c.housingId) {
const author = v.author
if (!author) continue
const ts = v.timestamp || m.timestamp || 0
const k = `${c.housingId}::${author}`
const prev = requestLatest.get(k)
if (!prev || ts >= prev.ts) requestLatest.set(k, { ts, value: !!c.value, author, housingId: c.housingId })
continue
}
}
for (const [key, replacesId] of naiveReplaces.entries()) {
const node = nodes.get(key)
if (!node) continue
const orig = nodes.get(replacesId)
if (!orig) continue
if (String(orig.author) !== String(node.author)) { nodes.delete(key); continue }
parent.set(key, replacesId)
child.set(replacesId, key)
}
const rootOf = (id) => {
let cur = id, guard = 0
while (parent.has(cur) && guard++ < 100000) cur = parent.get(cur)
return cur
}
const tipOf = (id) => {
let cur = id, guard = 0
while (child.has(cur) && guard++ < 100000) cur = child.get(cur)
return cur
}
const roots = new Set()
for (const id of nodes.keys()) roots.add(rootOf(id))
const tipByRoot = new Map()
for (const r of roots) tipByRoot.set(r, tipOf(r))
if (ssbClient) {
for (const m of messages) {
if (typeof m.value?.content !== 'string') continue
try {
const dec = ssbClient.private.unbox({ key: m.key, value: m.value, timestamp: m.value?.timestamp || m.timestamp || 0 })
const c = dec?.value?.content
if (!c || c.type !== 'housingRequest' || !c.housingId) continue
const author = dec.value.author
if (!author) continue
const ts = dec.value.timestamp || m.timestamp || 0
const k = `${c.housingId}::${author}`
const prev = requestLatest.get(k)
if (!prev || ts >= prev.ts) requestLatest.set(k, { ts, value: !!c.value, author, housingId: c.housingId })
} catch {}
}
}
const requestsByRoot = new Map()
const everRequestedByRoot = new Map()
for (const { housingId, author, value } of requestLatest.values()) {
if (!requestsByRoot.has(housingId)) requestsByRoot.set(housingId, new Set())
if (!everRequestedByRoot.has(housingId)) everRequestedByRoot.set(housingId, new Set())
everRequestedByRoot.get(housingId).add(author)
const set = requestsByRoot.get(housingId)
if (value) set.add(author)
else set.delete(author)
}
const opinionsByRoot = new Map()
for (const op of opinionMsgs) {
if (!nodes.has(op.target)) continue
const r = rootOf(op.target)
if (!opinionsByRoot.has(r)) opinionsByRoot.set(r, [])
opinionsByRoot.get(r).push(op)
}
const aggregateFor = (rootId, content, ownerId) => {
const opinions = { ...((content && content.opinions) || {}) }
const voters = safeArr(content && content.opinions_inhabitants).slice()
const voterSet = new Set(voters)
for (const op of (opinionsByRoot.get(rootId) || [])) {
if (!op.author || op.author === ownerId) continue
if (voterSet.has(op.author)) continue
if (!categories.includes(op.category)) continue
voterSet.add(op.author); voters.push(op.author)
opinions[op.category] = (opinions[op.category] || 0) + 1
}
return { opinions, opinions_inhabitants: voters }
}
return { tomb, nodes, parent, child, rootOf, tipOf, tipByRoot, requestsByRoot, everRequestedByRoot, aggregateFor }
}
const buildObject = (node, rootId, idx, viewer) => {
const raw = node.c || {}
const c = decryptHousing(raw, rootId)
if (c._undecryptable) return null
const author = node.author || c.author
const requests = Array.from(idx.requestsByRoot.get(rootId) || [])
const visibleRequests = (author === viewer) ? requests : (requests.includes(viewer) ? [viewer] : [])
const agg = idx.aggregateFor(rootId, c, author)
return {
id: node.key,
rootId,
housing_type: safeType(c.housing_type),
property_type: safeProperty(c.property_type),
title: String(c.title || ""),
description: String(c.description || ""),
rules: String(c.rules || ""),
place: String(c.place || ""),
mapUrl: String(c.mapUrl || ""),
price: Number.isFinite(toNum(c.price)) ? toNum(c.price).toFixed(6) : "0.000000",
rooms: toInt(c.rooms, 0),
size: nonNeg(c.size),
capacity: toInt(c.capacity, 0),
availableFrom: safeDate(c.availableFrom),
availableTo: safeDate(c.availableTo),
images: normalizeImages(c.images && c.images.length ? c.images : c.image),
image: normalizeImages(c.images && c.images.length ? c.images : c.image)[0] || null,
video: normalizeVideo(c.video),
author,
createdAt: c.createdAt || new Date(node.ts).toISOString(),
updatedAt: c.updatedAt || null,
status: String(c.status || "OPEN").toUpperCase() === "CLOSED" ? "CLOSED" : "OPEN",
tags: Array.isArray(c.tags) ? c.tags : normalizeTags(c.tags),
requests: visibleRequests,
requestCount: requests.length,
opinions: agg.opinions,
opinions_inhabitants: agg.opinions_inhabitants,
requestedByViewer: requests.includes(viewer),
everRequestedByViewer: (idx.everRequestedByRoot.get(rootId) || new Set()).has(viewer),
ratedByViewer: agg.opinions_inhabitants.includes(viewer),
visibility: String(c.visibility || "PUBLIC").toUpperCase() === "HIDDEN" ? "HIDDEN" : "PUBLIC"
}
}
const buildContent = (data, opts = {}) => {
const housing_type = safeType(data.housing_type)
if (!housing_type) throw new Error("Invalid housing type")
const title = String(data.title || "").trim()
if (!title) throw new Error("Invalid title")
const description = String(data.description || "").trim()
if (!description) throw new Error("Invalid description")
const availableFrom = safeDate(data.availableFrom)
const availableTo = safeDate(data.availableTo)
if (!availableFrom) throw new Error("The start date is required")
if (opts.enforceFutureStart) {
const [y, m, d] = String(availableFrom).split("-").map(Number)
const startsAt = new Date(y, (m || 1) - 1, d || 1)
const today = new Date()
today.setHours(0, 0, 0, 0)
if (startsAt < today) throw new Error("The start date cannot be earlier than today")
}
if (availableTo && new Date(availableTo) < new Date(availableFrom)) {
throw new Error("The end date cannot be earlier than the start date")
}
const images = normalizeImages(data.images !== undefined ? data.images : data.image)
const video = normalizeVideo(data.video)
return {
type: "housing",
housing_type,
property_type: safeProperty(data.property_type),
title,
description,
rules: String(data.rules || "").trim(),
place: String(data.place || "").trim(),
mapUrl: String(data.mapUrl || "").trim(),
price: housing_type === "couchsurfing" ? "0.000000" : nonNeg(data.price).toFixed(6),
rooms: Math.max(0, toInt(data.rooms, 0)),
size: nonNeg(data.size),
capacity: Math.max(0, toInt(data.capacity, 0)),
availableFrom,
availableTo,
images,
image: images[0] || null,
video,
tags: normalizeTags(data.tags),
visibility: String(data.visibility || "PUBLIC").toUpperCase() === "HIDDEN" ? "HIDDEN" : "PUBLIC"
}
}
return {
type: "housing",
HOUSING_TYPES,
PROPERTY_TYPES,
MAX_IMAGES,
async createHousing(data) {
const ssbClient = await openSsb()
const content = {
...buildContent(data, { enforceFutureStart: true }),
author: ssbClient.id,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
status: "OPEN",
opinions: {},
opinions_inhabitants: []
}
return publishHousing(ssbClient, content, null)
},
async resolveCurrentId(id) {
const ssbClient = await openSsb()
const idx = buildIndex(await readAll(ssbClient), ssbClient)
const tip = idx.tipOf(id)
if (idx.tomb.has(tip)) throw new Error("Housing not found")
return tip
},
async resolveRootId(id) {
const ssbClient = await openSsb()
const idx = buildIndex(await readAll(ssbClient), ssbClient)
const tip = idx.tipOf(id)
if (idx.tomb.has(tip)) throw new Error("Housing not found")
return idx.rootOf(tip)
},
async updateHousing(id, data) {
const ssbClient = await openSsb()
const idx = buildIndex(await readAll(ssbClient), ssbClient)
const tipId = idx.tipOf(id)
if (idx.tomb.has(tipId)) throw new Error("Housing not found")
const node = idx.nodes.get(tipId)
if (!node || !node.c) throw new Error("Housing not found")
if ((node.author || node.c.author) !== ssbClient.id) throw new Error("Unauthorized")
const rootId = idx.rootOf(tipId)
const current = decryptHousing(node.c, rootId)
if (current._undecryptable) throw new Error("Cannot decrypt this listing")
const startChanged = data.availableFrom !== undefined && safeDate(data.availableFrom) !== safeDate(current.availableFrom)
const merged = buildContent({
housing_type: data.housing_type === undefined ? current.housing_type : data.housing_type,
property_type: data.property_type === undefined ? current.property_type : data.property_type,
title: data.title === undefined ? current.title : data.title,
description: data.description === undefined ? current.description : data.description,
rules: data.rules === undefined ? current.rules : data.rules,
place: data.place === undefined ? current.place : data.place,
mapUrl: data.mapUrl === undefined ? current.mapUrl : data.mapUrl,
price: data.price === undefined ? current.price : data.price,
rooms: data.rooms === undefined ? current.rooms : data.rooms,
size: data.size === undefined ? current.size : data.size,
capacity: data.capacity === undefined ? current.capacity : data.capacity,
availableFrom: data.availableFrom === undefined ? current.availableFrom : data.availableFrom,
availableTo: data.availableTo === undefined ? current.availableTo : data.availableTo,
images: data.images === undefined
? (current.images && current.images.length ? current.images : current.image)
: data.images,
video: data.video === undefined ? current.video : data.video,
tags: data.tags === undefined ? current.tags : data.tags,
visibility: data.visibility === undefined ? current.visibility : data.visibility
}, { enforceFutureStart: startChanged })
let status = String(current.status || "OPEN").toUpperCase()
if (data.status !== undefined) {
const s = String(data.status || "").toUpperCase()
if (!["OPEN", "CLOSED"].includes(s)) throw new Error("Invalid status")
status = s
}
const next = {
...merged,
status,
author: current.author,
createdAt: current.createdAt,
updatedAt: new Date().toISOString(),
opinions: current.opinions || {},
opinions_inhabitants: safeArr(current.opinions_inhabitants),
replaces: tipId
}
const tomb = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: ssbClient.id }
await new Promise((res, rej) => ssbClient.publish(tomb, (e) => e ? rej(e) : res()))
return publishHousing(ssbClient, next, rootId)
},
async updateHousingStatus(id, status) {
return this.updateHousing(id, { status: String(status || "").toUpperCase() })
},
async deleteHousing(id) {
const ssbClient = await openSsb()
const idx = buildIndex(await readAll(ssbClient), ssbClient)
const tipId = idx.tipOf(id)
if (idx.tomb.has(tipId)) throw new Error("Housing not found")
const node = idx.nodes.get(tipId)
if (!node) throw new Error("Housing not found")
if ((node.author || node.c.author) !== ssbClient.id) throw new Error("Unauthorized")
const tomb = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: ssbClient.id }
return new Promise((res, rej) => ssbClient.publish(tomb, (e, r) => e ? rej(e) : res(r)))
},
async requestHousing(id) {
const ssbClient = await openSsb()
const me = ssbClient.id
const item = await this.getHousingById(id)
if (item.author === me) throw new Error("Cannot request your own listing")
if (item.status !== "OPEN") throw new Error("This listing is closed")
if (safeArr(item.requests).includes(me)) return { alreadyRequested: true }
const msg = { type: "housingRequest", housingId: item.rootId, value: true, createdAt: new Date().toISOString() }
return new Promise((res, rej) => ssbClient.private.publish(msg, [me, item.author], (e, m) => e ? rej(e) : res(m)))
},
async cancelRequest(id) {
const ssbClient = await openSsb()
const me = ssbClient.id
const item = await this.getHousingById(id)
if (item.author === me) throw new Error("Cannot cancel a request on your own listing")
if (!safeArr(item.requests).includes(me)) return { notRequested: true }
const msg = { type: "housingRequest", housingId: item.rootId, value: false, createdAt: new Date().toISOString() }
return new Promise((res, rej) => ssbClient.private.publish(msg, [me, item.author], (e, m) => e ? rej(e) : res(m)))
},
async createOpinion(id, category) {
if (!categories.includes(category)) throw new Error("Invalid category")
const ssbClient = await openSsb()
const me = ssbClient.id
const idx = buildIndex(await readAll(ssbClient), ssbClient)
const tipId = idx.tipOf(id)
if (idx.tomb.has(tipId)) throw new Error("Housing not found")
const node = idx.nodes.get(tipId)
if (!node) throw new Error("Housing not found")
const rootId = idx.rootOf(tipId)
const owner = node.author || node.c.author
if (owner === me) throw new Error("You cannot rate your own listing")
const agg = idx.aggregateFor(rootId, decryptHousing(node.c, rootId), owner)
if (agg.opinions_inhabitants.includes(me)) throw new Error("Already voted")
const everRequested = idx.everRequestedByRoot.get(rootId) || new Set()
if (!everRequested.has(me)) throw new Error("You can rate only a place you have requested")
const content = { type: "housingOpinion", target: rootId, category, createdAt: new Date().toISOString() }
return new Promise((res, rej) => ssbClient.publish(content, (e, m) => e ? rej(e) : res(m)))
},
async listHousing(filter = "ALL", viewerId = null, query = {}) {
const ssbClient = await openSsb()
const viewer = viewerId || ssbClient.id
const idx = buildIndex(await readAll(ssbClient), ssbClient)
const items = []
for (const [rootId, tipId] of idx.tipByRoot.entries()) {
if (idx.tomb.has(tipId)) continue
const node = idx.nodes.get(tipId)
if (!node) continue
const item = buildObject(node, rootId, idx, viewer)
if (!item) continue
if (item.visibility === "HIDDEN" && item.author !== viewer) continue
items.push(item)
}
const F = String(filter || "ALL").toUpperCase()
let list = dedupeByPreferring(
items,
(h) => (h.author && h.createdAt) ? [norm(h.author), norm(h.createdAt), norm(h.title)].join("|") : null,
(h) => h.requestCount
)
if (F === "MINE") list = list.filter(h => h.author === viewer)
else if (F === "REQUESTED") list = list.filter(h => safeArr(h.requests).includes(viewer))
else if (HOUSING_TYPES.includes(norm(F))) list = list.filter(h => h.housing_type === norm(F))
else if (F === "OPEN") list = list.filter(h => h.status === "OPEN")
else if (F === "CLOSED") list = list.filter(h => h.status === "CLOSED")
else if (F === "RECENT") list = list.filter(h => moment(h.createdAt).isAfter(moment().subtract(24, "hours")))
const search = String(query.search || query.q || "").trim()
if (search) list = list.filter(h => matchSearch(h, search))
const minP = toNum(query.minPrice)
const maxP = toNum(query.maxPrice)
if (Number.isFinite(minP)) list = list.filter(h => toNum(h.price) >= minP)
if (Number.isFinite(maxP)) list = list.filter(h => toNum(h.price) <= maxP)
const place = String(query.place || "").trim()
if (place) list = list.filter(h => norm(h.place).includes(norm(place)))
const ratingOf = (h) => safeArr(h.opinions_inhabitants).length
const sort = String(query.sort || "").trim()
if (F === "TOP" || sort === "rating") list.sort((a, b) => ratingOf(b) - ratingOf(a))
else if (sort === "price") list.sort((a, b) => toNum(a.price) - toNum(b.price))
else if (sort === "requests") list.sort((a, b) => b.requestCount - a.requestCount)
else list.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
return list
},
async getHousingById(id, viewerId = null) {
const ssbClient = await openSsb()
const viewer = viewerId || ssbClient.id
const idx = buildIndex(await readAll(ssbClient), ssbClient)
const tipId = idx.tipOf(id)
if (idx.tomb.has(tipId)) throw new Error("Housing not found")
const node = idx.nodes.get(tipId)
if (!node) throw new Error("Housing not found")
const rootId = idx.rootOf(tipId)
const item = buildObject(node, rootId, idx, viewer)
if (!item) throw new Error("Housing not found")
if (item.visibility === "HIDDEN" && item.author !== viewer) throw new Error("Housing not found")
return item
}
}
}

View file

@ -7,7 +7,7 @@
// cada integracion. Aqui solo depende de i18n, que se inyecta porque main_views
// lo muta en cada peticion sin reasignarlo.
const { a, div, img, input, label, nav, span } = require("../../server/node_modules/hyperaxe");
const { a, button, div, form, h2, h3, img, input, label, nav, p, section, span } = require("../../server/node_modules/hyperaxe");
const { getConfig } = require("../../configs/config-manager.js");
const sharedState = require("../../configs/shared-state");
@ -180,5 +180,59 @@ module.exports = ({ i18n }) => {
return div({ class: 'hive-nav' }, ...radios, grid, ...panels);
};
return { renderMobileTopbar, renderBottomBar, renderHiveNav, PINNABLE_MODULES };
// pantalla para personalizar la barra inferior (/settings/bottombar)
const bottomBarPickerView = (template) => {
const cfg = getConfig();
const modOn = (m) => !m || cfg.modules[m] === 'on' || cfg.modules[m] === undefined;
const MAX = 4;
const pinnedKeys = cfg.bottomBarPins || [];
const pinned = pinnedKeys
.map(key => PINNABLE_MODULES.find(m => m.key === key))
.filter(Boolean);
const full = pinned.length >= MAX;
const actionForm = (action, key, cls, node) => form({ method: 'POST', action: '/settings/bottombar', class: cls },
input({ type: 'hidden', name: 'action', value: action }),
input({ type: 'hidden', name: 'key', value: key }),
node
);
const pinnedRow = (m) => actionForm('remove', m.key, 'bb-manage-form',
button({ type: 'submit', class: 'bb-manage-btn', 'aria-label': i18n.bbRemove || 'Remove' },
span({ class: 'bb-manage-ico' }, m.glyph),
span({ class: 'bb-manage-lbl' }, i18n[m.labelKey] || m.key),
span({ class: 'bb-manage-x' }, '✕')
)
);
const addBtn = (m) => actionForm('add', m.key, 'bb-pick-form',
button({ type: 'submit', class: 'bb-pick-btn' },
span({ class: 'bb-pick-ico' }, m.glyph),
span({ class: 'bb-pick-lbl' }, i18n[m.labelKey] || m.key)
)
);
const addable = PINNABLE_MODULES.filter(m => modOn(m.mod) && !pinnedKeys.includes(m.key));
return template(
i18n.bbManage || 'Edit bar',
section(
div({ class: 'tags-header' },
h2(i18n.bbPickTitle || 'Customize the bar'),
p(i18n.bbPickDesc || 'Choose up to 4 shortcuts for the bottom bar.')
),
div({ class: 'bb-manage' },
h3(`${i18n.bbYourBar || 'Your bar'} (${pinned.length}/${MAX})`),
pinned.length
? div({ class: 'bb-manage-list' }, ...pinned.map(pinnedRow))
: p({ class: 'bb-manage-empty' }, i18n.bbPickNone || 'None')
),
div({ class: 'bb-manage-add' },
h3(i18n.bbAdd || 'Add'),
full
? p({ class: 'bb-manage-full' }, i18n.bbFull || 'Maximum reached')
: div({ class: 'bb-pick-grid' }, ...addable.map(addBtn))
),
a({ class: 'filter-btn bb-manage-done', href: '/activity' }, i18n.bbDone || 'Done')
)
);
};
return { renderMobileTopbar, renderBottomBar, renderHiveNav, PINNABLE_MODULES, bottomBarPickerView };
};

496
src/views/housing_view.js Normal file
View file

@ -0,0 +1,496 @@
const { form, button, div, h2, p, section, input, label, textarea, br, a, span, select, option, img, video, table, tr, td } = require("../server/node_modules/hyperaxe")
const { renderCommentsSection: renderSharedCommentsSection } = require("./comments_view");
const { template, i18n, userLink, renderOpenClosedChip, renderStateChip, renderVisibilityChip, renderLifespanChip, renderEcoTax, renderSpreadButton, renderContentActions, renderOpinionsVoting, renderEngagement, renderSpreadEditWarning } = require("./main_views")
const { blobUrl, blobIdOf, isVideoEntry, imagesOf, renderMediaThumb, renderPhotoGallery, renderGalleryFields } = require("./gallery_view")
const moment = require("../server/node_modules/moment")
const { config } = require("../server/SSB_server.js")
const { renderUrl } = require("../backend/renderUrl")
const opinionCategories = require("../backend/opinion_categories")
const { renderMapEmbed, renderMapLocationVisitLabel } = require("./maps_view")
const userId = config.keys.id
const FILTERS = [
{ key: "ALL", i18n: "housingFilterAll" },
{ key: "RECENT", i18n: "housingFilterRecent" },
{ key: "MINE", i18n: "housingFilterMine" },
{ key: "TOP", i18n: "housingFilterTop" },
{ key: "REQUESTED", i18n: "housingFilterRequested" },
{ key: "SALE", i18n: "housingFilterSale" },
{ key: "RENT", i18n: "housingFilterRent" },
{ key: "COUCHSURFING", i18n: "housingFilterCouchsurfing" },
{ key: "OPEN", i18n: "housingFilterOpen" },
{ key: "CLOSED", i18n: "housingFilterClosed" }
]
const MAX_IMAGES = 8
const safeArr = (v) => (Array.isArray(v) ? v : [])
const safeText = (v) => String(v || "").trim()
const parseNum = (v) => {
const n = parseFloat(String(v ?? "").replace(",", "."))
return Number.isFinite(n) ? n : NaN
}
const fmtPrice = (v) => {
const n = parseNum(v)
return Number.isFinite(n) ? n.toFixed(2) : String(v ?? "")
}
const priceLabel = (item) => {
if (item.housing_type === "couchsurfing") return i18n.housingFree || "FREE"
const suffix = item.housing_type === "rent" ? ` ${i18n.housingPerMonth || "/month"}` : ""
return `${fmtPrice(item.price)} ECO${suffix}`
}
const sumCats = (opinions = {}, cats = []) => (cats || []).reduce((s, c) => s + (Number((opinions || {})[c]) || 0), 0)
const renderStarRating = (opinions, voterCount) => {
const pos = sumCats(opinions, opinionCategories.positive)
const neg = sumCats(opinions, opinionCategories.constructive) + sumCats(opinions, opinionCategories.moderation)
const total = pos + neg
const full = total > 0 ? Math.round((pos / total) * 5) : 0
const stars = "★".repeat(full) + "☆".repeat(5 - full)
return span({ class: "housing-stars" }, `${stars} (${voterCount})`)
}
const buildReturnTo = (filter, params = {}) => {
const parts = [`filter=${encodeURIComponent(safeText(filter || "ALL"))}`]
const q = safeText(params.search || "")
if (q) parts.push(`search=${encodeURIComponent(q)}`)
if (String(params.minPrice ?? "") !== "") parts.push(`minPrice=${encodeURIComponent(String(params.minPrice))}`)
if (String(params.maxPrice ?? "") !== "") parts.push(`maxPrice=${encodeURIComponent(String(params.maxPrice))}`)
if (safeText(params.place)) parts.push(`place=${encodeURIComponent(safeText(params.place))}`)
if (safeText(params.sort)) parts.push(`sort=${encodeURIComponent(safeText(params.sort))}`)
return `/housing?${parts.join("&")}`
}
const renderTags = (tags = []) => {
const arr = safeArr(tags).map(t => String(t || "").trim()).filter(Boolean)
return arr.length
? div({ class: "card-tags" }, arr.map(tag => a({ class: "tag-link", href: `/search?query=%23${encodeURIComponent(tag)}` }, `#${tag}`)))
: null
}
const renderTypeChip = (item) => {
const t = String(item.housing_type || "").toUpperCase()
const emoji = item.housing_type === "sale" ? "🏷" : item.housing_type === "rent" ? "🔑" : "🛋"
return renderStateChip("whole", emoji, i18n["housingType" + t] || t)
}
const renderStatusChip = (status) => {
const s = String(status || "").toUpperCase()
return renderOpenClosedChip(s, {
statusChipOPEN: i18n.housingStatusOPEN || "OPEN",
statusChipCLOSED: i18n.housingStatusCLOSED || "CLOSED"
})
}
const renderRequestedChip = () => renderStateChip("whole", "★", i18n.housingRequestedBadge || "REQUESTED")
const renderInfoTable = (item) => {
const rows = []
const pushRow = (labelText, valueNode, valueClass) =>
rows.push(tr(
td({ class: "tribe-info-label" }, labelText),
td({ class: `tribe-info-value${valueClass ? " " + valueClass : ""}` }, valueNode)
))
pushRow(i18n.housingProperty, i18n["housingProperty" + String(item.property_type || "").toUpperCase()] || "—")
pushRow(i18n.housingPrice, priceLabel(item), "card-salary")
if (item.rooms > 0) pushRow(i18n.housingRooms, String(item.rooms))
if (item.size > 0) pushRow(i18n.housingSize, `${item.size}`)
if (item.capacity > 0) pushRow(i18n.housingCapacity, String(item.capacity))
if (item.availableFrom) pushRow(i18n.housingAvailableFrom, moment(item.availableFrom).format("YYYY/MM/DD"))
if (item.availableTo) pushRow(i18n.housingAvailableTo, moment(item.availableTo).format("YYYY/MM/DD"))
if (safeText(item.place)) pushRow(i18n.housingPlace, safeText(item.place))
if (item.mapUrl) {
const mapNode = renderMapLocationVisitLabel(item.mapUrl)
if (mapNode) pushRow(i18n.mapLocationTitle || "Map", mapNode)
}
return table({ class: "tribe-info-table housing-info-table" }, ...rows)
}
const today = () => moment().format("YYYY-MM-DD")
const renderStatusRow = (item, returnTo) => {
if (String(item.author) !== String(userId)) return null
const isOpen = item.status === "OPEN"
return div({ class: "tribe-side-actions housing-status-row" },
span({ class: "card-label" }, `${i18n.housingStatus}: `),
renderStatusChip(item.status),
form({ method: "POST", action: `/housing/status/${encodeURIComponent(item.id)}`, class: "inline-form" },
input({ type: "hidden", name: "returnTo", value: returnTo }),
button({ class: "tribe-action-btn", type: "submit", name: "status", value: isOpen ? "CLOSED" : "OPEN" },
isOpen ? (i18n.housingSetClosed || "Close") : (i18n.housingSetOpen || "Reopen"))
)
)
}
const renderOwnerActions = (item, returnTo) => {
if (String(item.author) !== String(userId)) return []
return [
form({ method: "GET", action: `/housing/edit/${encodeURIComponent(item.id)}` },
input({ type: "hidden", name: "returnTo", value: returnTo }),
button({ class: "update-btn", type: "submit" }, i18n.housingUpdateButton)
),
form({ method: "POST", action: `/housing/delete/${encodeURIComponent(item.id)}` },
input({ type: "hidden", name: "returnTo", value: returnTo }),
button({ class: "delete-btn", type: "submit" }, i18n.housingDeleteButton)
)
]
}
const renderRequestToggle = (item, returnTo) => {
if (String(item.author) === String(userId)) return null
if (item.status !== "OPEN") return null
const requested = safeArr(item.requests).includes(userId)
return requested
? form({ method: "POST", action: `/housing/cancel/${encodeURIComponent(item.id)}` },
input({ type: "hidden", name: "returnTo", value: returnTo }),
button({ type: "submit", class: "filter-btn" }, i18n.housingCancelButton)
)
: form({ method: "POST", action: `/housing/request/${encodeURIComponent(item.id)}` },
input({ type: "hidden", name: "returnTo", value: returnTo }),
button({ type: "submit", class: "filter-btn" }, i18n.housingRequestButton)
)
}
const renderHousingList = (items, filter, params = {}) => {
const list = safeArr(items)
if (!list.length) return p(i18n.housingNoItems)
return div({ class: "housing-grid" },
list.map((item) => {
const clip = safeText(item.video)
const cover = imagesOf(item)[0]
const isOwn = item.author && String(item.author) === String(userId)
const requested = safeArr(item.requests).includes(userId)
const chips = [
renderTypeChip(item),
renderStatusChip(item.status),
item.visibility === "HIDDEN" ? renderVisibilityChip("HIDDEN", i18n) : null,
requested ? renderRequestedChip() : null,
renderLifespanChip(item.lifetime, i18n)
].filter(Boolean)
return div({ class: "trending-card housing-card" + (isOwn ? " own-content" : "") },
div({ class: "card-header activity-card-header" },
span(),
renderContentActions(item.id, `/housing/${encodeURIComponent(item.id)}`, { spread: params.spreadMap && params.spreadMap.get(item.id) || null, author: item.author, favKind: 'housing', isFavorite: item.isFavorite, reportTitle: item.title })
),
div({ class: "card-section housing-card-body" },
clip || (cover && isVideoEntry(cover))
? div({ class: "tribe-card-image-wrapper housing-card-video" },
video({ controls: true, class: "housing-card-hero-video", src: blobUrl(blobIdOf(clip || cover)) })
)
: cover
? div({ class: "tribe-card-image-wrapper" },
a({ href: `/housing/${encodeURIComponent(item.id)}` },
img({ src: blobUrl(blobIdOf(cover), 256), class: "tribe-card-hero-image", alt: "" })
)
)
: null,
div({ class: "tribe-card-body" },
div({ class: "shop-title-row" },
h2({ class: "tribe-card-title" },
a({ href: `/housing/${encodeURIComponent(item.id)}` }, safeText(item.title) || i18n.housingTitle)
)
),
chips.length ? div({ class: "card-chips-row" }, ...chips) : null,
renderStarRating(item.opinions, safeArr(item.opinions_inhabitants).length),
div({ class: "card-date-highlight" }, priceLabel(item)),
safeText(item.place)
? div({ class: "card-field" },
span({ class: "card-label" }, `${i18n.housingPlace}: `),
span({ class: "card-value" }, safeText(item.place))
)
: null,
div({ class: "tribe-card-members" },
span({ class: "tribe-members-count" }, `${i18n.housingRequests}: ${item.requestCount || 0}`)
),
imagesOf(item).length > 1
? div({ class: "card-field" },
span({ class: "card-label" }, `${i18n.galleryPhotos}: `),
span({ class: "card-value" }, String(imagesOf(item).length))
)
: null
)
)
)
})
)
}
const renderHousingForm = (item = {}, mode = "create", maxImages = MAX_IMAGES, spreadWarning = null) => {
const isEdit = mode === "edit"
const type = String(item.housing_type || "").toLowerCase()
return div({ class: "div-center housing-form" },
form({
action: isEdit ? `/housing/update/${encodeURIComponent(item.id)}` : "/housing/create",
method: "POST",
enctype: "multipart/form-data"
},
input({ type: "hidden", name: "returnTo", value: "/housing?filter=MINE" }),
isEdit ? spreadWarning : null,
label(i18n.housingType),
br(),
select({ name: "housing_type", required: true },
option({ value: "sale", selected: type === "sale" ? "selected" : undefined }, i18n.housingTypeSALE),
option({ value: "rent", selected: type === "rent" ? "selected" : undefined }, i18n.housingTypeRENT),
option({ value: "couchsurfing", selected: type === "couchsurfing" ? "selected" : undefined }, i18n.housingTypeCOUCHSURFING)
),
br(),
br(),
label(i18n.housingProperty),
br(),
select({ name: "property_type" },
option({ value: "apartment", ...(item.property_type === "apartment" ? { selected: true } : {})}, i18n.housingPropertyAPARTMENT),
option({ value: "house", ...(item.property_type === "house" ? { selected: true } : {})}, i18n.housingPropertyHOUSE),
option({ value: "room", ...(item.property_type === "room" ? { selected: true } : {})}, i18n.housingPropertyROOM),
option({ value: "land", ...(item.property_type === "land" ? { selected: true } : {})}, i18n.housingPropertyLAND),
option({ value: "other", ...(item.property_type === "other" ? { selected: true } : {})}, i18n.housingPropertyOTHER)
),
br(),
br(),
label(i18n.housingTitleLabel),
br(),
input({ type: "text", name: "title", maxlength: "100", required: true, placeholder: i18n.housingTitlePlaceholder, value: item.title || "" }),
br(),
br(),
...renderGalleryFields(item, isEdit, maxImages),
label(i18n.housingDescription),
br(),
textarea({ name: "description", rows: "6", required: true, placeholder: i18n.housingDescriptionPlaceholder }, item.description || ""),
br(),
br(),
label(i18n.housingRules),
br(),
textarea({ name: "rules", rows: "4", placeholder: i18n.housingRulesPlaceholder }, item.rules || ""),
br(),
br(),
label(i18n.housingPlace),
br(),
input({ type: "text", name: "place", placeholder: i18n.housingPlacePlaceholder, value: item.place || "" }),
br(),
br(),
label(i18n.mapLocationTitle || "Map Location"),
br(),
input({ type: "text", name: "mapUrl", placeholder: i18n.mapUrlPlaceholder || "/maps/MAP_ID", value: item.mapUrl || "" }),
br(),
br(),
label(i18n.housingPriceLabel),
br(),
input({ type: "number", name: "price", step: "0.01", min: "0", value: item.price || "" }),
br(),
br(),
label(i18n.housingRooms),
br(),
input({ type: "number", name: "rooms", min: "0", value: item.rooms || "" }),
br(),
br(),
label(i18n.housingSizeLabel),
br(),
input({ type: "number", name: "size", step: "0.01", min: "0", value: item.size || "" }),
br(),
br(),
label(i18n.housingCapacity),
br(),
input({ type: "number", name: "capacity", min: "0", value: item.capacity || "" }),
br(),
br(),
label(i18n.housingAvailableFrom),
br(),
input({ type: "date", name: "availableFrom", required: true, min: isEdit ? undefined : today(), value: item.availableFrom ? String(item.availableFrom).slice(0, 10) : "" }),
br(),
br(),
label(i18n.housingAvailableTo),
br(),
input({ type: "date", name: "availableTo", min: item.availableFrom ? String(item.availableFrom).slice(0, 10) : today(), value: item.availableTo ? String(item.availableTo).slice(0, 10) : "" }),
br(),
br(),
label(i18n.housingTags),
br(),
input({ type: "text", name: "tags", value: Array.isArray(item.tags) ? item.tags.join(", ") : (item.tags || "") }),
br(),
br(),
label(i18n.visibilityLabel || "Visibility"),
br(),
select({ name: "visibility" },
option({ value: "PUBLIC", ...((item.visibility || "PUBLIC") === "PUBLIC" ? { selected: true } : {})}, i18n.visibilityPublic || "Public"),
option({ value: "HIDDEN", ...(item.visibility === "HIDDEN" ? { selected: true } : {})}, i18n.visibilityHidden || "Hidden")
),
br(),
br(),
button({ type: "submit" }, isEdit ? i18n.housingUpdateButton : i18n.housingCreateButton)
)
)
}
const renderFiltersBar = (filter, params = {}) =>
div({ class: "filters" },
form({ method: "GET", action: "/housing", class: "ui-toolbar ui-toolbar--filters" },
input({ type: "hidden", name: "search", value: safeText(params.search || "") }),
input({ type: "hidden", name: "minPrice", value: String(params.minPrice ?? "") }),
input({ type: "hidden", name: "maxPrice", value: String(params.maxPrice ?? "") }),
input({ type: "hidden", name: "place", value: safeText(params.place || "") }),
input({ type: "hidden", name: "sort", value: safeText(params.sort || "") }),
...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.housingCreateButton)
)
)
exports.housingView = async (items, filter = "ALL", params = {}) => {
const search = safeText(params.search || "")
const minPrice = params.minPrice ?? ""
const maxPrice = params.maxPrice ?? ""
const place = safeText(params.place || "")
const sort = safeText(params.sort || "recent")
const isForm = filter === "CREATE" || filter === "EDIT"
return template(
i18n.housingTitle,
section(
div({ class: "tags-header" },
h2(i18n.housingTitle),
p(i18n.housingDescriptionText)
),
renderFiltersBar(filter, { search, minPrice, maxPrice, place, sort })
),
section(
isForm
? renderHousingForm(filter === "EDIT" ? (Array.isArray(items) ? items[0] : items) || {} : (params.draft || {}), filter === "EDIT" ? "edit" : "create", Number(params.maxImages) > 0 ? Number(params.maxImages) : MAX_IMAGES, await renderSpreadEditWarning(filter === "EDIT" ? ((Array.isArray(items) ? items[0] : items) || {}).id : null))
: section(
div({ class: "housing-search" },
form({ method: "GET", action: "/housing", class: "filter-box" },
input({ type: "hidden", name: "filter", value: filter || "ALL" }),
input({ type: "text", name: "search", value: search, placeholder: i18n.housingSearchPlaceholder, class: "filter-box__input" }),
div({ class: "filter-box__controls" },
input({ type: "text", name: "place", value: place, placeholder: i18n.housingPlacePlaceholder, class: "filter-box__input housing-place-input" }),
div({ class: "transfer-range" },
input({ type: "number", name: "minPrice", step: "0.01", min: "0", value: String(minPrice ?? ""), placeholder: i18n.housingMinPrice, class: "filter-box__number transfer-amount-input" }),
input({ type: "number", name: "maxPrice", step: "0.01", min: "0", value: String(maxPrice ?? ""), placeholder: i18n.housingMaxPrice, class: "filter-box__number transfer-amount-input" })
),
select({ name: "sort", class: "filter-box__select" },
option({ value: "recent", ...(sort === "recent" ? { selected: true } : {})}, i18n.housingSortRecent),
option({ value: "price", ...(sort === "price" ? { selected: true } : {})}, i18n.housingSortPrice),
option({ value: "requests", ...(sort === "requests" ? { selected: true } : {})}, i18n.housingSortRequests),
option({ value: "rating", ...(sort === "rating" ? { selected: true } : {})}, i18n.housingSortRating)
),
button({ type: "submit", class: "filter-box__button" }, i18n.housingSearchButton)
)
)
),
br(),
div({ class: "housing-list" }, renderHousingList(items, filter, params))
)
)
)
}
const renderCommentsSection = (itemId, returnTo, comments = []) => {
return renderSharedCommentsSection({
action: `/housing/${encodeURIComponent(itemId)}/comments`,
comments: comments,
returnTo: returnTo
});
};
exports.singleHousingView = async (item, filter = "ALL", comments = [], params = {}) => {
const returnTo = safeText(params.returnTo) || buildReturnTo(filter, params)
const isAuthor = String(item.author) === String(userId)
const requested = safeArr(item.requests).includes(userId)
const voters = safeArr(item.opinions_inhabitants)
const chips = [
renderTypeChip(item),
renderStatusChip(item.status),
item.visibility === "HIDDEN" ? renderVisibilityChip("HIDDEN", i18n) : null,
requested ? renderRequestedChip() : null,
renderLifespanChip(item.lifetime, i18n),
renderEcoTax(item.msgSize, item.id)
].filter(Boolean)
const sideActions = []
const requestToggle = renderRequestToggle(item, returnTo)
if (requestToggle) sideActions.push(requestToggle)
const ownerActions = renderOwnerActions(item, returnTo)
const nextVisibility = item.visibility === "PUBLIC" ? "HIDDEN" : "PUBLIC"
const isHidden = item.visibility === "HIDDEN"
const visibilityRow = isAuthor
? div({ class: "tribe-side-actions shop-visibility-row housing-visibility-row" },
span({ class: "card-label" }, `${i18n.visibilityLabel || "Visibility"}: `),
isHidden
? renderStateChip("encrypted", "🔒", i18n.encryptedChipLabel || "E2E")
: renderStateChip("mutuals", "👁", i18n.visibilityPublic || "PUBLIC"),
form({ method: "POST", action: `/housing/visibility/${encodeURIComponent(item.id)}`, class: "inline-form" },
input({ type: "hidden", name: "returnTo", value: returnTo }),
input({ type: "hidden", name: "visibility", value: nextVisibility }),
button({ type: "submit", class: "tribe-action-btn" },
nextVisibility === "PUBLIC" ? (i18n.visibilityMakePublic || "Make public") : (i18n.visibilityMakeHidden || "Make hidden"))
)
)
: null
const cover = imagesOf(item)[0]
const housingSide = div({ class: "tribe-side" },
div({ class: "card-header activity-card-header" },
renderContentActions(item.id, null, { spread: params.spreads || null, author: item.author, favKind: 'housing', isFavorite: item.isFavorite, reportTitle: item.title })
),
div({ class: "shop-title-row" },
h2({ class: "tribe-card-title" }, safeText(item.title) || i18n.housingTitle)
),
chips.length ? div({ class: "card-chips-row" }, ...chips) : null,
renderStarRating(item.opinions, voters.length),
cover && !isVideoEntry(cover) ? img({ src: blobUrl(blobIdOf(cover), 256), class: "tribe-detail-image", alt: "" }) : null,
renderInfoTable(item),
div({ class: "tribe-card-members" },
span({ class: "tribe-members-count" }, `${i18n.housingRequests}: ${item.requestCount || 0}`)
),
sideActions.length ? div({ class: "tribe-side-actions" }, ...sideActions) : null,
renderStatusRow(item, returnTo),
ownerActions.length ? div({ class: "tribe-side-actions owner-actions" }, ...ownerActions) : null,
visibilityRow,
renderTags(item.tags)
)
const renderSection = (titleText, bodyText) =>
safeText(bodyText)
? div({ class: "job-section" },
h2({ class: "job-section-title" }, titleText),
p({ class: "tribe-side-description" }, ...renderUrl(bodyText))
)
: null
const housingMain = div({ class: "tribe-main" },
renderPhotoGallery(item),
renderSection(i18n.housingDescription, item.description),
renderSection(i18n.housingRules, item.rules),
item.mapUrl ? div({ class: "job-section" }, renderMapEmbed(params.mapData, item.mapUrl)) : null,
p({ class: "card-footer" },
span({ class: "date-link" }, `${moment(item.createdAt).format("YYYY/MM/DD HH:mm")} ${i18n.performed} `),
userLink(item.author)
),
renderEngagement(item.id,
!isAuthor && item.everRequestedByViewer
? renderOpinionsVoting("/housing/opinions", item.id, item.opinions, returnTo, voters)
: null,
renderCommentsSection(item.id, returnTo, comments)
)
)
return template(
i18n.housingTitle,
section(
div({ class: "tags-header" }, h2(i18n.housingTitle), p(i18n.housingDescriptionText)),
renderFiltersBar(filter, params),
div({ class: "tribe-details" }, housingSide, housingMain)
)
)
}

View file

@ -1186,6 +1186,8 @@ const renderBottomBar = forkNav.renderBottomBar;
const renderHiveNav = forkNav.renderHiveNav;
exports.PINNABLE_MODULES = forkNav.PINNABLE_MODULES;
exports.renderHiveNav = renderHiveNav;
// se resuelve al llamarse, cuando template ya esta definido mas abajo
exports.bottomBarPickerView = () => forkNav.bottomBarPickerView(template);
const template = (titlePrefix, ...elements) => {
const currentConfig = getConfig();