From 82b6c3ae059dd57601955cc4a7b3c218e860d302 Mon Sep 17 00:00:00 2001 From: SITO Date: Tue, 18 Aug 2026 22:03:41 +0200 Subject: [PATCH] upstream: integrar 0.9.5 en 34 ficheros mas y portar los modulos nuevos Segunda tanda. Resueltas por merge de tres vias 15 divergencias que no solapaban, y aplicada la version de 0.9.5 en 19 vistas y modelos donde la rama solo arrastraba codigo antiguo (firmas viejas de renderProductForm, renderJobForm, listTags sin search, y el import sin renderSpreadEditWarning). Se preserva la eliminacion de housing donde la rama lo habia descartado: modules_view, blockchain_view, search_model y tags_model. Modulos nuevos del dev portados: blog, data, gallery, mentions, polls, workflows, comments, recurrence y media_gallery, mas contentPdf, pdfDocument y content_favorites. main_views recupera renderEngagement, spreadsFor y renderSpreadEditWarning, que la rama habia eliminado y que las vistas de 0.9.5 necesitan. Sin ellas polls_view lanzaba TypeError al editar. El overlay de i18n pasa de 15 a 41 claves (46 en castellano): incorpora las 9 que upstream tenia en 0.9.1 y elimino en 0.9.5 mientras nuestro codigo las sigue usando (menuBlogs, spreadHint, chatShareUrl, forumFilterHot, publishBlog...) y las 17 de Karvan, que nunca existieron y tiraban de texto de reserva en ingles; ahora estan traducidas al castellano. Verificado con la app arrancada: declara 0.9.5, 0 claves i18n sin resolver (antes 26), 26 rutas responden 200/302, y la interfaz de la rama sale intacta: 10 categorias y 47 modulos en los hexagonos, topbar con Personal/Community y avatar, 7 accesos rapidos en la barra inferior y 0 etiquetas vacias (antes 1, la de Blogs). --- src/client/assets/styles/mobile.css | 30 +- .../assets/translations/fork/i18n_fork.js | 540 ++++++++++++++---- src/client/middleware.js | 6 +- src/configs/oasis-config.json | 8 +- src/models/activity_model.js | 49 +- src/models/blog_model.js | 168 ++++++ src/models/data_model.js | 292 ++++++++++ src/models/inhabitants_model.js | 52 +- src/models/larp_model.js | 7 +- src/models/mentions_model.js | 171 ++++++ src/models/projects_model.js | 11 +- src/models/search_model.js | 21 +- src/models/tags_model.js | 40 +- src/models/trending_model.js | 5 +- src/models/workflows_model.js | 104 ++++ src/views/agenda_view.js | 13 +- src/views/audio_view.js | 209 ++----- src/views/blockchain_view.js | 22 +- src/views/blog_view.js | 179 ++++++ src/views/calendars_view.js | 174 +++--- src/views/courts_view.js | 11 +- src/views/cv_view.js | 247 ++++---- src/views/data_view.js | 172 ++++++ src/views/document_view.js | 196 ++----- src/views/event_view.js | 232 ++++---- src/views/image_view.js | 243 ++------ src/views/industry_view.js | 95 ++- src/views/jobs_view.js | 138 ++--- src/views/main_views.js | 28 + src/views/maps_view.js | 291 +++++----- src/views/mentions_view.js | 81 +++ src/views/modules_view.js | 40 +- src/views/opinions_view.js | 42 +- src/views/parliament_view.js | 4 +- src/views/projects_view.js | 104 ++-- src/views/report_view.js | 321 ++++------- src/views/search_view.js | 55 +- src/views/settings_view.js | 195 ++++--- src/views/shops_view.js | 377 ++++++------ src/views/task_view.js | 249 ++++---- src/views/torrents_view.js | 160 ++---- src/views/trending_view.js | 39 +- src/views/video_view.js | 216 ++----- 43 files changed, 3221 insertions(+), 2416 deletions(-) create mode 100644 src/models/blog_model.js create mode 100644 src/models/data_model.js create mode 100644 src/models/mentions_model.js create mode 100644 src/models/workflows_model.js create mode 100644 src/views/blog_view.js create mode 100644 src/views/data_view.js create mode 100644 src/views/mentions_view.js 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: `map` }), - div({ class: "map-card-body" }, - div({ class: "shop-title-row" }, - mapObj.title ? h2({ class: "tribe-card-title" }, a({ href: `/maps/${encodeURIComponent(mapObj.key)}?filter=${encodeURIComponent(filter)}` }, mapObj.title)) : null - ), - chips.length ? div({ class: "card-chips-row" }, ...chips) : null, - div({ class: "map-card-header" }, - div({ class: "map-card-info" }, - span({ class: "map-coords" }, `📍 ${mapObj.lat.toFixed(4)}, ${mapObj.lng.toFixed(4)}`), - markerCount > 0 ? span({ class: "map-marker-count" }, `▾ ${markerCount}`) : null, - mapObj.key ? renderMapUrl(mapObj) : null), - div({ class: "map-card-actions" }, - renderMapFavoriteToggle(mapObj, returnTo), - renderPMButton(mapObj.author), - ...ownerActions)), - safeText(mapObj.description) ? p({ class: "map-description" }, mapObj.description) : null, - (() => { - const btn = renderSpreadButton(mapObj.key, params.spreadMap && params.spreadMap.get(mapObj.key)); - return btn ? div({ class: "card-spread-left" }, btn) : null; - })(), - p({ class: "card-footer" }, - span({ class: "date-link" }, moment(mapObj.createdAt).fromNow()), - span(" · "), - userLink(mapObj.author))))); + span(), + renderContentActions(mapObj.key, href, { spread: params.spreadMap && params.spreadMap.get(mapObj.key) || null, author: mapObj.author, favKind: 'maps', isFavorite: mapObj.isFavorite, reportTitle: mapObj.title }) + ), + div({ class: "tribe-card-image-wrapper" }, + a({ href }, img({ src: thumbSrc, class: "tribe-card-hero-image", alt: "map" })) + ), + div({ class: "tribe-card-body" }, + div({ class: "shop-title-row" }, + h2({ class: "tribe-card-title" }, a({ href }, mapObj.title || i18n.mapAllSectionTitle)) + ), + chips.length ? div({ class: "card-chips-row" }, ...chips) : null, + p({ class: "job-meta-line" }, `📍 ${mapObj.lat.toFixed(4)}, ${mapObj.lng.toFixed(4)}`), + safeText(mapObj.description) ? p({ class: "tribe-card-description" }, mapObj.description) : null, + div({ class: "tribe-card-members" }, + span({ class: "tribe-members-count" }, `${i18n.mapMarkersTitle || "Markers"}: ${markerCount}`) + ) + ) + ); }; const renderMapList = (maps, filter, params = {}) => @@ -392,13 +378,20 @@ const renderMapList = (maps, filter, params = {}) => ? maps.map((mapObj) => renderMapCard(mapObj, filter, params)) : p(params.q ? i18n.mapNoMatch : i18n.noMaps); +exports.renderMapInvitePage = (code) => { + const pageContent = div({ class: "invite-page" }, + h2(i18n.tribeInviteCodeText, code), + form({ method: "GET", action: "/maps" }, + input({ type: "hidden", name: "filter", value: "all" }), + button({ type: "submit", class: "filter-btn" }, i18n.walletBack) + ) + ); + return template(i18n.mapInviteMode || i18n.tribeInviteCodeText || "Invite", section(pageContent)); +}; + exports.mapsView = async (maps, filter = "all", mapId = null, params = {}) => { - const title = filter === "mine" ? i18n.mapMineSectionTitle - : filter === "create" ? i18n.mapCreateSectionTitle - : filter === "edit" ? i18n.mapUpdateSectionTitle - : filter === "recent" ? i18n.mapRecentSectionTitle - : filter === "favorites" ? i18n.mapFavoritesSectionTitle - : i18n.mapAllSectionTitle; + if (filter === "edit") params = { ...params, spreadWarning: await renderSpreadEditWarning(mapId) }; + const title = i18n.mapTitle; const q = safeText(params.q || ""); const list = safeArr(maps); @@ -418,7 +411,7 @@ exports.mapsView = async (maps, filter = "all", mapId = null, params = {}) => { input({ type: "hidden", name: "filter", value: filter }), input({ type: "text", name: "q", value: q, placeholder: i18n.mapSearchPlaceholder, class: "filter-box__input" }), div({ class: "filter-box__controls" }, button({ type: "submit", class: "filter-box__button" }, i18n.mapSearchButton)))), - div({ class: "maps-list" }, renderMapList(list, filter, { q }))))); + div({ class: "jobs-grid" }, renderMapList(list, filter, { q }))))); }; exports.singleMapView = async (mapObj, filter = "all", params = {}) => { @@ -426,7 +419,7 @@ exports.singleMapView = async (mapObj, filter = "all", params = {}) => { const returnTo = safeText(params.returnTo) || buildReturnTo(filter, { q }); const ownerActions = renderMapOwnerActions(filter, mapObj, { q }); const tribeMembers = safeArr(params.tribeMembers); - const zoomVal = parseInt(params.zoom) || 2; + const zoomVal = parseInt(params.zoom) || 8; const allMarkers = [{ lat: mapObj.lat, lng: mapObj.lng }].concat( safeArr(mapObj.markers).map((m) => ({ lat: m.lat, lng: m.lng }))); @@ -435,55 +428,80 @@ exports.singleMapView = async (mapObj, filter = "all", params = {}) => { safeArr(mapObj.markers).map((m) => m.label || "")); const pinImages = [mapObj.image || ""].concat(safeArr(mapObj.markers).map((m) => m.image || "")); + const mapSide = div({ class: "tribe-side" }, + div({ class: "card-header activity-card-header" }, + renderContentActions(mapObj.key, null, { + author: mapObj.author, + favKind: 'maps', + isFavorite: mapObj.isFavorite, + spread: params.spreads || null, + returnTo, + reportTitle: mapObj.title + }) + ), + div({ class: "shop-title-row" }, + h2({ class: "tribe-card-title" }, mapObj.title || i18n.mapAllSectionTitle) + ), + div({ class: "card-chips-row" }, + renderStateChip("whole", "🗺", String(mapObj.mapType || "").toUpperCase()), + renderEncryptedChip(i18n), + renderLifespanChip(mapObj.lifetime, i18n) + ), + safeText(mapObj.description) ? p({ class: "tribe-side-description" }, mapObj.description) : null, + table({ class: "tribe-info-table jobs-info-table" }, + tr( + td({ class: "tribe-info-label" }, i18n.mapLatLabel), + td({ class: "tribe-info-value", colspan: "3" }, mapObj.lat.toFixed(6)) + ), + tr( + td({ class: "tribe-info-label" }, i18n.mapLngLabel), + td({ class: "tribe-info-value", colspan: "3" }, mapObj.lng.toFixed(6)) + ), + tr( + td({ class: "tribe-info-label" }, i18n.createdAtLabel || "Created at"), + td({ class: "tribe-info-value", colspan: "3" }, moment(mapObj.createdAt).format("YYYY/MM/DD HH:mm")) + ), + tr( + td({ class: "tribe-info-value", colspan: "4" }, userLink(mapObj.author)) + ) + ), + renderTags(mapObj.tags), + div({ class: "tribe-card-members" }, + span({ class: "tribe-members-count" }, `${i18n.mapMarkersTitle || "Markers"}: ${safeArr(mapObj.markers).length + 1}`) + ), + (String(mapObj.author) !== String(userId) && !mapObj.tribeId && mapObj.mapType !== "OPEN") + ? div({ class: "tribe-side-actions" }, + a({ class: "tribe-action-btn", href: "/invites#invites-maps" }, i18n.tribeEnterInvite) + ) + : null, + ownerActions.length ? div({ class: "tribe-side-actions owner-actions" }, ...ownerActions) : null + ); + + const mapMain = div({ class: "tribe-main" }, + renderMapUrl(mapObj), + renderMap(allMarkers, null, 0, { pinLabels, pinImages, pinPrefix: `detail${areaCounter}`, zoom: zoomVal, centerLat: parseFloat(mapObj.lat) || 0, centerLng: parseFloat(mapObj.lng) || 0 }), + form({ method: "GET", action: `/maps/${encodeURIComponent(mapObj.key)}` }, + label(i18n.mapZoomLabel || "Zoom"), + br(), + select({ name: "zoom" }, + [2, 3, 4, 5, 6, 7, 8].map(z => + option({ value: String(z), ...(zoomVal === z ? { selected: true } : {}) }, String(z)))), + br(), br(), + button({ type: "submit", class: "filter-btn" }, i18n.mapApplyZoom || "Apply Zoom")), + renderMarkersList(mapObj.markers, mapObj), + p({ class: "card-footer" }, + span({ class: "date-link" }, `${moment(mapObj.createdAt).format("YYYY/MM/DD HH:mm")} ${i18n.performed} `), + userLink(mapObj.author), + mapObj.updatedAt && mapObj.updatedAt !== mapObj.createdAt + ? span({ class: "votations-comment-date" }, ` · ${i18n.mapUpdatedAt}: ${moment(mapObj.updatedAt).format("YYYY/MM/DD HH:mm")}`) + : null), + renderMarkerForm(mapObj, returnTo, params, tribeMembers) + ); + return template(mapObj.title || i18n.mapTitle, + section(div({ class: "tags-header" }, h2(i18n.mapTitle), p(i18n.mapDescription))), section(renderFilters(filter, q)), - section( - div({ class: "map-detail" }, - mapObj.title ? div({ class: "shop-title-row" }, - h2({ class: "tribe-card-title" }, mapObj.title) - ) : null, - mapObj.title ? div({ class: "card-chips-row" }, - renderEncryptedChip(i18n), - renderLifespanChip(mapObj.lifetime, i18n) - ) : null, - safeText(mapObj.description) ? p({ class: "map-description" }, mapObj.description) : null, - div({ class: "map-detail-header" }, - div({ class: "map-detail-info" }, - span({ class: "map-type-badge" }, mapObj.mapType), - span({ class: "map-coords-detail" }, `📍 ${mapObj.lat.toFixed(6)}, ${mapObj.lng.toFixed(6)}`)), - div({ class: "map-detail-actions" }, - renderMapFavoriteToggle(mapObj, returnTo), - renderPMButton(mapObj.author), - (String(mapObj.author) !== String(userId) && !mapObj.tribeId && mapObj.mapType !== "OPEN") - ? a({ class: "tribe-action-btn", href: "/invites#invites-maps" }, i18n.tribeEnterInvite) - : null, - ...ownerActions)), - renderMapUrl(mapObj), - br(), - form({ method: "GET", action: `/maps/${encodeURIComponent(mapObj.key)}` }, - label(i18n.mapZoomLabel || "Zoom"), - br(), - select({ name: "zoom" }, - [2, 3, 4, 5, 6, 7, 8].map(z => - option({ value: String(z), ...(zoomVal === z ? { selected: true } : {}) }, String(z)))), - br(), br(), - button({ type: "submit", class: "filter-btn" }, i18n.mapApplyZoom || "Apply Zoom")), - br(), - renderMap(allMarkers, null, 0, { pinLabels, pinImages, pinPrefix: `detail${areaCounter}`, zoom: zoomVal, centerLat: parseFloat(mapObj.lat) || 0, centerLng: parseFloat(mapObj.lng) || 0 }), - renderMarkersList(mapObj.markers, mapObj), - renderTags(mapObj.tags), - (() => { - const btn = renderSpreadButton(mapObj.key); - return btn ? div({ class: "card-spread-centered" }, btn) : null; - })(), - br(), - p({ class: "card-footer" }, - span({ class: "date-link" }, `${moment(mapObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `), - userLink(mapObj.author), - mapObj.updatedAt && mapObj.updatedAt !== mapObj.createdAt - ? span({ class: "votations-comment-date" }, ` · ${i18n.mapUpdatedAt}: ${moment(mapObj.updatedAt).format("YYYY/MM/DD HH:mm:ss")}`) - : null), - renderMarkerForm(mapObj, returnTo, params, tribeMembers)))); + section(div({ class: "tribe-details" }, mapSide, mapMain))); }; exports.renderMapLocationUrl = (mapUrl) => { @@ -520,6 +538,7 @@ exports.renderMapEmbedWithZoom = (mapData, mapUrl, detailUrl, zoom) => { const lo = parseFloat(mapData.lng) || 0; return div({ class: "map-embed-section" }, span({ class: "card-label" }, (i18n.mapLocationTitle || "Map Location") + ":"), + renderMap([{ lat: la, lng: lo }], null, 0, { zoom: zoomVal, centerLat: la, centerLng: lo }), form({ method: "GET", action: detailUrl }, label(i18n.mapZoomLabel || "Zoom"), br(), @@ -528,8 +547,6 @@ exports.renderMapEmbedWithZoom = (mapData, mapUrl, detailUrl, zoom) => { option({ value: String(z), ...(zoomVal === z ? { selected: true } : {}) }, String(z)))), br(), br(), button({ type: "submit", class: "filter-btn" }, i18n.mapApplyZoom || "Apply Zoom")), - br(), - renderMap([{ lat: la, lng: lo }], null, 0, { zoom: zoomVal, centerLat: la, centerLng: lo }), mapUrl ? div({ class: "map-embed-url" }, a({ href: mapUrl, class: "map-location-link" }, mapUrl)) : null); }; 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) => {
Funding
${pledged.toFixed(2)} / ${goal.toFixed(2)} ECO · ${fundingPct}%
-
+
${deadline ? `
Deadline: ${deadline}
` : ''}
` : ''} ${desc ? `

Description

${desc}

` : ''} diff --git a/src/views/report_view.js b/src/views/report_view.js index f86b55a..bdabbed 100644 --- a/src/views/report_view.js +++ b/src/views/report_view.js @@ -1,5 +1,7 @@ const { div, h2, p, section, button, form, a, textarea, br, input, img, span, label, select, option, video, audio, table, tr, td } = require("../server/node_modules/hyperaxe"); -const { template, i18n, renderOpinionsVoting, userLink, renderStateChip, renderLifespanChip, renderEcoTax, renderSpreadButton } = require("./main_views"); +const { renderCommentsSection: renderSharedCommentsSection } = require("./comments_view"); +const { template, i18n, renderOpinionsVoting, renderEngagement, userLink, renderStateChip, renderLifespanChip, renderEcoTax, renderSpreadButton, renderSpreadEditWarning, renderContentActions, renderDocumentActions } = require("./main_views"); +const { renderPhotoGallery, renderGalleryFields } = require("./gallery_view"); const { config } = require("../server/SSB_server.js"); const moment = require("../server/node_modules/moment"); const { renderUrl } = require("../backend/renderUrl"); @@ -39,7 +41,7 @@ const STATUS_BY_FILTER = { }; const opt = (value, isSelected, text) => - option(Object.assign({ value }, isSelected ? { selected: "selected" } : {}), text); + option(Object.assign({ value }, isSelected ? { ...("selected" ? { selected: true } : {})} : {}), text); const hasAnyTemplateValue = (t) => { if (!t || typeof t !== "object") return false; @@ -62,15 +64,6 @@ const renderStackedTextField = (lbl, val) => ) : null; -const renderPmButton = (recipientId) => - recipientId && String(recipientId) !== String(userId) - ? form( - { method: "GET", action: "/pm" }, - input({ type: "hidden", name: "recipients", value: recipientId }), - button({ type: "submit", class: "filter-btn" }, i18n.privateMessage) - ) - : null; - const renderReportStatusSetter = (report) => { const st = normalizeStatus(report && report.status ? report.status : "OPEN"); return form( @@ -135,7 +128,6 @@ const renderTemplateDetails = (report) => { h2({ class: "report-template-title" }, i18n.reportsContentTemplateTitle), renderStackedTextField(i18n.reportsContentLocationLabel + ":", t.contentLocation), renderStackedTextField(i18n.reportsWhyInappropriateLabel + ":", t.whyInappropriate), - renderStackedTextField(i18n.reportsRequestedActionLabel + ":", t.requestedAction), renderStackedTextField(i18n.reportsEvidenceLinksLabel + ":", t.evidenceLinks) ); } @@ -144,73 +136,11 @@ const renderTemplateDetails = (report) => { }; const renderReportCommentsSection = (reportId, comments = []) => { - const commentsCount = Array.isArray(comments) ? comments.length : 0; - - return div( - { class: "vote-comments-section" }, - div( - { class: "comments-count" }, - span({ class: "card-label" }, i18n.voteCommentsLabel + ": "), - span({ class: "card-value" }, String(commentsCount)) - ), - div( - { class: "comment-form-wrapper" }, - h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel), - form( - { - method: "POST", - action: `/reports/${encodeURIComponent(reportId)}/comments`, - class: "comment-form", - enctype: "multipart/form-data" - }, - textarea({ - id: "comment-text", - name: "text", - rows: 4, - class: "comment-textarea", - placeholder: i18n.voteNewCommentPlaceholder - }), - div({ class: "comment-file-upload" }, label(i18n.uploadMedia), input({ type: "file", name: "blob" })), - br(), - button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton) - ) - ), - (() => { - const visibleComments = (comments || []).filter(c => { - const t = c && c.value && c.value.content && c.value.content.text; - return t && String(t).trim(); - }); - return visibleComments.length - ? div( - { class: "comments-list" }, - visibleComments.map((c) => { - const author = c.value && c.value.author ? c.value.author : ""; - const ts = c.value && c.value.timestamp ? c.value.timestamp : c.timestamp; - const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : ""; - const relDate = ts ? moment(ts).fromNow() : ""; - - const content = c.value && c.value.content ? c.value.content : {}; - const root = content.fork || content.root || ""; - const text = content.text || ""; - - return div( - { class: "votations-comment-card" }, - span( - { class: "created-at" }, - span(i18n.createdBy), - author ? userLink(author) : span("(unknown)"), - absDate ? span(" | ") : "", - absDate ? span({ class: "votations-comment-date" }, absDate) : "", - relDate ? span({ class: "votations-comment-date" }, " | ", i18n.sendTime) : "", - relDate && root ? a({ href: `/thread/${encodeURIComponent(root)}#${encodeURIComponent(c.key)}` }, relDate) : "" - ), - p({ class: "votations-comment-text" }, ...renderUrl(text)) - ); - }) - ) - : p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet); - })() - ); + return renderSharedCommentsSection({ + action: `/reports/${encodeURIComponent(reportId)}/comments`, + comments: comments, + returnTo: null + }); }; const renderTemplateForCategory = (category, templateData = {}) => { @@ -257,26 +187,6 @@ const renderTemplateForCategory = (category, templateData = {}) => { ); } - if (cat === "ABUSE") { - return div( - { class: "report-template-block" }, - h2({ class: "report-template-title" }, i18n.reportsAbuseTemplateTitle), - label(i18n.reportsWhatHappenedLabel), - br(), - textarea({ name: "whatHappened", rows: "4", placeholder: i18n.reportsWhatHappenedPlaceholder }, tval("whatHappened")), - br(), - br(), - label(i18n.reportsReportedUserLabel), - br(), - textarea({ name: "reportedUser", rows: "2", placeholder: i18n.reportsReportedUserPlaceholder }, tval("reportedUser")), - br(), - br(), - label(i18n.reportsEvidenceLinksLabel), - br(), - textarea({ name: "evidenceLinks", rows: "3", placeholder: i18n.reportsEvidenceLinksPlaceholder }, tval("evidenceLinks")) - ); - } - if (cat === "CONTENT") { return div( { class: "report-template-block" }, @@ -291,11 +201,6 @@ const renderTemplateForCategory = (category, templateData = {}) => { textarea({ name: "whyInappropriate", rows: "4", placeholder: i18n.reportsWhyInappropriatePlaceholder }, tval("whyInappropriate")), br(), br(), - label(i18n.reportsRequestedActionLabel), - br(), - textarea({ name: "requestedAction", rows: "3", placeholder: i18n.reportsRequestedActionPlaceholder }, tval("requestedAction")), - br(), - br(), label(i18n.reportsEvidenceLinksLabel), br(), textarea({ name: "evidenceLinks", rows: "3", placeholder: i18n.reportsEvidenceLinksPlaceholder }, tval("evidenceLinks")) @@ -377,6 +282,10 @@ const renderReportCard = (report, userId, currentFilter = "all", spreadInfo) => ].filter(Boolean); return div({ class: "tribe-card report-card" }, + div({ class: "card-header activity-card-header" }, + span(), + renderContentActions(report.id, `/reports/${encodeURIComponent(report.id)}`, { spread: spreadInfo || null, author: report.author, favKind: 'reports', isFavorite: report.isFavorite, reportTitle: report.title, report: false }) + ), div({ class: "tribe-card-body" }, div({ class: "shop-title-row" }, h2({ class: "tribe-card-title" }, @@ -386,33 +295,13 @@ const renderReportCard = (report, userId, currentFilter = "all", spreadInfo) => chips.length ? div({ class: "card-chips-row" }, ...chips) : null, div({ class: "tribe-card-members" }, span({ class: "tribe-members-count" }, `${i18n.reportsConfirmations}: ${confirmations.length}`) - ), - div({ class: "card-spread-centered" }, renderSpreadButton(report.id, spreadInfo)), - div({ class: "card-visit-btn-centered" }, - form({ method: "GET", action: `/reports/${encodeURIComponent(report.id)}` }, - input({ type: "hidden", name: "filter", value: currentFilter }), - button({ type: "submit", class: "filter-btn" }, i18n.viewReport || "View Report") - ) ) ) ); }; exports.reportView = async (reports, filter, reportId, createCategory, params = {}) => { - const title = - filter === "create" ? i18n.reportsCreateButton : - filter === "edit" ? i18n.reportsUpdateButton : - filter === "mine" ? i18n.reportsMineSectionTitle : - filter === "features" ? i18n.reportsFeaturesSectionTitle : - filter === "bugs" ? i18n.reportsBugsSectionTitle : - filter === "abuse" ? i18n.reportsAbuseSectionTitle : - filter === "content" ? i18n.reportsContentSectionTitle : - filter === "confirmed" ? i18n.reportsConfirmedSectionTitle : - filter === "open" ? i18n.reportsOpenSectionTitle : - filter === "under_review" ? i18n.reportsUnderReviewSectionTitle : - filter === "resolved" ? i18n.reportsResolvedSectionTitle : - filter === "invalid" ? i18n.reportsInvalidSectionTitle : - i18n.reportsAllSectionTitle; + const title = i18n.reportsTitle; let filtered = Array.isArray(reports) ? reports : []; @@ -433,18 +322,20 @@ exports.reportView = async (reports, filter, reportId, createCategory, params = const reportToEdit = filter === "edit" ? (Array.isArray(reports) ? reports.find((r) => r.id === reportId) : null) : null; + const formData = reportToEdit || params.draft || {}; const btnClass = (v) => (filter === v ? "filter-btn active" : "filter-btn"); - const selectedCategory = normU( + const rawCategory = normU( filter === "create" ? (createCategory || "FEATURES") : (reportToEdit?.category || "FEATURES") ); + const selectedCategory = ["FEATURES", "BUGS", "CONTENT"].includes(rawCategory) ? rawCategory : "FEATURES"; const selectedTemplate = reportToEdit?.template && typeof reportToEdit.template === "object" ? reportToEdit.template : {}; const applyLabel = i18n.apply || "Apply"; - const sev = String(reportToEdit?.severity || "low"); + const sev = String(formData.severity || "low"); const hiddenDescription = String(reportToEdit?.description || ""); return template( @@ -458,31 +349,51 @@ exports.reportView = async (reports, filter, reportId, createCategory, params = div( { class: "filters" }, form( - { method: "GET", action: "/reports" }, - button({ type: "submit", name: "filter", value: "all", class: btnClass("all") }, i18n.reportsFilterAll), - button({ type: "submit", name: "filter", value: "mine", class: btnClass("mine") }, i18n.reportsFilterMine), - button({ type: "submit", name: "filter", value: "features", class: btnClass("features") }, i18n.reportsFilterFeatures), - button({ type: "submit", name: "filter", value: "bugs", class: btnClass("bugs") }, i18n.reportsFilterBugs), - button({ type: "submit", name: "filter", value: "abuse", class: btnClass("abuse") }, i18n.reportsFilterAbuse), - button({ type: "submit", name: "filter", value: "content", class: btnClass("content") }, i18n.reportsFilterContent), - button({ type: "submit", name: "filter", value: "confirmed", class: btnClass("confirmed") }, i18n.reportsFilterConfirmed), - button({ type: "submit", name: "filter", value: "open", class: btnClass("open") }, i18n.reportsFilterOpen), - button({ type: "submit", name: "filter", value: "under_review", class: btnClass("under_review") }, i18n.reportsFilterUnderReview), - button({ type: "submit", name: "filter", value: "resolved", class: btnClass("resolved") }, i18n.reportsFilterResolved), - button({ type: "submit", name: "filter", value: "invalid", class: btnClass("invalid") }, i18n.reportsFilterInvalid), + { method: "GET", action: "/reports", class: "ui-toolbar ui-toolbar--filters" }, + button({ type: "submit", name: "filter", value: "all", class: btnClass("all") }, String(i18n.reportsFilterAll).toUpperCase()), + button({ type: "submit", name: "filter", value: "mine", class: btnClass("mine") }, String(i18n.reportsFilterMine).toUpperCase()), + button({ type: "submit", name: "filter", value: "recent", class: btnClass("recent") }, String(i18n.reportsFilterRecent).toUpperCase()), + button({ type: "submit", name: "filter", value: "top", class: btnClass("top") }, String(i18n.reportsFilterTop).toUpperCase()), + button({ type: "submit", name: "filter", value: "features", class: btnClass("features") }, String(i18n.reportsFilterFeatures).toUpperCase()), + button({ type: "submit", name: "filter", value: "bugs", class: btnClass("bugs") }, String(i18n.reportsFilterBugs).toUpperCase()), + button({ type: "submit", name: "filter", value: "content", class: btnClass("content") }, String(i18n.reportsFilterContent).toUpperCase()), + button({ type: "submit", name: "filter", value: "confirmed", class: btnClass("confirmed") }, String(i18n.reportsFilterConfirmed).toUpperCase()), button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.reportsCreateButton) + ), + form( + { method: "GET", action: "/reports", class: "ui-toolbar ui-toolbar--filters reports-subfilters" }, + button({ type: "submit", name: "filter", value: "open", class: btnClass("open") }, String(i18n.reportsFilterOpen).toUpperCase()), + button({ type: "submit", name: "filter", value: "under_review", class: btnClass("under_review") }, String(i18n.reportsFilterUnderReview).toUpperCase()), + button({ type: "submit", name: "filter", value: "resolved", class: btnClass("resolved") }, String(i18n.reportsFilterResolved).toUpperCase()), + button({ type: "submit", name: "filter", value: "invalid", class: btnClass("invalid") }, String(i18n.reportsFilterInvalid).toUpperCase()), + button({ type: "submit", name: "filter", value: "sev_low", class: btnClass("sev_low") }, String(i18n.reportsSeverityLow).toUpperCase()), + button({ type: "submit", name: "filter", value: "sev_medium", class: btnClass("sev_medium") }, String(i18n.reportsSeverityMedium).toUpperCase()), + button({ type: "submit", name: "filter", value: "sev_high", class: btnClass("sev_high") }, String(i18n.reportsSeverityHigh).toUpperCase()), + button({ type: "submit", name: "filter", value: "sev_critical", class: btnClass("sev_critical") }, String(i18n.reportsSeverityCritical).toUpperCase()) ) - ) + ), + filter === "edit" || filter === "create" + ? null + : div({ class: "filters" }, + form({ method: "GET", action: "/reports", class: "filter-box" }, + input({ type: "hidden", name: "filter", value: filter }), + input({ type: "text", name: "q", value: params.q || "", placeholder: i18n.reportsSearchPlaceholder, class: "filter-box__input" }), + div({ class: "filter-box__controls" }, + button({ type: "submit", class: "filter-box__button" }, i18n.searchButton) + ) + ) + ) ), section( filter === "edit" || filter === "create" ? div( { class: "report-form" }, + filter === "edit" ? await renderSpreadEditWarning(reportToEdit && (reportToEdit.id || reportToEdit.key)) : null, filter === "create" ? div( label(i18n.reportsTitleLabel), br(), - input({ type: "text", name: "title", required: true, value: params.prefillTitle || "", form: "report-create-form" }), + input({ type: "text", name: "title", maxlength: "100", required: true, value: params.prefillTitle || "", form: "report-create-form" }), br(), br(), form( @@ -491,10 +402,9 @@ exports.reportView = async (reports, filter, reportId, createCategory, params = label(i18n.reportsCategory), br(), select( - { name: "category" }, + { name: "category", class: "report-category-select" }, opt("FEATURES", selectedCategory === "FEATURES", i18n.reportsCategoryFeatures), opt("BUGS", selectedCategory === "BUGS", i18n.reportsCategoryBugs), - opt("ABUSE", selectedCategory === "ABUSE", i18n.reportsCategoryAbuse), opt("CONTENT", selectedCategory === "CONTENT", i18n.reportsCategoryContent) ), br(), @@ -508,6 +418,15 @@ exports.reportView = async (reports, filter, reportId, createCategory, params = { id: "report-create-form", action: "/reports/create", method: "POST", enctype: "multipart/form-data" }, input({ type: "hidden", name: "category", value: selectedCategory }), input({ type: "hidden", name: "description", value: "" }), + h2({ class: "report-template-main-title" }, i18n.reportsTemplateSectionTitle), + renderTemplateForCategory(selectedCategory, params.draft || {}), + ...renderGalleryFields(formData, false, 8), + br(), + label("Tags"), + br(), + input({ type: "text", name: "tags", value: Array.isArray(formData.tags) ? formData.tags.join(", ") : (formData.tags || "") }), + br(), + br(), label(i18n.reportsSeverity), br(), select( @@ -519,18 +438,6 @@ exports.reportView = async (reports, filter, reportId, createCategory, params = ), br(), br(), - h2({ class: "report-template-main-title" }, i18n.reportsTemplateSectionTitle), - renderTemplateForCategory(selectedCategory, {}), - label(i18n.reportsUploadFile), - br(), - input({ type: "file", name: "image" }), - br(), - br(), - label("Tags"), - br(), - input({ type: "text", name: "tags", value: "" }), - br(), - br(), button({ type: "submit", class: "create-button" }, i18n.reportsCreateButton) ) ) @@ -539,7 +446,7 @@ exports.reportView = async (reports, filter, reportId, createCategory, params = { id: "report-edit-form", action: `/reports/update/${encodeURIComponent(reportId)}`, method: "POST", enctype: "multipart/form-data" }, label(i18n.reportsTitleLabel), br(), - input({ type: "text", name: "title", required: true, value: reportToEdit?.title || "" }), + input({ type: "text", name: "title", maxlength: "100", required: true, value: reportToEdit?.title || "" }), br(), br(), input({ type: "hidden", name: "description", value: hiddenDescription }), @@ -549,11 +456,21 @@ exports.reportView = async (reports, filter, reportId, createCategory, params = { name: "category", required: true }, opt("FEATURES", selectedCategory === "FEATURES", i18n.reportsCategoryFeatures), opt("BUGS", selectedCategory === "BUGS", i18n.reportsCategoryBugs), - opt("ABUSE", selectedCategory === "ABUSE", i18n.reportsCategoryAbuse), opt("CONTENT", selectedCategory === "CONTENT", i18n.reportsCategoryContent) ), br(), br(), + h2({ class: "report-template-main-title" }, i18n.reportsTemplateSectionTitle), + renderTemplateForCategory(selectedCategory, selectedTemplate), + br(), + br(), + ...renderGalleryFields(reportToEdit || {}, true, 8), + br(), + label("Tags"), + br(), + input({ type: "text", name: "tags", value: reportToEdit?.tags?.join(", ") || "" }), + br(), + br(), label(i18n.reportsSeverity), br(), select( @@ -565,20 +482,6 @@ exports.reportView = async (reports, filter, reportId, createCategory, params = ), br(), br(), - h2({ class: "report-template-main-title" }, i18n.reportsTemplateSectionTitle), - renderTemplateForCategory(selectedCategory, selectedTemplate), - br(), - br(), - label(i18n.reportsUploadFile), - br(), - input({ type: "file", name: "image" }), - br(), - br(), - label("Tags"), - br(), - input({ type: "text", name: "tags", value: reportToEdit?.tags?.join(", ") || "" }), - br(), - br(), button({ type: "submit" }, i18n.reportsUpdateButton) ) ) @@ -605,21 +508,16 @@ exports.singleReportView = async (report, filter, comments = [], params = {}) => ].filter(Boolean); const sideActions = []; - const pm = renderPmButton(report.author); - if (pm) sideActions.push(pm); sideActions.push(form({ method: "POST", action: `/reports/confirm/${encodeURIComponent(report.id)}` }, - button({ type: "submit", class: "filter-btn" }, i18n.reportsConfirmButton) - )); - sideActions.push(a({ href: "/tasks?filter=create", target: "_blank" }, - button({ type: "button", class: "filter-btn" }, i18n.reportsCreateTaskButton) + button({ type: "submit", class: "tribe-action-btn" }, i18n.reportsConfirmButton) )); + const ownerActions = []; if (isAuthor) { - sideActions.push(renderReportStatusSetter(report)); - sideActions.push(form({ method: "GET", action: `/reports/edit/${encodeURIComponent(report.id)}` }, - button({ type: "submit", class: "update-btn" }, i18n.reportsUpdateButton) + ownerActions.push(form({ method: "GET", action: `/reports/edit/${encodeURIComponent(report.id)}` }, + button({ type: "submit", class: "tribe-action-btn" }, i18n.reportsUpdateButton) )); - sideActions.push(form({ method: "POST", action: `/reports/delete/${encodeURIComponent(report.id)}` }, - button({ type: "submit", class: "delete-btn" }, i18n.reportsDeleteButton) + ownerActions.push(form({ method: "POST", action: `/reports/delete/${encodeURIComponent(report.id)}` }, + button({ type: "submit", class: "tribe-action-btn danger-btn" }, i18n.reportsDeleteButton) )); } @@ -640,53 +538,72 @@ exports.singleReportView = async (report, filter, comments = [], params = {}) => pushRow(i18n.reportsCategory, report.category); const reportSide = div({ class: "tribe-side" }, + div({ class: "card-header activity-card-header" }, + renderContentActions(report.id, null, { spread: params.spreads || null, author: report.author, favKind: 'reports', isFavorite: report.isFavorite, reportTitle: report.title, report: false }) + ), div({ class: "shop-title-row" }, h2({ class: "tribe-card-title" }, report.title) ), chips.length ? div({ class: "card-chips-row" }, ...chips) : null, - div({ class: "card-spread-centered" }, renderSpreadButton(report.id, params.spreads)), table({ class: "tribe-info-table jobs-info-table" }, ...infoRows), tagsNode, div({ class: "tribe-card-members" }, span({ class: "tribe-members-count" }, `${i18n.reportsConfirmations}: ${confirmations.length}`) ), - sideActions.length ? div({ class: "tribe-side-actions" }, ...sideActions) : null + sideActions.length ? div({ class: "tribe-side-actions" }, ...sideActions) : null, + isAuthor + ? div({ class: "tribe-side-actions housing-status-row" }, + span({ class: "card-label" }, `${i18n.reportsStatus}: `), + renderStateChip(normalizeStatus(report.status) === "RESOLVED" ? "mutuals" : "whole", "◆", String(report.status || "OPEN").toUpperCase()), + renderReportStatusSetter(report) + ) + : null, + ownerActions.length ? div({ class: "tribe-side-actions owner-actions" }, ...ownerActions) : null, + renderDocumentActions('reports', report.id, [ + a({ href: "/tasks?filter=create", class: "filter-btn" }, i18n.reportsCreateTaskButton) + ]) ); const opinionsBar = renderOpinionsVoting('/reports/opinions', report.id, report.opinions, null, report.opinions_inhabitants); const reportMain = div({ class: "tribe-main" }, - (details || report.image) ? div({ class: "job-section" }, - details || null, - report.image ? renderMediaBlob(report.image, { class: "report-detail-image" }) : null - ) : null, + details ? div({ class: "job-section" }, details) : null, + renderPhotoGallery(report, 'report'), p({ class: "card-footer" }, span({ class: "date-link" }, `${moment(report.createdAt).format("YYYY/MM/DD HH:mm")} ${i18n.performed} `), userLink(report.author) ), - opinionsBar, - renderReportCommentsSection(report.id, comments) + renderEngagement(report.id, opinionsBar, renderReportCommentsSection(report.id, comments)) ); return template( report.title, section( + div({ class: "tags-header" }, h2(i18n.reportsTitle), p(i18n.reportsDescription)), div( { class: "filters" }, form( - { method: "GET", action: "/reports" }, - button({ type: "submit", name: "filter", value: "all", class: btnClass("all") }, i18n.reportsFilterAll), - button({ type: "submit", name: "filter", value: "mine", class: btnClass("mine") }, i18n.reportsFilterMine), - button({ type: "submit", name: "filter", value: "features", class: btnClass("features") }, i18n.reportsFilterFeatures), - button({ type: "submit", name: "filter", value: "bugs", class: btnClass("bugs") }, i18n.reportsFilterBugs), - button({ type: "submit", name: "filter", value: "abuse", class: btnClass("abuse") }, i18n.reportsFilterAbuse), - button({ type: "submit", name: "filter", value: "content", class: btnClass("content") }, i18n.reportsFilterContent), - button({ type: "submit", name: "filter", value: "confirmed", class: btnClass("confirmed") }, i18n.reportsFilterConfirmed), - button({ type: "submit", name: "filter", value: "open", class: btnClass("open") }, i18n.reportsFilterOpen), - button({ type: "submit", name: "filter", value: "under_review", class: btnClass("under_review") }, i18n.reportsFilterUnderReview), - button({ type: "submit", name: "filter", value: "resolved", class: btnClass("resolved") }, i18n.reportsFilterResolved), - button({ type: "submit", name: "filter", value: "invalid", class: btnClass("invalid") }, i18n.reportsFilterInvalid), + { method: "GET", action: "/reports", class: "ui-toolbar ui-toolbar--filters" }, + button({ type: "submit", name: "filter", value: "all", class: btnClass("all") }, String(i18n.reportsFilterAll).toUpperCase()), + button({ type: "submit", name: "filter", value: "mine", class: btnClass("mine") }, String(i18n.reportsFilterMine).toUpperCase()), + button({ type: "submit", name: "filter", value: "recent", class: btnClass("recent") }, String(i18n.reportsFilterRecent).toUpperCase()), + button({ type: "submit", name: "filter", value: "top", class: btnClass("top") }, String(i18n.reportsFilterTop).toUpperCase()), + button({ type: "submit", name: "filter", value: "features", class: btnClass("features") }, String(i18n.reportsFilterFeatures).toUpperCase()), + button({ type: "submit", name: "filter", value: "bugs", class: btnClass("bugs") }, String(i18n.reportsFilterBugs).toUpperCase()), + button({ type: "submit", name: "filter", value: "content", class: btnClass("content") }, String(i18n.reportsFilterContent).toUpperCase()), + button({ type: "submit", name: "filter", value: "confirmed", class: btnClass("confirmed") }, String(i18n.reportsFilterConfirmed).toUpperCase()), button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.reportsCreateButton) + ), + form( + { method: "GET", action: "/reports", class: "ui-toolbar ui-toolbar--filters reports-subfilters" }, + button({ type: "submit", name: "filter", value: "open", class: btnClass("open") }, String(i18n.reportsFilterOpen).toUpperCase()), + button({ type: "submit", name: "filter", value: "under_review", class: btnClass("under_review") }, String(i18n.reportsFilterUnderReview).toUpperCase()), + button({ type: "submit", name: "filter", value: "resolved", class: btnClass("resolved") }, String(i18n.reportsFilterResolved).toUpperCase()), + button({ type: "submit", name: "filter", value: "invalid", class: btnClass("invalid") }, String(i18n.reportsFilterInvalid).toUpperCase()), + button({ type: "submit", name: "filter", value: "sev_low", class: btnClass("sev_low") }, String(i18n.reportsSeverityLow).toUpperCase()), + button({ type: "submit", name: "filter", value: "sev_medium", class: btnClass("sev_medium") }, String(i18n.reportsSeverityMedium).toUpperCase()), + button({ type: "submit", name: "filter", value: "sev_high", class: btnClass("sev_high") }, String(i18n.reportsSeverityHigh).toUpperCase()), + button({ type: "submit", name: "filter", value: "sev_critical", class: btnClass("sev_critical") }, String(i18n.reportsSeverityCritical).toUpperCase()) ) ), div({ class: "tribe-details" }, reportSide, reportMain) diff --git a/src/views/search_view.js b/src/views/search_view.js index 52ba670..80485aa 100644 --- a/src/views/search_view.js +++ b/src/views/search_view.js @@ -4,6 +4,7 @@ const moment = require("../server/node_modules/moment"); const { renderTextWithStyles } = require('../backend/renderTextWithStyles'); const { renderUrl } = require('../backend/renderUrl'); const { sanitizeHtml } = require('../backend/sanitizeHtml'); +const { renderZoomableImage } = require('./gallery_view'); const { config } = require("../server/SSB_server.js"); const userId = config.keys.id; @@ -41,15 +42,14 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types = const contentTypes = [ "post", "about", "curriculum", "tribe", "market", "transfer", "feed", "votes", "report", "task", "event", "bookmark", "image", "audio", "video", "document", "torrent", - "bankWallet", "bankClaim", "project", "job", "industry", "industryBlueprint", "forum", "vote", "contact", "pub", "map", "shop", "shopProduct", "chat", "pad", "all" + "bankWallet", "bankClaim", "project", "job", "industry", "industryBlueprint", "forum", "vote", "contact", "map", "shop", "shopProduct", "chat", "pad", "all" ]; const filterSelect = select( { id: "search-type", name: "type", - class: "input-select", - style: "position:relative; z-index:10;" + class: "input-select search-select" }, contentTypes.map(type => option({ @@ -63,8 +63,7 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types = { id: "results-per-page", name: "resultsPerPage", - class: "input-select", - style: "position:relative; z-index:10;margin-left:10px;" + class: "input-select search-select" }, option({ value: "100", selected: resultCount === "100" ? "selected" : undefined }, "100"), option({ value: "50", selected: resultCount === "50" ? "selected" : undefined }, "50"), @@ -117,7 +116,7 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types = const s = String(value).trim().replace(/&/g, '&'); const m = s.match(/!\[[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/); const src = m ? m[1] : s; - return src.startsWith('&') ? img({ src: `/blob/${encodeURIComponent(src)}`, class: 'search-result-image' }) : null; + return src.startsWith('&') ? renderZoomableImage(`/blob/${encodeURIComponent(src)}`, { imgClass: 'search-result-image' }) : null; }; const renderContentHtml = (content) => { @@ -125,12 +124,12 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types = case 'post': return div({ class: 'search-post' }, content.contentWarning ? h2({ class: 'card-field' }, span({ class: 'card-value' }, content.contentWarning)) : null, - content.text ? div({ class: 'card-field' }, span({ class: 'card-value', innerHTML: sanitizeHtml(content.text) })) : null + content.text ? div({ class: 'card-field' }, span({ class: 'card-value', innerHTML: sanitizeHtml(rewriteHashtagLinks(renderTextWithStyles(content.text))) })) : null ); case 'about': return div({ class: 'search-about' }, content.name ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.name + ':'), span({ class: 'card-value' }, content.name)) : null, - content.description ? div({ class: 'card-field card-field-stacked' }, span({ class: 'card-label' }, i18n.description + ':'), span({ class: 'card-value' }, content.description)) : null, + content.description ? div({ class: 'card-field card-field-stacked' }, span({ class: 'card-value' }, content.description)) : null, content.image ? img({ src: `/image/64/${encodeURIComponent(content.image)}` }) : null ); case 'feed': { @@ -153,7 +152,7 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types = const cleanEvDesc = rawEvDesc.replace(/!\[[^\]]*\]\(&[^)]+\.sha256\)/g, '').trim(); return div({ class: 'search-event' }, content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.eventTitleLabel + ':'), span({ class: 'card-value' }, content.title)) : null, - cleanEvDesc ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchDescription + ':'), span({ class: 'card-value' }, cleanEvDesc)) : null, + cleanEvDesc ? div({ class: 'card-field' }, span({ class: 'card-value' }, cleanEvDesc)) : null, blobImg(content.image || blobInEvDesc), content.date ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.eventDate + ':'), span({ class: 'card-value' }, new Date(content.date).toLocaleString())) : null, content.location ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.eventLocation + ':'), span({ class: 'card-value' }, content.location)) : null, @@ -202,7 +201,7 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types = return div({ class: 'search-tribe' }, content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.title + ':'), span({ class: 'card-value' }, content.title)) : null, (() => { const s = String(content.image || '').trim().replace(/&/g, '&'); const m = s.match(/!\[[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/); const src = m ? m[1] : s; return src.startsWith('&') ? img({ src: `/blob/${encodeURIComponent(src)}`, class: 'feed-image' }) : img({ src: '/assets/images/default-tribe.png', class: 'feed-image' }); })(), - content.description ? div({ class: 'card-field card-field-stacked' }, span({ class: 'card-label' }, i18n.searchDescription + ':'), span({ class: 'card-value' }, ...renderUrl(content.description))) : null, + content.description ? div({ class: 'card-field card-field-stacked' }, span({ class: 'card-value' }, ...renderUrl(content.description))) : null, content.location ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.tribeLocationLabel + ':'), span({ class: 'card-value' }, ...renderUrl(content.location))) : null, div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.tribeIsAnonymousLabel + ':'), span({ class: 'card-value' }, content.isAnonymous ? i18n.tribePrivate : i18n.tribePublic)), content.inviteMode ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.tribeModeLabel + ':'), span({ class: 'card-value' }, String(content.inviteMode).toUpperCase())) : null, @@ -218,7 +217,7 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types = case 'audio': return content.url ? div({ class: 'search-audio' }, content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.audioTitleLabel + ':'), span({ class: 'card-value' }, content.title)) : null, - content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.audioDescriptionLabel + ':'), span({ class: 'card-value' }, content.description)) : null, + content.description ? div({ class: 'card-field' }, span({ class: 'card-value' }, content.description)) : null, br(), audioHyperaxe({ controls: true, src: `/blob/${encodeURIComponent(content.url)}`, type: content.mimeType, preload: 'metadata' }), br(), @@ -231,10 +230,9 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types = case 'image': return content.url ? div({ class: 'search-image' }, content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.imageTitleLabel + ':'), span({ class: 'card-value' }, content.title)) : null, - content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.imageDescriptionLabel + ':'), span({ class: 'card-value' }, content.description)) : null, - content.meme ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.trendingCategory + ':'), span({ class: 'card-value' }, i18n.meme)) : null, + content.description ? div({ class: 'card-field' }, span({ class: 'card-value' }, content.description)) : null, br(), - img({ src: `/blob/${encodeURIComponent(content.url)}` }), + renderZoomableImage(`/blob/${encodeURIComponent(content.url)}`, { imgClass: 'search-result-image' }), br(), content.tags && content.tags.length ? div({ class: 'card-tags' }, content.tags.map(tag => @@ -245,7 +243,7 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types = case 'video': return content.url ? div({ class: 'search-video' }, content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.videoTitleLabel + ':'), span({ class: 'card-value' }, content.title)) : null, - content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.videoDescriptionLabel + ':'), span({ class: 'card-value' }, content.description)) : null, + content.description ? div({ class: 'card-field' }, span({ class: 'card-value' }, content.description)) : null, br(), videoHyperaxe({ controls: true, src: `/blob/${encodeURIComponent(content.url)}`, type: content.mimeType || 'video/mp4', width: '640', height: '360' }), br(), @@ -276,7 +274,7 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types = case 'torrent': return div({ class: 'search-torrent' }, content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.torrentTitleLabel || i18n.title) + ':'), span({ class: 'card-value' }, content.title)) : null, - content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.torrentDescriptionLabel || i18n.searchDescription) + ':'), span({ class: 'card-value' }, content.description)) : null, + content.description ? div({ class: 'card-field' }, span({ class: 'card-value' }, content.description)) : null, content.size ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.torrentSizeLabel || 'Size') + ':'), span({ class: 'card-value' }, String(content.size))) : null, content.tags && content.tags.length ? div({ class: 'card-tags' }, content.tags.map(tag => @@ -287,7 +285,7 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types = case 'market': return div({ class: 'search-market' }, content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.title + ':'), span({ class: 'card-value' }, content.title)) : null, - content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchDescription + ':'), span({ class: 'card-value' }, content.description)) : null, + content.description ? div({ class: 'card-field' }, span({ class: 'card-value' }, content.description)) : null, content.item_type ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemType + ':'), span({ class: 'card-value' }, content.item_type.toUpperCase())) : null, content.status ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemCondition + ':'), span({ class: 'card-value' }, content.status)) : null, content.deadline ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemDeadline + ':'), span({ class: 'card-value' }, new Date(content.deadline).toLocaleString())) : null, @@ -297,7 +295,7 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types = content.seller ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemSeller + ':'), span({ class: 'card-value' }, userLink(content.seller))) : null, content.stock ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemStock + ':'), span({ class: 'card-value' }, content.stock || 'N/A')) : null, content.price ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchPriceLabel + ':'), span({ class: 'card-value' }, `${content.price} ECO`)) : null, - content.condition ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchDescription + ':'), span({ class: 'card-value' }, content.condition)) : null, + content.condition ? div({ class: 'card-field' }, span({ class: 'card-value' }, content.condition)) : null, content.includesShipping ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemIncludesShipping + ':'), span({ class: 'card-value' }, `${content.includesShipping ? i18n.YESLabel : i18n.NOLabel}`)) : null, content.auctions_poll && content.auctions_poll.length > 0 ? div({ class: 'auction-info' }, @@ -328,8 +326,7 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types = case 'bookmark': return div({ class: 'search-bookmark' }, content.url ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.bookmarkUrlLabel + ':'), span({ class: 'card-value' }, a({ href: content.url, target: '_blank' }, content.url))) : null, - content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchDescription + ':'), span({ class: 'card-value' }, content.description)) : null, - content.category ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.bookmarkCategory + ':'), span({ class: 'card-value' }, content.category)) : null, + content.description ? div({ class: 'card-field' }, span({ class: 'card-value' }, content.description)) : null, content.lastVisit ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.bookmarkLastVisit + ':'), span({ class: 'card-value' }, new Date(content.lastVisit).toLocaleString())) : null, content.tags && content.tags.length ? div({ class: 'card-tags' }, content.tags.map(tag => @@ -340,7 +337,7 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types = case 'task': return div({ class: 'search-task' }, content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.taskTitleLabel + ':'), span({ class: 'card-value' }, content.title)) : null, - content.description ? div({ class: 'card-field', style: 'display:flex;flex-direction:column;' }, span({ class: 'card-label' }, i18n.searchDescription + ':'), p({ class: 'card-value', style: 'white-space:pre-wrap;margin-top:4px;' }, content.description)) : null, + content.description ? div({ class: 'card-field card-field-stacked' }, p({ class: 'card-value card-value-pre' }, content.description)) : null, content.location ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchLocationLabel + ':'), span({ class: 'card-value' }, content.location)) : null, content.status ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchStatusLabel + ':'), span({ class: 'card-value' }, content.status)) : null, content.priority ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchPriorityLabel + ':'), span({ class: 'card-value' }, content.priority)) : null, @@ -360,7 +357,7 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types = content.status ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchStatusLabel + ':'), span({ class: 'card-value' }, content.status)) : null, content.severity ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.reportsSeverity + ':'), span({ class: 'card-value' }, content.severity)) : null, content.category ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchCategoryLabel + ':'), span({ class: 'card-value' }, content.category)) : null, - content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchDescription + ':'), span({ class: 'card-value' }, content.description)) : null, + content.description ? div({ class: 'card-field' }, span({ class: 'card-value' }, content.description)) : null, br(), blobImg(content.image), br(), @@ -393,8 +390,8 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types = case 'curriculum': return div({ class: 'search-curriculum' }, content.name ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.cvNameLabel + ':'), span({ class: 'card-value' }, content.name)) : null, - content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.cvDescriptionLabel + ':'), span({ class: 'card-value' }, content.description)) : null, - content.photo ? img({ src: `/blob/${encodeURIComponent(content.photo)}`, class: 'curriculum-photo' }) : null, + content.description ? div({ class: 'card-field' }, span({ class: 'card-value' }, content.description)) : null, + content.photo ? renderZoomableImage(`/blob/${encodeURIComponent(content.photo)}`, { imgClass: 'curriculum-photo' }) : null, content.location ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.cvLocationLabel + ':'), span({ class: 'card-value' }, content.location)) : null, content.status ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.cvStatusLabel + ':'), span({ class: 'card-value' }, content.status)) : null, content.preferences ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.cvPreferencesLabel + ':'), span({ class: 'card-value' }, content.preferences)) : null, @@ -474,7 +471,7 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types = return div({ class: 'search-forum' }, content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.title + ':'), span({ class: 'card-value' }, content.title)) : null, content.category ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchCategoryLabel + ':'), span({ class: 'card-value' }, content.category)) : null, - content.text ? div({ class: 'card-field card-field-stacked' }, span({ class: 'card-label' }, i18n.searchDescription + ':'), span({ class: 'card-value' }, content.text)) : null + content.text ? div({ class: 'card-field card-field-stacked' }, span({ class: 'card-value' }, content.text)) : null ); case 'vote': return div({ class: 'search-vote-link' }, @@ -492,7 +489,7 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types = return div({ class: 'search-shop' }, content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.title + ':'), span({ class: 'card-value' }, content.title)) : null, blobImg(content.image), - content.shortDescription ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchDescription + ':'), span({ class: 'card-value' }, content.shortDescription)) : null, + content.shortDescription ? div({ class: 'card-field' }, span({ class: 'card-value' }, content.shortDescription)) : null, content.visibility ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.shopStatus || 'STATUS') + ':'), span({ class: 'card-value' }, content.visibility)) : null, content.location ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.searchLocationLabel || 'LOCATION') + ':'), span({ class: 'card-value' }, content.location)) : null, content.tags && content.tags.length @@ -503,7 +500,7 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types = return div({ class: 'search-shop-product' }, content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.title + ':'), span({ class: 'card-value' }, content.title)) : null, blobImg(content.image), - content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.searchDescription + ':'), span({ class: 'card-value' }, content.description)) : null, + content.description ? div({ class: 'card-field' }, span({ class: 'card-value' }, content.description)) : null, content.price ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.searchPriceLabel || 'PRICE') + ':'), span({ class: 'card-value' }, `${content.price} ECO`)) : null, content.stock !== undefined ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.marketItemStock + ':'), span({ class: 'card-value' }, content.stock)) : null ); @@ -515,7 +512,7 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types = ); return div({ class: 'search-chat' }, content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.chatsTitle || 'Chat') + ':'), span({ class: 'card-value' }, content.title)) : null, - content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.chatDescription || 'Description') + ':'), span({ class: 'card-value' }, content.description)) : null, + content.description ? div({ class: 'card-field' }, span({ class: 'card-value' }, content.description)) : null, content.category ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.chatCategoryLabel || 'Category') + ':'), span({ class: 'card-value' }, content.category)) : null, content.status ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.chatStatus || 'STATUS') + ':'), span({ class: 'card-value' }, content.status)) : null ); @@ -546,7 +543,7 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types = case 'map': return div({ class: 'search-map' }, content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.title + ':'), span({ class: 'card-value' }, content.title)) : null, - content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.mapDescriptionLabel + ':'), span({ class: 'card-value' }, content.description)) : null, + content.description ? div({ class: 'card-field' }, span({ class: 'card-value' }, content.description)) : null, content.lat && content.lng ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.mapLocationTitle + ':'), span({ class: 'card-value' }, `${content.lat}, ${content.lng}`)) : null, content.mapType ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.mapTypeLabel + ':'), span({ class: 'card-value' }, content.mapType)) : null, content.tags && content.tags.length diff --git a/src/views/settings_view.js b/src/views/settings_view.js index 0969d08..d3099bb 100644 --- a/src/views/settings_view.js +++ b/src/views/settings_view.js @@ -1,9 +1,10 @@ -const { form, button, div, h2, h3, p, section, select, option, input, br, a, label, span } = require("../server/node_modules/hyperaxe"); +const { form, button, div, h2, h3, p, section, select, option, input, br, a, label, span, img } = require("../server/node_modules/hyperaxe"); const fs = require('fs'); const path = require('path'); const { getConfig } = require('../configs/config-manager.js'); const { template, selectedLanguage, i18n, setLanguage } = require('./main_views'); const i18nBase = require("../client/assets/translations/i18n"); +const { WORKFLOWS, currentWorkflow } = require('../models/workflows_model'); const snhUrl = "https://wiki.solarnethub.com/socialnet/overview"; @@ -28,18 +29,37 @@ const settingsView = ({ version, aiPrompt, fediverseAccount, fediverseError }) = const walletUrl = currentConfig.wallet.url; const walletUser = currentConfig.wallet.user; const walletFee = currentConfig.wallet.fee; - const pubId = currentConfig.walletPub?.pubId || ''; const currentWish = currentConfig.wish === 'mutuals' ? 'mutuals' : 'whole'; const currentPmVisibility = currentConfig.pmVisibility === 'mutuals' ? 'mutuals' : 'whole'; const themeElements = [ - option({ value: "Dark-SNH", selected: theme === "Dark-SNH" ? true : undefined }, "Dark-SNH"), - option({ value: "Clear-SNH", selected: theme === "Clear-SNH" ? true : undefined }, "Clear-SNH"), - option({ value: "Purple-SNH", selected: theme === "Purple-SNH" ? true : undefined }, "Purple-SNH"), - option({ value: "Matrix-SNH", selected: theme === "Matrix-SNH" ? true : undefined }, "Matrix-SNH"), - option({ value: "OasisMobile", selected: theme === "OasisMobile" ? true : undefined }, "Oasis-Mobile") + option({ value: "Dark-SNH", ...(theme === "Dark-SNH" ? true : undefined ? { selected: true } : {})}, "Dark-SNH"), + option({ value: "Clear-SNH", ...(theme === "Clear-SNH" ? true : undefined ? { selected: true } : {})}, "Clear-SNH"), + option({ value: "Purple-SNH", ...(theme === "Purple-SNH" ? true : undefined ? { selected: true } : {})}, "Purple-SNH"), + option({ value: "Matrix-SNH", ...(theme === "Matrix-SNH" ? true : undefined ? { selected: true } : {})}, "Matrix-SNH"), + option({ value: "OasisMobile", ...(theme === "OasisMobile" ? true : undefined ? { selected: true } : {})}, "Oasis-Mobile") ]; + const activeWorkflow = currentWorkflow(currentConfig) || ''; + const modOn = (name) => (currentConfig.modules || {})[`${name}Mod`] === 'on'; + + const workflowSection = section( + div({ class: "tags-header" }, + h2(i18n.workflowsTitle), + p(i18n.workflowsDescription), + form( + { action: "/settings/workflow", method: "POST" }, + select({ name: "workflow" }, + ...WORKFLOWS.map(w => activeWorkflow === w.key + ? option({ value: w.key, selected: true }, i18n[`workflow_${w.key}`]) + : option({ value: w.key }, i18n[`workflow_${w.key}`])) + ), + br(), br(), + button({ type: "submit" }, i18n.workflowsSet) + ) + ) + ); + const languageOption = (longName, shortName) => { return shortName === selectedLanguage ? option({ value: shortName, selected: true }, longName) @@ -69,19 +89,6 @@ const settingsView = ({ version, aiPrompt, fediverseAccount, fediverseError }) = updateButton ) ), - section( - div({ class: "tags-header" }, - h2(i18n.theme), - p(i18n.themeIntro), - form( - { action: "/settings/theme", method: "post" }, - select({ name: "theme" }, ...themeElements), - br(), - br(), - button({ type: "submit" }, i18n.setTheme) - ) - ) - ), section( div({ class: "tags-header" }, h2(i18n.language), @@ -115,19 +122,37 @@ const settingsView = ({ version, aiPrompt, fediverseAccount, fediverseError }) = { action: "/settings/ux", method: "POST" }, (() => { const aiNavEnabled = currentConfig.modules && currentConfig.modules.aiNavMod === 'on'; - const opts = [ - option({ value: "blocks", selected: (currentConfig.ux?.current !== "ainav") ? true : undefined }, i18n.uxModeMenus || "Blocks") - ]; - if (aiNavEnabled) { - opts.push(option({ value: "ainav", selected: currentConfig.ux?.current === "ainav" ? true : undefined }, i18n.uxModeAINav || "AI")); - } - return select({ name: "ux" }, ...opts); + const chatsEnabled = currentConfig.modules && currentConfig.modules.chatsMod === 'on'; + const cur = currentConfig.ux?.current === "ainav" ? "ainav" : currentConfig.ux?.current === "chats" ? "chats" : "blocks"; + const uxCard = (value, title, image) => label({ class: "welcome-ux-option" }, + input({ type: "radio", name: "ux", value, ...(cur === value ? { checked: true } : {}) }), + img({ src: image, class: "welcome-ux-shot", alt: title }), + span({ class: "welcome-ux-label" }, title) + ); + return div({ class: "welcome-ux-grid" }, + uxCard("blocks", i18n.uxModeMenus || "Blocks", "/assets/images/ux-blocks.png"), + chatsEnabled ? uxCard("chats", i18n.chatsTitle || "Chats", "/assets/images/ux-chats.png") : null, + aiNavEnabled ? uxCard("ainav", i18n.uxModeAINav || "AI", "/assets/images/ux-ainav.png") : null + ); })(), - br(), br(), button({ type: "submit" }, i18n.saveSettings) ) ) ), + workflowSection, + section( + div({ class: "tags-header" }, + h2(i18n.theme), + p(i18n.themeIntro), + form( + { action: "/settings/theme", method: "post" }, + select({ name: "theme" }, ...themeElements), + br(), + br(), + button({ type: "submit" }, i18n.setTheme) + ) + ) + ), section( div({ class: "tags-header" }, h2(i18n.homePageTitle), @@ -135,24 +160,58 @@ const settingsView = ({ version, aiPrompt, fediverseAccount, fediverseError }) = form( { action: "/settings/home-page", method: "POST" }, select({ name: "homePage" }, - option({ value: "activity", selected: currentConfig.homePage === "activity" ? true : undefined }, i18n.activityTitle), - option({ value: "ai", selected: currentConfig.homePage === "ai" ? true : undefined }, i18n.aiTitle), - option({ value: "trending", selected: currentConfig.homePage === "trending" ? true : undefined }, i18n.trendingTitle), - option({ value: "opinions", selected: currentConfig.homePage === "opinions" ? true : undefined }, i18n.opinionsTitle), - option({ value: "forum", selected: currentConfig.homePage === "forum" ? true : undefined }, i18n.forumTitle), - option({ value: "feed", selected: currentConfig.homePage === "feed" ? true : undefined }, i18n.feedTitle), - option({ value: "mentions", selected: currentConfig.homePage === "mentions" ? true : undefined }, i18n.mentions), - option({ value: "inbox", selected: currentConfig.homePage === "inbox" ? true : undefined }, i18n.inbox), - option({ value: "agenda", selected: currentConfig.homePage === "agenda" ? true : undefined }, i18n.agendaTitle), - option({ value: "favorites", selected: currentConfig.homePage === "favorites" ? true : undefined }, i18n.favoritesTitle), - option({ value: "stats", selected: currentConfig.homePage === "stats" ? true : undefined }, i18n.statsTitle), - option({ value: "blockexplorer", selected: currentConfig.homePage === "blockexplorer" ? true : undefined }, i18n.blockchain) + ...[ + { value: "activity", label: i18n.activityTitle }, + { value: "ai", label: i18n.aiTitle, mod: "ai" }, + { value: "trending", label: i18n.trendingTitle, mod: "trending" }, + { value: "forum", label: i18n.forumTitle, mod: "forum" }, + { value: "feed", label: i18n.feedTitle, mod: "feed" }, + { value: "chats", label: i18n.chatsTitle, mod: "chats" }, + { value: "inbox", label: i18n.inbox }, + { value: "mentions", label: i18n.mentions }, + { value: "agenda", label: i18n.agendaTitle, mod: "agenda" }, + { value: "market", label: i18n.marketTitle, mod: "market" }, + { value: "favorites", label: i18n.favoritesTitle, mod: "favorites" } + ].filter(o => !o.mod || modOn(o.mod)).map(o => currentConfig.homePage === o.value + ? option({ value: o.value, selected: true }, o.label) + : option({ value: o.value }, o.label)) ), br(), br(), button({ type: "submit" }, i18n.saveHomePage) ) ) ), + modOn('ai') ? section( + div({ class: "tags-header" }, + h2(i18n.aiTitle), + p(i18n.aiSettingsDescription), + form( + { action: "/settings/ai", method: "POST" }, + input({ + type: "text", + id: "ai_prompt", + name: "ai_prompt", + placeholder: aiPrompt, + value: aiPrompt, + maxlength: "128", + required: true + }), br(), + label({ for: "aiSuggestions", class: "lan-checkbox-label" }, + input({ + type: "checkbox", + id: "aiSuggestions", + name: "ai_suggestions", + value: "on", + class: "lan-checkbox-input", + checked: currentConfig.ai?.suggestions !== false ? true : undefined + }), + span({ class: "lan-checkbox-text" }, i18n.aiSuggestionsEnable) + ), + br(), + button({ type: "submit" }, i18n.saveSettings) + ) + ) + ) : null, section( div({ class: "tags-header" }, h2(i18n.ssbLogStream), @@ -221,9 +280,9 @@ const settingsView = ({ version, aiPrompt, fediverseAccount, fediverseError }) = form( { action: "/settings/wish", method: "POST" }, select({ name: "wish" }, - option({ value: "whole", selected: currentWish === "whole" ? true : undefined }, i18n.settingsWishWhole), - option({ value: "mutuals", selected: currentWish === "mutuals" ? true : undefined }, i18n.settingsWishMutuals), - option({ value: "only-lan", selected: currentWish === "only-lan" ? true : undefined }, i18n.settingsWishOnlyLan || "Only LAN") + option({ value: "whole", ...(currentWish === "whole" ? true : undefined ? { selected: true } : {})}, i18n.settingsWishWhole), + option({ value: "mutuals", ...(currentWish === "mutuals" ? true : undefined ? { selected: true } : {})}, i18n.settingsWishMutuals), + option({ value: "only-lan", ...(currentWish === "only-lan" ? true : undefined ? { selected: true } : {})}, i18n.settingsWishOnlyLan || "Only LAN") ), br(), br(), button({ type: "submit" }, i18n.saveSettings) ) @@ -236,14 +295,14 @@ const settingsView = ({ version, aiPrompt, fediverseAccount, fediverseError }) = form( { action: "/settings/pm-visibility", method: "POST" }, select({ name: "pmVisibility" }, - option({ value: "whole", selected: currentPmVisibility === "whole" ? true : undefined }, i18n.settingsPmVisibilityWhole), - option({ value: "mutuals", selected: currentPmVisibility === "mutuals" ? true : undefined }, i18n.settingsPmVisibilityMutuals) + option({ value: "whole", ...(currentPmVisibility === "whole" ? true : undefined ? { selected: true } : {})}, i18n.settingsPmVisibilityWhole), + option({ value: "mutuals", ...(currentPmVisibility === "mutuals" ? true : undefined ? { selected: true } : {})}, i18n.settingsPmVisibilityMutuals) ), br(), br(), button({ type: "submit" }, i18n.saveSettings) ) ) ), - section( + modOn('wallet') ? section( { id: "wallet" }, div({ class: "tags-header" }, h2(i18n.wallet), @@ -264,45 +323,9 @@ const settingsView = ({ version, aiPrompt, fediverseAccount, fediverseError }) = button({ type: "submit" }, i18n.walletConfiguration) ) ) - ), - section( - div({ class: "tags-header" }, - h2(i18n.pubIdTitle || "PUB Wallet"), - p(i18n.pubIdDescription || "Set the PUB OASIS ID. This will be used for PUB transactions (including the UBI)."), - form( - { action: "/settings/pub-id", method: "POST" }, - input({ - type: "text", - id: "pub_id", - name: "pub_id", - value: pubId, - placeholder: i18n.pubIdPlaceholder || "@example.ed25519" - }), br(), - button({ type: "submit" }, i18n.pubIdSave || "Save configuration") - ) - ) - ), - section( - div({ class: "tags-header" }, - h2(i18n.aiTitle), - p(i18n.aiSettingsDescription), - form( - { action: "/settings/ai", method: "POST" }, - input({ - type: "text", - id: "ai_prompt", - name: "ai_prompt", - placeholder: aiPrompt, - value: aiPrompt, - maxlength: "128", - required: true - }), br(), - button({ type: "submit" }, i18n.aiConfiguration) - ) - ) - ), - section( - { id: "fediverse" }, + ) : null, + modOn('fediverse') ? section( + { id: "multiverse" }, div({ class: "tags-header" }, h2(i18n.fediverseSettingsTitle), div({ class: "fediverse-network" }, @@ -333,7 +356,7 @@ const settingsView = ({ version, aiPrompt, fediverseAccount, fediverseError }) = ) ) ) - ), + ) : null, section( div({ class: "tags-header" }, h2(i18n.indexes), diff --git a/src/views/shops_view.js b/src/views/shops_view.js index 7f50ab0..d392be5 100644 --- a/src/views/shops_view.js +++ b/src/views/shops_view.js @@ -1,5 +1,6 @@ const { div, h2, p, section, button, form, a, span, textarea, br, input, label, select, option, img, progress, video, table, tr, td } = require("../server/node_modules/hyperaxe") -const { template, i18n, userLink, renderStateChip, renderLifespanChip, renderSpreadButton, renderOpinionsVoting, renderInviteQrCard } = require("./main_views") +const { renderCommentsSection: renderSharedCommentsSection } = require("./comments_view"); +const { template, i18n, userLink, renderStateChip, renderLifespanChip, renderSpreadButton, renderOpinionsVoting, renderEngagement, renderInviteQrCard , renderSpreadEditWarning, renderContentActions } = require("./main_views") const moment = require("../server/node_modules/moment") const { config } = require("../server/SSB_server.js") const { renderUrl } = require("../backend/renderUrl") @@ -60,18 +61,14 @@ const renderModeButtons = (currentFilter) => ) ) -const renderFavoriteToggle = (shop, returnTo) => - form( - { method: "POST", action: shop.isFavorite ? `/shops/favorites/remove/${encodeURIComponent(shop.key)}` : `/shops/favorites/add/${encodeURIComponent(shop.key)}` }, - returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null, - button({ type: "submit", class: "filter-btn" }, shop.isFavorite ? i18n.shopRemoveFavorite : i18n.shopAddFavorite) - ) - const renderShopCard = exports.renderShopCard = (shop, filter, params = {}) => { const returnTo = buildReturnTo(filter, params) const isAuthor = String(shop.author) === String(userId) return div({ class: "tribe-card" }, + div({ class: "card-header activity-card-header" }, + renderContentActions(shop.key, `/shops/${encodeURIComponent(shop.key)}`, { spread: params.spreadMap && params.spreadMap.get(shop.key) || null, author: shop.author, favKind: 'shops', isFavorite: shop.isFavorite, reportTitle: shop.title }) + ), div({ class: "tribe-card-image-wrapper" }, a({ href: `/shops/${encodeURIComponent(shop.key)}` }, renderMediaBlob(shop.image, '/assets/images/default-avatar.png', { class: 'tribe-card-hero-image' }) @@ -89,12 +86,6 @@ const renderShopCard = exports.renderShopCard = (shop, filter, params = {}) => { ), div({ class: "tribe-card-members" }, span({ class: "tribe-members-count" }, `${i18n.shopProducts}: ${shop.productCount || 0}`) - ), - div({ class: "card-spread-centered" }, renderSpreadButton(shop.key, params.spreadMap && params.spreadMap.get(shop.key))), - div({ class: "card-visit-btn-centered" }, - form({ method: 'GET', action: `/shops/${encodeURIComponent(shop.key)}` }, - button({ type: 'submit', class: 'filter-btn' }, i18n.viewShop || i18n.shopVisitShop || 'View Shop') - ) ) ) ) @@ -126,9 +117,6 @@ const renderProductCard = (product, shopId, returnTo) => { if (!isAuthor && stock > 0) { actions.push(a({ href: productUrl, class: "buy-btn" }, i18n.marketActionsBuy || i18n.shopBuy)); } - actions.push(form({ method: "POST", action: product.isFavorite ? `/shops/favorites/remove/${encodeURIComponent(product.key)}` : `/shops/favorites/add/${encodeURIComponent(product.key)}` }, - returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null, - button({ type: "submit", class: "filter-btn" }, product.isFavorite ? i18n.shopRemoveFavorite : i18n.shopAddFavorite))); return actions.length ? div({ class: "shop-product-actions" }, ...actions) : null; })() ) @@ -136,50 +124,23 @@ const renderProductCard = (product, shopId, returnTo) => { } const renderCommentsSection = (parentId, returnTo, comments = []) => { - const count = Array.isArray(comments) ? comments.length : 0 - return div({ class: "vote-comments-section market-comments" }, - div({ class: "comments-count" }, span({ class: "card-label" }, i18n.voteCommentsLabel + ": "), span({ class: "card-value" }, String(count))), - div({ class: "comment-form-wrapper" }, - h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel), - form({ method: "POST", action: `/shops/${encodeURIComponent(parentId)}/comments`, class: "comment-form", enctype: "multipart/form-data" }, - input({ type: "hidden", name: "returnTo", value: returnTo }), - textarea({ name: "text", rows: 4, class: "comment-textarea", placeholder: i18n.voteNewCommentPlaceholder }), - div({ class: "comment-file-upload" }, label(i18n.uploadMedia), input({ type: "file", name: "blob" })), - br(), - button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton) - ) - ), - count - ? div({ class: "comments-list" }, - comments.map(c => { - const author = c.value?.author || "" - const ts = c.value?.timestamp || c.timestamp - const text = c.value?.content?.text || "" - const rootId = c.value?.content?.fork || c.value?.content?.root || null - return div({ class: "votations-comment-card" }, - span({ class: "created-at" }, - span(i18n.createdBy), - author ? userLink(author) : span("(unknown)"), - ts ? span(" | ", span({ class: "votations-comment-date" }, moment(ts).format("YYYY/MM/DD HH:mm:ss"))) : "", - ts && rootId ? span(" | ", a({ href: `/thread/${encodeURIComponent(rootId)}#${encodeURIComponent(c.key)}` }, moment(ts).fromNow())) : "" - ), - p({ class: "votations-comment-text" }, ...renderUrl(text)) - ) - }) - ) - : p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet) - ) -} + return renderSharedCommentsSection({ + action: `/shops/${encodeURIComponent(parentId)}/comments`, + comments: comments, + returnTo: returnTo + }); +}; const renderShopForm = (filter, shop = {}, params = {}) => { const isEdit = filter === "edit" const returnTo = safeText(params.returnTo) || buildReturnTo("all") return div({ class: "create-tribe-form" }, h2(isEdit ? i18n.shopUpdateSectionTitle : i18n.shopCreateSectionTitle), + isEdit ? (params.spreadWarning || null) : null, form({ action: isEdit ? `/shops/update/${encodeURIComponent(shop.key || "")}` : "/shops/create", method: "POST", enctype: "multipart/form-data" }, input({ type: "hidden", name: "returnTo", value: returnTo }), label(i18n.title || "Title"), br, - input({ type: "text", name: "title", required: true, placeholder: i18n.shopTitlePlaceholder || "Name of your shop", value: shop.title || "" }), br(), + input({ type: "text", name: "title", maxlength: "100", required: true, placeholder: i18n.shopTitlePlaceholder || "Name of your shop", value: shop.title || "" }), br(), label(i18n.shopShortDescription), br, input({ type: "text", name: "shortDescription", required: true, maxlength: 160, placeholder: i18n.shopShortDescriptionPlaceholder || "Brief description of your shop", value: shop.shortDescription || "" }), br(), label(i18n.description || "Description"), br, @@ -197,8 +158,8 @@ const renderShopForm = (filter, shop = {}, params = {}) => { isEdit ? null : label(i18n.shopVisibility), isEdit ? null : br, isEdit ? null : select({ name: "visibility" }, - option({ value: "OPEN", selected: (shop.visibility || "OPEN") === "OPEN" }, i18n.shopOpen), - option({ value: "CLOSED", selected: shop.visibility === "CLOSED" }, i18n.shopClosed) + option({ value: "OPEN", ...((shop.visibility || "OPEN") === "OPEN" ? { selected: true } : {})}, i18n.shopOpen), + option({ value: "CLOSED", ...(shop.visibility === "CLOSED" ? { selected: true } : {})}, i18n.shopClosed) ), isEdit ? null : br(), br(), @@ -207,14 +168,15 @@ const renderShopForm = (filter, shop = {}, params = {}) => { ) } -const renderProductForm = (shopId, product = {}, isEdit = false, returnTo = "") => { +const renderProductForm = (shopId, product = {}, isEdit = false, returnTo = "", spreadWarning = null) => { return div({ class: "create-tribe-form" }, h2(isEdit ? i18n.shopProductUpdate : i18n.shopProductAdd), + isEdit ? spreadWarning : null, form({ action: isEdit ? `/shops/product/update/${encodeURIComponent(product.key || "")}` : "/shops/product/create", method: "POST", enctype: "multipart/form-data" }, input({ type: "hidden", name: "shopId", value: shopId }), input({ type: "hidden", name: "returnTo", value: returnTo }), label(i18n.title || "Title"), br, - input({ type: "text", name: "title", required: true, value: product.title || "" }), br(), + input({ type: "text", name: "title", maxlength: "100", required: true, value: product.title || "" }), br(), label(i18n.description || "Description"), br, textarea({ name: "description", rows: 4 }, product.description || ""), br, label(i18n.shopProductPrice), br, @@ -241,66 +203,47 @@ const renderProductForm = (shopId, product = {}, isEdit = false, returnTo = "") } exports.shopsView = async (shops, filter, shopToEdit = null, params = {}) => { + if (filter === "edit") params = { ...params, spreadWarning: await renderSpreadEditWarning(shopToEdit && (shopToEdit.key || shopToEdit.id)) }; const q = safeText(params.q || "") const sort = safeText(params.sort || "recent") const list = safeArr(shops) - - const title = - filter === "mine" ? i18n.shopMineSectionTitle : - filter === "recent" ? i18n.shopRecentSectionTitle : - filter === "top" ? i18n.shopTopSectionTitle : - filter === "products" ? i18n.shopProductsSectionTitle : - filter === "prices" ? (i18n.shopPricesSectionTitle || "Products by Price") : - filter === "favorites" ? i18n.shopFavoritesSectionTitle : - filter === "create" ? i18n.shopCreateSectionTitle : - filter === "edit" ? i18n.shopUpdateSectionTitle : - i18n.shopAllSectionTitle - + const title = i18n.shopsTitle const isForm = filter === "create" || filter === "edit" - - const viewerClearnet = !!(params.viewerPrefs && params.viewerPrefs.clearnetShops) - const header = [ - div({ class: "tags-header" }, - h2(title), - p(i18n.shopDescription) - ), - div({ class: "shop-title-row" }, renderReachChip(viewerClearnet, i18n)), - br() - ] - - const searchBar = div({ class: "filters" }, - form({ method: "GET", action: "/shops" }, - input({ type: "hidden", name: "filter", value: filter }), - input({ type: "text", name: "q", placeholder: i18n.shopSearchPlaceholder, value: q }), - br(), - button({ type: "submit" }, i18n.search), - br() - ) - ) - - const sortedProducts = filter === "products" - ? [...list].sort((a, b) => safeArr(b.opinions_inhabitants).length - safeArr(a.opinions_inhabitants).length) - : filter === "prices" - ? [...list].sort((a, b) => Number(b.price || 0) - Number(a.price || 0)) - : list + const isProducts = filter === "products" || filter === "prices" return template( title, - section(...header), + section(div({ class: "tags-header" }, h2(title), p(i18n.shopDescription))), section(renderModeButtons(filter)), - !isForm ? section(searchBar) : null, + !isForm + ? section( + div({ class: "filters" }, + form({ method: "GET", action: "/shops", class: "filter-box" }, + input({ type: "hidden", name: "filter", value: filter }), + input({ type: "text", name: "q", value: q, placeholder: i18n.shopSearchPlaceholder, class: "filter-box__input" }), + div({ class: "filter-box__controls" }, + select({ name: "sort", class: "filter-box__select" }, + option({ value: "recent", ...(sort === "recent" ? { selected: true } : {}) }, i18n.documentSortRecent), + option({ value: "top", ...(sort === "top" ? { selected: true } : {}) }, i18n.documentSortTop) + ), + button({ type: "submit", class: "filter-box__button" }, i18n.searchButton) + ) + ) + ) + ) + : null, section( isForm ? renderShopForm(filter, filter === "edit" ? (shopToEdit || {}) : {}, params) - : filter === "products" || filter === "prices" + : isProducts ? div({ class: "shop-products-grid" }, - sortedProducts.length - ? sortedProducts.map(prod => renderProductCard(prod, prod.shopId, buildReturnTo(filter, params))) - : p(i18n.shopNoItems) + list.length + ? list.map(prod => renderProductCard(prod, prod.shopId, buildReturnTo(filter, { q }))) + : p(i18n.shopNoProducts) ) : div({ class: "tribe-grid" }, list.length - ? list.map(shop => renderShopCard(shop, filter, { q, sort, spreadMap: params.spreadMap })) + ? list.map(shopItem => renderShopCard(shopItem, filter, params)) : p(i18n.shopNoItems) ) ) @@ -309,13 +252,15 @@ exports.shopsView = async (shops, filter, shopToEdit = null, params = {}) => { exports.singleShopView = async (shop, filter, products = [], comments = [], params = {}) => { const q = safeText(params.q || "") - const sort = safeText(params.sort || "recent") - const returnTo = safeText(params.returnTo) || buildReturnTo(filter, { q, sort }) + const returnTo = safeText(params.returnTo) || buildReturnTo(filter, { q }) const isAuthor = String(shop.author) === String(userId) - const fullShareUrl = `/shops/${encodeURIComponent(shop.key)}` + const isClearnet = !!(params.authorPrefs && params.authorPrefs.clearnetShops === true) + products = safeArr(products) - const isClearnet = !!(params.authorPrefs && params.authorPrefs.clearnetShops && shop.visibility !== 'CLOSED'); const shopSide = div({ class: "tribe-side" }, + div({ class: "card-header activity-card-header" }, + renderContentActions(shop.key, null, { spread: params.spreads || null, author: shop.author, favKind: 'shops', isFavorite: shop.isFavorite, reportTitle: shop.title }) + ), div({ class: "shop-title-row" }, h2({ class: "tribe-card-title" }, shop.title || i18n.shopUntitled) ), @@ -327,21 +272,13 @@ exports.singleShopView = async (shop, filter, products = [], comments = [], para renderLifespanChip(shop.lifetime, i18n), renderReachChip(isClearnet, i18n) ), - div({ class: "card-spread-centered" }, renderSpreadButton(shop.key, params.spreads)), renderMediaBlob(shop.image, '/assets/images/default-avatar.png', { class: 'tribe-detail-image' }), shop.description ? p({ class: "tribe-side-description" }, ...renderUrl(shop.description)) : null, - div({ class: "shop-share" }, - span({ class: "tribe-info-label" }, `${i18n.shopShareUrl}: `), - input({ type: "text", value: fullShareUrl, readonly: true, class: "shop-share-input" }) - ), table({ class: "tribe-info-table" }, tr( td({ class: "tribe-info-label" }, i18n.shopCreatedAt || "CREATED"), td({ class: "tribe-info-value", colspan: "3" }, new Date(shop.createdAt).toLocaleString()) ), - tr( - td({ class: "tribe-info-value", colspan: "4" }, userLink(shop.author)) - ), shop.location ? tr( td({ class: "tribe-info-label" }, i18n.shopLocation), td({ class: "tribe-info-value", colspan: "3" }, ...renderUrl(shop.location)) @@ -353,36 +290,15 @@ exports.singleShopView = async (shop, filter, products = [], comments = [], para shop.url ? tr( td({ class: "tribe-info-label" }, i18n.shopUrl), td({ class: "tribe-info-value", colspan: "3" }, ...renderUrl(shop.url)) - ) : null + ) : null, + tr( + td({ class: "tribe-info-value", colspan: "4" }, userLink(shop.author)) + ) ), renderMapEmbed(params.mapData, shop.mapUrl), div({ class: "tribe-card-members" }, span({ class: "tribe-members-count" }, `${i18n.shopProducts}: ${shop.productCount || 0}`) ), - div({ class: "tribe-side-actions" }, - renderFavoriteToggle(shop, returnTo), - shop.author && String(shop.author) !== String(userId) - ? form({ method: "GET", action: "/pm" }, input({ type: "hidden", name: "recipients", value: shop.author }), button({ type: "submit", class: "tribe-action-btn" }, i18n.privateMessage)) - : null, - isAuthor - ? form({ method: "GET", action: `/shops/edit/${encodeURIComponent(shop.key)}` }, - button({ type: "submit", class: "tribe-action-btn" }, i18n.shopUpdate) - ) - : null, - isAuthor - ? form({ method: "GET", action: `/shops/${encodeURIComponent(shop.key)}/orders` }, - button({ type: "submit", class: "tribe-action-btn" }, - i18n.shopOrdersTitle || "Orders", - Number(shop.pendingOrders || 0) > 0 ? span({ class: "shop-orders-badge", title: i18n.shopOrdersPending || "Pending orders" }, ` ● ${shop.pendingOrders}`) : null - ) - ) - : null, - isAuthor - ? form({ method: "POST", action: `/shops/delete/${encodeURIComponent(shop.key)}` }, - button({ type: "submit", class: "tribe-action-btn danger-btn" }, i18n.shopDelete) - ) - : null - ), isAuthor ? div({ class: "tribe-side-actions shop-visibility-row" }, span({ class: "card-label" }, `${i18n.visibilityLabel || "Visibility"}: `), @@ -416,6 +332,26 @@ exports.singleShopView = async (shop, filter, products = [], comments = [], para shop.encrypted ? i18n.shopMakePublic : i18n.shopMakePrivate)) ) : null, + isAuthor + ? div({ class: "tribe-side-actions" }, + form({ method: "GET", action: `/shops/${encodeURIComponent(shop.key)}/orders` }, + button({ type: "submit", class: "tribe-action-btn" }, + i18n.shopOrdersTitle || "Orders", + Number(shop.pendingOrders || 0) > 0 ? span({ class: "shop-orders-badge", title: i18n.shopOrdersPending || "Pending orders" }, ` ● ${shop.pendingOrders}`) : null + ) + ) + ) + : null, + isAuthor + ? div({ class: "tribe-side-actions owner-actions" }, + form({ method: "GET", action: `/shops/edit/${encodeURIComponent(shop.key)}` }, + button({ type: "submit", class: "tribe-action-btn" }, i18n.shopUpdate) + ), + form({ method: "POST", action: `/shops/delete/${encodeURIComponent(shop.key)}` }, + button({ type: "submit", class: "tribe-action-btn danger-btn" }, i18n.shopDelete) + ) + ) + : null, safeArr(shop.tags).length ? div({ class: "tribe-side-tags" }, safeArr(shop.tags).map(tag => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`))) : null @@ -436,6 +372,7 @@ exports.singleShopView = async (shop, filter, products = [], comments = [], para return template( shop.title || i18n.shopTitle, + section(div({ class: "tags-header" }, h2(i18n.shopsTitle), p(i18n.shopDescription))), section(renderModeButtons(filter)), section( div({ class: "tribe-details" }, @@ -451,89 +388,107 @@ exports.singleProductView = async (product, shop, comments = [], params = {}) => const isAuthor = String(product.author) === String(userId) const stock = Number(product.stock) || 0 - return template( - product.title || i18n.shopProductTitle, - section(renderModeButtons("products")), - section( - div({ class: "shop-detail" }, - div({ class: "bookmark-topbar transfer-topbar-single" }, - div({ class: "bookmark-topbar-left" }, - product.author && String(product.author) !== String(userId) - ? form({ method: "GET", action: "/pm" }, input({ type: "hidden", name: "recipients", value: product.author }), button({ type: "submit", class: "filter-btn" }, i18n.privateMessage)) - : null, - form({ method: "GET", action: `/shops/${encodeURIComponent(product.shopId)}` }, - button({ type: "submit", class: "filter-btn" }, `← ${i18n.shopBackToShop}`)) - ), - isAuthor - ? div({ class: "bookmark-actions transfer-actions" }, - form({ method: "GET", action: `/shops/product/edit/${encodeURIComponent(product.key)}` }, - input({ type: "hidden", name: "shopId", value: product.shopId }), - input({ type: "hidden", name: "returnTo", value: returnTo }), - button({ class: "update-btn", type: "submit" }, i18n.shopUpdate) - ), - form({ method: "POST", action: `/shops/product/delete/${encodeURIComponent(product.key)}` }, - input({ type: "hidden", name: "returnTo", value: returnTo }), - button({ class: "delete-btn", type: "submit" }, i18n.shopDelete) - ) - ) - : null - ), - product.image ? div({ class: "shop-detail-media" }, renderMediaBlob(product.image)) : null, - h2(product.title), - renderStarRating(product.opinions, safeArr(product.opinions_inhabitants).length), - product.description ? div({ class: "shop-detail-desc" }, ...renderUrl(product.description)) : null, - div({ class: "shop-product-price" }, `${Number(product.price || 0).toFixed(6)} ECO`), - div({ class: "confirmations-block stock-block" }, - div({ class: "card-field" }, - span({ class: "card-label" }, `${i18n.shopProductStock}: `), - span({ class: "card-value" }, stock > 0 ? String(stock) : i18n.shopOutOfStock) - ), - progress({ class: "confirmations-progress stock-progress", value: Math.min(stock, 100), max: 100 }) - ), - !isAuthor && stock > 0 - ? form({ method: "POST", action: `/shops/product/buy/${encodeURIComponent(product.key)}`, class: "shop-buy-form" }, - input({ type: "hidden", name: "returnTo", value: returnTo }), - p({ class: "shop-buy-form-note" }, i18n.shopBuyEncryptedNote || "Your delivery details are sent encrypted only to the shop owner."), - label(i18n.shopBuyDeliveryAddress || "Delivery address"), - br(), - textarea({ name: "deliveryAddress", required: true, rows: 3, placeholder: i18n.shopBuyDeliveryAddressPlaceholder || "" }), - br(), - br(), - label(i18n.shopBuyContact || "Contact"), - br(), - input({ type: "text", name: "contact", placeholder: i18n.shopBuyContactPlaceholder || "email, phone, etc." }), - br(), - br(), - label(i18n.shopBuyNotes || "Notes"), - br(), - textarea({ name: "notes", rows: 2, placeholder: i18n.shopBuyNotesPlaceholder || "" }), - br(), - br(), - button({ type: "submit", class: "buy-btn" }, i18n.marketActionsBuy || i18n.shopBuy) - ) - : null, - br(), - p({ class: "card-footer" }, - span({ class: "date-link" }, moment(product.createdAt).format("YYYY-MM-DD HH:mm")), - " ", - userLink(product.author) - ), - !isAuthor && params.canRate - ? renderOpinionsVoting('/shops/product/opinions', product.key, product.opinions, returnTo, product.opinions_inhabitants) - : null + const productSide = div({ class: "tribe-side" }, + div({ class: "card-header activity-card-header" }, + renderContentActions(product.key, null, { spread: params.spreads || null, author: product.author, favKind: 'shopProducts', isFavorite: product.isFavorite, returnTo, reportTitle: product.title }) + ), + div({ class: "shop-title-row" }, + h2({ class: "tribe-card-title" }, product.title || i18n.shopProductUntitled) + ), + renderStarRating(product.opinions, safeArr(product.opinions_inhabitants).length), + product.image ? renderMediaBlob(product.image, null, { class: "tribe-detail-image" }) : null, + div({ class: "card-date-highlight" }, `${Number(product.price || 0).toFixed(6)} ECO`), + div({ class: "confirmations-block stock-block" }, + div({ class: "card-field" }, + span({ class: "card-label" }, `${i18n.shopProductStock}: `), + span({ class: "card-value" }, stock > 0 ? String(stock) : i18n.shopOutOfStock) ), + progress({ class: "confirmations-progress stock-progress", value: Math.min(stock, 100), max: 100 }) + ), + table({ class: "tribe-info-table jobs-info-table" }, + tr( + td({ class: "tribe-info-label" }, i18n.shopCreatedAt || "CREATED"), + td({ class: "tribe-info-value", colspan: "3" }, moment(product.createdAt).format("YYYY/MM/DD HH:mm")) + ), + tr( + td({ class: "tribe-info-value", colspan: "4" }, userLink(product.author)) + ) + ), + div({ class: "tribe-side-actions" }, + form({ method: "GET", action: `/shops/${encodeURIComponent(product.shopId)}` }, + button({ type: "submit", class: "tribe-action-btn" }, `← ${i18n.shopBackToShop}`) + ) + ), + isAuthor + ? div({ class: "tribe-side-actions owner-actions" }, + form({ method: "GET", action: `/shops/product/edit/${encodeURIComponent(product.key)}` }, + input({ type: "hidden", name: "shopId", value: product.shopId }), + input({ type: "hidden", name: "returnTo", value: returnTo }), + button({ class: "tribe-action-btn", type: "submit" }, i18n.shopUpdate) + ), + form({ method: "POST", action: `/shops/product/delete/${encodeURIComponent(product.key)}` }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + button({ class: "tribe-action-btn danger-btn", type: "submit" }, i18n.shopDelete) + ) + ) + : null + ) + + const productMain = div({ class: "tribe-main" }, + product.description + ? div({ class: "job-section" }, + h2({ class: "job-section-title" }, i18n.marketItemDescription || i18n.shopDescription), + p({ class: "tribe-side-description" }, ...renderUrl(product.description)) + ) + : null, + !isAuthor && stock > 0 + ? div({ class: "job-section" }, + form({ method: "POST", action: `/shops/product/buy/${encodeURIComponent(product.key)}`, class: "shop-buy-form" }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + p({ class: "shop-buy-form-note" }, i18n.shopBuyEncryptedNote || "Your delivery details are sent encrypted only to the shop owner."), + label(i18n.shopBuyDeliveryAddress || "Delivery address"), + br(), + textarea({ name: "deliveryAddress", required: true, rows: 3, placeholder: i18n.shopBuyDeliveryAddressPlaceholder || "" }), + br(), + br(), + label(i18n.shopBuyContact || "Contact"), + br(), + input({ type: "text", name: "contact", placeholder: i18n.shopBuyContactPlaceholder || "email, phone, etc." }), + br(), + br(), + label(i18n.shopBuyNotes || "Notes"), + br(), + textarea({ name: "notes", rows: 2, placeholder: i18n.shopBuyNotesPlaceholder || "" }), + br(), + br(), + button({ type: "submit", class: "buy-btn" }, i18n.marketActionsBuy || i18n.shopBuy) + ) + ) + : null, + renderEngagement(product.key, + !isAuthor && params.canRate + ? renderOpinionsVoting('/shops/product/opinions', product.key, product.opinions, returnTo, product.opinions_inhabitants) + : null, renderCommentsSection(product.key, returnTo, comments) ) ) + + return template( + product.title || i18n.shopProductTitle, + section(div({ class: "tags-header" }, h2(i18n.shopsTitle), p(i18n.shopDescription))), + section(renderModeButtons("products")), + section(div({ class: "tribe-details" }, productSide, productMain)) + ) } + exports.editProductView = async (product, shopId, params = {}) => { const returnTo = safeText(params.returnTo) || `/shops/${encodeURIComponent(shopId)}` return template( i18n.shopProductUpdate, section( div({ class: "tags-header" }, h2(i18n.shopProductUpdate)), - renderProductForm(shopId, product, true, returnTo) + renderProductForm(shopId, product, true, returnTo, await renderSpreadEditWarning(product && (product.key || product.id))) ) ) } @@ -586,7 +541,7 @@ exports.shopOrdersView = async (shop, orders) => { div({ class: "card-field" }, span({ class: "card-label" }, `${i18n.shopBuyDeliveryAddress || "Delivery address"}:`), span({ class: "card-value" }, String(o.deliveryAddress || ""))), o.contact ? div({ class: "card-field" }, span({ class: "card-label" }, `${i18n.shopBuyContact || "Contact"}:`), span({ class: "card-value" }, String(o.contact))) : null, o.notes ? div({ class: "card-field" }, span({ class: "card-label" }, `${i18n.shopBuyNotes || "Notes"}:`), span({ class: "card-value" }, String(o.notes))) : null, - p({ class: "card-footer" }, span({ class: "date-link" }, moment(o.createdAt || o.ts).format("YYYY-MM-DD HH:mm"))), + p({ class: "card-footer" }, span({ class: "date-link" }, moment(o.createdAt || o.ts).format("YYYY/MM/DD HH:mm"))), orderActions(o) )) return template( @@ -623,7 +578,7 @@ exports.myPurchasesView = async (purchases) => { div({ class: "card-field" }, span({ class: "card-label" }, `${i18n.shopBuyDeliveryAddress || "Delivery address"}:`), span({ class: "card-value" }, String(o.deliveryAddress || ""))), o.contact ? div({ class: "card-field" }, span({ class: "card-label" }, `${i18n.shopBuyContact || "Contact"}:`), span({ class: "card-value" }, String(o.contact))) : null, o.notes ? div({ class: "card-field" }, span({ class: "card-label" }, `${i18n.shopBuyNotes || "Notes"}:`), span({ class: "card-value" }, String(o.notes))) : null, - p({ class: "card-footer" }, span({ class: "date-link" }, moment(o.createdAt || o.ts).format("YYYY-MM-DD HH:mm"))), + p({ class: "card-footer" }, span({ class: "date-link" }, moment(o.createdAt || o.ts).format("YYYY/MM/DD HH:mm"))), buyerActions(o) )) return template( diff --git a/src/views/task_view.js b/src/views/task_view.js index 38d18c8..3beb4e6 100644 --- a/src/views/task_view.js +++ b/src/views/task_view.js @@ -1,6 +1,8 @@ const { div, h2, p, section, button, form, input, select, option, a, br, textarea, label, span, table, tr, td, img, video } = require("../server/node_modules/hyperaxe"); +const { renderCommentsSection: renderSharedCommentsSection } = require("./comments_view"); const moment = require("../server/node_modules/moment"); -const { template, i18n, renderOpinionsVoting, userLink, renderStateChip, renderPrivacyChip, renderLifespanChip, renderEcoTax, renderSpreadButton } = require("./main_views"); +const { template, i18n, renderOpinionsVoting, renderEngagement, userLink, renderStateChip, renderPrivacyChip, renderLifespanChip, renderEcoTax, renderSpreadButton, renderSpreadEditWarning, renderContentActions, renderDocumentActions } = require("./main_views"); +const { renderPhotoGallery, renderGalleryFields, imagesOf } = require("./gallery_view"); const { config } = require("../server/SSB_server.js"); const { renderUrl } = require("../backend/renderUrl"); @@ -19,7 +21,7 @@ const renderTaskMediaBlob = (value, attrs = {}) => { const userId = config.keys.id; const opt = (value, isSelected, text) => - option(Object.assign({ value }, isSelected ? { selected: "selected" } : {}), text); + option(Object.assign({ value }, isSelected ? { ...("selected" ? { selected: true } : {})} : {}), text); const safeArray = (v) => Array.isArray(v) ? v : []; @@ -57,95 +59,50 @@ const renderTaskOwnerActions = (task, returnTo) => { input({ type: "hidden", name: "returnTo", value: returnTo }), button({ type: "submit", class: "delete-btn" }, i18n.taskDeleteButton) ), + ]; +}; + +const renderTaskStatusRow = (task, returnTo) => { + const st = normalizeStatus(task.status || "OPEN"); + return div({ class: "tribe-side-actions housing-status-row" }, + span({ class: "card-label" }, `${i18n.taskStatus}: `), + renderStateChip(st === "CLOSED" ? "hidden" : "mutuals", st === "CLOSED" ? "🔒" : "👁", statusLabel(task.status)), form( { method: "POST", action: `/tasks/status/${encodeURIComponent(task.id)}`, class: "project-control-form project-control-form--status" }, input({ type: "hidden", name: "returnTo", value: returnTo }), select( { name: "status", class: "project-control-select" }, - option({ value: "OPEN", selected: st === "OPEN" }, i18n.taskStatusOpen), - option({ value: "IN-PROGRESS", selected: st === "IN-PROGRESS" }, i18n.taskStatusInProgress), - option({ value: "CLOSED", selected: st === "CLOSED" }, i18n.taskStatusClosed) + option({ value: "OPEN", ...(st === "OPEN" ? { selected: true } : {})}, i18n.taskStatusOpen), + option({ value: "IN-PROGRESS", ...(st === "IN-PROGRESS" ? { selected: true } : {})}, i18n.taskStatusInProgress), + option({ value: "CLOSED", ...(st === "CLOSED" ? { selected: true } : {})}, i18n.taskStatusClosed) ), - button({ class: "status-btn project-control-btn", type: "submit" }, setStatusLabel) + button({ class: "status-btn project-control-btn", type: "submit" }, i18n.taskSetStatus) ) - ]; + ); }; const renderTaskAssignAction = (task, isAssignedToMe, returnTo) => { const st = normalizeStatus(task.status || "OPEN"); if (st === "CLOSED") return null; - return form( - { method: "POST", action: `/tasks/assign/${encodeURIComponent(task.id)}` }, - input({ type: "hidden", name: "returnTo", value: returnTo }), - button({ type: "submit", class: "filter-btn" }, isAssignedToMe ? i18n.taskUnassignButton : i18n.taskAssignButton) - ); + return [ + span({ class: "card-label" }, `${i18n.taskAssignedTo}: `), + renderStateChip(isAssignedToMe ? "mutuals" : "whole", isAssignedToMe ? "👤" : "○", + String(isAssignedToMe ? i18n.taskAssignedChip : i18n.taskUnassignedChip).toUpperCase()), + form( + { method: "POST", action: `/tasks/assign/${encodeURIComponent(task.id)}`, class: "project-control-form" }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + button({ type: "submit", class: "status-btn project-control-btn" }, isAssignedToMe ? i18n.taskUnassignButton : i18n.taskAssignButton) + ) + ]; }; const renderTaskCommentsSection = (taskId, comments = [], currentFilter = "all") => { - const commentsCount = Array.isArray(comments) ? comments.length : 0; const returnTo = `/tasks/${encodeURIComponent(taskId)}?filter=${encodeURIComponent(currentFilter || "all")}`; - - return div( - { class: "vote-comments-section" }, - div( - { class: "comments-count" }, - span({ class: "card-label" }, i18n.voteCommentsLabel + ": "), - span({ class: "card-value" }, String(commentsCount)) - ), - div( - { class: "comment-form-wrapper" }, - h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel), - form( - { method: "POST", action: `/tasks/${encodeURIComponent(taskId)}/comments`, class: "comment-form", enctype: "multipart/form-data" }, - input({ type: "hidden", name: "returnTo", value: returnTo }), - textarea({ - id: "comment-text", - name: "text", - rows: 4, - class: "comment-textarea", - placeholder: i18n.voteNewCommentPlaceholder - }), - div({ class: "comment-file-upload" }, label(i18n.uploadMedia), input({ type: "file", name: "blob" })), - br(), - button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton) - ) - ), - (() => { - const visibleComments = (comments || []).filter(c => { - const t = c && c.value && c.value.content && c.value.content.text; - return t && String(t).trim(); - }); - return visibleComments.length - ? div( - { class: "comments-list" }, - visibleComments.map((c) => { - const author = c.value && c.value.author ? c.value.author : ""; - const ts = c.value && c.value.timestamp ? c.value.timestamp : c.timestamp; - const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : ""; - const relDate = ts ? moment(ts).fromNow() : ""; - - const content = c.value && c.value.content ? c.value.content : {}; - const root = content.fork || content.root || ""; - const text = content.text || ""; - - return div( - { class: "votations-comment-card" }, - span( - { class: "created-at" }, - span(i18n.createdBy), - author ? userLink(author) : span("(unknown)"), - absDate ? span(" | ") : "", - absDate ? span({ class: "votations-comment-date" }, absDate) : "", - relDate ? span({ class: "votations-comment-date" }, " | ", i18n.sendTime) : "", - relDate && root ? a({ href: `/thread/${encodeURIComponent(root)}#${encodeURIComponent(c.key)}` }, relDate) : "" - ), - p({ class: "votations-comment-text" }, ...renderUrl(String(text))) - ); - }) - ) - : p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet); - })() - ); + return renderSharedCommentsSection({ + action: `/tasks/${encodeURIComponent(taskId)}/comments`, + comments: comments, + returnTo: returnTo + }); }; const renderTaskStatusChip = (status) => { @@ -176,10 +133,11 @@ const renderTaskItem = (task, filter, spreadInfo) => { const assignees = safeArray(task.assignees); const isPrivate = String(task.isPublic || "").toUpperCase() === "PRIVATE"; - const heroNode = task.image + const cover = imagesOf(task)[0] + const heroNode = cover ? div({ class: "tribe-card-image-wrapper" }, a({ href: `/tasks/${encodeURIComponent(task.id)}` }, - renderTaskMediaBlob(task.image, { class: "tribe-card-hero-image" }) + renderTaskMediaBlob(cover, { class: "tribe-card-hero-image" }) ), div({ class: "tribe-visit-btn-wrapper" }, form({ method: "GET", action: `/tasks/${encodeURIComponent(task.id)}` }, @@ -201,6 +159,10 @@ const renderTaskItem = (task, filter, spreadInfo) => { const end = task.endTime ? moment(task.endTime).format("YYYY/MM/DD HH:mm") : ""; return div({ class: "tribe-card task-card" }, + div({ class: "card-header activity-card-header" }, + span(), + renderContentActions(task.id, `/tasks/${encodeURIComponent(task.id)}`, { spread: spreadInfo || null, author: task.author, favKind: 'tasks', isFavorite: task.isFavorite, reportTitle: task.title }) + ), heroNode, div({ class: "tribe-card-body" }, div({ class: "shop-title-row" }, @@ -212,16 +174,7 @@ const renderTaskItem = (task, filter, spreadInfo) => { (start || end) ? p({ class: "card-date-highlight" }, start && end ? `${start} → ${end}` : (start || end)) : null, div({ class: "tribe-card-members" }, span({ class: "tribe-members-count" }, `${i18n.taskAssignedTo}: ${assignees.length}`) - ), - div({ class: "card-spread-centered" }, renderSpreadButton(task.id, spreadInfo)), - !heroNode - ? div({ class: "card-visit-btn-centered" }, - form({ method: "GET", action: `/tasks/${encodeURIComponent(task.id)}` }, - input({ type: "hidden", name: "filter", value: currentFilter }), - button({ type: "submit", class: "filter-btn" }, i18n.viewTask || "View Task") - ) - ) - : null + ) ) ); }; @@ -230,19 +183,7 @@ exports.taskView = async (tasks, filter, taskId, returnTo, params = {}) => { const list = Array.isArray(tasks) ? tasks : [tasks]; const currentFilter = filter || "all"; - const title = - currentFilter === "mine" ? i18n.taskMineSectionTitle : - currentFilter === "create" ? i18n.taskCreateSectionTitle : - currentFilter === "edit" ? i18n.taskUpdateSectionTitle : - currentFilter === "open" ? i18n.taskOpenTitle : - currentFilter === "in-progress" ? i18n.taskInProgressTitle : - currentFilter === "closed" ? i18n.taskClosedTitle : - currentFilter === "assigned" ? i18n.taskAssignedTitle : - currentFilter === "priority-urgent" ? i18n.taskFilterUrgent : - currentFilter === "priority-high" ? i18n.taskFilterHigh : - currentFilter === "priority-medium" ? i18n.taskFilterMedium : - currentFilter === "priority-low" ? i18n.taskFilterLow : - i18n.taskAllSectionTitle; + const title = i18n.tasksTitle; const canSee = (t) => { const vis = String(t.isPublic || "").toUpperCase(); @@ -268,7 +209,8 @@ exports.taskView = async (tasks, filter, taskId, returnTo, params = {}) => { filtered = filtered.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); const editTask = list.find((t) => t.id === taskId) || {}; - const editTags = Array.isArray(editTask.tags) ? editTask.tags : []; + const formData = currentFilter === "edit" ? editTask : (params.draft || {}); + const editTags = Array.isArray(formData.tags) ? formData.tags : (typeof formData.tags === "string" ? formData.tags.split(",").map(t => t.trim()).filter(Boolean) : []); const minCreate = moment().add(1, "minute").format("YYYY-MM-DDTHH:mm"); const ret = typeof returnTo === "string" && returnTo.startsWith("/tasks") @@ -287,40 +229,52 @@ exports.taskView = async (tasks, filter, taskId, returnTo, params = {}) => { { class: "filters" }, form( { method: "GET", action: "/tasks" }, - button({ type: "submit", name: "filter", value: "all", class: currentFilter === "all" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterAll), - button({ type: "submit", name: "filter", value: "mine", class: currentFilter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterMine), - button({ type: "submit", name: "filter", value: "assigned", class: currentFilter === "assigned" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterAssigned), - button({ type: "submit", name: "filter", value: "open", class: currentFilter === "open" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterOpen), - button({ type: "submit", name: "filter", value: "in-progress", class: currentFilter === "in-progress" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterInProgress), - button({ type: "submit", name: "filter", value: "closed", class: currentFilter === "closed" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterClosed), - button({ type: "submit", name: "filter", value: "priority-low", class: currentFilter === "priority-low" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterLow), - button({ type: "submit", name: "filter", value: "priority-medium", class: currentFilter === "priority-medium" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterMedium), - button({ type: "submit", name: "filter", value: "priority-high", class: currentFilter === "priority-high" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterHigh), - button({ type: "submit", name: "filter", value: "priority-urgent", class: currentFilter === "priority-urgent" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterUrgent), + button({ type: "submit", name: "filter", value: "all", class: currentFilter === "all" ? "filter-btn active" : "filter-btn" }, String(i18n.taskFilterAll).toUpperCase()), + button({ type: "submit", name: "filter", value: "mine", class: currentFilter === "mine" ? "filter-btn active" : "filter-btn" }, String(i18n.taskFilterMine).toUpperCase()), + button({ type: "submit", name: "filter", value: "assigned", class: currentFilter === "assigned" ? "filter-btn active" : "filter-btn" }, String(i18n.taskFilterAssigned).toUpperCase()), + button({ type: "submit", name: "filter", value: "open", class: currentFilter === "open" ? "filter-btn active" : "filter-btn" }, String(i18n.taskFilterOpen).toUpperCase()), + button({ type: "submit", name: "filter", value: "in-progress", class: currentFilter === "in-progress" ? "filter-btn active" : "filter-btn" }, String(i18n.taskFilterInProgress).toUpperCase()), + button({ type: "submit", name: "filter", value: "closed", class: currentFilter === "closed" ? "filter-btn active" : "filter-btn" }, String(i18n.taskFilterClosed).toUpperCase()), + button({ type: "submit", name: "filter", value: "priority-low", class: currentFilter === "priority-low" ? "filter-btn active" : "filter-btn" }, String(i18n.taskFilterLow).toUpperCase()), + button({ type: "submit", name: "filter", value: "priority-medium", class: currentFilter === "priority-medium" ? "filter-btn active" : "filter-btn" }, String(i18n.taskFilterMedium).toUpperCase()), + button({ type: "submit", name: "filter", value: "priority-high", class: currentFilter === "priority-high" ? "filter-btn active" : "filter-btn" }, String(i18n.taskFilterHigh).toUpperCase()), + button({ type: "submit", name: "filter", value: "priority-urgent", class: currentFilter === "priority-urgent" ? "filter-btn active" : "filter-btn" }, String(i18n.taskFilterUrgent).toUpperCase()), button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.taskCreateButton) ) - ) + ), + currentFilter === "edit" || currentFilter === "create" + ? null + : div({ class: "filters" }, + form({ method: "GET", action: "/tasks", class: "filter-box" }, + input({ type: "hidden", name: "filter", value: currentFilter }), + input({ type: "text", name: "q", value: params.q || "", placeholder: i18n.taskSearchPlaceholder, class: "filter-box__input" }), + div({ class: "filter-box__controls" }, + button({ type: "submit", class: "filter-box__button" }, i18n.searchButton) + ) + ) + ) ), section( currentFilter === "edit" || currentFilter === "create" ? div( { class: "task-form" }, + currentFilter === "edit" ? await renderSpreadEditWarning(taskId) : null, form( { action: currentFilter === "edit" ? `/tasks/update/${encodeURIComponent(taskId)}` : "/tasks/create", method: "POST", enctype: "multipart/form-data" }, input({ type: "hidden", name: "returnTo", value: ret }), label(i18n.taskTitleLabel), br(), - input({ type: "text", name: "title", required: true, value: currentFilter === "edit" ? (editTask.title || "") : (params.prefillTitle || "") }), br(), + input({ type: "text", name: "title", maxlength: "100", required: true, value: formData.title || params.prefillTitle || "" }), br(), label(i18n.taskDescriptionLabel), br(), - textarea({ name: "description", required: true, placeholder: i18n.taskDescriptionPlaceholder, rows: "4" }, currentFilter === "edit" ? (editTask.description || "") : (params.prefillDescription || "")), br(), - label(i18n.uploadMedia), br(), - input({ type: "file", name: "image", accept: "image/*" }), br(),br(), + textarea({ name: "description", required: true, placeholder: i18n.taskDescriptionPlaceholder, rows: "4" }, formData.description || params.prefillDescription || ""), br(), + ...renderGalleryFields(formData, currentFilter === "edit"), + br(), label(i18n.taskStartTimeLabel), br(), input({ type: "datetime-local", name: "startTime", required: true, min: currentFilter === "create" ? minCreate : undefined, - value: currentFilter === "edit" && editTask.startTime ? moment(editTask.startTime).format("YYYY-MM-DDTHH:mm") : "" + value: formData.startTime ? moment(formData.startTime).format("YYYY-MM-DDTHH:mm") : "" }), br(), br(), label(i18n.taskEndTimeLabel), br(), input({ @@ -328,25 +282,25 @@ exports.taskView = async (tasks, filter, taskId, returnTo, params = {}) => { name: "endTime", required: true, min: currentFilter === "create" ? minCreate : undefined, - value: currentFilter === "edit" && editTask.endTime ? moment(editTask.endTime).format("YYYY-MM-DDTHH:mm") : "" + value: formData.endTime ? moment(formData.endTime).format("YYYY-MM-DDTHH:mm") : "" }), br(), br(), label(i18n.taskPriorityLabel), br(), select( { name: "priority", required: true }, - opt("URGENT", String(editTask.priority || "").toUpperCase() === "URGENT", i18n.taskPriorityUrgent), - opt("HIGH", String(editTask.priority || "").toUpperCase() === "HIGH", i18n.taskPriorityHigh), - opt("MEDIUM", String(editTask.priority || "").toUpperCase() === "MEDIUM", i18n.taskPriorityMedium), - opt("LOW", !editTask.priority || String(editTask.priority || "").toUpperCase() === "LOW", i18n.taskPriorityLow) + opt("URGENT", String(formData.priority || "").toUpperCase() === "URGENT", i18n.taskPriorityUrgent), + opt("HIGH", String(formData.priority || "").toUpperCase() === "HIGH", i18n.taskPriorityHigh), + opt("MEDIUM", String(formData.priority || "").toUpperCase() === "MEDIUM", i18n.taskPriorityMedium), + opt("LOW", !formData.priority || String(formData.priority || "").toUpperCase() === "LOW", i18n.taskPriorityLow) ), br(), br(), label(i18n.taskLocationLabel), br(), - input({ type: "text", name: "location", value: editTask.location || "" }), br(), + input({ type: "text", name: "location", value: formData.location || "" }), br(), label(i18n.taskTagsLabel), br(), input({ type: "text", name: "tags", value: editTags.join(", ") }), br(), label(i18n.taskVisibilityLabel), br(), select( { name: "isPublic", id: "isPublic" }, - opt("PUBLIC", String(editTask.isPublic || "PUBLIC").toUpperCase() === "PUBLIC", i18n.taskPublic), - opt("PRIVATE", String(editTask.isPublic || "").toUpperCase() === "PRIVATE", i18n.taskPrivate) + opt("PUBLIC", String(formData.isPublic || "PUBLIC").toUpperCase() === "PUBLIC", i18n.taskPublic), + opt("PRIVATE", String(formData.isPublic || "").toUpperCase() === "PRIVATE", i18n.taskPrivate) ), br(), br(), button({ type: "submit" }, currentFilter === "edit" ? i18n.taskUpdateButton : i18n.taskCreateButton) ) @@ -371,16 +325,16 @@ exports.singleTaskView = async (task, filter, comments = [], params = {}) => { { class: "filters" }, form( { method: "GET", action: "/tasks" }, - button({ type: "submit", name: "filter", value: "all", class: currentFilter === "all" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterAll), - button({ type: "submit", name: "filter", value: "mine", class: currentFilter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterMine), - button({ type: "submit", name: "filter", value: "assigned", class: currentFilter === "assigned" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterAssigned), - button({ type: "submit", name: "filter", value: "open", class: currentFilter === "open" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterOpen), - button({ type: "submit", name: "filter", value: "in-progress", class: currentFilter === "in-progress" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterInProgress), - button({ type: "submit", name: "filter", value: "closed", class: currentFilter === "closed" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterClosed), - button({ type: "submit", name: "filter", value: "priority-low", class: currentFilter === "priority-low" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterLow), - button({ type: "submit", name: "filter", value: "priority-medium", class: currentFilter === "priority-medium" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterMedium), - button({ type: "submit", name: "filter", value: "priority-high", class: currentFilter === "priority-high" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterHigh), - button({ type: "submit", name: "filter", value: "priority-urgent", class: currentFilter === "priority-urgent" ? "filter-btn active" : "filter-btn" }, i18n.taskFilterUrgent), + button({ type: "submit", name: "filter", value: "all", class: currentFilter === "all" ? "filter-btn active" : "filter-btn" }, String(i18n.taskFilterAll).toUpperCase()), + button({ type: "submit", name: "filter", value: "mine", class: currentFilter === "mine" ? "filter-btn active" : "filter-btn" }, String(i18n.taskFilterMine).toUpperCase()), + button({ type: "submit", name: "filter", value: "assigned", class: currentFilter === "assigned" ? "filter-btn active" : "filter-btn" }, String(i18n.taskFilterAssigned).toUpperCase()), + button({ type: "submit", name: "filter", value: "open", class: currentFilter === "open" ? "filter-btn active" : "filter-btn" }, String(i18n.taskFilterOpen).toUpperCase()), + button({ type: "submit", name: "filter", value: "in-progress", class: currentFilter === "in-progress" ? "filter-btn active" : "filter-btn" }, String(i18n.taskFilterInProgress).toUpperCase()), + button({ type: "submit", name: "filter", value: "closed", class: currentFilter === "closed" ? "filter-btn active" : "filter-btn" }, String(i18n.taskFilterClosed).toUpperCase()), + button({ type: "submit", name: "filter", value: "priority-low", class: currentFilter === "priority-low" ? "filter-btn active" : "filter-btn" }, String(i18n.taskFilterLow).toUpperCase()), + button({ type: "submit", name: "filter", value: "priority-medium", class: currentFilter === "priority-medium" ? "filter-btn active" : "filter-btn" }, String(i18n.taskFilterMedium).toUpperCase()), + button({ type: "submit", name: "filter", value: "priority-high", class: currentFilter === "priority-high" ? "filter-btn active" : "filter-btn" }, String(i18n.taskFilterHigh).toUpperCase()), + button({ type: "submit", name: "filter", value: "priority-urgent", class: currentFilter === "priority-urgent" ? "filter-btn active" : "filter-btn" }, String(i18n.taskFilterUrgent).toUpperCase()), button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.taskCreateButton) ) ); @@ -421,15 +375,8 @@ exports.singleTaskView = async (task, filter, comments = [], params = {}) => { : null; const sideActions = []; - if (task.author && task.author !== userId) { - sideActions.push(form({ method: "GET", action: "/pm" }, - input({ type: "hidden", name: "recipients", value: task.author }), - button({ type: "submit", class: "filter-btn" }, i18n.privateMessage) - )); - } const assignNode = renderTaskAssignAction(task, isAssignedToMe, returnToSelf); - if (assignNode) sideActions.push(assignNode); - if (isAuthor) sideActions.push(...renderTaskOwnerActions(task, returnToSelf)); + const ownerActions = isAuthor ? renderTaskOwnerActions(task, returnToSelf) : []; const infoRows = []; const pushRow = (labelText, valueNode) => @@ -444,19 +391,25 @@ exports.singleTaskView = async (task, filter, comments = [], params = {}) => { if (task.location && String(task.location).trim()) pushRow(i18n.taskLocationLabel, task.location); const taskSide = div({ class: "tribe-side" }, + div({ class: "card-header activity-card-header" }, + renderContentActions(task.id, null, { spread: params.spreads || null, author: task.author, favKind: 'tasks', isFavorite: task.isFavorite, reportTitle: task.title }) + ), div({ class: "shop-title-row" }, h2({ class: "tribe-card-title" }, task.title) ), chips.length ? div({ class: "card-chips-row" }, ...chips) : null, - div({ class: "card-spread-centered" }, renderSpreadButton(task.id, params.spreads)), - task.image ? renderTaskMediaBlob(task.image, { class: "tribe-detail-image" }) : null, + renderPhotoGallery(task, 'task'), table({ class: "tribe-info-table jobs-info-table" }, ...infoRows), + assignNode ? div({ class: "tribe-side-actions housing-status-row task-assign-row" }, ...assignNode) : null, + isAuthor ? renderTaskStatusRow(task, returnToSelf) : null, tagsNode, div({ class: "tribe-card-members" }, span({ class: "tribe-members-count" }, `${i18n.taskAssignedTo}: ${assignees.length}`) ), assigneesListNode, - sideActions.length ? div({ class: "tribe-side-actions" }, ...sideActions) : null + sideActions.length ? div({ class: "tribe-side-actions" }, ...sideActions) : null, + ownerActions.length ? div({ class: "tribe-side-actions owner-actions" }, ...ownerActions) : null, + renderDocumentActions('tasks', task.id) ); const returnToOpinions = `/tasks/${encodeURIComponent(task.id)}?filter=${encodeURIComponent(currentFilter)}`; @@ -473,13 +426,13 @@ exports.singleTaskView = async (task, filter, comments = [], params = {}) => { span({ class: "date-link" }, `${moment(task.createdAt).format("YYYY/MM/DD HH:mm")} ${i18n.performed} `), userLink(task.author) ), - opinionsBar, - renderTaskCommentsSection(task.id, comments, currentFilter) + renderEngagement(task.id, opinionsBar, renderTaskCommentsSection(task.id, comments, currentFilter)) ); return template( task.title, section( + div({ class: "tags-header" }, h2(i18n.tasksTitle), p(i18n.tasksDescription)), filterBar, div({ class: "tribe-details" }, taskSide, taskMain) ) diff --git a/src/views/torrents_view.js b/src/views/torrents_view.js index dad95bc..cef7a56 100644 --- a/src/views/torrents_view.js +++ b/src/views/torrents_view.js @@ -18,8 +18,9 @@ const { th, td } = require("../server/node_modules/hyperaxe"); +const { renderCommentsSection: renderSharedCommentsSection } = require("./comments_view"); -const { template, i18n, renderOpinionsVoting, userLink, renderSpreadButton, renderEcoTax, renderLifespanChip } = require("./main_views"); +const { template, i18n, renderOpinionsVoting, renderEngagement, userLink, renderSpreadButton, renderEcoTax, renderLifespanChip , renderSpreadEditWarning, renderContentActions } = require("./main_views"); const moment = require("../server/node_modules/moment"); const { config } = require("../server/SSB_server.js"); const { renderUrl } = require("../backend/renderUrl"); @@ -49,21 +50,6 @@ const renderTags = (tags) => { : null; }; -const renderTorrentFavoriteToggle = (torrentObj, returnTo = "") => - form( - { - method: "POST", - action: torrentObj.isFavorite - ? `/torrents/favorites/remove/${encodeURIComponent(torrentObj.key)}` - : `/torrents/favorites/add/${encodeURIComponent(torrentObj.key)}` - }, - returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null, - button( - { type: "submit", class: "filter-btn" }, - torrentObj.isFavorite ? i18n.torrentRemoveFavoriteButton : i18n.torrentAddFavoriteButton - ) - ); - const renderTorrentOwnerActions = (filter, torrentObj, params = {}) => { const returnTo = buildReturnTo(filter, params); const isAuthor = String(torrentObj.author) === String(userId); @@ -93,63 +79,11 @@ const renderTorrentOwnerActions = (filter, torrentObj, params = {}) => { }; const renderTorrentCommentsSection = (torrentId, comments = [], returnTo = null) => { - const list = safeArr(comments); - const commentsCount = list.length; - - return div( - { class: "vote-comments-section" }, - div( - { class: "comments-count" }, - span({ class: "card-label" }, i18n.voteCommentsLabel + ": "), - span({ class: "card-value" }, String(commentsCount)) - ), - div( - { class: "comment-form-wrapper" }, - h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel), - form( - { method: "POST", action: `/torrents/${encodeURIComponent(torrentId)}/comments`, class: "comment-form", enctype: "multipart/form-data" }, - returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null, - textarea({ - id: "comment-text", - name: "text", - rows: 4, - class: "comment-textarea", - placeholder: i18n.voteNewCommentPlaceholder - }), - div({ class: "comment-file-upload" }, label(i18n.uploadMedia), input({ type: "file", name: "blob" })), - br(), - button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton) - ) - ), - list.length - ? div( - { class: "comments-list" }, - list.map((c) => { - const author = c?.value?.author || ""; - const ts = c?.value?.timestamp || c?.timestamp; - const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : ""; - const relDate = ts ? moment(ts).fromNow() : ""; - const content = c?.value?.content || {}; - const rootId = content.fork || content.root || null; - const text = content.text || ""; - - return div( - { class: "votations-comment-card" }, - span( - { class: "created-at" }, - span(i18n.createdBy), - author ? userLink(author) : span("(unknown)"), - absDate ? span(" | ") : "", - absDate ? span({ class: "votations-comment-date" }, absDate) : "", - relDate ? span({ class: "votations-comment-date" }, " | ", i18n.sendTime) : "", - relDate && rootId ? a({ href: `/thread/${encodeURIComponent(rootId)}#${encodeURIComponent(c.key)}` }, relDate) : "" - ), - p({ class: "votations-comment-text" }, ...renderUrl(text)) - ); - }) - ) - : p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet) - ); + return renderSharedCommentsSection({ + action: `/torrents/${encodeURIComponent(torrentId)}/comments`, + comments: comments, + returnTo: returnTo + }); }; const formatSize = (bytes) => { @@ -206,6 +140,7 @@ const renderTorrentForm = (filter, torrentId, torrentToEdit, params = {}) => { const tribeId = safeText(params.tribeId || ""); return div( { class: "div-center audio-form" }, + params.spreadWarning || null, form( { action: filter === "edit" ? `/torrents/update/${encodeURIComponent(torrentId)}` : "/torrents/create", @@ -221,7 +156,7 @@ const renderTorrentForm = (filter, torrentId, torrentToEdit, params = {}) => { br(), span(i18n.torrentTitleLabel), br(), - input({ type: "text", name: "title", placeholder: i18n.torrentTitlePlaceholder, value: torrentToEdit?.title || "", required: true }), + input({ type: "text", name: "title", maxlength: "100", placeholder: i18n.torrentTitlePlaceholder, value: torrentToEdit?.title || "", required: true }), br(), span(i18n.torrentDescriptionLabel), br(), @@ -243,20 +178,8 @@ const renderTorrentForm = (filter, torrentId, torrentToEdit, params = {}) => { }; exports.torrentsView = async (torrents, filter = "all", torrentId = null, params = {}) => { - const title = - filter === "mine" - ? i18n.torrentMineSectionTitle - : filter === "create" - ? i18n.torrentCreateSectionTitle - : filter === "edit" - ? i18n.torrentUpdateSectionTitle - : filter === "recent" - ? i18n.torrentRecentSectionTitle - : filter === "top" - ? i18n.torrentTopSectionTitle - : filter === "favorites" - ? i18n.torrentFavoritesSectionTitle - : i18n.torrentAllSectionTitle; + if (filter === "edit") params = { ...params, spreadWarning: await renderSpreadEditWarning(torrentId) }; + const title = i18n.torrentsTitle; const q = safeText(params.q || ""); const sort = safeText(params.sort || "recent"); @@ -283,14 +206,14 @@ exports.torrentsView = async (torrents, filter = "all", torrentId = null, params { method: "GET", action: "/torrents", class: "ui-toolbar ui-toolbar--filters" }, input({ type: "hidden", name: "q", value: q }), input({ type: "hidden", name: "sort", value: sort }), - button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, i18n.torrentFilterAll), - button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.torrentFilterMine), - button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.torrentFilterRecent), - button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, i18n.torrentFilterTop), - button( + button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, String(i18n.torrentFilterAll).toUpperCase()), + button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, String(i18n.torrentFilterMine).toUpperCase()), + button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, String(i18n.torrentFilterRecent).toUpperCase()), button( { type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" }, - i18n.torrentFilterFavorites + String(i18n.torrentFilterFavorites).toUpperCase() ), + + button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, String(i18n.torrentFilterTop).toUpperCase()), button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.torrentCreateButton) ) ) @@ -315,9 +238,9 @@ exports.torrentsView = async (torrents, filter = "all", torrentId = null, params { class: "filter-box__controls" }, select( { name: "sort", class: "filter-box__select" }, - option({ value: "recent", selected: sort === "recent" }, i18n.torrentSortRecent), - option({ value: "oldest", selected: sort === "oldest" }, i18n.torrentSortOldest), - option({ value: "top", selected: sort === "top" }, i18n.torrentSortTop) + option({ value: "recent", ...(sort === "recent" ? { selected: true } : {})}, i18n.torrentSortRecent), + option({ value: "oldest", ...(sort === "oldest" ? { selected: true } : {})}, i18n.torrentSortOldest), + option({ value: "top", ...(sort === "top" ? { selected: true } : {})}, i18n.torrentSortTop) ), button({ type: "submit", class: "filter-box__button" }, i18n.torrentSearchButton) ) @@ -345,18 +268,21 @@ exports.singleTorrentView = async (torrentObj, filter = "all", comments = [], pa const ownerActions = renderTorrentOwnerActions(filter, torrentObj, { q, sort }); const sideActions = []; - sideActions.push(renderTorrentFavoriteToggle(torrentObj, returnTo)); - if (torrentObj.author && String(torrentObj.author) !== String(userId)) { - sideActions.push(form( - { method: "GET", action: "/pm" }, - input({ type: "hidden", name: "recipients", value: torrentObj.author }), - button({ type: "submit", class: "filter-btn" }, i18n.privateMessage) - )); - } for (const a of ownerActions) sideActions.push(a); const tagsNode = renderTags(torrentObj.tags); + const detailActions = div({ class: "card-header activity-card-header" }, + renderContentActions(torrentObj.key, null, { + author: torrentObj.author, + favKind: 'torrents', + isFavorite: torrentObj.isFavorite, + spread: params.spreads || null, + returnTo, + reportTitle: torrentObj.title + }) + ); + const torrentSide = div({ class: "tribe-side" }, div({ class: "shop-title-row" }, title ? h2({ class: "tribe-card-title" }, title) : null, @@ -367,11 +293,11 @@ exports.singleTorrentView = async (torrentObj, filter = "all", comments = [], pa ? p({ class: "tribe-side-description" }, ...renderUrl(torrentObj.description)) : null, tagsNode, - div({ class: "card-spread-centered" }, renderSpreadButton(torrentObj.key, params.spreads)), sideActions.length ? div({ class: "tribe-side-actions" }, ...sideActions) : null ); const torrentMain = div({ class: "tribe-main" }, + detailActions, torrentObj.url && torrentObj.url.startsWith("&") ? div({ class: "torrent-download" }, a({ href: `/blob/${encodeURIComponent(torrentObj.url)}?name=${encodeURIComponent((torrentObj.title || 'download').replace(/\.torrent$/i, '') + '.torrent')}` , class: "filter-btn" }, i18n.torrentDownloadButton || "DOWNLOAD IT!") @@ -384,18 +310,20 @@ exports.singleTorrentView = async (torrentObj, filter = "all", comments = [], pa return p( { class: "card-footer" }, - span({ class: "date-link" }, `${moment(torrentObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `), + span({ class: "date-link" }, `${moment(torrentObj.createdAt).format("YYYY/MM/DD HH:mm")} ${i18n.performed} `), userLink(torrentObj.author), showUpdated ? span( { class: "votations-comment-date" }, - ` | ${i18n.torrentUpdatedAt}: ${moment(torrentObj.updatedAt).format("YYYY/MM/DD HH:mm:ss")}` + ` | ${i18n.torrentUpdatedAt}: ${moment(torrentObj.updatedAt).format("YYYY/MM/DD HH:mm")}` ) : null ); })(), - renderOpinionsVoting('/torrents/opinions', torrentObj.key, torrentObj.opinions, returnTo, torrentObj.opinions_inhabitants), - renderTorrentCommentsSection(torrentObj.key, comments, returnTo) + renderEngagement(torrentObj.key, + renderOpinionsVoting('/torrents/opinions', torrentObj.key, torrentObj.opinions, returnTo, torrentObj.opinions_inhabitants), + renderTorrentCommentsSection(torrentObj.key, comments, returnTo) + ) ); return template( @@ -411,14 +339,14 @@ exports.singleTorrentView = async (torrentObj, filter = "all", comments = [], pa { method: "GET", action: "/torrents", class: "ui-toolbar ui-toolbar--filters" }, input({ type: "hidden", name: "q", value: q }), input({ type: "hidden", name: "sort", value: sort }), - button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, i18n.torrentFilterAll), - button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.torrentFilterMine), - button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.torrentFilterRecent), - button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, i18n.torrentFilterTop), - button( + button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, String(i18n.torrentFilterAll).toUpperCase()), + button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, String(i18n.torrentFilterMine).toUpperCase()), + button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, String(i18n.torrentFilterRecent).toUpperCase()), button( { type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" }, - i18n.torrentFilterFavorites + String(i18n.torrentFilterFavorites).toUpperCase() ), + + button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, String(i18n.torrentFilterTop).toUpperCase()), button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.torrentCreateButton) ) ), diff --git a/src/views/trending_view.js b/src/views/trending_view.js index b24ab81..e097ae3 100644 --- a/src/views/trending_view.js +++ b/src/views/trending_view.js @@ -13,7 +13,7 @@ const filterButton = (mode, currentFilter) => input({ type: 'hidden', name: 'filter', value: mode }), button( { type: 'submit', class: currentFilter === mode ? 'filter-btn active' : 'filter-btn' }, - i18n[mode + 'Button'] || mode + String(i18n[mode + 'Button'] || mode).toUpperCase() ) ); @@ -42,12 +42,11 @@ const renderTrendingCard = (item, votes, categories, seenTitles, spreadMap = new ) ); } else if (c.type === 'image') { - const { url, title, description, meme } = c; + const { url, title, description } = c; contentHtml = div({ class: 'trending-image' }, div({ class: 'card-section image' }, title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.imageTitleLabel + ':'), span({ class: 'card-value' }, title)) : "", description ? [span({ class: 'card-label' }, i18n.imageDescriptionLabel + ":"), p(...renderUrl(description))] : null, - meme ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.trendingCategory + ':'), span({ class: 'card-value' }, i18n.meme)) : "", div({ class: 'card-field' }, img({ src: `/blob/${encodeURIComponent(url)}`, class: 'feed-image' })) ) ); @@ -129,7 +128,7 @@ const renderTrendingCard = (item, votes, categories, seenTitles, spreadMap = new : "" ) ); - } else if (c.type === 'votes') { + } else if (c.type === 'votes' || c.type === 'poll') { const { question, deadline, votes: vmap, totalVotes } = c; const votesList = vmap && typeof vmap === 'object' ? Object.entries(vmap).map(([o, cnt]) => ({ option: o, count: cnt })) @@ -198,12 +197,11 @@ const renderTrendingCard = (item, votes, categories, seenTitles, spreadMap = new span({ class: 'pm-exposition-chip pm-exposition-whole' }, span({ class: 'pm-exposition-text' }, String(c.type || '').toUpperCase()) ), - renderContentActions(item.key, detailHref) + renderContentActions(item.key, detailHref, { spread: spreadMap.get(item.key) || null }) ), div( { class: 'card-section trending-card-body' }, contentHtml, - div({ class: 'card-spread-left' }, renderSpreadButton(item.key, spreadMap.get(item.key))), p( { class: 'card-footer' }, span({ class: 'date-link' }, `${created} ${i18n.performed} `), @@ -228,14 +226,16 @@ const renderTrendingCard = (item, votes, categories, seenTitles, spreadMap = new ); })(), details({ class: 'opinions-voting-collapse' }, - summary({ class: 'opinions-summary' }, `${i18n.opinionsTitle || 'Opinions'} (${Object.values(c.opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0)})`), + summary({ class: 'opinions-summary' }, + span({ class: 'opinions-summary-icon' }, 'ꔍ'), + span({ class: 'opinions-summary-count' }, `(${Object.values(c.opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0)})`)), div( { class: 'voting-buttons' }, categories.map(cat => form({ method: 'POST', action: `/trending/${encodeURIComponent(item.key)}/${cat}` }, button( { class: 'vote-btn' }, - `${voteLabelFor(cat)} [${c.opinions?.[cat] || 0}]` + `${String(voteLabelFor(cat)).toUpperCase()} [${c.opinions?.[cat] || 0}]` ) ) ) @@ -245,14 +245,14 @@ const renderTrendingCard = (item, votes, categories, seenTitles, spreadMap = new ); }; -exports.trendingView = (items, filter, categories = opinionCategories, spreadMap = new Map()) => { +exports.trendingView = (items, filter, categories = opinionCategories, spreadMap = new Map(), q = '') => { const seenDocumentTitles = new Set(); const title = i18n.trendingTitle; - const baseFilters = ['TOP', 'ALL', 'MINE', 'RECENT']; + const baseFilters = ['ALL', 'MINE', 'RECENT', 'TOP']; const contentFilters = [ - ['feed', 'votes', 'event', 'task', 'report'], - ['project', 'industry', 'transfer', 'shopProduct'], + ['votes', 'event', 'task', 'report'], + ['feed', 'project', 'industry', 'shopProduct', 'transfer'], ['audio', 'bookmark', 'document', 'image', 'torrent', 'video'] ]; @@ -273,7 +273,9 @@ exports.trendingView = (items, filter, categories = opinionCategories, spreadMap return bVotes !== aVotes ? bVotes - aVotes : b.value.timestamp - a.value.timestamp; }); } else if (contentFilters.flat().includes(filter)) { - filteredItems = filteredItems.filter(item => item.value.content.type === filter); + filteredItems = filteredItems.filter(item => + item.value.content.type === filter || + (filter === 'votes' && item.value.content.type === 'poll')); } else if (filter !== 'ALL') { filteredItems = filteredItems.filter(item => (item.value.content.opinions_inhabitants || []).length > 0); } @@ -308,9 +310,18 @@ exports.trendingView = (items, filter, categories = opinionCategories, spreadMap div({ class: 'column' }, row.map(mode => filterButton(mode, filter))) ) ), + div({ class: 'filters' }, + form({ method: 'GET', action: '/trending', class: 'filter-box' }, + input({ type: 'hidden', name: 'filter', value: filter }), + input({ type: 'text', name: 'q', value: q, placeholder: i18n.trendingSearchPlaceholder, class: 'filter-box__input' }), + div({ class: 'filter-box__controls' }, + button({ type: 'submit', class: 'filter-box__button' }, i18n.searchButton) + ) + ) + ), section( cards.length - ? div({ class: 'trending-container', style: 'display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:20px;' }, ...cards) + ? div({ class: 'trending-container' }, ...cards) : div({ class: 'no-results' }, p(i18n.trendingNoContentMessage)) ) ) diff --git a/src/views/video_view.js b/src/views/video_view.js index 5dbfe9c..4ce1547 100644 --- a/src/views/video_view.js +++ b/src/views/video_view.js @@ -15,9 +15,10 @@ const { select, option } = require("../server/node_modules/hyperaxe"); +const { renderCommentsSection: renderSharedCommentsSection, renderCommentsLink } = require("./comments_view"); const moment = require("../server/node_modules/moment"); -const { template, i18n, renderOpinionsVoting, userLink, renderSpreadButton, renderEcoTax, renderLifespanChip, renderContentActions } = require("./main_views"); +const { template, i18n, renderOpinionsVoting, renderEngagement, userLink, renderSpreadButton, renderEcoTax, renderLifespanChip, renderContentActions , renderSpreadEditWarning } = require("./main_views"); const { config } = require("../server/SSB_server.js"); const { renderUrl } = require("../backend/renderUrl") const { renderMapLocationVisitLabel } = require("./maps_view"); @@ -37,18 +38,6 @@ const buildReturnTo = (filter, params = {}) => { return `/videos?${parts.join("&")}`; }; -const renderPMButton = (recipient, className = "filter-btn") => { - const r = safeText(recipient); - if (!r) return null; - if (String(r) === String(userId)) return null; - - return form( - { method: "GET", action: "/pm" }, - input({ type: "hidden", name: "recipients", value: r }), - button({ type: "submit", class: className }, i18n.privateMessage) - ); -}; - const renderTags = (tags) => { const list = safeArr(tags).map((t) => String(t || "").trim()).filter(Boolean); return list.length @@ -59,25 +48,10 @@ const renderTags = (tags) => { : null; }; -const renderVideoFavoriteToggle = (videoObj, returnTo = "") => - form( - { - method: "POST", - action: videoObj.isFavorite - ? `/videos/favorites/remove/${encodeURIComponent(videoObj.key)}` - : `/videos/favorites/add/${encodeURIComponent(videoObj.key)}` - }, - returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null, - button( - { type: "submit", class: "filter-btn" }, - videoObj.isFavorite ? i18n.videoRemoveFavoriteButton : i18n.videoAddFavoriteButton - ) - ); - const renderVideoPlayer = (videoObj) => videoObj?.url ? div( - { class: "video-container", style: "display:flex;gap:8px;align-items:center;flex-wrap:wrap;" }, + { class: "video-container video-container-row" }, videoHyperaxe({ controls: true, src: `/blob/${encodeURIComponent(videoObj.url)}`, @@ -115,66 +89,11 @@ const renderVideoOwnerActions = (filter, videoObj, params = {}) => { }; const renderVideoCommentsSection = (videoId, comments = [], returnTo = null) => { - const list = safeArr(comments).filter(c => { - const t = c && c.value && c.value.content && c.value.content.text; - return t && String(t).trim(); + return renderSharedCommentsSection({ + action: `/videos/${encodeURIComponent(videoId)}/comments`, + comments: comments, + returnTo: returnTo }); - const commentsCount = list.length; - - return div( - { class: "vote-comments-section" }, - div( - { class: "comments-count" }, - span({ class: "card-label" }, i18n.voteCommentsLabel + ": "), - span({ class: "card-value" }, String(commentsCount)) - ), - div( - { class: "comment-form-wrapper" }, - h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel), - form( - { method: "POST", action: `/videos/${encodeURIComponent(videoId)}/comments`, class: "comment-form", enctype: "multipart/form-data" }, - returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null, - textarea({ - id: "comment-text", - name: "text", - rows: 4, - class: "comment-textarea", - placeholder: i18n.voteNewCommentPlaceholder - }), - div({ class: "comment-file-upload" }, label(i18n.uploadMedia), input({ type: "file", name: "blob" })), - br(), - button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton) - ) - ), - list.length - ? div( - { class: "comments-list" }, - list.map((c) => { - const author = c?.value?.author || ""; - const ts = c?.value?.timestamp || c?.timestamp; - const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : ""; - const relDate = ts ? moment(ts).fromNow() : ""; - const content = c?.value?.content || {}; - const rootId = content.fork || content.root || null; - const text = content.text || ""; - - return div( - { class: "votations-comment-card" }, - span( - { class: "created-at" }, - span(i18n.createdBy), - author ? userLink(author) : span("(unknown)"), - absDate ? span(" | ") : "", - absDate ? span({ class: "votations-comment-date" }, absDate) : "", - relDate ? span({ class: "votations-comment-date" }, " | ", i18n.sendTime) : "", - relDate && rootId ? a({ href: `/thread/${encodeURIComponent(rootId)}#${encodeURIComponent(c.key)}` }, relDate) : "" - ), - p({ class: "votations-comment-text" }, ...renderUrl(text)) - ); - }) - ) - : p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet) - ); }; const renderVideoList = exports.renderVideoList = (videos, filter, params = {}) => { @@ -184,7 +103,6 @@ const renderVideoList = exports.renderVideoList = (videos, filter, params = {}) ? videos.map((videoObj) => { const commentCount = typeof videoObj.commentCount === "number" ? videoObj.commentCount : 0; const title = safeText(videoObj.title); - const ownerActions = renderVideoOwnerActions(filter, videoObj, params); const isOwn = videoObj.author && String(videoObj.author) === String(userId); return div( @@ -192,49 +110,17 @@ const renderVideoList = exports.renderVideoList = (videos, filter, params = {}) div( { class: "card-header activity-card-header" }, span(), - renderContentActions(videoObj.key, `/videos/${encodeURIComponent(videoObj.key)}`) + renderContentActions(videoObj.key, `/videos/${encodeURIComponent(videoObj.key)}`, { spread: (params.spreadMap && params.spreadMap.get(videoObj.key)) || params.spreads || null, author: videoObj.author, favKind: 'videos', isFavorite: videoObj.isFavorite, reportTitle: videoObj.title }) ), div( { class: "card-section video-card-body" }, - div( - { class: "bookmark-topbar" }, - div( - { class: "bookmark-topbar-left" }, - renderVideoFavoriteToggle(videoObj, returnTo), - renderPMButton(videoObj.author) - ), - ownerActions.length - ? div({ class: "bookmark-topbar-right" }, div({ class: "bookmark-actions" }, ...ownerActions)) - : null - ), title ? h2(title) : null, videoObj.lifetime ? div({ class: "card-chips-row" }, renderLifespanChip(videoObj.lifetime, i18n)) : null, renderVideoPlayer(videoObj), - div( - { class: "card-comments-summary" }, - span({ class: "card-label" }, i18n.voteCommentsLabel + ":"), - span({ class: "card-value" }, String(commentCount)), - br(), - br(), - form( - { method: "GET", action: `/videos/${encodeURIComponent(videoObj.key)}` }, - input({ type: "hidden", name: "returnTo", value: returnTo }), - input({ type: "hidden", name: "filter", value: filter || "all" }), - params.q ? input({ type: "hidden", name: "q", value: params.q }) : null, - params.sort ? input({ type: "hidden", name: "sort", value: params.sort }) : null, - button({ type: "submit", class: "filter-btn" }, i18n.voteCommentsForumButton) - ) + renderEngagement(videoObj.key, + renderOpinionsVoting('/videos/opinions', videoObj.key, videoObj.opinions, returnTo, videoObj.opinions_inhabitants), + renderCommentsLink({ href: `/videos/${encodeURIComponent(videoObj.key)}`, count: commentCount }) ), - div( - { class: "card-comments-summary feed-opinions-section" }, - div( - { class: "comments-count" }, - span({ class: "card-label" }, (i18n.opinionsTitle || "Opinions") + ": "), - span({ class: "card-value" }, String(Object.values(videoObj.opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0))) - ), - renderOpinionsVoting('/videos/opinions', videoObj.key, videoObj.opinions, returnTo, videoObj.opinions_inhabitants) - ), - div({ class: "card-spread-left" }, renderSpreadButton(videoObj.key, (params.spreadMap && params.spreadMap.get(videoObj.key)) || params.spreads)), renderMapLocationVisitLabel(videoObj.mapUrl), br(), (() => { @@ -244,12 +130,12 @@ const renderVideoList = exports.renderVideoList = (videos, filter, params = {}) return p( { class: "card-footer" }, - span({ class: "date-link" }, `${moment(videoObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `), + span({ class: "date-link" }, `${moment(videoObj.createdAt).format("YYYY/MM/DD HH:mm")} ${i18n.performed} `), userLink(videoObj.author), showUpdated ? span( { class: "votations-comment-date" }, - ` | ${i18n.videoUpdatedAt}: ${moment(videoObj.updatedAt).format("YYYY/MM/DD HH:mm:ss")}` + ` | ${i18n.videoUpdatedAt}: ${moment(videoObj.updatedAt).format("YYYY/MM/DD HH:mm")}` ) : null ); @@ -265,6 +151,7 @@ const renderVideoForm = (filter, videoId, videoToEdit, params = {}) => { return div( { class: "div-center video-form" }, + params.spreadWarning || null, form( { action: filter === "edit" ? `/videos/update/${encodeURIComponent(videoId)}` : "/videos/create", @@ -279,7 +166,7 @@ const renderVideoForm = (filter, videoId, videoToEdit, params = {}) => { br(), span(i18n.videoTitleLabel), br(), - input({ type: "text", name: "title", placeholder: i18n.videoTitlePlaceholder, value: videoToEdit?.title || "" }), + input({ type: "text", name: "title", maxlength: "100", placeholder: i18n.videoTitlePlaceholder, value: videoToEdit?.title || "" }), br(), span(i18n.videoDescriptionLabel), br(), @@ -305,20 +192,8 @@ const renderVideoForm = (filter, videoId, videoToEdit, params = {}) => { }; exports.videoView = async (videos, filter = "all", videoId = null, params = {}) => { - const title = - filter === "mine" - ? i18n.videoMineSectionTitle - : filter === "create" - ? i18n.videoCreateSectionTitle - : filter === "edit" - ? i18n.videoUpdateSectionTitle - : filter === "recent" - ? i18n.videoRecentSectionTitle - : filter === "top" - ? i18n.videoTopSectionTitle - : filter === "favorites" - ? i18n.videoFavoritesSectionTitle - : i18n.videoAllSectionTitle; + if (filter === "edit") params = { ...params, spreadWarning: await renderSpreadEditWarning(videoId) }; + const title = i18n.videoTitle; const q = safeText(params.q || ""); const sort = safeText(params.sort || "recent"); @@ -345,14 +220,14 @@ exports.videoView = async (videos, filter = "all", videoId = null, params = {}) { method: "GET", action: "/videos", class: "ui-toolbar ui-toolbar--filters" }, input({ type: "hidden", name: "q", value: q }), input({ type: "hidden", name: "sort", value: sort }), - button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, i18n.videoFilterAll), - button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.videoFilterMine), - button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.videoFilterRecent), - button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, i18n.videoFilterTop), - button( + button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, String(i18n.videoFilterAll).toUpperCase()), + button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, String(i18n.videoFilterMine).toUpperCase()), + button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, String(i18n.videoFilterRecent).toUpperCase()), button( { type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" }, - i18n.videoFilterFavorites + String(i18n.videoFilterFavorites).toUpperCase() ), + + button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, String(i18n.videoFilterTop).toUpperCase()), button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.videoCreateButton) ) ) @@ -377,9 +252,9 @@ exports.videoView = async (videos, filter = "all", videoId = null, params = {}) { class: "filter-box__controls" }, select( { name: "sort", class: "filter-box__select" }, - option({ value: "recent", selected: sort === "recent" }, i18n.videoSortRecent), - option({ value: "oldest", selected: sort === "oldest" }, i18n.videoSortOldest), - option({ value: "top", selected: sort === "top" }, i18n.videoSortTop) + option({ value: "recent", ...(sort === "recent" ? { selected: true } : {})}, i18n.videoSortRecent), + option({ value: "oldest", ...(sort === "oldest" ? { selected: true } : {})}, i18n.videoSortOldest), + option({ value: "top", ...(sort === "top" ? { selected: true } : {})}, i18n.videoSortTop) ), button({ type: "submit", class: "filter-box__button" }, i18n.videoSearchButton) ) @@ -407,14 +282,21 @@ exports.singleVideoView = async (videoObj, filter = "all", comments = [], params const ownerActions = renderVideoOwnerActions(filter, videoObj, { q, sort }); const sideActions = []; - sideActions.push(renderVideoFavoriteToggle(videoObj, returnTo)); - if (videoObj.author && String(videoObj.author) !== String(userId)) { - sideActions.push(renderPMButton(videoObj.author)); - } for (const a of ownerActions) sideActions.push(a); const tagsNode = renderTags(videoObj.tags); + const detailActions = div({ class: "card-header activity-card-header" }, + renderContentActions(videoObj.key, null, { + author: videoObj.author, + favKind: 'videos', + isFavorite: videoObj.isFavorite, + spread: params.spreads || null, + returnTo, + reportTitle: videoObj.title + }) + ); + const videoSide = div({ class: "tribe-side" }, div({ class: "shop-title-row" }, title ? h2({ class: "tribe-card-title" }, title) : null, @@ -425,12 +307,12 @@ exports.singleVideoView = async (videoObj, filter = "all", comments = [], params ? p({ class: "tribe-side-description" }, ...renderUrl(videoObj.description)) : null, tagsNode, - div({ class: "card-spread-centered" }, renderSpreadButton(videoObj.key, params.spreads)), renderMapLocationVisitLabel(videoObj.mapUrl), sideActions.length ? div({ class: "tribe-side-actions" }, ...sideActions) : null ); const videoMain = div({ class: "tribe-main" }, + detailActions, renderVideoPlayer(videoObj), (() => { const createdTs = videoObj.createdAt ? new Date(videoObj.createdAt).getTime() : NaN; @@ -439,18 +321,20 @@ exports.singleVideoView = async (videoObj, filter = "all", comments = [], params return p( { class: "card-footer" }, - span({ class: "date-link" }, `${moment(videoObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `), + span({ class: "date-link" }, `${moment(videoObj.createdAt).format("YYYY/MM/DD HH:mm")} ${i18n.performed} `), userLink(videoObj.author), showUpdated ? span( { class: "votations-comment-date" }, - ` | ${i18n.videoUpdatedAt}: ${moment(videoObj.updatedAt).format("YYYY/MM/DD HH:mm:ss")}` + ` | ${i18n.videoUpdatedAt}: ${moment(videoObj.updatedAt).format("YYYY/MM/DD HH:mm")}` ) : null ); })(), - renderOpinionsVoting('/videos/opinions', videoObj.key, videoObj.opinions, returnTo, videoObj.opinions_inhabitants), - renderVideoCommentsSection(videoObj.key, comments, returnTo) + renderEngagement(videoObj.key, + renderOpinionsVoting('/videos/opinions', videoObj.key, videoObj.opinions, returnTo, videoObj.opinions_inhabitants), + renderVideoCommentsSection(videoObj.key, comments, returnTo) + ) ); return template( @@ -466,14 +350,14 @@ exports.singleVideoView = async (videoObj, filter = "all", comments = [], params { method: "GET", action: "/videos", class: "ui-toolbar ui-toolbar--filters" }, input({ type: "hidden", name: "q", value: q }), input({ type: "hidden", name: "sort", value: sort }), - button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, i18n.videoFilterAll), - button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.videoFilterMine), - button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.videoFilterRecent), - button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, i18n.videoFilterTop), - button( + button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, String(i18n.videoFilterAll).toUpperCase()), + button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, String(i18n.videoFilterMine).toUpperCase()), + button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, String(i18n.videoFilterRecent).toUpperCase()), button( { type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" }, - i18n.videoFilterFavorites + String(i18n.videoFilterFavorites).toUpperCase() ), + + button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, String(i18n.videoFilterTop).toUpperCase()), button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.videoCreateButton) ) ),