upstream: integrar 0.9.5 en 34 ficheros mas y portar los modulos nuevos

Segunda tanda. Resueltas por merge de tres vias 15 divergencias que no solapaban,
y aplicada la version de 0.9.5 en 19 vistas y modelos donde la rama solo arrastraba
codigo antiguo (firmas viejas de renderProductForm, renderJobForm, listTags sin
search, y el import sin renderSpreadEditWarning). Se preserva la eliminacion de
housing donde la rama lo habia descartado: modules_view, blockchain_view,
search_model y tags_model.

Modulos nuevos del dev portados: blog, data, gallery, mentions, polls, workflows,
comments, recurrence y media_gallery, mas contentPdf, pdfDocument y content_favorites.

main_views recupera renderEngagement, spreadsFor y renderSpreadEditWarning, que la
rama habia eliminado y que las vistas de 0.9.5 necesitan. Sin ellas polls_view
lanzaba TypeError al editar.

El overlay de i18n pasa de 15 a 41 claves (46 en castellano): incorpora las 9 que
upstream tenia en 0.9.1 y elimino en 0.9.5 mientras nuestro codigo las sigue usando
(menuBlogs, spreadHint, chatShareUrl, forumFilterHot, publishBlog...) y las 17 de
Karvan, que nunca existieron y tiraban de texto de reserva en ingles; ahora estan
traducidas al castellano.

Verificado con la app arrancada: declara 0.9.5, 0 claves i18n sin resolver (antes
26), 26 rutas responden 200/302, y la interfaz de la rama sale intacta: 10
categorias y 47 modulos en los hexagonos, topbar con Personal/Community y avatar,
7 accesos rapidos en la barra inferior y 0 etiquetas vacias (antes 1, la de Blogs).
This commit is contained in:
SITO 2026-08-18 22:03:41 +02:00
parent f964e61a1c
commit 82b6c3ae05
43 changed files with 3221 additions and 2416 deletions

View file

@ -89,7 +89,6 @@ code {
}
.search-input,
.feed-search-input,
.activity-search-input {
width: 100% !important;
max-width: 100% !important;
@ -914,3 +913,32 @@ a.create-button {
.peers-technical-block .block-info-table td[data-label=""]::before { content: none !important; }
.peers-list .block-info-table td .peer-key,
.peers-technical-block .block-info-table td .peer-key { text-align: right !important; }
.relationship-status .relationship-actions { flex-direction: column !important; align-items: stretch !important; gap: 8px !important; }
.relationship-status .relationship-actions form,
.relationship-status .relationship-actions button { width: 100% !important; }
.profile-sensors-box { align-items: center !important; text-align: center !important; }
.profile-sensors-box > * { margin-left: auto !important; margin-right: auto !important; justify-content: center !important; }
.tribe-feed .refeed-column,
.tribe-feed-full .refeed-column { align-self: center !important; align-items: center !important; justify-content: center !important; }
.tribe-feed .card-footer,
.tribe-feed-full .card-footer { display: flex !important; flex-direction: column !important; align-items: center !important; text-align: center !important; gap: 2px !important; }
.invite-qr-card { display: flex !important; justify-content: center !important; border: none !important; background: transparent !important; padding: 0 !important; box-shadow: none !important; margin: 0 auto !important; }
.invite-qr-img { display: block; margin: 0 auto; }
.stats-kpi { display: flex !important; flex-direction: column !important; align-items: center !important; text-align: center !important; gap: 2px !important; }
.stats-kpi-label,
.stats-kpi-value { width: 100% !important; text-align: center !important; }
.main-column audio,
.main-column video { width: 100% !important; max-width: 100% !important; }
.main-column section:has(.chat-messages-list) { border: none !important; background: transparent !important; padding: 0 !important; box-shadow: none !important; }
.tribe-details:has(.chat-messages-list) { display: block !important; border: none !important; background: transparent !important; padding: 6px !important; margin: 0 !important; box-shadow: none !important; }
.chat-full-width { border: none !important; background: transparent !important; padding: 0 !important; }
.chat-messages-list { padding: 0 !important; }
.chat-message { max-width: 92% !important; }
.chat-message.chat-poll { max-width: 97% !important; }

View file

@ -2,202 +2,490 @@
// Se fusionan sobre las de upstream desde i18n.js, para no editar los 11
// ficheros oasis_*.js (upstream los reescribe entero en cada release).
//
// Contiene: las claves que la rama anade (barra inferior bb*, menu
// Personal/Community, karvanTitle) y los textos de upstream que la rama
// corrige (el grupo "spread" en castellano).
// Contiene tres grupos:
// 1. Claves que anade la rama: barra inferior (bb*), menu Personal/Community
// y el modulo Karvan.
// 2. Claves que upstream tenia en 0.9.1 y elimino en 0.9.5, pero que el
// codigo de la rama sigue usando (menuBlogs, spreadHint, chatShareUrl...).
// 3. Textos de upstream que la rama corrige (el grupo spread en castellano).
module.exports = {
en: {
peerLastChange: "Last change",
menuCommunity: "Community",
menuAll: "All",
filter: "Filter",
bbAdd: "Add",
bbManage: "Edit",
bbRemove: "Remove",
bbQuickActions: "Quick actions",
bbYourBar: "Your bar",
bbFull: "Maximum reached",
bbDone: "Done",
bbPickTitle: "Customize the bar",
bbFull: "Maximum reached",
bbManage: "Edit",
bbPickDesc: "Choose up to 4 shortcuts for the bottom bar.",
bbPickNone: "None",
karvanTitle: "Karvan"
bbPickTitle: "Customize the bar",
bbQuickActions: "Quick actions",
bbRemove: "Remove",
bbYourBar: "Your bar",
bookmarkCategoryLabel: "Category",
bookmarkCategoryPlaceholder: "Optional",
chatShareUrl: "Share URL",
filter: "Filter",
forumFilterHot: "Hot",
karvanActive: "Active rooms",
karvanBlurb: "Rooms that live only in memory and destroy themselves.",
karvanCall: "Call",
karvanCreate: "Create ephemeral room",
karvanEphemeral: "Ephemeral rooms",
karvanInvite: "Invite",
karvanInviteBad: "✗ That is not a valid @feed-id.",
karvanInviteFail: "✗ Could not send the invitation.",
karvanInvitePlaceholder: "Invite a contact by @feed-id…",
karvanInviteSent: "✓ Invitation sent to their inbox.",
karvanNone: "No active rooms. Create one — it disappears on its own.",
karvanRelay: "server relay",
karvanRoomName: "Room name (optional)",
karvanRooms: "Rooms",
karvanSay: "Temporary message…",
karvanSend: "Send",
karvanTitle: "Karvan",
karvanTtl: "Self-destruct in:",
menuAll: "All",
menuBlogs: "Blogs",
menuCommunity: "Community",
peerLastChange: "Last change",
profileVisibilityDevice: "Device",
publishBlog: "Publish Blog",
spreadHint: "Spread this to your supporters (replicates via your feed).",
spreadMore: "more"
},
es: {
peerLastChange: "Último cambio",
menuCommunity: "Comunidad",
menuAll: "Todo",
filter: "Filtrar",
bbAdd: "Añadir",
bbManage: "Editar",
bbRemove: "Quitar",
bbQuickActions: "Accesos rápidos",
bbYourBar: "Tu barra",
bbFull: "Máximo alcanzado",
bbDone: "Hecho",
bbPickTitle: "Personaliza la barra",
bbFull: "Máximo alcanzado",
bbManage: "Editar",
bbPickDesc: "Elige hasta 4 accesos para la barra inferior.",
bbPickNone: "Ninguno",
bbPickTitle: "Personaliza la barra",
bbQuickActions: "Accesos rápidos",
bbRemove: "Quitar",
bbYourBar: "Tu barra",
bookmarkCategoryLabel: "Categoría",
bookmarkCategoryPlaceholder: "Opcional",
chatShareUrl: "Compartir URL",
filter: "Filtrar",
forumFilterHot: "Populares",
karvanActive: "Salas activas",
karvanBlurb: "Salas que solo viven en memoria y se autodestruyen.",
karvanCall: "Llamar",
karvanCreate: "Crear sala efímera",
karvanEphemeral: "Salas efímeras",
karvanInvite: "Invitar",
karvanInviteBad: "✗ No es un @feed-id válido.",
karvanInviteFail: "✗ No se pudo enviar la invitación.",
karvanInvitePlaceholder: "Invita a un contacto por @feed-id…",
karvanInviteSent: "✓ Invitación enviada a su buzón.",
karvanNone: "No hay salas activas. Crea una: desaparece sola.",
karvanRelay: "relay del servidor",
karvanRoomName: "Nombre de la sala (opcional)",
karvanRooms: "Salas",
karvanSay: "Mensaje temporal…",
karvanSend: "Enviar",
karvanTitle: "Karvan",
karvanTtl: "Se autodestruye en:",
menuAll: "Todo",
menuBlogs: "Blogs",
menuCommunity: "Comunidad",
peerLastChange: "Último cambio",
profileVisibilityDevice: "Dispositivo",
publishBlog: "Publicar Blog",
spreadChron: "Difusión",
spreadHint: "Difunde esto a quienes te apoyan (se replica vía tu feed).",
spreadLabel: "Difundir",
spreadMore: "más",
spreaded: "Difundido",
spreadedDescription: "Lista de posts replicados por habitante.",
totalspreads: "Replicas totales",
spreadLabel: "Difundir",
spreadHint: "Difunde esto a quienes te apoyan (se replica vía tu feed)."
totalspreads: "Replicas totales"
},
fr: {
peerLastChange: "Last change",
menuCommunity: "Community",
menuAll: "All",
filter: "Filter",
bbAdd: "Add",
bbManage: "Edit",
bbRemove: "Remove",
bbQuickActions: "Quick actions",
bbYourBar: "Your bar",
bbFull: "Maximum reached",
bbDone: "Done",
bbPickTitle: "Customize the bar",
bbFull: "Maximum reached",
bbManage: "Edit",
bbPickDesc: "Choose up to 4 shortcuts for the bottom bar.",
bbPickNone: "None",
karvanTitle: "Karvan"
bbPickTitle: "Customize the bar",
bbQuickActions: "Quick actions",
bbRemove: "Remove",
bbYourBar: "Your bar",
bookmarkCategoryLabel: "Catégorie",
bookmarkCategoryPlaceholder: "Optionnel",
chatShareUrl: "Partager URL",
filter: "Filter",
forumFilterHot: "Populaires",
karvanActive: "Active rooms",
karvanBlurb: "Rooms that live only in memory and destroy themselves.",
karvanCall: "Call",
karvanCreate: "Create ephemeral room",
karvanEphemeral: "Ephemeral rooms",
karvanInvite: "Invite",
karvanInviteBad: "✗ That is not a valid @feed-id.",
karvanInviteFail: "✗ Could not send the invitation.",
karvanInvitePlaceholder: "Invite a contact by @feed-id…",
karvanInviteSent: "✓ Invitation sent to their inbox.",
karvanNone: "No active rooms. Create one — it disappears on its own.",
karvanRelay: "server relay",
karvanRoomName: "Room name (optional)",
karvanRooms: "Rooms",
karvanSay: "Temporary message…",
karvanSend: "Send",
karvanTitle: "Karvan",
karvanTtl: "Self-destruct in:",
menuAll: "All",
menuBlogs: "Blogs",
menuCommunity: "Community",
peerLastChange: "Last change",
profileVisibilityDevice: "Appareil",
publishBlog: "Publier un blog",
spreadHint: "Diffusez ceci à ceux qui vous soutiennent (réplique via votre feed).",
spreadMore: "plus"
},
eu: {
peerLastChange: "Last change",
menuCommunity: "Community",
menuAll: "All",
filter: "Filter",
bbAdd: "Add",
bbManage: "Edit",
bbRemove: "Remove",
bbQuickActions: "Quick actions",
bbYourBar: "Your bar",
bbFull: "Maximum reached",
bbDone: "Done",
bbPickTitle: "Customize the bar",
bbFull: "Maximum reached",
bbManage: "Edit",
bbPickDesc: "Choose up to 4 shortcuts for the bottom bar.",
bbPickNone: "None",
karvanTitle: "Karvan"
bbPickTitle: "Customize the bar",
bbQuickActions: "Quick actions",
bbRemove: "Remove",
bbYourBar: "Your bar",
bookmarkCategoryLabel: "Kategoria",
bookmarkCategoryPlaceholder: "Aukerakoa",
chatShareUrl: "URLa Partekatu",
filter: "Filter",
forumFilterHot: "Beroa",
karvanActive: "Active rooms",
karvanBlurb: "Rooms that live only in memory and destroy themselves.",
karvanCall: "Call",
karvanCreate: "Create ephemeral room",
karvanEphemeral: "Ephemeral rooms",
karvanInvite: "Invite",
karvanInviteBad: "✗ That is not a valid @feed-id.",
karvanInviteFail: "✗ Could not send the invitation.",
karvanInvitePlaceholder: "Invite a contact by @feed-id…",
karvanInviteSent: "✓ Invitation sent to their inbox.",
karvanNone: "No active rooms. Create one — it disappears on its own.",
karvanRelay: "server relay",
karvanRoomName: "Room name (optional)",
karvanRooms: "Rooms",
karvanSay: "Temporary message…",
karvanSend: "Send",
karvanTitle: "Karvan",
karvanTtl: "Self-destruct in:",
menuAll: "All",
menuBlogs: "Blogak",
menuCommunity: "Community",
peerLastChange: "Last change",
profileVisibilityDevice: "Gailua",
publishBlog: "Bloga argitaratu",
spreadHint: "Hedatu zure babesleei (zure feed bidez errepikatzen da).",
spreadMore: "gehiago"
},
de: {
peerLastChange: "Last change",
menuCommunity: "Community",
menuAll: "All",
filter: "Filter",
bbAdd: "Add",
bbManage: "Edit",
bbRemove: "Remove",
bbQuickActions: "Quick actions",
bbYourBar: "Your bar",
bbFull: "Maximum reached",
bbDone: "Done",
bbPickTitle: "Customize the bar",
bbFull: "Maximum reached",
bbManage: "Edit",
bbPickDesc: "Choose up to 4 shortcuts for the bottom bar.",
bbPickNone: "None",
karvanTitle: "Karvan"
bbPickTitle: "Customize the bar",
bbQuickActions: "Quick actions",
bbRemove: "Remove",
bbYourBar: "Your bar",
bookmarkCategoryLabel: "Kategorie",
bookmarkCategoryPlaceholder: "Optional",
chatShareUrl: "URL Teilen",
filter: "Filter",
forumFilterHot: "Beliebt",
karvanActive: "Active rooms",
karvanBlurb: "Rooms that live only in memory and destroy themselves.",
karvanCall: "Call",
karvanCreate: "Create ephemeral room",
karvanEphemeral: "Ephemeral rooms",
karvanInvite: "Invite",
karvanInviteBad: "✗ That is not a valid @feed-id.",
karvanInviteFail: "✗ Could not send the invitation.",
karvanInvitePlaceholder: "Invite a contact by @feed-id…",
karvanInviteSent: "✓ Invitation sent to their inbox.",
karvanNone: "No active rooms. Create one — it disappears on its own.",
karvanRelay: "server relay",
karvanRoomName: "Room name (optional)",
karvanRooms: "Rooms",
karvanSay: "Temporary message…",
karvanSend: "Send",
karvanTitle: "Karvan",
karvanTtl: "Self-destruct in:",
menuAll: "All",
menuBlogs: "Blogs",
menuCommunity: "Community",
peerLastChange: "Last change",
profileVisibilityDevice: "Gerät",
publishBlog: "Blog veröffentlichen",
spreadHint: "Verbreite dies an deine Unterstützer (über deinen Feed).",
spreadMore: "mehr"
},
it: {
peerLastChange: "Last change",
menuCommunity: "Community",
menuAll: "All",
filter: "Filter",
bbAdd: "Add",
bbManage: "Edit",
bbRemove: "Remove",
bbQuickActions: "Quick actions",
bbYourBar: "Your bar",
bbFull: "Maximum reached",
bbDone: "Done",
bbPickTitle: "Customize the bar",
bbFull: "Maximum reached",
bbManage: "Edit",
bbPickDesc: "Choose up to 4 shortcuts for the bottom bar.",
bbPickNone: "None",
karvanTitle: "Karvan"
bbPickTitle: "Customize the bar",
bbQuickActions: "Quick actions",
bbRemove: "Remove",
bbYourBar: "Your bar",
bookmarkCategoryLabel: "Categoria",
bookmarkCategoryPlaceholder: "Opzionale",
chatShareUrl: "Condividi URL",
filter: "Filter",
forumFilterHot: "Caldi",
karvanActive: "Active rooms",
karvanBlurb: "Rooms that live only in memory and destroy themselves.",
karvanCall: "Call",
karvanCreate: "Create ephemeral room",
karvanEphemeral: "Ephemeral rooms",
karvanInvite: "Invite",
karvanInviteBad: "✗ That is not a valid @feed-id.",
karvanInviteFail: "✗ Could not send the invitation.",
karvanInvitePlaceholder: "Invite a contact by @feed-id…",
karvanInviteSent: "✓ Invitation sent to their inbox.",
karvanNone: "No active rooms. Create one — it disappears on its own.",
karvanRelay: "server relay",
karvanRoomName: "Room name (optional)",
karvanRooms: "Rooms",
karvanSay: "Temporary message…",
karvanSend: "Send",
karvanTitle: "Karvan",
karvanTtl: "Self-destruct in:",
menuAll: "All",
menuBlogs: "Blog",
menuCommunity: "Community",
peerLastChange: "Last change",
profileVisibilityDevice: "Dispositivo",
publishBlog: "Pubblica post",
spreadHint: "Diffondi questo a chi ti supporta (replica via il tuo feed).",
spreadMore: "altro"
},
pt: {
peerLastChange: "Last change",
menuCommunity: "Community",
menuAll: "All",
filter: "Filter",
bbAdd: "Add",
bbManage: "Edit",
bbRemove: "Remove",
bbQuickActions: "Quick actions",
bbYourBar: "Your bar",
bbFull: "Maximum reached",
bbDone: "Done",
bbPickTitle: "Customize the bar",
bbFull: "Maximum reached",
bbManage: "Edit",
bbPickDesc: "Choose up to 4 shortcuts for the bottom bar.",
bbPickNone: "None",
karvanTitle: "Karvan"
bbPickTitle: "Customize the bar",
bbQuickActions: "Quick actions",
bbRemove: "Remove",
bbYourBar: "Your bar",
bookmarkCategoryLabel: "Categoria",
bookmarkCategoryPlaceholder: "Opcional",
chatShareUrl: "Partilhar URL",
filter: "Filter",
forumFilterHot: "Populares",
karvanActive: "Active rooms",
karvanBlurb: "Rooms that live only in memory and destroy themselves.",
karvanCall: "Call",
karvanCreate: "Create ephemeral room",
karvanEphemeral: "Ephemeral rooms",
karvanInvite: "Invite",
karvanInviteBad: "✗ That is not a valid @feed-id.",
karvanInviteFail: "✗ Could not send the invitation.",
karvanInvitePlaceholder: "Invite a contact by @feed-id…",
karvanInviteSent: "✓ Invitation sent to their inbox.",
karvanNone: "No active rooms. Create one — it disappears on its own.",
karvanRelay: "server relay",
karvanRoomName: "Room name (optional)",
karvanRooms: "Rooms",
karvanSay: "Temporary message…",
karvanSend: "Send",
karvanTitle: "Karvan",
karvanTtl: "Self-destruct in:",
menuAll: "All",
menuBlogs: "Blogues",
menuCommunity: "Community",
peerLastChange: "Last change",
profileVisibilityDevice: "Dispositivo",
publishBlog: "Publicar blog",
spreadHint: "Difunde isto aos teus apoiantes (replica pelo teu feed).",
spreadMore: "mais"
},
zh: {
peerLastChange: "Last change",
menuCommunity: "Community",
menuAll: "All",
filter: "Filter",
bbAdd: "Add",
bbManage: "Edit",
bbRemove: "Remove",
bbQuickActions: "Quick actions",
bbYourBar: "Your bar",
bbFull: "Maximum reached",
bbDone: "Done",
bbPickTitle: "Customize the bar",
bbFull: "Maximum reached",
bbManage: "Edit",
bbPickDesc: "Choose up to 4 shortcuts for the bottom bar.",
bbPickNone: "None",
karvanTitle: "Karvan"
bbPickTitle: "Customize the bar",
bbQuickActions: "Quick actions",
bbRemove: "Remove",
bbYourBar: "Your bar",
bookmarkCategoryLabel: "类别",
bookmarkCategoryPlaceholder: "可选",
chatShareUrl: "分享链接",
filter: "Filter",
forumFilterHot: "热门",
karvanActive: "Active rooms",
karvanBlurb: "Rooms that live only in memory and destroy themselves.",
karvanCall: "Call",
karvanCreate: "Create ephemeral room",
karvanEphemeral: "Ephemeral rooms",
karvanInvite: "Invite",
karvanInviteBad: "✗ That is not a valid @feed-id.",
karvanInviteFail: "✗ Could not send the invitation.",
karvanInvitePlaceholder: "Invite a contact by @feed-id…",
karvanInviteSent: "✓ Invitation sent to their inbox.",
karvanNone: "No active rooms. Create one — it disappears on its own.",
karvanRelay: "server relay",
karvanRoomName: "Room name (optional)",
karvanRooms: "Rooms",
karvanSay: "Temporary message…",
karvanSend: "Send",
karvanTitle: "Karvan",
karvanTtl: "Self-destruct in:",
menuAll: "All",
menuBlogs: "博客",
menuCommunity: "Community",
peerLastChange: "Last change",
profileVisibilityDevice: "设备",
publishBlog: "发布博客",
spreadHint: "转发给你的支持者(通过你的 feed 复制)。",
spreadMore: "更多"
},
ar: {
peerLastChange: "Last change",
menuCommunity: "Community",
menuAll: "All",
filter: "Filter",
bbAdd: "Add",
bbManage: "Edit",
bbRemove: "Remove",
bbQuickActions: "Quick actions",
bbYourBar: "Your bar",
bbFull: "Maximum reached",
bbDone: "Done",
bbPickTitle: "Customize the bar",
bbFull: "Maximum reached",
bbManage: "Edit",
bbPickDesc: "Choose up to 4 shortcuts for the bottom bar.",
bbPickNone: "None",
karvanTitle: "Karvan"
bbPickTitle: "Customize the bar",
bbQuickActions: "Quick actions",
bbRemove: "Remove",
bbYourBar: "Your bar",
bookmarkCategoryLabel: "الفئة",
bookmarkCategoryPlaceholder: "اختياري",
chatShareUrl: "مشاركة الرابط",
filter: "Filter",
forumFilterHot: "رائج",
karvanActive: "Active rooms",
karvanBlurb: "Rooms that live only in memory and destroy themselves.",
karvanCall: "Call",
karvanCreate: "Create ephemeral room",
karvanEphemeral: "Ephemeral rooms",
karvanInvite: "Invite",
karvanInviteBad: "✗ That is not a valid @feed-id.",
karvanInviteFail: "✗ Could not send the invitation.",
karvanInvitePlaceholder: "Invite a contact by @feed-id…",
karvanInviteSent: "✓ Invitation sent to their inbox.",
karvanNone: "No active rooms. Create one — it disappears on its own.",
karvanRelay: "server relay",
karvanRoomName: "Room name (optional)",
karvanRooms: "Rooms",
karvanSay: "Temporary message…",
karvanSend: "Send",
karvanTitle: "Karvan",
karvanTtl: "Self-destruct in:",
menuAll: "All",
menuBlogs: "مدونات",
menuCommunity: "Community",
peerLastChange: "Last change",
profileVisibilityDevice: "الجهاز",
publishBlog: "نشر مدونة",
spreadHint: "نشر هذا لداعميك (يتم النسخ عبر تغذيتك).",
spreadMore: "المزيد"
},
hi: {
peerLastChange: "Last change",
menuCommunity: "Community",
menuAll: "All",
filter: "Filter",
bbAdd: "Add",
bbManage: "Edit",
bbRemove: "Remove",
bbQuickActions: "Quick actions",
bbYourBar: "Your bar",
bbFull: "Maximum reached",
bbDone: "Done",
bbPickTitle: "Customize the bar",
bbFull: "Maximum reached",
bbManage: "Edit",
bbPickDesc: "Choose up to 4 shortcuts for the bottom bar.",
bbPickNone: "None",
karvanTitle: "Karvan"
bbPickTitle: "Customize the bar",
bbQuickActions: "Quick actions",
bbRemove: "Remove",
bbYourBar: "Your bar",
bookmarkCategoryLabel: "श्रेणी",
bookmarkCategoryPlaceholder: "वैकल्पिक",
chatShareUrl: "URL शेयर करें",
filter: "Filter",
forumFilterHot: "लोकप्रिय",
karvanActive: "Active rooms",
karvanBlurb: "Rooms that live only in memory and destroy themselves.",
karvanCall: "Call",
karvanCreate: "Create ephemeral room",
karvanEphemeral: "Ephemeral rooms",
karvanInvite: "Invite",
karvanInviteBad: "✗ That is not a valid @feed-id.",
karvanInviteFail: "✗ Could not send the invitation.",
karvanInvitePlaceholder: "Invite a contact by @feed-id…",
karvanInviteSent: "✓ Invitation sent to their inbox.",
karvanNone: "No active rooms. Create one — it disappears on its own.",
karvanRelay: "server relay",
karvanRoomName: "Room name (optional)",
karvanRooms: "Rooms",
karvanSay: "Temporary message…",
karvanSend: "Send",
karvanTitle: "Karvan",
karvanTtl: "Self-destruct in:",
menuAll: "All",
menuBlogs: "ब्लॉग",
menuCommunity: "Community",
peerLastChange: "Last change",
profileVisibilityDevice: "डिवाइस",
publishBlog: "ब्लॉग प्रकाशित करें",
spreadHint: "अपने समर्थकों को फैलाएँ (फ़ीड से प्रतिकृति)।",
spreadMore: "और"
},
ru: {
peerLastChange: "Last change",
menuCommunity: "Community",
menuAll: "All",
filter: "Filter",
bbAdd: "Add",
bbManage: "Edit",
bbRemove: "Remove",
bbQuickActions: "Quick actions",
bbYourBar: "Your bar",
bbFull: "Maximum reached",
bbDone: "Done",
bbPickTitle: "Customize the bar",
bbFull: "Maximum reached",
bbManage: "Edit",
bbPickDesc: "Choose up to 4 shortcuts for the bottom bar.",
bbPickNone: "None",
karvanTitle: "Karvan"
bbPickTitle: "Customize the bar",
bbQuickActions: "Quick actions",
bbRemove: "Remove",
bbYourBar: "Your bar",
bookmarkCategoryLabel: "Категория",
bookmarkCategoryPlaceholder: "Необязательно",
chatShareUrl: "Поделиться URL",
filter: "Filter",
forumFilterHot: "Популярные",
karvanActive: "Active rooms",
karvanBlurb: "Rooms that live only in memory and destroy themselves.",
karvanCall: "Call",
karvanCreate: "Create ephemeral room",
karvanEphemeral: "Ephemeral rooms",
karvanInvite: "Invite",
karvanInviteBad: "✗ That is not a valid @feed-id.",
karvanInviteFail: "✗ Could not send the invitation.",
karvanInvitePlaceholder: "Invite a contact by @feed-id…",
karvanInviteSent: "✓ Invitation sent to their inbox.",
karvanNone: "No active rooms. Create one — it disappears on its own.",
karvanRelay: "server relay",
karvanRoomName: "Room name (optional)",
karvanRooms: "Rooms",
karvanSay: "Temporary message…",
karvanSend: "Send",
karvanTitle: "Karvan",
karvanTtl: "Self-destruct in:",
menuAll: "All",
menuBlogs: "Блоги",
menuCommunity: "Community",
peerLastChange: "Last change",
profileVisibilityDevice: "Устройство",
publishBlog: "Опубликовать блог",
spreadHint: "Распространить вашим сторонникам (через ваш feed).",
spreadMore: "ещё"
}
};

View file

@ -42,7 +42,7 @@ const collectLocalIPs = () => {
module.exports = ({ host, port, middleware, allowHost }) => {
const assets = new Koa()
assets.use(koaStatic(join(__dirname, "..", "client", "assets")));
assets.use(koaStatic(join(__dirname, "..", "client", "assets"), { maxage: 60 * 60 * 1000 }));
const app = new Koa();
const validHosts = [];
@ -79,12 +79,14 @@ module.exports = ({ host, port, middleware, allowHost }) => {
return true;
};
const httpDebug = process.argv.includes('--debug') || process.env.OASIS_DEBUG === '1' || process.env.OASIS_DEBUG === 'true';
app.on("error", (err, ctx) => {
if (err && (err.code === 'ECONNRESET' || err.code === 'EPIPE')) {
return;
}
if (err && (err.name === 'BadRequestError' || err.status === 400)) {
console.error(`[400] ${err.message}`);
if (httpDebug) console.error(`[400] ${err.message}`);
return null;
}
console.error(err);

View file

@ -6,12 +6,8 @@
"current": "blocks"
},
"modules": {
"popularMod": "on",
"topicsMod": "on",
"summariesMod": "on",
"latestMod": "on",
"threadsMod": "on",
"multiverseMod": "on",
"blogsMod": "on",
"pollsMod": "on",
"fediverseMod": "on",
"invitesMod": "on",
"walletMod": "off",

View file

@ -164,7 +164,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
// ties correctly — the mock and real SSB can stamp equal timestamps.
if (prev && !((isTip && !prev.isTip) || (isTip === prev.isTip && (a.ts || 0) >= prev.ts))) continue;
const cc = a.content || {};
stateByRoot.set(root, { ts: a.ts || 0, isTip, status: String(cc.status || '').toUpperCase(), tribeId: cc.tribeId || null, title: String(cc.title || ''), description: String(cc.description || '') });
stateByRoot.set(root, { ts: a.ts || 0, isTip, status: String(cc.status || '').toUpperCase(), tribeId: cc.tribeId || null, title: String(cc.title || ''), description: String(cc.description || ''), members: Array.isArray(cc.members) ? cc.members.length : 0 });
}
const msgsByRoot = new Map();
for (const a of idToAction.values()) {
@ -181,7 +181,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
const val = await getMsg(ssbClient, root);
const cc = val && val.content;
if (cc && typeof cc === 'object' && cc.type === 'chat') {
stateByRoot.set(root, { ts: 0, status: String(cc.status || '').toUpperCase(), tribeId: cc.tribeId || null, title: String(cc.title || ''), description: String(cc.description || '') });
stateByRoot.set(root, { ts: 0, status: String(cc.status || '').toUpperCase(), tribeId: cc.tribeId || null, title: String(cc.title || ''), description: String(cc.description || ''), members: Array.isArray(cc.members) ? cc.members.length : 0 });
} else {
stateByRoot.set(root, null);
}
@ -202,6 +202,8 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
chatRoot: root,
title: info.title || '',
description: info.description || '',
members: info.members || 0,
messageCount: asc.length,
replies: asc.slice(-CHAT_THREAD_LIMIT).map(m => ({ id: m.id, author: m.author, ts: m.ts || 0, text: (m.content && m.content.text) || '' }))
}
});
@ -251,6 +253,21 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
const idToAction = new Map();
const rawById = new Map();
const voteTally = buildVoteTally(results);
const pollTally = new Map();
for (const m of results) {
const c = m && m.value && m.value.content;
if (!c || c.type !== 'pollVote' || typeof c.target !== 'string') continue;
if (!Array.isArray(c.choices)) continue;
const entry = pollTally.get(c.target) || { counts: {}, voters: new Map() };
entry.voters.set(m.value.author, { ts: m.value.timestamp || 0, choices: c.choices });
pollTally.set(c.target, entry);
}
for (const entry of pollTally.values()) {
entry.counts = {};
for (const v of entry.voters.values()) {
for (const choice of v.choices) entry.counts[choice] = (entry.counts[choice] || 0) + 1;
}
}
const fpIdx = tribeCrypto ? tribeCrypto.buildFingerprintIndex() : null;
const accessibleTribeIds = await buildAccessibleTribeIds();
@ -577,6 +594,11 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
const actionRoot = rootOf(a.id);
const extra = a.type === 'aiExchange' ? { helpfulVotes: aiHelpfulCounts.get(a.id) || 0 } : {};
const voteAgg = a.type === 'votes' ? voteTally.get(a.id) : null;
if (a.type === 'poll') {
const tally = pollTally.get(a.id) || pollTally.get(actionRoot);
a.pollCounts = tally ? tally.counts : {};
a.pollVoters = tally ? tally.voters.size : 0;
}
let content = voteAgg ? { ...c, ...voteAgg } : a.content;
if (a.type === 'feed') {
const base = a.content || {};
@ -688,12 +710,13 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
}
const tribeInternalTypes = new Set(['tribe-content', 'tribeParliamentCandidature', 'tribeParliamentTerm', 'tribeParliamentProposal', 'tribeParliamentRule', 'tribeParliamentLaw', 'tribeParliamentRevocation']);
const hiddenTypes = new Set(['padEntry', 'chatMessage', 'calendarDate', 'calendarNote', 'calendarReminderSent', 'taskReminderSent', 'feed-action', 'pubBalance', 'pubAvailability', 'log', 'gameScore']);
const hiddenTypes = new Set(['padEntry', 'chatMessage', 'calendarDate', 'calendarNote', 'calendarReminderSent', 'taskReminderSent', 'feed-action', 'pubBalance', 'pubAvailability', 'log', 'logPublic', 'gameScore', 'pollVote', 'pollClose', 'pollOpinion', 'curriculum']);
const chatThreadItems = await buildChatThreads(ssbClient, idToAction, rootOf, deduped);
deduped = deduped.concat(chatThreadItems);
const isAllowedTribeActivity = (a) => {
if (tribeInternalTypes.has(a.type)) return false;
const c = a.content || {};
if (a.type === 'poll' && c.chatId) return false;
if (c.tribeId) return false;
if (a.type === 'tribe') {
const isInitial = !c.replaces;
@ -704,8 +727,8 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
}
return true;
};
const itemVisible = (a) => {
if (hiddenTypes.has(a.type)) return false;
const itemVisible = (a, opts = {}) => {
if (hiddenTypes.has(a.type) && !(opts.allowHidden && opts.allowHidden.has(a.type))) return false;
const c = a.content || {};
if (c.encryptedPayload) return false;
if (a.type === 'pad' && c.status !== 'OPEN') return false;
@ -722,8 +745,8 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
if (a.type === 'transfer' && String(c.status || '').toUpperCase() === 'UNCONFIRMED' && a.author !== userId && c.from !== userId && c.to !== userId) return false;
return true;
};
const isVisible = (a) => {
if (!itemVisible(a)) return false;
const isVisible = (a, opts) => {
if (!itemVisible(a, opts)) return false;
if (a.type === 'post' || a.type === 'opinion') {
const c = a.content || {};
const ref = (typeof c.root === 'string' && c.root)
@ -742,6 +765,14 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
if (filter === 'mine') out = deduped.filter(a => a.author === userId && isAllowedTribeActivity(a) && isVisible(a));
else if (filter === 'recent') { const cutoff = Date.now() - 24 * 60 * 60 * 1000; out = deduped.filter(a => (a.ts || 0) >= cutoff && isAllowedTribeActivity(a) && isVisible(a)) }
else if (filter === 'all') out = deduped.filter(a => isAllowedTribeActivity(a) && isVisible(a));
else if (filter === 'top') {
const visible = deduped.filter(a => isAllowedTribeActivity(a) && isVisible(a));
const perAuthor = new Map();
for (const a of visible) perAuthor.set(a.author, (perAuthor.get(a.author) || 0) + 1);
out = visible
.map(a => ({ ...a, authorActions: perAuthor.get(a.author) || 0 }))
.sort((x, y) => (y.authorActions - x.authorActions) || ((y.ts || 0) - (x.ts || 0)));
}
else if (filter === 'banking') out = deduped.filter(a => a.type === 'bankWallet' || a.type === 'bankClaim' || a.type === 'ubiClaim');
else if (filter === 'tribe') out = deduped.filter(a => a.type === 'tribe' || String(a.type || '').startsWith('tribe'));
else if (filter === 'spread') out = deduped.filter(a => a.type === 'spread');
@ -756,6 +787,8 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
});
else if (filter === 'task')
out = deduped.filter(a => a.type === 'task' || a.type === 'taskAssignment');
else if (filter === 'votes')
out = deduped.filter(a => (a.type === 'votes' || a.type === 'poll') && isAllowedTribeActivity(a) && isVisible(a));
else if (filter === 'industry')
out = deduped.filter(a => ['industry', 'industryBuild', 'industryBlueprint', 'industryAllocation'].includes(a.type) && isVisible(a));
else if (filter === 'pad') out = deduped.filter(a => a.type === 'pad' && (a.content || {}).status === 'OPEN');
@ -764,7 +797,7 @@ module.exports = ({ cooler, tribeCrypto, tribesModel, padsModel, industryModel }
else if (filter === 'transfer') out = deduped.filter(a => a.type === 'transfer' && isVisible(a));
else out = deduped.filter(a => a.type === filter);
out.sort((a, b) => (b.ts || 0) - (a.ts || 0));
if (filter !== 'top') out.sort((a, b) => (b.ts || 0) - (a.ts || 0));
return out;
})();
_feedCacheInflight.set(cacheKey, promise);

168
src/models/blog_model.js Normal file
View file

@ -0,0 +1,168 @@
const pull = require('../server/node_modules/pull-stream');
const { getConfig } = require('../configs/config-manager.js');
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
const logLimit = getConfig().ssbLogStream?.limit || 1000;
const OPINION_TYPE = 'blogOpinion';
module.exports = ({ cooler, isPublic = false }) => {
let ssb;
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
const getAllMessages = async (ssbClient) =>
new Promise((resolve, reject) => {
pull(
ssbClient.createLogStream({ limit: logLimit }),
pull.collect((err, msgs) => (err ? reject(err) : resolve(msgs)))
);
});
const isRootPost = (c) =>
!!c && c.type === 'post' && typeof c.text === 'string' &&
!c.root && !c.fork && !c.branch && !c.about && !c.target;
const buildIndex = (messages) => {
const tomb = buildValidatedTombstoneSet(messages);
const posts = new Map();
const commentsByRoot = new Map();
const opinionsByRoot = new Map();
for (const m of messages) {
const v = m && m.value;
const c = v && v.content;
if (!c || typeof c !== 'object') continue;
if (tomb.has(m.key)) continue;
if (c.type === 'post' && typeof c.text === 'string') {
if (isRootPost(c)) {
posts.set(m.key, { key: m.key, author: v.author, ts: v.timestamp || m.timestamp || 0, c });
} else if (typeof c.root === 'string') {
commentsByRoot.set(c.root, (commentsByRoot.get(c.root) || 0) + 1);
}
continue;
}
if (c.type === OPINION_TYPE && typeof c.target === 'string') {
const entry = opinionsByRoot.get(c.target) || { counts: {}, voters: [] };
if (!entry.voters.includes(v.author)) {
entry.voters.push(v.author);
entry.counts[c.category] = (entry.counts[c.category] || 0) + 1;
}
opinionsByRoot.set(c.target, entry);
}
}
return { tomb, posts, commentsByRoot, opinionsByRoot };
};
const buildBlog = (node, idx) => {
const c = node.c || {};
const op = idx.opinionsByRoot.get(node.key) || { counts: {}, voters: [] };
return {
id: node.key,
key: node.key,
author: node.author,
subject: typeof c.contentWarning === 'string' ? c.contentWarning : '',
text: c.text || '',
mentions: Array.isArray(c.mentions) ? c.mentions : [],
allowComments: c.allowComments !== false,
createdAt: new Date(node.ts).toISOString(),
commentCount: idx.commentsByRoot.get(node.key) || 0,
opinions: op.counts,
opinions_inhabitants: op.voters
};
};
const collect = async () => {
const ssbClient = await openSsb();
const messages = await getAllMessages(ssbClient);
const idx = buildIndex(messages);
const list = [];
for (const node of idx.posts.values()) list.push(buildBlog(node, idx));
return { list, idx, viewerId: ssbClient.id };
};
const opinionScore = (blog) =>
Object.values(blog.opinions || {}).reduce((n, v) => n + (Number(v) || 0), 0);
return {
type: 'post',
async listAll(filter = 'ALL', opts = {}) {
const { list, viewerId } = await collect();
const f = String(filter || 'ALL').toUpperCase();
const favorites = Array.isArray(opts.favorites) ? new Set(opts.favorites.map(String)) : null;
let out = list.slice();
if (f === 'MINE') out = out.filter(b => String(b.author) === String(viewerId));
else if (f === 'FAVORITES') out = favorites ? out.filter(b => favorites.has(String(b.id))) : [];
const q = String(opts.q || '').trim().toLowerCase();
if (q) out = out.filter(b =>
b.text.toLowerCase().includes(q) || b.subject.toLowerCase().includes(q));
if (f === 'TOP') {
out.sort((a, b) => (opinionScore(b) + b.commentCount) - (opinionScore(a) + a.commentCount) ||
new Date(b.createdAt) - new Date(a.createdAt));
} else {
out.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
}
return out;
},
async getBlogById(id) {
const { list } = await collect();
const blog = list.find(b => b.id === id);
if (!blog) throw new Error('Blog not found');
return blog;
},
async createBlog({ text, subject = '', mentions = [], allowComments = true } = {}) {
if (isPublic) throw new Error('Not available in public mode');
const body = String(text || '').trim();
if (!body) throw new Error('Blog text is required');
const ssbClient = await openSsb();
const content = {
type: 'post',
text: body,
allowComments: allowComments !== false,
...(Array.isArray(mentions) && mentions.length ? { mentions } : {}),
...(String(subject || '').trim() ? { contentWarning: String(subject).trim() } : {})
};
return new Promise((res, rej) => ssbClient.publish(content, (err, msg) => err ? rej(err) : res(msg)));
},
async createOpinion(id, category) {
const categories = require('../backend/opinion_categories');
if (!categories.includes(category)) throw new Error('Invalid opinion category');
const ssbClient = await openSsb();
const userId = ssbClient.id;
const blog = await this.getBlogById(id);
if (blog.opinions_inhabitants.includes(userId)) throw new Error('Already opined');
const content = { type: OPINION_TYPE, target: blog.id, category, createdAt: new Date().toISOString() };
return new Promise((res, rej) => ssbClient.publish(content, (err, result) => err ? rej(err) : res(result)));
},
async resolveRootId(id) {
const ssbClient = await openSsb();
const msg = await new Promise((res) => ssbClient.get(id, (err, m) => res(err ? null : m)));
const c = msg && (msg.content || (msg.value && msg.value.content));
if (c && c.type === 'post' && typeof c.root === 'string') return c.root;
return id;
},
async blogHrefFor(id) {
const ssbClient = await openSsb();
const msg = await new Promise((res) => ssbClient.get(id, (err, m) => res(err ? null : m)));
const c = msg && (msg.content || (msg.value && msg.value.content));
if (!c || c.type !== 'post' || typeof c.text !== 'string') return null;
if (c.private === true || Array.isArray(c.recps)) return null;
if (typeof c.root === 'string') {
return `/blogs/${encodeURIComponent(c.root)}#${encodeURIComponent(id)}`;
}
if (c.fork || c.branch || c.about || c.target) return null;
return `/blogs/${encodeURIComponent(id)}`;
}
};
};

292
src/models/data_model.js Normal file
View file

@ -0,0 +1,292 @@
const pull = require('../server/node_modules/pull-stream');
const { getConfig } = require('../configs/config-manager.js');
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
const logLimit = getConfig().ssbLogStream?.limit || 1000;
const norm = (v) => String(v == null ? '' : v).trim().toLowerCase();
const KINDS = {
inhabitants: { type: 'curriculum', href: (id, c) => `/author/${encodeURIComponent(c.author)}` },
jobs: { type: 'job', href: (id) => `/jobs/${encodeURIComponent(id)}` },
projects: { type: 'project', href: (id) => `/projects/${encodeURIComponent(id)}` },
events: { type: 'event', href: (id) => `/events/${encodeURIComponent(id)}` },
tribes: { type: 'tribe', href: (id) => `/tribe/${encodeURIComponent(id)}` },
market: { type: 'market', href: (id) => `/market/${encodeURIComponent(id)}` },
housing: { type: 'housing', href: (id) => `/housing/${encodeURIComponent(id)}` },
industry: { type: 'industry', href: (id) => `/industry/${encodeURIComponent(id)}` },
tasks: { type: 'task', href: (id) => `/tasks/${encodeURIComponent(id)}` },
reports: { type: 'report', href: (id) => `/reports/${encodeURIComponent(id)}` },
votes: { type: 'poll', href: (id) => `/polls/${encodeURIComponent(id)}` },
audios: { type: 'audio', href: (id) => `/audios/${encodeURIComponent(id)}` },
videos: { type: 'video', href: (id) => `/videos/${encodeURIComponent(id)}` },
images: { type: 'image', href: (id) => `/images/${encodeURIComponent(id)}` },
documents: { type: 'document', href: (id) => `/documents/${encodeURIComponent(id)}` },
bookmarks: { type: 'bookmark', href: (id) => `/bookmarks/${encodeURIComponent(id)}` },
torrents: { type: 'torrent', href: (id) => `/torrents/${encodeURIComponent(id)}` },
chats: { type: 'chat', href: (id) => `/chats/${encodeURIComponent(id)}` },
pads: { type: 'pad', href: (id) => `/pads/${encodeURIComponent(id)}` },
maps: { type: 'map', href: (id) => `/maps/${encodeURIComponent(id)}` },
calendars: { type: 'calendar', href: (id) => `/calendars/${encodeURIComponent(id)}` },
forum: { type: 'forum', href: (id) => `/forum/${encodeURIComponent(id)}` }
};
const KIND_BY_TYPE = Object.fromEntries(Object.entries(KINDS).map(([k, v]) => [v.type, k]));
const cvSkills = (c) => [
...(c.personalSkills || []),
...(c.oasisSkills || []),
...(c.educationalSkills || []),
...(c.professionalSkills || [])
];
const PLACEHOLDERS = new Set(['unknown', 'n/a', 'na', 'none', '-', 'other']);
const TITLE_STOPWORDS = new Set([
'the', 'this', 'that', 'and', 'for', 'with', 'from', 'into', 'about', 'new', 'all', 'not', 'are', 'was', 'you', 'your', 'our',
'los', 'las', 'del', 'una', 'unos', 'unas', 'este', 'esta', 'esto', 'que', 'con', 'para', 'por', 'sin', 'sobre', 'bajo', 'mas', 'más'
]);
const titleTerms = (c) => String(c.title || c.name || c.question || c.concept || '')
.toLowerCase()
.split(/[^\p{L}\p{N}]+/u)
.filter(w => w.length >= 3 && !TITLE_STOPWORDS.has(w) && !/^\d+$/.test(w));
const termsOf = (kind, c) => {
const out = [];
if (kind === 'inhabitants') {
out.push(...cvSkills(c));
if (c.languages) out.push(...String(c.languages).split(/[,;]/));
} else if (kind === 'jobs') {
out.push(...(c.tasks || []), ...(c.tags || []), c.job_type, c.location);
} else if (kind === 'industry') {
out.push(...(c.tags || []), c.sector, ...(c.skills || []));
} else {
out.push(...(c.tags || []));
if (c.category) out.push(c.category);
}
return Array.from(new Set(out.map(norm).filter(t => t && !PLACEHOLDERS.has(t))));
};
const titleOf = (kind, c, author) => {
if (kind === 'inhabitants') return c.name || author || '';
return c.title || c.name || c.question || c.concept || '';
};
const jaccard = (a, b) => {
if (!a.size || !b.size) return { score: 0, common: [] };
const common = [...b].filter(x => a.has(x));
const union = a.size + b.size - common.length;
return { score: union > 0 ? common.length / union : 0, common };
};
module.exports = ({ cooler }) => {
let ssb;
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
const getAllMessages = async (ssbClient) =>
new Promise((resolve, reject) => {
pull(
ssbClient.createLogStream({ limit: logLimit }),
pull.collect((err, msgs) => (err ? reject(err) : resolve(msgs)))
);
});
const buildGraph = async () => {
const ssbClient = await openSsb();
const viewerId = ssbClient.id;
const messages = await getAllMessages(ssbClient);
const tomb = buildValidatedTombstoneSet(messages);
const latestByKey = new Map();
const replaced = new Set();
for (const m of messages) {
const v = m && m.value;
const c = v && v.content;
if (!c || typeof c !== 'object' || !c.type) continue;
if (tomb.has(m.key)) continue;
if (c.encryptedPayload || c.encryptedQuestion) continue;
if (c.tribeId && c.type !== 'tribe') continue;
const kind = KIND_BY_TYPE[c.type];
if (!kind) continue;
if (typeof c.replaces === 'string') replaced.add(c.replaces);
latestByKey.set(m.key, { key: m.key, author: v.author, ts: v.timestamp || m.timestamp || 0, kind, c });
}
const nodes = [];
const byAuthorCv = new Map();
for (const node of latestByKey.values()) {
if (replaced.has(node.key)) continue;
const coreTerms = termsOf(node.kind, node.c);
const extra = node.kind === 'inhabitants' ? [] : titleTerms(node.c).map(norm).filter(t => t && !PLACEHOLDERS.has(t));
const terms = Array.from(new Set([...coreTerms, ...extra]));
if (!terms.length) continue;
const entry = {
id: node.key,
kind: node.kind,
author: node.c.author || node.author,
title: titleOf(node.kind, node.c, node.c.author || node.author),
terms,
termSet: new Set(terms),
coreTermSet: new Set(coreTerms),
ts: node.ts,
createdAt: node.c.createdAt || new Date(node.ts).toISOString(),
href: KINDS[node.kind].href(node.key, node.c)
};
if (node.kind === 'inhabitants') {
const prev = byAuthorCv.get(entry.author);
if (prev && prev.ts >= entry.ts) continue;
byAuthorCv.set(entry.author, entry);
continue;
}
nodes.push(entry);
}
for (const cv of byAuthorCv.values()) nodes.push(cv);
return { viewerId, nodes, cvByAuthor: byAuthorCv };
};
const MAX_ENTITIES = 400;
const MAX_PAIRS = 300;
const strip = (n) => ({
id: n.id, kind: n.kind, author: n.author, title: n.title,
href: n.href, createdAt: n.createdAt, ts: n.ts
});
return {
KINDS: Object.keys(KINDS),
async listMatches(filter = 'ALL', opts = {}) {
const { viewerId, nodes, cvByAuthor } = await buildGraph();
const use = nodes.slice(0, MAX_ENTITIES);
const f = String(filter || 'ALL').toUpperCase();
const myTermSet = new Set();
const mineCv = cvByAuthor.get(viewerId);
if (mineCv) for (const t of mineCv.terms) myTermSet.add(t);
for (const n of use) {
if (String(n.author) === String(viewerId)) for (const t of n.terms) myTermSet.add(t);
}
const df = new Map();
for (const n of use) for (const t of n.termSet) df.set(t, (df.get(t) || 0) + 1);
const total = use.length || 1;
const weightOf = (t) => Math.log(1 + total / (df.get(t) || 1));
let out = [];
for (const n of use) {
if (String(n.author) === String(viewerId)) continue;
const common = [...n.termSet].filter(t => myTermSet.has(t));
if (!common.length) continue;
let commonW = 0;
for (const t of common) commonW += weightOf(t);
let itemW = 0;
for (const t of n.termSet) itemW += weightOf(t);
const score = itemW > 0 ? Math.min(1, commonW / itemW) : 0;
if (score <= 0) continue;
common.sort((x, y) => weightOf(y) - weightOf(x) || x.localeCompare(y));
out.push({ ...strip(n), score, common, connections: common.length });
}
if (f === 'RECENT') {
const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000;
out = out.filter(s => s.ts >= cutoff);
} else if (f !== 'ALL' && f !== 'TOP') {
const kind = f.toLowerCase();
if (KINDS[kind]) out = out.filter(s => s.kind === kind);
}
const q = norm(opts.q);
if (q) out = out.filter(s => norm(s.title).includes(q) || s.common.some(t => t.includes(q)));
if (f === 'RECENT') out.sort((x, y) => y.ts - x.ts || y.score - x.score);
else out.sort((x, y) => y.score - x.score || y.ts - x.ts);
return {
matches: out.slice(0, MAX_PAIRS),
total: out.length,
hasProfile: myTermSet.size > 0,
myTerms: [...myTermSet]
};
},
async jobMatchesFor(viewerId, { minScore = 0.8 } = {}) {
const { nodes, cvByAuthor } = await buildGraph();
const mine = cvByAuthor.get(viewerId);
if (!mine) return [];
return nodes
.filter(n => n.kind === 'jobs' && String(n.author) !== String(viewerId))
.map(n => {
const { score, common } = jaccard(mine.termSet, n.coreTermSet || n.termSet);
return { id: n.id, title: n.title, author: n.author, href: n.href, score, common };
})
.filter(m => m.score >= minScore)
.sort((a, b) => b.score - a.score);
},
async cohesion() {
const { cvByAuthor, nodes } = await buildGraph();
const use = nodes.slice(0, MAX_ENTITIES);
let comparisons = 0;
let sum = 0;
let connectedPairs = 0;
const linkedEntities = new Set();
for (let i = 0; i < use.length; i++) {
for (let j = i + 1; j < use.length; j++) {
const a = use[i], b = use[j];
if (a.kind === 'inhabitants' && b.kind === 'inhabitants' && a.author === b.author) continue;
const { score } = jaccard(a.termSet, b.termSet);
comparisons += 1;
sum += score;
if (score > 0) { connectedPairs += 1; linkedEntities.add(a.id); linkedEntities.add(b.id); }
}
}
const coefficient = comparisons > 0 ? sum / comparisons : 0;
const people = [...cvByAuthor.values()];
const linked = new Set();
let cvComparisons = 0;
let cvSum = 0;
for (let i = 0; i < people.length; i++) {
for (let j = i + 1; j < people.length; j++) {
const { score } = jaccard(people[i].termSet, people[j].termSet);
cvComparisons += 1;
cvSum += score;
if (score > 0) { linked.add(people[i].author); linked.add(people[j].author); }
}
}
const cvCoefficient = cvComparisons > 0 ? cvSum / cvComparisons : 0;
const skillSet = new Set();
for (const p of people) for (const t of p.terms) skillSet.add(t);
const termCount = new Map();
for (const n of nodes) for (const t of n.terms) termCount.set(t, (termCount.get(t) || 0) + 1);
const topTerms = [...termCount.entries()]
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.slice(0, 12)
.map(([term, count]) => ({ term, count }));
const perKind = {};
for (const n of nodes) perKind[n.kind] = (perKind[n.kind] || 0) + 1;
return {
coefficient,
percent: Math.round(coefficient * 1000) / 10,
comparisons,
pairs: connectedPairs,
entities: use.length,
distinctTerms: termCount.size,
topTerms,
perKind,
people: people.length,
skills: skillSet.size,
connected: linkedEntities.size,
isolated: Math.max(0, use.length - linkedEntities.size),
cvCoefficient,
cvPercent: Math.round(cvCoefficient * 1000) / 10
};
}
};
};

View file

@ -130,9 +130,9 @@ module.exports = ({ cooler }) => {
return filterInactive(users);
}
if (filter === 'all' || filter === 'TOP KARMA' || filter === 'TOP ACTIVITY' || filter === 'TOP ECO') {
if (filter === 'all' || filter === 'TOP KARMA' || filter === 'TOP ACTIVITY' || filter === 'TOP INACTIVITY' || filter === 'TOP ECO') {
let users = await listAllBase(ssbClient);
if (filter !== 'TOP ACTIVITY') {
if (filter !== 'TOP ACTIVITY' && filter !== 'TOP INACTIVITY') {
users = filterInactive(users);
}
if (search) {
@ -170,6 +170,7 @@ module.exports = ({ cooler }) => {
}));
if (filter === 'TOP KARMA') return withMetrics.sort((a, b) => (b.karmaScore || 0) - (a.karmaScore || 0));
if (filter === 'TOP ACTIVITY') return withMetrics.sort((a, b) => (b.lastActivityTs || 0) - (a.lastActivityTs || 0));
if (filter === 'TOP INACTIVITY') return withMetrics.sort((a, b) => (a.lastActivityTs || 0) - (b.lastActivityTs || 0));
if (filter === 'TOP ECO') return withMetrics.sort((a, b) => (b.ecoScore || 0) - (a.ecoScore || 0));
return withMetrics;
}
@ -254,7 +255,7 @@ module.exports = ({ cooler }) => {
);
}
if (filter === 'CVs' || filter === 'MATCHSKILLS') {
if (filter === 'CVs') {
const records = await new Promise((res, rej) => {
pull(
ssbClient.createLogStream({ limit: logLimit, reverse: true}),
@ -295,54 +296,13 @@ module.exports = ({ cooler }) => {
return out;
}
if (filter === 'MATCHSKILLS') {
let base = await Promise.all(cvs.map(async c => {
const photo = await fetchUserImageUrl(c.author, 256);
const lastActivityTs = await getLastActivityTimestamp(c.author);
const { bucket, range } = bucketLastActivity(lastActivityTs);
const norm = this._normalizeCurriculum(c, photo);
const karmaScore = await getLastKarmaScore(c.author).catch(() => 0);
return { ...norm, lastActivityTs, lastActivityBucket: bucket, lastActivityRange: range, karmaScore };
}));
base = filterInactive(base);
const mecv = await this.getCVByUserId();
const userSkills = Array.from(new Set(
(mecv
? [
...(mecv.personalSkills || []),
...(mecv.oasisSkills || []),
...(mecv.educationalSkills || []),
...(mecv.professionalSkills || [])
]
: []).map(s => String(s || '').toLowerCase()).filter(Boolean)
));
if (!userSkills.length) return [];
const userSet = new Set(userSkills);
const matches = base.map(c => {
if (c.id === userId) return null;
const theirSkillsRaw = (c.skills || []).map(s => String(s || '').toLowerCase()).filter(Boolean);
const theirSet = new Set(theirSkillsRaw);
const common = Array.from(theirSet).filter(s => userSet.has(s));
if (!common.length) return null;
const unionSize = userSet.size + theirSet.size - common.length;
const matchScore = unionSize > 0 ? common.length / unionSize : 0;
const matchCoverage = userSet.size > 0 ? common.length / userSet.size : 0;
return { ...c, commonSkills: common, matchScore, matchCoverage };
}).filter(Boolean);
return matches.sort((a, b) =>
(b.matchScore - a.matchScore) ||
(b.commonSkills.length - a.commonSkills.length) ||
((b.karmaScore || 0) - (a.karmaScore || 0)) ||
((b.lastActivityTs || 0) - (a.lastActivityTs || 0))
);
}
}
return [];
},
_normalizeCurriculum(c, photoUrl) {
const photo = photoUrl || toImageUrl(c.photo, 256);
const photo = c.photo ? toImageUrl(c.photo, 256) : (photoUrl || toImageUrl(null, 256));
return {
id: c.author,
name: c.name,
@ -358,6 +318,8 @@ module.exports = ({ cooler }) => {
languages: typeof c.languages === 'string'
? c.languages.split(',').map(x => x.trim())
: Array.isArray(c.languages) ? c.languages : [],
status: c.status,
preferences: c.preferences,
createdAt: c.createdAt
};
},

View file

@ -568,10 +568,14 @@ module.exports = ({ cooler, tribesModel, tribeCrypto }) => {
});
}
function wallIsPublic(houseKey, { isGoverning = false } = {}) {
return houseKey === 'academia' || isGoverning === true;
}
async function listHousePosts(houseKey, { viewerHouse = null, isGoverning = false } = {}) {
if (!VALID_KEY(houseKey)) return [];
const viewerIsMember = viewerHouse === houseKey;
if (!viewerIsMember && !isGoverning) return [];
if (!viewerIsMember && !wallIsPublic(houseKey, { isGoverning })) return [];
const client = await openSsb();
const memberships = await listAllMemberships();
return new Promise((resolve) => {
@ -912,6 +916,7 @@ module.exports = ({ cooler, tribesModel, tribeCrypto }) => {
return {
HOUSES,
HOUSE_KEYS,
wallIsPublic,
TEST_COOLDOWN_MS,
TEST_QUESTIONS_COUNT,
PROFILE_QUESTIONS,

View file

@ -0,0 +1,171 @@
const fs = require('fs');
const pull = require('../server/node_modules/pull-stream');
const SEEN_FILE = 'oasis-mentions-seen';
const seenPath = () => {
try { return require('../server/ssb_config').statePath(SEEN_FILE); } catch (_) { return null; }
};
const MAX_SEEN_KEYS = 500;
const readSeen = () => {
const p = seenPath();
if (!p) return { ts: 0, keys: [] };
let raw = '';
try { raw = fs.readFileSync(p, 'utf8').trim(); } catch (_) { return { ts: 0, keys: [] }; }
if (!raw) return { ts: 0, keys: [] };
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object') {
return { ts: Number(parsed.ts) || 0, keys: Array.isArray(parsed.keys) ? parsed.keys : [] };
}
} catch (_) {}
return { ts: Number(raw) || 0, keys: [] };
};
const writeSeen = (keys, ts) => {
const p = seenPath();
if (!p) return false;
const trimmed = (Array.isArray(keys) ? keys : []).filter(Boolean).slice(0, MAX_SEEN_KEYS);
try { fs.writeFileSync(p, JSON.stringify({ ts: Number(ts) || Date.now(), keys: trimmed })); return true; } catch (_) { return false; }
};
const { getConfig } = require('../configs/config-manager.js');
const { buildValidatedTombstoneSet } = require('./tombstone_validator');
const logLimit = getConfig().ssbLogStream?.limit || 1000;
const TEXT_FIELDS = ['text', 'description', 'title', 'concept', 'question', 'rules', 'message', 'body', 'subject'];
const SKIP_TYPES = new Set([
'about', 'contact', 'vote', 'pub', 'tombstone', 'karmaScore',
'tribe-invite-msg', 'tribe-invite-tombstone', 'tribe-key-distrib'
]);
module.exports = ({ cooler }) => {
let ssb;
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
const getAllMessages = async (ssbClient) =>
new Promise((resolve, reject) => {
pull(
ssbClient.createLogStream({ limit: logLimit }),
pull.collect((err, msgs) => (err ? reject(err) : resolve(msgs)))
);
});
const mentionsList = (content, feedId) => {
const m = content && content.mentions;
if (!m) return false;
const values = Array.isArray(m) ? m : (typeof m === 'object' ? Object.values(m).flat() : []);
return values.some(v => v && (v.link === feedId || v === feedId));
};
const mentionsText = (content, feedId) => {
const bare = feedId.startsWith('@') ? feedId.slice(1) : feedId;
for (const field of TEXT_FIELDS) {
const v = content[field];
if (typeof v === 'string' && (v.includes(feedId) || v.includes(bare))) return true;
}
if (Array.isArray(content.tags)) {
if (content.tags.some(t => typeof t === 'string' && (t.includes(feedId) || t.includes(bare)))) return true;
}
return false;
};
const snippet = (content) => {
for (const field of TEXT_FIELDS) {
const v = content[field];
if (typeof v === 'string' && v.trim()) return v.trim();
}
return '';
};
const titleOf = (content) => {
for (const field of ['title', 'concept', 'question', 'subject']) {
const v = content[field];
if (typeof v === 'string' && v.trim()) return v.trim();
}
return '';
};
return {
async listMentions(filter = 'ALL', opts = {}) {
const ssbClient = await openSsb();
const myFeedId = ssbClient.id;
const messages = await getAllMessages(ssbClient);
const tomb = buildValidatedTombstoneSet(messages);
const out = [];
const seen = new Set();
const replaced = new Set();
for (const m of messages) {
const v = m && m.value;
const c = v && v.content;
if (!c || typeof c !== 'object' || !c.type) continue;
if (tomb.has(m.key)) continue;
if (SKIP_TYPES.has(c.type)) continue;
if (c.encryptedPayload || c.encryptedQuestion) continue;
if (Array.isArray(c.recps) || Array.isArray(v.content && v.content.recps)) continue;
if (c.tribeId && c.type !== 'tribe') continue;
if (String(v.author) === String(myFeedId)) continue;
if (typeof c.replaces === 'string') replaced.add(c.replaces);
if (!mentionsList(c, myFeedId) && !mentionsText(c, myFeedId)) continue;
if (seen.has(m.key)) continue;
seen.add(m.key);
out.push({
id: m.key,
key: m.key,
author: v.author,
type: c.type,
title: titleOf(c),
text: snippet(c),
content: c,
createdAt: new Date(v.timestamp || m.timestamp || 0).toISOString(),
ts: v.timestamp || m.timestamp || 0
});
}
const fresh = out.filter(x => !replaced.has(x.key));
const q = String(opts.q || '').trim().toLowerCase();
let list = q
? fresh.filter(x => x.text.toLowerCase().includes(q) || x.title.toLowerCase().includes(q) || x.type.toLowerCase().includes(q))
: fresh;
const f = String(filter || 'ALL').toUpperCase();
if (f !== 'ALL' && f !== 'RECENT') list = list.filter(x => x.type === filter);
list.sort((a, b) => b.ts - a.ts);
return list;
},
seenAt: () => readSeen().ts,
markSeen(list) {
const items = Array.isArray(list) ? list : [];
const keys = items.map(x => x && x.key).filter(Boolean);
const newest = items.reduce((max, x) => Math.max(max, Number(x && x.ts) || 0), 0);
return writeSeen(keys, newest || Date.now());
},
unseenOf(list) {
const seen = readSeen();
const known = new Set(seen.keys);
const items = Array.isArray(list) ? list : [];
if (known.size) return items.filter(x => x && !known.has(x.key));
if (seen.ts) return items.filter(x => x && (Number(x.ts) || 0) > seen.ts);
return items;
},
async countUnseen() {
return this.unseenOf(await this.listMentions('ALL')).length;
},
async countTypes(list) {
const counts = {};
for (const item of (Array.isArray(list) ? list : [])) {
counts[item.type] = (counts[item.type] || 0) + 1;
}
return counts;
}
};
};

View file

@ -546,7 +546,7 @@ module.exports = ({ cooler }) => {
return publishReplace(ssbClient, project.id, patch)
},
async listProjects(filter) {
async listProjects(filter, opts = {}) {
const ssbClient = await openSsb()
const currentUserId = ssbClient.id
const idx = buildProjectIndex(await getAllMsgs(ssbClient))
@ -580,6 +580,15 @@ module.exports = ({ cooler }) => {
else if (F === "RECENT") list = list.filter((p) => p && moment(p.createdAt).isAfter(moment().subtract(24, "hours")))
else if (F === "FOLLOWING") list = list.filter((p) => Array.isArray(p.followers) && p.followers.includes(currentUserId))
const q = norm(opts.q)
if (q) {
list = list.filter((p) =>
norm(p.title).includes(q) ||
norm(p.description).includes(q) ||
(Array.isArray(p.tags) && p.tags.some(t => norm(t).includes(q)))
)
}
if (F === "TOP") {
list.sort((a, b) => (parseFloat(b.pledged || 0) / (parseFloat(b.goal || 1))) - (parseFloat(a.pledged || 0) / (parseFloat(a.goal || 1))))
} else {

View file

@ -40,7 +40,7 @@ module.exports = ({ cooler, padsModel, tribeCrypto, tribesModel }) => {
'post', 'about', 'curriculum', 'tribe', 'transfer', 'feed',
'votes', 'report', 'task', 'event', 'bookmark', 'document',
'image', 'audio', 'video', 'torrent', 'market', 'bankWallet', 'bankClaim',
'project', 'job', 'industry', 'industryBlueprint', 'forum', 'vote', 'contact', 'pub', 'map', 'shop', 'shopProduct', 'chat', 'pad'
'project', 'job', 'industry', 'industryBlueprint', 'forum', 'vote', 'contact', 'pub', 'map', 'shop', 'shopProduct', 'chat', 'pad', 'poll'
];
const getRelevantFields = (type, content) => {
@ -60,7 +60,7 @@ module.exports = ({ cooler, padsModel, tribeCrypto, tribesModel }) => {
case 'audio':
return [content?.url, content?.mimeType, content?.title, content?.description, ...(content?.tags || [])];
case 'image':
return [content?.url, content?.title, content?.description, ...(content?.tags || []), content?.meme];
return [content?.url, content?.title, content?.description, ...(content?.tags || [])];
case 'video':
return [content?.url, content?.mimeType, content?.title, content?.description, ...(content?.tags || [])];
case 'document':
@ -85,6 +85,12 @@ module.exports = ({ cooler, padsModel, tribeCrypto, tribesModel }) => {
return [content?.amount, content?.epochId, content?.allocationId, content?.txid];
case 'project':
return [content?.title, content?.status, content?.progress, content?.goal, content?.pledged, content?.deadline, (content?.followers || []).length, (content?.backers || []).length, (content?.milestones || []).length, content?.bounty, content?.bountyAmount, content?.bounty_currency, content?.activity?.kind, content?.activityActor];
case 'poll':
return content?.encryptedQuestion
? []
: [content?.question, ...(content?.options || []), ...(content?.tags || [])];
case 'housing':
return [content?.title, content?.housing_type, content?.property_type, content?.place, content?.price, content?.rooms, content?.size, content?.capacity, content?.status];
case 'job':
return [content?.title, content?.job_type, ...(content?.tasks || []), content?.location, content?.vacants, content?.salary, content?.status, (content?.subscribers || []).length];
case 'industry':
@ -232,6 +238,17 @@ module.exports = ({ cooler, padsModel, tribeCrypto, tribesModel }) => {
].join('|');
}
if (t === 'housing') {
return [
'housing',
author,
norm(c.title),
norm(c.place),
norm(c.price),
norm(c.housing_type)
].join('|');
}
if (t === 'job') {
return [
'job',

View file

@ -13,7 +13,10 @@ module.exports = ({ cooler, padsModel, tribesModel }) => {
const norm = (v) => String(v == null ? '' : v).trim().toLowerCase();
const normalizeTag = (tag) => String(tag == null ? '' : tag).trim().replace(/^#/, '');
const tagKey = (tag) => normalizeTag(tag).toLowerCase();
const tagKey = (tag) => {
const k = normalizeTag(tag).toLowerCase();
return k.includes(':') ? '' : k;
};
const getDedupeKey = (msg) => {
const c = msg?.value?.content || {};
@ -102,7 +105,7 @@ module.exports = ({ cooler, padsModel, tribesModel }) => {
};
return {
async listTags(filter = 'all') {
async listTags(filter = 'all', search = '') {
const ssbClient = await openSsb();
const messages = await new Promise((resolve, reject) => {
@ -158,21 +161,34 @@ module.exports = ({ cooler, padsModel, tribesModel }) => {
if (c.type === 'task' && String(c.isPublic).toUpperCase() === 'PRIVATE') return false;
if ((c.type === 'chat' || c.type === 'pad' || c.type === 'map' || c.type === 'calendar') && c.status === 'INVITE-ONLY' && c.author !== viewerId && !(Array.isArray(c.members) && c.members.includes(viewerId))) return false;
if (c.type === 'shop' && c.visibility === 'CLOSED') return false;
if (c.type === 'poll' && c.chatId) return false;
return true;
});
filtered = dedupeKeepLatest(filtered);
const counts = new Map();
const DAY = 24 * 60 * 60 * 1000;
const bump = (k, display, { mine, ts }) => {
const prev = counts.get(k);
if (!prev) counts.set(k, { name: display, count: 1, mine: mine ? 1 : 0, lastTs: ts || 0 });
else counts.set(k, {
name: prev.name || display,
count: prev.count + 1,
mine: prev.mine + (mine ? 1 : 0),
lastTs: Math.max(prev.lastTs || 0, ts || 0)
});
};
for (const record of filtered) {
const tagsArr = record?.value?.content?.tags || [];
const uniqueTags = new Set(tagsArr.map(tagKey).filter(Boolean));
const mine = String(record?.value?.author || '') === String(viewerId);
const ts = Number(record?.value?.timestamp || record?.timestamp || 0);
for (const k of uniqueTags) {
const display = normalizeTag(tagsArr.find(t => tagKey(t) === k) || k) || k;
const prev = counts.get(k);
if (!prev) counts.set(k, { name: display, count: 1 });
else counts.set(k, { name: prev.name || display, count: prev.count + 1 });
bump(k, display, { mine, ts });
}
}
@ -186,16 +202,22 @@ module.exports = ({ cooler, padsModel, tribesModel }) => {
const uniquePadTags = new Set(pad.tags.map(tagKey).filter(Boolean));
for (const k of uniquePadTags) {
const display = normalizeTag(pad.tags.find(t => tagKey(t) === k) || k) || k;
const prev = counts.get(k);
if (!prev) counts.set(k, { name: display, count: 1 });
else counts.set(k, { name: prev.name || display, count: prev.count + 1 });
bump(k, display, { mine: String(pad.author || '') === String(viewerId), ts: Number(pad.createdAt ? new Date(pad.createdAt).getTime() : 0) });
}
}
}
let tags = Array.from(counts.values());
if (filter === 'top') {
const q = String(search || '').trim().toLowerCase().replace(/^#/, '');
if (q) tags = tags.filter(t => String(t.name || '').toLowerCase().includes(q));
if (filter === 'mine') {
tags = tags.filter(t => (t.mine || 0) > 0).sort((a, b) => b.mine - a.mine || a.name.localeCompare(b.name));
} else if (filter === 'recent') {
const since = Date.now() - 7 * DAY;
tags = tags.filter(t => (t.lastTs || 0) >= since).sort((a, b) => (b.lastTs || 0) - (a.lastTs || 0));
} else if (filter === 'top') {
tags.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
} else if (filter === 'cloud') {
const max = Math.max(...tags.map(t => t.count), 1);

View file

@ -19,7 +19,7 @@ module.exports = ({ cooler }) => {
};
const types = [
'bookmark', 'votes', 'feed',
'bookmark', 'votes', 'poll', 'feed',
'image', 'audio', 'video', 'document', 'torrent', 'transfer',
'industry', 'project', 'report', 'task', 'event', 'shopProduct'
];
@ -141,7 +141,8 @@ module.exports = ({ cooler }) => {
case 'feed':
return `feed::${(c.text || '').replace(/\s+/g, ' ').trim()}`;
case 'votes':
return `votes::${(c.question || '').replace(/\s+/g, ' ').trim()}`;
case 'poll':
return `${c.type}::${(c.question || '').replace(/\s+/g, ' ').trim()}`;
case 'transfer':
return `transfer::${(c.concept || '')}|${c.amount || ''}|${c.from || ''}|${c.to || ''}|${c.deadline || ''}`;
default:

View file

@ -0,0 +1,104 @@
const ALL_MODULES = [
'agenda', 'ai', 'aiNav', 'audios', 'banking', 'blogs', 'bookmarks', 'calendars', 'chats', 'cipher',
'courts', 'dev', 'docs', 'events', 'favorites', 'fediverse', 'feed', 'forum', 'games', 'graphos',
'housing', 'images', 'industry', 'invites', 'jobs', 'larp', 'legacy', 'logs', 'maps', 'market',
'melody', 'opinions', 'pads', 'parliament', 'pixelia', 'polls', 'projects', 'reports', 'shops',
'tags', 'tasks', 'torrents', 'transfers', 'trending', 'tribes', 'videos', 'votes', 'wallet'
];
const NETWORK = ['feed', 'blogs', 'tags', 'trending', 'opinions', 'pads', 'forum', 'maps', 'chats'];
const MEDIA = ['audios', 'bookmarks', 'docs', 'images', 'torrents', 'videos'];
const OFFICE = ['agenda', 'calendars', 'events', 'tasks', 'reports', 'favorites'];
const GOVERNANCE = ['tribes', 'larp', 'votes', 'polls', 'parliament', 'courts', 'logs'];
const ECONOMY = ['banking', 'wallet', 'transfers', 'market', 'housing', 'jobs', 'shops', 'industry', 'projects'];
const MOBILE_MODULES = [
'agenda', 'favorites', 'wallet', 'tribes', 'larp', 'votes', 'polls', 'events', 'calendars', 'tasks',
'reports', 'banking', 'market', 'housing', 'jobs', 'shops', 'transfers', 'cipher', 'invites',
'games', 'audios', 'bookmarks', 'docs', 'images', ...NETWORK
];
const WORKFLOWS = [
{
key: 'default',
theme: 'Dark-SNH',
homePage: 'activity',
modules: ALL_MODULES.filter(m => m !== 'aiNav')
},
{
key: 'jobs',
theme: 'Clear-SNH',
homePage: 'activity',
modules: [...OFFICE, 'jobs', 'projects', 'industry', 'market', 'shops', 'banking', 'wallet', 'transfers',
'forum', 'chats', 'pads', 'feed', 'tags', 'trending', 'docs', 'invites']
},
{
key: 'ruling',
theme: 'Purple-SNH',
homePage: 'activity',
modules: [...GOVERNANCE, ...OFFICE, 'forum', 'pads', 'chats', 'feed', 'blogs', 'opinions', 'tags', 'trending', 'docs', 'invites']
},
{
key: 'politics',
theme: 'Dark-SNH',
homePage: 'trending',
modules: ['parliament', 'courts', 'votes', 'polls', 'reports', 'logs', 'forum', 'opinions', 'blogs',
'feed', 'tags', 'trending', 'pads', 'chats', 'fediverse', 'tribes', 'events', 'agenda']
},
{
key: 'social',
theme: 'Clear-SNH',
homePage: 'feed',
modules: [...NETWORK, ...MEDIA, 'events', 'calendars', 'agenda', 'favorites', 'games', 'pixelia',
'melody', 'tribes', 'fediverse', 'invites', 'polls']
},
{
key: 'business',
theme: 'Dark-SNH',
homePage: 'activity',
modules: [...ECONOMY, ...OFFICE, 'forum', 'chats', 'pads', 'docs', 'feed', 'tags', 'trending', 'invites', 'cipher']
},
{
key: 'night',
theme: 'Matrix-SNH',
homePage: 'feed',
modules: ['feed', 'blogs', 'opinions', 'chats', 'forum', 'tags', 'trending', 'audios', 'videos',
'images', 'bookmarks', 'melody', 'games', 'pixelia', 'favorites']
},
{
key: 'mobile',
theme: 'OasisMobile',
homePage: 'activity',
modules: MOBILE_MODULES
}
];
const WORKFLOW_KEYS = WORKFLOWS.map(w => w.key);
const getWorkflow = (key) => WORKFLOWS.find(w => w.key === String(key || '')) || null;
const modulesOf = (workflow) => {
const set = new Set(workflow.modules.filter(m => ALL_MODULES.includes(m)));
return ALL_MODULES.filter(m => set.has(m));
};
const currentWorkflow = (config) => {
const modules = (config && config.modules) || {};
const theme = (config && config.themes && config.themes.current) || '';
return WORKFLOW_KEYS.find(key => {
const w = getWorkflow(key);
if (theme !== w.theme) return false;
const on = new Set(modulesOf(w));
return ALL_MODULES.every(m => (modules[`${m}Mod`] === 'on') === on.has(m));
}) || null;
};
module.exports = {
ALL_MODULES,
MOBILE_MODULES,
WORKFLOWS,
WORKFLOW_KEYS,
getWorkflow,
modulesOf,
currentWorkflow
};

View file

@ -1,4 +1,4 @@
const { div, h2, p, section, button, form, img, textarea, a, br, h1, span } = require("../server/node_modules/hyperaxe");
const { div, h2, p, section, button, form, img, input, textarea, a, br, h1, span } = require("../server/node_modules/hyperaxe");
const { template, i18n, userLink, renderContentActions } = require('./main_views');
const moment = require('../server/node_modules/moment');
const { config } = require('../server/SSB_server.js');
@ -212,7 +212,7 @@ const renderAgendaItem = (item, userId, filter) => {
);
};
exports.agendaView = async (data, filter) => {
exports.agendaView = async (data, filter, q = '') => {
const { items = [], counts: _c = {} } = data || {};
const counts = { all: 0, open: 0, closed: 0, events: 0, tasks: 0, reports: 0, tribes: 0, jobs: 0, market: 0, projects: 0, transfers: 0, calendars: 0, discarded: 0, ..._c };
return template(
@ -260,6 +260,15 @@ exports.agendaView = async (data, filter) => {
`DISCARDED (${counts.discarded})`)
)
),
div({ class: 'filters' },
form({ method: 'GET', action: '/agenda', class: 'filter-box' },
input({ type: 'hidden', name: 'filter', value: filter }),
input({ type: 'text', name: 'q', value: q, placeholder: i18n.agendaSearchPlaceholder, class: 'filter-box__input' }),
div({ class: 'filter-box__controls' },
button({ type: 'submit', class: 'filter-box__button' }, i18n.searchButton)
)
)
),
div({ class: 'agenda-list' },
items.length
? items.map(item => renderAgendaItem(item, userId, filter))

View file

@ -15,8 +15,9 @@ const {
label,
option
} = require("../server/node_modules/hyperaxe");
const { renderCommentsSection: renderSharedCommentsSection, renderCommentsLink } = require("./comments_view");
const { template, i18n, renderOpinionsVoting, userLink, renderSpreadButton, renderEcoTax, renderLifespanChip, renderContentActions } = require("./main_views");
const { template, i18n, renderOpinionsVoting, renderEngagement, userLink, renderSpreadButton, renderEcoTax, renderLifespanChip, renderContentActions , renderSpreadEditWarning } = require("./main_views");
const moment = require("../server/node_modules/moment");
const { config } = require("../server/SSB_server.js");
const { renderUrl } = require("../backend/renderUrl")
@ -47,21 +48,6 @@ const renderTags = (tags) => {
: null;
};
const renderAudioFavoriteToggle = (audioObj, returnTo = "") =>
form(
{
method: "POST",
action: audioObj.isFavorite
? `/audios/favorites/remove/${encodeURIComponent(audioObj.key)}`
: `/audios/favorites/add/${encodeURIComponent(audioObj.key)}`
},
returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
button(
{ type: "submit", class: "filter-btn" },
audioObj.isFavorite ? i18n.audioRemoveFavoriteButton : i18n.audioAddFavoriteButton
)
);
const renderTranscodeButton = (audioObj) =>
audioObj.isBcs
? form(
@ -108,66 +94,11 @@ const renderAudioOwnerActions = (filter, audioObj, params = {}) => {
};
const renderAudioCommentsSection = (audioId, comments = [], returnTo = null) => {
const list = safeArr(comments).filter(c => {
const t = c && c.value && c.value.content && c.value.content.text;
return t && String(t).trim();
return renderSharedCommentsSection({
action: `/audios/${encodeURIComponent(audioId)}/comments`,
comments: comments,
returnTo: returnTo
});
const commentsCount = list.length;
return div(
{ class: "vote-comments-section" },
div(
{ class: "comments-count" },
span({ class: "card-label" }, i18n.voteCommentsLabel + ": "),
span({ class: "card-value" }, String(commentsCount))
),
div(
{ class: "comment-form-wrapper" },
h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
form(
{ method: "POST", action: `/audios/${encodeURIComponent(audioId)}/comments`, class: "comment-form", enctype: "multipart/form-data" },
returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
textarea({
id: "comment-text",
name: "text",
rows: 4,
class: "comment-textarea",
placeholder: i18n.voteNewCommentPlaceholder
}),
div({ class: "comment-file-upload" }, label(i18n.uploadMedia), input({ type: "file", name: "blob" })),
br(),
button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
)
),
list.length
? div(
{ class: "comments-list" },
list.map((c) => {
const author = c?.value?.author || "";
const ts = c?.value?.timestamp || c?.timestamp;
const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : "";
const relDate = ts ? moment(ts).fromNow() : "";
const content = c?.value?.content || {};
const rootId = content.fork || content.root || null;
const text = content.text || "";
return div(
{ class: "votations-comment-card" },
span(
{ class: "created-at" },
span(i18n.createdBy),
author ? userLink(author) : span("(unknown)"),
absDate ? span(" | ") : "",
absDate ? span({ class: "votations-comment-date" }, absDate) : "",
relDate ? span({ class: "votations-comment-date" }, " | ", i18n.sendTime) : "",
relDate && rootId ? a({ href: `/thread/${encodeURIComponent(rootId)}#${encodeURIComponent(c.key)}` }, relDate) : ""
),
p({ class: "votations-comment-text" }, ...renderUrl(text))
);
})
)
: p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet)
);
};
const renderAudioList = exports.renderAudioList = (audios, filter, params = {}) => {
@ -177,7 +108,6 @@ const renderAudioList = exports.renderAudioList = (audios, filter, params = {})
? audios.map((audioObj) => {
const commentCount = typeof audioObj.commentCount === "number" ? audioObj.commentCount : 0;
const title = safeText(audioObj.title);
const ownerActions = renderAudioOwnerActions(filter, audioObj, params);
const isOwn = audioObj.author && String(audioObj.author) === String(userId);
return div(
@ -185,55 +115,17 @@ const renderAudioList = exports.renderAudioList = (audios, filter, params = {})
div(
{ class: "card-header activity-card-header" },
span(),
renderContentActions(audioObj.key, `/audios/${encodeURIComponent(audioObj.key)}`)
renderContentActions(audioObj.key, `/audios/${encodeURIComponent(audioObj.key)}`, { spread: (params.spreadMap && params.spreadMap.get(audioObj.key)) || params.spreads || null, author: audioObj.author, favKind: 'audios', isFavorite: audioObj.isFavorite, reportTitle: audioObj.title })
),
div(
{ class: "card-section audio-card-body" },
div(
{ class: "bookmark-topbar" },
div(
{ class: "bookmark-topbar-left" },
renderAudioFavoriteToggle(audioObj, returnTo),
audioObj.author && String(audioObj.author) !== String(userId)
? form(
{ method: "GET", action: "/pm" },
input({ type: "hidden", name: "recipients", value: audioObj.author }),
button({ type: "submit", class: "filter-btn" }, i18n.audioMessageAuthorButton)
)
: null
),
ownerActions.length
? div({ class: "bookmark-topbar-right" }, div({ class: "bookmark-actions" }, ...ownerActions))
: null
),
title ? h2(title) : null,
audioObj.lifetime ? div({ class: "card-chips-row" }, renderLifespanChip(audioObj.lifetime, i18n)) : null,
renderAudioPlayer(audioObj),
div(
{ class: "card-comments-summary" },
span({ class: "card-label" }, i18n.voteCommentsLabel + ":"),
span({ class: "card-value" }, String(commentCount)),
br(),
br(),
form(
{ method: "GET", action: `/audios/${encodeURIComponent(audioObj.key)}` },
input({ type: "hidden", name: "returnTo", value: returnTo }),
input({ type: "hidden", name: "filter", value: filter || "all" }),
params.q ? input({ type: "hidden", name: "q", value: params.q }) : null,
params.sort ? input({ type: "hidden", name: "sort", value: params.sort }) : null,
button({ type: "submit", class: "filter-btn" }, i18n.voteCommentsForumButton)
)
renderEngagement(audioObj.key,
renderOpinionsVoting('/audios/opinions', audioObj.key, audioObj.opinions, returnTo, audioObj.opinions_inhabitants),
renderCommentsLink({ href: `/audios/${encodeURIComponent(audioObj.key)}`, count: commentCount })
),
div(
{ class: "card-comments-summary feed-opinions-section" },
div(
{ class: "comments-count" },
span({ class: "card-label" }, (i18n.opinionsTitle || "Opinions") + ": "),
span({ class: "card-value" }, String(Object.values(audioObj.opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0)))
),
renderOpinionsVoting('/audios/opinions', audioObj.key, audioObj.opinions, returnTo, audioObj.opinions_inhabitants)
),
div({ class: "card-spread-left" }, renderSpreadButton(audioObj.key, (params.spreadMap && params.spreadMap.get(audioObj.key)) || params.spreads)),
renderMapLocationVisitLabel(audioObj.mapUrl),
br(),
(() => {
@ -243,12 +135,12 @@ const renderAudioList = exports.renderAudioList = (audios, filter, params = {})
return p(
{ class: "card-footer" },
span({ class: "date-link" }, `${moment(audioObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
span({ class: "date-link" }, `${moment(audioObj.createdAt).format("YYYY/MM/DD HH:mm")} ${i18n.performed} `),
userLink(audioObj.author),
showUpdated
? span(
{ class: "votations-comment-date" },
` | ${i18n.audioUpdatedAt}: ${moment(audioObj.updatedAt).format("YYYY/MM/DD HH:mm:ss")}`
` | ${i18n.audioUpdatedAt}: ${moment(audioObj.updatedAt).format("YYYY/MM/DD HH:mm")}`
)
: null
);
@ -263,6 +155,7 @@ const renderAudioForm = (filter, audioId, audioToEdit, params = {}) => {
const returnTo = safeText(params.returnTo) || buildReturnTo("all", params);
return div(
{ class: "div-center audio-form" },
params.spreadWarning || null,
form(
{
action: filter === "edit" ? `/audios/update/${encodeURIComponent(audioId)}` : "/audios/create",
@ -277,7 +170,7 @@ const renderAudioForm = (filter, audioId, audioToEdit, params = {}) => {
br(),
span(i18n.audioTitleLabel),
br(),
input({ type: "text", name: "title", placeholder: i18n.audioTitlePlaceholder, value: audioToEdit?.title || "" }),
input({ type: "text", name: "title", maxlength: "100", placeholder: i18n.audioTitlePlaceholder, value: audioToEdit?.title || "" }),
br(),
span(i18n.audioDescriptionLabel),
br(),
@ -303,20 +196,8 @@ const renderAudioForm = (filter, audioId, audioToEdit, params = {}) => {
};
exports.audioView = async (audios, filter = "all", audioId = null, params = {}) => {
const title =
filter === "mine"
? i18n.audioMineSectionTitle
: filter === "create"
? i18n.audioCreateSectionTitle
: filter === "edit"
? i18n.audioUpdateSectionTitle
: filter === "recent"
? i18n.audioRecentSectionTitle
: filter === "top"
? i18n.audioTopSectionTitle
: filter === "favorites"
? i18n.audioFavoritesSectionTitle
: i18n.audioAllSectionTitle;
if (filter === "edit") params = { ...params, spreadWarning: await renderSpreadEditWarning(audioId) };
const title = i18n.audioTitle;
const q = safeText(params.q || "");
const sort = safeText(params.sort || "recent");
@ -343,15 +224,15 @@ exports.audioView = async (audios, filter = "all", audioId = null, params = {})
{ method: "GET", action: "/audios", class: "ui-toolbar ui-toolbar--filters" },
input({ type: "hidden", name: "q", value: q }),
input({ type: "hidden", name: "sort", value: sort }),
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterAll),
button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterMine),
button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterRecent),
button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterTop),
button({ type: "submit", name: "filter", value: "bcs", class: filter === "bcs" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterBcs || "BCS"),
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, String(i18n.audioFilterAll).toUpperCase()),
button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, String(i18n.audioFilterMine).toUpperCase()),
button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, String(i18n.audioFilterRecent).toUpperCase()),
button(
{ type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" },
i18n.audioFilterFavorites
String(i18n.audioFilterFavorites).toUpperCase()
),
button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, String(i18n.audioFilterTop).toUpperCase()),
button({ type: "submit", name: "filter", value: "bcs", class: filter === "bcs" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterBcs || "BCS"),
button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.audioCreateButton)
)
)
@ -376,9 +257,9 @@ exports.audioView = async (audios, filter = "all", audioId = null, params = {})
{ class: "filter-box__controls" },
select(
{ name: "sort", class: "filter-box__select" },
option({ value: "recent", selected: sort === "recent" }, i18n.audioSortRecent),
option({ value: "oldest", selected: sort === "oldest" }, i18n.audioSortOldest),
option({ value: "top", selected: sort === "top" }, i18n.audioSortTop)
option({ value: "recent", ...(sort === "recent" ? { selected: true } : {})}, i18n.audioSortRecent),
option({ value: "oldest", ...(sort === "oldest" ? { selected: true } : {})}, i18n.audioSortOldest),
option({ value: "top", ...(sort === "top" ? { selected: true } : {})}, i18n.audioSortTop)
),
button({ type: "submit", class: "filter-box__button" }, i18n.audioSearchButton)
)
@ -407,7 +288,6 @@ exports.singleAudioView = async (audioObj, filter = "all", comments = [], params
const ownerActions = renderAudioOwnerActions(filter, audioObj, { q, sort });
const sideActions = [];
sideActions.push(renderAudioFavoriteToggle(audioObj, returnTo));
if (audioObj.author && String(audioObj.author) !== String(userId)) {
sideActions.push(form(
{ method: "GET", action: "/pm" },
@ -425,6 +305,17 @@ exports.singleAudioView = async (audioObj, filter = "all", comments = [], params
const tagsNode = renderTags(audioObj.tags);
const detailActions = div({ class: "card-header activity-card-header" },
renderContentActions(audioObj.key, null, {
author: audioObj.author,
favKind: 'audios',
isFavorite: audioObj.isFavorite,
spread: params.spreads || null,
returnTo,
reportTitle: audioObj.title
})
);
const audioSide = div({ class: "tribe-side" },
div({ class: "shop-title-row" },
title ? h2({ class: "tribe-card-title" }, title) : null,
@ -435,12 +326,12 @@ exports.singleAudioView = async (audioObj, filter = "all", comments = [], params
? p({ class: "tribe-side-description" }, ...renderUrl(audioObj.description))
: null,
tagsNode,
div({ class: "card-spread-centered" }, renderSpreadButton(audioObj.key, params.spreads)),
renderMapLocationVisitLabel(audioObj.mapUrl),
sideActions.length ? div({ class: "tribe-side-actions" }, ...sideActions) : null
);
const audioMain = div({ class: "tribe-main" },
detailActions,
renderAudioPlayer(audioObj),
(() => {
const createdTs = audioObj.createdAt ? new Date(audioObj.createdAt).getTime() : NaN;
@ -449,18 +340,20 @@ exports.singleAudioView = async (audioObj, filter = "all", comments = [], params
return p(
{ class: "card-footer" },
span({ class: "date-link" }, `${moment(audioObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
span({ class: "date-link" }, `${moment(audioObj.createdAt).format("YYYY/MM/DD HH:mm")} ${i18n.performed} `),
userLink(audioObj.author),
showUpdated
? span(
{ class: "votations-comment-date" },
` | ${i18n.audioUpdatedAt}: ${moment(audioObj.updatedAt).format("YYYY/MM/DD HH:mm:ss")}`
` | ${i18n.audioUpdatedAt}: ${moment(audioObj.updatedAt).format("YYYY/MM/DD HH:mm")}`
)
: null
);
})(),
renderOpinionsVoting('/audios/opinions', audioObj.key, audioObj.opinions, returnTo, audioObj.opinions_inhabitants),
renderAudioCommentsSection(audioObj.key, comments, returnTo)
renderEngagement(audioObj.key,
renderOpinionsVoting('/audios/opinions', audioObj.key, audioObj.opinions, returnTo, audioObj.opinions_inhabitants),
renderAudioCommentsSection(audioObj.key, comments, returnTo)
)
);
return template(
@ -476,15 +369,15 @@ exports.singleAudioView = async (audioObj, filter = "all", comments = [], params
{ method: "GET", action: "/audios", class: "ui-toolbar ui-toolbar--filters" },
input({ type: "hidden", name: "q", value: q }),
input({ type: "hidden", name: "sort", value: sort }),
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterAll),
button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterMine),
button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterRecent),
button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterTop),
button({ type: "submit", name: "filter", value: "bcs", class: filter === "bcs" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterBcs || "BCS"),
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, String(i18n.audioFilterAll).toUpperCase()),
button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, String(i18n.audioFilterMine).toUpperCase()),
button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, String(i18n.audioFilterRecent).toUpperCase()),
button(
{ type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" },
i18n.audioFilterFavorites
String(i18n.audioFilterFavorites).toUpperCase()
),
button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, String(i18n.audioFilterTop).toUpperCase()),
button({ type: "submit", name: "filter", value: "bcs", class: filter === "bcs" ? "filter-btn active" : "filter-btn" }, i18n.audioFilterBcs || "BCS"),
button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.audioCreateButton)
)
),
@ -499,7 +392,7 @@ exports.audioTranscodeDetailView = async ({ audio, decoded = false, stegoPayload
const title = i18n.audioTranscodeDetailTitle || "Transcode";
const composition = Array.isArray(audio.bcsComposition) ? audio.bcsComposition : [];
const hasStego = decoded && stegoPayload && (stegoPayload.id || stegoPayload.ts || stegoPayload.msg);
const stegoDate = hasStego && Number.isFinite(stegoPayload.ts) ? moment(stegoPayload.ts).format("YYYY/MM/DD HH:mm:ss") : null;
const stegoDate = hasStego && Number.isFinite(stegoPayload.ts) ? moment(stegoPayload.ts).format("YYYY/MM/DD HH:mm") : null;
return template(
title,
@ -520,7 +413,7 @@ exports.audioTranscodeDetailView = async ({ audio, decoded = false, stegoPayload
p({ class: "transcode-meta card-footer" },
userLink(audio.author),
span({ class: "melody-meta-sep" }, " · "),
span({ class: "card-value" }, moment(audio.createdAt).format("YYYY/MM/DD HH:mm:ss")),
span({ class: "card-value" }, moment(audio.createdAt).format("YYYY/MM/DD HH:mm")),
itemSize ? span({ class: "melody-meta-sep" }, " · ") : null,
itemSize ? renderEcoTax(itemSize, audio.key) : null
),

View file

@ -9,7 +9,7 @@ const FILTER_LABELS = {
feed: i18n.typeFeed, event: i18n.typeEvent, task: i18n.typeTask, report: i18n.typeReport,
image: i18n.typeImage, audio: i18n.typeAudio, video: i18n.typeVideo, post: i18n.typePost,
forum: i18n.typeForum, about: i18n.typeAbout, contact: i18n.typeContact, pub: i18n.typePub,
transfer: i18n.typeTransfer, market: i18n.typeMarket, job: i18n.typeJob, tribe: i18n.typeTribe,
transfer: i18n.typeTransfer, market: i18n.typeMarket, job: i18n.typeJob, housing: i18n.typeHousing, tribe: i18n.typeTribe,
project: i18n.typeProject, industry: i18n.typeIndustry, industryBlueprint: i18n.industryBlueprints, banking: i18n.typeBanking, bankWallet: i18n.typeBankWallet, bankClaim: i18n.typeBankClaim,
aiExchange: i18n.typeAiExchange, parliament: i18n.typeParliament, courts: i18n.typeCourts,
map: i18n.typeMap, shop: i18n.typeShop, shopProduct: i18n.typeShopProduct || 'Shop Product',
@ -20,7 +20,7 @@ const FILTER_LABELS = {
const BASE_FILTERS = ['recent', 'all', 'mine', 'tombstone', 'logs'];
const CAT_BLOCK1 = ['votes', 'event', 'task', 'report', 'calendar', 'parliament', 'courts'];
const CAT_BLOCK2 = ['pub', 'tribe', 'about', 'contact', 'curriculum', 'vote', 'aiExchange'];
const CAT_BLOCK3 = ['banking', 'job', 'market', 'project', 'industry', 'industryBlueprint', 'transfer', 'feed', 'post', 'pixelia', 'shop', 'gameScore'];
const CAT_BLOCK3 = ['banking', 'job', 'market', 'project', 'industry', 'transfer', 'feed', 'post', 'pixelia', 'shop', 'gameScore'];
const CAT_BLOCK4 = ['forum', 'pad', 'chat', 'bookmark', 'image', 'video', 'audio', 'document', 'map', 'torrent'];
const SEARCH_FIELDS = ['author','id','from','to'];
@ -100,7 +100,7 @@ const computeStats = (blocks) => {
const renderStatsPanel = (stats, currentFilter, search) => {
if (!stats || stats.total === 0) return null;
const topTypes = stats.typeBreakdown.slice(0, 10);
const topTypes = stats.typeBreakdown.filter(t => t.type !== 'industryBlueprint').slice(0, 10);
return div({ class: 'blockchain-stats' },
div({ class: 'tags-header' },
h3(`${i18n.blockchainStatsTitle || 'Stats'} (${stats.total})`)
@ -179,6 +179,7 @@ const getViewDetailsAction = (type, block) => {
case 'pub': return `/invites`;
case 'market': return `/market/${encodeURIComponent(block.id)}`;
case 'job': return `/jobs/${encodeURIComponent(block.id)}`;
case 'housing': return `/housing/${encodeURIComponent(block.id)}`;
case 'project': return `/projects/${encodeURIComponent(block.id)}`;
case 'industry': return `/industry/${encodeURIComponent(block.id)}`;
case 'industryBlueprint': return `/industry/blueprint/${encodeURIComponent(block.id)}`;
@ -209,6 +210,7 @@ const getViewDetailsAction = (type, block) => {
case 'chat': return `/chats/${encodeURIComponent(block.id)}`;
case 'gameScore': return `/games?filter=scoring`;
case 'log': return `/logs/view/${encodeURIComponent(block.id)}`;
case 'poll': return `/polls/${encodeURIComponent(block.id)}`;
case 'calendarDate':
case 'calendarNote': return block.content?.calendarId ? `/calendars/${encodeURIComponent(block.content.calendarId)}` : `/calendars`;
case 'padEntry': return block.content?.padId ? `/pads/${encodeURIComponent(block.content.padId)}` : `/pads`;
@ -221,7 +223,7 @@ const TYPE_COLORS = {
post:'#3498db', vote:'#9b59b6', votes:'#9b59b6', about:'#1abc9c', contact:'#16a085',
pub:'#2ecc71', tribe:'#e67e22', event:'#e74c3c', task:'#f39c12', report:'#c0392b',
image:'#2980b9', audio:'#8e44ad', video:'#d35400', document:'#27ae60', bookmark:'#f1c40f',
forum:'#1abc9c', feed:'#95a5a6', transfer:'#e74c3c', market:'#e67e22', job:'#3498db',
forum:'#1abc9c', feed:'#95a5a6', transfer:'#e74c3c', market:'#e67e22', job:'#3498db', housing:'#16a085',
project:'#2ecc71', banking:'#f39c12', bankWallet:'#f39c12', bankClaim:'#f39c12',
pixelia:'#9b59b6', curriculum:'#1abc9c', aiExchange:'#3498db', tombstone:'#7f8c8d',
parliamentTerm:'#8e44ad', parliamentProposal:'#8e44ad', parliamentLaw:'#8e44ad',
@ -416,9 +418,7 @@ const renderSingleBlockView = (block, filter = 'recent', userId, search = {}, vi
button({ type:'submit', class:'filter-btn' }, `${i18n.blockchainBack}`)
),
!block.isTombstoned && !block.isReplaced && getViewDetailsAction(block.type, block) ?
form({ method:'GET', action:getViewDetailsAction(block.type, block) },
button({ type:'submit', class:'filter-btn' }, i18n.visitContent)
)
a({ href:getViewDetailsAction(block.type, block), class:'btn-singleview btn-content', title:i18n.visitContent }, '↗')
: (block.isTombstoned || block.isReplaced) ?
div({ class: 'deleted-label' },
i18n.blockchainContentDeleted || "This content has been deleted."
@ -474,7 +474,7 @@ const renderBlockchainView = (blocks, filter, userId, search = {}, extras = {})
input({ type: 'datetime-local', name: 'to', value: toVal, class: 'blockexplorer-search-input' })
),
div({ class: 'blockexplorer-search-actions' },
button({ type: 'submit', class: 'filter-box__button' }, i18n.searchSubmit)
div({ class: 'filter-box__controls' }, button({ type: 'submit', class: 'filter-box__button' }, i18n.searchSubmit))
)
)
)
@ -526,11 +526,9 @@ const renderBlockchainView = (blocks, filter, userId, search = {}, extras = {})
div({ class:'block' },
div({ class:'block-buttons' },
block.restricted ? null : a({ href:`/blockexplorer/block/${encodeURIComponent(block.id)}${qs}`, class:'btn-singleview', title:i18n.blockchainDetails }, '⦿'),
block.restricted ? null : a({ href:`/blockexplorer/block/${encodeURIComponent(block.id)}${qs}&view=datagram`, class:'btn-singleview btn-datagram', title:i18n.blockchainDatagram || 'Datagram' }, '⊞'),
block.restricted ? null : a({ href:`/blockexplorer/block/${encodeURIComponent(block.id)}${qs}&view=datagram`, class:'btn-singleview btn-datagram', title:i18n.blockchainDatagram }, '⊞'),
!block.isTombstoned && !block.isReplaced && getViewDetailsAction(block.type, block) ?
form({ method:'GET', action:getViewDetailsAction(block.type, block) },
button({ type:'submit', class:'filter-btn' }, i18n.visitContent)
)
a({ href:getViewDetailsAction(block.type, block), class:'btn-singleview btn-content', title:i18n.visitContent }, '↗')
: (block.isTombstoned || block.isReplaced) ?
div({ class: 'deleted-label' },
i18n.blockchainContentDeleted || "This content has been deleted."

179
src/views/blog_view.js Normal file
View file

@ -0,0 +1,179 @@
const { div, h2, p, section, button, form, a, input, label, span, textarea, br, table, tr, td } = require("../server/node_modules/hyperaxe");
const { renderCommentsSection: renderSharedCommentsSection } = require("./comments_view");
const { template, i18n, userLink, renderOpinionsVoting, renderEngagement, renderSpreadButton, renderContentActions } = require("./main_views");
const moment = require("../server/node_modules/moment");
const { config } = require("../server/SSB_server.js");
const { renderUrl } = require("../backend/renderUrl");
const { renderTextWithStyles } = require("../backend/renderTextWithStyles");
const { sanitizeHtml } = require("../backend/sanitizeHtml");
const userId = config.keys.id;
const FILTERS = [
{ key: "ALL", i18n: "blogFilterAll" },
{ key: "MINE", i18n: "blogFilterMine" },
{ key: "RECENT", i18n: "blogFilterRecent" },
{ key: "FAVORITES", i18n: "blogFilterFavorites" },
{ key: "TOP", i18n: "blogFilterTop" }
];
const safeText = (v) => String(v == null ? "" : v).trim();
const excerpt = (text, max = 420) => {
const s = safeText(text);
return s.length > max ? s.slice(0, max) + "…" : s;
};
const renderFilterBar = (filter, q, showSearch = true) =>
section(
div({ class: "filters" },
form({ method: "GET", action: "/blogs", class: "ui-toolbar ui-toolbar--filters" },
...FILTERS.map(f =>
button({ type: "submit", name: "filter", value: f.key, class: filter === f.key ? "filter-btn active" : "filter-btn" }, String(i18n[f.i18n]).toUpperCase())
),
button({ type: "submit", name: "filter", value: "CREATE", class: "create-button" }, i18n.blogCreateButton)
)
),
showSearch
? div({ class: "filters" },
form({ method: "GET", action: "/blogs", class: "filter-box" },
input({ type: "hidden", name: "filter", value: filter }),
input({ type: "text", name: "q", value: q || "", placeholder: i18n.blogSearchPlaceholder, class: "filter-box__input" }),
div({ class: "filter-box__controls" },
button({ type: "submit", class: "filter-box__button" }, i18n.searchButton)
)
)
)
: null
);
const renderBlogCard = (blog, filter, spreadInfo) => {
const href = `/blogs/${encodeURIComponent(blog.id)}`;
const isOwn = String(blog.author) === String(userId);
return div({ class: "trending-card blog-card" + (isOwn ? " own-content" : "") },
div({ class: "card-header activity-card-header" },
renderContentActions(blog.id, href, { spread: spreadInfo || null, author: blog.author, favKind: 'blogs', isFavorite: blog.isFavorite, reportTitle: blog.subject || blog.text })
),
div({ class: "card-section blog-card-body" },
blog.subject
? div({ class: "shop-title-row" },
h2({ class: "tribe-card-title" }, a({ href }, blog.subject))
)
: null,
div({ class: "blog-card-text", innerHTML: sanitizeHtml(renderTextWithStyles(excerpt(blog.text))) }),
p({ class: "card-footer" },
span({ class: "date-link" }, moment(blog.createdAt).format("YYYY/MM/DD HH:mm")),
span(" · "),
userLink(blog.author)
)
)
);
};
const renderCreateForm = () =>
section(
div({ class: "publish-form" },
form({ action: "/blogs/create", method: "POST", enctype: "multipart/form-data" },
label({ for: "subject" }, i18n.blogSubject),
br(),
input({ type: "text", name: "subject", id: "subject", maxlength: "150", placeholder: i18n.contentWarningPlaceholder }),
br(),
br(),
label({ for: "text" }, i18n.blogMessage),
br(),
textarea({ required: true, name: "text", id: "text", rows: "8", class: "publish-textarea", maxlength: "7000", placeholder: i18n.publishWarningPlaceholder }),
br(),
br(),
label({ for: "blob" }, i18n.blogMedia),
br(),
input({ type: "file", id: "blob", name: "blob", multiple: true, accept: "image/*,video/*,audio/*,application/pdf" }),
br(),
br(),
div({ class: "blog-allow-comments" },
input({ type: "hidden", name: "allowComments", value: "0" }),
label(
input({ type: "checkbox", name: "allowComments", value: "1", checked: true }),
" ",
i18n.blogAllowComments
)
),
br(),
button({ type: "submit" }, i18n.blogPublish)
)
)
);
const renderCommentsSection = (blog, comments = []) => {
const href = `/blogs/${encodeURIComponent(blog.id)}`;
return renderSharedCommentsSection({
action: `${href}/comments`,
comments: comments,
returnTo: href,
closedNote: blog.allowComments ? null : i18n.blogCommentsClosed
});
};
exports.blogView = async (blogs = [], filter = "ALL", params = {}) => {
const showForm = String(filter).toUpperCase() === "CREATE";
const spreadMap = params.spreadMap instanceof Map ? params.spreadMap : new Map();
return template(
i18n.blogTitle,
section(
div({ class: "tags-header" },
h2(i18n.blogTitle),
p(i18n.blogDescription)
)
),
renderFilterBar(showForm ? "ALL" : filter, params.q, !showForm),
showForm
? renderCreateForm()
: section(
blogs.length
? div({ class: "jobs-grid" }, ...blogs.map(b => renderBlogCard(b, filter, spreadMap.get(b.id))))
: p({ class: "no-content" }, i18n.blogNoItems)
)
);
};
exports.singleBlogView = async (blog, comments = [], params = {}) => {
const href = `/blogs/${encodeURIComponent(blog.id)}`;
const isAuthor = String(blog.author) === String(userId);
const blogSide = div({ class: "tribe-side" },
div({ class: "card-header activity-card-header" },
renderContentActions(blog.id, null, { spread: params.spreads || null, author: blog.author, favKind: 'blogs', isFavorite: blog.isFavorite, reportTitle: blog.subject || blog.text })
),
div({ class: "shop-title-row" },
h2({ class: "tribe-card-title" }, blog.subject || i18n.blogTitle)
),
table({ class: "tribe-info-table jobs-info-table" },
tr(
td({ class: "tribe-info-label" }, i18n.createdAtLabel || "Created at"),
td({ class: "tribe-info-value", colspan: "3" }, moment(blog.createdAt).format("YYYY/MM/DD HH:mm"))
),
tr(
td({ class: "tribe-info-value", colspan: "4" }, userLink(blog.author))
)
),
div({ class: "tribe-card-members" },
span({ class: "tribe-members-count" }, `${i18n.blogComments}: ${blog.commentCount || 0}`)
),
);
const blogMain = div({ class: "tribe-main" },
div({ class: "job-section" },
div({ class: "blog-detail-text", innerHTML: sanitizeHtml(renderTextWithStyles(blog.text || "")) })
),
renderEngagement(blog.id,
renderOpinionsVoting('/blogs/opinions', blog.id, blog.opinions, href, blog.opinions_inhabitants),
renderCommentsSection(blog, comments)
)
);
return template(
blog.subject || i18n.blogTitle,
section(div({ class: "tags-header" }, h2(i18n.blogTitle), p(i18n.blogDescription))),
renderFilterBar("ALL", params.q, false),
section(div({ class: "tribe-details" }, blogSide, blogMain))
);
};

View file

@ -1,5 +1,6 @@
const { div, h2, h3, h4, p, section, button, form, a, span, br, textarea, input, label, select, option, table, tr, td, ul, li } = require("../server/node_modules/hyperaxe")
const { template, i18n, userLink, renderStateChip, renderLifespanChip, renderSpreadButton } = require("./main_views")
const { template, i18n, userLink, renderStateChip, renderLifespanChip, renderSpreadButton , renderSpreadEditWarning, renderContentActions, renderDocumentActions } = require("./main_views")
const { renderMapLocationVisitLabel } = require("./maps_view")
const { renderEncryptedChip } = require("./clearnet_view")
const moment = require("../server/node_modules/moment")
const { config } = require("../server/SSB_server.js")
@ -20,13 +21,6 @@ const renderNoteText = (text) => {
return result
}
const renderCalendarFavoriteToggle = (cal, returnTo) =>
form(
{ method: "POST", action: cal.isFavorite ? `/calendars/favorites/remove/${encodeURIComponent(cal.rootId)}` : `/calendars/favorites/add/${encodeURIComponent(cal.rootId)}` },
returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
button({ type: "submit", class: "tribe-action-btn" }, cal.isFavorite ? (i18n.calendarRemoveFavorite || "Remove Favorite") : (i18n.calendarAddFavorite || "Add Favorite"))
)
const renderModeButtons = (currentFilter) =>
div({ class: "tribe-mode-buttons" },
["all", "mine", "recent", "favorites", "open", "closed"].map(f =>
@ -63,62 +57,62 @@ const renderCalendarCard = (cal, spreadInfo) => {
renderLifespanChip(cal.lifetime, i18n)
].filter(Boolean)
return div({ class: "tribe-card" },
div({ class: "card-header activity-card-header" },
renderContentActions(cal.rootId, href, { spread: spreadInfo || null, author: cal.author, favKind: 'calendars', isFavorite: cal.isFavorite, reportTitle: cal.title })
),
div({ class: "tribe-card-body" },
div({ class: "shop-title-row" },
h2({ class: "tribe-card-title" }, a({ href }, cal.title || "\u2014"))
),
chips.length ? div({ class: "card-chips-row" }, ...chips) : null,
cal.deadline
? p({ class: "job-meta-line" }, `${i18n.calendarDeadlineLabel || "Deadline"}: ${moment(cal.deadline).format("YYYY-MM-DD HH:mm")}`)
? p({ class: "job-meta-line" }, `${i18n.calendarDeadlineLabel || "Deadline"}: ${moment(cal.deadline).format("YYYY/MM/DD HH:mm")}`)
: null,
div({ class: "tribe-card-members" },
span({ class: "tribe-members-count calendar-participants-count" }, `${i18n.calendarParticipantsLabel || "Participants"}: ${cal.participants.length}`)
),
div({ class: "card-spread-centered" }, renderSpreadButton(cal.rootId, spreadInfo)),
div({ class: "card-visit-btn-centered" },
a({ href, class: "filter-btn" }, i18n.calendarVisitCalendar || "Visit Calendar")
)
)
)
}
const renderIntervalBlock = () =>
div({ class: "calendar-interval-block" },
const renderIntervalBlock = (bounds = {}, current = "", until = "") => {
const sel = (v) => (String(current || "") === v ? { selected: true } : {})
return div({ class: "calendar-interval-block" },
span({ class: "calendar-interval-label" }, i18n.calendarIntervalLabel || "Interval"),
div({ class: "calendar-interval-row" },
input({ type: "hidden", name: "intervalWeekly", value: "0" }),
label({ class: "calendar-interval-option" },
input({ type: "checkbox", name: "intervalWeekly", value: "1" }),
" ", i18n.calendarIntervalWeekly || "Weekly"
),
input({ type: "hidden", name: "intervalMonthly", value: "0" }),
label({ class: "calendar-interval-option" },
input({ type: "checkbox", name: "intervalMonthly", value: "1" }),
" ", i18n.calendarIntervalMonthly || "Monthly"
),
input({ type: "hidden", name: "intervalYearly", value: "0" }),
label({ class: "calendar-interval-option" },
input({ type: "checkbox", name: "intervalYearly", value: "1" }),
" ", i18n.calendarIntervalYearly || "Yearly"
)
select({ name: "interval", class: "calendar-interval-select" },
option({ value: "", ...sel("") }, i18n.calendarIntervalNone),
option({ value: "weekly", ...sel("weekly") }, i18n.calendarIntervalWeekly || "Weekly"),
option({ value: "monthly", ...sel("monthly") }, i18n.calendarIntervalMonthly || "Monthly"),
option({ value: "yearly", ...sel("yearly") }, i18n.calendarIntervalYearly || "Yearly")
),
span({ class: "calendar-interval-label calendar-interval-until" }, i18n.calendarIntervalUntil || "Until"),
input({ type: "datetime-local", name: "intervalDeadline" }),
input({
type: "datetime-local",
name: "intervalDeadline",
...(until ? { value: until } : {}),
...(bounds.min ? { min: bounds.min } : {}),
...(bounds.max ? { max: bounds.max } : {})
}),
br()
)
}
const renderCreateForm = (calendarToEdit, params) => {
const isEdit = !!calendarToEdit
const tribeId = (params && params.tribeId) || ""
const now = moment().add(1, "minute").format("YYYY-MM-DDTHH:mm")
const deadlineMax = calendarToEdit && calendarToEdit.deadline
? moment(calendarToEdit.deadline).format("YYYY-MM-DDTHH:mm")
: ""
const action = isEdit ? `/calendars/update/${encodeURIComponent(calendarToEdit.rootId)}` : "/calendars/create"
const sectionTitle = isEdit ? (i18n.calendarUpdateSectionTitle || "Update Calendar") : (i18n.calendarCreateSectionTitle || "Create New Calendar")
return div({ class: "div-center audio-form" },
h2(sectionTitle),
(params && params.spreadWarning) || null,
form({ method: "POST", action },
tribeId ? input({ type: "hidden", name: "tribeId", value: tribeId }) : null,
span(i18n.calendarTitleLabel || "Title"), br(),
input({ type: "text", name: "title", required: true, placeholder: i18n.calendarTitlePlaceholder || "Calendar title...", value: calendarToEdit ? calendarToEdit.title : "" }),
input({ type: "text", name: "title", maxlength: "100", required: true, placeholder: i18n.calendarTitlePlaceholder || "Calendar title...", value: calendarToEdit ? calendarToEdit.title : "" }),
br(), br(),
span(i18n.calendarStatusLabel || "Status"), br(),
select({ name: "status", required: true },
@ -132,17 +126,21 @@ const renderCreateForm = (calendarToEdit, params) => {
span(i18n.calendarTagsLabel || "Tags"), br(),
input({ type: "text", name: "tags", placeholder: i18n.calendarTagsPlaceholder || "tag1, tag2...", value: calendarToEdit && Array.isArray(calendarToEdit.tags) ? calendarToEdit.tags.join(", ") : "" }),
br(), br(),
span(i18n.mapLocationTitle || "Map Location"), br(),
input({ type: "text", name: "mapUrl", placeholder: i18n.mapUrlPlaceholder || "/maps/MAP_ID", value: (calendarToEdit && calendarToEdit.mapUrl) || "" }),
br(), br(),
!isEdit
? [
span(i18n.calendarFirstDateLabel || "Date"), br(),
input({ type: "datetime-local", name: "firstDate", required: true, min: now }),
input({ type: "datetime-local", name: "firstDate", required: true, min: now, max: deadlineMax || undefined }),
br(), br(),
span(i18n.calendarFormDescription || "Description"), br(),
input({ type: "text", name: "firstDateLabel", placeholder: i18n.calendarDatePlaceholder || "Describe this date..." }),
br(), br(),
renderIntervalBlock(),
span(i18n.calendarFirstNoteLabel || "Notes"), br(),
textarea({ name: "firstNote", rows: "3", placeholder: i18n.calendarNotePlaceholder || "Add a note..." }),
br(), br(),
renderIntervalBlock({ min: now, max: deadlineMax || undefined }),
br(), br()
]
: null,
@ -192,17 +190,10 @@ exports.renderCalendarInvitePage = (code) => {
}
exports.calendarsView = async (calendars, filter, calendarToEdit, params) => {
if (calendarToEdit) params = { ...(params || {}), spreadWarning: await renderSpreadEditWarning(calendarToEdit.id || calendarToEdit.key || calendarToEdit.rootId) };
const q = (params && params.q) || ""
const showForm = filter === "create" || filter === "edit" || !!calendarToEdit
const headerMap = {
all: i18n.calendarAllSectionTitle || "Calendars",
mine: i18n.calendarMineSectionTitle || "Your Calendars",
recent: i18n.calendarRecentSectionTitle || "Recent Calendars",
favorites: i18n.calendarFavoritesSectionTitle || "Favorites",
open: i18n.calendarOpenSectionTitle || "Open Calendars",
closed: i18n.calendarClosedSectionTitle || "Closed Calendars"
}
const headerText = headerMap[filter] || headerMap.all
const headerText = i18n.calendarsTitle || "Calendars"
return template(
i18n.calendarsTitle || "Calendars",
@ -212,14 +203,17 @@ exports.calendarsView = async (calendars, filter, calendarToEdit, params) => {
p(i18n.calendarsDescription || "Discover and manage calendars.")
),
renderModeButtons(filter),
q
? div({ class: "filters" },
form({ method: "GET", action: "/calendars" },
input({ type: "text", name: "q", value: q, placeholder: i18n.calendarSearchPlaceholder || "Search calendars..." }),
button({ type: "submit", class: "filter-btn" }, i18n.searchButton || "Search")
showForm
? null
: div({ class: "filters" },
form({ method: "GET", action: "/calendars", class: "filter-box" },
input({ type: "hidden", name: "filter", value: filter }),
input({ type: "text", name: "q", value: q, placeholder: i18n.calendarSearchPlaceholder || "Search calendars...", class: "filter-box__input" }),
div({ class: "filter-box__controls" },
button({ type: "submit", class: "filter-box__button" }, i18n.searchButton)
)
)
)
: null
),
section(
showForm
@ -266,26 +260,17 @@ exports.singleCalendarView = async (calendar, dates, notesByDate, params) => {
h2({ class: "tribe-card-title" }, calendar.title || "\u2014")
),
detailChips.length ? div({ class: "card-chips-row" }, ...detailChips) : null,
div({ class: "card-spread-centered" }, renderSpreadButton(calendar.rootId, params && params.spreads)),
div({ class: "shop-share" },
span({ class: "tribe-info-label" }, i18n.calendarsShareUrl || "Share URL"),
input({ type: "text", readonly: true, value: shareUrl, class: "shop-share-input" })
),
table({ class: "tribe-info-table" },
tr(td({ class: "tribe-info-label" }, i18n.calendarCreated || "Created"), td({ class: "tribe-info-value", colspan: "3" }, moment(calendar.createdAt).format("YYYY-MM-DD"))),
tr(td({ class: "tribe-info-label" }, i18n.calendarCreated || "Created"), td({ class: "tribe-info-value", colspan: "3" }, moment(calendar.createdAt).format("YYYY/MM/DD HH:mm"))),
calendar.deadline ? tr(td({ class: "tribe-info-label" }, i18n.calendarDeadlineLabel || "Deadline"), td({ class: "tribe-info-value", colspan: "3" }, moment(calendar.deadline).format("YYYY/MM/DD HH:mm"))) : null,
tr(td({ class: "tribe-info-label" }, i18n.calendarStatusLabel || "Status"), td({ class: "tribe-info-value", colspan: "3" }, renderStatus(calendar))),
tr(td({ class: "tribe-info-value", colspan: "4" }, userLink(calendar.author))),
calendar.deadline ? tr(td({ class: "tribe-info-label" }, i18n.calendarDeadlineLabel || "Deadline"), td({ class: "tribe-info-value", colspan: "3" }, moment(calendar.deadline).format("YYYY-MM-DD HH:mm"))) : null
tr(td({ class: "tribe-info-value", colspan: "4" }, userLink(calendar.author)))
),
div({ class: "tribe-side-actions" },
renderCalendarFavoriteToggle(calendar, shareUrl),
isAuthor
? form({ method: "GET", action: "/calendars" },
input({ type: "hidden", name: "filter", value: "edit" }),
input({ type: "hidden", name: "id", value: calendar.rootId }),
button({ type: "submit", class: "tribe-action-btn" }, i18n.calendarUpdate || "Update")
)
: null,
tags,
div({ class: "tribe-card-members" },
span({ class: "tribe-members-count calendar-participants-count" }, `${i18n.calendarParticipantsLabel || "Participants"}: ${calendar.participants.length}`)
),
div({ class: "tribe-side-actions calendar-invite-actions" },
isAuthor && calendar.status !== "OPEN"
? form({ method: "POST", action: `/calendars/generate-invite/${encodeURIComponent(calendar.rootId)}` },
button({ type: "submit", class: "tribe-action-btn" }, i18n.tribeGenerateInvite)
@ -295,27 +280,18 @@ exports.singleCalendarView = async (calendar, dates, notesByDate, params) => {
if (!(isAuthor && !calendar.tribeId)) return null
const invs = Array.isArray(calendar.invites) ? calendar.invites : []
const openInvite = invs.find(inv => typeof inv === "object" && inv && inv.public === true && inv.code)
if (openInvite) return [
if (openInvite) return div({ class: "calendar-open-invite-block" },
div({ class: "tribe-open-invite" },
span({ class: "card-label" }, i18n.tribeInviteCodeText),
span({ class: "tribe-open-invite-code" }, openInvite.code)
),
form({ method: "POST", action: `/calendars/open-invite/remove/${encodeURIComponent(calendar.rootId)}` },
button({ type: "submit", class: "tribe-action-btn danger-btn" }, i18n.tribeRemoveInvitation)
)
]
)
return form({ method: "POST", action: `/calendars/open-invite/create/${encodeURIComponent(calendar.rootId)}` },
button({ type: "submit", class: "tribe-action-btn" }, i18n.tribeOpenInvitation)
)
})(),
isAuthor
? form({ method: "POST", action: `/calendars/delete/${encodeURIComponent(calendar.rootId)}` },
button({ type: "submit", class: "tribe-action-btn danger-btn" }, i18n.calendarDelete || "Delete")
)
: null,
!isAuthor
? a({ href: `/pm?to=${encodeURIComponent(calendar.author)}`, class: "tribe-action-btn" }, "PM")
: null,
!isAuthor && !isParticipant && calendar.status === "OPEN"
? form({ method: "POST", action: `/calendars/join/${encodeURIComponent(calendar.rootId)}` },
button({ type: "submit", class: "create-button" }, i18n.calendarJoin || "Join Calendar")
@ -330,34 +306,43 @@ exports.singleCalendarView = async (calendar, dates, notesByDate, params) => {
)
: null
),
tags,
div({ class: "tribe-card-members" },
span({ class: "tribe-members-count calendar-participants-count" }, `${i18n.calendarParticipantsLabel || "Participants"}: ${calendar.participants.length}`)
)
isAuthor
? div({ class: "tribe-side-actions calendar-owner-actions" },
form({ method: "GET", action: "/calendars" },
input({ type: "hidden", name: "filter", value: "edit" }),
input({ type: "hidden", name: "id", value: calendar.rootId }),
button({ type: "submit", class: "tribe-action-btn" }, i18n.calendarUpdate || "Update")
),
form({ method: "POST", action: `/calendars/delete/${encodeURIComponent(calendar.rootId)}` },
button({ type: "submit", class: "tribe-action-btn danger-btn" }, i18n.calendarDelete || "Delete")
)
)
: null,
renderDocumentActions('calendars', calendar.rootId)
)
const minDate = now.add(1, "minute").format("YYYY-MM-DDTHH:mm")
const calMax = calendar.deadline ? moment(calendar.deadline).format("YYYY-MM-DDTHH:mm") : ""
const canAddDate = !calClosed && (calendar.status === "OPEN" || isAuthor)
const unifiedForm = canAddDate
? div({ class: "div-center audio-form" },
h4(i18n.calendarAddEntry || "Add Entry"),
form({ method: "POST", action: `/calendars/add-date/${encodeURIComponent(calendar.rootId)}` },
span(i18n.calendarDateLabel || "Date"), br(),
input({ type: "datetime-local", name: "date", required: true, min: minDate }),
input({ type: "datetime-local", name: "date", required: true, min: minDate, max: calMax || undefined }),
br(), br(),
span(i18n.calendarFormDescription || "Description"), br(),
input({ type: "text", name: "label", placeholder: i18n.calendarDatePlaceholder || "Describe this date..." }),
br(), br(),
renderIntervalBlock(),
br(),
isParticipant
? [
span(i18n.calendarNoteLabel || "Note (optional)"), br(),
span(i18n.calendarNoteLabel), br(),
textarea({ name: "text", rows: "3", placeholder: i18n.calendarNotePlaceholder || "Add a note..." }),
br(), br()
]
: null,
renderIntervalBlock({ min: minDate, max: calMax || undefined }),
br(),
button({ type: "submit", class: "create-button" }, i18n.calendarAddEntry || "Add Entry")
)
)
@ -391,7 +376,7 @@ exports.singleCalendarView = async (calendar, dates, notesByDate, params) => {
)
: null,
div({ class: "calendar-date-item-header" },
`${moment(d.date).format("YYYY-MM-DD HH:mm")}${d.label ? " \u2014 " + d.label : ""}`
`${moment(d.date).format("YYYY/MM/DD HH:mm")}${d.label ? " \u2014 " + d.label : ""}`
),
(() => {
const visibleNotes = notes.filter(n => n.text && String(n.text).trim())
@ -424,6 +409,17 @@ exports.singleCalendarView = async (calendar, dates, notesByDate, params) => {
: null
const calMain = div({ class: "tribe-main" },
div({ class: "card-header activity-card-header" },
renderContentActions(calendar.rootId, null, {
author: calendar.author,
favKind: 'calendars',
isFavorite: calendar.isFavorite,
spread: (params && params.spreads) || null,
returnTo: shareUrl,
reportTitle: calendar.title
})
),
calNav,
grid,
dayNotesSection,
@ -442,3 +438,5 @@ exports.singleCalendarView = async (calendar, dates, notesByDate, params) => {
section(div({ class: "tribe-details" }, calSide, calMain))
)
}
exports.renderIntervalBlock = renderIntervalBlock

View file

@ -1069,17 +1069,18 @@ const CaseSearch = (filter, search = '') =>
div(
{ class: 'filters' },
form(
{ method: 'GET', action: '/courts' },
{ method: 'GET', action: '/courts', class: 'filter-box' },
input({ type: 'hidden', name: 'filter', value: filter }),
input({
type: 'text',
name: 'search',
placeholder: i18n.searchCasesPlaceholder,
value: search || ''
value: search || '',
class: 'filter-box__input'
}),
br(),
button({ type: 'submit' }, i18n.applyFilters),
br()
div({ class: 'filter-box__controls' },
button({ type: 'submit', class: 'filter-box__button' }, i18n.searchButton)
)
)
);

View file

@ -1,5 +1,5 @@
const { form, button, div, h2, p, section, textarea, label, input, br, img, a, select, option, span } = require("../server/node_modules/hyperaxe");
const { template, i18n, userLink, renderVisibilityChip } = require('./main_views');
const { form, button, div, h2, p, section, textarea, label, input, br, img, a, select, option, span, table, tr, td } = require("../server/node_modules/hyperaxe");
const { template, i18n, userLink, renderVisibilityChip, renderDocumentActions } = require('./main_views');
const { renderUrl } = require('../backend/renderUrl');
const generateCVBox = (label, content, className) => {
@ -9,22 +9,8 @@ const generateCVBox = (label, content, className) => {
);
};
const generateTags = (tags) => {
return tags && tags.length
? div(
tags.map(tag =>
a({
href: `/search?query=%23${encodeURIComponent(tag)}`,
class: "tag-link",
style: "margin-right:0.8em;margin-bottom:0.5em;"
}, `#${tag}`)
)
)
: null;
};
exports.createCVView = async (cv = {}, editMode = false) => {
const title = editMode ? i18n.cvEditSectionTitle : i18n.cvCreateSectionTitle;
const title = i18n.cvTitle;
return template(
title,
@ -40,6 +26,20 @@ exports.createCVView = async (cv = {}, editMode = false) => {
enctype: "multipart/form-data"
},
div({ class: "cv-box cv-ai-box" },
div({ class: "poll-switch" },
input({ type: "hidden", name: "aiManaged", value: "0" }),
label(
input({ type: "checkbox", name: "aiManaged", value: "1", ...(cv.aiManaged === false ? {} : { checked: true }) }),
" ", i18n.cvAiManaged
)
),
div({ class: "cv-threshold-row" },
label({ for: "cv_match_threshold" }, `${i18n.cvMatchThreshold}: `),
input({ type: "number", id: "cv_match_threshold", name: "matchThreshold", min: "0", max: "100", step: "5", value: String(cv.matchThreshold != null ? cv.matchThreshold : 80) })
)
),
generateCVBox(i18n.cvPersonal, [
label(i18n.cvNameLabel), br(),
input({ type: "text", name: "name", required: true, value: cv.name || "" }), br(),
@ -125,12 +125,99 @@ exports.cvView = async (cv) => {
)
}
const hasPersonal = cv.contact || cv.name || cv.description || cv.photo || typeof cv.oasisContributor === "boolean" || (cv.personalSkills && cv.personalSkills.length);
const hasPersonalExp = cv.personalExperiences;
const hasOasis = cv.oasisExperiences || (cv.oasisSkills && cv.oasisSkills.length);
const hasEducational = cv.educationExperiences || cv.languages || (cv.educationalSkills && cv.educationalSkills.length);
const hasProfessional = cv.professionalExperiences || (cv.professionalSkills && cv.professionalSkills.length);
const hasAvailability = cv.location || cv.status || cv.preferences;
const skills = (list) => (Array.isArray(list) ? list.filter(Boolean) : []);
const allSkills = [
...skills(cv.personalSkills),
...skills(cv.educationalSkills),
...skills(cv.professionalSkills),
...skills(cv.oasisSkills)
];
const uniqueSkills = Array.from(new Set(allSkills));
const renderSkillTags = (list) => skills(list).length
? div({ class: "card-tags" },
skills(list).map(tag =>
a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`))
)
: null;
const renderBlock = (heading, body, list) => (body || skills(list).length)
? div({ class: "cv-box" },
h2({ class: "job-section-title" }, heading),
body ? p({ class: "tribe-side-description" }, ...renderUrl(String(body))) : null,
renderSkillTags(list)
)
: null;
const vis = (cv.visibility || 'PUBLIC').toUpperCase() === 'HIDDEN' ? 'HIDDEN' : 'PUBLIC';
const nextVis = vis === 'PUBLIC' ? 'HIDDEN' : 'PUBLIC';
const infoRows = [];
const pushRow = (labelText, valueNode) =>
infoRows.push(tr(
td({ class: "tribe-info-label" }, labelText),
td({ class: "tribe-info-value" }, valueNode)
));
if (cv.location) pushRow(i18n.cvLocationLabel, cv.location);
if (cv.status) pushRow(i18n.cvStatusLabel, cv.status);
if (cv.preferences) pushRow(i18n.cvPreferencesLabel, cv.preferences);
if (cv.languages) pushRow(i18n.cvLanguagesLabel, String(cv.languages).toUpperCase());
pushRow(i18n.cvAiManaged, cv.aiManaged === false ? i18n.switchOff : `${i18n.switchOn} · ${cv.matchThreshold != null ? cv.matchThreshold : 80}%`);
pushRow(i18n.cvCreatedAt, new Date(cv.createdAt).toLocaleString());
if (cv.updatedAt) pushRow(i18n.cvUpdatedAt, new Date(cv.updatedAt).toLocaleString());
const cvSide = div({ class: "tribe-side" },
div({ class: "shop-title-row" },
h2({ class: "tribe-card-title" }, cv.name || i18n.unnamed || 'Anonymous')
),
cv.photo
? img({ src: `/blob/${encodeURIComponent(cv.photo)}`, class: "cv-photo tribe-detail-image" })
: null,
(cv.contact || cv.author)
? div({ class: 'profile-qr' },
a({ href: `/author/${encodeURIComponent(cv.contact || cv.author)}` },
img({ class: 'profile-qr-img', src: `/qr/${encodeURIComponent(cv.contact || cv.author)}?size=240`, alt: 'QR' })))
: null,
cv.contact ? p(userLink(cv.contact)) : null,
table({ class: "tribe-info-table jobs-info-table" }, ...infoRows),
uniqueSkills.length
? div({ class: "tribe-card-members" },
span({ class: "tribe-members-count" }, `${i18n.cvSkillsCount}: ${uniqueSkills.length}`)
)
: null,
div({ class: "tribe-side-actions cv-visibility-row housing-status-row" },
span({ class: "card-label" }, `${i18n.visibilityLabel || 'Visibility'}: `),
renderVisibilityChip(vis, i18n),
form({ method: "POST", action: `/cv/visibility/${encodeURIComponent(cv.id)}`, class: "inline-form" },
input({ type: "hidden", name: "visibility", value: nextVis }),
button({ type: "submit", class: "filter-btn" },
nextVis === 'PUBLIC' ? (i18n.visibilityMakePublic || 'Make public') : (i18n.visibilityMakeHidden || 'Make hidden')
)
)
),
div({ class: "tribe-side-actions" },
form({ method: "GET", action: `/cv/edit/${encodeURIComponent(cv.id)}` },
button({ type: "submit", class: "update-btn" }, i18n.cvEditButton)
),
form({ method: "POST", action: `/cv/delete/${encodeURIComponent(cv.id)}` },
button({ type: "submit", class: "delete-btn" }, i18n.cvDeleteButton)
)
),
renderDocumentActions('cv', null)
);
const cvMain = div({ class: "tribe-main" },
cv.description
? div({ class: "job-section" },
h2({ class: "job-section-title" }, i18n.cvDescriptionLabel),
p({ class: "tribe-side-description" }, ...renderUrl(String(cv.description)))
)
: null,
renderBlock(i18n.cvPersonal, cv.personalExperiences, cv.personalSkills),
renderBlock(i18n.cvEducationalView, cv.educationExperiences, cv.educationalSkills),
renderBlock(i18n.cvProfessionalView, cv.professionalExperiences, cv.professionalSkills),
renderBlock(i18n.cvOasisContributorView, cv.oasisExperiences, cv.oasisSkills)
);
return template(
title,
@ -139,119 +226,7 @@ exports.cvView = async (cv) => {
h2(title),
p(i18n.cvDescription)
),
div({ class: "cv-section" },
div({ class: "cv-item" }, ...[
(() => {
const vis = (cv.visibility || 'PUBLIC').toUpperCase() === 'HIDDEN' ? 'HIDDEN' : 'PUBLIC';
const next = vis === 'PUBLIC' ? 'HIDDEN' : 'PUBLIC';
return div({ class: "cv-visibility-row" },
span({ class: "card-label" }, `${i18n.visibilityLabel || 'Visibility'}: `),
renderVisibilityChip(vis, i18n),
" ",
form({ method: "POST", action: `/cv/visibility/${encodeURIComponent(cv.id)}`, class: "inline-form" },
input({ type: "hidden", name: "visibility", value: next }),
button({ type: "submit", class: "filter-btn" },
next === 'PUBLIC' ? (i18n.visibilityMakePublic || 'Make public') : (i18n.visibilityMakeHidden || 'Make hidden')
)
)
);
})(),
div({ class: "cv-actions" },
form({ method: "GET", action: `/cv/edit/${encodeURIComponent(cv.id)}` },
button({ type: "submit" }, i18n.cvEditButton)
),
form({ method: "POST", action: `/cv/delete/${encodeURIComponent(cv.id)}` },
button({ type: "submit", class: "danger-btn" }, i18n.cvDeleteButton)
)
),
div({ class: "cv-meta" },
p(`${i18n.cvCreatedAt}: ${new Date(cv.createdAt).toLocaleString()}`),
cv.updatedAt ? p(`${i18n.cvUpdatedAt}: ${new Date(cv.updatedAt).toLocaleString()}`) : null
),
hasPersonal ? div({ class: "cv-box personal" }, ...[
cv.photo
? img({
src: `/blob/${encodeURIComponent(cv.photo)}`,
class: "cv-photo"
})
: null,
cv.name ? h2(`${cv.name}`) : null,
(cv.contact || cv.author)
? a({ href: `/author/${encodeURIComponent(cv.contact || cv.author)}`, class: 'inhabitant-qr-link' },
img({ class: 'inhabitant-qr-small', src: `/qr/${encodeURIComponent(cv.contact || cv.author)}?size=120`, alt: 'QR' }))
: null,
cv.contact ? p(userLink(cv.contact)) : null,
cv.description ? p(...renderUrl(`${cv.description}`)) : null,
(cv.personalSkills && cv.personalSkills.length)
? div(
cv.personalSkills.map(tag =>
a({
href: `/search?query=%23${encodeURIComponent(tag)}`,
class: "tag-link",
style: "margin-right:0.8em;margin-bottom:0.5em;"
}, `#${tag}`)
)
)
: null
]) : null,
hasPersonal ? div({ class: "cv-box personal" }, ...[
h2(i18n.cvLanguagesLabel),
cv.languages ? p(`${cv.languages.toUpperCase()}`) : null
]) : null,
hasEducational ? div({ class: "cv-box education" }, ...[
h2(i18n.cvEducationalView),
cv.educationExperiences ? p(...renderUrl(`${cv.educationExperiences}`)) : null,
(cv.educationalSkills && cv.educationalSkills.length)
? div(
cv.educationalSkills.map(tag =>
a({
href: `/search?query=%23${encodeURIComponent(tag)}`,
class: "tag-link",
style: "margin-right:0.8em;margin-bottom:0.5em;"
}, `#${tag}`)
)
)
: null
]) : null,
hasProfessional ? div({ class: "cv-box professional" }, ...[
h2(i18n.cvProfessionalView),
cv.professionalExperiences ? p(...renderUrl(`${cv.professionalExperiences}`)) : null,
(cv.professionalSkills && cv.professionalSkills.length)
? div(
cv.professionalSkills.map(tag =>
a({
href: `/search?query=%23${encodeURIComponent(tag)}`,
class: "tag-link",
style: "margin-right:0.8em;margin-bottom:0.5em;"
}, `#${tag}`)
)
)
: null
]) : null,
hasOasis ? div({ class: "cv-box oasis" }, ...[
h2(i18n.cvOasisContributorView),
cv.oasisExperiences ? p(...renderUrl(`${cv.oasisExperiences}`)) : null,
(cv.oasisSkills && cv.oasisSkills.length)
? div(
cv.oasisSkills.map(tag =>
a({
href: `/search?query=%23${encodeURIComponent(tag)}`,
class: "tag-link",
style: "margin-right:0.8em;margin-bottom:0.5em;"
}, `#${tag}`)
)
)
: null
]) : null,
hasAvailability ? div({ class: "cv-box availability" }, ...[
h2(i18n.cvAvailabilityView),
cv.location ? p(`${i18n.cvLocationLabel}: ${cv.location}`) : null,
cv.status ? p(`${i18n.cvStatusLabel}: ${cv.status}`) : null,
cv.preferences ? p(`${i18n.cvPreferencesLabel}: ${cv.preferences}`) : null
]) : null
])
)
div({ class: "tribe-details" }, cvSide, cvMain)
)
);
};

172
src/views/data_view.js Normal file
View file

@ -0,0 +1,172 @@
const { div, h2, p, section, button, form, a, input, span, table, tr, td, ul, li } = require("../server/node_modules/hyperaxe");
const { template, i18n, userLink } = require("./main_views");
const KIND_LABEL = {
inhabitants: () => i18n.dataKindInhabitants,
jobs: () => i18n.dataKindJobs,
projects: () => i18n.dataKindProjects,
events: () => i18n.dataKindEvents,
tribes: () => i18n.dataKindTribes,
market: () => i18n.dataKindMarket,
housing: () => i18n.dataKindHousing,
industry: () => i18n.dataKindIndustry,
tasks: () => i18n.dataKindTasks,
reports: () => i18n.dataKindReports,
votes: () => i18n.dataKindVotes,
audios: () => i18n.audioTitle,
videos: () => i18n.videoTitle,
images: () => i18n.imageTitle,
documents: () => i18n.documentTitle,
bookmarks: () => i18n.bookmarkTitle,
torrents: () => i18n.typeTorrent,
chats: () => i18n.chatsTitle,
pads: () => i18n.padsTitle,
maps: () => i18n.mapTitle,
calendars: () => i18n.calendarsTitle,
forum: () => i18n.forumTitle
};
const FILTER_COLUMNS = [
["ALL", "RECENT", "TOP"],
["INHABITANTS", "TRIBES"],
["VOTES", "EVENTS", "CALENDARS", "TASKS", "REPORTS"],
["MARKET", "HOUSING", "JOBS", "PROJECTS", "INDUSTRY"],
["FORUM", "CHATS", "PADS", "MAPS"],
["AUDIOS", "BOOKMARKS", "DOCUMENTS", "IMAGES", "TORRENTS", "VIDEOS"]
];
const kindLabel = (kind) => (KIND_LABEL[kind] ? KIND_LABEL[kind]() : String(kind || '').toUpperCase());
const filterLabel = (key) => {
const map = {
ALL: i18n.dataFilterAll, MINE: i18n.dataFilterMine,
RECENT: i18n.dataFilterRecent, TOP: i18n.dataFilterTop
};
return map[key] || kindLabel(key.toLowerCase());
};
const filterButton = (mode, current, q) =>
form({ method: "GET", action: "/data" },
input({ type: "hidden", name: "q", value: q || "" }),
button({
type: "submit", name: "filter", value: mode,
class: current === mode ? "filter-btn active" : "filter-btn"
}, String(filterLabel(mode)).toUpperCase())
);
const renderFilters = (current, q) =>
section(
div({ class: "activity-filter-grid" },
...FILTER_COLUMNS.map(col =>
div({ class: "activity-filter-col" },
...col.map(mode => filterButton(mode, current, q))
)
)
),
div({ class: "data-search" },
form({ method: "GET", action: "/data", class: "filter-box" },
input({ type: "hidden", name: "filter", value: current }),
input({ type: "text", name: "q", value: q || "", placeholder: i18n.dataSearchPlaceholder, class: "filter-box__input" }),
div({ class: "filter-box__controls" },
button({ type: "submit", class: "filter-box__button" }, i18n.searchButton)
)
)
)
);
const scorePct = (score) => ((Number(score) || 0) * 100).toFixed(1).replace(/\.0$/, '');
const scaleOf = (pct) => Math.min(4, Math.floor((Number(pct) || 0) / 20));
const renderCohesion = (c) => {
const measurable = c.entities >= 2;
const pct = measurable ? Number(c.percent) || 0 : 0;
return section(
div({ class: "tags-header" },
h2(i18n.dataCohesionTitle),
p(i18n.dataCohesionHint)
),
div({ class: "data-cohesion" },
p({ class: "data-cohesion-value" },
span({ class: "card-label data-cohesion-label" }, `${i18n.dataCcShort}: `),
measurable ? `${c.percent}%` : "—"),
div({ class: "poll-bar data-cohesion-bar" },
div({ class: `poll-bar-fill poll-bar-fill-${Math.round(pct / 5) * 5} data-scale-${scaleOf(pct)}` })),
div({ class: "data-cohesion-figures" },
div({ class: "data-figure" },
span({ class: "data-figure-value" }, String(c.entities)),
span({ class: "data-figure-label" }, i18n.dataStatEntities)
),
div({ class: "data-figure" },
span({ class: "data-figure-value" }, String(c.distinctTerms)),
span({ class: "data-figure-label" }, i18n.dataStatTerms)
),
div({ class: "data-figure" },
span({ class: "data-figure-value" }, String(c.pairs)),
span({ class: "data-figure-label" }, i18n.dataStatPairs)
),
div({ class: "data-figure" },
span({ class: "data-figure-value" }, String(c.isolated || 0)),
span({ class: "data-figure-label" }, i18n.dataStatIsolated)
),
div({ class: "data-figure" },
span({ class: "data-figure-value" }, String(c.connected || 0)),
span({ class: "data-figure-label" }, i18n.dataStatConnected)
)
)
)
);
};
const shortId = (id) => {
const value = String(id || "");
return value.length > 14 ? `${value.slice(0, 12)}` : value;
};
const renderMatchRow = (m, isBest) => {
const common = Array.isArray(m.common) ? m.common : [];
return div({ class: isBest ? "data-card data-best-card" : "data-card" },
div({ class: "data-side-top" },
span({ class: "data-kind-chip" }, String(kindLabel(m.kind)).toUpperCase()),
span({ class: "card-label data-score-label" }, `${isBest ? i18n.dataBestMatch : (i18n.dataCcShort || "CC")}: `),
span({ class: isBest ? "data-score-value data-best-score" : "data-score-value" }, `${scorePct(m.score)}%`),
a({ href: m.href, class: "btn-singleview btn-content data-side-visit", title: i18n.visitContent }, "↗")
),
a({ href: m.href, class: "data-card-title" }, m.title || shortId(m.id)),
span({ class: "data-card-author" }, userLink(m.author)),
common.length
? div({ class: "data-common-row" },
span({ class: "card-label" }, `${i18n.dataCommonTerms}: `),
...common.slice(0, 8).map(t =>
a({ href: `/search?query=%23${encodeURIComponent(t)}`, class: "tag-link" }, `#${t}`))
)
: null
);
};
exports.dataView = async (payload = {}) => {
const filter = String(payload.filter || 'ALL').toUpperCase();
const q = payload.q || '';
const matches = Array.isArray(payload.matches) ? payload.matches : [];
return template(
i18n.dataTitle,
section(div({ class: "tags-header" }, h2(i18n.dataTitle), p(i18n.dataDescription))),
renderFilters(filter, q),
payload.cohesion ? renderCohesion(payload.cohesion) : null,
section(
div({ class: "tags-header" },
h2(q ? `${i18n.dataTopicTitle} #${q}` : i18n.dataMatchesTitle),
p(q ? i18n.dataTopicHint : i18n.dataMatchesHint),
typeof payload.total === "number" && payload.total > matches.length
? p({ class: "data-cohesion-hint" }, `${matches.length} / ${payload.total}`)
: null
),
matches.length
? div({ class: "data-list" }, ...matches.map((m, idx) => renderMatchRow(m, idx === 0)))
: p({ class: "no-content" }, payload.hasProfile === false
? i18n.dataNoProfile
: i18n.dataNoMatches)
)
);
};

View file

@ -1,8 +1,9 @@
const { form, button, div, h2, p, section, input, label, br, a, span, textarea, select, option } =
require("../server/node_modules/hyperaxe");
const { renderCommentsSection: renderSharedCommentsSection, renderCommentsLink } = require("./comments_view");
const moment = require("../server/node_modules/moment");
const { template, i18n, renderOpinionsVoting, userLink, renderSpreadButton, renderEcoTax, renderLifespanChip, renderContentActions } = require("./main_views");
const { template, i18n, renderOpinionsVoting, renderEngagement, userLink, renderSpreadButton, renderEcoTax, renderLifespanChip, renderContentActions , renderSpreadEditWarning } = require("./main_views");
const { config } = require("../server/SSB_server.js");
const { renderUrl } = require("../backend/renderUrl");
@ -33,21 +34,6 @@ const renderTags = (tags) => {
: null;
};
const renderFavoriteToggle = (doc, returnTo) =>
form(
{
method: "POST",
action: doc.isFavorite
? `/documents/favorites/remove/${encodeURIComponent(doc.key)}`
: `/documents/favorites/add/${encodeURIComponent(doc.key)}`
},
returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
button(
{ type: "submit", class: "filter-btn" },
doc.isFavorite ? i18n.documentRemoveFavoriteButton : i18n.documentAddFavoriteButton
)
);
const renderDocumentActions = (filter, doc, params = {}) => {
const returnTo = buildReturnTo(filter, params);
const isAuthor = String(doc.author) === String(userId);
@ -73,70 +59,11 @@ const renderDocumentActions = (filter, doc, params = {}) => {
};
const renderDocumentCommentsSection = (documentKey, rootId, comments = [], returnTo = null) => {
const list = safeArr(comments).filter(c => {
const t = c && c.value && c.value.content && c.value.content.text;
return t && String(t).trim();
return renderSharedCommentsSection({
action: `/documents/${encodeURIComponent(documentKey)}/comments`,
comments: comments,
returnTo: returnTo
});
const commentsCount = list.length;
return div(
{ class: "vote-comments-section" },
div(
{ class: "comments-count" },
span({ class: "card-label" }, i18n.voteCommentsLabel + ": "),
span({ class: "card-value" }, String(commentsCount))
),
div(
{ class: "comment-form-wrapper" },
h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
form(
{ method: "POST", action: `/documents/${encodeURIComponent(documentKey)}/comments`, class: "comment-form", enctype: "multipart/form-data" },
returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
rootId ? input({ type: "hidden", name: "rootId", value: rootId }) : null,
textarea({
id: "comment-text",
name: "text",
rows: 4,
class: "comment-textarea",
placeholder: i18n.voteNewCommentPlaceholder
}),
div({ class: "comment-file-upload" }, label(i18n.uploadMedia), input({ type: "file", name: "blob" })),
br(),
button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
)
),
list.length
? div(
{ class: "comments-list" },
list.map((c) => {
const author = c?.value?.author || "";
const ts = c?.value?.timestamp || c?.timestamp;
const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : "";
const relDate = ts ? moment(ts).fromNow() : "";
const content = c?.value?.content || {};
const text = content.text || "";
const threadRoot = content.fork || content.root || null;
return div(
{ class: "votations-comment-card" },
span(
{ class: "created-at" },
span(i18n.createdBy),
author ? userLink(author) : span("(unknown)"),
absDate ? span(" | ") : "",
absDate ? span({ class: "votations-comment-date" }, absDate) : "",
relDate ? span({ class: "votations-comment-date" }, " | ", i18n.sendTime) : "",
relDate && threadRoot
? a({ href: `/thread/${encodeURIComponent(threadRoot)}#${encodeURIComponent(c.key)}` }, relDate)
: ""
),
p({ class: "votations-comment-text" }, ...renderUrl(text))
);
})
)
: p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet)
);
};
const renderDocumentList = exports.renderDocumentList = (documents, filter, params = {}) => {
@ -163,49 +90,19 @@ const renderDocumentList = exports.renderDocumentList = (documents, filter, para
div(
{ class: "card-header activity-card-header" },
span(),
renderContentActions(doc.key, `/documents/${encodeURIComponent(doc.key)}`)
renderContentActions(doc.key, `/documents/${encodeURIComponent(doc.key)}`, { spread: (params.spreadMap && params.spreadMap.get(doc.key)) || params.spreads || null, author: doc.author, favKind: 'documents', isFavorite: doc.isFavorite, reportTitle: doc.title })
),
div(
{ class: "card-section document-card-body" },
div(
{ class: "bookmark-topbar" },
div(
{ class: "bookmark-topbar-left" },
renderFavoriteToggle(doc, returnTo),
topbarLeft
),
renderDocumentActions(filter, doc, params)
),
title ? h2(title) : null,
doc.lifetime ? div({ class: "card-chips-row" }, renderLifespanChip(doc.lifetime, i18n)) : null,
doc?.url
? div({ id: pdfId, class: "pdf-viewer-container", "data-pdf-url": `/blob/${encodeURIComponent(doc.url)}` })
: p(i18n.documentNoFile),
div(
{ class: "card-comments-summary" },
span({ class: "card-label" }, i18n.voteCommentsLabel + ":"),
span({ class: "card-value" }, String(commentCount)),
br(),
br(),
form(
{ method: "GET", action: `/documents/${encodeURIComponent(doc.key)}` },
input({ type: "hidden", name: "returnTo", value: returnTo }),
input({ type: "hidden", name: "filter", value: filter || "all" }),
params.q ? input({ type: "hidden", name: "q", value: params.q }) : null,
params.sort ? input({ type: "hidden", name: "sort", value: params.sort }) : null,
button({ type: "submit", class: "filter-btn" }, i18n.voteCommentsForumButton)
)
renderEngagement(doc.key,
renderOpinionsVoting('/documents/opinions', doc.key, doc.opinions, returnTo, doc.opinions_inhabitants),
renderCommentsLink({ href: `/documents/${encodeURIComponent(doc.key)}`, count: commentCount })
),
div(
{ class: "card-comments-summary feed-opinions-section" },
div(
{ class: "comments-count" },
span({ class: "card-label" }, (i18n.opinionsTitle || "Opinions") + ": "),
span({ class: "card-value" }, String(Object.values(doc.opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0)))
),
renderOpinionsVoting('/documents/opinions', doc.key, doc.opinions, returnTo, doc.opinions_inhabitants)
),
div({ class: "card-spread-left" }, renderSpreadButton(doc.key, (params.spreadMap && params.spreadMap.get(doc.key)) || params.spreads)),
br(),
(() => {
const createdTs = doc.createdAt ? new Date(doc.createdAt).getTime() : NaN;
@ -214,12 +111,12 @@ const renderDocumentList = exports.renderDocumentList = (documents, filter, para
return p(
{ class: "card-footer" },
span({ class: "date-link" }, `${moment(doc.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
span({ class: "date-link" }, `${moment(doc.createdAt).format("YYYY/MM/DD HH:mm")} ${i18n.performed} `),
userLink(doc.author),
showUpdated
? span(
{ class: "votations-comment-date" },
` | ${i18n.documentUpdatedAt}: ${moment(doc.updatedAt).format("YYYY/MM/DD HH:mm:ss")}`
` | ${i18n.documentUpdatedAt}: ${moment(doc.updatedAt).format("YYYY/MM/DD HH:mm")}`
)
: null
);
@ -237,6 +134,7 @@ const renderDocumentForm = (filter, documentId, docToEdit, params = {}) => {
return div(
{ class: "div-center document-form" },
params.spreadWarning || null,
form(
{
action: filter === "edit" ? `/documents/update/${encodeURIComponent(documentId)}` : "/documents/create",
@ -251,7 +149,7 @@ const renderDocumentForm = (filter, documentId, docToEdit, params = {}) => {
br(),
label(i18n.documentTitleLabel),
br(),
input({ type: "text", name: "title", placeholder: i18n.documentTitlePlaceholder, value: docToEdit?.title || "" }),
input({ type: "text", name: "title", maxlength: "100", placeholder: i18n.documentTitlePlaceholder, value: docToEdit?.title || "" }),
br(),
label(i18n.documentDescriptionLabel),
br(),
@ -268,20 +166,8 @@ const renderDocumentForm = (filter, documentId, docToEdit, params = {}) => {
};
exports.documentView = async (documents, filter = "all", documentId = null, params = {}) => {
const title =
filter === "mine"
? i18n.documentMineSectionTitle
: filter === "create"
? i18n.documentCreateSectionTitle
: filter === "edit"
? i18n.documentUpdateSectionTitle
: filter === "recent"
? i18n.documentRecentSectionTitle
: filter === "top"
? i18n.documentTopSectionTitle
: filter === "favorites"
? i18n.documentFavoritesSectionTitle
: i18n.documentAllSectionTitle;
if (filter === "edit") params = { ...params, spreadWarning: await renderSpreadEditWarning(documentId) };
const title = i18n.documentTitle;
const q = safeText(params.q || "");
const sort = safeText(params.sort || "recent");
@ -308,14 +194,14 @@ exports.documentView = async (documents, filter = "all", documentId = null, para
{ method: "GET", action: "/documents", class: "ui-toolbar ui-toolbar--filters" },
input({ type: "hidden", name: "q", value: q }),
input({ type: "hidden", name: "sort", value: sort }),
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, i18n.documentFilterAll),
button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.documentFilterMine),
button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, i18n.documentFilterTop),
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, String(i18n.documentFilterAll).toUpperCase()),
button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, String(i18n.documentFilterMine).toUpperCase()),
button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, String(i18n.documentFilterRecent).toUpperCase()),
button(
{ type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" },
i18n.documentFilterFavorites
String(i18n.documentFilterFavorites).toUpperCase()
),
button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.documentFilterRecent),
button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, String(i18n.documentFilterTop).toUpperCase()),
button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.documentCreateButton)
)
)
@ -334,9 +220,9 @@ exports.documentView = async (documents, filter = "all", documentId = null, para
{ class: "filter-box__controls" },
select(
{ name: "sort", class: "filter-box__select" },
option({ value: "recent", selected: sort === "recent" }, i18n.documentSortRecent),
option({ value: "oldest", selected: sort === "oldest" }, i18n.documentSortOldest),
option({ value: "top", selected: sort === "top" }, i18n.documentSortTop)
option({ value: "recent", ...(sort === "recent" ? { selected: true } : {})}, i18n.documentSortRecent),
option({ value: "oldest", ...(sort === "oldest" ? { selected: true } : {})}, i18n.documentSortOldest),
option({ value: "top", ...(sort === "top" ? { selected: true } : {})}, i18n.documentSortTop)
),
button({ type: "submit", class: "filter-box__button" }, i18n.documentSearchButton)
)
@ -368,7 +254,6 @@ exports.singleDocumentView = async (doc, filter = "all", comments = [], params =
].filter(Boolean);
const sideActions = [];
sideActions.push(renderFavoriteToggle(doc, returnTo));
if (doc.author && String(doc.author) !== String(userId)) {
sideActions.push(form(
{ method: "GET", action: "/pm" },
@ -393,6 +278,17 @@ exports.singleDocumentView = async (doc, filter = "all", comments = [], params =
const tagsNode = renderTags(doc.tags);
const detailActions = div({ class: "card-header activity-card-header" },
renderContentActions(doc.key, null, {
author: doc.author,
favKind: 'documents',
isFavorite: doc.isFavorite,
spread: params.spreads || null,
returnTo,
reportTitle: doc.title
})
);
const docSide = div({ class: "tribe-side" },
div({ class: "shop-title-row" },
title ? h2({ class: "tribe-card-title" }, title) : null,
@ -403,11 +299,11 @@ exports.singleDocumentView = async (doc, filter = "all", comments = [], params =
? p({ class: "tribe-side-description" }, ...renderUrl(doc.description))
: null,
tagsNode,
div({ class: "card-spread-centered" }, renderSpreadButton(doc.key, params.spreads)),
sideActions.length ? div({ class: "tribe-side-actions" }, ...sideActions) : null
);
const docMain = div({ class: "tribe-main" },
detailActions,
doc?.url
? div({ id: pdfId, class: "pdf-viewer-container", "data-pdf-url": `/blob/${encodeURIComponent(doc.url)}` })
: p(i18n.documentNoFile),
@ -418,18 +314,20 @@ exports.singleDocumentView = async (doc, filter = "all", comments = [], params =
return p(
{ class: "card-footer" },
span({ class: "date-link" }, `${moment(doc.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
span({ class: "date-link" }, `${moment(doc.createdAt).format("YYYY/MM/DD HH:mm")} ${i18n.performed} `),
userLink(doc.author),
showUpdated
? span(
{ class: "votations-comment-date" },
` | ${i18n.documentUpdatedAt}: ${moment(doc.updatedAt).format("YYYY/MM/DD HH:mm:ss")}`
` | ${i18n.documentUpdatedAt}: ${moment(doc.updatedAt).format("YYYY/MM/DD HH:mm")}`
)
: null
);
})(),
renderOpinionsVoting('/documents/opinions', doc.key, doc.opinions, returnTo, doc.opinions_inhabitants),
renderDocumentCommentsSection(doc.key, doc.rootId || doc.key, comments, returnTo)
renderEngagement(doc.key,
renderOpinionsVoting('/documents/opinions', doc.key, doc.opinions, returnTo, doc.opinions_inhabitants),
renderDocumentCommentsSection(doc.key, doc.rootId || doc.key, comments, returnTo)
)
);
const tpl = template(
@ -445,14 +343,14 @@ exports.singleDocumentView = async (doc, filter = "all", comments = [], params =
{ method: "GET", action: "/documents", class: "ui-toolbar ui-toolbar--filters" },
input({ type: "hidden", name: "q", value: q }),
input({ type: "hidden", name: "sort", value: sort }),
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, i18n.documentFilterAll),
button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.documentFilterMine),
button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, i18n.documentFilterTop),
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, String(i18n.documentFilterAll).toUpperCase()),
button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, String(i18n.documentFilterMine).toUpperCase()),
button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, String(i18n.documentFilterRecent).toUpperCase()),
button(
{ type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" },
i18n.documentFilterFavorites
String(i18n.documentFilterFavorites).toUpperCase()
),
button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.documentFilterRecent),
button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, String(i18n.documentFilterTop).toUpperCase()),
button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.documentCreateButton)
)
),

View file

@ -1,5 +1,8 @@
const { div, h2, p, section, button, form, a, span, textarea, br, input, label, select, option, table, tr, td } = require("../server/node_modules/hyperaxe");
const { template, i18n, renderOpinionsVoting, userLink, renderStateChip, renderOpenClosedChip, renderPrivacyChip, renderLifespanChip, renderEcoTax, renderSpreadButton, renderContentActions } = require("./main_views");
const { div, h2, p, section, button, form, a, span, textarea, br, input, label, select, option, table, tr, td, details, summary, ul, li } = require("../server/node_modules/hyperaxe");
const { renderCommentsSection: renderSharedCommentsSection } = require("./comments_view");
const { template, i18n, renderOpinionsVoting, renderEngagement, userLink, renderStateChip, renderOpenClosedChip, renderPrivacyChip, renderLifespanChip, renderEcoTax, renderSpreadButton, renderContentActions, renderSpreadEditWarning, renderDocumentActions } = require("./main_views");
const { renderPhotoGallery, renderGalleryFields } = require("./gallery_view");
const { renderIntervalBlock } = require("./calendars_view");
const moment = require("../server/node_modules/moment");
const { config } = require("../server/SSB_server.js");
const { renderUrl } = require("../backend/renderUrl");
@ -18,7 +21,7 @@ exports.renderEventInvitePage = (code) => {
};
const opt = (value, isSelected, text) =>
option(Object.assign({ value }, isSelected ? { selected: "selected" } : {}), text);
option(Object.assign({ value }, isSelected ? { ...("selected" ? { selected: true } : {})} : {}), text);
const safeArray = (v) => (Array.isArray(v) ? v : []);
@ -105,70 +108,12 @@ const renderEventAttendAction = (e, isAttending, returnTo) => {
};
const renderEventCommentsSection = (eventId, comments = [], currentFilter = "all") => {
const commentsCount = Array.isArray(comments) ? comments.length : 0;
const returnTo = `/events/${encodeURIComponent(eventId)}?filter=${encodeURIComponent(currentFilter || "all")}`;
return div(
{ class: "vote-comments-section" },
div(
{ class: "comments-count" },
span({ class: "card-label" }, i18n.voteCommentsLabel + ": "),
span({ class: "card-value" }, String(commentsCount))
),
div(
{ class: "comment-form-wrapper" },
h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
form(
{ method: "POST", action: `/events/${encodeURIComponent(eventId)}/comments`, class: "comment-form", enctype: "multipart/form-data" },
input({ type: "hidden", name: "returnTo", value: returnTo }),
textarea({
id: "comment-text",
name: "text",
rows: 4,
class: "comment-textarea",
placeholder: i18n.voteNewCommentPlaceholder
}),
div({ class: "comment-file-upload" }, label(i18n.uploadMedia), input({ type: "file", name: "blob" })),
br(),
button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
)
),
(() => {
const visibleComments = (comments || []).filter(c => {
const t = c && c.value && c.value.content && c.value.content.text;
return t && String(t).trim();
});
return visibleComments.length
? div(
{ class: "comments-list" },
visibleComments.map((c) => {
const author = c.value && c.value.author ? c.value.author : "";
const ts = c.value && c.value.timestamp ? c.value.timestamp : c.timestamp;
const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : "";
const relDate = ts ? moment(ts).fromNow() : "";
const content = c.value && c.value.content ? c.value.content : {};
const root = content.fork || content.root || "";
const text = content.text || "";
return div(
{ class: "votations-comment-card" },
span(
{ class: "created-at" },
span(i18n.createdBy),
author ? userLink(author) : span("(unknown)"),
absDate ? span(" | ") : "",
absDate ? span({ class: "votations-comment-date" }, absDate) : "",
relDate ? span({ class: "votations-comment-date" }, " | ", i18n.sendTime) : "",
relDate && root ? a({ href: `/thread/${encodeURIComponent(root)}#${encodeURIComponent(c.key)}` }, relDate) : ""
),
p({ class: "votations-comment-text" }, ...renderUrl(String(text)))
);
})
)
: p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet);
})()
);
return renderSharedCommentsSection({
action: `/events/${encodeURIComponent(eventId)}/comments`,
comments: comments,
returnTo: returnTo
});
};
const renderEventStatusChip = (status) => {
@ -198,7 +143,7 @@ const renderEventItem = exports.renderEventItem = (e, filter, spreadInfo) => {
div(
{ class: "card-header activity-card-header" },
span(),
renderContentActions(e.id, `/events/${encodeURIComponent(e.id)}?filter=${encodeURIComponent(currentFilter)}`)
renderContentActions(e.id, `/events/${encodeURIComponent(e.id)}?filter=${encodeURIComponent(currentFilter)}`, { spread: spreadInfo || null, author: e.organizer || e.author, favKind: 'events', isFavorite: e.isFavorite, reportTitle: e.title })
),
div({ class: "card-section event-card-body" },
div({ class: "shop-title-row" },
@ -216,8 +161,7 @@ const renderEventItem = exports.renderEventItem = (e, filter, spreadInfo) => {
: null,
div({ class: "tribe-card-members" },
span({ class: "tribe-members-count" }, `${i18n.eventAttendees}: ${attendees.length}`)
),
div({ class: "card-spread-centered" }, renderSpreadButton(e.id, spreadInfo))
)
)
);
};
@ -228,14 +172,12 @@ exports.eventView = async (events, filter, eventId, returnTo, params = {}) => {
const { renderReachChip: renderReachChipEvents } = require('./clearnet_view');
const viewerClearnetEvents = !!(params.viewerPrefs && params.viewerPrefs.clearnetEvents);
const title =
currentFilter === "mine" ? i18n.eventMineSectionTitle :
currentFilter === "create" ? i18n.eventCreateSectionTitle :
currentFilter === "edit" ? i18n.eventUpdateSectionTitle :
i18n.eventAllSectionTitle;
const title = i18n.eventsTitle;
const eventToEdit = list.find((e) => e.id === eventId) || {};
const editTags = Array.isArray(eventToEdit.tags) ? eventToEdit.tags.filter(Boolean) : [];
const formData = currentFilter === "edit" ? eventToEdit : (params.draft || {});
const spreadWarning = currentFilter === "edit" ? await renderSpreadEditWarning(eventToEdit && eventToEdit.id) : null;
const editTags = Array.isArray(formData.tags) ? formData.tags.filter(Boolean) : (typeof formData.tags === "string" ? formData.tags.split(",").map(t => t.trim()).filter(Boolean) : []);
const canSee = (e) => {
const isPub = normalizePrivacy(e.isPublic) === "public";
@ -270,7 +212,12 @@ exports.eventView = async (events, filter, eventId, returnTo, params = {}) => {
const minCreate = moment().add(1, "minute").format("YYYY-MM-DDTHH:mm");
const ret = typeof returnTo === "string" && returnTo.startsWith("/events") ? returnTo : "/events?filter=mine";
const editPrivacy = normalizePrivacy(eventToEdit.isPublic);
const editPrivacy = normalizePrivacy(formData.isPublic);
const editInterval = formData.interval
|| (formData.intervalWeekly ? "weekly" : formData.intervalMonthly ? "monthly" : formData.intervalYearly ? "yearly" : "");
const editIntervalUntil = (formData.recurrenceUntil || formData.intervalDeadline)
? moment(formData.recurrenceUntil || formData.intervalDeadline).format("YYYY-MM-DDTHH:mm")
: "";
return template(
title,
@ -285,21 +232,33 @@ exports.eventView = async (events, filter, eventId, returnTo, params = {}) => {
{ class: "filters" },
form(
{ method: "GET", action: "/events" },
button({ type: "submit", name: "filter", value: "all", class: currentFilter === "all" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterAll),
button({ type: "submit", name: "filter", value: "mine", class: currentFilter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterMine),
button({ type: "submit", name: "filter", value: "today", class: currentFilter === "today" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterToday),
button({ type: "submit", name: "filter", value: "week", class: currentFilter === "week" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterWeek),
button({ type: "submit", name: "filter", value: "month", class: currentFilter === "month" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterMonth),
button({ type: "submit", name: "filter", value: "year", class: currentFilter === "year" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterYear),
button({ type: "submit", name: "filter", value: "archived", class: currentFilter === "archived" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterArchived),
button({ type: "submit", name: "filter", value: "all", class: currentFilter === "all" ? "filter-btn active" : "filter-btn" }, String(i18n.eventFilterAll).toUpperCase()),
button({ type: "submit", name: "filter", value: "mine", class: currentFilter === "mine" ? "filter-btn active" : "filter-btn" }, String(i18n.eventFilterMine).toUpperCase()),
button({ type: "submit", name: "filter", value: "today", class: currentFilter === "today" ? "filter-btn active" : "filter-btn" }, String(i18n.eventFilterToday).toUpperCase()),
button({ type: "submit", name: "filter", value: "week", class: currentFilter === "week" ? "filter-btn active" : "filter-btn" }, String(i18n.eventFilterWeek).toUpperCase()),
button({ type: "submit", name: "filter", value: "month", class: currentFilter === "month" ? "filter-btn active" : "filter-btn" }, String(i18n.eventFilterMonth).toUpperCase()),
button({ type: "submit", name: "filter", value: "year", class: currentFilter === "year" ? "filter-btn active" : "filter-btn" }, String(i18n.eventFilterYear).toUpperCase()),
button({ type: "submit", name: "filter", value: "archived", class: currentFilter === "archived" ? "filter-btn active" : "filter-btn" }, String(i18n.eventFilterArchived).toUpperCase()),
button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.eventCreateButton)
)
)
),
currentFilter === "edit" || currentFilter === "create"
? null
: div({ class: "filters" },
form({ method: "GET", action: "/events", class: "filter-box" },
input({ type: "hidden", name: "filter", value: currentFilter }),
input({ type: "text", name: "q", value: params.q || "", placeholder: i18n.eventSearchPlaceholder, class: "filter-box__input" }),
div({ class: "filter-box__controls" },
button({ type: "submit", class: "filter-box__button" }, i18n.searchButton)
)
)
)
),
section(
currentFilter === "edit" || currentFilter === "create"
? div(
{ class: "event-form" },
spreadWarning,
form(
{
action: currentFilter === "edit" ? `/events/update/${encodeURIComponent(eventId)}` : "/events/create",
@ -311,23 +270,20 @@ exports.eventView = async (events, filter, eventId, returnTo, params = {}) => {
br(),
input({
type: "text",
name: "title",
name: "title", maxlength: "100",
id: "title",
required: true,
value: currentFilter === "edit" ? eventToEdit.title || "" : ""
value: formData.title || ""
}),
br(),
label(i18n.eventDescriptionLabel),
br(),
textarea(
{ name: "description", id: "description", placeholder: i18n.eventDescriptionPlaceholder, rows: "4" },
currentFilter === "edit" ? eventToEdit.description || "" : ""
formData.description || ""
),
br(),
label(i18n.uploadMedia),
br(),
input({ type: "file", name: "image", accept: "image/*" }),
br(),
...renderGalleryFields(formData, currentFilter === "edit"),
br(),
label(i18n.eventDateLabel),
br(),
@ -337,19 +293,10 @@ exports.eventView = async (events, filter, eventId, returnTo, params = {}) => {
id: "date",
required: true,
min: currentFilter === "create" ? minCreate : undefined,
value: currentFilter === "edit" && eventToEdit.date ? moment(eventToEdit.date).format("YYYY-MM-DDTHH:mm") : ""
value: formData.date ? moment(formData.date).format("YYYY-MM-DDTHH:mm") : ""
}),
br(),
br(),
label(i18n.eventPrivacyLabel),
br(),
select(
{ name: "isPublic", id: "isPublic" },
opt("public", editPrivacy !== "private", i18n.eventPublic),
opt("private", editPrivacy === "private", i18n.eventPrivate)
),
br(),
br(),
label(i18n.eventLocationLabel),
br(),
input({
@ -357,17 +304,17 @@ exports.eventView = async (events, filter, eventId, returnTo, params = {}) => {
name: "location",
id: "location",
required: true,
value: currentFilter === "edit" ? eventToEdit.location || "" : ""
value: formData.location || ""
}),
br(),
label(i18n.mapLocationTitle || "Map Location"),
br(),
input({ type: "text", name: "mapUrl", placeholder: i18n.mapUrlPlaceholder || "/maps/MAP_ID", value: eventToEdit?.mapUrl || "" }),
input({ type: "text", name: "mapUrl", placeholder: i18n.mapUrlPlaceholder || "/maps/MAP_ID", value: formData.mapUrl || "" }),
br(),
br(),
label(i18n.eventUrlLabel),
br(),
input({ type: "url", name: "url", id: "url", value: currentFilter === "edit" ? eventToEdit.url || "" : "" }),
input({ type: "url", name: "url", id: "url", value: formData.url || "" }),
br(),
br(),
label(i18n.eventPriceLabel),
@ -378,13 +325,28 @@ exports.eventView = async (events, filter, eventId, returnTo, params = {}) => {
id: "price",
min: "0.000000",
step: "0.000001",
value: currentFilter === "edit" ? parseFloat(eventToEdit.price || 0).toFixed(6) : (0).toFixed(6)
value: parseFloat(formData.price || 0).toFixed(6)
}),
br(),
br(),
label(i18n.eventTagsLabel),
br(),
input({ type: "text", name: "tags", id: "tags", value: currentFilter === "edit" ? editTags.join(", ") : "" }),
input({ type: "text", name: "tags", id: "tags", value: editTags.join(", ") }),
br(),
br(),
div({ class: "event-recurrence-block" },
label(i18n.eventRecurrenceLabel),
br(),
renderIntervalBlock({ min: minCreate }, editInterval, editIntervalUntil)
),
br(),
label(i18n.eventPrivacyLabel),
br(),
select(
{ name: "isPublic", id: "isPublic" },
opt("public", editPrivacy !== "private", i18n.eventPublic),
opt("private", editPrivacy === "private", i18n.eventPrivate)
),
br(),
br(),
...(currentFilter === "create" ? [
@ -419,13 +381,13 @@ exports.singleEventView = async (event, filter, comments = [], params = {}) => {
{ class: "filters" },
form(
{ method: "GET", action: "/events" },
button({ type: "submit", name: "filter", value: "all", class: currentFilter === "all" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterAll),
button({ type: "submit", name: "filter", value: "mine", class: currentFilter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterMine),
button({ type: "submit", name: "filter", value: "today", class: currentFilter === "today" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterToday),
button({ type: "submit", name: "filter", value: "week", class: currentFilter === "week" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterWeek),
button({ type: "submit", name: "filter", value: "month", class: currentFilter === "month" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterMonth),
button({ type: "submit", name: "filter", value: "year", class: currentFilter === "year" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterYear),
button({ type: "submit", name: "filter", value: "archived", class: currentFilter === "archived" ? "filter-btn active" : "filter-btn" }, i18n.eventFilterArchived),
button({ type: "submit", name: "filter", value: "all", class: currentFilter === "all" ? "filter-btn active" : "filter-btn" }, String(i18n.eventFilterAll).toUpperCase()),
button({ type: "submit", name: "filter", value: "mine", class: currentFilter === "mine" ? "filter-btn active" : "filter-btn" }, String(i18n.eventFilterMine).toUpperCase()),
button({ type: "submit", name: "filter", value: "today", class: currentFilter === "today" ? "filter-btn active" : "filter-btn" }, String(i18n.eventFilterToday).toUpperCase()),
button({ type: "submit", name: "filter", value: "week", class: currentFilter === "week" ? "filter-btn active" : "filter-btn" }, String(i18n.eventFilterWeek).toUpperCase()),
button({ type: "submit", name: "filter", value: "month", class: currentFilter === "month" ? "filter-btn active" : "filter-btn" }, String(i18n.eventFilterMonth).toUpperCase()),
button({ type: "submit", name: "filter", value: "year", class: currentFilter === "year" ? "filter-btn active" : "filter-btn" }, String(i18n.eventFilterYear).toUpperCase()),
button({ type: "submit", name: "filter", value: "archived", class: currentFilter === "archived" ? "filter-btn active" : "filter-btn" }, String(i18n.eventFilterArchived).toUpperCase()),
button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.eventCreateButton)
)
);
@ -455,20 +417,14 @@ exports.singleEventView = async (event, filter, comments = [], params = {}) => {
].filter(Boolean);
const sideActions = [];
if (event.organizer && event.organizer !== userId) {
sideActions.push(form({ method: "GET", action: "/pm" },
input({ type: "hidden", name: "recipients", value: event.organizer }),
button({ type: "submit", class: "filter-btn" }, i18n.privateMessage)
));
}
const attendNode = renderEventAttendAction(event, isAttending, returnToSelf);
if (attendNode) sideActions.push(attendNode);
if (params.linkedCalendarId) {
sideActions.push(form({ method: "GET", action: `/calendars/${encodeURIComponent(params.linkedCalendarId)}` },
button({ type: "submit", class: "filter-btn" }, i18n.eventVisitCalendar || "Visit calendar")
button({ type: "submit", class: "tribe-action-btn" }, i18n.eventVisitCalendar || "Visit calendar")
));
}
if (isOrganizer) sideActions.push(...renderEventOwnerActions(event, returnToSelf));
const ownerActions = isOrganizer ? renderEventOwnerActions(event, returnToSelf) : [];
const tagsNode = event.tags && event.tags.filter(Boolean).length
? div({ class: "card-tags" },
@ -482,7 +438,16 @@ exports.singleEventView = async (event, filter, comments = [], params = {}) => {
td({ class: "tribe-info-label" }, labelText),
td({ class: "tribe-info-value" }, valueNode)
));
if (event.date) pushRow(i18n.eventDateLabel, moment(event.date).format("YYYY/MM/DD HH:mm"));
if (event.date) pushRow(i18n.eventDateLabel, moment(event.recurring && event.nextDate ? event.nextDate : event.date).format("YYYY/MM/DD HH:mm"));
if (event.recurring) {
const parts = [
event.intervalWeekly ? (i18n.calendarIntervalWeekly || "Weekly") : null,
event.intervalMonthly ? (i18n.calendarIntervalMonthly || "Monthly") : null,
event.intervalYearly ? (i18n.calendarIntervalYearly || "Yearly") : null
].filter(Boolean);
pushRow(i18n.eventRecurrenceLabel, parts.join(" · "));
if (event.recurrenceUntil) pushRow(i18n.eventRecurrenceUntil, moment(event.recurrenceUntil).format("YYYY/MM/DD HH:mm"));
}
pushRow(i18n.eventStatus, eventStatusLabel(event.status));
pushRow(i18n.eventPrivacyLabel, privacyLabel(event.isPublic));
if (event.location && String(event.location).trim()) pushRow(i18n.eventLocationLabel, event.location);
@ -497,18 +462,31 @@ exports.singleEventView = async (event, filter, comments = [], params = {}) => {
: null;
const eventSide = div({ class: "tribe-side" },
div({ class: "card-header activity-card-header" },
renderContentActions(event.id, null, { spread: params.spreads || null, author: event.organizer || event.author, favKind: 'events', isFavorite: event.isFavorite, reportTitle: event.title })
),
div({ class: "shop-title-row" },
h2({ class: "tribe-card-title" }, event.title)
),
chips.length ? div({ class: "card-chips-row" }, ...chips) : null,
div({ class: "card-spread-centered" }, renderSpreadButton(event.id, params.spreads)),
renderPhotoGallery(event, 'event'),
table({ class: "tribe-info-table jobs-info-table" }, ...infoRows),
event.recurring && Array.isArray(event.occurrences) && event.occurrences.length > 1
? details({ class: "event-occurrences" },
summary({ class: "chat-thread-summary" }, `${i18n.eventUpcomingDates} (${event.occurrences.length})`),
ul({ class: "event-occurrence-list" },
...event.occurrences.map(d => li(moment(d).format("YYYY/MM/DD HH:mm")))
)
)
: null,
tagsNode,
div({ class: "tribe-card-members" },
span({ class: "tribe-members-count" }, `${i18n.eventAttendees}: ${attendees.length}`)
),
attendeesListNode,
sideActions.length ? div({ class: "tribe-side-actions" }, ...sideActions) : null
sideActions.length ? div({ class: "tribe-side-actions" }, ...sideActions) : null,
ownerActions.length ? div({ class: "tribe-side-actions owner-actions" }, ...ownerActions) : null,
renderDocumentActions('events', event.id)
);
const returnToOpinions = `/events/${encodeURIComponent(event.id)}?filter=${encodeURIComponent(currentFilter)}`;
@ -526,13 +504,13 @@ exports.singleEventView = async (event, filter, comments = [], params = {}) => {
span({ class: "date-link" }, `${moment(event.createdAt).format("YYYY/MM/DD HH:mm")} ${i18n.performed} `),
userLink(event.organizer)
),
opinionsBar,
renderEventCommentsSection(event.id, comments, currentFilter)
renderEngagement(event.id, opinionsBar, renderEventCommentsSection(event.id, comments, currentFilter))
);
return template(
event.title,
section(
div({ class: "tags-header" }, h2(i18n.eventsTitle), p(i18n.eventsDescription)),
filterBar,
div({ class: "tribe-details" }, eventSide, eventMain)
)
@ -543,7 +521,7 @@ exports.clearnetEventView = async (event) => {
const { escapeHtml: esc, renderClearnetPage } = require('./clearnet_view');
const title = esc(event.title || 'Event');
const desc = esc(event.description || '');
const dateStr = event.date ? esc(moment(event.date).format("YYYY-MM-DD HH:mm")) : '';
const dateStr = event.date ? esc(moment(event.date).format("YYYY/MM/DD HH:mm")) : '';
const loc = esc(event.location || '');
const price = parseFloat(event.price || 0);
const priceStr = price > 0 ? `${price.toFixed(2)} ECO` : '';

View file

@ -1,8 +1,9 @@
const { form, button, div, h2, p, section, input, label, br, a, img, span, textarea, select, option } =
require("../server/node_modules/hyperaxe");
const { renderCommentsSection: renderSharedCommentsSection, renderCommentsLink } = require("./comments_view");
const moment = require("../server/node_modules/moment");
const { template, i18n, renderOpinionsVoting, userLink, renderSpreadButton, renderEcoTax, renderLifespanChip, renderStateChip, renderContentActions } = require("./main_views");
const { template, i18n, renderOpinionsVoting, renderEngagement, userLink, renderSpreadButton, renderEcoTax, renderLifespanChip, renderStateChip, renderContentActions , renderSpreadEditWarning } = require("./main_views");
const { config } = require("../server/SSB_server.js");
const { renderUrl } = require("../backend/renderUrl")
const { renderMapLocationVisitLabel } = require("./maps_view");
@ -22,18 +23,6 @@ const buildReturnTo = (filter, params = {}) => {
return `/images?${parts.join("&")}`;
};
const renderPMButton = (recipient, className = "filter-btn") => {
const r = safeText(recipient);
if (!r) return null;
if (String(r) === String(userId)) return null;
return form(
{ method: "GET", action: "/pm" },
input({ type: "hidden", name: "recipients", value: r }),
button({ type: "submit", class: className }, i18n.privateMessage)
);
};
const renderTags = (tags) => {
const list = safeArr(tags).map((t) => String(t || "").trim()).filter(Boolean);
return list.length
@ -44,27 +33,12 @@ const renderTags = (tags) => {
: null;
};
const renderImageFavoriteToggle = (imgObj, returnTo = "") =>
form(
{
method: "POST",
action: imgObj.isFavorite
? `/images/favorites/remove/${encodeURIComponent(imgObj.key)}`
: `/images/favorites/add/${encodeURIComponent(imgObj.key)}`
},
returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
button(
{ type: "submit", class: "filter-btn" },
imgObj.isFavorite ? i18n.imageRemoveFavoriteButton : i18n.imageAddFavoriteButton
)
);
const renderImageMedia = (imgObj, filter, params = {}) => {
const src = imgObj?.url ? `/blob/${encodeURIComponent(imgObj.url)}` : "";
return imgObj?.url
? div(
{ class: "image-container", style: "display:flex;gap:8px;align-items:center;flex-wrap:wrap;" },
{ class: "image-container image-container-row" },
a(
{
href: `/images/${encodeURIComponent(imgObj.key)}?filter=${encodeURIComponent(filter || "all")}${
@ -111,7 +85,6 @@ const renderImageList = exports.renderImageList = (images, filter, params = {})
? images.map((imgObj) => {
const commentCount = typeof imgObj.commentCount === "number" ? imgObj.commentCount : 0;
const title = safeText(imgObj.title);
const ownerActions = renderImageOwnerActions(filter, imgObj, params);
const isOwn = imgObj.author && String(imgObj.author) === String(userId);
return div(
@ -119,50 +92,19 @@ const renderImageList = exports.renderImageList = (images, filter, params = {})
div(
{ class: "card-header activity-card-header" },
span(),
renderContentActions(imgObj.key, `/images/${encodeURIComponent(imgObj.key)}`)
renderContentActions(imgObj.key, `/images/${encodeURIComponent(imgObj.key)}`, { spread: (params.spreadMap && params.spreadMap.get(imgObj.key)) || params.spreads || null, author: imgObj.author, favKind: 'images', isFavorite: imgObj.isFavorite, reportTitle: imgObj.title })
),
div(
{ class: "card-section image-card-body" },
div(
{ class: "bookmark-topbar" },
div(
{ class: "bookmark-topbar-left" },
renderImageFavoriteToggle(imgObj, returnTo),
renderPMButton(imgObj.author)
),
ownerActions.length ? div({ class: "bookmark-actions" }, ...ownerActions) : null
),
title ? h2(title) : null,
(imgObj.meme || imgObj.lifetime) ? div({ class: "card-chips-row" },
imgObj.meme ? a({ href: "/images?filter=meme", class: "chip-link" }, renderStateChip("mutuals", null, i18n.imageFilterMeme || "MEME")) : null,
imgObj.lifetime ? div({ class: "card-chips-row" },
imgObj.lifetime ? renderLifespanChip(imgObj.lifetime, i18n) : null
) : null,
renderImageMedia(imgObj, filter, params),
div(
{ class: "card-comments-summary" },
span({ class: "card-label" }, i18n.voteCommentsLabel + ":"),
span({ class: "card-value" }, String(commentCount)),
br(),
br(),
form(
{ method: "GET", action: `/images/${encodeURIComponent(imgObj.key)}` },
input({ type: "hidden", name: "returnTo", value: returnTo }),
input({ type: "hidden", name: "filter", value: filter || "all" }),
params.q ? input({ type: "hidden", name: "q", value: params.q }) : null,
params.sort ? input({ type: "hidden", name: "sort", value: params.sort }) : null,
button({ type: "submit", class: "filter-btn" }, i18n.voteCommentsForumButton)
)
renderEngagement(imgObj.key,
renderOpinionsVoting('/images/opinions', imgObj.key, imgObj.opinions, returnTo, imgObj.opinions_inhabitants),
renderCommentsLink({ href: `/images/${encodeURIComponent(imgObj.key)}`, count: commentCount })
),
div(
{ class: "card-comments-summary feed-opinions-section" },
div(
{ class: "comments-count" },
span({ class: "card-label" }, (i18n.opinionsTitle || "Opinions") + ": "),
span({ class: "card-value" }, String(Object.values(imgObj.opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0)))
),
renderOpinionsVoting('/images/opinions', imgObj.key, imgObj.opinions, returnTo, imgObj.opinions_inhabitants)
),
div({ class: "card-spread-left" }, renderSpreadButton(imgObj.key, (params.spreadMap && params.spreadMap.get(imgObj.key)) || params.spreads)),
renderMapLocationVisitLabel(imgObj.mapUrl),
br(),
(() => {
@ -172,12 +114,12 @@ const renderImageList = exports.renderImageList = (images, filter, params = {})
return p(
{ class: "card-footer" },
span({ class: "date-link" }, `${moment(imgObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
span({ class: "date-link" }, `${moment(imgObj.createdAt).format("YYYY/MM/DD HH:mm")} ${i18n.performed} `),
userLink(imgObj.author),
showUpdated
? span(
{ class: "votations-comment-date" },
` | ${i18n.imageUpdatedAt}: ${moment(imgObj.updatedAt).format("YYYY/MM/DD HH:mm:ss")}`
` | ${i18n.imageUpdatedAt}: ${moment(imgObj.updatedAt).format("YYYY/MM/DD HH:mm")}`
)
: null
);
@ -195,6 +137,7 @@ const renderImageForm = (filter, imageId, imageToEdit, params = {}) => {
return div(
{ class: "div-center image-form" },
params.spreadWarning || null,
form(
{
action: filter === "edit" ? `/images/update/${encodeURIComponent(imageId)}` : "/images/create",
@ -212,7 +155,7 @@ const renderImageForm = (filter, imageId, imageToEdit, params = {}) => {
: null,
label(i18n.imageTitleLabel),
br(),
input({ type: "text", name: "title", placeholder: i18n.imageTitlePlaceholder, value: imageToEdit?.title || "" }),
input({ type: "text", name: "title", maxlength: "100", placeholder: i18n.imageTitlePlaceholder, value: imageToEdit?.title || "" }),
br(),
label(i18n.imageDescriptionLabel),
br(),
@ -222,21 +165,10 @@ const renderImageForm = (filter, imageId, imageToEdit, params = {}) => {
br(),
input({ type: "text", name: "mapUrl", placeholder: i18n.mapUrlPlaceholder || "/maps/MAP_ID", value: imageToEdit?.mapUrl || "" }),
br(),
input({ type: "hidden", name: "meme", value: "0" }),
label(i18n.imageTagsLabel),
br(),
input({ type: "text", name: "tags", placeholder: i18n.imageTagsPlaceholder, value: tagsValue }),
br(),
label(i18n.imageMemeLabel),
input({
id: "meme-checkbox",
type: "checkbox",
name: "meme",
value: "1",
class: "meme-checkbox",
...(imageToEdit?.meme ? { checked: true } : {})
}),
br(),
br(),
button({ type: "submit" }, filter === "edit" ? i18n.imageUpdateButton : i18n.imageCreateButton)
)
@ -269,88 +201,16 @@ const renderLightbox = (images) =>
});
const renderImageCommentsSection = (imageKey, comments = [], returnTo = null) => {
const list = safeArr(comments).filter(c => {
const t = c && c.value && c.value.content && c.value.content.text;
return t && String(t).trim();
return renderSharedCommentsSection({
action: `/images/${encodeURIComponent(imageKey)}/comments`,
comments: comments,
returnTo: returnTo
});
const commentsCount = list.length;
return div(
{ class: "vote-comments-section" },
div(
{ class: "comments-count" },
span({ class: "card-label" }, i18n.voteCommentsLabel + ": "),
span({ class: "card-value" }, String(commentsCount))
),
div(
{ class: "comment-form-wrapper" },
h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
form(
{ method: "POST", action: `/images/${encodeURIComponent(imageKey)}/comments`, class: "comment-form", enctype: "multipart/form-data" },
returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
textarea({
id: "comment-text",
name: "text",
rows: 4,
class: "comment-textarea",
placeholder: i18n.voteNewCommentPlaceholder
}),
div({ class: "comment-file-upload" }, label(i18n.uploadMedia), input({ type: "file", name: "blob" })),
br(),
button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
)
),
list.length
? div(
{ class: "comments-list" },
list.map((c) => {
const author = c?.value?.author || "";
const ts = c?.value?.timestamp || c?.timestamp;
const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : "";
const relDate = ts ? moment(ts).fromNow() : "";
const content = c?.value?.content || {};
const text = content.text || "";
const threadRoot = content.fork || content.root || null;
return div(
{ class: "votations-comment-card" },
span(
{ class: "created-at" },
span(i18n.createdBy),
author ? userLink(author) : span("(unknown)"),
absDate ? span(" | ") : "",
absDate ? span({ class: "votations-comment-date" }, absDate) : "",
relDate ? span({ class: "votations-comment-date" }, " | ", i18n.sendTime) : "",
relDate && threadRoot ? a({ href: `/thread/${encodeURIComponent(threadRoot)}#${encodeURIComponent(c.key)}` }, relDate) : ""
),
p({ class: "votations-comment-text" }, ...renderUrl(text))
);
})
)
: p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet)
);
};
exports.imageView = async (images, filter = "all", imageId = null, params = {}) => {
const title =
filter === "mine"
? i18n.imageMineSectionTitle
: filter === "create"
? i18n.imageCreateSectionTitle
: filter === "edit"
? i18n.imageUpdateSectionTitle
: filter === "gallery"
? i18n.imageGallerySectionTitle
: filter === "meme"
? i18n.imageMemeSectionTitle
: filter === "recent"
? i18n.imageRecentSectionTitle
: filter === "top"
? i18n.imageTopSectionTitle
: filter === "favorites"
? i18n.imageFavoritesSectionTitle
: i18n.imageAllSectionTitle;
if (filter === "edit") params = { ...params, spreadWarning: await renderSpreadEditWarning(imageId) };
const title = i18n.imageTitle;
const q = safeText(params.q || "");
const sort = safeText(params.sort || "recent");
@ -377,19 +237,18 @@ exports.imageView = async (images, filter = "all", imageId = null, params = {})
{ method: "GET", action: "/images", class: "ui-toolbar ui-toolbar--filters" },
input({ type: "hidden", name: "q", value: q }),
input({ type: "hidden", name: "sort", value: sort }),
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, i18n.imageFilterAll),
button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.imageFilterMine),
button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.imageFilterRecent),
button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, i18n.imageFilterTop),
button(
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, String(i18n.imageFilterAll).toUpperCase()),
button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, String(i18n.imageFilterMine).toUpperCase()),
button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, String(i18n.imageFilterRecent).toUpperCase()), button(
{ type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" },
i18n.imageFilterFavorites
String(i18n.imageFilterFavorites).toUpperCase()
),
button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, String(i18n.imageFilterTop).toUpperCase()),
button(
{ type: "submit", name: "filter", value: "gallery", class: filter === "gallery" ? "filter-btn active" : "filter-btn" },
i18n.imageFilterGallery
String(i18n.imageFilterGallery).toUpperCase()
),
button({ type: "submit", name: "filter", value: "meme", class: filter === "meme" ? "filter-btn active" : "filter-btn" }, i18n.imageFilterMeme),
button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.imageCreateButton)
)
)
@ -414,9 +273,9 @@ exports.imageView = async (images, filter = "all", imageId = null, params = {})
{ class: "filter-box__controls" },
select(
{ name: "sort", class: "filter-box__select" },
option({ value: "recent", selected: sort === "recent" }, i18n.imageSortRecent),
option({ value: "oldest", selected: sort === "oldest" }, i18n.imageSortOldest),
option({ value: "top", selected: sort === "top" }, i18n.imageSortTop)
option({ value: "recent", ...(sort === "recent" ? { selected: true } : {})}, i18n.imageSortRecent),
option({ value: "oldest", ...(sort === "oldest" ? { selected: true } : {})}, i18n.imageSortOldest),
option({ value: "top", ...(sort === "top" ? { selected: true } : {})}, i18n.imageSortTop)
),
button({ type: "submit", class: "filter-box__button" }, i18n.imageSearchButton)
)
@ -438,25 +297,28 @@ exports.singleImageView = async (imageObj, filter = "all", comments = [], params
const { renderReachChip } = require('./clearnet_view');
const isClearnet = !!(params.authorPrefs && params.authorPrefs.clearnetImages);
const memeChip = imageObj.meme
? a({ href: "/images?filter=meme", class: "chip-link" }, renderStateChip("mutuals", null, i18n.imageFilterMeme || "MEME"))
: null;
const chips = [
memeChip,
renderLifespanChip(imageObj.lifetime, i18n),
imageObj.sizeBytes ? renderEcoTax(imageObj.sizeBytes, imageObj.key) : null
].filter(Boolean);
const ownerActions = renderImageOwnerActions(filter, imageObj, { q, sort });
const sideActions = [];
sideActions.push(renderImageFavoriteToggle(imageObj, returnTo));
if (imageObj.author && String(imageObj.author) !== String(userId)) {
sideActions.push(renderPMButton(imageObj.author));
}
for (const a of ownerActions) sideActions.push(a);
const tagsNode = renderTags(imageObj.tags);
const detailActions = div({ class: "card-header activity-card-header" },
renderContentActions(imageObj.key, null, {
author: imageObj.author,
favKind: 'images',
isFavorite: imageObj.isFavorite,
spread: params.spreads || null,
returnTo,
reportTitle: imageObj.title
})
);
const imageSide = div({ class: "tribe-side" },
div({ class: "shop-title-row" },
title ? h2({ class: "tribe-card-title" }, title) : null,
@ -467,12 +329,12 @@ exports.singleImageView = async (imageObj, filter = "all", comments = [], params
? p({ class: "tribe-side-description" }, ...renderUrl(imageObj.description))
: null,
tagsNode,
div({ class: "card-spread-centered" }, renderSpreadButton(imageObj.key, params.spreads)),
renderMapLocationVisitLabel(imageObj.mapUrl),
sideActions.length ? div({ class: "tribe-side-actions" }, ...sideActions) : null
);
const imageMain = div({ class: "tribe-main" },
detailActions,
imageObj?.url
? div(
{ class: "image-container" },
@ -491,18 +353,20 @@ exports.singleImageView = async (imageObj, filter = "all", comments = [], params
return p(
{ class: "card-footer" },
span({ class: "date-link" }, `${moment(imageObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
span({ class: "date-link" }, `${moment(imageObj.createdAt).format("YYYY/MM/DD HH:mm")} ${i18n.performed} `),
userLink(imageObj.author),
showUpdated
? span(
{ class: "votations-comment-date" },
` | ${i18n.imageUpdatedAt}: ${moment(imageObj.updatedAt).format("YYYY/MM/DD HH:mm:ss")}`
` | ${i18n.imageUpdatedAt}: ${moment(imageObj.updatedAt).format("YYYY/MM/DD HH:mm")}`
)
: null
);
})(),
renderOpinionsVoting('/images/opinions', imageObj.key, imageObj.opinions, returnTo, imageObj.opinions_inhabitants),
renderImageCommentsSection(imageObj.key, comments, returnTo)
renderEngagement(imageObj.key,
renderOpinionsVoting('/images/opinions', imageObj.key, imageObj.opinions, returnTo, imageObj.opinions_inhabitants),
renderImageCommentsSection(imageObj.key, comments, returnTo)
)
);
return template(
@ -518,19 +382,18 @@ exports.singleImageView = async (imageObj, filter = "all", comments = [], params
{ method: "GET", action: "/images", class: "ui-toolbar ui-toolbar--filters" },
input({ type: "hidden", name: "q", value: q }),
input({ type: "hidden", name: "sort", value: sort }),
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, i18n.imageFilterAll),
button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.imageFilterMine),
button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.imageFilterRecent),
button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, i18n.imageFilterTop),
button(
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, String(i18n.imageFilterAll).toUpperCase()),
button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, String(i18n.imageFilterMine).toUpperCase()),
button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, String(i18n.imageFilterRecent).toUpperCase()), button(
{ type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" },
i18n.imageFilterFavorites
String(i18n.imageFilterFavorites).toUpperCase()
),
button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, String(i18n.imageFilterTop).toUpperCase()),
button(
{ type: "submit", name: "filter", value: "gallery", class: filter === "gallery" ? "filter-btn active" : "filter-btn" },
i18n.imageFilterGallery
String(i18n.imageFilterGallery).toUpperCase()
),
button({ type: "submit", name: "filter", value: "meme", class: filter === "meme" ? "filter-btn active" : "filter-btn" }, i18n.imageFilterMeme),
button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.imageCreateButton)
)
),

View file

@ -1,5 +1,5 @@
const { form, button, div, h2, p, section, input, label, textarea, br, a, span, select, option, ul, li, img, video, audio, table, thead, tbody, tr, td, th } = require("../server/node_modules/hyperaxe")
const { template, i18n, renderOpinionsVoting, userLink, renderStateChip, renderLifespanChip, renderEcoTax, renderSpreadButton, renderContentActions } = require("./main_views")
const { template, i18n, renderOpinionsVoting, renderEngagement, userLink, renderStateChip, renderLifespanChip, renderEcoTax, renderSpreadButton, renderContentActions , renderSpreadEditWarning } = require("./main_views")
const moment = require("../server/node_modules/moment")
const { config } = require("../server/SSB_server.js")
const { renderMapEmbedWithZoom } = require("./maps_view")
@ -120,7 +120,7 @@ const renderFacilityList = exports.renderFacilityList = (facilities, filter, spr
return div({ class: "trending-card tribes-card industry-card" + (isOwn ? " own-content" : "") },
div({ class: "card-header activity-card-header" },
span(),
renderContentActions(fc.id || fc.key, href)
renderContentActions(fc.id || fc.key, href, { spread: spreadMap.get(fc.id || fc.key) || null })
),
div({ class: "card-section tribes-card-body" },
div({ class: "tribe-card-image-wrapper" },
@ -140,7 +140,6 @@ const renderFacilityList = exports.renderFacilityList = (facilities, filter, spr
div({ class: "tribe-card-members" },
span({ class: "tribe-members-count" }, `${i18n.industryMembers || "Members"}: ${fc.memberCount != null ? fc.memberCount : safeArr(fc.members).length}`)
),
div({ class: "card-spread-centered" }, renderSpreadButton(fc.id || fc.key, spreadMap.get(fc.id || fc.key)))
)
)
)
@ -148,13 +147,14 @@ const renderFacilityList = exports.renderFacilityList = (facilities, filter, spr
)
}
const renderFacilityForm = (facility, mode) => {
const renderFacilityForm = (facility, mode, spreadWarning = null) => {
const fc = facility || {}
const isEdit = mode === "edit"
const returnTo = "/industry?filter=MINE"
const curPolicy = fc.membershipPolicy || "vote"
const curSector = fc.sector || "other"
return div({ class: "div-center industry-form" },
isEdit ? spreadWarning : null,
form({
action: isEdit ? `/industry/update/${encodeURIComponent(fc.id)}` : "/industry/create",
method: "POST",
@ -284,8 +284,8 @@ const renderBuildCard = (b, opts = {}) => {
b.blueprintImage ? renderMediaBlob(b.blueprintImage, { class: "post-image" }) : null,
b.notes ? p({ class: "tribe-card-description" }, ...renderUrl(safeText(b.notes))) : null,
div({ class: "industry-blueprint-meta industry-dates-row" },
b.startDate ? p(span({ class: "industry-meta-label" }, `${i18n.industryBuildStart || "Start date"}: `), moment(b.startDate).format("YYYY-MM-DD")) : null,
b.endDate ? p(span({ class: "industry-meta-label" }, `${i18n.industryBuildEnd || "End date"}: `), moment(b.endDate).format("YYYY-MM-DD")) : null,
b.startDate ? p(span({ class: "industry-meta-label" }, `${i18n.industryBuildStart || "Start date"}: `), moment(b.startDate).format("YYYY/MM/DD")) : null,
b.endDate ? p(span({ class: "industry-meta-label" }, `${i18n.industryBuildEnd || "End date"}: `), moment(b.endDate).format("YYYY/MM/DD")) : null,
),
renderBlueprintEstimate(b, i18n.industryFinalPrice || "Final price"),
p(span({ class: "industry-meta-label" }, `${i18n.industryContributions || "Contributions"}: `), String(safeArr(b.contributions).length)),
@ -308,6 +308,7 @@ const renderGlobalBuilds = (builds, spreadMap = new Map()) => {
exports.industryView = async (facilitiesOrForm, filter, params = {}) => {
const f = String(filter || "ALL").toUpperCase()
const facilityEditWarning = f === "EDIT" ? await renderSpreadEditWarning((safeArr(facilitiesOrForm)[0] || {}).id) : null
const search = safeText(params.search)
const sectorSel = safeText(params.sector)
const isForm = f === "CREATE" || f === "EDIT"
@ -324,14 +325,14 @@ exports.industryView = async (facilitiesOrForm, filter, params = {}) => {
form({ method: "GET", action: "/industry", class: "ui-toolbar ui-toolbar--filters" },
input({ type: "hidden", name: "search", value: search }),
input({ type: "hidden", name: "sector", value: sectorSel }),
FILTERS.map((x) => button({ type: "submit", name: "filter", value: x.key, class: f === x.key ? "filter-btn active" : "filter-btn" }, i18n[x.i18n] || x.key))
FILTERS.map((x) => button({ type: "submit", name: "filter", value: x.key, class: f === x.key ? "filter-btn active" : "filter-btn" }, String(i18n[x.i18n] || x.key).toUpperCase()))
.concat(button({ type: "submit", name: "filter", value: "CREATE", class: "create-button" }, i18n.industryCreateFacility || "Create facility"))
)
),
isRules
? renderIndustryRules()
: isForm
? renderFacilityForm(f === "EDIT" ? (safeArr(facilitiesOrForm)[0] || {}) : {}, f === "EDIT" ? "edit" : "create")
? renderFacilityForm(f === "EDIT" ? (safeArr(facilitiesOrForm)[0] || {}) : {}, f === "EDIT" ? "edit" : "create", facilityEditWarning)
: f === "BLUEPRINTS"
? div({ class: "industry-list" }, renderGlobalBlueprints(facilitiesOrForm, params.spreadMap || new Map()))
: f === "BUILDS"
@ -346,7 +347,7 @@ exports.industryView = async (facilitiesOrForm, filter, params = {}) => {
[option({ value: "", ...(sectorSel ? {} : { selected: true }) }, i18n.industryAllSectors || "All sectors")]
.concat(SECTORS.map((s) => option({ value: s, ...(s === sectorSel ? { selected: true } : {}) }, sectorLabel(s))))
),
button({ type: "submit", class: "filter-btn" }, i18n.applyFilters || "Apply")
button({ type: "submit", class: "filter-box__button" }, i18n.searchButton)
)
)
),
@ -507,10 +508,9 @@ const renderBlueprintsSection = (fc, blueprints, isMember, spreadMap = new Map()
const cards = list.map((bp) => {
const actions = renderBlueprintGovernActions(fc.id, bp, isMember, `/industry/${encodeURIComponent(fc.id)}`)
return div({ class: "industry-card-wrap" },
div({ class: "card-header activity-card-header" }, span(), renderContentActions(bp.id, `/industry/blueprint/${encodeURIComponent(bp.id)}`)),
div({ class: "card-header activity-card-header" }, span(), renderContentActions(bp.id, `/industry/blueprint/${encodeURIComponent(bp.id)}`, { spread: spreadMap.get(bp.id) || null })),
div({ class: "industry-blueprint-card" },
div({ class: "card-chips-row" },
renderSpreadButton(bp.id, spreadMap.get(bp.id)),
renderStateChip("half", bp.outKind === "digital" ? "💾" : "📦", i18n["industryKind_" + bp.outKind] || bp.outKind),
renderStateChip("half", "⚖", String(bp.license || "copyleft").toUpperCase())
),
@ -532,12 +532,13 @@ const renderBlueprintsSection = (fc, blueprints, isMember, spreadMap = new Map()
)
}
const renderBlueprintForm = (fc, bp, mode) => {
const renderBlueprintForm = (fc, bp, mode, spreadWarning = null) => {
const isEdit = mode === "edit"
const curKind = bp.outKind || "physical"
const materialsText = safeArr(bp.materials).map(m => `${m.item}:${m.qty}:${m.price != null ? m.price : 0}`).join("\n")
return div({ class: "industry-form industry-blueprint-form" },
h2(isEdit ? (i18n.industryEditBlueprint || "Edit blueprint") : (i18n.industryNewBlueprint || "New blueprint")),
isEdit ? spreadWarning : null,
form({ method: "POST", action: isEdit ? `/industry/blueprints/${encodeURIComponent(bp.id)}/update` : `/industry/${encodeURIComponent(fc.id)}/blueprints`, enctype: "multipart/form-data" },
label(i18n.industryName || "Name"),
br(), input({ type: "text", name: "name", required: true, maxlength: "80", value: bp.name || "" }), br(),
@ -558,12 +559,13 @@ const renderBlueprintForm = (fc, bp, mode) => {
)
}
const renderBuildForm = (fc, blueprints, build, mode) => {
const renderBuildForm = (fc, blueprints, build, mode, spreadWarning = null) => {
const b = build || {}
const isEdit = mode === "edit"
const today = new Date().toISOString().slice(0, 10)
return div({ class: "industry-form industry-build-form" },
h2(isEdit ? (i18n.industryEditBuild || "Edit build") : (i18n.industryNewBuild || "Propose a build")),
isEdit ? spreadWarning : null,
form({ method: "POST", action: isEdit ? `/industry/builds/${encodeURIComponent(b.id)}/update` : `/industry/${encodeURIComponent(fc.id)}/builds`, enctype: "multipart/form-data" },
label(i18n.industryBuildTitle || "Title"),
br(), input({ type: "text", name: "title", required: true, maxlength: "120", value: safeText(b.title) }), br(),
@ -630,11 +632,11 @@ const renderFacilitySide = (fc, returnTo, params = {}) => {
}
if (isSteward) {
sideActions.push(form({ method: "GET", action: `/industry/edit/${encodeURIComponent(fc.id)}` },
button({ type: "submit", class: "update-btn" }, i18n.industryUpdateButton || "Update")
button({ type: "submit", class: "tribe-action-btn" }, i18n.industryUpdateButton || "Update")
))
if (memberCount <= 1) {
sideActions.push(form({ method: "POST", action: `/industry/delete/${encodeURIComponent(fc.id)}` },
button({ type: "submit", class: "delete-btn danger-btn" }, i18n.industryDeleteButton || "Delete")
button({ type: "submit", class: "tribe-action-btn danger-btn" }, i18n.industryDeleteButton || "Delete")
))
}
}
@ -642,14 +644,13 @@ const renderFacilitySide = (fc, returnTo, params = {}) => {
return div({ class: "tribe-side" },
div({ class: "shop-title-row" }, h2({ class: "tribe-card-title" }, a({ href: `/industry/${encodeURIComponent(fc.id)}` }, safeText(fc.name) || (i18n.industryTitle || "Industry")))),
div({ class: "card-chips-row" }, ...chips),
div({ class: "card-spread-centered" }, renderSpreadButton(fc.id || fc.key, params.spreads)),
fc.image ? renderMediaBlob(fc.image, { class: "tribe-detail-image" }) : null,
fc.description ? p({ class: "tribe-side-description" }, ...renderUrl(safeText(fc.description))) : null,
table({ class: "tribe-info-table" },
tr(td({ class: "tribe-info-value tribe-author-cell", colspan: "4" }, userLink(fc.steward))),
tr(td({ class: "tribe-info-label" }, i18n.industryLaborRate || "Labor rate"), td({ class: "tribe-info-value", colspan: "3" }, `${fc.laborRate || 0} ECO/h`)),
tr(td({ class: "tribe-info-label" }, i18n.industryQuorum || "Quorum"), td({ class: "tribe-info-value", colspan: "3" }, String(fc.quorum))),
tr(td({ class: "tribe-info-label" }, i18n.industryMajority || "Majority"), td({ class: "tribe-info-value", colspan: "3" }, `${Math.round((fc.majority || 0.5) * 100)}%`))
tr(td({ class: "tribe-info-label" }, i18n.industryMajority || "Majority"), td({ class: "tribe-info-value", colspan: "3" }, `${Math.round((fc.majority || 0.5) * 100)}%`)),
tr(td({ class: "tribe-info-value tribe-author-cell", colspan: "4" }, userLink(fc.steward)))
),
fc.mapUrl ? renderMapEmbedWithZoom(params.mapData, fc.mapUrl, returnTo, params.zoom) : null,
safeArr(fc.tags).length
@ -689,13 +690,12 @@ exports.singleFacilityView = async (facility, filter, params = {}) => {
i18n.industryTitle || "Industry",
section(
div({ class: "card-header activity-card-header" },
span(),
renderContentActions(fc.id || fc.key, returnTo)
renderContentActions(fc.id || fc.key, null, { spread: params.spreads || null })
),
div({ class: "tags-header" }, h2(i18n.industryTitle || "Industry"), p(i18n.industryDescription || "Network-owned production facilities.")),
div({ class: "filters" },
form({ method: "GET", action: "/industry", class: "ui-toolbar ui-toolbar--filters" },
FILTERS.map((x) => button({ type: "submit", name: "filter", value: x.key, class: f === x.key ? "filter-btn active" : "filter-btn" }, i18n[x.i18n] || x.key))
FILTERS.map((x) => button({ type: "submit", name: "filter", value: x.key, class: f === x.key ? "filter-btn active" : "filter-btn" }, String(i18n[x.i18n] || x.key).toUpperCase()))
.concat(button({ type: "submit", name: "filter", value: "CREATE", class: "create-button" }, i18n.industryCreateFacility || "Create facility"))
)
),
@ -708,17 +708,16 @@ exports.blueprintEditView = async (bp, fc) => template(
i18n.industryTitle || "Industry",
section(
div({ class: "card-header activity-card-header" },
span(),
renderContentActions(fc.id || fc.key, `/industry/${encodeURIComponent(fc.id)}`)
),
div({ class: "tags-header" }, h2(i18n.industryTitle || "Industry"), p(i18n.industryDescription || "Network-owned production facilities.")),
div({ class: "filters" },
form({ method: "GET", action: "/industry", class: "ui-toolbar ui-toolbar--filters" },
FILTERS.map((x) => button({ type: "submit", name: "filter", value: x.key, class: "filter-btn" }, i18n[x.i18n] || x.key))
FILTERS.map((x) => button({ type: "submit", name: "filter", value: x.key, class: "filter-btn" }, String(i18n[x.i18n] || x.key).toUpperCase()))
.concat(button({ type: "submit", name: "filter", value: "CREATE", class: "create-button" }, i18n.industryCreateFacility || "Create facility"))
)
),
renderBlueprintForm(fc, bp, "edit")
renderBlueprintForm(fc, bp, "edit", await renderSpreadEditWarning(bp && bp.id))
)
)
@ -726,17 +725,16 @@ exports.buildEditView = async (b, fc) => template(
i18n.industryTitle || "Industry",
section(
div({ class: "card-header activity-card-header" },
span(),
renderContentActions(b.id, `/industry/build/${encodeURIComponent(b.id)}`)
),
div({ class: "tags-header" }, h2(i18n.industryTitle || "Industry"), p(i18n.industryDescription || "Network-owned production facilities.")),
div({ class: "filters" },
form({ method: "GET", action: "/industry", class: "ui-toolbar ui-toolbar--filters" },
FILTERS.map((x) => button({ type: "submit", name: "filter", value: x.key, class: "filter-btn" }, i18n[x.i18n] || x.key))
FILTERS.map((x) => button({ type: "submit", name: "filter", value: x.key, class: "filter-btn" }, String(i18n[x.i18n] || x.key).toUpperCase()))
.concat(button({ type: "submit", name: "filter", value: "CREATE", class: "create-button" }, i18n.industryCreateFacility || "Create facility"))
)
),
renderBuildForm(fc, [], b, "edit")
renderBuildForm(fc, [], b, "edit", await renderSpreadEditWarning(b && b.id))
)
)
@ -749,25 +747,22 @@ exports.singleBlueprintView = async (blueprint, params = {}) => {
return template(
i18n.industryTitle || "Industry",
section(div({ class: "tags-header" }, h2(i18n.industryTitle), p(i18n.industryDescription))),
section(
div({ class: "filters" },
form({ method: "GET", action: "/industry", class: "ui-toolbar ui-toolbar--filters" },
FILTERS.map((x) => button({ type: "submit", name: "filter", value: x.key, class: "filter-btn" }, i18n[x.i18n] || x.key))
FILTERS.map((x) => button({ type: "submit", name: "filter", value: x.key, class: "filter-btn" }, String(i18n[x.i18n] || x.key).toUpperCase()))
.concat(button({ type: "submit", name: "filter", value: "CREATE", class: "create-button" }, i18n.industryCreateFacility || "Create facility"))
)
)
),
section(
div({ class: "shop-detail" },
(bp.author && String(bp.author) !== String(userId))
? div({ class: "bookmark-topbar transfer-topbar-single" },
div({ class: "bookmark-topbar-left" },
form({ method: "GET", action: "/pm" }, input({ type: "hidden", name: "recipients", value: bp.author }), button({ type: "submit", class: "filter-btn" }, i18n.privateMessage))
)
)
: null,
div({ class: "card-chips-row" },
renderSpreadButton(bp.id, params.spreads),
div({ class: "card-header activity-card-header" },
span(),
renderContentActions(bp.id, null, { spread: params.spreads || null, author: bp.author, reportTitle: bp.name || bp.title })
),
div({ class: "card-chips-row" },
renderStateChip("half", bp.outKind === "digital" ? "💾" : "📦", i18n["industryKind_" + bp.outKind] || bp.outKind),
renderStateChip("half", "⚖", String(bp.license || "copyleft").toUpperCase())
),
@ -915,8 +910,8 @@ exports.singleBuildView = async (build, params = {}) => {
const productName = safeText(b.blueprintName) || safeText(b.title)
const crossTags = [facilityName, productName].filter(Boolean).map(x => x.toLowerCase().replace(/\s+/g, "-")).join(", ")
const crossDates = [
b.startDate ? `${i18n.industryBuildStart || "Start date"}: ${moment(b.startDate).format("YYYY-MM-DD")}` : null,
b.endDate ? `${i18n.industryBuildEnd || "End date"}: ${moment(b.endDate).format("YYYY-MM-DD")}` : null
b.startDate ? `${i18n.industryBuildStart || "Start date"}: ${moment(b.startDate).format("YYYY/MM/DD")}` : null,
b.endDate ? `${i18n.industryBuildEnd || "End date"}: ${moment(b.endDate).format("YYYY/MM/DD")}` : null
].filter(Boolean).join(" · ")
const crossContext = [
`${i18n.industryFacility || "Facility"}: ${facilityName}`,
@ -937,34 +932,32 @@ exports.singleBuildView = async (build, params = {}) => {
return template(
i18n.industryTitle || "Industry",
section(div({ class: "tags-header" }, h2(i18n.industryTitle), p(i18n.industryDescription))),
section(
div({ class: "filters" },
form({ method: "GET", action: "/industry", class: "ui-toolbar ui-toolbar--filters" },
FILTERS.map((x) => button({ type: "submit", name: "filter", value: x.key, class: "filter-btn" }, i18n[x.i18n] || x.key))
FILTERS.map((x) => button({ type: "submit", name: "filter", value: x.key, class: "filter-btn" }, String(i18n[x.i18n] || x.key).toUpperCase()))
.concat(button({ type: "submit", name: "filter", value: "CREATE", class: "create-button" }, i18n.industryCreateFacility || "Create facility"))
)
)
),
section(
div({ class: "shop-detail" },
(b.proposer && String(b.proposer) !== String(userId))
? div({ class: "bookmark-topbar transfer-topbar-single" },
div({ class: "bookmark-topbar-left" },
form({ method: "GET", action: "/pm" }, input({ type: "hidden", name: "recipients", value: b.proposer }), button({ type: "submit", class: "filter-btn" }, i18n.privateMessage))
)
)
: null,
b.image ? div({ class: "shop-detail-media" }, renderMediaBlob(b.image, { class: "post-image" })) : null,
div({ class: "card-header activity-card-header" },
span(),
renderContentActions(b.id, null, { spread: params.spreads || null, author: b.proposer, reportTitle: b.title })
),
b.image ? div({ class: "shop-detail-media" }, renderMediaBlob(b.image, { class: "post-image" })) : null,
h2(safeText(b.title) || (i18n.industryBuild || "Build")),
div({ class: "card-chips-row" }, renderSpreadButton(b.id, params.spreads), renderBuildStatusChip(status)),
div({ class: "card-chips-row" }, renderBuildStatusChip(status)),
div({ class: "industry-meta" },
b.blueprintName ? p(span({ class: "industry-meta-label" }, `${i18n.industryBlueprint || "Blueprint"}: `), safeText(b.blueprintName)) : null,
b.blueprintImage ? renderMediaBlob(b.blueprintImage, { class: "post-image" }) : null,
p(span({ class: "industry-meta-label" }, `${i18n.industryProposer || "Proposer"}: `), userLink(b.proposer)),
b.notes ? p({ class: "industry-build-notes" }, span({ class: "industry-meta-label" }, `${i18n.industryBuildNotes || "Notes"}: `), safeText(b.notes)) : null,
div({ class: "industry-dates-row" },
b.startDate ? p(span({ class: "industry-meta-label" }, `${i18n.industryBuildStart || "Start date"}: `), moment(b.startDate).format("YYYY-MM-DD")) : null,
b.endDate ? p(span({ class: "industry-meta-label" }, `${i18n.industryBuildEnd || "End date"}: `), moment(b.endDate).format("YYYY-MM-DD")) : null,
b.startDate ? p(span({ class: "industry-meta-label" }, `${i18n.industryBuildStart || "Start date"}: `), moment(b.startDate).format("YYYY/MM/DD")) : null,
b.endDate ? p(span({ class: "industry-meta-label" }, `${i18n.industryBuildEnd || "End date"}: `), moment(b.endDate).format("YYYY/MM/DD")) : null,
(b.endDate && status !== "COMPLETED" && status !== "FAILED")
? p(span({ class: "industry-meta-label" }, `${i18n.industryTimeLeft || "Time left"}: `), `${Math.max(0, Math.ceil((new Date(b.endDate) - Date.now()) / 86400000))}d`)
: null

View file

@ -1,9 +1,11 @@
const { form, button, div, h2, p, section, input, label, textarea, br, a, span, select, option, img, progress, video, audio, table, tr, td } = require("../server/node_modules/hyperaxe")
const { template, i18n, userLink, renderStateChip, renderOpenClosedChip, renderVisibilityChip, renderLifespanChip, renderEcoTax, renderSpreadButton, renderContentActions } = require("./main_views")
const { renderCommentsSection: renderSharedCommentsSection } = require("./comments_view");
const { template, i18n, userLink, renderStateChip, renderOpenClosedChip, renderVisibilityChip, renderLifespanChip, renderEcoTax, renderSpreadButton, renderContentActions, renderSpreadEditWarning } = require("./main_views")
const moment = require("../server/node_modules/moment")
const { config } = require("../server/SSB_server.js")
const { renderUrl } = require("../backend/renderUrl")
const { renderMapLocationUrl, renderMapEmbed, renderMapLocationVisitLabel } = require("./maps_view")
const { resolvePhoto } = require("./inhabitants_view")
const renderMediaBlob = (value, attrs = {}) => {
if (!value) return null
@ -24,6 +26,8 @@ const userId = config.keys.id
const FILTERS = [
{ key: "ALL", i18n: "jobsFilterAll", title: "jobsAllTitle" },
{ key: "MINE", i18n: "jobsFilterMine", title: "jobsMineTitle" },
{ key: "RECENT", i18n: "jobsFilterRecent", title: "jobsRecentTitle" },
{ key: "TOP", i18n: "jobsFilterTop", title: "jobsTopTitle" },
{ key: "APPLIED", i18n: "jobsFilterApplied", title: "jobsAppliedTitle" },
{ key: "REMOTE", i18n: "jobsFilterRemote", title: "jobsRemoteTitle" },
{ key: "PRESENCIAL", i18n: "jobsFilterPresencial", title: "jobsPresencialTitle" },
@ -32,17 +36,9 @@ const FILTERS = [
{ key: "EXCHANGE", i18n: "jobsFilterExchange", title: "jobsExchangeTitle" },
{ key: "OPEN", i18n: "jobsFilterOpen", title: "jobsOpenTitle" },
{ key: "CLOSED", i18n: "jobsFilterClosed", title: "jobsClosedTitle" },
{ key: "RECENT", i18n: "jobsFilterRecent", title: "jobsRecentTitle" },
{ key: "TOP", i18n: "jobsFilterTop", title: "jobsTopTitle" },
{ key: "CV", i18n: "jobsCV", title: "jobsCVTitle" }
]
function resolvePhoto(photoField, size = 256) {
if (typeof photoField === "string" && photoField.startsWith("/image/")) return photoField
if (typeof photoField === "string" && /^&[A-Za-z0-9+/=]+\.sha256$/.test(photoField)) return `/image/${size}/${encodeURIComponent(photoField)}`
return "/assets/images/default-avatar.png"
}
const safeArr = (v) => (Array.isArray(v) ? v : [])
const safeText = (v) => String(v || "").trim()
@ -109,7 +105,7 @@ const renderUpdatedLabel = (createdAt, updatedAt) => {
const updatedTs = updatedAt ? new Date(updatedAt).getTime() : NaN
const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs)
return showUpdated
? span({ class: "votations-comment-date" }, ` | ${i18n.jobsUpdatedAt}: ${moment(updatedAt).format("YYYY/MM/DD HH:mm:ss")}`)
? span({ class: "votations-comment-date" }, ` | ${i18n.jobsUpdatedAt}: ${moment(updatedAt).format("YYYY/MM/DD HH:mm")}`)
: null
}
@ -245,7 +241,7 @@ const renderJobList = exports.renderJobList = (jobs, filter, params = {}) => {
div(
{ class: "card-header activity-card-header" },
span(),
renderContentActions(job.id, `/jobs/${encodeURIComponent(job.id)}`)
renderContentActions(job.id, `/jobs/${encodeURIComponent(job.id)}`, { spread: params.spreadMap && params.spreadMap.get(job.id) || null, author: job.author, favKind: 'jobs', isFavorite: job.isFavorite, reportTitle: job.title })
),
div({ class: "card-section job-card-body" },
heroNode,
@ -259,8 +255,7 @@ const renderJobList = exports.renderJobList = (jobs, filter, params = {}) => {
div({ class: "card-date-highlight" }, compensationText),
div({ class: "tribe-card-members" },
span({ class: "tribe-members-count" }, `${i18n.jobSubscribers}: ${subs.length}`)
),
div({ class: "card-spread-centered" }, renderSpreadButton(job.id, params.spreadMap && params.spreadMap.get(job.id)))
)
)
)
)
@ -268,10 +263,11 @@ const renderJobList = exports.renderJobList = (jobs, filter, params = {}) => {
)
}
const renderJobForm = (job = {}, mode = "create") => {
const renderJobForm = (job = {}, mode = "create", spreadWarning = null) => {
const isEdit = mode === "edit"
return div(
{ class: "div-center job-form" },
isEdit ? spreadWarning : null,
form(
{
action: isEdit ? `/jobs/update/${encodeURIComponent(job.id)}` : "/jobs/create",
@ -292,7 +288,7 @@ const renderJobForm = (job = {}, mode = "create") => {
br(),
label(i18n.jobTitle),
br(),
input({ type: "text", name: "title", required: true, placeholder: i18n.jobTitlePlaceholder, value: job.title || "" }),
input({ type: "text", name: "title", maxlength: "100", required: true, placeholder: i18n.jobTitlePlaceholder, value: job.title || "" }),
br(),
br(),
label(i18n.jobImage),
@ -325,8 +321,8 @@ const renderJobForm = (job = {}, mode = "create") => {
br(),
select(
{ name: "job_time", required: true },
option({ value: "partial", selected: job.job_time === "partial" }, i18n.jobTimePartial),
option({ value: "complete", selected: job.job_time === "complete" }, i18n.jobTimeComplete)
option({ value: "partial", ...(job.job_time === "partial" ? { selected: true } : {})}, i18n.jobTimePartial),
option({ value: "complete", ...(job.job_time === "complete" ? { selected: true } : {})}, i18n.jobTimeComplete)
),
br(),
br(),
@ -339,8 +335,8 @@ const renderJobForm = (job = {}, mode = "create") => {
br(),
select(
{ name: "location", required: true },
option({ value: "remote", selected: job.location === "remote" }, i18n.jobLocationRemote),
option({ value: "presencial", selected: job.location === "presencial" }, i18n.jobLocationPresencial)
option({ value: "remote", ...(job.location === "remote" ? { selected: true } : {})}, i18n.jobLocationRemote),
option({ value: "presencial", ...(job.location === "presencial" ? { selected: true } : {})}, i18n.jobLocationPresencial)
),
br(),
br(),
@ -353,8 +349,8 @@ const renderJobForm = (job = {}, mode = "create") => {
br(),
select(
{ name: "visibility" },
option({ value: "PUBLIC", selected: (job.visibility || "PUBLIC") === "PUBLIC" }, i18n.visibilityPublic || "Public"),
option({ value: "HIDDEN", selected: job.visibility === "HIDDEN" }, i18n.visibilityHidden || "Hidden")
option({ value: "PUBLIC", ...((job.visibility || "PUBLIC") === "PUBLIC" ? { selected: true } : {})}, i18n.visibilityPublic || "Public"),
option({ value: "HIDDEN", ...(job.visibility === "HIDDEN" ? { selected: true } : {})}, i18n.visibilityHidden || "Hidden")
),
br(),
br(),
@ -419,9 +415,23 @@ const renderCVList = (inhabitants) =>
{ class: "inhabitant-details" },
user.description ? p(...renderUrl(user.description)) : null,
p(userLink(user.id)),
user.skills && user.skills.length
? div({ class: "card-tags" },
user.skills.slice(0, 12).map((skill) =>
a({ href: `/search?query=%23${encodeURIComponent(skill)}`, class: "tag-link" }, `#${skill}`))
)
: null,
typeof user.matchScore === "number"
? div({ class: "cv-match-row" },
span({ class: "card-label" }, `${i18n.matchScore}: `),
span({ class: "card-value" }, `${Math.round(user.matchScore * 100)}%`)
)
: null,
div(
{ class: "cv-actions" },
form({ method: "GET", action: `/inhabitant/${encodeURIComponent(user.id)}` }, button({ type: "submit", class: "filter-btn" }, i18n.inhabitantviewDetails)),
form({ method: "GET", action: `/inhabitant/${encodeURIComponent(user.id)}` }, button({ type: "submit", class: "filter-btn" }, i18n.cvVisitButton)),
form({ method: "GET", action: `/cv/pdf/${encodeURIComponent(user.id)}` }, button({ type: "submit", class: "filter-btn" }, i18n.generatePdf)),
form({ method: "POST", action: `/cv/share/${encodeURIComponent(user.id)}` }, button({ type: "submit", class: "filter-btn" }, i18n.sharePm)),
!isMe ? renderPmButton(user.id) : null
)
)
@ -431,6 +441,10 @@ const renderCVList = (inhabitants) =>
)
exports.jobsView = async (jobsOrCVs, filter = "ALL", params = {}) => {
if (filter === "EDIT") {
const editing = Array.isArray(jobsOrCVs) ? jobsOrCVs[0] : jobsOrCVs
params = { ...params, spreadWarning: await renderSpreadEditWarning(editing && editing.id) }
}
const search = safeText(params.search || "")
const minSalary = params.minSalary ?? ""
const maxSalary = params.maxSalary ?? ""
@ -459,7 +473,7 @@ exports.jobsView = async (jobsOrCVs, filter = "ALL", params = {}) => {
input({ type: "hidden", name: "maxSalary", value: String(maxSalary ?? "") }),
input({ type: "hidden", name: "sort", value: sort }),
...FILTERS.map((f) =>
button({ type: "submit", name: "filter", value: f.key, class: filter === f.key ? "filter-btn active" : "filter-btn" }, i18n[f.i18n])
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.jobsCreateJob)
)
@ -492,7 +506,7 @@ exports.jobsView = async (jobsOrCVs, filter = "ALL", params = {}) => {
...(params.tasks ? { tasks: params.tasks } : {}),
...(params.salary ? { salary: params.salary } : {})
}
return renderJobForm(jobToEdit, filter === "EDIT" ? "edit" : "create")
return renderJobForm(jobToEdit, filter === "EDIT" ? "edit" : "create", params.spreadWarning)
})()
: section(
div(
@ -510,9 +524,9 @@ exports.jobsView = async (jobsOrCVs, filter = "ALL", params = {}) => {
),
select(
{ name: "sort", class: "filter-box__select" },
option({ value: "recent", selected: sort === "recent" }, i18n.jobsSortRecent),
option({ value: "salary", selected: sort === "salary" }, i18n.jobsSortSalary),
option({ value: "subscribers", selected: sort === "subscribers" }, i18n.jobsSortSubscribers)
option({ value: "recent", ...(sort === "recent" ? { selected: true } : {})}, i18n.jobsSortRecent),
option({ value: "salary", ...(sort === "salary" ? { selected: true } : {})}, i18n.jobsSortSalary),
option({ value: "subscribers", ...(sort === "subscribers" ? { selected: true } : {})}, i18n.jobsSortSubscribers)
),
button({ type: "submit", class: "filter-box__button" }, i18n.jobsSearchButton)
)
@ -526,61 +540,12 @@ exports.jobsView = async (jobsOrCVs, filter = "ALL", params = {}) => {
}
const renderJobCommentsSection = (jobId, returnTo, comments = []) => {
const list = safeArr(comments).filter(c => {
const t = c && c.value && c.value.content && c.value.content.text
return t && String(t).trim()
})
const commentsCount = list.length
return div(
{ id: "comments", class: "vote-comments-section" },
div(
{ class: "comments-count" },
span({ class: "card-label" }, i18n.voteCommentsLabel + ": "),
span({ class: "card-value" }, String(commentsCount))
),
div(
{ class: "comment-form-wrapper" },
h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
form(
{ method: "POST", action: `/jobs/${encodeURIComponent(jobId)}/comments`, class: "comment-form", enctype: "multipart/form-data" },
input({ type: "hidden", name: "returnTo", value: returnTo }),
textarea({ id: "comment-text", name: "text", rows: 4, class: "comment-textarea", placeholder: i18n.voteNewCommentPlaceholder }),
div({ class: "comment-file-upload" }, label(i18n.uploadMedia), input({ type: "file", name: "blob" })),
br(),
button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
)
),
list.length
? div(
{ class: "comments-list" },
list.map((c) => {
const author = c?.value?.author || ""
const ts = c?.value?.timestamp || c?.timestamp
const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : ""
const relDate = ts ? moment(ts).fromNow() : ""
const userName = author && author.includes("@") ? author.split("@")[1] : author
const rootId = c?.value?.content ? (c.value.content.fork || c.value.content.root) : null
const text = c?.value?.content?.text || ""
return div(
{ class: "votations-comment-card" },
span(
{ class: "created-at" },
span(i18n.createdBy),
author ? userLink(author) : span("(unknown)"),
absDate ? span(" | ") : "",
absDate ? span({ class: "votations-comment-date" }, absDate) : "",
relDate ? span({ class: "votations-comment-date" }, " | ", i18n.sendTime) : "",
relDate && rootId ? a({ href: `/thread/${encodeURIComponent(rootId)}#${encodeURIComponent(c.key)}` }, relDate) : ""
),
p({ class: "votations-comment-text" }, ...renderUrl(text))
)
})
)
: p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet)
)
}
return renderSharedCommentsSection({
action: `/jobs/${encodeURIComponent(jobId)}/comments`,
comments: comments,
returnTo: returnTo
});
};
const renderCandidates = (candidates, jobId) => {
if (!Array.isArray(candidates) || candidates.length === 0) return null;
@ -643,7 +608,6 @@ exports.singleJobsView = async (job, filter = "ALL", comments = [], params = {})
const ownerActions = renderJobOwnerActions(job, returnTo)
const applyToggle = renderJobApplyToggle(job, returnTo)
const pmBtn = renderPmButton(job.author)
const visibility = (job.visibility || 'PUBLIC').toUpperCase() === 'HIDDEN' ? 'HIDDEN' : 'PUBLIC'
const chips = [
renderJobStatusChip(job.status),
@ -671,15 +635,16 @@ exports.singleJobsView = async (job, filter = "ALL", comments = [], params = {})
const sideActions = []
if (applyToggle) sideActions.push(applyToggle)
if (pmBtn) sideActions.push(pmBtn)
for (const a of ownerActions) sideActions.push(a)
const jobSide = div({ class: "tribe-side" },
div({ class: "card-header activity-card-header" },
renderContentActions(job.id, null, { spread: params.spreads || null, author: job.author, favKind: 'jobs', isFavorite: job.isFavorite, reportTitle: job.title })
),
div({ class: "shop-title-row" },
h2({ class: "tribe-card-title" }, safeText(job.title) || i18n.jobsTitle)
),
chips.length ? div({ class: "card-chips-row" }, ...chips) : null,
div({ class: "card-spread-centered" }, renderSpreadButton(job.id, params.spreads)),
job.image
? renderMediaBlob(job.image, { class: "tribe-detail-image" })
: null,
@ -720,6 +685,7 @@ exports.singleJobsView = async (job, filter = "ALL", comments = [], params = {})
return template(
i18n.jobsTitle,
section(div({ class: "tags-header" }, h2(i18n.jobsTitle), p(i18n.jobsDescription))),
section(
div(
{ class: "filters" },
@ -730,7 +696,7 @@ exports.singleJobsView = async (job, filter = "ALL", comments = [], params = {})
input({ type: "hidden", name: "maxSalary", value: String(params.maxSalary ?? "") }),
input({ type: "hidden", name: "sort", value: safeText(params.sort || "recent") }),
...FILTERS.map((f) =>
button({ type: "submit", name: "filter", value: f.key, class: filter === f.key ? "filter-btn active" : "filter-btn" }, i18n[f.i18n])
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.jobsCreateJob)
)

View file

@ -255,6 +255,34 @@ exports.i18n = i18n;
Object.defineProperty(exports, 'selectedLanguage', { get: () => selectedLanguage });
const opinionCategoriesList = require('../backend/opinion_categories');
const renderEngagement = (id, opinionsNode, commentsNode) => {
const parts = [opinionsNode, commentsNode].filter(Boolean);
if (!parts.length) return null;
return div({ class: 'card-engage' }, ...parts);
};
exports.renderEngagement = renderEngagement;
let spreadsModel = null;
const spreadsFor = async (msgId) => {
if (!msgId || typeof msgId !== 'string' || !msgId.startsWith('%')) return 0;
try {
if (!spreadsModel) spreadsModel = require('../models/main_models')({ cooler, isPublic: config.public }).spreads;
const info = await spreadsModel.forMessage(msgId);
return info && Number(info.count) > 0 ? Number(info.count) : 0;
} catch (_) {
return 0;
}
};
const renderSpreadEditWarning = async (msgId) => {
const count = await spreadsFor(msgId);
if (!count) return null;
return div({ class: 'error-box spread-edit-warning' },
p(`${i18n.spreadEditWarning} (${count})`)
);
};
exports.renderSpreadEditWarning = renderSpreadEditWarning;
const renderOpinionsVoting = (basePath, id, opinions, returnTo, voters) => {
const ops = opinions || {};
const total = Object.values(ops).reduce((s, n) => s + (Number(n) || 0), 0);

View file

@ -1,8 +1,8 @@
const { form, button, div, h2, h3, p, section, input, label, br, a, span, textarea, select, option, img, strong } =
const { form, button, div, h2, h3, p, section, input, label, br, a, span, textarea, select, option, img, strong, table, tr, td } =
require("../server/node_modules/hyperaxe");
const moment = require("../server/node_modules/moment");
const { template, i18n, userLink, renderLifespanChip, renderSpreadButton, renderContentActions } = require("./main_views");
const { template, i18n, userLink, renderStateChip, renderLifespanChip, renderSpreadButton, renderContentActions, renderSpreadEditWarning } = require("./main_views");
const { renderEncryptedChip } = require("./clearnet_view");
const { config } = require("../server/SSB_server.js");
const { renderMapWithPins, renderZoomedMapWithPins, getViewportBounds, latLngToPx, pxToLatLng, MAP_W, MAP_H, getMaxTileZoom } = require("../maps/map_renderer");
@ -20,14 +20,6 @@ const buildReturnTo = (filter, params = {}) => {
return `/maps?${parts.join("&")}`;
};
const renderPMButton = (recipient) => {
const r = safeText(recipient);
if (!r || String(r) === String(userId)) return null;
return form({ method: "GET", action: "/pm" },
input({ type: "hidden", name: "recipients", value: r }),
button({ type: "submit", class: "filter-btn" }, i18n.privateMessage));
};
const renderTags = (tags) => {
const list = safeArr(tags).map((t) => String(t || "").trim()).filter(Boolean);
return list.length
@ -35,14 +27,6 @@ const renderTags = (tags) => {
: null;
};
const renderMapFavoriteToggle = (mapObj, returnTo = "") =>
form({
method: "POST",
action: mapObj.isFavorite ? `/maps/favorites/remove/${encodeURIComponent(mapObj.key)}` : `/maps/favorites/add/${encodeURIComponent(mapObj.key)}`
},
returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
button({ type: "submit", class: "filter-btn" }, mapObj.isFavorite ? i18n.mapRemoveFavoriteButton : i18n.mapAddFavoriteButton));
let areaCounter = 0;
const buildAreas = (clickUrl, latParam = "lat", lngParam = "lng", viewport = null) => {
@ -120,11 +104,14 @@ const renderMap = (markers, clickUrl, mainIdx, opts = {}) => {
const clean = url.replace(/&amp;/g, "&");
return `<a href="${clean}" class="map-popup-link" target="_blank" rel="noopener">${url}</a>`;
}).replace(/\n/g, "<br>");
const sz = 20 * Math.pow(2, Math.max(0, zoom - getMaxTileZoom()));
const x1 = Math.max(0, px.x - sz);
const y1 = Math.max(0, px.y - sz);
const x2 = Math.min(MAP_W, px.x + sz);
const y2 = Math.min(MAP_H, px.y + sz);
const scale = Math.pow(2, Math.max(0, zoom - getMaxTileZoom()));
const halfW = Math.max(26, 26 * scale);
const pinTop = Math.max(48, 48 * scale);
const pinFoot = Math.max(10, 10 * scale);
const x1 = Math.max(0, px.x - halfW);
const y1 = Math.max(0, px.y - pinTop);
const x2 = Math.min(MAP_W, px.x + halfW);
const y2 = Math.min(MAP_H, px.y + pinFoot);
const popupId = `${pfx}_${i}`;
const latStr = typeof m.lat === "number" ? m.lat.toFixed(4) : "";
const lngStr = typeof m.lng === "number" ? m.lng.toFixed(4) : "";
@ -174,13 +161,13 @@ const renderMapOwnerActions = (filter, mapObj, params = {}) => {
input({ type: "hidden", name: "returnTo", value: returnTo }),
button({ class: "delete-btn", type: "submit" }, i18n.mapDeleteButton))
];
const isPrivateMap = !mapObj.tribeId && mapObj.mapType !== "OPEN";
if (isPrivateMap) {
const invitable = !mapObj.tribeId && mapObj.mapType === "CLOSED";
if (invitable) {
actions.push(form({ method: "POST", action: `/maps/generate-invite/${encodeURIComponent(mapObj.key)}` },
button({ type: "submit", class: "tribe-action-btn" }, i18n.tribeGenerateInvite)));
}
const openInvite = Array.isArray(mapObj.invites) ? mapObj.invites.find(inv => typeof inv === "object" && inv.public === true && inv.code) : null;
if (!mapObj.tribeId) {
if (invitable) {
if (openInvite) {
actions.push(div({ class: 'tribe-open-invite' },
span({ class: 'card-label' }, i18n.tribeInviteCodeText),
@ -200,10 +187,10 @@ const renderFilters = (filter, q) =>
div({ class: "filters" },
form({ method: "GET", action: "/maps", class: "ui-toolbar ui-toolbar--filters" },
input({ type: "hidden", name: "q", value: q || "" }),
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, i18n.mapFilterAll),
button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.mapFilterMine),
button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.mapFilterRecent),
button({ type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" }, i18n.mapFilterFavorites),
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, String(i18n.mapFilterAll).toUpperCase()),
button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, String(i18n.mapFilterMine).toUpperCase()),
button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, String(i18n.mapFilterRecent).toUpperCase()),
button({ type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" }, String(i18n.mapFilterFavorites).toUpperCase()),
button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.mapUploadButton)));
const renderMapForm = (filter, mapId, mapToEdit, params = {}) => {
@ -221,9 +208,10 @@ const renderMapForm = (filter, mapId, mapToEdit, params = {}) => {
const cleanUrl = `/maps?filter=create${params.tribeId ? '&tribeId=' + encodeURIComponent(params.tribeId) : ''}`;
const pickerMarkers = latVal && lngVal ? [{ lat: parseFloat(latVal), lng: parseFloat(lngVal) }] : [];
return div({ class: "map-create-layout" },
div({ class: "map-form map-form-full" },
form({
return div({ class: "div-center audio-form" },
params.spreadWarning || null,
h2(filter === "edit" ? i18n.mapUpdateButton : i18n.mapCreateButton),
form({
action: filter === "edit" ? `/maps/update/${encodeURIComponent(mapId)}` : "/maps/create",
method: "POST",
enctype: "multipart/form-data"
@ -231,40 +219,47 @@ const renderMapForm = (filter, mapId, mapToEdit, params = {}) => {
input({ type: "hidden", name: "returnTo", value: returnTo }),
input({ type: "hidden", name: "filter", value: "create" }),
params.tribeId ? input({ type: "hidden", name: "tribeId", value: params.tribeId }) : null,
label(i18n.title || "Title"),
input({ type: "text", name: "title", placeholder: i18n.mapTitlePlaceholder || "Map title", value: titleVal }),
label(i18n.mapDescriptionLabel),
label(i18n.title || "Title"), br(),
input({ type: "text", name: "title", maxlength: "100", placeholder: i18n.mapTitlePlaceholder || "Map title", value: titleVal }),
br(), br(),
label(i18n.mapDescriptionLabel), br(),
textarea({ name: "description", placeholder: i18n.mapDescriptionPlaceholder, rows: "3" }, descVal),
label(i18n.mapTagsLabel),
br(), br(),
label(i18n.mapTagsLabel), br(),
input({ type: "text", name: "tags", placeholder: i18n.mapTagsPlaceholder, value: tagsValue }),
label(i18n.mapTypeLabel),
br(), br(),
label(i18n.mapTypeLabel), br(),
select({ name: "mapType" },
option({ value: "SINGLE", ...(mapTypeVal === "SINGLE" ? { selected: true } : {}) }, "SINGLE"),
option({ value: "OPEN", ...(mapTypeVal === "OPEN" ? { selected: true } : {}) }, "OPEN"),
option({ value: "CLOSED", ...(mapTypeVal === "CLOSED" ? { selected: true } : {}) }, "CLOSED")),
br(),br(),
label(i18n.mapMarkerLabelField),
option({ value: "SINGLE", ...(mapTypeVal === "SINGLE" ? { selected: true } : {}) }, i18n.mapTypeSingle),
option({ value: "OPEN", ...(mapTypeVal === "OPEN" ? { selected: true } : {}) }, i18n.mapTypeOpen),
option({ value: "CLOSED", ...(mapTypeVal === "CLOSED" ? { selected: true } : {}) }, i18n.mapTypeClosed)),
br(), br(),
label(i18n.mapMarkerLabelField), br(),
textarea({ name: "markerLabel", placeholder: i18n.mapMarkerLabelPlaceholder, rows: "3" }, markerLabelVal),
label(i18n.markerImageLabel || "Marker Image"),
br(), br(),
label(i18n.markerImageLabel || "Marker Image"), br(),
input({ type: "file", name: "image", accept: "image/*" }),
br(), br(),
label(i18n.mapLatLabel),
label(i18n.mapLatLabel), br(),
input({ type: "text", name: "lat", placeholder: i18n.mapLatPlaceholder, value: latVal }),
label(i18n.mapLngLabel),
br(), br(),
label(i18n.mapLngLabel), br(),
input({ type: "text", name: "lng", placeholder: i18n.mapLngPlaceholder, value: lngVal }),
br(), br(),
div({ class: "map-form-row" },
button({ type: "submit", attrs: { formmethod: "GET" }, formaction: "/maps", class: "filter-btn" }, i18n.mapAddMarkerButton || "Add Marker"),
a({ href: cleanUrl, class: "filter-btn" }, i18n.mapCleanMarkerButton || "Clean Marker")),
renderCoordPreview(latVal, lngVal),
label(i18n.mapZoomLabel || "Zoom"),
br(),
label(i18n.mapZoomLabel || "Zoom"), br(),
select({ name: "zoom" },
[2, 3, 4, 5, 6, 7, 8].map(z =>
option({ value: String(z), ...(zoomVal === z ? { selected: true } : {}) }, String(z)))),
br(),br(),
br(), br(),
button({ type: "submit", attrs: { formmethod: "GET" }, formaction: "/maps", class: "filter-btn" }, i18n.mapApplyZoom || "Apply Zoom"),
div({ class: "map-form-map-slot" },
renderMap(pickerMarkers, null, 0, { zoom: zoomVal, centerLat: parseFloat(latVal) || 0, centerLng: parseFloat(lngVal) || 0 })),
button({ type: "submit", class: "create-button" }, filter === "edit" ? i18n.mapUpdateButton : i18n.mapCreateButton))));
button({ type: "submit", class: "create-button" }, filter === "edit" ? i18n.mapUpdateButton : i18n.mapCreateButton)));
};
const renderMarkerForm = (mapObj, returnTo, params = {}, tribeMembers = []) => {
@ -341,8 +336,7 @@ const renderMarkersList = (markers, mapObj) => {
const renderMapCard = (mapObj, filter, params = {}) => {
const returnTo = buildReturnTo(filter, params);
const ownerActions = renderMapOwnerActions(filter, mapObj, params);
const markerCount = safeArr(mapObj.markers).length;
const markerCount = safeArr(mapObj.markers).length + 1;
const thumbMarkers = [{ lat: mapObj.lat, lng: mapObj.lng }].concat(
safeArr(mapObj.markers).map((m) => ({ lat: m.lat, lng: m.lng })));
@ -350,41 +344,33 @@ const renderMapCard = (mapObj, filter, params = {}) => {
const thumbSrc = thumbFile ? `/mapcache/${thumbFile}` : "/assets/images/worldmap-z2.png";
const chips = [
renderStateChip("whole", "🗺", String(mapObj.mapType || "").toUpperCase()),
renderEncryptedChip(i18n),
renderLifespanChip(mapObj.lifetime, i18n)
].filter(Boolean);
const isOwn = mapObj.author && String(mapObj.author) === String(userId);
return div({ class: "trending-card map-card" + (isOwn ? " own-content" : "") },
const href = `/maps/${encodeURIComponent(mapObj.key)}?filter=${encodeURIComponent(filter)}`;
return div({ class: "tribe-card" + (isOwn ? " own-content" : "") },
div({ class: "card-header activity-card-header" },
span({ class: "map-type-badge" }, mapObj.mapType),
renderContentActions(mapObj.key, `/maps/${encodeURIComponent(mapObj.key)}?filter=${encodeURIComponent(filter)}`)),
div({ class: "card-section maps-card-body" },
a({ href: `/maps/${encodeURIComponent(mapObj.key)}?filter=${encodeURIComponent(filter)}`, class: "map-card-thumb-link" },
{ innerHTML: `<img src="${thumbSrc}" class="map-card-thumb" alt="map">` }),
div({ class: "map-card-body" },
div({ class: "shop-title-row" },
mapObj.title ? h2({ class: "tribe-card-title" }, a({ href: `/maps/${encodeURIComponent(mapObj.key)}?filter=${encodeURIComponent(filter)}` }, mapObj.title)) : null
),
chips.length ? div({ class: "card-chips-row" }, ...chips) : null,
div({ class: "map-card-header" },
div({ class: "map-card-info" },
span({ class: "map-coords" }, `📍 ${mapObj.lat.toFixed(4)}, ${mapObj.lng.toFixed(4)}`),
markerCount > 0 ? span({ class: "map-marker-count" }, `${markerCount}`) : null,
mapObj.key ? renderMapUrl(mapObj) : null),
div({ class: "map-card-actions" },
renderMapFavoriteToggle(mapObj, returnTo),
renderPMButton(mapObj.author),
...ownerActions)),
safeText(mapObj.description) ? p({ class: "map-description" }, mapObj.description) : null,
(() => {
const btn = renderSpreadButton(mapObj.key, params.spreadMap && params.spreadMap.get(mapObj.key));
return btn ? div({ class: "card-spread-left" }, btn) : null;
})(),
p({ class: "card-footer" },
span({ class: "date-link" }, moment(mapObj.createdAt).fromNow()),
span(" · "),
userLink(mapObj.author)))));
span(),
renderContentActions(mapObj.key, href, { spread: params.spreadMap && params.spreadMap.get(mapObj.key) || null, author: mapObj.author, favKind: 'maps', isFavorite: mapObj.isFavorite, reportTitle: mapObj.title })
),
div({ class: "tribe-card-image-wrapper" },
a({ href }, img({ src: thumbSrc, class: "tribe-card-hero-image", alt: "map" }))
),
div({ class: "tribe-card-body" },
div({ class: "shop-title-row" },
h2({ class: "tribe-card-title" }, a({ href }, mapObj.title || i18n.mapAllSectionTitle))
),
chips.length ? div({ class: "card-chips-row" }, ...chips) : null,
p({ class: "job-meta-line" }, `📍 ${mapObj.lat.toFixed(4)}, ${mapObj.lng.toFixed(4)}`),
safeText(mapObj.description) ? p({ class: "tribe-card-description" }, mapObj.description) : null,
div({ class: "tribe-card-members" },
span({ class: "tribe-members-count" }, `${i18n.mapMarkersTitle || "Markers"}: ${markerCount}`)
)
)
);
};
const renderMapList = (maps, filter, params = {}) =>
@ -392,13 +378,20 @@ const renderMapList = (maps, filter, params = {}) =>
? maps.map((mapObj) => renderMapCard(mapObj, filter, params))
: p(params.q ? i18n.mapNoMatch : i18n.noMaps);
exports.renderMapInvitePage = (code) => {
const pageContent = div({ class: "invite-page" },
h2(i18n.tribeInviteCodeText, code),
form({ method: "GET", action: "/maps" },
input({ type: "hidden", name: "filter", value: "all" }),
button({ type: "submit", class: "filter-btn" }, i18n.walletBack)
)
);
return template(i18n.mapInviteMode || i18n.tribeInviteCodeText || "Invite", section(pageContent));
};
exports.mapsView = async (maps, filter = "all", mapId = null, params = {}) => {
const title = filter === "mine" ? i18n.mapMineSectionTitle
: filter === "create" ? i18n.mapCreateSectionTitle
: filter === "edit" ? i18n.mapUpdateSectionTitle
: filter === "recent" ? i18n.mapRecentSectionTitle
: filter === "favorites" ? i18n.mapFavoritesSectionTitle
: i18n.mapAllSectionTitle;
if (filter === "edit") params = { ...params, spreadWarning: await renderSpreadEditWarning(mapId) };
const title = i18n.mapTitle;
const q = safeText(params.q || "");
const list = safeArr(maps);
@ -418,7 +411,7 @@ exports.mapsView = async (maps, filter = "all", mapId = null, params = {}) => {
input({ type: "hidden", name: "filter", value: filter }),
input({ type: "text", name: "q", value: q, placeholder: i18n.mapSearchPlaceholder, class: "filter-box__input" }),
div({ class: "filter-box__controls" }, button({ type: "submit", class: "filter-box__button" }, i18n.mapSearchButton)))),
div({ class: "maps-list" }, renderMapList(list, filter, { q })))));
div({ class: "jobs-grid" }, renderMapList(list, filter, { q })))));
};
exports.singleMapView = async (mapObj, filter = "all", params = {}) => {
@ -426,7 +419,7 @@ exports.singleMapView = async (mapObj, filter = "all", params = {}) => {
const returnTo = safeText(params.returnTo) || buildReturnTo(filter, { q });
const ownerActions = renderMapOwnerActions(filter, mapObj, { q });
const tribeMembers = safeArr(params.tribeMembers);
const zoomVal = parseInt(params.zoom) || 2;
const zoomVal = parseInt(params.zoom) || 8;
const allMarkers = [{ lat: mapObj.lat, lng: mapObj.lng }].concat(
safeArr(mapObj.markers).map((m) => ({ lat: m.lat, lng: m.lng })));
@ -435,55 +428,80 @@ exports.singleMapView = async (mapObj, filter = "all", params = {}) => {
safeArr(mapObj.markers).map((m) => m.label || ""));
const pinImages = [mapObj.image || ""].concat(safeArr(mapObj.markers).map((m) => m.image || ""));
const mapSide = div({ class: "tribe-side" },
div({ class: "card-header activity-card-header" },
renderContentActions(mapObj.key, null, {
author: mapObj.author,
favKind: 'maps',
isFavorite: mapObj.isFavorite,
spread: params.spreads || null,
returnTo,
reportTitle: mapObj.title
})
),
div({ class: "shop-title-row" },
h2({ class: "tribe-card-title" }, mapObj.title || i18n.mapAllSectionTitle)
),
div({ class: "card-chips-row" },
renderStateChip("whole", "🗺", String(mapObj.mapType || "").toUpperCase()),
renderEncryptedChip(i18n),
renderLifespanChip(mapObj.lifetime, i18n)
),
safeText(mapObj.description) ? p({ class: "tribe-side-description" }, mapObj.description) : null,
table({ class: "tribe-info-table jobs-info-table" },
tr(
td({ class: "tribe-info-label" }, i18n.mapLatLabel),
td({ class: "tribe-info-value", colspan: "3" }, mapObj.lat.toFixed(6))
),
tr(
td({ class: "tribe-info-label" }, i18n.mapLngLabel),
td({ class: "tribe-info-value", colspan: "3" }, mapObj.lng.toFixed(6))
),
tr(
td({ class: "tribe-info-label" }, i18n.createdAtLabel || "Created at"),
td({ class: "tribe-info-value", colspan: "3" }, moment(mapObj.createdAt).format("YYYY/MM/DD HH:mm"))
),
tr(
td({ class: "tribe-info-value", colspan: "4" }, userLink(mapObj.author))
)
),
renderTags(mapObj.tags),
div({ class: "tribe-card-members" },
span({ class: "tribe-members-count" }, `${i18n.mapMarkersTitle || "Markers"}: ${safeArr(mapObj.markers).length + 1}`)
),
(String(mapObj.author) !== String(userId) && !mapObj.tribeId && mapObj.mapType !== "OPEN")
? div({ class: "tribe-side-actions" },
a({ class: "tribe-action-btn", href: "/invites#invites-maps" }, i18n.tribeEnterInvite)
)
: null,
ownerActions.length ? div({ class: "tribe-side-actions owner-actions" }, ...ownerActions) : null
);
const mapMain = div({ class: "tribe-main" },
renderMapUrl(mapObj),
renderMap(allMarkers, null, 0, { pinLabels, pinImages, pinPrefix: `detail${areaCounter}`, zoom: zoomVal, centerLat: parseFloat(mapObj.lat) || 0, centerLng: parseFloat(mapObj.lng) || 0 }),
form({ method: "GET", action: `/maps/${encodeURIComponent(mapObj.key)}` },
label(i18n.mapZoomLabel || "Zoom"),
br(),
select({ name: "zoom" },
[2, 3, 4, 5, 6, 7, 8].map(z =>
option({ value: String(z), ...(zoomVal === z ? { selected: true } : {}) }, String(z)))),
br(), br(),
button({ type: "submit", class: "filter-btn" }, i18n.mapApplyZoom || "Apply Zoom")),
renderMarkersList(mapObj.markers, mapObj),
p({ class: "card-footer" },
span({ class: "date-link" }, `${moment(mapObj.createdAt).format("YYYY/MM/DD HH:mm")} ${i18n.performed} `),
userLink(mapObj.author),
mapObj.updatedAt && mapObj.updatedAt !== mapObj.createdAt
? span({ class: "votations-comment-date" }, ` · ${i18n.mapUpdatedAt}: ${moment(mapObj.updatedAt).format("YYYY/MM/DD HH:mm")}`)
: null),
renderMarkerForm(mapObj, returnTo, params, tribeMembers)
);
return template(mapObj.title || i18n.mapTitle,
section(div({ class: "tags-header" }, h2(i18n.mapTitle), p(i18n.mapDescription))),
section(renderFilters(filter, q)),
section(
div({ class: "map-detail" },
mapObj.title ? div({ class: "shop-title-row" },
h2({ class: "tribe-card-title" }, mapObj.title)
) : null,
mapObj.title ? div({ class: "card-chips-row" },
renderEncryptedChip(i18n),
renderLifespanChip(mapObj.lifetime, i18n)
) : null,
safeText(mapObj.description) ? p({ class: "map-description" }, mapObj.description) : null,
div({ class: "map-detail-header" },
div({ class: "map-detail-info" },
span({ class: "map-type-badge" }, mapObj.mapType),
span({ class: "map-coords-detail" }, `📍 ${mapObj.lat.toFixed(6)}, ${mapObj.lng.toFixed(6)}`)),
div({ class: "map-detail-actions" },
renderMapFavoriteToggle(mapObj, returnTo),
renderPMButton(mapObj.author),
(String(mapObj.author) !== String(userId) && !mapObj.tribeId && mapObj.mapType !== "OPEN")
? a({ class: "tribe-action-btn", href: "/invites#invites-maps" }, i18n.tribeEnterInvite)
: null,
...ownerActions)),
renderMapUrl(mapObj),
br(),
form({ method: "GET", action: `/maps/${encodeURIComponent(mapObj.key)}` },
label(i18n.mapZoomLabel || "Zoom"),
br(),
select({ name: "zoom" },
[2, 3, 4, 5, 6, 7, 8].map(z =>
option({ value: String(z), ...(zoomVal === z ? { selected: true } : {}) }, String(z)))),
br(), br(),
button({ type: "submit", class: "filter-btn" }, i18n.mapApplyZoom || "Apply Zoom")),
br(),
renderMap(allMarkers, null, 0, { pinLabels, pinImages, pinPrefix: `detail${areaCounter}`, zoom: zoomVal, centerLat: parseFloat(mapObj.lat) || 0, centerLng: parseFloat(mapObj.lng) || 0 }),
renderMarkersList(mapObj.markers, mapObj),
renderTags(mapObj.tags),
(() => {
const btn = renderSpreadButton(mapObj.key);
return btn ? div({ class: "card-spread-centered" }, btn) : null;
})(),
br(),
p({ class: "card-footer" },
span({ class: "date-link" }, `${moment(mapObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
userLink(mapObj.author),
mapObj.updatedAt && mapObj.updatedAt !== mapObj.createdAt
? span({ class: "votations-comment-date" }, ` · ${i18n.mapUpdatedAt}: ${moment(mapObj.updatedAt).format("YYYY/MM/DD HH:mm:ss")}`)
: null),
renderMarkerForm(mapObj, returnTo, params, tribeMembers))));
section(div({ class: "tribe-details" }, mapSide, mapMain)));
};
exports.renderMapLocationUrl = (mapUrl) => {
@ -520,6 +538,7 @@ exports.renderMapEmbedWithZoom = (mapData, mapUrl, detailUrl, zoom) => {
const lo = parseFloat(mapData.lng) || 0;
return div({ class: "map-embed-section" },
span({ class: "card-label" }, (i18n.mapLocationTitle || "Map Location") + ":"),
renderMap([{ lat: la, lng: lo }], null, 0, { zoom: zoomVal, centerLat: la, centerLng: lo }),
form({ method: "GET", action: detailUrl },
label(i18n.mapZoomLabel || "Zoom"),
br(),
@ -528,8 +547,6 @@ exports.renderMapEmbedWithZoom = (mapData, mapUrl, detailUrl, zoom) => {
option({ value: String(z), ...(zoomVal === z ? { selected: true } : {}) }, String(z)))),
br(), br(),
button({ type: "submit", class: "filter-btn" }, i18n.mapApplyZoom || "Apply Zoom")),
br(),
renderMap([{ lat: la, lng: lo }], null, 0, { zoom: zoomVal, centerLat: la, centerLng: lo }),
mapUrl ? div({ class: "map-embed-url" },
a({ href: mapUrl, class: "map-location-link" }, mapUrl)) : null);
};

View file

@ -0,0 +1,81 @@
const { div, h2, p, section, button, form, input, span } = require("../server/node_modules/hyperaxe");
const { template, i18n, userLink, renderContentActions } = require("./main_views");
const { getViewDetailsAction } = require("./activity_view");
const moment = require("../server/node_modules/moment");
const { config } = require("../server/SSB_server.js");
const { renderUrl } = require("../backend/renderUrl");
const userId = config.keys.id;
const excerpt = (text, max = 320) => {
const s = String(text == null ? "" : text).trim();
return s.length > max ? s.slice(0, max) + "…" : s;
};
const typeLabel = (type) => i18n['type' + String(type || '').charAt(0).toUpperCase() + String(type || '').slice(1)] || String(type || '').toUpperCase();
const renderMentionCard = (item) => {
const href = getViewDetailsAction(item.type, { id: item.id, key: item.id, author: item.author, content: item.content });
const isOwn = String(item.author) === String(userId);
return div({ class: "trending-card mention-card" + (isOwn ? " own-content" : "") },
div({ class: "card-header activity-card-header" },
div({ class: 'card-chips-row' },
span({ class: 'pm-exposition-chip pm-exposition-whole' },
span({ class: 'pm-exposition-text' }, typeLabel(item.type))
)
),
renderContentActions(item.id, href, { author: item.author, reportTitle: item.title || item.text })
),
div({ class: "card-section mention-card-body" },
item.title
? div({ class: "shop-title-row" }, h2({ class: "tribe-card-title" }, item.title))
: null,
item.text ? p({ class: "mention-text" }, ...renderUrl(excerpt(item.text))) : null,
p({ class: "card-footer" },
span({ class: "date-link" }, moment(item.createdAt).format("YYYY/MM/DD HH:mm")),
span(" · "),
userLink(item.author)
)
)
);
};
exports.mentionsView = async (items = [], filter = 'ALL', params = {}) => {
const counts = params.counts || {};
const types = Object.keys(counts).sort((a, b) => (counts[b] - counts[a]) || a.localeCompare(b));
const q = params.q || '';
return template(
i18n.mentions,
section(
div({ class: "tags-header" },
h2(i18n.mentions),
p(i18n.mentionsDescription)
),
types.length
? div({ class: "filters" },
form({ method: "GET", action: "/mentions", class: "ui-toolbar ui-toolbar--filters" },
...types.map(t =>
button({ type: "submit", name: "filter", value: t, class: filter === t ? "filter-btn active" : "filter-btn" },
`${typeLabel(t)} (${counts[t]})`)
)
)
)
: null,
div({ class: "filters" },
form({ method: "GET", action: "/mentions", class: "filter-box" },
input({ type: "hidden", name: "filter", value: filter }),
input({ type: "text", name: "q", value: q, placeholder: i18n.mentionsSearchPlaceholder, class: "filter-box__input" }),
div({ class: "filter-box__controls" },
button({ type: "submit", class: "filter-box__button" }, i18n.searchButton)
)
)
)
),
section(
items.length
? div({ class: "mentions-list" }, ...items.map(renderMentionCard))
: p({ class: "empty" }, i18n.noMentions)
)
);
};

View file

@ -1,6 +1,7 @@
const { form, button, div, h2, p, section, table, tr, td, input, a, br, ul, li, span, strong } = require("../server/node_modules/hyperaxe");
const { template, i18n } = require('./main_views');
const { getConfig } = require('../configs/config-manager.js');
const { MOBILE_MODULES, ALL_MODULES } = require('../models/workflows_model');
const modulesView = () => {
const config = getConfig().modules;
@ -19,6 +20,7 @@ const modulesView = () => {
{ name: 'docs', label: i18n.modulesDocsLabel, description: i18n.modulesDocsDescription },
{ name: 'events', label: i18n.modulesEventsLabel, description: i18n.modulesEventsDescription },
{ name: 'favorites', label: i18n.modulesFavoritesLabel, description: i18n.modulesFavoritesDescription },
{ name: 'blogs', label: i18n.modulesBlogsLabel, description: i18n.modulesBlogsDescription },
{ name: 'fediverse', label: i18n.modulesFediverseLabel, description: i18n.modulesFediverseDescription },
{ name: 'feed', label: i18n.modulesFeedLabel, description: i18n.modulesFeedDescription },
{ name: 'forum', label: i18n.modulesForumLabel, description: i18n.modulesForumDescription },
@ -29,26 +31,21 @@ const modulesView = () => {
{ name: 'invites', label: i18n.modulesInvitesLabel, description: i18n.modulesInvitesDescription },
{ name: 'jobs', label: i18n.modulesJobsLabel, description: i18n.modulesJobsDescription },
{ name: 'larp', label: i18n.modulesLarpLabel, description: i18n.modulesLarpDescription },
{ name: 'latest', label: i18n.modulesLatestLabel, description: i18n.modulesLatestDescription },
{ name: 'legacy', label: i18n.modulesLegacyLabel, description: i18n.modulesLegacyDescription },
{ name: 'logs', label: i18n.modulesLogsLabel, description: i18n.modulesLogsDescription },
{ name: 'maps', label: i18n.modulesMapLabel, description: i18n.modulesMapDescription },
{ name: 'market', label: i18n.modulesMarketLabel, description: i18n.modulesMarketDescription },
{ name: 'melody', label: i18n.modulesMelodyLabel, description: i18n.modulesMelodyDescription },
{ name: 'multiverse', label: i18n.modulesMultiverseLabel, description: i18n.modulesMultiverseDescription },
{ name: 'opinions', label: i18n.modulesOpinionsLabel, description: i18n.modulesOpinionsDescription },
{ name: 'pads', label: i18n.modulesPadsLabel, description: i18n.modulesPadsDescription },
{ name: 'parliament', label: i18n.modulesParliamentLabel, description: i18n.modulesParliamentDescription },
{ name: 'pixelia', label: i18n.modulesPixeliaLabel, description: i18n.modulesPixeliaDescription },
{ name: 'popular', label: i18n.modulesPopularLabel, description: i18n.modulesPopularDescription },
{ name: 'polls', label: i18n.modulesPollsLabel, description: i18n.modulesPollsDescription },
{ name: 'projects', label: i18n.modulesProjectsLabel, description: i18n.modulesProjectsDescription },
{ name: 'reports', label: i18n.modulesReportsLabel, description: i18n.modulesReportsDescription },
{ name: 'shops', label: i18n.modulesShopsLabel, description: i18n.modulesShopsDescription },
{ name: 'summaries', label: i18n.modulesSummariesLabel, description: i18n.modulesSummariesDescription },
{ name: 'tags', label: i18n.modulesTagsLabel, description: i18n.modulesTagsDescription },
{ name: 'tasks', label: i18n.modulesTasksLabel, description: i18n.modulesTasksDescription },
{ name: 'threads', label: i18n.modulesThreadsLabel, description: i18n.modulesThreadsDescription },
{ name: 'topics', label: i18n.modulesTopicsLabel, description: i18n.modulesTopicsDescription },
{ name: 'torrents', label: i18n.modulesTorrentsLabel, description: i18n.modulesTorrentsDescription },
{ name: 'transfers', label: i18n.modulesTransfersLabel, description: i18n.modulesTransfersDescription },
{ name: 'trending', label: i18n.modulesTrendingLabel, description: i18n.modulesTrendingDescription },
@ -70,34 +67,35 @@ const modulesView = () => {
const header = div({ class: 'tags-header' },
h2(i18n.modulesViewTitle),
p(i18n.modulesViewDescription),
div({ style: 'margin-bottom:16px;' },
div({ class: 'modules-counts' },
`${i18n.modulesTotalModulesLabel}: `,
strong({ style: 'color:#888;' }, totalModulesCount),
ul({ style: 'list-style-type:none; padding:0; margin:0;' },
li({ style: 'font-size:18px; color:#555; margin:8px 0;' },
strong({ class: 'modules-count-value' }, totalModulesCount),
ul({ class: 'modules-count-list' },
li({ class: 'modules-count-item' },
`${i18n.modulesEnabledModulesLabel}: `,
span({ style: 'color:#888;' }, activeModulesCount)
span({ class: 'modules-count-value' }, activeModulesCount)
),
li({ style: 'font-size:18px; color:#555; margin:8px 0;' },
li({ class: 'modules-count-item' },
`${i18n.modulesDisabledModulesLabel}: `,
span({ style: 'color:#888;' }, disabledModulesCount)
span({ class: 'modules-count-value' }, disabledModulesCount)
)
)
)
);
const PRESETS = {
minimal: ['feed', 'forum', 'games', 'images', 'videos', 'audios', 'bookmarks', 'tags', 'trending', 'popular', 'latest', 'threads', 'opinions', 'cipher', 'legacy'],
social: ['agenda', 'audios', 'bookmarks', 'calendars', 'chats', 'cipher', 'courts', 'docs', 'events', 'favorites', 'fediverse', 'feed', 'forum', 'games', 'images', 'invites', 'larp', 'legacy', 'logs', 'maps', 'multiverse', 'opinions', 'pads', 'parliament', 'pixelia', 'melody', 'projects', 'reports', 'tags', 'tasks', 'threads', 'trending', 'tribes', 'videos', 'votes'],
economy: ['agenda', 'audios', 'bookmarks', 'calendars', 'chats', 'cipher', 'courts', 'docs', 'events', 'favorites', 'fediverse', 'feed', 'forum', 'games', 'images', 'invites', 'larp', 'legacy', 'logs', 'maps', 'multiverse', 'opinions', 'pads', 'parliament', 'pixelia', 'melody', 'projects', 'reports', 'tags', 'tasks', 'threads', 'trending', 'tribes', 'videos', 'votes', 'banking', 'wallet', 'transfers', 'market', 'jobs', 'shops', 'industry'],
full: modules.map(m => m.name)
minimal: ['feed', 'forum', 'games', 'images', 'videos', 'audios', 'bookmarks', 'tags', 'trending', 'blogs', 'polls', 'opinions', 'cipher', 'legacy'],
social: ['agenda', 'audios', 'bookmarks', 'calendars', 'chats', 'cipher', 'courts', 'docs', 'events', 'favorites', 'fediverse', 'feed', 'forum', 'games', 'images', 'invites', 'larp', 'legacy', 'logs', 'maps', 'blogs', 'polls', 'opinions', 'pads', 'parliament', 'pixelia', 'melody', 'projects', 'reports', 'tags', 'tasks', 'trending', 'tribes', 'videos', 'votes'],
economy: ['agenda', 'audios', 'bookmarks', 'calendars', 'chats', 'cipher', 'courts', 'docs', 'events', 'favorites', 'fediverse', 'feed', 'forum', 'games', 'images', 'invites', 'larp', 'legacy', 'logs', 'maps', 'blogs', 'polls', 'opinions', 'pads', 'parliament', 'pixelia', 'melody', 'projects', 'reports', 'tags', 'tasks', 'trending', 'tribes', 'videos', 'votes', 'banking', 'wallet', 'transfers', 'market', 'jobs', 'shops', 'industry'],
mobile: MOBILE_MODULES,
full: ALL_MODULES
};
const presetButtons = div({ class: 'preset-group', style: 'display:flex;gap:8px;flex-wrap:nowrap;margin-bottom:16px;' },
const presetButtons = div({ class: 'preset-group' },
Object.entries(PRESETS).map(([key, mods]) => {
const presetLabel = (i18n[`modulesPreset_${key}`] || key).toUpperCase();
const isActive = modules.every(m => mods.includes(m.name) === (moduleStates[`${m.name}Mod`] === 'on'));
return form({ action: "/modules/preset", method: "post", style: "display:inline;margin:0;" },
return form({ action: "/modules/preset", method: "post", class: "modules-preset-form" },
input({ type: "hidden", name: "preset", value: key }),
button({
type: 'submit',
@ -120,7 +118,7 @@ const modulesView = () => {
tr(
td(i18n.modulesModuleName),
td(i18n.modulesModuleDescription),
td({ style: 'text-align: center;' }, i18n.modulesModuleStatus)
td({ class: 'module-status-cell' }, i18n.modulesModuleStatus)
),
modules.map(mod =>
tr(
@ -140,7 +138,7 @@ const modulesView = () => {
)
),
div(
{ class: "save-button-container", style: "margin-top: 20px; text-align: center;" },
{ class: "save-button-container" },
button({ type: "submit", class: "submit-button" }, i18n.saveSettings)
)
)

View file

@ -102,10 +102,6 @@ const renderContentHtml = (content, key) => {
p(...renderUrl(content.description))
]
: null,
content.meme ? div({ class: 'card-field' },
span({ class: 'card-label' }, i18n.trendingCategory + ':'),
span({ class: 'card-value' }, i18n.meme)
) : "",
br(),
div({ class: 'card-field' },
img({ src: `/blob/${encodeURIComponent(content.url)}`, class: 'feed-image' })
@ -265,7 +261,7 @@ const renderContentHtml = (content, key) => {
}
};
exports.opinionsView = (items, filter, spreadMap = new Map()) => {
exports.opinionsView = (items, filter, spreadMap = new Map(), q = '') => {
seenDocumentTitles.clear();
items = items
.filter(item => {
@ -282,7 +278,7 @@ exports.opinionsView = (items, filter, spreadMap = new Map()) => {
});
const title = i18n.opinionsTitle;
const baseFilters = ['TOP', 'ALL', 'MINE', 'RECENT'];
const baseFilters = ['ALL', 'MINE', 'RECENT', 'TOP'];
const cards = items
.map(item => {
@ -303,12 +299,11 @@ exports.opinionsView = (items, filter, spreadMap = new Map()) => {
span({ class: 'pm-exposition-chip pm-exposition-whole' },
span({ class: 'pm-exposition-text' }, String(c.type || '').toUpperCase())
),
renderContentActions(key, detailHref(c.type, key))
renderContentActions(key, detailHref(c.type, key), { spread: spreadMap.get(key) || null })
),
div(
{ class: 'card-section opinions-card-body' },
contentHtml,
div({ class: 'card-spread-left' }, renderSpreadButton(key, spreadMap.get(key))),
p({ class: 'card-footer' },
span({ class: 'date-link' }, `${created} ${i18n.performed} `),
userLink(item.value.author)
@ -331,10 +326,12 @@ exports.opinionsView = (items, filter, spreadMap = new Map()) => {
);
})(),
details({ class: 'opinions-voting-collapse' },
summary({ class: 'opinions-summary' }, `${i18n.opinionsTitle || 'Opinions'} (${Object.values(c.opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0)})`),
summary({ class: 'opinions-summary' },
span({ class: 'opinions-summary-icon' }, 'ꔍ'),
span({ class: 'opinions-summary-count' }, `(${Object.values(c.opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0)})`)),
div({ class: 'voting-buttons' },
allCats.map(cat => {
const label = `${i18n['vote' + cat.charAt(0).toUpperCase() + cat.slice(1)] || cat} [${c.opinions?.[cat] || 0}]`;
const label = `${String(i18n['vote' + cat.charAt(0).toUpperCase() + cat.slice(1)] || cat).toUpperCase()} [${c.opinions?.[cat] || 0}]`;
if (voted) {
return button({ class: 'vote-btn', type: 'button' }, label);
}
@ -364,7 +361,7 @@ exports.opinionsView = (items, filter, spreadMap = new Map()) => {
baseFilters.map(mode =>
form({ method: 'GET', action: '/opinions' },
input({ type: 'hidden', name: 'filter', value: mode }),
button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, i18n[mode + 'Button'] || mode)
button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, String(i18n[mode + 'Button'] || mode).toUpperCase())
)
)
),
@ -372,7 +369,7 @@ exports.opinionsView = (items, filter, spreadMap = new Map()) => {
opinionCategories.positive.slice(0, 5).map(mode =>
form({ method: 'GET', action: '/opinions' },
input({ type: 'hidden', name: 'filter', value: mode }),
button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, i18n[mode + 'Button'] || mode)
button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, String(i18n[mode + 'Button'] || mode).toUpperCase())
)
)
),
@ -380,7 +377,7 @@ exports.opinionsView = (items, filter, spreadMap = new Map()) => {
opinionCategories.positive.slice(5, 10).map(mode =>
form({ method: 'GET', action: '/opinions' },
input({ type: 'hidden', name: 'filter', value: mode }),
button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, i18n[mode + 'Button'] || mode)
button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, String(i18n[mode + 'Button'] || mode).toUpperCase())
)
)
),
@ -388,7 +385,7 @@ exports.opinionsView = (items, filter, spreadMap = new Map()) => {
opinionCategories.positive.slice(10, 15).map(mode =>
form({ method: 'GET', action: '/opinions' },
input({ type: 'hidden', name: 'filter', value: mode }),
button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, i18n[mode + 'Button'] || mode)
button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, String(i18n[mode + 'Button'] || mode).toUpperCase())
)
)
)
@ -398,7 +395,7 @@ exports.opinionsView = (items, filter, spreadMap = new Map()) => {
opinionCategories.constructive.slice(0, 5).map(mode =>
form({ method: 'GET', action: '/opinions' },
input({ type: 'hidden', name: 'filter', value: mode }),
button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, i18n[mode + 'Button'] || mode)
button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, String(i18n[mode + 'Button'] || mode).toUpperCase())
)
)
),
@ -406,7 +403,7 @@ exports.opinionsView = (items, filter, spreadMap = new Map()) => {
opinionCategories.constructive.slice(5, 11).map(mode =>
form({ method: 'GET', action: '/opinions' },
input({ type: 'hidden', name: 'filter', value: mode }),
button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, i18n[mode + 'Button'] || mode)
button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, String(i18n[mode + 'Button'] || mode).toUpperCase())
)
)
),
@ -414,7 +411,7 @@ exports.opinionsView = (items, filter, spreadMap = new Map()) => {
opinionCategories.moderation.slice(0, 5).map(mode =>
form({ method: 'GET', action: '/opinions' },
input({ type: 'hidden', name: 'filter', value: mode }),
button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, i18n[mode + 'Button'] || mode)
button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, String(i18n[mode + 'Button'] || mode).toUpperCase())
)
)
),
@ -422,11 +419,20 @@ exports.opinionsView = (items, filter, spreadMap = new Map()) => {
opinionCategories.moderation.slice(5, 10).map(mode =>
form({ method: 'GET', action: '/opinions' },
input({ type: 'hidden', name: 'filter', value: mode }),
button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, i18n[mode + 'Button'] || mode)
button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, String(i18n[mode + 'Button'] || mode).toUpperCase())
)
)
)
),
div({ class: 'filters' },
form({ method: 'GET', action: '/opinions', class: 'filter-box' },
input({ type: 'hidden', name: 'filter', value: filter }),
input({ type: 'text', name: 'q', value: q, placeholder: i18n.opinionsSearchPlaceholder, class: 'filter-box__input' }),
div({ class: 'filter-box__controls' },
button({ type: 'submit', class: 'filter-box__button' }, i18n.searchButton)
)
)
),
section(
cards.length
? div({ class: 'opinions-container' }, ...cards)

View file

@ -264,7 +264,7 @@ const CandidatureStats = (cands, govCard, leaderMeta, electionQuorum = 2) => {
h2(i18n.parliamentElectionsStatusTitle),
div({ class: 'card-field card-field--spaced' },
span({ class: 'card-label' }, winLbl + ': '),
span({ class: 'card-value' }, hasQuorum ? idLink : span({ style: 'color:#ffcc00;font-weight:bold;' }, i18n.voteNoQuorum || 'NO QUORUM'))
span({ class: 'card-value' }, hasQuorum ? idLink : span({ class: 'vote-no-quorum' }, i18n.voteNoQuorum || 'NO QUORUM'))
),
hasQuorum
? div({ class: 'card-field card-field--spaced' },
@ -344,7 +344,7 @@ const ProposalForm = () =>
form(
{ method: 'POST', action: '/parliament/proposals/create' },
label(i18n.parliamentProposalTitle), br(),
input({ type: 'text', name: 'title', required: true }), br(), br(),
input({ type: 'text', name: 'title', maxlength: '100', required: true }), br(), br(),
label(i18n.parliamentProposalDescription), br(),
textarea({ name: 'description', rows: 5, maxlength: 1000 }), br(), br(),
button({ type: 'submit', class: 'create-button' }, i18n.parliamentProposalPublish)

View file

@ -1,5 +1,6 @@
const { form, button, div, h2, p, section, input, label, textarea, br, a, span, select, option, img, ul, li, table, thead, tbody, tr, th, td, progress, video, audio } = require("../server/node_modules/hyperaxe")
const { template, i18n, renderOpinionsVoting, userLink, renderStateChip, renderLifespanChip, renderEcoTax, renderSpreadButton, renderContentActions } = require("./main_views")
const { renderCommentsSection: renderSharedCommentsSection } = require("./comments_view");
const { template, i18n, renderOpinionsVoting, renderEngagement, userLink, renderStateChip, renderLifespanChip, renderEcoTax, renderSpreadButton, renderContentActions, renderSpreadEditWarning } = require("./main_views")
const moment = require("../server/node_modules/moment")
const { config } = require("../server/SSB_server.js")
const { renderUrl } = require("../backend/renderUrl")
@ -308,8 +309,8 @@ const renderMilestonesAndBounties = (project, filter, editable) => {
br(),
select(
{ name: "milestoneIndex" },
option({ value: "", selected: b.milestoneIndex == null }, "-"),
...milestones.map((m2, idx2) => option({ value: String(idx2), selected: b.milestoneIndex === idx2 }, m2.title))
option({ value: "", ...(b.milestoneIndex == null ? { selected: true } : {})}, "-"),
...milestones.map((m2, idx2) => option({ value: String(idx2), ...(b.milestoneIndex === idx2 ? { selected: true } : {})}, m2.title))
),
br(),
br(),
@ -408,7 +409,7 @@ const renderProjectList = exports.renderProjectList = (projects, filter, spreadM
div(
{ class: "card-header activity-card-header" },
span(),
renderContentActions(pr.id || pr.key, `/projects/${encodeURIComponent(pr.id)}`)
renderContentActions(pr.id || pr.key, `/projects/${encodeURIComponent(pr.id)}`, { spread: spreadMap.get(pr.id || pr.key) || null, author: pr.author, favKind: 'projects', isFavorite: pr.isFavorite, reportTitle: pr.title })
),
div({ class: "card-section project-card-body" },
heroNode,
@ -426,15 +427,14 @@ const renderProjectList = exports.renderProjectList = (projects, filter, spreadM
: null,
div({ class: "tribe-card-members" },
span({ class: "tribe-members-count" }, `${i18n.projectFollowers}: ${followersCount(pr)}`)
),
div({ class: "card-spread-centered" }, renderSpreadButton(pr.id || pr.key, spreadMap.get(pr.id || pr.key)))
)
)
)
})
)
}
const renderProjectForm = (project, mode) => {
const renderProjectForm = (project, mode, spreadWarning = null) => {
const pr = project || {}
const isEdit = mode === "edit"
const nowLocal = moment().format("YYYY-MM-DDTHH:mm")
@ -444,6 +444,7 @@ const renderProjectForm = (project, mode) => {
return div(
{ class: "div-center project-form" },
isEdit ? spreadWarning : null,
form(
{
action: isEdit ? `/projects/update/${encodeURIComponent(pr.id)}` : "/projects/create",
@ -453,7 +454,7 @@ const renderProjectForm = (project, mode) => {
input({ type: "hidden", name: "returnTo", value: returnTo }),
label(i18n.projectTitle),
br(),
input({ type: "text", name: "title", required: true, placeholder: i18n.projectTitlePlaceholder, value: pr.title || "" }),
input({ type: "text", name: "title", maxlength: "100", required: true, placeholder: i18n.projectTitlePlaceholder, value: pr.title || "" }),
br(),
label(i18n.projectDescription),
br(),
@ -505,6 +506,10 @@ const renderProjectForm = (project, mode) => {
}
exports.projectsView = async (projectsOrForm, filter, _unused, params = {}) => {
if (String(filter).toUpperCase() === "EDIT") {
const editing = Array.isArray(projectsOrForm) ? projectsOrForm[0] : projectsOrForm
params = { ...params, spreadWarning: await renderSpreadEditWarning(editing && (editing.id || editing.key)) }
}
const f = String(filter || "ALL").toUpperCase()
const filterObj = FILTERS.find((x) => x.key === f) || FILTERS[0]
const sectionTitle = i18n[filterObj.title] || i18n.projectAllTitle
@ -524,14 +529,25 @@ exports.projectsView = async (projectsOrForm, filter, _unused, params = {}) => {
{ class: "filters" },
form(
{ method: "GET", action: "/projects", class: "ui-toolbar ui-toolbar--filters" },
FILTERS.map((x) => button({ type: "submit", name: "filter", value: x.key, class: f === x.key ? "filter-btn active" : "filter-btn" }, i18n[x.i18n]))
FILTERS.map((x) => button({ type: "submit", name: "filter", value: x.key, class: f === x.key ? "filter-btn active" : "filter-btn" }, String(i18n[x.i18n]).toUpperCase()))
.concat(button({ type: "submit", name: "filter", value: "CREATE", class: "create-button" }, i18n.projectCreateProject))
)
),
f === "CREATE" || f === "EDIT"
? null
: div({ class: "filters" },
form({ method: "GET", action: "/projects", class: "filter-box" },
input({ type: "hidden", name: "filter", value: f }),
input({ type: "text", name: "q", value: safeText(params.q), placeholder: i18n.projectSearchPlaceholder, class: "filter-box__input" }),
div({ class: "filter-box__controls" },
button({ type: "submit", class: "filter-box__button" }, i18n.searchButton)
)
)
),
f === "CREATE" || f === "EDIT"
? (() => {
const prToEdit = f === "EDIT" ? (safeArr(projectsOrForm)[0] || {}) : (params.prefill || {})
return renderProjectForm(prToEdit, f === "EDIT" ? "edit" : "create")
return renderProjectForm(prToEdit, f === "EDIT" ? "edit" : "create", params.spreadWarning)
})()
: (f === "BACKERS"
? renderBackersLeaderboard(projectsOrForm)
@ -564,12 +580,6 @@ exports.singleProjectView = async (project, filter, comments, params = {}) => {
].filter(Boolean)
const sideActions = []
if (!isAuthor && pr.author) {
sideActions.push(form({ method: "GET", action: "/pm" },
input({ type: "hidden", name: "recipients", value: pr.author }),
button({ type: "submit", class: "filter-btn" }, i18n.privateMessage)
))
}
if (!isAuthor && statusUpper === "ACTIVE") {
sideActions.push(isFollower
? form({ method: "POST", action: `/projects/unfollow/${encodeURIComponent(pr.id)}` },
@ -584,7 +594,7 @@ exports.singleProjectView = async (project, filter, comments, params = {}) => {
}
if (isAuthor) {
sideActions.push(form({ method: "GET", action: `/projects/edit/${encodeURIComponent(pr.id)}` },
button({ class: "update-btn", type: "submit" }, i18n.projectUpdateButton)
button({ class: "tribe-action-btn", type: "submit" }, i18n.projectUpdateButton)
))
sideActions.push(form({ method: "POST", action: `/projects/delete/${encodeURIComponent(pr.id)}` },
input({ type: "hidden", name: "returnTo", value: returnTo }),
@ -595,10 +605,10 @@ exports.singleProjectView = async (project, filter, comments, params = {}) => {
input({ type: "hidden", name: "returnTo", value: returnTo }),
select(
{ name: "status", class: "project-control-select" },
option({ value: "ACTIVE", selected: statusUpper === "ACTIVE" }, i18n.projectStatusACTIVE),
option({ value: "PAUSED", selected: statusUpper === "PAUSED" }, i18n.projectStatusPAUSED),
option({ value: "COMPLETED", selected: statusUpper === "COMPLETED" }, i18n.projectStatusCOMPLETED),
option({ value: "CANCELLED", selected: statusUpper === "CANCELLED" }, i18n.projectStatusCANCELLED)
option({ value: "ACTIVE", ...(statusUpper === "ACTIVE" ? { selected: true } : {})}, i18n.projectStatusACTIVE),
option({ value: "PAUSED", ...(statusUpper === "PAUSED" ? { selected: true } : {})}, i18n.projectStatusPAUSED),
option({ value: "COMPLETED", ...(statusUpper === "COMPLETED" ? { selected: true } : {})}, i18n.projectStatusCOMPLETED),
option({ value: "CANCELLED", ...(statusUpper === "CANCELLED" ? { selected: true } : {})}, i18n.projectStatusCANCELLED)
),
button({ class: "status-btn project-control-btn", type: "submit" }, i18n.projectSetStatus)
))
@ -611,11 +621,13 @@ exports.singleProjectView = async (project, filter, comments, params = {}) => {
}
const projectSide = div({ class: "tribe-side" },
div({ class: "card-header activity-card-header" },
renderContentActions(pr.id || pr.key, null, { spread: params.spreads || null, author: pr.author, favKind: 'projects', isFavorite: pr.isFavorite, reportTitle: pr.title })
),
div({ class: "shop-title-row" },
h2({ class: "tribe-card-title" }, pr.title)
),
chips.length ? div({ class: "card-chips-row" }, ...chips) : null,
div({ class: "card-spread-centered" }, renderSpreadButton(pr.id || pr.key, params.spreads)),
pr.image ? renderMediaBlob(pr.image, { class: "tribe-detail-image" }) : null,
div({ class: "job-price-line card-salary" }, `${pr.goal || 0} ECO`),
div({ class: "job-price-line card-salary" }, `${i18n.projectFollowers}: ${followersCount(pr)}`),
@ -642,41 +654,14 @@ exports.singleProjectView = async (project, filter, comments, params = {}) => {
span({ class: "date-link" }, `${moment(pr.createdAt).format("YYYY/MM/DD HH:mm")} ${i18n.performed} `),
userLink(pr.author)
),
renderOpinionsVoting('/projects/opinions', pr.id || pr.key, pr.opinions, null, pr.opinions_inhabitants),
div(
{ id: "comments", class: "vote-comments-section" },
div({ class: "comment-form-wrapper" },
h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
form(
{ method: "POST", action: `/projects/${encodeURIComponent(pr.id || pr.key)}/comments`, class: "comment-form", enctype: "multipart/form-data" },
textarea({ id: "comment-text", name: "text", rows: 4, class: "comment-textarea", placeholder: i18n.voteNewCommentPlaceholder }),
div({ class: "comment-file-upload" }, label(i18n.uploadMedia), br(), input({ type: "file", name: "blob" })),
br(),
button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
)
),
(() => {
const visibleComments = (comments || []).filter(c => {
const t = c && c.value && c.value.content && c.value.content.text
return t && String(t).trim()
})
return visibleComments.length
? div({ class: "comments-list" },
visibleComments.map((c) => {
const author = c?.value?.author || ""
const ts = c?.value?.timestamp || c?.timestamp
const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : ""
const relDate = ts ? moment(ts).fromNow() : ""
return div({ class: "comment-card" },
div({ class: "comment-header" }, userLink(author)),
div({ class: "comment-date" }, span({ title: absDate }, relDate)),
div({ class: "comment-body" }, ...renderUrl(c?.value?.content?.text || ""))
)
})
)
: p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet)
})()
)
renderEngagement(pr.id || pr.key,
renderOpinionsVoting('/projects/opinions', pr.id || pr.key, pr.opinions, null, pr.opinions_inhabitants),
renderSharedCommentsSection({
action: `/projects/${encodeURIComponent(pr.id || pr.key)}/comments`,
comments,
returnTo: `/projects/${encodeURIComponent(pr.id || pr.key)}`
})
),
)
return template(
@ -687,7 +672,7 @@ exports.singleProjectView = async (project, filter, comments, params = {}) => {
{ class: "filters" },
form(
{ method: "GET", action: "/projects", class: "ui-toolbar ui-toolbar--filters" },
FILTERS.map((x) => button({ type: "submit", name: "filter", value: x.key, class: f === x.key ? "filter-btn active" : "filter-btn" }, i18n[x.i18n]))
FILTERS.map((x) => button({ type: "submit", name: "filter", value: x.key, class: f === x.key ? "filter-btn active" : "filter-btn" }, String(i18n[x.i18n]).toUpperCase()))
.concat(button({ type: "submit", name: "filter", value: "CREATE", class: "create-button" }, i18n.projectCreateProject))
)
),
@ -720,6 +705,7 @@ exports.clearnetProjectView = async (project) => {
.cn-prj-funding-amount{color:var(--fg);font-size:18px;font-weight:700;margin-bottom:8px}
.cn-prj-bar{height:8px;background:#000;border-radius:4px;overflow:hidden}
.cn-prj-bar-fill{height:100%;background:var(--fg);border-radius:4px}
${Array.from({ length: 21 }, (_, i) => `.cn-prj-bar-fill-${i * 5}{width:${i * 5}%}`).join('\n')}
.cn-prj-section{margin-top:24px}
.cn-prj-section h2{color:var(--fg);font-size:18px;text-transform:uppercase;letter-spacing:2px;margin:0 0 12px;padding-bottom:6px;border-bottom:1px solid var(--border)}
.cn-prj-section p{color:var(--fg-soft);white-space:pre-wrap;line-height:1.6;font-size:15px;margin:0}
@ -741,7 +727,7 @@ exports.clearnetProjectView = async (project) => {
<div class="cn-prj-funding">
<div class="cn-prj-funding-label">Funding</div>
<div class="cn-prj-funding-amount">${pledged.toFixed(2)} / ${goal.toFixed(2)} ECO · ${fundingPct}%</div>
<div class="cn-prj-bar"><div class="cn-prj-bar-fill" style="width:${fundingPct}%"></div></div>
<div class="cn-prj-bar"><div class="cn-prj-bar-fill cn-prj-bar-fill-${Math.round(fundingPct / 5) * 5}"></div></div>
${deadline ? `<div class="cn-prj-deadline">Deadline: ${deadline}</div>` : ''}
</div>` : ''}
${desc ? `<div class="cn-prj-section"><h2>Description</h2><p>${desc}</p></div>` : ''}

View file

@ -1,5 +1,7 @@
const { div, h2, p, section, button, form, a, textarea, br, input, img, span, label, select, option, video, audio, table, tr, td } = require("../server/node_modules/hyperaxe");
const { template, i18n, renderOpinionsVoting, userLink, renderStateChip, renderLifespanChip, renderEcoTax, renderSpreadButton } = require("./main_views");
const { renderCommentsSection: renderSharedCommentsSection } = require("./comments_view");
const { template, i18n, renderOpinionsVoting, renderEngagement, userLink, renderStateChip, renderLifespanChip, renderEcoTax, renderSpreadButton, renderSpreadEditWarning, renderContentActions, renderDocumentActions } = require("./main_views");
const { renderPhotoGallery, renderGalleryFields } = require("./gallery_view");
const { config } = require("../server/SSB_server.js");
const moment = require("../server/node_modules/moment");
const { renderUrl } = require("../backend/renderUrl");
@ -39,7 +41,7 @@ const STATUS_BY_FILTER = {
};
const opt = (value, isSelected, text) =>
option(Object.assign({ value }, isSelected ? { selected: "selected" } : {}), text);
option(Object.assign({ value }, isSelected ? { ...("selected" ? { selected: true } : {})} : {}), text);
const hasAnyTemplateValue = (t) => {
if (!t || typeof t !== "object") return false;
@ -62,15 +64,6 @@ const renderStackedTextField = (lbl, val) =>
)
: null;
const renderPmButton = (recipientId) =>
recipientId && String(recipientId) !== String(userId)
? form(
{ method: "GET", action: "/pm" },
input({ type: "hidden", name: "recipients", value: recipientId }),
button({ type: "submit", class: "filter-btn" }, i18n.privateMessage)
)
: null;
const renderReportStatusSetter = (report) => {
const st = normalizeStatus(report && report.status ? report.status : "OPEN");
return form(
@ -135,7 +128,6 @@ const renderTemplateDetails = (report) => {
h2({ class: "report-template-title" }, i18n.reportsContentTemplateTitle),
renderStackedTextField(i18n.reportsContentLocationLabel + ":", t.contentLocation),
renderStackedTextField(i18n.reportsWhyInappropriateLabel + ":", t.whyInappropriate),
renderStackedTextField(i18n.reportsRequestedActionLabel + ":", t.requestedAction),
renderStackedTextField(i18n.reportsEvidenceLinksLabel + ":", t.evidenceLinks)
);
}
@ -144,73 +136,11 @@ const renderTemplateDetails = (report) => {
};
const renderReportCommentsSection = (reportId, comments = []) => {
const commentsCount = Array.isArray(comments) ? comments.length : 0;
return div(
{ class: "vote-comments-section" },
div(
{ class: "comments-count" },
span({ class: "card-label" }, i18n.voteCommentsLabel + ": "),
span({ class: "card-value" }, String(commentsCount))
),
div(
{ class: "comment-form-wrapper" },
h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
form(
{
method: "POST",
action: `/reports/${encodeURIComponent(reportId)}/comments`,
class: "comment-form",
enctype: "multipart/form-data"
},
textarea({
id: "comment-text",
name: "text",
rows: 4,
class: "comment-textarea",
placeholder: i18n.voteNewCommentPlaceholder
}),
div({ class: "comment-file-upload" }, label(i18n.uploadMedia), input({ type: "file", name: "blob" })),
br(),
button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
)
),
(() => {
const visibleComments = (comments || []).filter(c => {
const t = c && c.value && c.value.content && c.value.content.text;
return t && String(t).trim();
});
return visibleComments.length
? div(
{ class: "comments-list" },
visibleComments.map((c) => {
const author = c.value && c.value.author ? c.value.author : "";
const ts = c.value && c.value.timestamp ? c.value.timestamp : c.timestamp;
const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : "";
const relDate = ts ? moment(ts).fromNow() : "";
const content = c.value && c.value.content ? c.value.content : {};
const root = content.fork || content.root || "";
const text = content.text || "";
return div(
{ class: "votations-comment-card" },
span(
{ class: "created-at" },
span(i18n.createdBy),
author ? userLink(author) : span("(unknown)"),
absDate ? span(" | ") : "",
absDate ? span({ class: "votations-comment-date" }, absDate) : "",
relDate ? span({ class: "votations-comment-date" }, " | ", i18n.sendTime) : "",
relDate && root ? a({ href: `/thread/${encodeURIComponent(root)}#${encodeURIComponent(c.key)}` }, relDate) : ""
),
p({ class: "votations-comment-text" }, ...renderUrl(text))
);
})
)
: p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet);
})()
);
return renderSharedCommentsSection({
action: `/reports/${encodeURIComponent(reportId)}/comments`,
comments: comments,
returnTo: null
});
};
const renderTemplateForCategory = (category, templateData = {}) => {
@ -257,26 +187,6 @@ const renderTemplateForCategory = (category, templateData = {}) => {
);
}
if (cat === "ABUSE") {
return div(
{ class: "report-template-block" },
h2({ class: "report-template-title" }, i18n.reportsAbuseTemplateTitle),
label(i18n.reportsWhatHappenedLabel),
br(),
textarea({ name: "whatHappened", rows: "4", placeholder: i18n.reportsWhatHappenedPlaceholder }, tval("whatHappened")),
br(),
br(),
label(i18n.reportsReportedUserLabel),
br(),
textarea({ name: "reportedUser", rows: "2", placeholder: i18n.reportsReportedUserPlaceholder }, tval("reportedUser")),
br(),
br(),
label(i18n.reportsEvidenceLinksLabel),
br(),
textarea({ name: "evidenceLinks", rows: "3", placeholder: i18n.reportsEvidenceLinksPlaceholder }, tval("evidenceLinks"))
);
}
if (cat === "CONTENT") {
return div(
{ class: "report-template-block" },
@ -291,11 +201,6 @@ const renderTemplateForCategory = (category, templateData = {}) => {
textarea({ name: "whyInappropriate", rows: "4", placeholder: i18n.reportsWhyInappropriatePlaceholder }, tval("whyInappropriate")),
br(),
br(),
label(i18n.reportsRequestedActionLabel),
br(),
textarea({ name: "requestedAction", rows: "3", placeholder: i18n.reportsRequestedActionPlaceholder }, tval("requestedAction")),
br(),
br(),
label(i18n.reportsEvidenceLinksLabel),
br(),
textarea({ name: "evidenceLinks", rows: "3", placeholder: i18n.reportsEvidenceLinksPlaceholder }, tval("evidenceLinks"))
@ -377,6 +282,10 @@ const renderReportCard = (report, userId, currentFilter = "all", spreadInfo) =>
].filter(Boolean);
return div({ class: "tribe-card report-card" },
div({ class: "card-header activity-card-header" },
span(),
renderContentActions(report.id, `/reports/${encodeURIComponent(report.id)}`, { spread: spreadInfo || null, author: report.author, favKind: 'reports', isFavorite: report.isFavorite, reportTitle: report.title, report: false })
),
div({ class: "tribe-card-body" },
div({ class: "shop-title-row" },
h2({ class: "tribe-card-title" },
@ -386,33 +295,13 @@ const renderReportCard = (report, userId, currentFilter = "all", spreadInfo) =>
chips.length ? div({ class: "card-chips-row" }, ...chips) : null,
div({ class: "tribe-card-members" },
span({ class: "tribe-members-count" }, `${i18n.reportsConfirmations}: ${confirmations.length}`)
),
div({ class: "card-spread-centered" }, renderSpreadButton(report.id, spreadInfo)),
div({ class: "card-visit-btn-centered" },
form({ method: "GET", action: `/reports/${encodeURIComponent(report.id)}` },
input({ type: "hidden", name: "filter", value: currentFilter }),
button({ type: "submit", class: "filter-btn" }, i18n.viewReport || "View Report")
)
)
)
);
};
exports.reportView = async (reports, filter, reportId, createCategory, params = {}) => {
const title =
filter === "create" ? i18n.reportsCreateButton :
filter === "edit" ? i18n.reportsUpdateButton :
filter === "mine" ? i18n.reportsMineSectionTitle :
filter === "features" ? i18n.reportsFeaturesSectionTitle :
filter === "bugs" ? i18n.reportsBugsSectionTitle :
filter === "abuse" ? i18n.reportsAbuseSectionTitle :
filter === "content" ? i18n.reportsContentSectionTitle :
filter === "confirmed" ? i18n.reportsConfirmedSectionTitle :
filter === "open" ? i18n.reportsOpenSectionTitle :
filter === "under_review" ? i18n.reportsUnderReviewSectionTitle :
filter === "resolved" ? i18n.reportsResolvedSectionTitle :
filter === "invalid" ? i18n.reportsInvalidSectionTitle :
i18n.reportsAllSectionTitle;
const title = i18n.reportsTitle;
let filtered = Array.isArray(reports) ? reports : [];
@ -433,18 +322,20 @@ exports.reportView = async (reports, filter, reportId, createCategory, params =
const reportToEdit = filter === "edit"
? (Array.isArray(reports) ? reports.find((r) => r.id === reportId) : null)
: null;
const formData = reportToEdit || params.draft || {};
const btnClass = (v) => (filter === v ? "filter-btn active" : "filter-btn");
const selectedCategory = normU(
const rawCategory = normU(
filter === "create"
? (createCategory || "FEATURES")
: (reportToEdit?.category || "FEATURES")
);
const selectedCategory = ["FEATURES", "BUGS", "CONTENT"].includes(rawCategory) ? rawCategory : "FEATURES";
const selectedTemplate = reportToEdit?.template && typeof reportToEdit.template === "object" ? reportToEdit.template : {};
const applyLabel = i18n.apply || "Apply";
const sev = String(reportToEdit?.severity || "low");
const sev = String(formData.severity || "low");
const hiddenDescription = String(reportToEdit?.description || "");
return template(
@ -458,31 +349,51 @@ exports.reportView = async (reports, filter, reportId, createCategory, params =
div(
{ class: "filters" },
form(
{ method: "GET", action: "/reports" },
button({ type: "submit", name: "filter", value: "all", class: btnClass("all") }, i18n.reportsFilterAll),
button({ type: "submit", name: "filter", value: "mine", class: btnClass("mine") }, i18n.reportsFilterMine),
button({ type: "submit", name: "filter", value: "features", class: btnClass("features") }, i18n.reportsFilterFeatures),
button({ type: "submit", name: "filter", value: "bugs", class: btnClass("bugs") }, i18n.reportsFilterBugs),
button({ type: "submit", name: "filter", value: "abuse", class: btnClass("abuse") }, i18n.reportsFilterAbuse),
button({ type: "submit", name: "filter", value: "content", class: btnClass("content") }, i18n.reportsFilterContent),
button({ type: "submit", name: "filter", value: "confirmed", class: btnClass("confirmed") }, i18n.reportsFilterConfirmed),
button({ type: "submit", name: "filter", value: "open", class: btnClass("open") }, i18n.reportsFilterOpen),
button({ type: "submit", name: "filter", value: "under_review", class: btnClass("under_review") }, i18n.reportsFilterUnderReview),
button({ type: "submit", name: "filter", value: "resolved", class: btnClass("resolved") }, i18n.reportsFilterResolved),
button({ type: "submit", name: "filter", value: "invalid", class: btnClass("invalid") }, i18n.reportsFilterInvalid),
{ method: "GET", action: "/reports", class: "ui-toolbar ui-toolbar--filters" },
button({ type: "submit", name: "filter", value: "all", class: btnClass("all") }, String(i18n.reportsFilterAll).toUpperCase()),
button({ type: "submit", name: "filter", value: "mine", class: btnClass("mine") }, String(i18n.reportsFilterMine).toUpperCase()),
button({ type: "submit", name: "filter", value: "recent", class: btnClass("recent") }, String(i18n.reportsFilterRecent).toUpperCase()),
button({ type: "submit", name: "filter", value: "top", class: btnClass("top") }, String(i18n.reportsFilterTop).toUpperCase()),
button({ type: "submit", name: "filter", value: "features", class: btnClass("features") }, String(i18n.reportsFilterFeatures).toUpperCase()),
button({ type: "submit", name: "filter", value: "bugs", class: btnClass("bugs") }, String(i18n.reportsFilterBugs).toUpperCase()),
button({ type: "submit", name: "filter", value: "content", class: btnClass("content") }, String(i18n.reportsFilterContent).toUpperCase()),
button({ type: "submit", name: "filter", value: "confirmed", class: btnClass("confirmed") }, String(i18n.reportsFilterConfirmed).toUpperCase()),
button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.reportsCreateButton)
),
form(
{ method: "GET", action: "/reports", class: "ui-toolbar ui-toolbar--filters reports-subfilters" },
button({ type: "submit", name: "filter", value: "open", class: btnClass("open") }, String(i18n.reportsFilterOpen).toUpperCase()),
button({ type: "submit", name: "filter", value: "under_review", class: btnClass("under_review") }, String(i18n.reportsFilterUnderReview).toUpperCase()),
button({ type: "submit", name: "filter", value: "resolved", class: btnClass("resolved") }, String(i18n.reportsFilterResolved).toUpperCase()),
button({ type: "submit", name: "filter", value: "invalid", class: btnClass("invalid") }, String(i18n.reportsFilterInvalid).toUpperCase()),
button({ type: "submit", name: "filter", value: "sev_low", class: btnClass("sev_low") }, String(i18n.reportsSeverityLow).toUpperCase()),
button({ type: "submit", name: "filter", value: "sev_medium", class: btnClass("sev_medium") }, String(i18n.reportsSeverityMedium).toUpperCase()),
button({ type: "submit", name: "filter", value: "sev_high", class: btnClass("sev_high") }, String(i18n.reportsSeverityHigh).toUpperCase()),
button({ type: "submit", name: "filter", value: "sev_critical", class: btnClass("sev_critical") }, String(i18n.reportsSeverityCritical).toUpperCase())
)
)
),
filter === "edit" || filter === "create"
? null
: div({ class: "filters" },
form({ method: "GET", action: "/reports", class: "filter-box" },
input({ type: "hidden", name: "filter", value: filter }),
input({ type: "text", name: "q", value: params.q || "", placeholder: i18n.reportsSearchPlaceholder, class: "filter-box__input" }),
div({ class: "filter-box__controls" },
button({ type: "submit", class: "filter-box__button" }, i18n.searchButton)
)
)
)
),
section(
filter === "edit" || filter === "create"
? div(
{ class: "report-form" },
filter === "edit" ? await renderSpreadEditWarning(reportToEdit && (reportToEdit.id || reportToEdit.key)) : null,
filter === "create"
? div(
label(i18n.reportsTitleLabel),
br(),
input({ type: "text", name: "title", required: true, value: params.prefillTitle || "", form: "report-create-form" }),
input({ type: "text", name: "title", maxlength: "100", required: true, value: params.prefillTitle || "", form: "report-create-form" }),
br(),
br(),
form(
@ -491,10 +402,9 @@ exports.reportView = async (reports, filter, reportId, createCategory, params =
label(i18n.reportsCategory),
br(),
select(
{ name: "category" },
{ name: "category", class: "report-category-select" },
opt("FEATURES", selectedCategory === "FEATURES", i18n.reportsCategoryFeatures),
opt("BUGS", selectedCategory === "BUGS", i18n.reportsCategoryBugs),
opt("ABUSE", selectedCategory === "ABUSE", i18n.reportsCategoryAbuse),
opt("CONTENT", selectedCategory === "CONTENT", i18n.reportsCategoryContent)
),
br(),
@ -508,6 +418,15 @@ exports.reportView = async (reports, filter, reportId, createCategory, params =
{ id: "report-create-form", action: "/reports/create", method: "POST", enctype: "multipart/form-data" },
input({ type: "hidden", name: "category", value: selectedCategory }),
input({ type: "hidden", name: "description", value: "" }),
h2({ class: "report-template-main-title" }, i18n.reportsTemplateSectionTitle),
renderTemplateForCategory(selectedCategory, params.draft || {}),
...renderGalleryFields(formData, false, 8),
br(),
label("Tags"),
br(),
input({ type: "text", name: "tags", value: Array.isArray(formData.tags) ? formData.tags.join(", ") : (formData.tags || "") }),
br(),
br(),
label(i18n.reportsSeverity),
br(),
select(
@ -519,18 +438,6 @@ exports.reportView = async (reports, filter, reportId, createCategory, params =
),
br(),
br(),
h2({ class: "report-template-main-title" }, i18n.reportsTemplateSectionTitle),
renderTemplateForCategory(selectedCategory, {}),
label(i18n.reportsUploadFile),
br(),
input({ type: "file", name: "image" }),
br(),
br(),
label("Tags"),
br(),
input({ type: "text", name: "tags", value: "" }),
br(),
br(),
button({ type: "submit", class: "create-button" }, i18n.reportsCreateButton)
)
)
@ -539,7 +446,7 @@ exports.reportView = async (reports, filter, reportId, createCategory, params =
{ id: "report-edit-form", action: `/reports/update/${encodeURIComponent(reportId)}`, method: "POST", enctype: "multipart/form-data" },
label(i18n.reportsTitleLabel),
br(),
input({ type: "text", name: "title", required: true, value: reportToEdit?.title || "" }),
input({ type: "text", name: "title", maxlength: "100", required: true, value: reportToEdit?.title || "" }),
br(),
br(),
input({ type: "hidden", name: "description", value: hiddenDescription }),
@ -549,11 +456,21 @@ exports.reportView = async (reports, filter, reportId, createCategory, params =
{ name: "category", required: true },
opt("FEATURES", selectedCategory === "FEATURES", i18n.reportsCategoryFeatures),
opt("BUGS", selectedCategory === "BUGS", i18n.reportsCategoryBugs),
opt("ABUSE", selectedCategory === "ABUSE", i18n.reportsCategoryAbuse),
opt("CONTENT", selectedCategory === "CONTENT", i18n.reportsCategoryContent)
),
br(),
br(),
h2({ class: "report-template-main-title" }, i18n.reportsTemplateSectionTitle),
renderTemplateForCategory(selectedCategory, selectedTemplate),
br(),
br(),
...renderGalleryFields(reportToEdit || {}, true, 8),
br(),
label("Tags"),
br(),
input({ type: "text", name: "tags", value: reportToEdit?.tags?.join(", ") || "" }),
br(),
br(),
label(i18n.reportsSeverity),
br(),
select(
@ -565,20 +482,6 @@ exports.reportView = async (reports, filter, reportId, createCategory, params =
),
br(),
br(),
h2({ class: "report-template-main-title" }, i18n.reportsTemplateSectionTitle),
renderTemplateForCategory(selectedCategory, selectedTemplate),
br(),
br(),
label(i18n.reportsUploadFile),
br(),
input({ type: "file", name: "image" }),
br(),
br(),
label("Tags"),
br(),
input({ type: "text", name: "tags", value: reportToEdit?.tags?.join(", ") || "" }),
br(),
br(),
button({ type: "submit" }, i18n.reportsUpdateButton)
)
)
@ -605,21 +508,16 @@ exports.singleReportView = async (report, filter, comments = [], params = {}) =>
].filter(Boolean);
const sideActions = [];
const pm = renderPmButton(report.author);
if (pm) sideActions.push(pm);
sideActions.push(form({ method: "POST", action: `/reports/confirm/${encodeURIComponent(report.id)}` },
button({ type: "submit", class: "filter-btn" }, i18n.reportsConfirmButton)
));
sideActions.push(a({ href: "/tasks?filter=create", target: "_blank" },
button({ type: "button", class: "filter-btn" }, i18n.reportsCreateTaskButton)
button({ type: "submit", class: "tribe-action-btn" }, i18n.reportsConfirmButton)
));
const ownerActions = [];
if (isAuthor) {
sideActions.push(renderReportStatusSetter(report));
sideActions.push(form({ method: "GET", action: `/reports/edit/${encodeURIComponent(report.id)}` },
button({ type: "submit", class: "update-btn" }, i18n.reportsUpdateButton)
ownerActions.push(form({ method: "GET", action: `/reports/edit/${encodeURIComponent(report.id)}` },
button({ type: "submit", class: "tribe-action-btn" }, i18n.reportsUpdateButton)
));
sideActions.push(form({ method: "POST", action: `/reports/delete/${encodeURIComponent(report.id)}` },
button({ type: "submit", class: "delete-btn" }, i18n.reportsDeleteButton)
ownerActions.push(form({ method: "POST", action: `/reports/delete/${encodeURIComponent(report.id)}` },
button({ type: "submit", class: "tribe-action-btn danger-btn" }, i18n.reportsDeleteButton)
));
}
@ -640,53 +538,72 @@ exports.singleReportView = async (report, filter, comments = [], params = {}) =>
pushRow(i18n.reportsCategory, report.category);
const reportSide = div({ class: "tribe-side" },
div({ class: "card-header activity-card-header" },
renderContentActions(report.id, null, { spread: params.spreads || null, author: report.author, favKind: 'reports', isFavorite: report.isFavorite, reportTitle: report.title, report: false })
),
div({ class: "shop-title-row" },
h2({ class: "tribe-card-title" }, report.title)
),
chips.length ? div({ class: "card-chips-row" }, ...chips) : null,
div({ class: "card-spread-centered" }, renderSpreadButton(report.id, params.spreads)),
table({ class: "tribe-info-table jobs-info-table" }, ...infoRows),
tagsNode,
div({ class: "tribe-card-members" },
span({ class: "tribe-members-count" }, `${i18n.reportsConfirmations}: ${confirmations.length}`)
),
sideActions.length ? div({ class: "tribe-side-actions" }, ...sideActions) : null
sideActions.length ? div({ class: "tribe-side-actions" }, ...sideActions) : null,
isAuthor
? div({ class: "tribe-side-actions housing-status-row" },
span({ class: "card-label" }, `${i18n.reportsStatus}: `),
renderStateChip(normalizeStatus(report.status) === "RESOLVED" ? "mutuals" : "whole", "◆", String(report.status || "OPEN").toUpperCase()),
renderReportStatusSetter(report)
)
: null,
ownerActions.length ? div({ class: "tribe-side-actions owner-actions" }, ...ownerActions) : null,
renderDocumentActions('reports', report.id, [
a({ href: "/tasks?filter=create", class: "filter-btn" }, i18n.reportsCreateTaskButton)
])
);
const opinionsBar = renderOpinionsVoting('/reports/opinions', report.id, report.opinions, null, report.opinions_inhabitants);
const reportMain = div({ class: "tribe-main" },
(details || report.image) ? div({ class: "job-section" },
details || null,
report.image ? renderMediaBlob(report.image, { class: "report-detail-image" }) : null
) : null,
details ? div({ class: "job-section" }, details) : null,
renderPhotoGallery(report, 'report'),
p({ class: "card-footer" },
span({ class: "date-link" }, `${moment(report.createdAt).format("YYYY/MM/DD HH:mm")} ${i18n.performed} `),
userLink(report.author)
),
opinionsBar,
renderReportCommentsSection(report.id, comments)
renderEngagement(report.id, opinionsBar, renderReportCommentsSection(report.id, comments))
);
return template(
report.title,
section(
div({ class: "tags-header" }, h2(i18n.reportsTitle), p(i18n.reportsDescription)),
div(
{ class: "filters" },
form(
{ method: "GET", action: "/reports" },
button({ type: "submit", name: "filter", value: "all", class: btnClass("all") }, i18n.reportsFilterAll),
button({ type: "submit", name: "filter", value: "mine", class: btnClass("mine") }, i18n.reportsFilterMine),
button({ type: "submit", name: "filter", value: "features", class: btnClass("features") }, i18n.reportsFilterFeatures),
button({ type: "submit", name: "filter", value: "bugs", class: btnClass("bugs") }, i18n.reportsFilterBugs),
button({ type: "submit", name: "filter", value: "abuse", class: btnClass("abuse") }, i18n.reportsFilterAbuse),
button({ type: "submit", name: "filter", value: "content", class: btnClass("content") }, i18n.reportsFilterContent),
button({ type: "submit", name: "filter", value: "confirmed", class: btnClass("confirmed") }, i18n.reportsFilterConfirmed),
button({ type: "submit", name: "filter", value: "open", class: btnClass("open") }, i18n.reportsFilterOpen),
button({ type: "submit", name: "filter", value: "under_review", class: btnClass("under_review") }, i18n.reportsFilterUnderReview),
button({ type: "submit", name: "filter", value: "resolved", class: btnClass("resolved") }, i18n.reportsFilterResolved),
button({ type: "submit", name: "filter", value: "invalid", class: btnClass("invalid") }, i18n.reportsFilterInvalid),
{ method: "GET", action: "/reports", class: "ui-toolbar ui-toolbar--filters" },
button({ type: "submit", name: "filter", value: "all", class: btnClass("all") }, String(i18n.reportsFilterAll).toUpperCase()),
button({ type: "submit", name: "filter", value: "mine", class: btnClass("mine") }, String(i18n.reportsFilterMine).toUpperCase()),
button({ type: "submit", name: "filter", value: "recent", class: btnClass("recent") }, String(i18n.reportsFilterRecent).toUpperCase()),
button({ type: "submit", name: "filter", value: "top", class: btnClass("top") }, String(i18n.reportsFilterTop).toUpperCase()),
button({ type: "submit", name: "filter", value: "features", class: btnClass("features") }, String(i18n.reportsFilterFeatures).toUpperCase()),
button({ type: "submit", name: "filter", value: "bugs", class: btnClass("bugs") }, String(i18n.reportsFilterBugs).toUpperCase()),
button({ type: "submit", name: "filter", value: "content", class: btnClass("content") }, String(i18n.reportsFilterContent).toUpperCase()),
button({ type: "submit", name: "filter", value: "confirmed", class: btnClass("confirmed") }, String(i18n.reportsFilterConfirmed).toUpperCase()),
button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.reportsCreateButton)
),
form(
{ method: "GET", action: "/reports", class: "ui-toolbar ui-toolbar--filters reports-subfilters" },
button({ type: "submit", name: "filter", value: "open", class: btnClass("open") }, String(i18n.reportsFilterOpen).toUpperCase()),
button({ type: "submit", name: "filter", value: "under_review", class: btnClass("under_review") }, String(i18n.reportsFilterUnderReview).toUpperCase()),
button({ type: "submit", name: "filter", value: "resolved", class: btnClass("resolved") }, String(i18n.reportsFilterResolved).toUpperCase()),
button({ type: "submit", name: "filter", value: "invalid", class: btnClass("invalid") }, String(i18n.reportsFilterInvalid).toUpperCase()),
button({ type: "submit", name: "filter", value: "sev_low", class: btnClass("sev_low") }, String(i18n.reportsSeverityLow).toUpperCase()),
button({ type: "submit", name: "filter", value: "sev_medium", class: btnClass("sev_medium") }, String(i18n.reportsSeverityMedium).toUpperCase()),
button({ type: "submit", name: "filter", value: "sev_high", class: btnClass("sev_high") }, String(i18n.reportsSeverityHigh).toUpperCase()),
button({ type: "submit", name: "filter", value: "sev_critical", class: btnClass("sev_critical") }, String(i18n.reportsSeverityCritical).toUpperCase())
)
),
div({ class: "tribe-details" }, reportSide, reportMain)

View file

@ -4,6 +4,7 @@ const moment = require("../server/node_modules/moment");
const { renderTextWithStyles } = require('../backend/renderTextWithStyles');
const { renderUrl } = require('../backend/renderUrl');
const { sanitizeHtml } = require('../backend/sanitizeHtml');
const { renderZoomableImage } = require('./gallery_view');
const { config } = require("../server/SSB_server.js");
const userId = config.keys.id;
@ -41,15 +42,14 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
const contentTypes = [
"post", "about", "curriculum", "tribe", "market", "transfer", "feed", "votes",
"report", "task", "event", "bookmark", "image", "audio", "video", "document", "torrent",
"bankWallet", "bankClaim", "project", "job", "industry", "industryBlueprint", "forum", "vote", "contact", "pub", "map", "shop", "shopProduct", "chat", "pad", "all"
"bankWallet", "bankClaim", "project", "job", "industry", "industryBlueprint", "forum", "vote", "contact", "map", "shop", "shopProduct", "chat", "pad", "all"
];
const filterSelect = select(
{
id: "search-type",
name: "type",
class: "input-select",
style: "position:relative; z-index:10;"
class: "input-select search-select"
},
contentTypes.map(type =>
option({
@ -63,8 +63,7 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
{
id: "results-per-page",
name: "resultsPerPage",
class: "input-select",
style: "position:relative; z-index:10;margin-left:10px;"
class: "input-select search-select"
},
option({ value: "100", selected: resultCount === "100" ? "selected" : undefined }, "100"),
option({ value: "50", selected: resultCount === "50" ? "selected" : undefined }, "50"),
@ -117,7 +116,7 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
const s = String(value).trim().replace(/&amp;/g, '&');
const m = s.match(/!\[[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/);
const src = m ? m[1] : s;
return src.startsWith('&') ? img({ src: `/blob/${encodeURIComponent(src)}`, class: 'search-result-image' }) : null;
return src.startsWith('&') ? renderZoomableImage(`/blob/${encodeURIComponent(src)}`, { imgClass: 'search-result-image' }) : null;
};
const renderContentHtml = (content) => {
@ -125,12 +124,12 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
case 'post':
return div({ class: 'search-post' },
content.contentWarning ? h2({ class: 'card-field' }, span({ class: 'card-value' }, content.contentWarning)) : null,
content.text ? div({ class: 'card-field' }, span({ class: 'card-value', innerHTML: sanitizeHtml(content.text) })) : null
content.text ? div({ class: 'card-field' }, span({ class: 'card-value', innerHTML: sanitizeHtml(rewriteHashtagLinks(renderTextWithStyles(content.text))) })) : null
);
case 'about':
return div({ class: 'search-about' },
content.name ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.name + ':'), span({ class: 'card-value' }, content.name)) : null,
content.description ? div({ class: 'card-field card-field-stacked' }, span({ class: 'card-label' }, i18n.description + ':'), span({ class: 'card-value' }, content.description)) : null,
content.description ? div({ class: 'card-field card-field-stacked' }, span({ class: 'card-value' }, content.description)) : null,
content.image ? img({ src: `/image/64/${encodeURIComponent(content.image)}` }) : null
);
case 'feed': {
@ -153,7 +152,7 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
const cleanEvDesc = rawEvDesc.replace(/!\[[^\]]*\]\(&[^)]+\.sha256\)/g, '').trim();
return div({ class: 'search-event' },
content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.eventTitleLabel + ':'), span({ class: 'card-value' }, content.title)) : null,
cleanEvDesc ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchDescription + ':'), span({ class: 'card-value' }, cleanEvDesc)) : null,
cleanEvDesc ? div({ class: 'card-field' }, span({ class: 'card-value' }, cleanEvDesc)) : null,
blobImg(content.image || blobInEvDesc),
content.date ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.eventDate + ':'), span({ class: 'card-value' }, new Date(content.date).toLocaleString())) : null,
content.location ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.eventLocation + ':'), span({ class: 'card-value' }, content.location)) : null,
@ -202,7 +201,7 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
return div({ class: 'search-tribe' },
content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.title + ':'), span({ class: 'card-value' }, content.title)) : null,
(() => { const s = String(content.image || '').trim().replace(/&amp;/g, '&'); const m = s.match(/!\[[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/); const src = m ? m[1] : s; return src.startsWith('&') ? img({ src: `/blob/${encodeURIComponent(src)}`, class: 'feed-image' }) : img({ src: '/assets/images/default-tribe.png', class: 'feed-image' }); })(),
content.description ? div({ class: 'card-field card-field-stacked' }, span({ class: 'card-label' }, i18n.searchDescription + ':'), span({ class: 'card-value' }, ...renderUrl(content.description))) : null,
content.description ? div({ class: 'card-field card-field-stacked' }, span({ class: 'card-value' }, ...renderUrl(content.description))) : null,
content.location ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.tribeLocationLabel + ':'), span({ class: 'card-value' }, ...renderUrl(content.location))) : null,
div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.tribeIsAnonymousLabel + ':'), span({ class: 'card-value' }, content.isAnonymous ? i18n.tribePrivate : i18n.tribePublic)),
content.inviteMode ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.tribeModeLabel + ':'), span({ class: 'card-value' }, String(content.inviteMode).toUpperCase())) : null,
@ -218,7 +217,7 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
case 'audio':
return content.url ? div({ class: 'search-audio' },
content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.audioTitleLabel + ':'), span({ class: 'card-value' }, content.title)) : null,
content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.audioDescriptionLabel + ':'), span({ class: 'card-value' }, content.description)) : null,
content.description ? div({ class: 'card-field' }, span({ class: 'card-value' }, content.description)) : null,
br(),
audioHyperaxe({ controls: true, src: `/blob/${encodeURIComponent(content.url)}`, type: content.mimeType, preload: 'metadata' }),
br(),
@ -231,10 +230,9 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
case 'image':
return content.url ? div({ class: 'search-image' },
content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.imageTitleLabel + ':'), span({ class: 'card-value' }, content.title)) : null,
content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.imageDescriptionLabel + ':'), span({ class: 'card-value' }, content.description)) : null,
content.meme ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.trendingCategory + ':'), span({ class: 'card-value' }, i18n.meme)) : null,
content.description ? div({ class: 'card-field' }, span({ class: 'card-value' }, content.description)) : null,
br(),
img({ src: `/blob/${encodeURIComponent(content.url)}` }),
renderZoomableImage(`/blob/${encodeURIComponent(content.url)}`, { imgClass: 'search-result-image' }),
br(),
content.tags && content.tags.length
? div({ class: 'card-tags' }, content.tags.map(tag =>
@ -245,7 +243,7 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
case 'video':
return content.url ? div({ class: 'search-video' },
content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.videoTitleLabel + ':'), span({ class: 'card-value' }, content.title)) : null,
content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.videoDescriptionLabel + ':'), span({ class: 'card-value' }, content.description)) : null,
content.description ? div({ class: 'card-field' }, span({ class: 'card-value' }, content.description)) : null,
br(),
videoHyperaxe({ controls: true, src: `/blob/${encodeURIComponent(content.url)}`, type: content.mimeType || 'video/mp4', width: '640', height: '360' }),
br(),
@ -276,7 +274,7 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
case 'torrent':
return div({ class: 'search-torrent' },
content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.torrentTitleLabel || i18n.title) + ':'), span({ class: 'card-value' }, content.title)) : null,
content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.torrentDescriptionLabel || i18n.searchDescription) + ':'), span({ class: 'card-value' }, content.description)) : null,
content.description ? div({ class: 'card-field' }, span({ class: 'card-value' }, content.description)) : null,
content.size ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.torrentSizeLabel || 'Size') + ':'), span({ class: 'card-value' }, String(content.size))) : null,
content.tags && content.tags.length
? div({ class: 'card-tags' }, content.tags.map(tag =>
@ -287,7 +285,7 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
case 'market':
return div({ class: 'search-market' },
content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.title + ':'), span({ class: 'card-value' }, content.title)) : null,
content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchDescription + ':'), span({ class: 'card-value' }, content.description)) : null,
content.description ? div({ class: 'card-field' }, span({ class: 'card-value' }, content.description)) : null,
content.item_type ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemType + ':'), span({ class: 'card-value' }, content.item_type.toUpperCase())) : null,
content.status ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemCondition + ':'), span({ class: 'card-value' }, content.status)) : null,
content.deadline ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemDeadline + ':'), span({ class: 'card-value' }, new Date(content.deadline).toLocaleString())) : null,
@ -297,7 +295,7 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
content.seller ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemSeller + ':'), span({ class: 'card-value' }, userLink(content.seller))) : null,
content.stock ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemStock + ':'), span({ class: 'card-value' }, content.stock || 'N/A')) : null,
content.price ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchPriceLabel + ':'), span({ class: 'card-value' }, `${content.price} ECO`)) : null,
content.condition ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchDescription + ':'), span({ class: 'card-value' }, content.condition)) : null,
content.condition ? div({ class: 'card-field' }, span({ class: 'card-value' }, content.condition)) : null,
content.includesShipping ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemIncludesShipping + ':'), span({ class: 'card-value' }, `${content.includesShipping ? i18n.YESLabel : i18n.NOLabel}`)) : null,
content.auctions_poll && content.auctions_poll.length > 0
? div({ class: 'auction-info' },
@ -328,8 +326,7 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
case 'bookmark':
return div({ class: 'search-bookmark' },
content.url ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.bookmarkUrlLabel + ':'), span({ class: 'card-value' }, a({ href: content.url, target: '_blank' }, content.url))) : null,
content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchDescription + ':'), span({ class: 'card-value' }, content.description)) : null,
content.category ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.bookmarkCategory + ':'), span({ class: 'card-value' }, content.category)) : null,
content.description ? div({ class: 'card-field' }, span({ class: 'card-value' }, content.description)) : null,
content.lastVisit ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.bookmarkLastVisit + ':'), span({ class: 'card-value' }, new Date(content.lastVisit).toLocaleString())) : null,
content.tags && content.tags.length
? div({ class: 'card-tags' }, content.tags.map(tag =>
@ -340,7 +337,7 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
case 'task':
return div({ class: 'search-task' },
content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.taskTitleLabel + ':'), span({ class: 'card-value' }, content.title)) : null,
content.description ? div({ class: 'card-field', style: 'display:flex;flex-direction:column;' }, span({ class: 'card-label' }, i18n.searchDescription + ':'), p({ class: 'card-value', style: 'white-space:pre-wrap;margin-top:4px;' }, content.description)) : null,
content.description ? div({ class: 'card-field card-field-stacked' }, p({ class: 'card-value card-value-pre' }, content.description)) : null,
content.location ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchLocationLabel + ':'), span({ class: 'card-value' }, content.location)) : null,
content.status ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchStatusLabel + ':'), span({ class: 'card-value' }, content.status)) : null,
content.priority ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchPriorityLabel + ':'), span({ class: 'card-value' }, content.priority)) : null,
@ -360,7 +357,7 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
content.status ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchStatusLabel + ':'), span({ class: 'card-value' }, content.status)) : null,
content.severity ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.reportsSeverity + ':'), span({ class: 'card-value' }, content.severity)) : null,
content.category ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchCategoryLabel + ':'), span({ class: 'card-value' }, content.category)) : null,
content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchDescription + ':'), span({ class: 'card-value' }, content.description)) : null,
content.description ? div({ class: 'card-field' }, span({ class: 'card-value' }, content.description)) : null,
br(),
blobImg(content.image),
br(),
@ -393,8 +390,8 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
case 'curriculum':
return div({ class: 'search-curriculum' },
content.name ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.cvNameLabel + ':'), span({ class: 'card-value' }, content.name)) : null,
content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.cvDescriptionLabel + ':'), span({ class: 'card-value' }, content.description)) : null,
content.photo ? img({ src: `/blob/${encodeURIComponent(content.photo)}`, class: 'curriculum-photo' }) : null,
content.description ? div({ class: 'card-field' }, span({ class: 'card-value' }, content.description)) : null,
content.photo ? renderZoomableImage(`/blob/${encodeURIComponent(content.photo)}`, { imgClass: 'curriculum-photo' }) : null,
content.location ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.cvLocationLabel + ':'), span({ class: 'card-value' }, content.location)) : null,
content.status ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.cvStatusLabel + ':'), span({ class: 'card-value' }, content.status)) : null,
content.preferences ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.cvPreferencesLabel + ':'), span({ class: 'card-value' }, content.preferences)) : null,
@ -474,7 +471,7 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
return div({ class: 'search-forum' },
content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.title + ':'), span({ class: 'card-value' }, content.title)) : null,
content.category ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchCategoryLabel + ':'), span({ class: 'card-value' }, content.category)) : null,
content.text ? div({ class: 'card-field card-field-stacked' }, span({ class: 'card-label' }, i18n.searchDescription + ':'), span({ class: 'card-value' }, content.text)) : null
content.text ? div({ class: 'card-field card-field-stacked' }, span({ class: 'card-value' }, content.text)) : null
);
case 'vote':
return div({ class: 'search-vote-link' },
@ -492,7 +489,7 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
return div({ class: 'search-shop' },
content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.title + ':'), span({ class: 'card-value' }, content.title)) : null,
blobImg(content.image),
content.shortDescription ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchDescription + ':'), span({ class: 'card-value' }, content.shortDescription)) : null,
content.shortDescription ? div({ class: 'card-field' }, span({ class: 'card-value' }, content.shortDescription)) : null,
content.visibility ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.shopStatus || 'STATUS') + ':'), span({ class: 'card-value' }, content.visibility)) : null,
content.location ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.searchLocationLabel || 'LOCATION') + ':'), span({ class: 'card-value' }, content.location)) : null,
content.tags && content.tags.length
@ -503,7 +500,7 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
return div({ class: 'search-shop-product' },
content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.title + ':'), span({ class: 'card-value' }, content.title)) : null,
blobImg(content.image),
content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchDescription + ':'), span({ class: 'card-value' }, content.description)) : null,
content.description ? div({ class: 'card-field' }, span({ class: 'card-value' }, content.description)) : null,
content.price ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.searchPriceLabel || 'PRICE') + ':'), span({ class: 'card-value' }, `${content.price} ECO`)) : null,
content.stock !== undefined ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemStock + ':'), span({ class: 'card-value' }, content.stock)) : null
);
@ -515,7 +512,7 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
);
return div({ class: 'search-chat' },
content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.chatsTitle || 'Chat') + ':'), span({ class: 'card-value' }, content.title)) : null,
content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.chatDescription || 'Description') + ':'), span({ class: 'card-value' }, content.description)) : null,
content.description ? div({ class: 'card-field' }, span({ class: 'card-value' }, content.description)) : null,
content.category ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.chatCategoryLabel || 'Category') + ':'), span({ class: 'card-value' }, content.category)) : null,
content.status ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.chatStatus || 'STATUS') + ':'), span({ class: 'card-value' }, content.status)) : null
);
@ -546,7 +543,7 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
case 'map':
return div({ class: 'search-map' },
content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.title + ':'), span({ class: 'card-value' }, content.title)) : null,
content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.mapDescriptionLabel + ':'), span({ class: 'card-value' }, content.description)) : null,
content.description ? div({ class: 'card-field' }, span({ class: 'card-value' }, content.description)) : null,
content.lat && content.lng ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.mapLocationTitle + ':'), span({ class: 'card-value' }, `${content.lat}, ${content.lng}`)) : null,
content.mapType ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.mapTypeLabel + ':'), span({ class: 'card-value' }, content.mapType)) : null,
content.tags && content.tags.length

View file

@ -1,9 +1,10 @@
const { form, button, div, h2, h3, p, section, select, option, input, br, a, label, span } = require("../server/node_modules/hyperaxe");
const { form, button, div, h2, h3, p, section, select, option, input, br, a, label, span, img } = require("../server/node_modules/hyperaxe");
const fs = require('fs');
const path = require('path');
const { getConfig } = require('../configs/config-manager.js');
const { template, selectedLanguage, i18n, setLanguage } = require('./main_views');
const i18nBase = require("../client/assets/translations/i18n");
const { WORKFLOWS, currentWorkflow } = require('../models/workflows_model');
const snhUrl = "https://wiki.solarnethub.com/socialnet/overview";
@ -28,18 +29,37 @@ const settingsView = ({ version, aiPrompt, fediverseAccount, fediverseError }) =
const walletUrl = currentConfig.wallet.url;
const walletUser = currentConfig.wallet.user;
const walletFee = currentConfig.wallet.fee;
const pubId = currentConfig.walletPub?.pubId || '';
const currentWish = currentConfig.wish === 'mutuals' ? 'mutuals' : 'whole';
const currentPmVisibility = currentConfig.pmVisibility === 'mutuals' ? 'mutuals' : 'whole';
const themeElements = [
option({ value: "Dark-SNH", selected: theme === "Dark-SNH" ? true : undefined }, "Dark-SNH"),
option({ value: "Clear-SNH", selected: theme === "Clear-SNH" ? true : undefined }, "Clear-SNH"),
option({ value: "Purple-SNH", selected: theme === "Purple-SNH" ? true : undefined }, "Purple-SNH"),
option({ value: "Matrix-SNH", selected: theme === "Matrix-SNH" ? true : undefined }, "Matrix-SNH"),
option({ value: "OasisMobile", selected: theme === "OasisMobile" ? true : undefined }, "Oasis-Mobile")
option({ value: "Dark-SNH", ...(theme === "Dark-SNH" ? true : undefined ? { selected: true } : {})}, "Dark-SNH"),
option({ value: "Clear-SNH", ...(theme === "Clear-SNH" ? true : undefined ? { selected: true } : {})}, "Clear-SNH"),
option({ value: "Purple-SNH", ...(theme === "Purple-SNH" ? true : undefined ? { selected: true } : {})}, "Purple-SNH"),
option({ value: "Matrix-SNH", ...(theme === "Matrix-SNH" ? true : undefined ? { selected: true } : {})}, "Matrix-SNH"),
option({ value: "OasisMobile", ...(theme === "OasisMobile" ? true : undefined ? { selected: true } : {})}, "Oasis-Mobile")
];
const activeWorkflow = currentWorkflow(currentConfig) || '';
const modOn = (name) => (currentConfig.modules || {})[`${name}Mod`] === 'on';
const workflowSection = section(
div({ class: "tags-header" },
h2(i18n.workflowsTitle),
p(i18n.workflowsDescription),
form(
{ action: "/settings/workflow", method: "POST" },
select({ name: "workflow" },
...WORKFLOWS.map(w => activeWorkflow === w.key
? option({ value: w.key, selected: true }, i18n[`workflow_${w.key}`])
: option({ value: w.key }, i18n[`workflow_${w.key}`]))
),
br(), br(),
button({ type: "submit" }, i18n.workflowsSet)
)
)
);
const languageOption = (longName, shortName) => {
return shortName === selectedLanguage
? option({ value: shortName, selected: true }, longName)
@ -69,19 +89,6 @@ const settingsView = ({ version, aiPrompt, fediverseAccount, fediverseError }) =
updateButton
)
),
section(
div({ class: "tags-header" },
h2(i18n.theme),
p(i18n.themeIntro),
form(
{ action: "/settings/theme", method: "post" },
select({ name: "theme" }, ...themeElements),
br(),
br(),
button({ type: "submit" }, i18n.setTheme)
)
)
),
section(
div({ class: "tags-header" },
h2(i18n.language),
@ -115,19 +122,37 @@ const settingsView = ({ version, aiPrompt, fediverseAccount, fediverseError }) =
{ action: "/settings/ux", method: "POST" },
(() => {
const aiNavEnabled = currentConfig.modules && currentConfig.modules.aiNavMod === 'on';
const opts = [
option({ value: "blocks", selected: (currentConfig.ux?.current !== "ainav") ? true : undefined }, i18n.uxModeMenus || "Blocks")
];
if (aiNavEnabled) {
opts.push(option({ value: "ainav", selected: currentConfig.ux?.current === "ainav" ? true : undefined }, i18n.uxModeAINav || "AI"));
}
return select({ name: "ux" }, ...opts);
const chatsEnabled = currentConfig.modules && currentConfig.modules.chatsMod === 'on';
const cur = currentConfig.ux?.current === "ainav" ? "ainav" : currentConfig.ux?.current === "chats" ? "chats" : "blocks";
const uxCard = (value, title, image) => label({ class: "welcome-ux-option" },
input({ type: "radio", name: "ux", value, ...(cur === value ? { checked: true } : {}) }),
img({ src: image, class: "welcome-ux-shot", alt: title }),
span({ class: "welcome-ux-label" }, title)
);
return div({ class: "welcome-ux-grid" },
uxCard("blocks", i18n.uxModeMenus || "Blocks", "/assets/images/ux-blocks.png"),
chatsEnabled ? uxCard("chats", i18n.chatsTitle || "Chats", "/assets/images/ux-chats.png") : null,
aiNavEnabled ? uxCard("ainav", i18n.uxModeAINav || "AI", "/assets/images/ux-ainav.png") : null
);
})(),
br(), br(),
button({ type: "submit" }, i18n.saveSettings)
)
)
),
workflowSection,
section(
div({ class: "tags-header" },
h2(i18n.theme),
p(i18n.themeIntro),
form(
{ action: "/settings/theme", method: "post" },
select({ name: "theme" }, ...themeElements),
br(),
br(),
button({ type: "submit" }, i18n.setTheme)
)
)
),
section(
div({ class: "tags-header" },
h2(i18n.homePageTitle),
@ -135,24 +160,58 @@ const settingsView = ({ version, aiPrompt, fediverseAccount, fediverseError }) =
form(
{ action: "/settings/home-page", method: "POST" },
select({ name: "homePage" },
option({ value: "activity", selected: currentConfig.homePage === "activity" ? true : undefined }, i18n.activityTitle),
option({ value: "ai", selected: currentConfig.homePage === "ai" ? true : undefined }, i18n.aiTitle),
option({ value: "trending", selected: currentConfig.homePage === "trending" ? true : undefined }, i18n.trendingTitle),
option({ value: "opinions", selected: currentConfig.homePage === "opinions" ? true : undefined }, i18n.opinionsTitle),
option({ value: "forum", selected: currentConfig.homePage === "forum" ? true : undefined }, i18n.forumTitle),
option({ value: "feed", selected: currentConfig.homePage === "feed" ? true : undefined }, i18n.feedTitle),
option({ value: "mentions", selected: currentConfig.homePage === "mentions" ? true : undefined }, i18n.mentions),
option({ value: "inbox", selected: currentConfig.homePage === "inbox" ? true : undefined }, i18n.inbox),
option({ value: "agenda", selected: currentConfig.homePage === "agenda" ? true : undefined }, i18n.agendaTitle),
option({ value: "favorites", selected: currentConfig.homePage === "favorites" ? true : undefined }, i18n.favoritesTitle),
option({ value: "stats", selected: currentConfig.homePage === "stats" ? true : undefined }, i18n.statsTitle),
option({ value: "blockexplorer", selected: currentConfig.homePage === "blockexplorer" ? true : undefined }, i18n.blockchain)
...[
{ value: "activity", label: i18n.activityTitle },
{ value: "ai", label: i18n.aiTitle, mod: "ai" },
{ value: "trending", label: i18n.trendingTitle, mod: "trending" },
{ value: "forum", label: i18n.forumTitle, mod: "forum" },
{ value: "feed", label: i18n.feedTitle, mod: "feed" },
{ value: "chats", label: i18n.chatsTitle, mod: "chats" },
{ value: "inbox", label: i18n.inbox },
{ value: "mentions", label: i18n.mentions },
{ value: "agenda", label: i18n.agendaTitle, mod: "agenda" },
{ value: "market", label: i18n.marketTitle, mod: "market" },
{ value: "favorites", label: i18n.favoritesTitle, mod: "favorites" }
].filter(o => !o.mod || modOn(o.mod)).map(o => currentConfig.homePage === o.value
? option({ value: o.value, selected: true }, o.label)
: option({ value: o.value }, o.label))
),
br(), br(),
button({ type: "submit" }, i18n.saveHomePage)
)
)
),
modOn('ai') ? section(
div({ class: "tags-header" },
h2(i18n.aiTitle),
p(i18n.aiSettingsDescription),
form(
{ action: "/settings/ai", method: "POST" },
input({
type: "text",
id: "ai_prompt",
name: "ai_prompt",
placeholder: aiPrompt,
value: aiPrompt,
maxlength: "128",
required: true
}), br(),
label({ for: "aiSuggestions", class: "lan-checkbox-label" },
input({
type: "checkbox",
id: "aiSuggestions",
name: "ai_suggestions",
value: "on",
class: "lan-checkbox-input",
checked: currentConfig.ai?.suggestions !== false ? true : undefined
}),
span({ class: "lan-checkbox-text" }, i18n.aiSuggestionsEnable)
),
br(),
button({ type: "submit" }, i18n.saveSettings)
)
)
) : null,
section(
div({ class: "tags-header" },
h2(i18n.ssbLogStream),
@ -221,9 +280,9 @@ const settingsView = ({ version, aiPrompt, fediverseAccount, fediverseError }) =
form(
{ action: "/settings/wish", method: "POST" },
select({ name: "wish" },
option({ value: "whole", selected: currentWish === "whole" ? true : undefined }, i18n.settingsWishWhole),
option({ value: "mutuals", selected: currentWish === "mutuals" ? true : undefined }, i18n.settingsWishMutuals),
option({ value: "only-lan", selected: currentWish === "only-lan" ? true : undefined }, i18n.settingsWishOnlyLan || "Only LAN")
option({ value: "whole", ...(currentWish === "whole" ? true : undefined ? { selected: true } : {})}, i18n.settingsWishWhole),
option({ value: "mutuals", ...(currentWish === "mutuals" ? true : undefined ? { selected: true } : {})}, i18n.settingsWishMutuals),
option({ value: "only-lan", ...(currentWish === "only-lan" ? true : undefined ? { selected: true } : {})}, i18n.settingsWishOnlyLan || "Only LAN")
), br(), br(),
button({ type: "submit" }, i18n.saveSettings)
)
@ -236,14 +295,14 @@ const settingsView = ({ version, aiPrompt, fediverseAccount, fediverseError }) =
form(
{ action: "/settings/pm-visibility", method: "POST" },
select({ name: "pmVisibility" },
option({ value: "whole", selected: currentPmVisibility === "whole" ? true : undefined }, i18n.settingsPmVisibilityWhole),
option({ value: "mutuals", selected: currentPmVisibility === "mutuals" ? true : undefined }, i18n.settingsPmVisibilityMutuals)
option({ value: "whole", ...(currentPmVisibility === "whole" ? true : undefined ? { selected: true } : {})}, i18n.settingsPmVisibilityWhole),
option({ value: "mutuals", ...(currentPmVisibility === "mutuals" ? true : undefined ? { selected: true } : {})}, i18n.settingsPmVisibilityMutuals)
), br(), br(),
button({ type: "submit" }, i18n.saveSettings)
)
)
),
section(
modOn('wallet') ? section(
{ id: "wallet" },
div({ class: "tags-header" },
h2(i18n.wallet),
@ -264,45 +323,9 @@ const settingsView = ({ version, aiPrompt, fediverseAccount, fediverseError }) =
button({ type: "submit" }, i18n.walletConfiguration)
)
)
),
section(
div({ class: "tags-header" },
h2(i18n.pubIdTitle || "PUB Wallet"),
p(i18n.pubIdDescription || "Set the PUB OASIS ID. This will be used for PUB transactions (including the UBI)."),
form(
{ action: "/settings/pub-id", method: "POST" },
input({
type: "text",
id: "pub_id",
name: "pub_id",
value: pubId,
placeholder: i18n.pubIdPlaceholder || "@example.ed25519"
}), br(),
button({ type: "submit" }, i18n.pubIdSave || "Save configuration")
)
)
),
section(
div({ class: "tags-header" },
h2(i18n.aiTitle),
p(i18n.aiSettingsDescription),
form(
{ action: "/settings/ai", method: "POST" },
input({
type: "text",
id: "ai_prompt",
name: "ai_prompt",
placeholder: aiPrompt,
value: aiPrompt,
maxlength: "128",
required: true
}), br(),
button({ type: "submit" }, i18n.aiConfiguration)
)
)
),
section(
{ id: "fediverse" },
) : null,
modOn('fediverse') ? section(
{ id: "multiverse" },
div({ class: "tags-header" },
h2(i18n.fediverseSettingsTitle),
div({ class: "fediverse-network" },
@ -333,7 +356,7 @@ const settingsView = ({ version, aiPrompt, fediverseAccount, fediverseError }) =
)
)
)
),
) : null,
section(
div({ class: "tags-header" },
h2(i18n.indexes),

View file

@ -1,5 +1,6 @@
const { div, h2, p, section, button, form, a, span, textarea, br, input, label, select, option, img, progress, video, table, tr, td } = require("../server/node_modules/hyperaxe")
const { template, i18n, userLink, renderStateChip, renderLifespanChip, renderSpreadButton, renderOpinionsVoting, renderInviteQrCard } = require("./main_views")
const { renderCommentsSection: renderSharedCommentsSection } = require("./comments_view");
const { template, i18n, userLink, renderStateChip, renderLifespanChip, renderSpreadButton, renderOpinionsVoting, renderEngagement, renderInviteQrCard , renderSpreadEditWarning, renderContentActions } = require("./main_views")
const moment = require("../server/node_modules/moment")
const { config } = require("../server/SSB_server.js")
const { renderUrl } = require("../backend/renderUrl")
@ -60,18 +61,14 @@ const renderModeButtons = (currentFilter) =>
)
)
const renderFavoriteToggle = (shop, returnTo) =>
form(
{ method: "POST", action: shop.isFavorite ? `/shops/favorites/remove/${encodeURIComponent(shop.key)}` : `/shops/favorites/add/${encodeURIComponent(shop.key)}` },
returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
button({ type: "submit", class: "filter-btn" }, shop.isFavorite ? i18n.shopRemoveFavorite : i18n.shopAddFavorite)
)
const renderShopCard = exports.renderShopCard = (shop, filter, params = {}) => {
const returnTo = buildReturnTo(filter, params)
const isAuthor = String(shop.author) === String(userId)
return div({ class: "tribe-card" },
div({ class: "card-header activity-card-header" },
renderContentActions(shop.key, `/shops/${encodeURIComponent(shop.key)}`, { spread: params.spreadMap && params.spreadMap.get(shop.key) || null, author: shop.author, favKind: 'shops', isFavorite: shop.isFavorite, reportTitle: shop.title })
),
div({ class: "tribe-card-image-wrapper" },
a({ href: `/shops/${encodeURIComponent(shop.key)}` },
renderMediaBlob(shop.image, '/assets/images/default-avatar.png', { class: 'tribe-card-hero-image' })
@ -89,12 +86,6 @@ const renderShopCard = exports.renderShopCard = (shop, filter, params = {}) => {
),
div({ class: "tribe-card-members" },
span({ class: "tribe-members-count" }, `${i18n.shopProducts}: ${shop.productCount || 0}`)
),
div({ class: "card-spread-centered" }, renderSpreadButton(shop.key, params.spreadMap && params.spreadMap.get(shop.key))),
div({ class: "card-visit-btn-centered" },
form({ method: 'GET', action: `/shops/${encodeURIComponent(shop.key)}` },
button({ type: 'submit', class: 'filter-btn' }, i18n.viewShop || i18n.shopVisitShop || 'View Shop')
)
)
)
)
@ -126,9 +117,6 @@ const renderProductCard = (product, shopId, returnTo) => {
if (!isAuthor && stock > 0) {
actions.push(a({ href: productUrl, class: "buy-btn" }, i18n.marketActionsBuy || i18n.shopBuy));
}
actions.push(form({ method: "POST", action: product.isFavorite ? `/shops/favorites/remove/${encodeURIComponent(product.key)}` : `/shops/favorites/add/${encodeURIComponent(product.key)}` },
returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
button({ type: "submit", class: "filter-btn" }, product.isFavorite ? i18n.shopRemoveFavorite : i18n.shopAddFavorite)));
return actions.length ? div({ class: "shop-product-actions" }, ...actions) : null;
})()
)
@ -136,50 +124,23 @@ const renderProductCard = (product, shopId, returnTo) => {
}
const renderCommentsSection = (parentId, returnTo, comments = []) => {
const count = Array.isArray(comments) ? comments.length : 0
return div({ class: "vote-comments-section market-comments" },
div({ class: "comments-count" }, span({ class: "card-label" }, i18n.voteCommentsLabel + ": "), span({ class: "card-value" }, String(count))),
div({ class: "comment-form-wrapper" },
h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
form({ method: "POST", action: `/shops/${encodeURIComponent(parentId)}/comments`, class: "comment-form", enctype: "multipart/form-data" },
input({ type: "hidden", name: "returnTo", value: returnTo }),
textarea({ name: "text", rows: 4, class: "comment-textarea", placeholder: i18n.voteNewCommentPlaceholder }),
div({ class: "comment-file-upload" }, label(i18n.uploadMedia), input({ type: "file", name: "blob" })),
br(),
button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
)
),
count
? div({ class: "comments-list" },
comments.map(c => {
const author = c.value?.author || ""
const ts = c.value?.timestamp || c.timestamp
const text = c.value?.content?.text || ""
const rootId = c.value?.content?.fork || c.value?.content?.root || null
return div({ class: "votations-comment-card" },
span({ class: "created-at" },
span(i18n.createdBy),
author ? userLink(author) : span("(unknown)"),
ts ? span(" | ", span({ class: "votations-comment-date" }, moment(ts).format("YYYY/MM/DD HH:mm:ss"))) : "",
ts && rootId ? span(" | ", a({ href: `/thread/${encodeURIComponent(rootId)}#${encodeURIComponent(c.key)}` }, moment(ts).fromNow())) : ""
),
p({ class: "votations-comment-text" }, ...renderUrl(text))
)
})
)
: p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet)
)
}
return renderSharedCommentsSection({
action: `/shops/${encodeURIComponent(parentId)}/comments`,
comments: comments,
returnTo: returnTo
});
};
const renderShopForm = (filter, shop = {}, params = {}) => {
const isEdit = filter === "edit"
const returnTo = safeText(params.returnTo) || buildReturnTo("all")
return div({ class: "create-tribe-form" },
h2(isEdit ? i18n.shopUpdateSectionTitle : i18n.shopCreateSectionTitle),
isEdit ? (params.spreadWarning || null) : null,
form({ action: isEdit ? `/shops/update/${encodeURIComponent(shop.key || "")}` : "/shops/create", method: "POST", enctype: "multipart/form-data" },
input({ type: "hidden", name: "returnTo", value: returnTo }),
label(i18n.title || "Title"), br,
input({ type: "text", name: "title", required: true, placeholder: i18n.shopTitlePlaceholder || "Name of your shop", value: shop.title || "" }), br(),
input({ type: "text", name: "title", maxlength: "100", required: true, placeholder: i18n.shopTitlePlaceholder || "Name of your shop", value: shop.title || "" }), br(),
label(i18n.shopShortDescription), br,
input({ type: "text", name: "shortDescription", required: true, maxlength: 160, placeholder: i18n.shopShortDescriptionPlaceholder || "Brief description of your shop", value: shop.shortDescription || "" }), br(),
label(i18n.description || "Description"), br,
@ -197,8 +158,8 @@ const renderShopForm = (filter, shop = {}, params = {}) => {
isEdit ? null : label(i18n.shopVisibility),
isEdit ? null : br,
isEdit ? null : select({ name: "visibility" },
option({ value: "OPEN", selected: (shop.visibility || "OPEN") === "OPEN" }, i18n.shopOpen),
option({ value: "CLOSED", selected: shop.visibility === "CLOSED" }, i18n.shopClosed)
option({ value: "OPEN", ...((shop.visibility || "OPEN") === "OPEN" ? { selected: true } : {})}, i18n.shopOpen),
option({ value: "CLOSED", ...(shop.visibility === "CLOSED" ? { selected: true } : {})}, i18n.shopClosed)
),
isEdit ? null : br(),
br(),
@ -207,14 +168,15 @@ const renderShopForm = (filter, shop = {}, params = {}) => {
)
}
const renderProductForm = (shopId, product = {}, isEdit = false, returnTo = "") => {
const renderProductForm = (shopId, product = {}, isEdit = false, returnTo = "", spreadWarning = null) => {
return div({ class: "create-tribe-form" },
h2(isEdit ? i18n.shopProductUpdate : i18n.shopProductAdd),
isEdit ? spreadWarning : null,
form({ action: isEdit ? `/shops/product/update/${encodeURIComponent(product.key || "")}` : "/shops/product/create", method: "POST", enctype: "multipart/form-data" },
input({ type: "hidden", name: "shopId", value: shopId }),
input({ type: "hidden", name: "returnTo", value: returnTo }),
label(i18n.title || "Title"), br,
input({ type: "text", name: "title", required: true, value: product.title || "" }), br(),
input({ type: "text", name: "title", maxlength: "100", required: true, value: product.title || "" }), br(),
label(i18n.description || "Description"), br,
textarea({ name: "description", rows: 4 }, product.description || ""), br,
label(i18n.shopProductPrice), br,
@ -241,66 +203,47 @@ const renderProductForm = (shopId, product = {}, isEdit = false, returnTo = "")
}
exports.shopsView = async (shops, filter, shopToEdit = null, params = {}) => {
if (filter === "edit") params = { ...params, spreadWarning: await renderSpreadEditWarning(shopToEdit && (shopToEdit.key || shopToEdit.id)) };
const q = safeText(params.q || "")
const sort = safeText(params.sort || "recent")
const list = safeArr(shops)
const title =
filter === "mine" ? i18n.shopMineSectionTitle :
filter === "recent" ? i18n.shopRecentSectionTitle :
filter === "top" ? i18n.shopTopSectionTitle :
filter === "products" ? i18n.shopProductsSectionTitle :
filter === "prices" ? (i18n.shopPricesSectionTitle || "Products by Price") :
filter === "favorites" ? i18n.shopFavoritesSectionTitle :
filter === "create" ? i18n.shopCreateSectionTitle :
filter === "edit" ? i18n.shopUpdateSectionTitle :
i18n.shopAllSectionTitle
const title = i18n.shopsTitle
const isForm = filter === "create" || filter === "edit"
const viewerClearnet = !!(params.viewerPrefs && params.viewerPrefs.clearnetShops)
const header = [
div({ class: "tags-header" },
h2(title),
p(i18n.shopDescription)
),
div({ class: "shop-title-row" }, renderReachChip(viewerClearnet, i18n)),
br()
]
const searchBar = div({ class: "filters" },
form({ method: "GET", action: "/shops" },
input({ type: "hidden", name: "filter", value: filter }),
input({ type: "text", name: "q", placeholder: i18n.shopSearchPlaceholder, value: q }),
br(),
button({ type: "submit" }, i18n.search),
br()
)
)
const sortedProducts = filter === "products"
? [...list].sort((a, b) => safeArr(b.opinions_inhabitants).length - safeArr(a.opinions_inhabitants).length)
: filter === "prices"
? [...list].sort((a, b) => Number(b.price || 0) - Number(a.price || 0))
: list
const isProducts = filter === "products" || filter === "prices"
return template(
title,
section(...header),
section(div({ class: "tags-header" }, h2(title), p(i18n.shopDescription))),
section(renderModeButtons(filter)),
!isForm ? section(searchBar) : null,
!isForm
? section(
div({ class: "filters" },
form({ method: "GET", action: "/shops", class: "filter-box" },
input({ type: "hidden", name: "filter", value: filter }),
input({ type: "text", name: "q", value: q, placeholder: i18n.shopSearchPlaceholder, class: "filter-box__input" }),
div({ class: "filter-box__controls" },
select({ name: "sort", class: "filter-box__select" },
option({ value: "recent", ...(sort === "recent" ? { selected: true } : {}) }, i18n.documentSortRecent),
option({ value: "top", ...(sort === "top" ? { selected: true } : {}) }, i18n.documentSortTop)
),
button({ type: "submit", class: "filter-box__button" }, i18n.searchButton)
)
)
)
)
: null,
section(
isForm
? renderShopForm(filter, filter === "edit" ? (shopToEdit || {}) : {}, params)
: filter === "products" || filter === "prices"
: isProducts
? div({ class: "shop-products-grid" },
sortedProducts.length
? sortedProducts.map(prod => renderProductCard(prod, prod.shopId, buildReturnTo(filter, params)))
: p(i18n.shopNoItems)
list.length
? list.map(prod => renderProductCard(prod, prod.shopId, buildReturnTo(filter, { q })))
: p(i18n.shopNoProducts)
)
: div({ class: "tribe-grid" },
list.length
? list.map(shop => renderShopCard(shop, filter, { q, sort, spreadMap: params.spreadMap }))
? list.map(shopItem => renderShopCard(shopItem, filter, params))
: p(i18n.shopNoItems)
)
)
@ -309,13 +252,15 @@ exports.shopsView = async (shops, filter, shopToEdit = null, params = {}) => {
exports.singleShopView = async (shop, filter, products = [], comments = [], params = {}) => {
const q = safeText(params.q || "")
const sort = safeText(params.sort || "recent")
const returnTo = safeText(params.returnTo) || buildReturnTo(filter, { q, sort })
const returnTo = safeText(params.returnTo) || buildReturnTo(filter, { q })
const isAuthor = String(shop.author) === String(userId)
const fullShareUrl = `/shops/${encodeURIComponent(shop.key)}`
const isClearnet = !!(params.authorPrefs && params.authorPrefs.clearnetShops === true)
products = safeArr(products)
const isClearnet = !!(params.authorPrefs && params.authorPrefs.clearnetShops && shop.visibility !== 'CLOSED');
const shopSide = div({ class: "tribe-side" },
div({ class: "card-header activity-card-header" },
renderContentActions(shop.key, null, { spread: params.spreads || null, author: shop.author, favKind: 'shops', isFavorite: shop.isFavorite, reportTitle: shop.title })
),
div({ class: "shop-title-row" },
h2({ class: "tribe-card-title" }, shop.title || i18n.shopUntitled)
),
@ -327,21 +272,13 @@ exports.singleShopView = async (shop, filter, products = [], comments = [], para
renderLifespanChip(shop.lifetime, i18n),
renderReachChip(isClearnet, i18n)
),
div({ class: "card-spread-centered" }, renderSpreadButton(shop.key, params.spreads)),
renderMediaBlob(shop.image, '/assets/images/default-avatar.png', { class: 'tribe-detail-image' }),
shop.description ? p({ class: "tribe-side-description" }, ...renderUrl(shop.description)) : null,
div({ class: "shop-share" },
span({ class: "tribe-info-label" }, `${i18n.shopShareUrl}: `),
input({ type: "text", value: fullShareUrl, readonly: true, class: "shop-share-input" })
),
table({ class: "tribe-info-table" },
tr(
td({ class: "tribe-info-label" }, i18n.shopCreatedAt || "CREATED"),
td({ class: "tribe-info-value", colspan: "3" }, new Date(shop.createdAt).toLocaleString())
),
tr(
td({ class: "tribe-info-value", colspan: "4" }, userLink(shop.author))
),
shop.location ? tr(
td({ class: "tribe-info-label" }, i18n.shopLocation),
td({ class: "tribe-info-value", colspan: "3" }, ...renderUrl(shop.location))
@ -353,36 +290,15 @@ exports.singleShopView = async (shop, filter, products = [], comments = [], para
shop.url ? tr(
td({ class: "tribe-info-label" }, i18n.shopUrl),
td({ class: "tribe-info-value", colspan: "3" }, ...renderUrl(shop.url))
) : null
) : null,
tr(
td({ class: "tribe-info-value", colspan: "4" }, userLink(shop.author))
)
),
renderMapEmbed(params.mapData, shop.mapUrl),
div({ class: "tribe-card-members" },
span({ class: "tribe-members-count" }, `${i18n.shopProducts}: ${shop.productCount || 0}`)
),
div({ class: "tribe-side-actions" },
renderFavoriteToggle(shop, returnTo),
shop.author && String(shop.author) !== String(userId)
? form({ method: "GET", action: "/pm" }, input({ type: "hidden", name: "recipients", value: shop.author }), button({ type: "submit", class: "tribe-action-btn" }, i18n.privateMessage))
: null,
isAuthor
? form({ method: "GET", action: `/shops/edit/${encodeURIComponent(shop.key)}` },
button({ type: "submit", class: "tribe-action-btn" }, i18n.shopUpdate)
)
: null,
isAuthor
? form({ method: "GET", action: `/shops/${encodeURIComponent(shop.key)}/orders` },
button({ type: "submit", class: "tribe-action-btn" },
i18n.shopOrdersTitle || "Orders",
Number(shop.pendingOrders || 0) > 0 ? span({ class: "shop-orders-badge", title: i18n.shopOrdersPending || "Pending orders" }, `${shop.pendingOrders}`) : null
)
)
: null,
isAuthor
? form({ method: "POST", action: `/shops/delete/${encodeURIComponent(shop.key)}` },
button({ type: "submit", class: "tribe-action-btn danger-btn" }, i18n.shopDelete)
)
: null
),
isAuthor
? div({ class: "tribe-side-actions shop-visibility-row" },
span({ class: "card-label" }, `${i18n.visibilityLabel || "Visibility"}: `),
@ -416,6 +332,26 @@ exports.singleShopView = async (shop, filter, products = [], comments = [], para
shop.encrypted ? i18n.shopMakePublic : i18n.shopMakePrivate))
)
: null,
isAuthor
? div({ class: "tribe-side-actions" },
form({ method: "GET", action: `/shops/${encodeURIComponent(shop.key)}/orders` },
button({ type: "submit", class: "tribe-action-btn" },
i18n.shopOrdersTitle || "Orders",
Number(shop.pendingOrders || 0) > 0 ? span({ class: "shop-orders-badge", title: i18n.shopOrdersPending || "Pending orders" }, `${shop.pendingOrders}`) : null
)
)
)
: null,
isAuthor
? div({ class: "tribe-side-actions owner-actions" },
form({ method: "GET", action: `/shops/edit/${encodeURIComponent(shop.key)}` },
button({ type: "submit", class: "tribe-action-btn" }, i18n.shopUpdate)
),
form({ method: "POST", action: `/shops/delete/${encodeURIComponent(shop.key)}` },
button({ type: "submit", class: "tribe-action-btn danger-btn" }, i18n.shopDelete)
)
)
: null,
safeArr(shop.tags).length
? div({ class: "tribe-side-tags" }, safeArr(shop.tags).map(tag => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`)))
: null
@ -436,6 +372,7 @@ exports.singleShopView = async (shop, filter, products = [], comments = [], para
return template(
shop.title || i18n.shopTitle,
section(div({ class: "tags-header" }, h2(i18n.shopsTitle), p(i18n.shopDescription))),
section(renderModeButtons(filter)),
section(
div({ class: "tribe-details" },
@ -451,89 +388,107 @@ exports.singleProductView = async (product, shop, comments = [], params = {}) =>
const isAuthor = String(product.author) === String(userId)
const stock = Number(product.stock) || 0
return template(
product.title || i18n.shopProductTitle,
section(renderModeButtons("products")),
section(
div({ class: "shop-detail" },
div({ class: "bookmark-topbar transfer-topbar-single" },
div({ class: "bookmark-topbar-left" },
product.author && String(product.author) !== String(userId)
? form({ method: "GET", action: "/pm" }, input({ type: "hidden", name: "recipients", value: product.author }), button({ type: "submit", class: "filter-btn" }, i18n.privateMessage))
: null,
form({ method: "GET", action: `/shops/${encodeURIComponent(product.shopId)}` },
button({ type: "submit", class: "filter-btn" }, `${i18n.shopBackToShop}`))
),
isAuthor
? div({ class: "bookmark-actions transfer-actions" },
form({ method: "GET", action: `/shops/product/edit/${encodeURIComponent(product.key)}` },
input({ type: "hidden", name: "shopId", value: product.shopId }),
input({ type: "hidden", name: "returnTo", value: returnTo }),
button({ class: "update-btn", type: "submit" }, i18n.shopUpdate)
),
form({ method: "POST", action: `/shops/product/delete/${encodeURIComponent(product.key)}` },
input({ type: "hidden", name: "returnTo", value: returnTo }),
button({ class: "delete-btn", type: "submit" }, i18n.shopDelete)
)
)
: null
),
product.image ? div({ class: "shop-detail-media" }, renderMediaBlob(product.image)) : null,
h2(product.title),
renderStarRating(product.opinions, safeArr(product.opinions_inhabitants).length),
product.description ? div({ class: "shop-detail-desc" }, ...renderUrl(product.description)) : null,
div({ class: "shop-product-price" }, `${Number(product.price || 0).toFixed(6)} ECO`),
div({ class: "confirmations-block stock-block" },
div({ class: "card-field" },
span({ class: "card-label" }, `${i18n.shopProductStock}: `),
span({ class: "card-value" }, stock > 0 ? String(stock) : i18n.shopOutOfStock)
),
progress({ class: "confirmations-progress stock-progress", value: Math.min(stock, 100), max: 100 })
),
!isAuthor && stock > 0
? form({ method: "POST", action: `/shops/product/buy/${encodeURIComponent(product.key)}`, class: "shop-buy-form" },
input({ type: "hidden", name: "returnTo", value: returnTo }),
p({ class: "shop-buy-form-note" }, i18n.shopBuyEncryptedNote || "Your delivery details are sent encrypted only to the shop owner."),
label(i18n.shopBuyDeliveryAddress || "Delivery address"),
br(),
textarea({ name: "deliveryAddress", required: true, rows: 3, placeholder: i18n.shopBuyDeliveryAddressPlaceholder || "" }),
br(),
br(),
label(i18n.shopBuyContact || "Contact"),
br(),
input({ type: "text", name: "contact", placeholder: i18n.shopBuyContactPlaceholder || "email, phone, etc." }),
br(),
br(),
label(i18n.shopBuyNotes || "Notes"),
br(),
textarea({ name: "notes", rows: 2, placeholder: i18n.shopBuyNotesPlaceholder || "" }),
br(),
br(),
button({ type: "submit", class: "buy-btn" }, i18n.marketActionsBuy || i18n.shopBuy)
)
: null,
br(),
p({ class: "card-footer" },
span({ class: "date-link" }, moment(product.createdAt).format("YYYY-MM-DD HH:mm")),
" ",
userLink(product.author)
),
!isAuthor && params.canRate
? renderOpinionsVoting('/shops/product/opinions', product.key, product.opinions, returnTo, product.opinions_inhabitants)
: null
const productSide = div({ class: "tribe-side" },
div({ class: "card-header activity-card-header" },
renderContentActions(product.key, null, { spread: params.spreads || null, author: product.author, favKind: 'shopProducts', isFavorite: product.isFavorite, returnTo, reportTitle: product.title })
),
div({ class: "shop-title-row" },
h2({ class: "tribe-card-title" }, product.title || i18n.shopProductUntitled)
),
renderStarRating(product.opinions, safeArr(product.opinions_inhabitants).length),
product.image ? renderMediaBlob(product.image, null, { class: "tribe-detail-image" }) : null,
div({ class: "card-date-highlight" }, `${Number(product.price || 0).toFixed(6)} ECO`),
div({ class: "confirmations-block stock-block" },
div({ class: "card-field" },
span({ class: "card-label" }, `${i18n.shopProductStock}: `),
span({ class: "card-value" }, stock > 0 ? String(stock) : i18n.shopOutOfStock)
),
progress({ class: "confirmations-progress stock-progress", value: Math.min(stock, 100), max: 100 })
),
table({ class: "tribe-info-table jobs-info-table" },
tr(
td({ class: "tribe-info-label" }, i18n.shopCreatedAt || "CREATED"),
td({ class: "tribe-info-value", colspan: "3" }, moment(product.createdAt).format("YYYY/MM/DD HH:mm"))
),
tr(
td({ class: "tribe-info-value", colspan: "4" }, userLink(product.author))
)
),
div({ class: "tribe-side-actions" },
form({ method: "GET", action: `/shops/${encodeURIComponent(product.shopId)}` },
button({ type: "submit", class: "tribe-action-btn" }, `${i18n.shopBackToShop}`)
)
),
isAuthor
? div({ class: "tribe-side-actions owner-actions" },
form({ method: "GET", action: `/shops/product/edit/${encodeURIComponent(product.key)}` },
input({ type: "hidden", name: "shopId", value: product.shopId }),
input({ type: "hidden", name: "returnTo", value: returnTo }),
button({ class: "tribe-action-btn", type: "submit" }, i18n.shopUpdate)
),
form({ method: "POST", action: `/shops/product/delete/${encodeURIComponent(product.key)}` },
input({ type: "hidden", name: "returnTo", value: returnTo }),
button({ class: "tribe-action-btn danger-btn", type: "submit" }, i18n.shopDelete)
)
)
: null
)
const productMain = div({ class: "tribe-main" },
product.description
? div({ class: "job-section" },
h2({ class: "job-section-title" }, i18n.marketItemDescription || i18n.shopDescription),
p({ class: "tribe-side-description" }, ...renderUrl(product.description))
)
: null,
!isAuthor && stock > 0
? div({ class: "job-section" },
form({ method: "POST", action: `/shops/product/buy/${encodeURIComponent(product.key)}`, class: "shop-buy-form" },
input({ type: "hidden", name: "returnTo", value: returnTo }),
p({ class: "shop-buy-form-note" }, i18n.shopBuyEncryptedNote || "Your delivery details are sent encrypted only to the shop owner."),
label(i18n.shopBuyDeliveryAddress || "Delivery address"),
br(),
textarea({ name: "deliveryAddress", required: true, rows: 3, placeholder: i18n.shopBuyDeliveryAddressPlaceholder || "" }),
br(),
br(),
label(i18n.shopBuyContact || "Contact"),
br(),
input({ type: "text", name: "contact", placeholder: i18n.shopBuyContactPlaceholder || "email, phone, etc." }),
br(),
br(),
label(i18n.shopBuyNotes || "Notes"),
br(),
textarea({ name: "notes", rows: 2, placeholder: i18n.shopBuyNotesPlaceholder || "" }),
br(),
br(),
button({ type: "submit", class: "buy-btn" }, i18n.marketActionsBuy || i18n.shopBuy)
)
)
: null,
renderEngagement(product.key,
!isAuthor && params.canRate
? renderOpinionsVoting('/shops/product/opinions', product.key, product.opinions, returnTo, product.opinions_inhabitants)
: null,
renderCommentsSection(product.key, returnTo, comments)
)
)
return template(
product.title || i18n.shopProductTitle,
section(div({ class: "tags-header" }, h2(i18n.shopsTitle), p(i18n.shopDescription))),
section(renderModeButtons("products")),
section(div({ class: "tribe-details" }, productSide, productMain))
)
}
exports.editProductView = async (product, shopId, params = {}) => {
const returnTo = safeText(params.returnTo) || `/shops/${encodeURIComponent(shopId)}`
return template(
i18n.shopProductUpdate,
section(
div({ class: "tags-header" }, h2(i18n.shopProductUpdate)),
renderProductForm(shopId, product, true, returnTo)
renderProductForm(shopId, product, true, returnTo, await renderSpreadEditWarning(product && (product.key || product.id)))
)
)
}
@ -586,7 +541,7 @@ exports.shopOrdersView = async (shop, orders) => {
div({ class: "card-field" }, span({ class: "card-label" }, `${i18n.shopBuyDeliveryAddress || "Delivery address"}:`), span({ class: "card-value" }, String(o.deliveryAddress || ""))),
o.contact ? div({ class: "card-field" }, span({ class: "card-label" }, `${i18n.shopBuyContact || "Contact"}:`), span({ class: "card-value" }, String(o.contact))) : null,
o.notes ? div({ class: "card-field" }, span({ class: "card-label" }, `${i18n.shopBuyNotes || "Notes"}:`), span({ class: "card-value" }, String(o.notes))) : null,
p({ class: "card-footer" }, span({ class: "date-link" }, moment(o.createdAt || o.ts).format("YYYY-MM-DD HH:mm"))),
p({ class: "card-footer" }, span({ class: "date-link" }, moment(o.createdAt || o.ts).format("YYYY/MM/DD HH:mm"))),
orderActions(o)
))
return template(
@ -623,7 +578,7 @@ exports.myPurchasesView = async (purchases) => {
div({ class: "card-field" }, span({ class: "card-label" }, `${i18n.shopBuyDeliveryAddress || "Delivery address"}:`), span({ class: "card-value" }, String(o.deliveryAddress || ""))),
o.contact ? div({ class: "card-field" }, span({ class: "card-label" }, `${i18n.shopBuyContact || "Contact"}:`), span({ class: "card-value" }, String(o.contact))) : null,
o.notes ? div({ class: "card-field" }, span({ class: "card-label" }, `${i18n.shopBuyNotes || "Notes"}:`), span({ class: "card-value" }, String(o.notes))) : null,
p({ class: "card-footer" }, span({ class: "date-link" }, moment(o.createdAt || o.ts).format("YYYY-MM-DD HH:mm"))),
p({ class: "card-footer" }, span({ class: "date-link" }, moment(o.createdAt || o.ts).format("YYYY/MM/DD HH:mm"))),
buyerActions(o)
))
return template(

View file

@ -1,6 +1,8 @@
const { div, h2, p, section, button, form, input, select, option, a, br, textarea, label, span, table, tr, td, img, video } = require("../server/node_modules/hyperaxe");
const { renderCommentsSection: renderSharedCommentsSection } = require("./comments_view");
const moment = require("../server/node_modules/moment");
const { template, i18n, renderOpinionsVoting, userLink, renderStateChip, renderPrivacyChip, renderLifespanChip, renderEcoTax, renderSpreadButton } = require("./main_views");
const { template, i18n, renderOpinionsVoting, renderEngagement, userLink, renderStateChip, renderPrivacyChip, renderLifespanChip, renderEcoTax, renderSpreadButton, renderSpreadEditWarning, renderContentActions, renderDocumentActions } = require("./main_views");
const { renderPhotoGallery, renderGalleryFields, imagesOf } = require("./gallery_view");
const { config } = require("../server/SSB_server.js");
const { renderUrl } = require("../backend/renderUrl");
@ -19,7 +21,7 @@ const renderTaskMediaBlob = (value, attrs = {}) => {
const userId = config.keys.id;
const opt = (value, isSelected, text) =>
option(Object.assign({ value }, isSelected ? { selected: "selected" } : {}), text);
option(Object.assign({ value }, isSelected ? { ...("selected" ? { selected: true } : {})} : {}), text);
const safeArray = (v) => Array.isArray(v) ? v : [];
@ -57,95 +59,50 @@ const renderTaskOwnerActions = (task, returnTo) => {
input({ type: "hidden", name: "returnTo", value: returnTo }),
button({ type: "submit", class: "delete-btn" }, i18n.taskDeleteButton)
),
];
};
const renderTaskStatusRow = (task, returnTo) => {
const st = normalizeStatus(task.status || "OPEN");
return div({ class: "tribe-side-actions housing-status-row" },
span({ class: "card-label" }, `${i18n.taskStatus}: `),
renderStateChip(st === "CLOSED" ? "hidden" : "mutuals", st === "CLOSED" ? "🔒" : "👁", statusLabel(task.status)),
form(
{ method: "POST", action: `/tasks/status/${encodeURIComponent(task.id)}`, class: "project-control-form project-control-form--status" },
input({ type: "hidden", name: "returnTo", value: returnTo }),
select(
{ name: "status", class: "project-control-select" },
option({ value: "OPEN", selected: st === "OPEN" }, i18n.taskStatusOpen),
option({ value: "IN-PROGRESS", selected: st === "IN-PROGRESS" }, i18n.taskStatusInProgress),
option({ value: "CLOSED", selected: st === "CLOSED" }, i18n.taskStatusClosed)
option({ value: "OPEN", ...(st === "OPEN" ? { selected: true } : {})}, i18n.taskStatusOpen),
option({ value: "IN-PROGRESS", ...(st === "IN-PROGRESS" ? { selected: true } : {})}, i18n.taskStatusInProgress),
option({ value: "CLOSED", ...(st === "CLOSED" ? { selected: true } : {})}, i18n.taskStatusClosed)
),
button({ class: "status-btn project-control-btn", type: "submit" }, setStatusLabel)
button({ class: "status-btn project-control-btn", type: "submit" }, i18n.taskSetStatus)
)
];
);
};
const renderTaskAssignAction = (task, isAssignedToMe, returnTo) => {
const st = normalizeStatus(task.status || "OPEN");
if (st === "CLOSED") return null;
return form(
{ method: "POST", action: `/tasks/assign/${encodeURIComponent(task.id)}` },
input({ type: "hidden", name: "returnTo", value: returnTo }),
button({ type: "submit", class: "filter-btn" }, isAssignedToMe ? i18n.taskUnassignButton : i18n.taskAssignButton)
);
return [
span({ class: "card-label" }, `${i18n.taskAssignedTo}: `),
renderStateChip(isAssignedToMe ? "mutuals" : "whole", isAssignedToMe ? "👤" : "○",
String(isAssignedToMe ? i18n.taskAssignedChip : i18n.taskUnassignedChip).toUpperCase()),
form(
{ method: "POST", action: `/tasks/assign/${encodeURIComponent(task.id)}`, class: "project-control-form" },
input({ type: "hidden", name: "returnTo", value: returnTo }),
button({ type: "submit", class: "status-btn project-control-btn" }, isAssignedToMe ? i18n.taskUnassignButton : i18n.taskAssignButton)
)
];
};
const renderTaskCommentsSection = (taskId, comments = [], currentFilter = "all") => {
const commentsCount = Array.isArray(comments) ? comments.length : 0;
const returnTo = `/tasks/${encodeURIComponent(taskId)}?filter=${encodeURIComponent(currentFilter || "all")}`;
return div(
{ class: "vote-comments-section" },
div(
{ class: "comments-count" },
span({ class: "card-label" }, i18n.voteCommentsLabel + ": "),
span({ class: "card-value" }, String(commentsCount))
),
div(
{ class: "comment-form-wrapper" },
h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
form(
{ method: "POST", action: `/tasks/${encodeURIComponent(taskId)}/comments`, class: "comment-form", enctype: "multipart/form-data" },
input({ type: "hidden", name: "returnTo", value: returnTo }),
textarea({
id: "comment-text",
name: "text",
rows: 4,
class: "comment-textarea",
placeholder: i18n.voteNewCommentPlaceholder
}),
div({ class: "comment-file-upload" }, label(i18n.uploadMedia), input({ type: "file", name: "blob" })),
br(),
button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
)
),
(() => {
const visibleComments = (comments || []).filter(c => {
const t = c && c.value && c.value.content && c.value.content.text;
return t && String(t).trim();
});
return visibleComments.length
? div(
{ class: "comments-list" },
visibleComments.map((c) => {
const author = c.value && c.value.author ? c.value.author : "";
const ts = c.value && c.value.timestamp ? c.value.timestamp : c.timestamp;
const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : "";
const relDate = ts ? moment(ts).fromNow() : "";
const content = c.value && c.value.content ? c.value.content : {};
const root = content.fork || content.root || "";
const text = content.text || "";
return div(
{ class: "votations-comment-card" },
span(
{ class: "created-at" },
span(i18n.createdBy),
author ? userLink(author) : span("(unknown)"),
absDate ? span(" | ") : "",
absDate ? span({ class: "votations-comment-date" }, absDate) : "",
relDate ? span({ class: "votations-comment-date" }, " | ", i18n.sendTime) : "",
relDate && root ? a({ href: `/thread/${encodeURIComponent(root)}#${encodeURIComponent(c.key)}` }, relDate) : ""
),
p({ class: "votations-comment-text" }, ...renderUrl(String(text)))
);
})
)
: p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet);
})()
);
return renderSharedCommentsSection({
action: `/tasks/${encodeURIComponent(taskId)}/comments`,
comments: comments,
returnTo: returnTo
});
};
const renderTaskStatusChip = (status) => {
@ -176,10 +133,11 @@ const renderTaskItem = (task, filter, spreadInfo) => {
const assignees = safeArray(task.assignees);
const isPrivate = String(task.isPublic || "").toUpperCase() === "PRIVATE";
const heroNode = task.image
const cover = imagesOf(task)[0]
const heroNode = cover
? div({ class: "tribe-card-image-wrapper" },
a({ href: `/tasks/${encodeURIComponent(task.id)}` },
renderTaskMediaBlob(task.image, { class: "tribe-card-hero-image" })
renderTaskMediaBlob(cover, { class: "tribe-card-hero-image" })
),
div({ class: "tribe-visit-btn-wrapper" },
form({ method: "GET", action: `/tasks/${encodeURIComponent(task.id)}` },
@ -201,6 +159,10 @@ const renderTaskItem = (task, filter, spreadInfo) => {
const end = task.endTime ? moment(task.endTime).format("YYYY/MM/DD HH:mm") : "";
return div({ class: "tribe-card task-card" },
div({ class: "card-header activity-card-header" },
span(),
renderContentActions(task.id, `/tasks/${encodeURIComponent(task.id)}`, { spread: spreadInfo || null, author: task.author, favKind: 'tasks', isFavorite: task.isFavorite, reportTitle: task.title })
),
heroNode,
div({ class: "tribe-card-body" },
div({ class: "shop-title-row" },
@ -212,16 +174,7 @@ const renderTaskItem = (task, filter, spreadInfo) => {
(start || end) ? p({ class: "card-date-highlight" }, start && end ? `${start}${end}` : (start || end)) : null,
div({ class: "tribe-card-members" },
span({ class: "tribe-members-count" }, `${i18n.taskAssignedTo}: ${assignees.length}`)
),
div({ class: "card-spread-centered" }, renderSpreadButton(task.id, spreadInfo)),
!heroNode
? div({ class: "card-visit-btn-centered" },
form({ method: "GET", action: `/tasks/${encodeURIComponent(task.id)}` },
input({ type: "hidden", name: "filter", value: currentFilter }),
button({ type: "submit", class: "filter-btn" }, i18n.viewTask || "View Task")
)
)
: null
)
)
);
};
@ -230,19 +183,7 @@ exports.taskView = async (tasks, filter, taskId, returnTo, params = {}) => {
const list = Array.isArray(tasks) ? tasks : [tasks];
const currentFilter = filter || "all";
const title =
currentFilter === "mine" ? i18n.taskMineSectionTitle :
currentFilter === "create" ? i18n.taskCreateSectionTitle :
currentFilter === "edit" ? i18n.taskUpdateSectionTitle :
currentFilter === "open" ? i18n.taskOpenTitle :
currentFilter === "in-progress" ? i18n.taskInProgressTitle :
currentFilter === "closed" ? i18n.taskClosedTitle :
currentFilter === "assigned" ? i18n.taskAssignedTitle :
currentFilter === "priority-urgent" ? i18n.taskFilterUrgent :
currentFilter === "priority-high" ? i18n.taskFilterHigh :
currentFilter === "priority-medium" ? i18n.taskFilterMedium :
currentFilter === "priority-low" ? i18n.taskFilterLow :
i18n.taskAllSectionTitle;
const title = i18n.tasksTitle;
const canSee = (t) => {
const vis = String(t.isPublic || "").toUpperCase();
@ -268,7 +209,8 @@ exports.taskView = async (tasks, filter, taskId, returnTo, params = {}) => {
filtered = filtered.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
const editTask = list.find((t) => t.id === taskId) || {};
const editTags = Array.isArray(editTask.tags) ? editTask.tags : [];
const formData = currentFilter === "edit" ? editTask : (params.draft || {});
const editTags = Array.isArray(formData.tags) ? formData.tags : (typeof formData.tags === "string" ? formData.tags.split(",").map(t => t.trim()).filter(Boolean) : []);
const minCreate = moment().add(1, "minute").format("YYYY-MM-DDTHH:mm");
const ret = typeof returnTo === "string" && returnTo.startsWith("/tasks")
@ -287,40 +229,52 @@ exports.taskView = async (tasks, filter, taskId, returnTo, params = {}) => {
{ class: "filters" },
form(
{ method: "GET", action: "/tasks" },
button({ type: "submit", name: "filter", value: "all", class: currentFilter === "all" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterAll),
button({ type: "submit", name: "filter", value: "mine", class: currentFilter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterMine),
button({ type: "submit", name: "filter", value: "assigned", class: currentFilter === "assigned" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterAssigned),
button({ type: "submit", name: "filter", value: "open", class: currentFilter === "open" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterOpen),
button({ type: "submit", name: "filter", value: "in-progress", class: currentFilter === "in-progress" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterInProgress),
button({ type: "submit", name: "filter", value: "closed", class: currentFilter === "closed" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterClosed),
button({ type: "submit", name: "filter", value: "priority-low", class: currentFilter === "priority-low" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterLow),
button({ type: "submit", name: "filter", value: "priority-medium", class: currentFilter === "priority-medium" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterMedium),
button({ type: "submit", name: "filter", value: "priority-high", class: currentFilter === "priority-high" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterHigh),
button({ type: "submit", name: "filter", value: "priority-urgent", class: currentFilter === "priority-urgent" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterUrgent),
button({ type: "submit", name: "filter", value: "all", class: currentFilter === "all" ? "filter-btn active" : "filter-btn" }, String(i18n.taskFilterAll).toUpperCase()),
button({ type: "submit", name: "filter", value: "mine", class: currentFilter === "mine" ? "filter-btn active" : "filter-btn" }, String(i18n.taskFilterMine).toUpperCase()),
button({ type: "submit", name: "filter", value: "assigned", class: currentFilter === "assigned" ? "filter-btn active" : "filter-btn" }, String(i18n.taskFilterAssigned).toUpperCase()),
button({ type: "submit", name: "filter", value: "open", class: currentFilter === "open" ? "filter-btn active" : "filter-btn" }, String(i18n.taskFilterOpen).toUpperCase()),
button({ type: "submit", name: "filter", value: "in-progress", class: currentFilter === "in-progress" ? "filter-btn active" : "filter-btn" }, String(i18n.taskFilterInProgress).toUpperCase()),
button({ type: "submit", name: "filter", value: "closed", class: currentFilter === "closed" ? "filter-btn active" : "filter-btn" }, String(i18n.taskFilterClosed).toUpperCase()),
button({ type: "submit", name: "filter", value: "priority-low", class: currentFilter === "priority-low" ? "filter-btn active" : "filter-btn" }, String(i18n.taskFilterLow).toUpperCase()),
button({ type: "submit", name: "filter", value: "priority-medium", class: currentFilter === "priority-medium" ? "filter-btn active" : "filter-btn" }, String(i18n.taskFilterMedium).toUpperCase()),
button({ type: "submit", name: "filter", value: "priority-high", class: currentFilter === "priority-high" ? "filter-btn active" : "filter-btn" }, String(i18n.taskFilterHigh).toUpperCase()),
button({ type: "submit", name: "filter", value: "priority-urgent", class: currentFilter === "priority-urgent" ? "filter-btn active" : "filter-btn" }, String(i18n.taskFilterUrgent).toUpperCase()),
button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.taskCreateButton)
)
)
),
currentFilter === "edit" || currentFilter === "create"
? null
: div({ class: "filters" },
form({ method: "GET", action: "/tasks", class: "filter-box" },
input({ type: "hidden", name: "filter", value: currentFilter }),
input({ type: "text", name: "q", value: params.q || "", placeholder: i18n.taskSearchPlaceholder, class: "filter-box__input" }),
div({ class: "filter-box__controls" },
button({ type: "submit", class: "filter-box__button" }, i18n.searchButton)
)
)
)
),
section(
currentFilter === "edit" || currentFilter === "create"
? div(
{ class: "task-form" },
currentFilter === "edit" ? await renderSpreadEditWarning(taskId) : null,
form(
{ action: currentFilter === "edit" ? `/tasks/update/${encodeURIComponent(taskId)}` : "/tasks/create", method: "POST", enctype: "multipart/form-data" },
input({ type: "hidden", name: "returnTo", value: ret }),
label(i18n.taskTitleLabel), br(),
input({ type: "text", name: "title", required: true, value: currentFilter === "edit" ? (editTask.title || "") : (params.prefillTitle || "") }), br(),
input({ type: "text", name: "title", maxlength: "100", required: true, value: formData.title || params.prefillTitle || "" }), br(),
label(i18n.taskDescriptionLabel), br(),
textarea({ name: "description", required: true, placeholder: i18n.taskDescriptionPlaceholder, rows: "4" }, currentFilter === "edit" ? (editTask.description || "") : (params.prefillDescription || "")), br(),
label(i18n.uploadMedia), br(),
input({ type: "file", name: "image", accept: "image/*" }), br(),br(),
textarea({ name: "description", required: true, placeholder: i18n.taskDescriptionPlaceholder, rows: "4" }, formData.description || params.prefillDescription || ""), br(),
...renderGalleryFields(formData, currentFilter === "edit"),
br(),
label(i18n.taskStartTimeLabel), br(),
input({
type: "datetime-local",
name: "startTime",
required: true,
min: currentFilter === "create" ? minCreate : undefined,
value: currentFilter === "edit" && editTask.startTime ? moment(editTask.startTime).format("YYYY-MM-DDTHH:mm") : ""
value: formData.startTime ? moment(formData.startTime).format("YYYY-MM-DDTHH:mm") : ""
}), br(), br(),
label(i18n.taskEndTimeLabel), br(),
input({
@ -328,25 +282,25 @@ exports.taskView = async (tasks, filter, taskId, returnTo, params = {}) => {
name: "endTime",
required: true,
min: currentFilter === "create" ? minCreate : undefined,
value: currentFilter === "edit" && editTask.endTime ? moment(editTask.endTime).format("YYYY-MM-DDTHH:mm") : ""
value: formData.endTime ? moment(formData.endTime).format("YYYY-MM-DDTHH:mm") : ""
}), br(), br(),
label(i18n.taskPriorityLabel), br(),
select(
{ name: "priority", required: true },
opt("URGENT", String(editTask.priority || "").toUpperCase() === "URGENT", i18n.taskPriorityUrgent),
opt("HIGH", String(editTask.priority || "").toUpperCase() === "HIGH", i18n.taskPriorityHigh),
opt("MEDIUM", String(editTask.priority || "").toUpperCase() === "MEDIUM", i18n.taskPriorityMedium),
opt("LOW", !editTask.priority || String(editTask.priority || "").toUpperCase() === "LOW", i18n.taskPriorityLow)
opt("URGENT", String(formData.priority || "").toUpperCase() === "URGENT", i18n.taskPriorityUrgent),
opt("HIGH", String(formData.priority || "").toUpperCase() === "HIGH", i18n.taskPriorityHigh),
opt("MEDIUM", String(formData.priority || "").toUpperCase() === "MEDIUM", i18n.taskPriorityMedium),
opt("LOW", !formData.priority || String(formData.priority || "").toUpperCase() === "LOW", i18n.taskPriorityLow)
), br(), br(),
label(i18n.taskLocationLabel), br(),
input({ type: "text", name: "location", value: editTask.location || "" }), br(),
input({ type: "text", name: "location", value: formData.location || "" }), br(),
label(i18n.taskTagsLabel), br(),
input({ type: "text", name: "tags", value: editTags.join(", ") }), br(),
label(i18n.taskVisibilityLabel), br(),
select(
{ name: "isPublic", id: "isPublic" },
opt("PUBLIC", String(editTask.isPublic || "PUBLIC").toUpperCase() === "PUBLIC", i18n.taskPublic),
opt("PRIVATE", String(editTask.isPublic || "").toUpperCase() === "PRIVATE", i18n.taskPrivate)
opt("PUBLIC", String(formData.isPublic || "PUBLIC").toUpperCase() === "PUBLIC", i18n.taskPublic),
opt("PRIVATE", String(formData.isPublic || "").toUpperCase() === "PRIVATE", i18n.taskPrivate)
), br(), br(),
button({ type: "submit" }, currentFilter === "edit" ? i18n.taskUpdateButton : i18n.taskCreateButton)
)
@ -371,16 +325,16 @@ exports.singleTaskView = async (task, filter, comments = [], params = {}) => {
{ class: "filters" },
form(
{ method: "GET", action: "/tasks" },
button({ type: "submit", name: "filter", value: "all", class: currentFilter === "all" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterAll),
button({ type: "submit", name: "filter", value: "mine", class: currentFilter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterMine),
button({ type: "submit", name: "filter", value: "assigned", class: currentFilter === "assigned" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterAssigned),
button({ type: "submit", name: "filter", value: "open", class: currentFilter === "open" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterOpen),
button({ type: "submit", name: "filter", value: "in-progress", class: currentFilter === "in-progress" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterInProgress),
button({ type: "submit", name: "filter", value: "closed", class: currentFilter === "closed" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterClosed),
button({ type: "submit", name: "filter", value: "priority-low", class: currentFilter === "priority-low" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterLow),
button({ type: "submit", name: "filter", value: "priority-medium", class: currentFilter === "priority-medium" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterMedium),
button({ type: "submit", name: "filter", value: "priority-high", class: currentFilter === "priority-high" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterHigh),
button({ type: "submit", name: "filter", value: "priority-urgent", class: currentFilter === "priority-urgent" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterUrgent),
button({ type: "submit", name: "filter", value: "all", class: currentFilter === "all" ? "filter-btn active" : "filter-btn" }, String(i18n.taskFilterAll).toUpperCase()),
button({ type: "submit", name: "filter", value: "mine", class: currentFilter === "mine" ? "filter-btn active" : "filter-btn" }, String(i18n.taskFilterMine).toUpperCase()),
button({ type: "submit", name: "filter", value: "assigned", class: currentFilter === "assigned" ? "filter-btn active" : "filter-btn" }, String(i18n.taskFilterAssigned).toUpperCase()),
button({ type: "submit", name: "filter", value: "open", class: currentFilter === "open" ? "filter-btn active" : "filter-btn" }, String(i18n.taskFilterOpen).toUpperCase()),
button({ type: "submit", name: "filter", value: "in-progress", class: currentFilter === "in-progress" ? "filter-btn active" : "filter-btn" }, String(i18n.taskFilterInProgress).toUpperCase()),
button({ type: "submit", name: "filter", value: "closed", class: currentFilter === "closed" ? "filter-btn active" : "filter-btn" }, String(i18n.taskFilterClosed).toUpperCase()),
button({ type: "submit", name: "filter", value: "priority-low", class: currentFilter === "priority-low" ? "filter-btn active" : "filter-btn" }, String(i18n.taskFilterLow).toUpperCase()),
button({ type: "submit", name: "filter", value: "priority-medium", class: currentFilter === "priority-medium" ? "filter-btn active" : "filter-btn" }, String(i18n.taskFilterMedium).toUpperCase()),
button({ type: "submit", name: "filter", value: "priority-high", class: currentFilter === "priority-high" ? "filter-btn active" : "filter-btn" }, String(i18n.taskFilterHigh).toUpperCase()),
button({ type: "submit", name: "filter", value: "priority-urgent", class: currentFilter === "priority-urgent" ? "filter-btn active" : "filter-btn" }, String(i18n.taskFilterUrgent).toUpperCase()),
button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.taskCreateButton)
)
);
@ -421,15 +375,8 @@ exports.singleTaskView = async (task, filter, comments = [], params = {}) => {
: null;
const sideActions = [];
if (task.author && task.author !== userId) {
sideActions.push(form({ method: "GET", action: "/pm" },
input({ type: "hidden", name: "recipients", value: task.author }),
button({ type: "submit", class: "filter-btn" }, i18n.privateMessage)
));
}
const assignNode = renderTaskAssignAction(task, isAssignedToMe, returnToSelf);
if (assignNode) sideActions.push(assignNode);
if (isAuthor) sideActions.push(...renderTaskOwnerActions(task, returnToSelf));
const ownerActions = isAuthor ? renderTaskOwnerActions(task, returnToSelf) : [];
const infoRows = [];
const pushRow = (labelText, valueNode) =>
@ -444,19 +391,25 @@ exports.singleTaskView = async (task, filter, comments = [], params = {}) => {
if (task.location && String(task.location).trim()) pushRow(i18n.taskLocationLabel, task.location);
const taskSide = div({ class: "tribe-side" },
div({ class: "card-header activity-card-header" },
renderContentActions(task.id, null, { spread: params.spreads || null, author: task.author, favKind: 'tasks', isFavorite: task.isFavorite, reportTitle: task.title })
),
div({ class: "shop-title-row" },
h2({ class: "tribe-card-title" }, task.title)
),
chips.length ? div({ class: "card-chips-row" }, ...chips) : null,
div({ class: "card-spread-centered" }, renderSpreadButton(task.id, params.spreads)),
task.image ? renderTaskMediaBlob(task.image, { class: "tribe-detail-image" }) : null,
renderPhotoGallery(task, 'task'),
table({ class: "tribe-info-table jobs-info-table" }, ...infoRows),
assignNode ? div({ class: "tribe-side-actions housing-status-row task-assign-row" }, ...assignNode) : null,
isAuthor ? renderTaskStatusRow(task, returnToSelf) : null,
tagsNode,
div({ class: "tribe-card-members" },
span({ class: "tribe-members-count" }, `${i18n.taskAssignedTo}: ${assignees.length}`)
),
assigneesListNode,
sideActions.length ? div({ class: "tribe-side-actions" }, ...sideActions) : null
sideActions.length ? div({ class: "tribe-side-actions" }, ...sideActions) : null,
ownerActions.length ? div({ class: "tribe-side-actions owner-actions" }, ...ownerActions) : null,
renderDocumentActions('tasks', task.id)
);
const returnToOpinions = `/tasks/${encodeURIComponent(task.id)}?filter=${encodeURIComponent(currentFilter)}`;
@ -473,13 +426,13 @@ exports.singleTaskView = async (task, filter, comments = [], params = {}) => {
span({ class: "date-link" }, `${moment(task.createdAt).format("YYYY/MM/DD HH:mm")} ${i18n.performed} `),
userLink(task.author)
),
opinionsBar,
renderTaskCommentsSection(task.id, comments, currentFilter)
renderEngagement(task.id, opinionsBar, renderTaskCommentsSection(task.id, comments, currentFilter))
);
return template(
task.title,
section(
div({ class: "tags-header" }, h2(i18n.tasksTitle), p(i18n.tasksDescription)),
filterBar,
div({ class: "tribe-details" }, taskSide, taskMain)
)

View file

@ -18,8 +18,9 @@ const {
th,
td
} = require("../server/node_modules/hyperaxe");
const { renderCommentsSection: renderSharedCommentsSection } = require("./comments_view");
const { template, i18n, renderOpinionsVoting, userLink, renderSpreadButton, renderEcoTax, renderLifespanChip } = require("./main_views");
const { template, i18n, renderOpinionsVoting, renderEngagement, userLink, renderSpreadButton, renderEcoTax, renderLifespanChip , renderSpreadEditWarning, renderContentActions } = require("./main_views");
const moment = require("../server/node_modules/moment");
const { config } = require("../server/SSB_server.js");
const { renderUrl } = require("../backend/renderUrl");
@ -49,21 +50,6 @@ const renderTags = (tags) => {
: null;
};
const renderTorrentFavoriteToggle = (torrentObj, returnTo = "") =>
form(
{
method: "POST",
action: torrentObj.isFavorite
? `/torrents/favorites/remove/${encodeURIComponent(torrentObj.key)}`
: `/torrents/favorites/add/${encodeURIComponent(torrentObj.key)}`
},
returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
button(
{ type: "submit", class: "filter-btn" },
torrentObj.isFavorite ? i18n.torrentRemoveFavoriteButton : i18n.torrentAddFavoriteButton
)
);
const renderTorrentOwnerActions = (filter, torrentObj, params = {}) => {
const returnTo = buildReturnTo(filter, params);
const isAuthor = String(torrentObj.author) === String(userId);
@ -93,63 +79,11 @@ const renderTorrentOwnerActions = (filter, torrentObj, params = {}) => {
};
const renderTorrentCommentsSection = (torrentId, comments = [], returnTo = null) => {
const list = safeArr(comments);
const commentsCount = list.length;
return div(
{ class: "vote-comments-section" },
div(
{ class: "comments-count" },
span({ class: "card-label" }, i18n.voteCommentsLabel + ": "),
span({ class: "card-value" }, String(commentsCount))
),
div(
{ class: "comment-form-wrapper" },
h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
form(
{ method: "POST", action: `/torrents/${encodeURIComponent(torrentId)}/comments`, class: "comment-form", enctype: "multipart/form-data" },
returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
textarea({
id: "comment-text",
name: "text",
rows: 4,
class: "comment-textarea",
placeholder: i18n.voteNewCommentPlaceholder
}),
div({ class: "comment-file-upload" }, label(i18n.uploadMedia), input({ type: "file", name: "blob" })),
br(),
button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
)
),
list.length
? div(
{ class: "comments-list" },
list.map((c) => {
const author = c?.value?.author || "";
const ts = c?.value?.timestamp || c?.timestamp;
const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : "";
const relDate = ts ? moment(ts).fromNow() : "";
const content = c?.value?.content || {};
const rootId = content.fork || content.root || null;
const text = content.text || "";
return div(
{ class: "votations-comment-card" },
span(
{ class: "created-at" },
span(i18n.createdBy),
author ? userLink(author) : span("(unknown)"),
absDate ? span(" | ") : "",
absDate ? span({ class: "votations-comment-date" }, absDate) : "",
relDate ? span({ class: "votations-comment-date" }, " | ", i18n.sendTime) : "",
relDate && rootId ? a({ href: `/thread/${encodeURIComponent(rootId)}#${encodeURIComponent(c.key)}` }, relDate) : ""
),
p({ class: "votations-comment-text" }, ...renderUrl(text))
);
})
)
: p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet)
);
return renderSharedCommentsSection({
action: `/torrents/${encodeURIComponent(torrentId)}/comments`,
comments: comments,
returnTo: returnTo
});
};
const formatSize = (bytes) => {
@ -206,6 +140,7 @@ const renderTorrentForm = (filter, torrentId, torrentToEdit, params = {}) => {
const tribeId = safeText(params.tribeId || "");
return div(
{ class: "div-center audio-form" },
params.spreadWarning || null,
form(
{
action: filter === "edit" ? `/torrents/update/${encodeURIComponent(torrentId)}` : "/torrents/create",
@ -221,7 +156,7 @@ const renderTorrentForm = (filter, torrentId, torrentToEdit, params = {}) => {
br(),
span(i18n.torrentTitleLabel),
br(),
input({ type: "text", name: "title", placeholder: i18n.torrentTitlePlaceholder, value: torrentToEdit?.title || "", required: true }),
input({ type: "text", name: "title", maxlength: "100", placeholder: i18n.torrentTitlePlaceholder, value: torrentToEdit?.title || "", required: true }),
br(),
span(i18n.torrentDescriptionLabel),
br(),
@ -243,20 +178,8 @@ const renderTorrentForm = (filter, torrentId, torrentToEdit, params = {}) => {
};
exports.torrentsView = async (torrents, filter = "all", torrentId = null, params = {}) => {
const title =
filter === "mine"
? i18n.torrentMineSectionTitle
: filter === "create"
? i18n.torrentCreateSectionTitle
: filter === "edit"
? i18n.torrentUpdateSectionTitle
: filter === "recent"
? i18n.torrentRecentSectionTitle
: filter === "top"
? i18n.torrentTopSectionTitle
: filter === "favorites"
? i18n.torrentFavoritesSectionTitle
: i18n.torrentAllSectionTitle;
if (filter === "edit") params = { ...params, spreadWarning: await renderSpreadEditWarning(torrentId) };
const title = i18n.torrentsTitle;
const q = safeText(params.q || "");
const sort = safeText(params.sort || "recent");
@ -283,14 +206,14 @@ exports.torrentsView = async (torrents, filter = "all", torrentId = null, params
{ method: "GET", action: "/torrents", class: "ui-toolbar ui-toolbar--filters" },
input({ type: "hidden", name: "q", value: q }),
input({ type: "hidden", name: "sort", value: sort }),
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, i18n.torrentFilterAll),
button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.torrentFilterMine),
button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.torrentFilterRecent),
button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, i18n.torrentFilterTop),
button(
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, String(i18n.torrentFilterAll).toUpperCase()),
button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, String(i18n.torrentFilterMine).toUpperCase()),
button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, String(i18n.torrentFilterRecent).toUpperCase()), button(
{ type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" },
i18n.torrentFilterFavorites
String(i18n.torrentFilterFavorites).toUpperCase()
),
button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, String(i18n.torrentFilterTop).toUpperCase()),
button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.torrentCreateButton)
)
)
@ -315,9 +238,9 @@ exports.torrentsView = async (torrents, filter = "all", torrentId = null, params
{ class: "filter-box__controls" },
select(
{ name: "sort", class: "filter-box__select" },
option({ value: "recent", selected: sort === "recent" }, i18n.torrentSortRecent),
option({ value: "oldest", selected: sort === "oldest" }, i18n.torrentSortOldest),
option({ value: "top", selected: sort === "top" }, i18n.torrentSortTop)
option({ value: "recent", ...(sort === "recent" ? { selected: true } : {})}, i18n.torrentSortRecent),
option({ value: "oldest", ...(sort === "oldest" ? { selected: true } : {})}, i18n.torrentSortOldest),
option({ value: "top", ...(sort === "top" ? { selected: true } : {})}, i18n.torrentSortTop)
),
button({ type: "submit", class: "filter-box__button" }, i18n.torrentSearchButton)
)
@ -345,18 +268,21 @@ exports.singleTorrentView = async (torrentObj, filter = "all", comments = [], pa
const ownerActions = renderTorrentOwnerActions(filter, torrentObj, { q, sort });
const sideActions = [];
sideActions.push(renderTorrentFavoriteToggle(torrentObj, returnTo));
if (torrentObj.author && String(torrentObj.author) !== String(userId)) {
sideActions.push(form(
{ method: "GET", action: "/pm" },
input({ type: "hidden", name: "recipients", value: torrentObj.author }),
button({ type: "submit", class: "filter-btn" }, i18n.privateMessage)
));
}
for (const a of ownerActions) sideActions.push(a);
const tagsNode = renderTags(torrentObj.tags);
const detailActions = div({ class: "card-header activity-card-header" },
renderContentActions(torrentObj.key, null, {
author: torrentObj.author,
favKind: 'torrents',
isFavorite: torrentObj.isFavorite,
spread: params.spreads || null,
returnTo,
reportTitle: torrentObj.title
})
);
const torrentSide = div({ class: "tribe-side" },
div({ class: "shop-title-row" },
title ? h2({ class: "tribe-card-title" }, title) : null,
@ -367,11 +293,11 @@ exports.singleTorrentView = async (torrentObj, filter = "all", comments = [], pa
? p({ class: "tribe-side-description" }, ...renderUrl(torrentObj.description))
: null,
tagsNode,
div({ class: "card-spread-centered" }, renderSpreadButton(torrentObj.key, params.spreads)),
sideActions.length ? div({ class: "tribe-side-actions" }, ...sideActions) : null
);
const torrentMain = div({ class: "tribe-main" },
detailActions,
torrentObj.url && torrentObj.url.startsWith("&")
? div({ class: "torrent-download" },
a({ href: `/blob/${encodeURIComponent(torrentObj.url)}?name=${encodeURIComponent((torrentObj.title || 'download').replace(/\.torrent$/i, '') + '.torrent')}` , class: "filter-btn" }, i18n.torrentDownloadButton || "DOWNLOAD IT!")
@ -384,18 +310,20 @@ exports.singleTorrentView = async (torrentObj, filter = "all", comments = [], pa
return p(
{ class: "card-footer" },
span({ class: "date-link" }, `${moment(torrentObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
span({ class: "date-link" }, `${moment(torrentObj.createdAt).format("YYYY/MM/DD HH:mm")} ${i18n.performed} `),
userLink(torrentObj.author),
showUpdated
? span(
{ class: "votations-comment-date" },
` | ${i18n.torrentUpdatedAt}: ${moment(torrentObj.updatedAt).format("YYYY/MM/DD HH:mm:ss")}`
` | ${i18n.torrentUpdatedAt}: ${moment(torrentObj.updatedAt).format("YYYY/MM/DD HH:mm")}`
)
: null
);
})(),
renderOpinionsVoting('/torrents/opinions', torrentObj.key, torrentObj.opinions, returnTo, torrentObj.opinions_inhabitants),
renderTorrentCommentsSection(torrentObj.key, comments, returnTo)
renderEngagement(torrentObj.key,
renderOpinionsVoting('/torrents/opinions', torrentObj.key, torrentObj.opinions, returnTo, torrentObj.opinions_inhabitants),
renderTorrentCommentsSection(torrentObj.key, comments, returnTo)
)
);
return template(
@ -411,14 +339,14 @@ exports.singleTorrentView = async (torrentObj, filter = "all", comments = [], pa
{ method: "GET", action: "/torrents", class: "ui-toolbar ui-toolbar--filters" },
input({ type: "hidden", name: "q", value: q }),
input({ type: "hidden", name: "sort", value: sort }),
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, i18n.torrentFilterAll),
button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.torrentFilterMine),
button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.torrentFilterRecent),
button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, i18n.torrentFilterTop),
button(
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, String(i18n.torrentFilterAll).toUpperCase()),
button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, String(i18n.torrentFilterMine).toUpperCase()),
button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, String(i18n.torrentFilterRecent).toUpperCase()), button(
{ type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" },
i18n.torrentFilterFavorites
String(i18n.torrentFilterFavorites).toUpperCase()
),
button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, String(i18n.torrentFilterTop).toUpperCase()),
button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.torrentCreateButton)
)
),

View file

@ -13,7 +13,7 @@ const filterButton = (mode, currentFilter) =>
input({ type: 'hidden', name: 'filter', value: mode }),
button(
{ type: 'submit', class: currentFilter === mode ? 'filter-btn active' : 'filter-btn' },
i18n[mode + 'Button'] || mode
String(i18n[mode + 'Button'] || mode).toUpperCase()
)
);
@ -42,12 +42,11 @@ const renderTrendingCard = (item, votes, categories, seenTitles, spreadMap = new
)
);
} else if (c.type === 'image') {
const { url, title, description, meme } = c;
const { url, title, description } = c;
contentHtml = div({ class: 'trending-image' },
div({ class: 'card-section image' },
title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.imageTitleLabel + ':'), span({ class: 'card-value' }, title)) : "",
description ? [span({ class: 'card-label' }, i18n.imageDescriptionLabel + ":"), p(...renderUrl(description))] : null,
meme ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.trendingCategory + ':'), span({ class: 'card-value' }, i18n.meme)) : "",
div({ class: 'card-field' }, img({ src: `/blob/${encodeURIComponent(url)}`, class: 'feed-image' }))
)
);
@ -129,7 +128,7 @@ const renderTrendingCard = (item, votes, categories, seenTitles, spreadMap = new
: ""
)
);
} else if (c.type === 'votes') {
} else if (c.type === 'votes' || c.type === 'poll') {
const { question, deadline, votes: vmap, totalVotes } = c;
const votesList = vmap && typeof vmap === 'object'
? Object.entries(vmap).map(([o, cnt]) => ({ option: o, count: cnt }))
@ -198,12 +197,11 @@ const renderTrendingCard = (item, votes, categories, seenTitles, spreadMap = new
span({ class: 'pm-exposition-chip pm-exposition-whole' },
span({ class: 'pm-exposition-text' }, String(c.type || '').toUpperCase())
),
renderContentActions(item.key, detailHref)
renderContentActions(item.key, detailHref, { spread: spreadMap.get(item.key) || null })
),
div(
{ class: 'card-section trending-card-body' },
contentHtml,
div({ class: 'card-spread-left' }, renderSpreadButton(item.key, spreadMap.get(item.key))),
p(
{ class: 'card-footer' },
span({ class: 'date-link' }, `${created} ${i18n.performed} `),
@ -228,14 +226,16 @@ const renderTrendingCard = (item, votes, categories, seenTitles, spreadMap = new
);
})(),
details({ class: 'opinions-voting-collapse' },
summary({ class: 'opinions-summary' }, `${i18n.opinionsTitle || 'Opinions'} (${Object.values(c.opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0)})`),
summary({ class: 'opinions-summary' },
span({ class: 'opinions-summary-icon' }, 'ꔍ'),
span({ class: 'opinions-summary-count' }, `(${Object.values(c.opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0)})`)),
div(
{ class: 'voting-buttons' },
categories.map(cat =>
form({ method: 'POST', action: `/trending/${encodeURIComponent(item.key)}/${cat}` },
button(
{ class: 'vote-btn' },
`${voteLabelFor(cat)} [${c.opinions?.[cat] || 0}]`
`${String(voteLabelFor(cat)).toUpperCase()} [${c.opinions?.[cat] || 0}]`
)
)
)
@ -245,14 +245,14 @@ const renderTrendingCard = (item, votes, categories, seenTitles, spreadMap = new
);
};
exports.trendingView = (items, filter, categories = opinionCategories, spreadMap = new Map()) => {
exports.trendingView = (items, filter, categories = opinionCategories, spreadMap = new Map(), q = '') => {
const seenDocumentTitles = new Set();
const title = i18n.trendingTitle;
const baseFilters = ['TOP', 'ALL', 'MINE', 'RECENT'];
const baseFilters = ['ALL', 'MINE', 'RECENT', 'TOP'];
const contentFilters = [
['feed', 'votes', 'event', 'task', 'report'],
['project', 'industry', 'transfer', 'shopProduct'],
['votes', 'event', 'task', 'report'],
['feed', 'project', 'industry', 'shopProduct', 'transfer'],
['audio', 'bookmark', 'document', 'image', 'torrent', 'video']
];
@ -273,7 +273,9 @@ exports.trendingView = (items, filter, categories = opinionCategories, spreadMap
return bVotes !== aVotes ? bVotes - aVotes : b.value.timestamp - a.value.timestamp;
});
} else if (contentFilters.flat().includes(filter)) {
filteredItems = filteredItems.filter(item => item.value.content.type === filter);
filteredItems = filteredItems.filter(item =>
item.value.content.type === filter ||
(filter === 'votes' && item.value.content.type === 'poll'));
} else if (filter !== 'ALL') {
filteredItems = filteredItems.filter(item => (item.value.content.opinions_inhabitants || []).length > 0);
}
@ -308,9 +310,18 @@ exports.trendingView = (items, filter, categories = opinionCategories, spreadMap
div({ class: 'column' }, row.map(mode => filterButton(mode, filter)))
)
),
div({ class: 'filters' },
form({ method: 'GET', action: '/trending', class: 'filter-box' },
input({ type: 'hidden', name: 'filter', value: filter }),
input({ type: 'text', name: 'q', value: q, placeholder: i18n.trendingSearchPlaceholder, class: 'filter-box__input' }),
div({ class: 'filter-box__controls' },
button({ type: 'submit', class: 'filter-box__button' }, i18n.searchButton)
)
)
),
section(
cards.length
? div({ class: 'trending-container', style: 'display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:20px;' }, ...cards)
? div({ class: 'trending-container' }, ...cards)
: div({ class: 'no-results' }, p(i18n.trendingNoContentMessage))
)
)

View file

@ -15,9 +15,10 @@ const {
select,
option
} = require("../server/node_modules/hyperaxe");
const { renderCommentsSection: renderSharedCommentsSection, renderCommentsLink } = require("./comments_view");
const moment = require("../server/node_modules/moment");
const { template, i18n, renderOpinionsVoting, userLink, renderSpreadButton, renderEcoTax, renderLifespanChip, renderContentActions } = require("./main_views");
const { template, i18n, renderOpinionsVoting, renderEngagement, userLink, renderSpreadButton, renderEcoTax, renderLifespanChip, renderContentActions , renderSpreadEditWarning } = require("./main_views");
const { config } = require("../server/SSB_server.js");
const { renderUrl } = require("../backend/renderUrl")
const { renderMapLocationVisitLabel } = require("./maps_view");
@ -37,18 +38,6 @@ const buildReturnTo = (filter, params = {}) => {
return `/videos?${parts.join("&")}`;
};
const renderPMButton = (recipient, className = "filter-btn") => {
const r = safeText(recipient);
if (!r) return null;
if (String(r) === String(userId)) return null;
return form(
{ method: "GET", action: "/pm" },
input({ type: "hidden", name: "recipients", value: r }),
button({ type: "submit", class: className }, i18n.privateMessage)
);
};
const renderTags = (tags) => {
const list = safeArr(tags).map((t) => String(t || "").trim()).filter(Boolean);
return list.length
@ -59,25 +48,10 @@ const renderTags = (tags) => {
: null;
};
const renderVideoFavoriteToggle = (videoObj, returnTo = "") =>
form(
{
method: "POST",
action: videoObj.isFavorite
? `/videos/favorites/remove/${encodeURIComponent(videoObj.key)}`
: `/videos/favorites/add/${encodeURIComponent(videoObj.key)}`
},
returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
button(
{ type: "submit", class: "filter-btn" },
videoObj.isFavorite ? i18n.videoRemoveFavoriteButton : i18n.videoAddFavoriteButton
)
);
const renderVideoPlayer = (videoObj) =>
videoObj?.url
? div(
{ class: "video-container", style: "display:flex;gap:8px;align-items:center;flex-wrap:wrap;" },
{ class: "video-container video-container-row" },
videoHyperaxe({
controls: true,
src: `/blob/${encodeURIComponent(videoObj.url)}`,
@ -115,66 +89,11 @@ const renderVideoOwnerActions = (filter, videoObj, params = {}) => {
};
const renderVideoCommentsSection = (videoId, comments = [], returnTo = null) => {
const list = safeArr(comments).filter(c => {
const t = c && c.value && c.value.content && c.value.content.text;
return t && String(t).trim();
return renderSharedCommentsSection({
action: `/videos/${encodeURIComponent(videoId)}/comments`,
comments: comments,
returnTo: returnTo
});
const commentsCount = list.length;
return div(
{ class: "vote-comments-section" },
div(
{ class: "comments-count" },
span({ class: "card-label" }, i18n.voteCommentsLabel + ": "),
span({ class: "card-value" }, String(commentsCount))
),
div(
{ class: "comment-form-wrapper" },
h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
form(
{ method: "POST", action: `/videos/${encodeURIComponent(videoId)}/comments`, class: "comment-form", enctype: "multipart/form-data" },
returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
textarea({
id: "comment-text",
name: "text",
rows: 4,
class: "comment-textarea",
placeholder: i18n.voteNewCommentPlaceholder
}),
div({ class: "comment-file-upload" }, label(i18n.uploadMedia), input({ type: "file", name: "blob" })),
br(),
button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
)
),
list.length
? div(
{ class: "comments-list" },
list.map((c) => {
const author = c?.value?.author || "";
const ts = c?.value?.timestamp || c?.timestamp;
const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : "";
const relDate = ts ? moment(ts).fromNow() : "";
const content = c?.value?.content || {};
const rootId = content.fork || content.root || null;
const text = content.text || "";
return div(
{ class: "votations-comment-card" },
span(
{ class: "created-at" },
span(i18n.createdBy),
author ? userLink(author) : span("(unknown)"),
absDate ? span(" | ") : "",
absDate ? span({ class: "votations-comment-date" }, absDate) : "",
relDate ? span({ class: "votations-comment-date" }, " | ", i18n.sendTime) : "",
relDate && rootId ? a({ href: `/thread/${encodeURIComponent(rootId)}#${encodeURIComponent(c.key)}` }, relDate) : ""
),
p({ class: "votations-comment-text" }, ...renderUrl(text))
);
})
)
: p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet)
);
};
const renderVideoList = exports.renderVideoList = (videos, filter, params = {}) => {
@ -184,7 +103,6 @@ const renderVideoList = exports.renderVideoList = (videos, filter, params = {})
? videos.map((videoObj) => {
const commentCount = typeof videoObj.commentCount === "number" ? videoObj.commentCount : 0;
const title = safeText(videoObj.title);
const ownerActions = renderVideoOwnerActions(filter, videoObj, params);
const isOwn = videoObj.author && String(videoObj.author) === String(userId);
return div(
@ -192,49 +110,17 @@ const renderVideoList = exports.renderVideoList = (videos, filter, params = {})
div(
{ class: "card-header activity-card-header" },
span(),
renderContentActions(videoObj.key, `/videos/${encodeURIComponent(videoObj.key)}`)
renderContentActions(videoObj.key, `/videos/${encodeURIComponent(videoObj.key)}`, { spread: (params.spreadMap && params.spreadMap.get(videoObj.key)) || params.spreads || null, author: videoObj.author, favKind: 'videos', isFavorite: videoObj.isFavorite, reportTitle: videoObj.title })
),
div(
{ class: "card-section video-card-body" },
div(
{ class: "bookmark-topbar" },
div(
{ class: "bookmark-topbar-left" },
renderVideoFavoriteToggle(videoObj, returnTo),
renderPMButton(videoObj.author)
),
ownerActions.length
? div({ class: "bookmark-topbar-right" }, div({ class: "bookmark-actions" }, ...ownerActions))
: null
),
title ? h2(title) : null,
videoObj.lifetime ? div({ class: "card-chips-row" }, renderLifespanChip(videoObj.lifetime, i18n)) : null,
renderVideoPlayer(videoObj),
div(
{ class: "card-comments-summary" },
span({ class: "card-label" }, i18n.voteCommentsLabel + ":"),
span({ class: "card-value" }, String(commentCount)),
br(),
br(),
form(
{ method: "GET", action: `/videos/${encodeURIComponent(videoObj.key)}` },
input({ type: "hidden", name: "returnTo", value: returnTo }),
input({ type: "hidden", name: "filter", value: filter || "all" }),
params.q ? input({ type: "hidden", name: "q", value: params.q }) : null,
params.sort ? input({ type: "hidden", name: "sort", value: params.sort }) : null,
button({ type: "submit", class: "filter-btn" }, i18n.voteCommentsForumButton)
)
renderEngagement(videoObj.key,
renderOpinionsVoting('/videos/opinions', videoObj.key, videoObj.opinions, returnTo, videoObj.opinions_inhabitants),
renderCommentsLink({ href: `/videos/${encodeURIComponent(videoObj.key)}`, count: commentCount })
),
div(
{ class: "card-comments-summary feed-opinions-section" },
div(
{ class: "comments-count" },
span({ class: "card-label" }, (i18n.opinionsTitle || "Opinions") + ": "),
span({ class: "card-value" }, String(Object.values(videoObj.opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0)))
),
renderOpinionsVoting('/videos/opinions', videoObj.key, videoObj.opinions, returnTo, videoObj.opinions_inhabitants)
),
div({ class: "card-spread-left" }, renderSpreadButton(videoObj.key, (params.spreadMap && params.spreadMap.get(videoObj.key)) || params.spreads)),
renderMapLocationVisitLabel(videoObj.mapUrl),
br(),
(() => {
@ -244,12 +130,12 @@ const renderVideoList = exports.renderVideoList = (videos, filter, params = {})
return p(
{ class: "card-footer" },
span({ class: "date-link" }, `${moment(videoObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
span({ class: "date-link" }, `${moment(videoObj.createdAt).format("YYYY/MM/DD HH:mm")} ${i18n.performed} `),
userLink(videoObj.author),
showUpdated
? span(
{ class: "votations-comment-date" },
` | ${i18n.videoUpdatedAt}: ${moment(videoObj.updatedAt).format("YYYY/MM/DD HH:mm:ss")}`
` | ${i18n.videoUpdatedAt}: ${moment(videoObj.updatedAt).format("YYYY/MM/DD HH:mm")}`
)
: null
);
@ -265,6 +151,7 @@ const renderVideoForm = (filter, videoId, videoToEdit, params = {}) => {
return div(
{ class: "div-center video-form" },
params.spreadWarning || null,
form(
{
action: filter === "edit" ? `/videos/update/${encodeURIComponent(videoId)}` : "/videos/create",
@ -279,7 +166,7 @@ const renderVideoForm = (filter, videoId, videoToEdit, params = {}) => {
br(),
span(i18n.videoTitleLabel),
br(),
input({ type: "text", name: "title", placeholder: i18n.videoTitlePlaceholder, value: videoToEdit?.title || "" }),
input({ type: "text", name: "title", maxlength: "100", placeholder: i18n.videoTitlePlaceholder, value: videoToEdit?.title || "" }),
br(),
span(i18n.videoDescriptionLabel),
br(),
@ -305,20 +192,8 @@ const renderVideoForm = (filter, videoId, videoToEdit, params = {}) => {
};
exports.videoView = async (videos, filter = "all", videoId = null, params = {}) => {
const title =
filter === "mine"
? i18n.videoMineSectionTitle
: filter === "create"
? i18n.videoCreateSectionTitle
: filter === "edit"
? i18n.videoUpdateSectionTitle
: filter === "recent"
? i18n.videoRecentSectionTitle
: filter === "top"
? i18n.videoTopSectionTitle
: filter === "favorites"
? i18n.videoFavoritesSectionTitle
: i18n.videoAllSectionTitle;
if (filter === "edit") params = { ...params, spreadWarning: await renderSpreadEditWarning(videoId) };
const title = i18n.videoTitle;
const q = safeText(params.q || "");
const sort = safeText(params.sort || "recent");
@ -345,14 +220,14 @@ exports.videoView = async (videos, filter = "all", videoId = null, params = {})
{ method: "GET", action: "/videos", class: "ui-toolbar ui-toolbar--filters" },
input({ type: "hidden", name: "q", value: q }),
input({ type: "hidden", name: "sort", value: sort }),
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, i18n.videoFilterAll),
button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.videoFilterMine),
button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.videoFilterRecent),
button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, i18n.videoFilterTop),
button(
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, String(i18n.videoFilterAll).toUpperCase()),
button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, String(i18n.videoFilterMine).toUpperCase()),
button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, String(i18n.videoFilterRecent).toUpperCase()), button(
{ type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" },
i18n.videoFilterFavorites
String(i18n.videoFilterFavorites).toUpperCase()
),
button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, String(i18n.videoFilterTop).toUpperCase()),
button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.videoCreateButton)
)
)
@ -377,9 +252,9 @@ exports.videoView = async (videos, filter = "all", videoId = null, params = {})
{ class: "filter-box__controls" },
select(
{ name: "sort", class: "filter-box__select" },
option({ value: "recent", selected: sort === "recent" }, i18n.videoSortRecent),
option({ value: "oldest", selected: sort === "oldest" }, i18n.videoSortOldest),
option({ value: "top", selected: sort === "top" }, i18n.videoSortTop)
option({ value: "recent", ...(sort === "recent" ? { selected: true } : {})}, i18n.videoSortRecent),
option({ value: "oldest", ...(sort === "oldest" ? { selected: true } : {})}, i18n.videoSortOldest),
option({ value: "top", ...(sort === "top" ? { selected: true } : {})}, i18n.videoSortTop)
),
button({ type: "submit", class: "filter-box__button" }, i18n.videoSearchButton)
)
@ -407,14 +282,21 @@ exports.singleVideoView = async (videoObj, filter = "all", comments = [], params
const ownerActions = renderVideoOwnerActions(filter, videoObj, { q, sort });
const sideActions = [];
sideActions.push(renderVideoFavoriteToggle(videoObj, returnTo));
if (videoObj.author && String(videoObj.author) !== String(userId)) {
sideActions.push(renderPMButton(videoObj.author));
}
for (const a of ownerActions) sideActions.push(a);
const tagsNode = renderTags(videoObj.tags);
const detailActions = div({ class: "card-header activity-card-header" },
renderContentActions(videoObj.key, null, {
author: videoObj.author,
favKind: 'videos',
isFavorite: videoObj.isFavorite,
spread: params.spreads || null,
returnTo,
reportTitle: videoObj.title
})
);
const videoSide = div({ class: "tribe-side" },
div({ class: "shop-title-row" },
title ? h2({ class: "tribe-card-title" }, title) : null,
@ -425,12 +307,12 @@ exports.singleVideoView = async (videoObj, filter = "all", comments = [], params
? p({ class: "tribe-side-description" }, ...renderUrl(videoObj.description))
: null,
tagsNode,
div({ class: "card-spread-centered" }, renderSpreadButton(videoObj.key, params.spreads)),
renderMapLocationVisitLabel(videoObj.mapUrl),
sideActions.length ? div({ class: "tribe-side-actions" }, ...sideActions) : null
);
const videoMain = div({ class: "tribe-main" },
detailActions,
renderVideoPlayer(videoObj),
(() => {
const createdTs = videoObj.createdAt ? new Date(videoObj.createdAt).getTime() : NaN;
@ -439,18 +321,20 @@ exports.singleVideoView = async (videoObj, filter = "all", comments = [], params
return p(
{ class: "card-footer" },
span({ class: "date-link" }, `${moment(videoObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
span({ class: "date-link" }, `${moment(videoObj.createdAt).format("YYYY/MM/DD HH:mm")} ${i18n.performed} `),
userLink(videoObj.author),
showUpdated
? span(
{ class: "votations-comment-date" },
` | ${i18n.videoUpdatedAt}: ${moment(videoObj.updatedAt).format("YYYY/MM/DD HH:mm:ss")}`
` | ${i18n.videoUpdatedAt}: ${moment(videoObj.updatedAt).format("YYYY/MM/DD HH:mm")}`
)
: null
);
})(),
renderOpinionsVoting('/videos/opinions', videoObj.key, videoObj.opinions, returnTo, videoObj.opinions_inhabitants),
renderVideoCommentsSection(videoObj.key, comments, returnTo)
renderEngagement(videoObj.key,
renderOpinionsVoting('/videos/opinions', videoObj.key, videoObj.opinions, returnTo, videoObj.opinions_inhabitants),
renderVideoCommentsSection(videoObj.key, comments, returnTo)
)
);
return template(
@ -466,14 +350,14 @@ exports.singleVideoView = async (videoObj, filter = "all", comments = [], params
{ method: "GET", action: "/videos", class: "ui-toolbar ui-toolbar--filters" },
input({ type: "hidden", name: "q", value: q }),
input({ type: "hidden", name: "sort", value: sort }),
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, i18n.videoFilterAll),
button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.videoFilterMine),
button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.videoFilterRecent),
button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, i18n.videoFilterTop),
button(
button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, String(i18n.videoFilterAll).toUpperCase()),
button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, String(i18n.videoFilterMine).toUpperCase()),
button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, String(i18n.videoFilterRecent).toUpperCase()), button(
{ type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" },
i18n.videoFilterFavorites
String(i18n.videoFilterFavorites).toUpperCase()
),
button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, String(i18n.videoFilterTop).toUpperCase()),
button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.videoCreateButton)
)
),