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:
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
280
src/backend/contentPdf.js
Normal 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 };
|
||||
|
|
@ -13,6 +13,7 @@ if (!fs.existsSync(configFilePath)) {
|
|||
},
|
||||
"modules": {
|
||||
"blogsMod": "on",
|
||||
"housingMod": "off",
|
||||
"karvanMod": "on",
|
||||
"popularMod": "on",
|
||||
"topicsMod": "on",
|
||||
|
|
|
|||
|
|
@ -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
554
src/models/housing_model.js
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
496
src/views/housing_view.js
Normal 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} m²`)
|
||||
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)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue