diff --git a/src/client/assets/styles/mobile.css b/src/client/assets/styles/mobile.css
index e7e301d..81cc969 100644
--- a/src/client/assets/styles/mobile.css
+++ b/src/client/assets/styles/mobile.css
@@ -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; }
diff --git a/src/client/assets/translations/fork/i18n_fork.js b/src/client/assets/translations/fork/i18n_fork.js
index 2e6fe25..e5c9021 100644
--- a/src/client/assets/translations/fork/i18n_fork.js
+++ b/src/client/assets/translations/fork/i18n_fork.js
@@ -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: "ещё"
}
};
diff --git a/src/client/middleware.js b/src/client/middleware.js
index 2801b1c..aaf4139 100644
--- a/src/client/middleware.js
+++ b/src/client/middleware.js
@@ -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);
diff --git a/src/configs/oasis-config.json b/src/configs/oasis-config.json
index b07c518..3321f2d 100644
--- a/src/configs/oasis-config.json
+++ b/src/configs/oasis-config.json
@@ -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",
diff --git a/src/models/activity_model.js b/src/models/activity_model.js
index 83d8a22..9043bda 100644
--- a/src/models/activity_model.js
+++ b/src/models/activity_model.js
@@ -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);
diff --git a/src/models/blog_model.js b/src/models/blog_model.js
new file mode 100644
index 0000000..8a7df90
--- /dev/null
+++ b/src/models/blog_model.js
@@ -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)}`;
+ }
+ };
+};
diff --git a/src/models/data_model.js b/src/models/data_model.js
new file mode 100644
index 0000000..06ce6bb
--- /dev/null
+++ b/src/models/data_model.js
@@ -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
+ };
+ }
+ };
+};
diff --git a/src/models/inhabitants_model.js b/src/models/inhabitants_model.js
index 87b1a06..3dc0cda 100644
--- a/src/models/inhabitants_model.js
+++ b/src/models/inhabitants_model.js
@@ -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
};
},
diff --git a/src/models/larp_model.js b/src/models/larp_model.js
index e495d69..510b484 100644
--- a/src/models/larp_model.js
+++ b/src/models/larp_model.js
@@ -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,
diff --git a/src/models/mentions_model.js b/src/models/mentions_model.js
new file mode 100644
index 0000000..3f1054b
--- /dev/null
+++ b/src/models/mentions_model.js
@@ -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;
+ }
+ };
+};
diff --git a/src/models/projects_model.js b/src/models/projects_model.js
index 05ecf90..0602d8e 100644
--- a/src/models/projects_model.js
+++ b/src/models/projects_model.js
@@ -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 {
diff --git a/src/models/search_model.js b/src/models/search_model.js
index aed753a..ca8ee78 100644
--- a/src/models/search_model.js
+++ b/src/models/search_model.js
@@ -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',
diff --git a/src/models/tags_model.js b/src/models/tags_model.js
index d7c4fea..a6e2440 100644
--- a/src/models/tags_model.js
+++ b/src/models/tags_model.js
@@ -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);
diff --git a/src/models/trending_model.js b/src/models/trending_model.js
index e0ab387..d356c36 100644
--- a/src/models/trending_model.js
+++ b/src/models/trending_model.js
@@ -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:
diff --git a/src/models/workflows_model.js b/src/models/workflows_model.js
new file mode 100644
index 0000000..19c258c
--- /dev/null
+++ b/src/models/workflows_model.js
@@ -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
+};
diff --git a/src/views/agenda_view.js b/src/views/agenda_view.js
index f0613a4..f0112f7 100644
--- a/src/views/agenda_view.js
+++ b/src/views/agenda_view.js
@@ -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))
diff --git a/src/views/audio_view.js b/src/views/audio_view.js
index 7b4f57c..5502f7a 100644
--- a/src/views/audio_view.js
+++ b/src/views/audio_view.js
@@ -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
),
diff --git a/src/views/blockchain_view.js b/src/views/blockchain_view.js
index aa5adf5..4573ec9 100644
--- a/src/views/blockchain_view.js
+++ b/src/views/blockchain_view.js
@@ -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."
diff --git a/src/views/blog_view.js b/src/views/blog_view.js
new file mode 100644
index 0000000..fb6401c
--- /dev/null
+++ b/src/views/blog_view.js
@@ -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))
+ );
+};
diff --git a/src/views/calendars_view.js b/src/views/calendars_view.js
index 2ae1127..30a5cc4 100644
--- a/src/views/calendars_view.js
+++ b/src/views/calendars_view.js
@@ -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
diff --git a/src/views/courts_view.js b/src/views/courts_view.js
index 52e85c2..bdeab80 100644
--- a/src/views/courts_view.js
+++ b/src/views/courts_view.js
@@ -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)
+ )
)
);
diff --git a/src/views/cv_view.js b/src/views/cv_view.js
index 9725175..c64e183 100644
--- a/src/views/cv_view.js
+++ b/src/views/cv_view.js
@@ -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)
)
);
};
-
diff --git a/src/views/data_view.js b/src/views/data_view.js
new file mode 100644
index 0000000..9878c3a
--- /dev/null
+++ b/src/views/data_view.js
@@ -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)
+ )
+ );
+};
diff --git a/src/views/document_view.js b/src/views/document_view.js
index e1e98f2..70bcca7 100644
--- a/src/views/document_view.js
+++ b/src/views/document_view.js
@@ -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)
)
),
diff --git a/src/views/event_view.js b/src/views/event_view.js
index c3b29fa..6b16f48 100644
--- a/src/views/event_view.js
+++ b/src/views/event_view.js
@@ -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` : '';
diff --git a/src/views/image_view.js b/src/views/image_view.js
index 36e74e8..8071fac 100644
--- a/src/views/image_view.js
+++ b/src/views/image_view.js
@@ -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)
)
),
diff --git a/src/views/industry_view.js b/src/views/industry_view.js
index c91dcbc..c04be4d 100644
--- a/src/views/industry_view.js
+++ b/src/views/industry_view.js
@@ -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
diff --git a/src/views/jobs_view.js b/src/views/jobs_view.js
index 5b5b76b..0ea989f 100644
--- a/src/views/jobs_view.js
+++ b/src/views/jobs_view.js
@@ -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)
)
diff --git a/src/views/main_views.js b/src/views/main_views.js
index 72a7c9c..c625479 100644
--- a/src/views/main_views.js
+++ b/src/views/main_views.js
@@ -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);
diff --git a/src/views/maps_view.js b/src/views/maps_view.js
index 997555f..7f2b570 100644
--- a/src/views/maps_view.js
+++ b/src/views/maps_view.js
@@ -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(/&/g, "&");
return `${url}`;
}).replace(/\n/g, "
");
- 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: `` }),
- 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);
};
diff --git a/src/views/mentions_view.js b/src/views/mentions_view.js
new file mode 100644
index 0000000..5aa82c6
--- /dev/null
+++ b/src/views/mentions_view.js
@@ -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)
+ )
+ );
+};
diff --git a/src/views/modules_view.js b/src/views/modules_view.js
index 70cb158..606490d 100644
--- a/src/views/modules_view.js
+++ b/src/views/modules_view.js
@@ -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)
)
)
diff --git a/src/views/opinions_view.js b/src/views/opinions_view.js
index 3e0d57a..00adc8f 100644
--- a/src/views/opinions_view.js
+++ b/src/views/opinions_view.js
@@ -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)
diff --git a/src/views/parliament_view.js b/src/views/parliament_view.js
index 9ebad8c..e3e3628 100644
--- a/src/views/parliament_view.js
+++ b/src/views/parliament_view.js
@@ -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)
diff --git a/src/views/projects_view.js b/src/views/projects_view.js
index b99820f..5a46005 100644
--- a/src/views/projects_view.js
+++ b/src/views/projects_view.js
@@ -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) => {
${desc}