diff --git a/.gitea/workflows/apk.yml b/.gitea/workflows/apk.yml new file mode 100644 index 0000000..19e7429 --- /dev/null +++ b/.gitea/workflows/apk.yml @@ -0,0 +1,53 @@ +# Construye la APK y la deja publicada como borrador en las releases. +# +# NO se dispara sola con cada commit: la APK son mas de 100 MB y el backend tarda +# en empaquetarse. Se lanza a mano desde la interfaz de gitea, o al crear un tag. +# +# El runner necesita: JDK 17 con compilador, el SDK de Android (plataforma 35 y +# build-tools), node, zip y unzip. Un runner pelado NO vale. +# +# Secretos que hay que dar de alta en el repositorio: +# OASIS_KEYSTORE_B64 el almacen de claves en base64 +# OASIS_KEYSTORE_PASS su contraseña +# OASIS_KEY_ALIAS el alias (por defecto "oasis") +# +# El token para publicar lo pone gitea solo en GITEA_TOKEN. + +name: APK + +on: + workflow_dispatch: + push: + tags: + - 'v*' + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # release.sh cuenta commits para el versionCode + + - name: Restaurar el almacen de claves + run: | + echo "${{ secrets.OASIS_KEYSTORE_B64 }}" | base64 -d > /tmp/oasis.jks + chmod 600 /tmp/oasis.jks + + - name: Construir, verificar y publicar + env: + JAVA_HOME: /usr/lib/jvm/temurin-17-jdk-amd64 + ANDROID_HOME: /opt/android-sdk + OASIS_KEYSTORE: /tmp/oasis.jks + OASIS_KEYSTORE_PASS: ${{ secrets.OASIS_KEYSTORE_PASS }} + OASIS_KEY_ALIAS: ${{ secrets.OASIS_KEY_ALIAS }} + OASIS_GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + OASIS_GITEA_URL: ${{ github.server_url }} + OASIS_GITEA_REPO: ${{ github.repository }} + run: | + cd android + ./scripts/release.sh --release --publish + + - name: Borrar el almacen de claves + if: always() + run: rm -f /tmp/oasis.jks diff --git a/.gitignore b/.gitignore index 724afd9..c2658d7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1 @@ node_modules/ - -# pruebas en local: el node_modules suele ser un enlace y el estado de la llamada lo escribe el movil -src/server/node_modules -.phone_state.json -android/.tools/ -src/server/.update_required diff --git a/GUIA_DE_DESARROLLO.md b/GUIA_DE_DESARROLLO.md deleted file mode 100644 index f79ea6c..0000000 --- a/GUIA_DE_DESARROLLO.md +++ /dev/null @@ -1,385 +0,0 @@ -# Guía de desarrollo - -Notas para quien vaya a meter mano en la rama del móvil. Leedlas antes de empezar, -que nos ahorramos disgustos. - -Esta rama va encima de Oasis, que es de epsylon. Él saca versión casi cada día -(39 entre la 1.0.1 y la 1.3.3, en siete semanas) y cada versión llega en un solo -commit. Si metemos cosas en sus ficheros, en cada integración hay que volver a -pelearse con ellas. Por eso la idea es sencilla: lo de Oasis lo cambia él; nosotros -cambiamos cómo se ve y se usa en el móvil, y lo hacemos en ficheros nuestros. - -Los números y de dónde salen están en `docs/FRONTERA_UPSTREAM.md`. - - -## La estrategia: una capa encima - -Lo del dev entra tal cual y lo nuestro va encima, en ficheros nuestros: hojas de -estilo, vistas y un router propio que va delante del suyo. Se eligió así frente a -las otras salidas: - -- Un fork que copie sus vistas y siga por libre se queda viejo en semanas (39 - versiones en siete semanas). -- Una app propia que hable con su backend obliga a rehacer toda la interfaz, y Oasis - no tiene API: devuelve HTML. -- Reescribir su HTML o sus respuestas al vuelo se rompe sin avisar con cualquier - cambio suyo. Se probó y se quitó el 20/10/2026. - -Las reglas de la capa: - -1. Lo del dev no se cambia: ni su código ni lo que contesta (HTML, redirecciones, - cabeceras). Nuestro código no mira sus respuestas. -2. El aspecto, con CSS en nuestras hojas. -3. Otro HTML en una pantalla suya: función en `mobile_ui.js` y una línea en su vista. -4. Una pantalla o una ruta nueva: en `fork_routes.js`; lo que no es nuestro pasa al - dev con `next()`. -5. Si una pantalla del dev ya hace algo (editar el perfil, escribir, guardar los - módulos), se enlaza a ella o se envía a su ruta y se acepta a dónde vuelve. No se - copia su lógica. -6. Se pueden usar sus funciones (`getConfig`, `saveConfig`, leer de sus modelos). - Reescribirlas, no. -7. Si algo no se puede hacer con estas reglas, se le pide al dev. - -Dónde va cada cosa: - -| Quiero... | Va en | -|---|---| -| Cambiar colores, tamaños, márgenes | `styles/mobile_ux.css` (piezas) o `styles/activista.css` (lanzador y barras) | -| Cambiar los colores de serie o las paletas | Variables `--a26-*` de `themes/Activista.css`; las paletas, en `mobile_settings.js` | -| La barra de unirse a un pub | `views/mobile_pubbar.js` | -| Otro HTML en una pantalla del dev | Función en `views/mobile_ui.js` + una línea en su vista | -| Una pantalla nueva | Vista propia en `views/mobile_*.js` + ruta en `fork_routes.js` | -| Algo en Ajustes del móvil | `views/mobile_settings.js` | -| Un texto nuevo | `translations/i18n_fork.js`, en los 13 idiomas | -| Un icono | `views/mobile_icons.js` | -| Un workflow (perfil) o la barra de abajo de un perfil | `models/fork_workflows.js` (`FORK_WORKFLOWS` y `BARS`) | -| Algo de la APK | `android/scripts/sobre_oficial.sh` o `android/icono/` | -| Algo que la capa no deja | Pedírselo al dev | - -Cómo seguir modificando, paso a paso: - -1. Arrancar en local (ver «Probar en local») y localizar en el inspector la clase o - la pantalla. -2. Decidir con la tabla dónde va. Si la respuesta es un fichero del dev, parar y - releer las reglas. -3. Hacer el cambio. -4. Pasar «Antes de hacer commit». -5. Commit en castellano y subir la rama `PRUEBAS` a forgejo. -6. Montar la APK con `android/scripts/sobre_oficial.sh` y probarla en el emulador - `Oasis_Pruebas`. -7. Si entra un fichero o un mecanismo nuevo, apuntarlo en esta guía en el mismo commit. - - -## Lo que no se toca - -- `src/backend/backend.js`. Cambia en 34 de cada 39 versiones. Ni rutas nuevas, ni - middleware, ni envolver funciones suyas como `checkMod`. -- La cadena de middleware del final de `backend.js`. Crece en cada versión. -- Los modelos de `src/models/`, salvo el nuestro (`fork_workflows.js`). Ahí se decide qué se publica en la red y cómo se cuenta. - Si lo cambiamos, nuestro móvil cuenta distinto que el escritorio de al lado. Un - ejemplo real: quien crea una votación no puede votar en ella. No nos gusta, pero - es una regla del modelo, así que se explica en pantalla y punto. -- `src/server/` (arranque de SSB, `ssb_config.js`, `package.json`). -- `src/backend/request_guards.js`, que lleva la CSP. Nada de `style=""` ni de - ` + `; + } return html; }; diff --git a/src/views/agenda_view.js b/src/views/agenda_view.js index ee44ab3..e7e5512 100644 --- a/src/views/agenda_view.js +++ b/src/views/agenda_view.js @@ -1,6 +1,6 @@ -const { div, h2, p, section, button, form, input, a, 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 { safeExternalHref } = require("../backend/renderStyledText"); -const { template, i18n, userLink, renderCardTop, renderModuleStats, renderStateChip, CONTENT_FAV_KIND, CONTENT_SPREADABLE, contentDeleteAction, paged } = require('./main_views'); +const { template, i18n, userLink, renderContentActions, renderModuleStats, renderStateChip, CONTENT_FAV_KIND, CONTENT_SPREADABLE, contentDeleteAction, paged } = require('./main_views'); const moment = require('../server/node_modules/moment'); const { config } = require('../server/SSB_server.js'); @@ -151,24 +151,27 @@ const renderAgendaItem = (item, userId, filter, extras = {}) => { const href = getViewDetailsAction(item); const title = item.title || item.name || item.concept || ''; return div({ class: 'trending-card agenda-card' + (isOwn ? ' own-content' : '') }, - renderCardTop(item.id, href, { - date: item.createdAt, - chips: [ - span({ class: 'pm-exposition-chip pm-exposition-whole' }, - span({ class: 'pm-exposition-text' }, String(item.type || '').toUpperCase()) - ), - chips.filter(Boolean).length ? div({ class: 'card-chips-row' }, ...chips.filter(Boolean)) : null - ], - author, - reportTitle: title, - spread: CONTENT_SPREADABLE.has(item.type) ? ((spreadMap && spreadMap.get(item.id)) || null) : undefined, - ...(favKind ? { favKind, isFavorite: item.isFavorite === true || (!!favIndex && [item.rootId, item.id].some(id => id && favIndex.get(String(id)) === favKind)) } : {}), - returnTo: `/agenda?filter=${encodeURIComponent(filter || 'all')}`, - deleteAction: isOwn ? agendaDeleteAction(item) : undefined - }), + div({ class: 'card-header activity-card-header' }, + span({ class: 'pm-exposition-chip pm-exposition-whole' }, + span({ class: 'pm-exposition-text' }, String(item.type || '').toUpperCase()) + ), + renderContentActions(item.id, href, { + author, + reportTitle: title, + spread: CONTENT_SPREADABLE.has(item.type) ? ((spreadMap && spreadMap.get(item.id)) || null) : undefined, + ...(favKind ? { favKind, isFavorite: item.isFavorite === true || (!!favIndex && [item.rootId, item.id].some(id => id && favIndex.get(String(id)) === favKind)) } : {}), + returnTo: `/agenda?filter=${encodeURIComponent(filter || 'all')}`, + deleteAction: isOwn ? agendaDeleteAction(item) : undefined + }) + ), div({ class: 'card-section agenda-card-body' }, div({ class: 'shop-title-row' }, h2({ class: 'tribe-card-title' }, a({ href }, title))), + chips.filter(Boolean).length ? div({ class: 'card-chips-row' }, ...chips.filter(Boolean)) : null, ...body.filter(Boolean), + p({ class: 'card-footer' }, + span({ class: 'date-link' }, `${item.createdAt ? moment(item.createdAt).format('YYYY/MM/DD HH:mm') : ''}`), + author ? userLink(author) : '' + ), div({ class: 'agenda-card-actions' }, actionButton, ...extraActions) ) ); diff --git a/src/views/audio_view.js b/src/views/audio_view.js index 1206d7e..83e9b6b 100644 --- a/src/views/audio_view.js +++ b/src/views/audio_view.js @@ -18,8 +18,7 @@ const { const { renderCommentsSection: renderSharedCommentsSection, renderCommentsLink } = require("./comments_view"); const { renderLicenseChip, renderLicenseSelect, renderReachChip, renderClearnetSelector, renderClearnetSwitch, renderTribeOriginChip } = require('./clearnet_view'); -const { clearnetItemHref, template, i18n, renderOpinionsVoting, renderEngagement, userLink, renderEcoTax, renderLifespanChip, renderCardTop, renderCardByline, renderSpreadEditWarning, renderModuleStats, moduleIsEmpty, contentDeleteAction, paged, blobUrlOf, renderMediaLockNote, renderEditedChip, renderDetailShare } = require("./main_views"); -const { renderDownloadButton } = require("./gallery_view"); +const { clearnetItemHref, template, i18n, renderOpinionsVoting, renderEngagement, userLink, renderSpreadButton, renderEcoTax, renderLifespanChip, renderContentActions, renderSpreadEditWarning, renderModuleStats, moduleIsEmpty, contentDeleteAction, paged, blobUrlOf } = require("./main_views"); const moment = require("../server/node_modules/moment"); const { config } = require("../server/SSB_server.js"); const { renderStyledText } = require("../backend/renderStyledText") @@ -70,17 +69,20 @@ const renderAudioPlayer = (audioObj, opts = {}) => const renderAudioOwnerActions = (filter, audioObj, params = {}) => { const returnTo = buildReturnTo(filter, params); const isAuthor = String(audioObj.author) === String(userId); + const hasOpinions = Object.keys(audioObj.opinions || {}).length > 0; if (!isAuthor) return []; const items = []; - items.push( - form( - { method: "GET", action: `/audios/edit/${encodeURIComponent(audioObj.key)}` }, - input({ type: "hidden", name: "returnTo", value: returnTo }), - button({ class: "update-btn", type: "submit" }, i18n.audioUpdateButton) - ) - ); + if (!hasOpinions) { + items.push( + form( + { method: "GET", action: `/audios/edit/${encodeURIComponent(audioObj.key)}` }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + button({ class: "update-btn", type: "submit" }, i18n.audioUpdateButton) + ) + ); + } return items; }; @@ -93,15 +95,6 @@ const renderAudioCommentsSection = (audioId, comments = [], returnTo = null) => }); }; -const renderAudioUpdated = (audioObj) => { - const createdTs = audioObj.createdAt ? new Date(audioObj.createdAt).getTime() : NaN; - const updatedTs = audioObj.updatedAt ? new Date(audioObj.updatedAt).getTime() : NaN; - const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs); - return showUpdated - ? span({ class: "votations-comment-date" }, `${i18n.audioUpdatedAt}: ${moment(audioObj.updatedAt).format("YYYY/MM/DD HH:mm")}`) - : null; -}; - const renderAudioList = exports.renderAudioList = (audios, filter, params = {}) => { const returnTo = buildReturnTo(filter, params); @@ -109,37 +102,53 @@ 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 href = `/audios/${encodeURIComponent(audioObj.key)}`; const isOwn = audioObj.author && String(audioObj.author) === String(userId); - const byline = { - author: audioObj.author, - date: audioObj.createdAt, - chips: [ - audioObj.tribeOrigin ? renderTribeOriginChip(audioObj.tribeOrigin) : audioObj.clearnet === true ? renderReachChip(true, i18n, clearnetItemHref('audios', audioObj.title, audioObj.key)) : null, - renderLicenseChip(audioObj.license), - ...(typeof params.titleChips === "function" ? params.titleChips(audioObj) : []), - renderLifespanChip(audioObj.lifetime, i18n) - ], - bylineExtra: [renderAudioUpdated(audioObj)] - }; - const header = typeof params.headerActions === "function" - ? div({ class: "card-header activity-card-header card-top" }, renderCardByline(byline), params.headerActions(audioObj)) + const headerActions = typeof params.headerActions === "function" + ? params.headerActions(audioObj) : audioObj.tribeOrigin - ? renderCardTop(audioObj.key, audioObj.tribeOrigin.href, { ...byline, spread: (params.spreadMap && params.spreadMap.get(audioObj.key)) || null, reportTitle: audioObj.title }) - : renderCardTop(audioObj.key, href, { ...byline, spread: (params.spreadMap && params.spreadMap.get(audioObj.key)) || params.spreads || null, favKind: 'audios', torrentFrom: { blobId: audioObj.url, name: audioObj.title }, isFavorite: audioObj.isFavorite, reportTitle: audioObj.title, deleteAction: isOwn ? contentDeleteAction('audio', audioObj.key) : undefined, returnTo }); - const titleHref = typeof params.headerActions === "function" ? null : audioObj.tribeOrigin ? audioObj.tribeOrigin.href : href; - const engagement = typeof params.engagement === "function" ? params.engagement(audioObj) : null; + ? renderContentActions(null, audioObj.tribeOrigin.href) + : renderContentActions(audioObj.key, `/audios/${encodeURIComponent(audioObj.key)}`, { spread: (params.spreadMap && params.spreadMap.get(audioObj.key)) || params.spreads || null, author: audioObj.author, favKind: 'audios', torrentFrom: { blobId: audioObj.url, name: audioObj.title }, isFavorite: audioObj.isFavorite, reportTitle: audioObj.title, deleteAction: isOwn ? contentDeleteAction('audio', audioObj.key) : undefined, returnTo }); + const titleHref = typeof params.headerActions === "function" ? null : audioObj.tribeOrigin ? audioObj.tribeOrigin.href : `/audios/${encodeURIComponent(audioObj.key)}`; + const engagement = typeof params.engagement === "function" + ? params.engagement(audioObj) + : audioObj.tribeOrigin ? null : renderEngagement(audioObj.key, + renderOpinionsVoting('/audios/opinions', audioObj.key, audioObj.opinions, returnTo, audioObj.opinions_inhabitants), + renderCommentsLink({ href: `/audios/${encodeURIComponent(audioObj.key)}`, count: commentCount }) + ); return div( { class: "trending-card audio-card" + (isOwn ? " own-content" : "") }, - header, + div( + { class: "card-header activity-card-header" }, + span(), + headerActions + ), div( { class: "card-section audio-card-body" }, - title ? div({ class: "shop-title-row" }, h2(titleHref ? a({ href: titleHref, class: "card-title-link" }, title) : title)) : null, + div({ class: "shop-title-row" }, title ? h2(titleHref ? a({ href: titleHref, class: "card-title-link" }, title) : title) : null, audioObj.tribeOrigin ? renderTribeOriginChip(audioObj.tribeOrigin) : audioObj.clearnet === true ? renderReachChip(true, i18n, clearnetItemHref('audios', audioObj.title, audioObj.key)) : null, renderLicenseChip(audioObj.license), ...(typeof params.titleChips === "function" ? params.titleChips(audioObj) : [])), + audioObj.lifetime ? div({ class: "card-chips-row" }, renderLifespanChip(audioObj.lifetime, i18n)) : null, renderAudioPlayer(audioObj), ...(typeof params.bodyExtra === "function" ? params.bodyExtra(audioObj) : []), + engagement, renderMapLocationVisitLabel(audioObj.mapUrl), - engagement + br(), + (() => { + const createdTs = audioObj.createdAt ? new Date(audioObj.createdAt).getTime() : NaN; + const updatedTs = audioObj.updatedAt ? new Date(audioObj.updatedAt).getTime() : NaN; + const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs); + + return p( + { class: "card-footer" }, + span({ class: "date-link" }, `${moment(audioObj.createdAt).format("YYYY/MM/DD HH:mm")}`), + userLink(audioObj.author), + showUpdated + ? span( + { class: "votations-comment-date" }, + ` | ${i18n.audioUpdatedAt}: ${moment(audioObj.updatedAt).format("YYYY/MM/DD HH:mm")}` + ) + : null + ); + })() ) ); }) @@ -160,7 +169,7 @@ const renderAudioForm = (filter, audioId, audioToEdit, params = {}) => { input({ type: "hidden", name: "returnTo", value: returnTo }), span(i18n.audioFileLabel), br(), - (filter === "edit" && renderMediaLockNote(audioToEdit)) || input({ type: "file", name: "audio", accept: "audio/*", required: filter !== "edit" }), + input({ type: "file", name: "audio", accept: "audio/*", required: filter !== "edit" }), br(), br(), span(i18n.audioTitleLabel), @@ -285,7 +294,6 @@ exports.singleAudioView = async (audioObj, filter = "all", comments = [], params const returnTo = safeText(params.returnTo) || buildReturnTo(filter, { q, sort }); const title = safeText(audioObj.title); - const href = `/audios/${encodeURIComponent(audioObj.key)}`; const isAuthor = String(audioObj.author) === String(userId); const isClearnet = !!audioObj.clearnet; @@ -306,28 +314,26 @@ exports.singleAudioView = async (audioObj, filter = "all", comments = [], params const tagsNode = renderTags(audioObj.tags); - const audioSide = div({ class: "tribe-side" }, - renderCardTop(audioObj.key, href, { arrow: false, + const detailActions = div({ class: "card-header activity-card-header" }, + renderContentActions(audioObj.key, `/audios/${encodeURIComponent(audioObj.key)}`, { author: audioObj.author, - date: audioObj.createdAt, - chips: [ - renderReachChip(isClearnet, i18n, isClearnet ? clearnetItemHref('audios', audioObj.title, audioObj.key) : null), - renderLicenseChip(audioObj.license), - ...chips - ], - bylineExtra: [renderEditedChip(audioObj.key, audioObj.rootId), renderAudioUpdated(audioObj)], favKind: 'audios', torrentFrom: { blobId: audioObj.url, name: audioObj.title }, isFavorite: audioObj.isFavorite, spread: params.spreads || null, returnTo, reportTitle: audioObj.title, deleteAction: isAuthor ? contentDeleteAction('audio', audioObj.key) : undefined - }), + }) + ); + + const audioSide = div({ class: "tribe-side" }, div({ class: "shop-title-row" }, title ? h2({ class: "tribe-card-title" }, title) : null, - isAuthor ? renderClearnetSwitch('audios', audioObj.rootId || audioObj.key, isClearnet) : null + renderReachChip(isClearnet, i18n, isClearnet ? clearnetItemHref('audios', audioObj.title, audioObj.key) : null), + isAuthor ? renderClearnetSwitch('audios', audioObj.rootId || audioObj.key, isClearnet) : null, + renderLicenseChip(audioObj.license) ), - renderDetailShare(audioObj.key, href, audioObj.title), + chips.length ? div({ class: "card-chips-row" }, ...chips) : null, safeText(audioObj.description) ? p({ class: "tribe-side-description" }, ...renderStyledText(audioObj.description)) : null, @@ -337,11 +343,29 @@ exports.singleAudioView = async (audioObj, filter = "all", comments = [], params ); const audioMain = div({ class: "tribe-main" }, + detailActions, renderAudioPlayer(audioObj), - audioObj?.url ? renderDownloadButton(blobUrlOf(audioObj.url), audioObj.title || "") : null, + (() => { + const createdTs = audioObj.createdAt ? new Date(audioObj.createdAt).getTime() : NaN; + const updatedTs = audioObj.updatedAt ? new Date(audioObj.updatedAt).getTime() : NaN; + const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs); + + return p( + { class: "card-footer" }, + span({ class: "date-link" }, `${moment(audioObj.createdAt).format("YYYY/MM/DD HH:mm")}`), + userLink(audioObj.author), + showUpdated + ? span( + { class: "votations-comment-date" }, + ` | ${i18n.audioUpdatedAt}: ${moment(audioObj.updatedAt).format("YYYY/MM/DD HH:mm")}` + ) + : null + ); + })(), renderEngagement(audioObj.key, renderOpinionsVoting('/audios/opinions', audioObj.key, audioObj.opinions, returnTo, audioObj.opinions_inhabitants), - renderAudioCommentsSection(audioObj.key, comments, returnTo)) + renderAudioCommentsSection(audioObj.key, comments, returnTo) + ) ); return template( @@ -395,10 +419,15 @@ exports.audioTranscodeDetailView = async ({ audio, decoded = false, stegoPayload ) ), div({ class: "bookmark-item card" }, - renderCardTop(audio.key, `/audios/${encodeURIComponent(audio.key)}`, { author: audio.author, date: audio.createdAt, chips: [itemSize ? renderEcoTax(itemSize, audio.key) : null], reportTitle: audio.title }), audio.title ? h2(audio.title) : null, renderAudioPlayer(audio, { skipTranscode: true }), - renderDetailShare(audio.key, `/audios/${encodeURIComponent(audio.key)}`, audio.title), + 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")), + itemSize ? span({ class: "melody-meta-sep" }, " · ") : null, + itemSize ? renderEcoTax(itemSize, audio.key) : null + ), safeText(audio.description) ? p({ class: "melody-bcs-desc" }, audio.description) : null, renderTags(audio.tags), form({ method: "POST", action: `/melody/transcode/${encodeURIComponent(audio.key)}`, class: "audio-transcode-run-form audio-transcode-form" }, @@ -435,4 +464,5 @@ exports.audioTranscodeDetailView = async ({ audio, decoded = false, stegoPayload ); }; +exports.audiosTranscodeView = exports.audioTranscodeDetailView; diff --git a/src/views/blockchain_view.js b/src/views/blockchain_view.js index e77200b..2c9ae2e 100644 --- a/src/views/blockchain_view.js +++ b/src/views/blockchain_view.js @@ -15,12 +15,9 @@ const FILTER_LABELS = { map: i18n.typeMap, shop: i18n.typeShop, shopProduct: i18n.typeShopProduct || 'Shop Product', pad: i18n.typePad || 'PAD', room: i18n.typeRoom || 'ROOM', roomMember: i18n.typeRoomMember || 'ROOM PARTICIPANT', wikiPage: i18n.typeWiki || 'WIKI', emergency: i18n.typeEmergency || 'EMERGENCY', emergencyConfirm: i18n.typeEmergencyConfirm || 'EMERGENCY CONFIRM', emergencyUpdate: i18n.typeEmergencyUpdate || 'EMERGENCY UPDATE', mailingList: i18n.typeMailingList || 'MAILING LIST', logisticsRoute: i18n.typeLogisticsRoute || 'ROUTE', logisticsRating: i18n.typeLogisticsRating || 'ROUTE RATING', podcast: i18n.typePodcast || 'PODCAST', podcastEpisode: i18n.typePodcastEpisode || 'EPISODE', podcastOpinion: i18n.typePodcastOpinion || 'EPISODE OPINION', podcastPlay: i18n.typePodcastPlay || 'EPISODE PLAY', campaign: i18n.typeCampaign || 'CAMPAIGN', campaignSignature: i18n.typeCampaignSignature || 'SIGNATURE', campaignUpdate: i18n.typeCampaignUpdate || 'CAMPAIGN UPDATE', chat: i18n.typeChat || 'CHAT', gameScore: i18n.typeGameScore || 'GAME SCORE', calendar: i18n.typeCalendar || 'CALENDAR', torrent: i18n.typeTorrent, file: i18n.typeFile, - school: i18n.typeSchool || 'SCHOOL', comment: i18n.typeComment + school: i18n.typeSchool || 'SCHOOL' }; -const blockKind = (block) => block && block.type === 'post' && block.content && typeof block.content.root === 'string' ? 'comment' : block && block.type; -const blockLabel = (block) => FILTER_LABELS[blockKind(block)] || blockKind(block); - const BASE_FILTERS = ['all', 'recent', 'mine', 'tombstone', 'logs']; const CAT_BLOCK1 = ['votes', 'event', 'task', 'report', 'calendar', 'school', 'parliament', 'courts']; const CAT_BLOCK2 = ['pub', 'tribe', 'about', 'contact', 'curriculum', 'vote', 'aiExchange']; @@ -81,7 +78,7 @@ const filterBlocks = (blocks, filter, userId) => { if (filter === 'shop') return blocks.filter(b => b.type === 'shop' || b.type === 'shopProduct'); if (filter === 'school') return blocks.filter(b => ['schoolCourse', 'schoolLesson', 'schoolEnroll', 'schoolCertificate'].includes(b.type)); if (filter === 'logs') return blocks.filter(b => b.type === 'log' && b.author === userId); - return blocks.filter(b => blockKind(b) === filter); + return blocks.filter(b => b.type === filter); }; const computeStats = (blocks) => { @@ -90,7 +87,7 @@ const computeStats = (blocks) => { const byAuthor = new Map(); for (const b of arr) { if (!b || !b.type) continue; - byType.set(blockKind(b), (byType.get(blockKind(b)) || 0) + 1); + byType.set(b.type, (byType.get(b.type) || 0) + 1); if (b.author) byAuthor.set(b.author, (byAuthor.get(b.author) || 0) + 1); } const typeBreakdown = Array.from(byType.entries()) @@ -255,7 +252,7 @@ const renderBlockDiagram = (blocks, qs) => { h3({ class: 'block-diagram-title' }, i18n.blockchainLatestDatagram || 'Latest Datagram'), ...last2.map(block => { const ts = moment(block.ts).format('YYYY-MM-DD HH:mm:ss'); - const typeLabel = blockLabel(block).toUpperCase(); + const typeLabel = (FILTER_LABELS[block.type] || block.type).toUpperCase(); const color = TYPE_COLORS[block.type] || '#95a5a6'; const shortId = block.id.length > 20 ? block.id.slice(0, 10) + '…' + block.id.slice(-8) : block.id; const shortAuthor = block.author.length > 20 ? block.author.slice(0, 10) + '…' + block.author.slice(-8) : block.author; @@ -348,7 +345,7 @@ const renderSingleBlockView = (block, filter = 'recent', userId, search = {}, vi ), div({ class: 'block-row block-row--meta' }, span({ class: 'blockchain-card-label' }, `${i18n.blockchainBlockType}:`), - span({ class: 'blockchain-card-value' }, blockLabel(block).toUpperCase()) + span({ class: 'blockchain-card-value' }, (FILTER_LABELS[block.type]||block.type).toUpperCase()) ), div({ class: 'block-row block-row--meta' }, span({ class: 'blockchain-card-label' }, `${i18n.blockchainBlockTimestamp}:`), @@ -369,7 +366,7 @@ const renderSingleBlockView = (block, filter = 'recent', userId, search = {}, vi ), div({ class: 'block-row block-row--meta' }, span({ class: 'blockchain-card-label' }, `${i18n.blockchainBlockType}:`), - span({ class: 'blockchain-card-value' }, blockLabel(block).toUpperCase()) + span({ class: 'blockchain-card-value' }, (FILTER_LABELS[block.type]||block.type).toUpperCase()) ), div({ class: 'block-row block-row--meta' }, span({ class: 'blockchain-card-label' }, `${i18n.oasisIdLabel || 'OasisID'}:`), @@ -553,7 +550,7 @@ const renderBlockchainView = (blocks, filter, userId, search = {}, extras = {}) table({ class:'block-info-table' }, tr(td({ class:'card-label' }, i18n.blockchainBlockTimestamp), td({ class:'card-value' }, moment(block.ts).format('YYYY-MM-DDTHH:mm:ss.SSSZ'))), tr(td({ class:'card-label' }, i18n.blockchainBlockID), td({ class:'card-value' }, block.id)), - tr(td({ class:'card-label' }, i18n.blockchainBlockType), td({ class:'card-value' }, blockLabel(block).toUpperCase())), + tr(td({ class:'card-label' }, i18n.blockchainBlockType), td({ class:'card-value' }, (FILTER_LABELS[block.type]||block.type).toUpperCase())), tr(td({ class:'card-label' }, i18n.blockchainBlockAuthor), td({ class:'card-value' }, a({ href:`/author/${encodeURIComponent(block.author)}`, class:'block-author user-link' }, block.author))), tr(td({ class:'card-label' }, i18n.blockchainBlockCarbon || 'Carbon footprint'), td({ class:'card-value' }, formatCarbon(block.size))) ) diff --git a/src/views/blog_view.js b/src/views/blog_view.js index db65c3d..d9bbfe1 100644 --- a/src/views/blog_view.js +++ b/src/views/blog_view.js @@ -1,9 +1,10 @@ -const { div, h2, p, section, button, form, a, input, label, span, textarea, br } = require("../server/node_modules/hyperaxe"); +const { div, h2, p, section, button, form, a, input, label, span, textarea, br, table, tr, td } = require("../server/node_modules/hyperaxe"); const RAW_HTML = Symbol.for('oasis.rawHtml'); const { TEXT_CAP } = require('../backend/long_text'); const { renderCommentsSection: renderSharedCommentsSection } = require("./comments_view"); -const { clearnetItemHref, template, i18n, renderOpinionsVoting, renderEngagement, renderCardTop, contentDeleteAction, renderSubscriptionBox, renderModuleStats, moduleIsEmpty, paged, renderDetailShare } = require("./main_views"); +const { clearnetItemHref, template, i18n, userLink, renderOpinionsVoting, renderEngagement, renderSpreadButton, renderContentActions, contentDeleteAction, renderSubscriptionBox, renderModuleStats, moduleIsEmpty, paged } = require("./main_views"); const { renderReachChip, renderClearnetSelector, renderClearnetSwitch } = require("./clearnet_view"); +const moment = require("../server/node_modules/moment"); const { config } = require("../server/SSB_server.js"); const { renderStyledHtml } = require("../backend/renderStyledText"); const { sanitizeHtml } = require("../backend/sanitizeHtml"); @@ -72,16 +73,22 @@ 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" : "") }, - renderCardTop(blog.id, href, { date: blog.createdAt, chips: blog.clearnet === true ? [renderReachChip(true, i18n, clearnetItemHref("blog", blog.subject || blog.text, blog.id))] : [], spread: spreadInfo || null, author: blog.author, favKind: 'blogs', isFavorite: blog.isFavorite, reportTitle: blog.subject || blog.text, deleteAction: isOwn ? contentDeleteAction('blog', blog.id) : undefined }), + 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, deleteAction: isOwn ? contentDeleteAction('blog', blog.id) : undefined }) + ), div({ class: "card-section blog-card-body" }, blog.subject ? div({ class: "shop-title-row" }, h2({ class: "tribe-card-title" }, a({ href }, blog.subject)) ) : null, + blog.clearnet === true ? div({ class: "card-chips-row" }, renderReachChip(true, i18n, clearnetItemHref("blog", blog.subject || blog.text, blog.id))) : null, div({ class: "blog-card-text", [RAW_HTML]: sanitizeHtml(renderStyledHtml(excerpt(blog.text))) }), renderBlogTags(blog.tags), - null + p({ class: "card-footer" }, + span({ class: "date-link" }, `${moment(blog.createdAt).format("YYYY/MM/DD HH:mm")}`), + userLink(blog.author) + ) ) ); }; @@ -177,12 +184,23 @@ exports.singleBlogView = async (blog, comments = [], params = {}) => { const isClearnet = !!blog.clearnet; const blogSide = div({ class: "tribe-side" }, - renderCardTop(blog.id, href, { arrow: false, date: blog.createdAt, chips: [renderReachChip(isClearnet, i18n, isClearnet ? clearnetItemHref("blog", blog.subject || blog.text, blog.id) : null)], spread: params.spreads || null, author: blog.author, favKind: 'blogs', isFavorite: blog.isFavorite, reportTitle: blog.subject || blog.text, returnTo: href, deleteAction: isAuthor ? contentDeleteAction('blog', blog.id) : undefined }), - div({ class: "shop-title-row" }, - h2({ class: "tribe-card-title" }, blog.subject || i18n.blogTitle) + div({ class: "card-header activity-card-header" }, + renderContentActions(blog.id, href, { spread: params.spreads || null, author: blog.author, favKind: 'blogs', isFavorite: blog.isFavorite, reportTitle: blog.subject || blog.text, returnTo: href, deleteAction: isAuthor ? contentDeleteAction('blog', blog.id) : undefined }) + ), + div({ class: "shop-title-row" }, + h2({ class: "tribe-card-title" }, blog.subject || i18n.blogTitle), + renderReachChip(isClearnet, i18n, isClearnet ? clearnetItemHref("blog", blog.subject || blog.text, blog.id) : null) ), - renderDetailShare(blog.id, href, blog.subject || blog.text), isAuthor ? renderClearnetSwitch("posts", blog.rootId || blog.id, isClearnet) : null, + 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}`) ), @@ -205,7 +223,8 @@ exports.singleBlogView = async (blog, comments = [], params = {}) => { ), renderEngagement(blog.id, renderOpinionsVoting('/blogs/opinions', blog.id, blog.opinions, href, blog.opinions_inhabitants), - renderCommentsSection(blog, comments)) + renderCommentsSection(blog, comments) + ) ); return template( diff --git a/src/views/bookmark_view.js b/src/views/bookmark_view.js index 3e386f2..8b6d2eb 100644 --- a/src/views/bookmark_view.js +++ b/src/views/bookmark_view.js @@ -2,7 +2,7 @@ const { form, button, div, h2, p, section, input, label, textarea, br, a, span, require("../server/node_modules/hyperaxe"); const { renderCommentsSection: renderSharedCommentsSection, renderCommentsLink } = require("./comments_view"); -const { clearnetItemHref, template, i18n, renderOpinionsVoting, renderEngagement, renderEcoTax, renderLifespanChip, renderCardTop, renderCardByline, renderSpreadEditWarning, renderModuleStats, moduleIsEmpty, contentDeleteAction, paged, renderMediaLockNote, renderEditedChip, renderDetailShare } = require("./main_views"); +const { clearnetItemHref, template, i18n, renderOpinionsVoting, renderEngagement, userLink, renderSpreadButton, renderEcoTax, renderLifespanChip, renderContentActions, renderSpreadEditWarning, renderModuleStats, moduleIsEmpty, contentDeleteAction, paged } = require("./main_views"); const { renderReachChip, renderClearnetSelector, renderClearnetSwitch, renderTribeOriginChip } = require("./clearnet_view"); const moment = require("../server/node_modules/moment"); const { config } = require("../server/SSB_server.js"); @@ -60,15 +60,6 @@ const renderTags = (tags) => { : null; }; -const renderBookmarkUpdated = (bookmark) => { - const createdTs = bookmark.createdAt ? new Date(bookmark.createdAt).getTime() : NaN; - const updatedTs = bookmark.updatedAt ? new Date(bookmark.updatedAt).getTime() : NaN; - const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs); - return showUpdated - ? span({ class: "votations-comment-date" }, `${i18n.bookmarkUpdatedAt}: ${moment(bookmark.updatedAt).format("YYYY/MM/DD HH:mm")}`) - : null; -}; - const renderBookmarkList = exports.renderBookmarkList = (filteredBookmarks, filter, params = {}) => { const returnTo = buildReturnTo(filter, params); @@ -87,32 +78,53 @@ const renderBookmarkList = exports.renderBookmarkList = (filteredBookmarks, filt : i18n.noUrl; const isOwn = bookmark.author && String(bookmark.author) === String(userId); - const href = `/bookmarks/${encodeURIComponent(bookmark.id)}`; const reachChip = bookmark.tribeOrigin ? renderTribeOriginChip(bookmark.tribeOrigin) : bookmark.clearnet === true ? renderReachChip(true, i18n, clearnetItemHref("bookmarks", bookmark.title || bookmark.url, bookmark.id)) : null; const extraChips = typeof params.titleChips === "function" ? params.titleChips(bookmark).filter(Boolean) : []; - const byline = { - author: bookmark.author, - date: bookmark.createdAt, - chips: [renderLifespanChip(bookmark.lifetime, i18n), reachChip, ...extraChips], - bylineExtra: [renderBookmarkUpdated(bookmark)] - }; - const header = typeof params.headerActions === "function" - ? div({ class: "card-header activity-card-header card-top" }, renderCardByline(byline), params.headerActions(bookmark)) + const headerActions = typeof params.headerActions === "function" + ? params.headerActions(bookmark) : bookmark.tribeOrigin - ? renderCardTop(bookmark.id, bookmark.tribeOrigin.href, { ...byline, spread: (params.spreadMap && params.spreadMap.get(bookmark.id)) || null, reportTitle: bookmark.title || bookmark.url }) - : renderCardTop(bookmark.id, href, { ...byline, spread: (params.spreadMap && params.spreadMap.get(bookmark.id)) || params.spreads || null, favKind: 'bookmarks', isFavorite: bookmark.isFavorite, reportTitle: bookmark.title, deleteAction: isOwn ? contentDeleteAction('bookmark', bookmark.id) : undefined, returnTo }); - const titleHref = typeof params.headerActions === "function" ? null : bookmark.tribeOrigin ? bookmark.tribeOrigin.href : href; - const engagement = typeof params.engagement === "function" ? params.engagement(bookmark) : null; + ? renderContentActions(null, bookmark.tribeOrigin.href) + : renderContentActions(bookmark.id, `/bookmarks/${encodeURIComponent(bookmark.id)}`, { spread: (params.spreadMap && params.spreadMap.get(bookmark.id)) || params.spreads || null, author: bookmark.author, favKind: 'bookmarks', isFavorite: bookmark.isFavorite, reportTitle: bookmark.title, deleteAction: isOwn ? contentDeleteAction('bookmark', bookmark.id) : undefined, returnTo }); + const titleHref = typeof params.headerActions === "function" ? null : bookmark.tribeOrigin ? bookmark.tribeOrigin.href : `/bookmarks/${encodeURIComponent(bookmark.id)}`; + const engagement = typeof params.engagement === "function" + ? params.engagement(bookmark) + : bookmark.tribeOrigin ? null : renderEngagement(bookmark.id, + renderOpinionsVoting('/bookmarks/opinions', bookmark.id, bookmark.opinions, returnTo, bookmark.opinions_inhabitants), + renderCommentsLink({ href: `/bookmarks/${encodeURIComponent(bookmark.id)}`, count: commentCount }) + ); return div( { class: "trending-card bookmark-card" + (isOwn ? " own-content" : "") }, - header, + div( + { class: "card-header activity-card-header" }, + span(), + headerActions + ), div( { class: "card-section bookmark-card-body" }, h2({ class: "bookmark-title" }, titleHref ? a({ href: titleHref, class: "card-title-link" }, bookmark.title || bookmark.url || "") : (bookmark.url ? urlLink : (bookmark.title || ""))), + bookmark.lifetime || reachChip || extraChips.length ? div({ class: "card-chips-row" }, renderLifespanChip(bookmark.lifetime, i18n), reachChip, ...extraChips) : null, titleHref ? (bookmark.title && bookmark.url ? p({ class: "bookmark-subtitle" }, urlLink) : null) : (bookmark.title && bookmark.url ? p({ class: "bookmark-subtitle" }, bookmark.title) : null), params.hideLastVisit ? null : renderCardField(i18n.bookmarkLastVisitLabel + ":", lastVisitTxt), ...(typeof params.bodyExtra === "function" ? params.bodyExtra(bookmark) : []), - engagement + br, + engagement, + (() => { + const createdTs = bookmark.createdAt ? new Date(bookmark.createdAt).getTime() : NaN; + const updatedTs = bookmark.updatedAt ? new Date(bookmark.updatedAt).getTime() : NaN; + const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs); + + return p( + { class: "card-footer" }, + span({ class: "date-link" }, `${moment(bookmark.createdAt).format("YYYY/MM/DD HH:mm")}`), + userLink(bookmark.author), + showUpdated + ? span( + { class: "votations-comment-date" }, + ` | ${i18n.bookmarkUpdatedAt}: ${moment(bookmark.updatedAt).format("YYYY/MM/DD HH:mm")}` + ) + : null + ); + })() ) ); }) @@ -136,11 +148,6 @@ const renderBookmarkForm = (filter, bookmarkId, bookmarkToEdit, tags, params = { form( { action: filter === "edit" ? `/bookmarks/update/${encodeURIComponent(bookmarkId)}` : "/bookmarks/create", method: "POST" }, input({ type: "hidden", name: "returnTo", value: returnTo }), - label(i18n.bookmarkTitleLabel), - br(), - input({ type: "text", name: "title", maxlength: "100", placeholder: i18n.bookmarkDescriptionPlaceholder, value: filter === "edit" ? bookmarkToEdit.title || "" : "" }), - br(), - br(), label(i18n.bookmarkUrlLabel), br(), input({ @@ -149,16 +156,14 @@ const renderBookmarkForm = (filter, bookmarkId, bookmarkToEdit, tags, params = { id: "url", required: true, placeholder: i18n.bookmarkUrlPlaceholder, - value: filter === "edit" ? bookmarkToEdit.url || "" : "", - ...(filter === "edit" && renderMediaLockNote(bookmarkToEdit) ? { readonly: true } : {}) + value: filter === "edit" ? bookmarkToEdit.url || "" : "" }), - filter === "edit" ? renderMediaLockNote(bookmarkToEdit) : null, br(), br(), label(i18n.bookmarkDescriptionLabel), br(), textarea( - { name: "description", id: "description", maxlength: "5000", placeholder: i18n.bookmarkDescriptionPlaceholder, rows: "4" }, + { name: "description", id: "description", placeholder: i18n.bookmarkDescriptionPlaceholder, rows: "4" }, filter === "edit" ? bookmarkToEdit.description || "" : "" ), br(), @@ -270,6 +275,7 @@ exports.singleBookmarkView = async (bookmark, filter = "all", comments = [], par const returnTo = params.returnTo || buildReturnTo(filter, { q, sort }); const isAuthor = String(bookmark.author) === String(userId); + const hasOpinions = Object.keys(bookmark.opinions || {}).length > 0; const isClearnet = !!bookmark.clearnet; const lastVisit = bookmark.lastVisit ? moment(bookmark.lastVisit) : null; @@ -288,7 +294,8 @@ exports.singleBookmarkView = async (bookmark, filter = "all", comments = [], par ].filter(Boolean); const sideActions = []; - if (isAuthor) { + sideActions.push(renderPMButton(bookmark.author)); + if (isAuthor && !hasOpinions) { sideActions.push(form( { method: "GET", action: `/bookmarks/edit/${encodeURIComponent(bookmark.id)}` }, input({ type: "hidden", name: "returnTo", value: returnTo }), @@ -297,30 +304,27 @@ exports.singleBookmarkView = async (bookmark, filter = "all", comments = [], par } const tagsNode = renderTags(bookmark.tags); - const href = `/bookmarks/${encodeURIComponent(bookmark.id)}`; - const bookmarkSide = div({ class: "tribe-side" }, - renderCardTop(bookmark.id, href, { arrow: false, + const detailActions = div({ class: "card-header activity-card-header" }, + renderContentActions(bookmark.id, `/bookmarks/${encodeURIComponent(bookmark.id)}`, { author: bookmark.author, - date: bookmark.createdAt, - chips: [ - renderReachChip(isClearnet, i18n, isClearnet ? clearnetItemHref("bookmarks", bookmark.title || bookmark.url, bookmark.id) : null), - ...chips - ], - bylineExtra: [renderEditedChip(bookmark.id, bookmark.rootId), renderBookmarkUpdated(bookmark)], favKind: 'bookmarks', isFavorite: bookmark.isFavorite, spread: params.spreads || null, returnTo, reportTitle: bookmark.title, deleteAction: isAuthor ? contentDeleteAction('bookmark', bookmark.id) : undefined - }), + }) + ); + + const bookmarkSide = div({ class: "tribe-side" }, div({ class: "shop-title-row" }, - h2({ class: "tribe-card-title" }, bookmark.title || urlLink || "") + h2({ class: "tribe-card-title" }, bookmark.url ? urlLink : (bookmark.title || "")), + renderReachChip(isClearnet, i18n, isClearnet ? clearnetItemHref("bookmarks", bookmark.title || bookmark.url, bookmark.id) : null) ), - renderDetailShare(bookmark.id, href, bookmark.title), isAuthor ? renderClearnetSwitch("bookmarks", bookmark.rootId || bookmark.id, isClearnet) : null, - bookmark.title && bookmark.url ? p({ class: "bookmark-subtitle" }, urlLink) : null, + bookmark.title && bookmark.url ? p({ class: "bookmark-subtitle" }, bookmark.title) : null, + chips.length ? div({ class: "card-chips-row" }, ...chips) : null, safeText(bookmark.description) ? p({ class: "tribe-side-description" }, ...renderStyledText(bookmark.description)) : null, @@ -329,10 +333,28 @@ exports.singleBookmarkView = async (bookmark, filter = "all", comments = [], par ); const bookmarkMain = div({ class: "tribe-main" }, + detailActions, renderCardField(i18n.bookmarkLastVisitLabel + ":", lastVisitTxt), + (() => { + const createdTs = bookmark.createdAt ? new Date(bookmark.createdAt).getTime() : NaN; + const updatedTs = bookmark.updatedAt ? new Date(bookmark.updatedAt).getTime() : NaN; + const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs); + return p( + { class: "card-footer" }, + span({ class: "date-link" }, `${moment(bookmark.createdAt).format("YYYY/MM/DD HH:mm")}`), + userLink(bookmark.author), + showUpdated + ? span( + { class: "votations-comment-date" }, + ` | ${i18n.bookmarkUpdatedAt}: ${moment(bookmark.updatedAt).format("YYYY/MM/DD HH:mm")}` + ) + : null + ); + })(), renderEngagement(bookmark.id, renderOpinionsVoting('/bookmarks/opinions', bookmark.id, bookmark.opinions, returnTo, bookmark.opinions_inhabitants), - renderBookmarkCommentsSection(bookmark.id, bookmark.rootId, comments, returnTo)) + renderBookmarkCommentsSection(bookmark.id, bookmark.rootId, comments, returnTo) + ) ); return template( diff --git a/src/views/calendars_view.js b/src/views/calendars_view.js index b43452d..f1990c4 100644 --- a/src/views/calendars_view.js +++ b/src/views/calendars_view.js @@ -1,6 +1,6 @@ -const { div, h2, h4, p, section, button, form, a, span, br, hr, textarea, input, label, select, option, table, tr, td } = require("../server/node_modules/hyperaxe") +const { div, h2, h3, h4, p, section, button, form, a, span, br, hr, textarea, input, label, select, option, table, tr, td, ul, li } = require("../server/node_modules/hyperaxe") const { renderStyledText } = require("../backend/renderStyledText") -const { clearnetItemHref, template, i18n, userLink, renderStateChip, renderLifespanChip, renderSpreadEditWarning, renderCardTop, renderEngagement, renderDocumentActions, renderInviteQrCard, renderSubscriptionBox, renderModuleStatsBy, moduleIsEmpty, contentDeleteAction, paged, renderDetailShare } = require("./main_views") +const { clearnetItemHref, template, i18n, userLink, renderStateChip, renderLifespanChip, renderSpreadButton , renderSpreadEditWarning, renderContentActions, renderDocumentActions, renderInviteQrCard, renderSubscriptionBox, renderModuleStatsBy, moduleIsEmpty, contentDeleteAction, paged } = require("./main_views") const { renderMapEmbed } = require("./maps_view") const { renderEncryptedChip, renderReachChip, renderClearnetSelector, renderClearnetSwitch, renderTribeOriginChip } = require("./clearnet_view") const moment = require("../server/node_modules/moment") @@ -58,13 +58,16 @@ const renderCalendarCard = exports.renderCalendarCard = (cal, spreadInfo) => { : (cal.subscriptionIn === false ? renderStateChip("closed", "✉", i18n.subscriptionOff) : null) ].filter(Boolean) return div({ class: "tribe-card" }, - renderCardTop(cal.rootId, href, origin - ? { author: cal.author, date: cal.createdAt, chips, reportTitle: cal.title } - : { date: cal.createdAt, chips, spread: spreadInfo || null, author: cal.author, favKind: 'calendars', isFavorite: cal.isFavorite, reportTitle: cal.title, deleteAction: String(cal.author) === String(userId) ? contentDeleteAction('calendar', cal.rootId) : undefined }), + div({ class: "card-header activity-card-header" }, + renderContentActions(cal.rootId, href, origin + ? { author: cal.author, reportTitle: cal.title } + : { spread: spreadInfo || null, author: cal.author, favKind: 'calendars', isFavorite: cal.isFavorite, reportTitle: cal.title, deleteAction: String(cal.author) === String(userId) ? contentDeleteAction('calendar', cal.rootId) : undefined }) + ), 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 ? div({ class: "card-field card-field-mb" }, span({ class: "card-label" }, `${String(i18n.calendarDeadlineLabel).toUpperCase()}: `), @@ -78,8 +81,7 @@ const renderCalendarCard = exports.renderCalendarCard = (cal, spreadInfo) => { ? null : div({ class: "tribe-card-members" }, span({ class: "tribe-members-count calendar-participants-count" }, `${i18n.calendarParticipantsLabel || "Participants"}: ${cal.participants.length}`) - ), - null + ) ) ) } @@ -284,30 +286,21 @@ exports.singleCalendarView = async (calendar, dates, notesByDate, params) => { renderEncryptedChip(i18n), renderLifespanChip(calendar.lifetime, i18n), canClearnet ? renderReachChip(isClearnet, i18n, isClearnet ? calClearnetHref(calendar) : null) : null, + isAuthor && canClearnet ? renderClearnetSwitch("calendars", calendar.rootId, isClearnet) : null, (isAuthor || isParticipant) ? renderStateChip(subscriptionIn ? "mutuals" : "closed", "✉", subscriptionIn ? i18n.subscriptionOn : i18n.subscriptionOff) : null ].filter(Boolean) const calSide = div({ class: "tribe-side" }, - renderCardTop(calendar.rootId, shareUrl, { arrow: false, - date: calendar.createdAt, - chips: detailChips, - author: calendar.author, - favKind: 'calendars', - isFavorite: calendar.isFavorite, - spread: (params && params.spreads) || null, - returnTo: shareUrl, - reportTitle: calendar.title, - deleteAction: isAuthor ? contentDeleteAction('calendar', calendar.rootId) : undefined - }), div({ class: "shop-title-row" }, h2({ class: "tribe-card-title" }, calendar.title || "\u2014") ), - renderDetailShare(calendar.rootId, shareUrl, calendar.title), - isAuthor && canClearnet ? renderClearnetSwitch("calendars", calendar.rootId, isClearnet) : null, + detailChips.length ? div({ class: "card-chips-row" }, ...detailChips) : null, 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 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-label" }, i18n.calendarStatusLabel || "Status"), td({ class: "tribe-info-value", colspan: "3" }, renderStatus(calendar))), + tr(td({ class: "tribe-info-value", colspan: "4" }, userLink(calendar.author))) ), tags, renderMapEmbed(params && params.mapData, calendar.mapUrl), @@ -371,8 +364,7 @@ exports.singleCalendarView = async (calendar, dates, notesByDate, params) => { ) ) : null, - renderDocumentActions('calendars', calendar.rootId), - null + renderDocumentActions('calendars', calendar.rootId) ) const minDate = now.add(1, "minute").format("YYYY-MM-DDTHH:mm") @@ -464,6 +456,18 @@ exports.singleCalendarView = async (calendar, dates, notesByDate, params) => { : null const calMain = div({ class: "tribe-main" }, + div({ class: "card-header activity-card-header" }, + renderContentActions(calendar.rootId, shareUrl, { + author: calendar.author, + favKind: 'calendars', + isFavorite: calendar.isFavorite, + spread: (params && params.spreads) || null, + returnTo: shareUrl, + reportTitle: calendar.title, + deleteAction: isAuthor ? contentDeleteAction('calendar', calendar.rootId) : undefined + }) + ), + calNav, grid, dayNotesSection, @@ -500,7 +504,8 @@ exports.clearnetCalendarView = async (calendar, dates, notesByDate = {}) => { const entriesHtml = entries.map(e => { const notes = (notesByDate[e.key] || []).filter(n => n && String(n.text || "").trim()) return `
-
${e.label ? `` : ""}
${e.when.map(w => `📅 ${fmt(w)}`).join("")}
+ ${e.label ? `
${esc(e.label)}
` : ""} +
${e.when.map(w => `📅 ${fmt(w)}`).join("")}
${notes.map(n => `

${renderRichText(n.text)}

`).join("")}
` }).join("") @@ -508,8 +513,7 @@ exports.clearnetCalendarView = async (calendar, dates, notesByDate = {}) => { .cn-cal-title{color:var(--fg);margin:0 0 16px 0;font-size:32px;font-weight:700;word-break:break-word} .cn-cal-meta{display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin-bottom:16px} .cn-cal-entry{border:1px solid var(--border);border-radius:8px;padding:14px 16px;margin:12px 0;background:var(--bg-elev)} -.cn-cal-entry-head{display:flex;flex-wrap:wrap;align-items:center;gap:6px 12px;margin-bottom:8px} -.cn-cal-entry-label{color:var(--fg);font-weight:600;font-size:16px;word-break:break-word} +.cn-cal-entry-label{color:var(--fg);font-weight:600;font-size:16px;margin-bottom:8px;word-break:break-word} .cn-cal-dates{display:flex;flex-wrap:wrap;gap:6px} .cn-cal-note{color:var(--fg-soft);line-height:1.5;margin:10px 0 0 0;word-break:break-word} .cn-cal-empty{color:var(--fg-dim)} diff --git a/src/views/campaigns_view.js b/src/views/campaigns_view.js index 407b5fb..d3758fb 100644 --- a/src/views/campaigns_view.js +++ b/src/views/campaigns_view.js @@ -1,5 +1,5 @@ const { div, h2, h3, p, section, button, form, a, span, br, textarea, input, label, select, option, img, progress, table, tr, td, video: videoHyperaxe } = require("../server/node_modules/hyperaxe"); -const { clearnetItemHref, template, i18n, userLink, renderStateChip, renderCardTop, renderSubscriptionBox, renderModuleStats, renderOpinionsVoting, renderEngagement, moduleIsEmpty, contentDeleteAction, paged, renderEditedChip, renderMediaLockNote, renderDetailShare } = require("./main_views"); +const { clearnetItemHref, template, i18n, userLink, renderStateChip, renderContentActions, renderSubscriptionBox, renderModuleStats, renderOpinionsVoting, renderEngagement, moduleIsEmpty, contentDeleteAction, paged } = require("./main_views"); const { renderReachChip, renderClearnetSelector, renderClearnetSwitch } = require("./clearnet_view"); const { renderCommentsSection } = require("./comments_view"); const { renderMapEmbed } = require("./maps_view"); @@ -56,13 +56,16 @@ exports.campaignChipFor = campaignChipFor; const renderCampaignCard = (cp, params = {}) => div({ class: `tribe-card campaign-card campaign-${cp.status.toLowerCase()}` }, - renderCardTop(cp.id, campaignHref(cp), { date: cp.createdAt, chips: [statusChip(cp), outcomeChip(cp), categoryChip(cp), cp.clearnet === true && clearnetEligible(cp) ? renderReachChip(true, i18n, clearnetItemHref("campaigns", cp.title, cp.id)) : null], author: cp.author, favKind: "campaigns", isFavorite: cp.isFavorite, reportTitle: cp.title, spread: (params.spreadMap && params.spreadMap.get(cp.id)) || null, deleteAction: cp.isOwner ? contentDeleteAction("campaign", cp.id) : undefined }), + div({ class: "card-header activity-card-header" }, + span(), + renderContentActions(cp.id, campaignHref(cp), { author: cp.author, favKind: "campaigns", isFavorite: cp.isFavorite, reportTitle: cp.title, spread: (params.spreadMap && params.spreadMap.get(cp.id)) || null, deleteAction: cp.isOwner ? contentDeleteAction("campaign", cp.id) : undefined }) + ), div({ class: "tribe-card-body" }, renderCover(cp), div({ class: "shop-title-row" }, h2({ class: "tribe-card-title" }, a({ href: campaignHref(cp) }, cp.title || "—"))), + div({ class: "card-chips-row" }, statusChip(cp), outcomeChip(cp), categoryChip(cp), cp.clearnet === true && clearnetEligible(cp) ? renderReachChip(true, i18n, clearnetItemHref("campaigns", cp.title, cp.id)) : null), renderProgress(cp), - cp.deadline ? p({ class: "job-meta-line" }, `${i18n.campaignDeadlineLabel}: ${fmt(cp.deadline)}`) : null, - null + cp.deadline ? p({ class: "job-meta-line" }, `${i18n.campaignDeadlineLabel}: ${fmt(cp.deadline)}`) : null ) ); @@ -92,7 +95,7 @@ const renderForm = (cp) => label(i18n.campaignTextLabel), br(), textarea({ name: "text", rows: 10, maxlength: "8000", placeholder: i18n.campaignTextPlaceholder }, cp ? cp.text : ""), br(), label(i18n.uploadMedia), br(), - (cp && renderMediaLockNote(cp)) || input({ type: "file", name: "blob", accept: "image/*,video/*,audio/*,application/pdf,.torrent" }), br(), br(), + input({ type: "file", name: "blob", accept: "image/*,video/*,audio/*,application/pdf,.torrent" }), br(), br(), label(i18n.campaignCategoryLabel), br(), select({ name: "category" }, ...CATEGORIES.map(c => option({ value: c, ...((cp ? cp.category : "OTHER") === c ? { selected: true } : {}) }, catLabel(c)))), br(), br(), label(i18n.campaignGoalLabel), br(), @@ -152,22 +155,25 @@ const renderSignatures = (cp) => const renderUpdate = (cp, u, href, editing) => div({ class: "campaign-update", id: `update-${encodeURIComponent(u.id)}` }, - renderCardTop(null, null, { author: u.author, date: u.createdAt, chips: [u.edited ? renderStateChip("neutral", "", i18n.campaignUpdateEdited) : null] }), - cp.isOwner - ? div({ class: "emergency-update-head campaign-update-head" }, - div({ class: "tribe-side-actions emergency-update-actions" }, + div({ class: "emergency-update-head campaign-update-head" }, + div({ class: "card-chips-row" }, + u.edited ? renderStateChip("neutral", "", i18n.campaignUpdateEdited) : null + ), + cp.isOwner + ? div({ class: "tribe-side-actions emergency-update-actions" }, editing ? null : form({ method: "GET", action: `${href}#update-${encodeURIComponent(u.id)}` }, input({ type: "hidden", name: "editUpdate", value: u.id }), button({ type: "submit", class: "update-btn" }, i18n.campaignUpdate)), form({ method: "POST", action: `/campaigns/updates/${encodeURIComponent(u.id)}/delete` }, button({ type: "submit", class: "delete-btn" }, i18n.campaignDelete)) ) - ) - : null, + : null + ), editing ? form({ method: "POST", action: `/campaigns/updates/${encodeURIComponent(u.id)}/edit`, enctype: "multipart/form-data", class: "campaign-update-form" }, textarea({ name: "text", rows: 3, maxlength: "3000", required: true, autofocus: true }, u.text), br(), input({ type: "file", name: "blob", accept: "image/*,video/*,audio/*,application/pdf,.torrent" }), br(), br(), button({ type: "submit", class: "create-button" }, i18n.campaignUpdate) ) - : div({ class: "campaign-update-text" }, ...renderStyledText(u.text)) + : div({ class: "campaign-update-text" }, ...renderStyledText(u.text)), + p({ class: "card-footer" }, span({ class: "date-link" }, `${fmt(u.createdAt)}`), userLink(u.author)) ); const renderUpdates = (cp, href, editUpdate) => @@ -197,30 +203,34 @@ exports.singleCampaignView = async (cp, params = {}) => { ].filter(Boolean) : []; const side = div({ class: "tribe-side" }, - renderCardTop(cp.id, href, { arrow: false, - date: cp.createdAt, - chips: [ - statusChip(cp), outcomeChip(cp), categoryChip(cp), cp.proposalId ? renderStateChip("neutral", "⚖", String(i18n.campaignElevated).toUpperCase()) : null, - renderReachChip(isClearnet, i18n, isClearnet ? clearnetItemHref("campaigns", cp.title, cp.id) : null) - ], - bylineExtra: [renderEditedChip(cp.tipId, cp.rootId)], - author: cp.author, favKind: "campaigns", isFavorite: cp.isFavorite, reportTitle: cp.title, spread: params.spread || null, returnTo: href, deleteAction: cp.isOwner ? contentDeleteAction("campaign", cp.id) : undefined - }), + div({ class: "card-header activity-card-header" }, + renderContentActions(cp.id, href, { author: cp.author, favKind: "campaigns", isFavorite: cp.isFavorite, reportTitle: cp.title, spread: params.spread || null, returnTo: href, deleteAction: cp.isOwner ? contentDeleteAction("campaign", cp.id) : undefined }) + ), div({ class: "shop-title-row" }, h2({ class: "tribe-card-title" }, cp.title)), - cp.isOwner && canClearnet ? renderClearnetSwitch("campaigns", cp.rootId || cp.id, isClearnet) : null, - cp.deadline - ? table({ class: "tribe-info-table jobs-info-table" }, - tr( + div({ class: "card-chips-row" }, + statusChip(cp), outcomeChip(cp), categoryChip(cp), cp.proposalId ? renderStateChip("neutral", "⚖", String(i18n.campaignElevated).toUpperCase()) : null, + renderReachChip(isClearnet, i18n, isClearnet ? clearnetItemHref("campaigns", cp.title, cp.id) : null), + cp.isOwner && canClearnet ? renderClearnetSwitch("campaigns", cp.rootId || cp.id, isClearnet) : null + ), + 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" }, fmt(cp.createdAt)) + ), + cp.deadline + ? tr( td({ class: "tribe-info-label" }, i18n.campaignDeadlineLabel), td({ class: "tribe-info-value", colspan: "3" }, fmt(cp.deadline)) ) - ) - : null, + : null, + tr( + td({ class: "tribe-info-value", colspan: "4" }, userLink(cp.author)) + ) + ), div({ class: "campaign-qr" }, img({ src: `${href}/qr.png`, alt: i18n.campaignQrAlt, class: "campaign-qr-img" }), p({ class: "campaign-qr-caption" }, i18n.campaignQrCaption) ), - renderDetailShare(cp.id, href, cp.title), renderProgress(cp), renderMapEmbed(params.mapData, cp.mapUrl), sideActions.length ? div({ class: "tribe-side-actions" }, ...sideActions) : null, @@ -265,7 +275,8 @@ exports.singleCampaignView = async (cp, params = {}) => { renderSignatures(cp), renderEngagement(cp.id, renderOpinionsVoting("/campaigns/opinions", cp.id, cp.opinions, href, cp.opinions_inhabitants), - renderCommentsSection({ action: `/campaigns/${encodeURIComponent(cp.id)}/comments`, comments: params.comments || [], returnTo: href })) + renderCommentsSection({ action: `/campaigns/${encodeURIComponent(cp.id)}/comments`, comments: params.comments || [], returnTo: href }) + ) ); return template( cp.title, diff --git a/src/views/chats_view.js b/src/views/chats_view.js index 721874a..bb8202f 100644 --- a/src/views/chats_view.js +++ b/src/views/chats_view.js @@ -1,5 +1,5 @@ -const { div, h2, p, section, button, form, a, span, textarea, br, input, label, select, option, img, table, tr, td, details, summary, video: videoHyperaxe, audio: audioHyperaxe } = require("../server/node_modules/hyperaxe") -const { template, i18n, userLink, renderContentActions, userLinkLabel, renderStateChip, renderLifespanChip, renderCardTop, renderEngagement, renderInviteQrCard, renderSubscriptionBox, renderModuleStatsBy, moduleIsEmpty, renderTorrentDownload, contentDeleteAction, paged } = require("./main_views") +const { div, h2, p, section, button, form, a, span, textarea, br, input, label, select, option, img, table, tr, td, ul, li, details, summary, video: videoHyperaxe, audio: audioHyperaxe } = require("../server/node_modules/hyperaxe") +const { template, i18n, userLink, userLinkLabel, renderStateChip, renderLifespanChip, renderSpreadButton, renderContentActions, renderInviteQrCard, renderSubscriptionBox, renderModuleStatsBy, moduleIsEmpty, renderTorrentDownload, torrentDownloadHref, contentDeleteAction, paged } = require("./main_views") const { renderEncryptedChip } = require("./clearnet_view") const { renderResults, renderBallot, outcomeOf } = require("./polls_view") const moment = require("../server/node_modules/moment") @@ -84,17 +84,17 @@ const renderChatCard = exports.renderChatCard = (chat, filter, params = {}) => { const href = `/chats/${encodeURIComponent(chat.key)}` return div({ class: "tribe-card" }, - renderCardTop(chat.key, href, { - date: chat.createdAt, - chips, - spread: params.spread || null, - author: chat.author, - favKind: 'chats', - isFavorite: chat.isFavorite, - returnTo: buildReturnTo(filter, params), - reportTitle: chat.title, - deleteAction: String(chat.author) === String(userId) ? contentDeleteAction('chat', chat.key) : undefined - }), + div({ class: "card-header activity-card-header" }, + renderContentActions(chat.key, href, { + spread: params.spread || null, + author: chat.author, + favKind: 'chats', + isFavorite: chat.isFavorite, + returnTo: buildReturnTo(filter, params), + reportTitle: chat.title, + deleteAction: String(chat.author) === String(userId) ? contentDeleteAction('chat', chat.key) : undefined + }) + ), div({ class: "tribe-card-image-wrapper" }, a({ href: `/chats/${encodeURIComponent(chat.key)}#chat-latest` }, renderMediaBlob(chat.image, "/assets/images/default-avatar.png", { class: "tribe-card-hero-image" }) @@ -106,11 +106,11 @@ const renderChatCard = exports.renderChatCard = (chat, filter, params = {}) => { a({ href: `/chats/${encodeURIComponent(chat.key)}#chat-latest` }, chat.title || i18n.chatUntitled) ) ), + chips.length ? div({ class: "card-chips-row" }, ...chips) : null, chat.description ? p({ class: "tribe-card-description" }, chat.description) : null, div({ class: "tribe-card-members" }, span({ class: "tribe-members-count" }, `${i18n.chatParticipants}: ${safeArr(chat.members).length}`) - ), - null + ) ) ) } @@ -406,8 +406,8 @@ exports.singleChatView = async (chat, filter, messages = [], params = {}) => { : null ].filter(Boolean) const chatSide = div({ class: "tribe-side" }, - div({ class: "card-header activity-card-header card-top" }, - renderContentActions(chat.key, `/chats/${encodeURIComponent(chat.key)}`, { menu: true, arrow: false, spread: params.spreads || null, author: chat.author, favKind: 'chats', isFavorite: chat.isFavorite, returnTo: params.returnTo, reportTitle: chat.title, deleteAction: isAuthor ? contentDeleteAction('chat', chat.key) : undefined }) + div({ class: "card-header activity-card-header" }, + renderContentActions(chat.key, `/chats/${encodeURIComponent(chat.key)}`, { spread: params.spreads || null, author: chat.author, favKind: 'chats', isFavorite: chat.isFavorite, returnTo: params.returnTo, reportTitle: chat.title, deleteAction: isAuthor ? contentDeleteAction('chat', chat.key) : undefined }) ), div({ class: "shop-title-row" }, h2({ class: "tribe-card-title" }, chat.title || i18n.chatUntitled) diff --git a/src/views/clearnet_view.js b/src/views/clearnet_view.js index 792de5d..a54687d 100644 --- a/src/views/clearnet_view.js +++ b/src/views/clearnet_view.js @@ -163,7 +163,7 @@ const LICENSES = [ { id: 'LAL-1.3', label: 'Free Art License 1.3', name: 'Free Art License 1.3' } ]; const DEFAULT_LICENSE = LICENSES[0].id; -const LICENSED_KINDS = new Set(['wiki', 'audio', 'video', 'image', 'document', 'file', 'torrent']); +const LICENSED_KINDS = new Set(['wiki', 'audio', 'video', 'image', 'document']); const licenseInfo = (id) => { const v = String(id || '').trim() || DEFAULT_LICENSE; return LICENSES.find(l => l.id === v) || null; @@ -366,8 +366,6 @@ const getCurrentPalette = () => { }; const buildBaseCss = (p) => ` -.cn-pdf-frame{display:block;width:100%;max-width:100%;height:80vh;min-height:520px;border:0;margin:0 0 1rem 0;background:transparent} -.pending-media{display:flex;align-items:center;justify-content:center;min-height:120px;max-width:480px;padding:16px;border:1px dashed #8a8a8a;border-radius:10px;background:rgba(128,128,128,0.15);color:#a0a0a0;text-align:center;box-sizing:border-box;margin:8px 0} :root{ --bg:${p.bg}; --bg-elev:${p.bgElev}; --bg-sub:${p.bgSub}; --fg:${p.fg}; --fg-soft:${p.fgSoft}; --fg-dim:${p.fgDim}; @@ -392,12 +390,10 @@ footer.cn-footer .cn-footer-logo{width:56px;height:auto;display:block;margin:0 a footer.cn-footer .cn-footer-line{margin:3px 0} `; -const renderRssLink = (href) => ``; - const renderClearnetHubSearch = ({ type = '', query = '' } = {}) => ``; -const renderClearnetPage = ({ title, ogTitle, ogDescription = '', ogImage = null, extraCss = '', body, headerExtra = null, hubFeedId = null, feed = '' }) => { +const renderClearnetPage = ({ title, ogTitle, ogDescription = '', ogImage = null, extraCss = '', body, headerExtra = null, hubFeedId = null }) => { const safeTitle = escapeHtml(title || 'Oasis'); const safeOgTitle = escapeHtml(ogTitle || title || 'Oasis'); const safeOgDesc = escapeHtml(ogDescription || ''); @@ -419,15 +415,12 @@ const renderClearnetPage = ({ title, ogTitle, ogDescription = '', ogImage = null - ${feed ? `` : ''} - -${clearnetPdfViewer(state, { title: t, back, embed: true })} -`; -}; -const renderClearnetPdfView = (state, { title: t = '', back = '' } = {}) => renderClearnetPage({ - title: t || 'PDF', - extraCss: CLEARNET_PDF_CSS, - body: `${back ? `

← ${escapeHtml(cnText('goBack', 'Go back'))}

` : ''}

${escapeHtml(t || 'PDF')}

${clearnetPdfViewer(state, { title: t, back })}` -}); - const renderClearnetMediaView = ({ kind, item }) => { const blob = blobUrl(item.url); const title = escapeHtml(item.title || cnText('cnUntitled', 'Untitled')); @@ -565,7 +501,7 @@ const renderClearnetMediaView = ({ kind, item }) => { .cn-media-frame audio,.cn-media-frame video{width:100%;max-width:100%;display:block;border-radius:6px;background:#000} .cn-media-frame .cn-media-doc{display:inline-block;max-width:100%;box-sizing:border-box;overflow-wrap:anywhere;background:var(--bg-elev);border:1px solid var(--border);border-radius:6px;padding:10px 18px;color:var(--fg);text-decoration:none} .cn-media-frame .cn-media-doc:hover{border-color:var(--fg)} -.cn-media-frame .cn-media-docs{display:flex;flex-direction:row;flex-wrap:wrap;align-items:center;gap:10px} +.cn-media-frame .cn-media-docs{display:flex;flex-direction:column;align-items:flex-start;gap:10px} `; const fileKind = kind === 'document' || kind === 'torrent' || kind === 'file'; let mediaHtml = ''; @@ -578,16 +514,14 @@ const renderClearnetMediaView = ({ kind, item }) => { mediaHtml = ``; } else if (kind === 'file' || kind === 'torrent') { const dlName = (v) => String(v || '').replace(/[\\/:*?"<>|\r\n]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 120); - const extLabel = (name) => { const m = String(name || '').match(/\.([A-Za-z0-9]{1,8})$/); return m ? m[1].toUpperCase() : 'FILE'; }; - const dlLink = (href, name, label) => `\u2B07 ${escapeHtml(label)}`; + const dlLink = (href, name) => `⇩ ${escapeHtml(name)}`; const original = kind === 'file' ? blob : blobUrl(item.source); const originalName = dlName(kind === 'file' ? item.fileName : item.sourceName) || dlName(item.title) || 'file'; const torrentBlob = kind === 'file' ? blobUrl(item.torrentUrl) : blob; const torrentName = `${kind === 'file' ? originalName : (dlName(item.title) || 'download').replace(/\.torrent$/i, '')}.torrent`; - const magnet = typeof item.magnet === 'string' && item.magnet.startsWith('magnet:?') ? `\u2B07 ${escapeHtml(cnText('torrentMagnet', 'MAGNET'))}` : ''; - mediaHtml = `
${[original ? dlLink(original, originalName, extLabel(originalName)) : '', torrentBlob ? dlLink(torrentBlob, torrentName, 'TORRENT') : '', magnet].filter(Boolean).join('')}
`; + mediaHtml = `
${[original ? dlLink(original, originalName) : '', torrentBlob ? dlLink(torrentBlob, torrentName) : ''].filter(Boolean).join('')}
`; } else if (kind === 'document') { - mediaHtml = renderClearnetPdfFrame(blob, item.title); + mediaHtml = `⇩ ${title}`; } else { mediaHtml = `${title}`; } @@ -612,13 +546,13 @@ const renderClearnetMediaView = ({ kind, item }) => { ogTitle: item.title || (kind ? kindLabel(kind) : 'Oasis'), ogDescription: item.description || '', ogImage: (kind === 'image') ? blob : null, - extraCss: kind === 'document' ? extraCss + CLEARNET_PDF_CSS : extraCss, + extraCss, body, hubFeedId: item.author || null }); }; -const renderClearnetPodcastView = ({ channel, feed = '' }) => { +const renderClearnetPodcastView = ({ channel }) => { const title = escapeHtml(channel.title || cnText('cnUntitled', 'Untitled')); const desc = renderRichText(channel.description || ''); const cover = channel.cover && channel.cover.blobId ? blobUrl(channel.cover.blobId) : null; @@ -645,7 +579,7 @@ const renderClearnetPodcastView = ({ channel, feed = '' }) => { return `

#${Number(ep.number) || 0} · ${escapeHtml(ep.title || '')}

${epDate}
${player}${ep.description ? `

${renderRichText(ep.description)}

` : ''}
`; }).join(''); const body = ` -
${renderKindTag('podcast')}${dateStr ? `📅 ${dateStr}` : ''}🎧 ${episodes.length}${renderClearnetLicense(channel.license)}${feed ? `RSS` : ''}
+
${renderKindTag('podcast')}${dateStr ? `📅 ${dateStr}` : ''}🎧 ${episodes.length}

${title}

${cover ? (coverIsVideo ? `` : `${title}`) : ''} ${desc ? `

${desc}

` : ''} @@ -658,7 +592,6 @@ const renderClearnetPodcastView = ({ channel, feed = '' }) => { ogImage: cover && !coverIsVideo ? cover : null, extraCss, body, - feed, hubFeedId: channel.author || null }); }; @@ -696,9 +629,5 @@ module.exports = { CLEARNET_PAGER_CSS, paginateClearnet, renderClearnetPager, - renderClearnetMediaView, - renderClearnetPdfEmbed, - renderClearnetPdfView, - renderClearnetPdfFrame, - CLEARNET_PDF_CSS + renderClearnetMediaView }; diff --git a/src/views/comments_view.js b/src/views/comments_view.js index 74846f8..b71f73f 100644 --- a/src/views/comments_view.js +++ b/src/views/comments_view.js @@ -68,7 +68,7 @@ const renderCommentsSection = ({ action, comments = [], returnTo = null, closedN h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel), form({ method: "POST", action, class: "comment-form", enctype: "multipart/form-data" }, returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null, - textarea({ name: "text", rows: 4, maxlength: String(TEXT_CAP), class: "comment-textarea", placeholder: i18n.voteNewCommentPlaceholder }), + textarea({ name: "text", rows: 4, maxlength: String(TEXT_CAP), class: "comment-textarea", placeholder: i18n.voteNewCommentPlaceholder, required: true }), div({ class: "comment-file-upload" }, label(i18n.uploadMedia), input({ type: "file", name: "blob" })), br(), button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton) diff --git a/src/views/courts_view.js b/src/views/courts_view.js index d0e18ad..5f9fa9d 100644 --- a/src/views/courts_view.js +++ b/src/views/courts_view.js @@ -1,6 +1,6 @@ -const { form, button, div, h2, p, section, input, label, br, a, span, table, thead, tbody, tr, th, td, textarea, select, option, img } = require('../server/node_modules/hyperaxe'); +const { form, button, div, h2, p, section, input, label, br, a, span, table, thead, tbody, tr, th, td, textarea, select, option, ul, li, img } = require('../server/node_modules/hyperaxe'); const moment = require('../server/node_modules/moment'); -const { template, i18n, userLink, renderStateChip, renderModuleStatsBy, paged, renderCardTop, renderEngagement } = require('./main_views'); +const { template, i18n, userLink, renderStateChip, renderModuleStatsBy, paged } = require('./main_views'); const { renderStyledText, safeExternalHref } = require('../backend/renderStyledText'); const CourtsE2EChip = () => renderStateChip('encrypted', '🔒', i18n.encryptedChipLabel || 'E2E'); @@ -44,9 +44,6 @@ const PRESET_KEYS = { const presetLabel = (t) => i18n[PRESET_KEYS[t]] || t; const caseTitle = (title) => CASE_TITLE_PRESETS.reduce((acc, t) => acc.split(t).join(presetLabel(t)), String(title || '')); const statusLabel = (status) => String(i18n[`courtsStatus${String(status || '').toUpperCase()}`] || status || '').toUpperCase(); -const caseHref = (c) => `/courts/cases/${encodeURIComponent(c.id)}`; -const caseTop = (c, chips = [CourtsE2EChip()]) => renderCardTop(null, caseHref(c), { author: c.accuser, date: c.createdAt || c.openedAt, chips }); -const caseShare = (c) => renderEngagement(c.id, null, null, { href: caseHref(c), title: caseTitle(c.title) }); const FILTERS = [ { value: 'cases', key: 'courtsFilterCases' }, @@ -466,32 +463,38 @@ const CaseCard = (c) => { (c.isAccuser || c.isRespondent); return div( { class: 'card' }, - caseTop(c), - h2(caseTitle(c.title)), div( - { class: 'card-field' }, - span({ class: 'card-label' }, i18n.courtsCaseMethod + ': '), - span({ class: 'card-value' }, methodLabel(c.method)) - ), - div( - { class: 'card-field' }, - span({ class: 'card-label' }, i18n.courtsThStatus + ': '), - span({ class: 'card-value' }, statusLabel(c.status)) - ), - c.answerBy - ? div( + { class: 'card-header' }, + div( + { class: 'card-header__meta' }, + h2(caseTitle(c.title)), + div({ class: 'card-chips-row' }, CourtsE2EChip()), + div( { class: 'card-field' }, - span({ class: 'card-label' }, i18n.courtsThAnswerBy + ': '), - span({ class: 'card-value' }, fmt(c.answerBy)) - ) - : null, - c.evidenceBy - ? div( + span({ class: 'card-label' }, i18n.courtsCaseMethod + ': '), + span({ class: 'card-value' }, methodLabel(c.method)) + ), + div( { class: 'card-field' }, - span({ class: 'card-label' }, i18n.courtsThEvidenceBy + ': '), - span({ class: 'card-value' }, fmt(c.evidenceBy)) - ) - : null, + span({ class: 'card-label' }, i18n.courtsThStatus + ': '), + span({ class: 'card-value' }, statusLabel(c.status)) + ), + c.answerBy + ? div( + { class: 'card-field' }, + span({ class: 'card-label' }, i18n.courtsThAnswerBy + ': '), + span({ class: 'card-value' }, fmt(c.answerBy)) + ) + : null, + c.evidenceBy + ? div( + { class: 'card-field' }, + span({ class: 'card-label' }, i18n.courtsThEvidenceBy + ': '), + span({ class: 'card-value' }, fmt(c.evidenceBy)) + ) + : null + ) + ), div( { class: 'table-wrap mt-2' }, applyEl(table, { class: 'table table--centered' }, [ @@ -678,12 +681,13 @@ const MyCaseCard = (c) => { return div( { class: 'card' }, - caseTop(c), + br(), div( { class: 'card-field' }, span({ class: 'card-label' }, i18n.courtsThCase + ': '), span({ class: 'card-value' }, caseTitle(c.title)) ), + div({ class: 'card-chips-row' }, CourtsE2EChip()), div( { class: 'card-field' }, span({ class: 'card-label' }, i18n.courtsThStatus + ': '), @@ -938,7 +942,7 @@ const HistoryList = (rows = []) => { const canShowDescription = hh.mine || hh.publicDetails; return div( { class: 'card' }, - caseTop(hh, []), + br(), hh.method ? div( { class: 'card-field' }, @@ -959,7 +963,14 @@ const HistoryList = (rows = []) => { span({ class: 'card-value' }, String(hh.status || '')) ), h2(caseTitle(hh.title)), - canShowDescription ? RichTextBlock(hh.description || '') : null + canShowDescription ? RichTextBlock(hh.description || '') : null, + form( + { + method: 'GET', + action: `/courts/cases/${encodeURIComponent(hh.id)}` + }, + button({ type: 'submit', class: 'create-button' }, i18n.courtsViewDetails) + ) ); }); return div({ class: 'cards' }, ...cards); @@ -1059,32 +1070,46 @@ const CaseDetailsBlock = (c) => { return div( { class: 'card case-details-card' }, - caseTop(c), - h2(caseTitle(c.title)), - c.method - ? div( - { class: 'card-field' }, - span({ class: 'card-label' }, i18n.courtsCaseMethod + ': '), - span({ class: 'card-value' }, methodLabel(c.method)) - ) - : null, + br(), div( - { class: 'card-field' }, - span({ class: 'card-label' }, i18n.courtsThJudge + ': '), - span({ class: 'card-value' }, judgeValue) - ), - div( - { class: 'card-field' }, - span({ class: 'card-label' }, i18n.courtsThStatus + ': '), - span({ class: 'card-value' }, statusLabel(c.status)) - ), - c.decidedAt - ? div( + { class: 'card-header' }, + div( + { class: 'card-header__meta' }, + h2(caseTitle(c.title)), + div({ class: 'card-chips-row' }, CourtsE2EChip()), + c.method + ? div( + { class: 'card-field' }, + span({ class: 'card-label' }, i18n.courtsCaseMethod + ': '), + span({ class: 'card-value' }, methodLabel(c.method)) + ) + : null, + div( { class: 'card-field' }, - span({ class: 'card-label' }, i18n.courtsThDecisionBy + ': '), - span({ class: 'card-value' }, fmt(c.decidedAt)) - ) - : null, + span({ class: 'card-label' }, i18n.courtsThJudge + ': '), + span({ class: 'card-value' }, judgeValue) + ), + div( + { class: 'card-field' }, + span({ class: 'card-label' }, i18n.courtsThStatus + ': '), + span({ class: 'card-value' }, statusLabel(c.status)) + ), + c.createdAt + ? div( + { class: 'card-field' }, + span({ class: 'card-label' }, i18n.courtsThCreatedAt + ': '), + span({ class: 'card-value' }, fmt(c.createdAt)) + ) + : null, + c.decidedAt + ? div( + { class: 'card-field' }, + span({ class: 'card-label' }, i18n.courtsThDecisionBy + ': '), + span({ class: 'card-value' }, fmt(c.decidedAt)) + ) + : null + ) + ), div( { class: 'table-wrap mt-2' }, applyEl(table, { class: 'table table--centered' }, [ @@ -1259,8 +1284,7 @@ const CaseDetailsBlock = (c) => { ) ) ) - : null, - caseShare(c) + : null ); }; diff --git a/src/views/document_view.js b/src/views/document_view.js index f991dfe..e609e1a 100644 --- a/src/views/document_view.js +++ b/src/views/document_view.js @@ -4,7 +4,7 @@ const { renderCommentsSection: renderSharedCommentsSection, renderCommentsLink } const moment = require("../server/node_modules/moment"); const { renderLicenseChip, renderLicenseSelect, renderReachChip, renderClearnetSelector, renderClearnetSwitch, renderTribeOriginChip } = require('./clearnet_view'); -const { clearnetItemHref, template, i18n, renderOpinionsVoting, renderEngagement, renderEcoTax, renderLifespanChip, renderCardTop, renderCardByline, renderSpreadEditWarning, renderModuleStats, moduleIsEmpty, contentDeleteAction, paged, blobUrlOf, renderPdfDocument, renderMediaLockNote, renderEditedChip, renderDetailShare } = require("./main_views"); +const { clearnetItemHref, template, i18n, renderOpinionsVoting, renderEngagement, userLink, renderSpreadButton, renderEcoTax, renderLifespanChip, renderContentActions, renderSpreadEditWarning, renderModuleStats, moduleIsEmpty, contentDeleteAction, paged, blobUrlOf } = require("./main_views"); const { config } = require("../server/SSB_server.js"); const { renderStyledText } = require("../backend/renderStyledText"); @@ -23,6 +23,7 @@ const buildReturnTo = (filter, params = {}) => { return `/documents?${parts.join("&")}`; }; +const safeDomId = (prefix, key) => `${prefix}${String(key || "").replace(/[^A-Za-z0-9_-]/g, "_")}`; const renderTags = (tags) => { const list = safeArr(tags).map((t) => String(t || "").trim()).filter(Boolean); @@ -42,15 +43,6 @@ const renderDocumentCommentsSection = (documentKey, rootId, comments = [], retur }); }; -const renderDocumentUpdated = (doc) => { - const createdTs = doc.createdAt ? new Date(doc.createdAt).getTime() : NaN; - const updatedTs = doc.updatedAt ? new Date(doc.updatedAt).getTime() : NaN; - const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs); - return showUpdated - ? span({ class: "votations-comment-date" }, `${i18n.documentUpdatedAt}: ${moment(doc.updatedAt).format("YYYY/MM/DD HH:mm")}`) - : null; -}; - const renderDocumentList = exports.renderDocumentList = (documents, filter, params = {}) => { const returnTo = buildReturnTo(filter, params); @@ -58,38 +50,55 @@ const renderDocumentList = exports.renderDocumentList = (documents, filter, para ? documents.map((doc) => { const commentCount = typeof doc.commentCount === "number" ? doc.commentCount : 0; const title = safeText(doc.title); - const href = `/documents/${encodeURIComponent(doc.key)}`; + const pdfId = safeDomId("pdf-container-", doc.key); const isOwn = doc.author && String(doc.author) === String(userId); - const byline = { - author: doc.author, - date: doc.createdAt, - chips: [ - doc.tribeOrigin ? renderTribeOriginChip(doc.tribeOrigin) : doc.clearnet === true ? renderReachChip(true, i18n, clearnetItemHref('documents', doc.title, doc.key)) : null, - renderLicenseChip(doc.license), - ...(typeof params.titleChips === "function" ? params.titleChips(doc) : []), - renderLifespanChip(doc.lifetime, i18n) - ], - bylineExtra: [renderDocumentUpdated(doc)] - }; - const header = typeof params.headerActions === "function" - ? div({ class: "card-header activity-card-header card-top" }, renderCardByline(byline), params.headerActions(doc)) + const headerActions = typeof params.headerActions === "function" + ? params.headerActions(doc) : doc.tribeOrigin - ? renderCardTop(doc.key, doc.tribeOrigin.href, { ...byline, spread: (params.spreadMap && params.spreadMap.get(doc.key)) || null, reportTitle: doc.title }) - : renderCardTop(doc.key, href, { ...byline, spread: (params.spreadMap && params.spreadMap.get(doc.key)) || params.spreads || null, favKind: 'documents', torrentFrom: { blobId: doc.url, name: doc.title }, isFavorite: doc.isFavorite, reportTitle: doc.title, deleteAction: isOwn ? contentDeleteAction('document', doc.key) : undefined, returnTo }); - const titleHref = typeof params.headerActions === "function" ? null : doc.tribeOrigin ? doc.tribeOrigin.href : href; - const engagement = typeof params.engagement === "function" ? params.engagement(doc) : null; + ? renderContentActions(null, doc.tribeOrigin.href) + : renderContentActions(doc.key, `/documents/${encodeURIComponent(doc.key)}`, { spread: (params.spreadMap && params.spreadMap.get(doc.key)) || params.spreads || null, author: doc.author, favKind: 'documents', torrentFrom: { blobId: doc.url, name: doc.title }, isFavorite: doc.isFavorite, reportTitle: doc.title, deleteAction: isOwn ? contentDeleteAction('document', doc.key) : undefined, returnTo }); + const titleHref = typeof params.headerActions === "function" ? null : doc.tribeOrigin ? doc.tribeOrigin.href : `/documents/${encodeURIComponent(doc.key)}`; + const engagement = typeof params.engagement === "function" + ? params.engagement(doc) + : doc.tribeOrigin ? null : renderEngagement(doc.key, + renderOpinionsVoting('/documents/opinions', doc.key, doc.opinions, returnTo, doc.opinions_inhabitants), + renderCommentsLink({ href: `/documents/${encodeURIComponent(doc.key)}`, count: commentCount }) + ); return div( { class: "trending-card document-card" + (isOwn ? " own-content" : "") }, - header, + div( + { class: "card-header activity-card-header" }, + span(), + headerActions + ), div( { class: "card-section document-card-body" }, - title ? div({ class: "shop-title-row" }, h2(titleHref ? a({ href: titleHref, class: "card-title-link" }, title) : title)) : null, + div({ class: "shop-title-row" }, title ? h2(titleHref ? a({ href: titleHref, class: "card-title-link" }, title) : title) : null, doc.tribeOrigin ? renderTribeOriginChip(doc.tribeOrigin) : doc.clearnet === true ? renderReachChip(true, i18n, clearnetItemHref('documents', doc.title, doc.key)) : null, renderLicenseChip(doc.license), ...(typeof params.titleChips === "function" ? params.titleChips(doc) : [])), + doc.lifetime ? div({ class: "card-chips-row" }, renderLifespanChip(doc.lifetime, i18n)) : null, doc?.url - ? renderPdfDocument(blobUrlOf(doc.url), { title: doc.title }) + ? div({ id: pdfId, class: "pdf-viewer-container", "data-pdf-url": blobUrlOf(doc.url) }) : p(i18n.documentNoFile), ...(typeof params.bodyExtra === "function" ? params.bodyExtra(doc) : []), - engagement + engagement, + br(), + (() => { + const createdTs = doc.createdAt ? new Date(doc.createdAt).getTime() : NaN; + const updatedTs = doc.updatedAt ? new Date(doc.updatedAt).getTime() : NaN; + const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs); + + return p( + { class: "card-footer" }, + span({ class: "date-link" }, `${moment(doc.createdAt).format("YYYY/MM/DD HH:mm")}`), + userLink(doc.author), + showUpdated + ? span( + { class: "votations-comment-date" }, + ` | ${i18n.documentUpdatedAt}: ${moment(doc.updatedAt).format("YYYY/MM/DD HH:mm")}` + ) + : null + ); + })() ) ); }) @@ -113,7 +122,7 @@ const renderDocumentForm = (filter, documentId, docToEdit, params = {}) => { input({ type: "hidden", name: "returnTo", value: returnTo }), label(i18n.documentFileLabel), br(), - (filter === "edit" && renderMediaLockNote(docToEdit)) || input({ type: "file", name: "document", accept: "application/pdf", required: filter !== "edit" }), + input({ type: "file", name: "document", accept: "application/pdf", required: filter !== "edit" }), br(), br(), label(i18n.documentTitleLabel), @@ -214,7 +223,7 @@ exports.documentView = async (documents, filter = "all", documentId = null, para ) ); - return tpl; + return `${tpl}`; }; exports.singleDocumentView = async (doc, filter = "all", comments = [], params = {}) => { @@ -224,7 +233,9 @@ exports.singleDocumentView = async (doc, filter = "all", comments = [], params = const returnTo = safeText(params.returnTo) || buildReturnTo(filter, { q, sort }); const title = safeText(doc.title); + const pdfId = safeDomId("pdf-container-", doc.key); const isAuthor = String(doc.author) === String(userId); + const hasOpinions = Object.keys(doc.opinions || {}).length > 0; const isClearnet = !!doc.clearnet; const chips = [ @@ -233,7 +244,7 @@ exports.singleDocumentView = async (doc, filter = "all", comments = [], params = ].filter(Boolean); const sideActions = []; - if (isAuthor) { + if (isAuthor && !hasOpinions) { sideActions.push(form( { method: "GET", action: `/documents/edit/${encodeURIComponent(doc.key)}` }, input({ type: "hidden", name: "returnTo", value: returnTo }), @@ -242,30 +253,27 @@ exports.singleDocumentView = async (doc, filter = "all", comments = [], params = } const tagsNode = renderTags(doc.tags); - const href = `/documents/${encodeURIComponent(doc.key)}`; - const docSide = div({ class: "tribe-side" }, - renderCardTop(doc.key, href, { arrow: false, + const detailActions = div({ class: "card-header activity-card-header" }, + renderContentActions(doc.key, `/documents/${encodeURIComponent(doc.key)}`, { author: doc.author, - date: doc.createdAt, - chips: [ - renderReachChip(isClearnet, i18n, isClearnet ? clearnetItemHref('documents', doc.title, doc.key) : null), - renderLicenseChip(doc.license), - ...chips - ], - bylineExtra: [renderEditedChip(doc.key, doc.rootId), renderDocumentUpdated(doc)], favKind: 'documents', torrentFrom: { blobId: doc.url, name: doc.title }, isFavorite: doc.isFavorite, spread: params.spreads || null, returnTo: buildReturnTo(filter, { q, sort }), reportTitle: doc.title, deleteAction: isAuthor ? contentDeleteAction('document', doc.key) : undefined - }), + }) + ); + + const docSide = div({ class: "tribe-side" }, div({ class: "shop-title-row" }, title ? h2({ class: "tribe-card-title" }, title) : null, - isAuthor ? renderClearnetSwitch('documents', doc.rootId || doc.key, isClearnet) : null + renderReachChip(isClearnet, i18n, isClearnet ? clearnetItemHref('documents', doc.title, doc.key) : null), + isAuthor ? renderClearnetSwitch('documents', doc.rootId || doc.key, isClearnet) : null, + renderLicenseChip(doc.license) ), - renderDetailShare(doc.key, href, doc.title), + chips.length ? div({ class: "card-chips-row" }, ...chips) : null, safeText(doc.description) ? p({ class: "tribe-side-description" }, ...renderStyledText(doc.description)) : null, @@ -274,12 +282,31 @@ exports.singleDocumentView = async (doc, filter = "all", comments = [], params = ); const docMain = div({ class: "tribe-main" }, + detailActions, doc?.url - ? renderPdfDocument(blobUrlOf(doc.url), { title: doc.title, preview: true }) + ? div({ id: pdfId, class: "pdf-viewer-container", "data-pdf-url": blobUrlOf(doc.url) }) : p(i18n.documentNoFile), + (() => { + const createdTs = doc.createdAt ? new Date(doc.createdAt).getTime() : NaN; + const updatedTs = doc.updatedAt ? new Date(doc.updatedAt).getTime() : NaN; + const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs); + + return p( + { class: "card-footer" }, + span({ class: "date-link" }, `${moment(doc.createdAt).format("YYYY/MM/DD HH:mm")}`), + userLink(doc.author), + showUpdated + ? span( + { class: "votations-comment-date" }, + ` | ${i18n.documentUpdatedAt}: ${moment(doc.updatedAt).format("YYYY/MM/DD HH:mm")}` + ) + : null + ); + })(), renderEngagement(doc.key, renderOpinionsVoting('/documents/opinions', doc.key, doc.opinions, returnTo, doc.opinions_inhabitants), - renderDocumentCommentsSection(doc.key, doc.rootId || doc.key, comments, returnTo)) + renderDocumentCommentsSection(doc.key, doc.rootId || doc.key, comments, returnTo) + ) ); const tpl = template( @@ -309,6 +336,6 @@ exports.singleDocumentView = async (doc, filter = "all", comments = [], params = ) ); - return tpl; + return `${tpl}`; }; diff --git a/src/views/emergencies_view.js b/src/views/emergencies_view.js index a90689e..b5c00e2 100644 --- a/src/views/emergencies_view.js +++ b/src/views/emergencies_view.js @@ -1,5 +1,5 @@ const { div, h2, h3, p, section, button, form, a, span, br, textarea, input, label, select, option, img, video: videoHyperaxe, table, tr, td } = require("../server/node_modules/hyperaxe"); -const { clearnetItemHref, template, i18n, renderStateChip, renderCardTop, renderCardByline, renderSubscriptionBox, renderModuleStats, moduleIsEmpty, contentDeleteAction, paged, renderEngagement, renderDetailShare } = require("./main_views"); +const { clearnetItemHref, template, i18n, userLink, renderStateChip, renderContentActions, renderSubscriptionBox, renderModuleStats, moduleIsEmpty, contentDeleteAction, paged } = require("./main_views"); const { renderReachChip, renderClearnetSelector, renderClearnetSwitch } = require("./clearnet_view"); const { renderCommentsSection } = require("./comments_view"); const { renderMapEmbed } = require("./maps_view"); @@ -32,13 +32,16 @@ const emergencyHref = (emergency) => `/emergencies/${encodeURIComponent(emergenc const renderEmergencyCard = (emergency, params = {}) => div({ class: `tribe-card emergency-card emergency-card-${String(emergency.severity || "UNVERIFIED").toLowerCase()}` }, - renderCardTop(emergency.id, emergencyHref(emergency), { date: emergency.createdAt, chips: [severityChip(emergency), statusChip(emergency), categoryChip(emergency), emergency.clearnet === true && clearnetEligible(emergency) ? renderReachChip(true, i18n, clearnetItemHref("emergencies", emergency.title, emergency.id)) : null], author: emergency.author, favKind: "emergencies", isFavorite: emergency.isFavorite, reportTitle: emergency.title, spread: (params.spreadMap && params.spreadMap.get(emergency.id)) || null, deleteAction: String(emergency.author) === String(userId) ? contentDeleteAction("emergency", emergency.id) : undefined }), + div({ class: "card-header activity-card-header" }, + span(), + renderContentActions(emergency.id, emergencyHref(emergency), { author: emergency.author, favKind: "emergencies", isFavorite: emergency.isFavorite, reportTitle: emergency.title, spread: (params.spreadMap && params.spreadMap.get(emergency.id)) || null, deleteAction: String(emergency.author) === String(userId) ? contentDeleteAction("emergency", emergency.id) : undefined }) + ), div({ class: "tribe-card-body" }, emergency.media && emergency.media.kind === "image" ? a({ href: emergencyHref(emergency) }, img({ loading: 'lazy', class: "emergency-card-cover", src: `/blob/${encodeURIComponent(emergency.media.blobId)}`, alt: emergency.title || "" })) : null, emergency.media && emergency.media.kind === "video" ? videoHyperaxe({ class: "emergency-card-cover", src: `/blob/${encodeURIComponent(emergency.media.blobId)}`, controls: true, preload: "metadata" }) : null, div({ class: "shop-title-row" }, h2({ class: "tribe-card-title" }, a({ href: emergencyHref(emergency) }, emergency.title || "—"))), - emergency.expiresAt ? p({ class: "job-meta-line" }, `${i18n.emergencyExpiresAt}: ${moment(emergency.expiresAt).format("YYYY/MM/DD HH:mm")}`) : null, - null + div({ class: "card-chips-row" }, severityChip(emergency), statusChip(emergency), categoryChip(emergency), emergency.clearnet === true && clearnetEligible(emergency) ? renderReachChip(true, i18n, clearnetItemHref("emergencies", emergency.title, emergency.id)) : null), + emergency.expiresAt ? p({ class: "job-meta-line" }, `${i18n.emergencyExpiresAt}: ${moment(emergency.expiresAt).format("YYYY/MM/DD HH:mm")}`) : null ) ); @@ -152,18 +155,30 @@ exports.singleEmergencyView = async (emergency, params = {}) => { : []; const updates = Array.isArray(emergency.updates) ? emergency.updates : []; const side = div({ class: "tribe-side" }, - renderCardTop(emergency.id, href, { arrow: false, date: emergency.createdAt, chips: [severityChip(emergency), statusChip(emergency), categoryChip(emergency), renderReachChip(isClearnet, i18n, isClearnet ? clearnetItemHref("emergencies", emergency.title, emergency.id) : null)], author: emergency.author, favKind: "emergencies", isFavorite: emergency.isFavorite, reportTitle: emergency.title, spread: params.spread || null, returnTo: href, deleteAction: isAuthor ? contentDeleteAction("emergency", emergency.id) : undefined }), + div({ class: "card-header activity-card-header" }, + renderContentActions(emergency.id, href, { author: emergency.author, favKind: "emergencies", isFavorite: emergency.isFavorite, reportTitle: emergency.title, spread: params.spread || null, returnTo: href, deleteAction: isAuthor ? contentDeleteAction("emergency", emergency.id) : undefined }) + ), div({ class: "shop-title-row" }, h2({ class: "tribe-card-title" }, emergency.title)), - renderDetailShare(emergency.id, href, emergency.title), - isAuthor && canClearnet ? renderClearnetSwitch("emergencies", emergency.rootId || emergency.id, isClearnet) : null, - emergency.expiresAt - ? table({ class: "tribe-info-table jobs-info-table" }, - tr( + div({ class: "card-chips-row" }, + severityChip(emergency), statusChip(emergency), categoryChip(emergency), + renderReachChip(isClearnet, i18n, isClearnet ? clearnetItemHref("emergencies", emergency.title, emergency.id) : null), + isAuthor && canClearnet ? renderClearnetSwitch("emergencies", emergency.rootId || emergency.id, isClearnet) : null + ), + 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(emergency.createdAt).format("YYYY/MM/DD HH:mm")) + ), + emergency.expiresAt + ? tr( td({ class: "tribe-info-label" }, i18n.emergencyExpiresAt), td({ class: "tribe-info-value", colspan: "3" }, moment(emergency.expiresAt).format("YYYY/MM/DD HH:mm")) ) - ) - : null, + : null, + tr( + td({ class: "tribe-info-value", colspan: "4" }, userLink(emergency.author)) + ) + ), div({ class: "tribe-card-members" }, span({ class: "tribe-members-count" }, `${i18n.emergencyConfirmations}: ${emergency.confirmationCount || 0}`) ), @@ -200,10 +215,10 @@ exports.singleEmergencyView = async (emergency, params = {}) => { const editing = params.editUpdate && params.editUpdate === u.id; return div({ class: "emergency-update", id: `update-${encodeURIComponent(u.id)}` }, div({ class: "emergency-update-head" }, - renderCardByline({ author: u.author, date: u.createdAt, chips: [ + div({ class: "card-chips-row" }, renderStateChip(u.confirmationCount > 0 ? "mutuals" : "neutral", "✓", `${i18n.emergencyConfirmations} · ${u.confirmationCount || 0}`), u.edited ? renderStateChip("neutral", "", i18n.emergencyUpdateEdited) : null - ] }), + ), div({ class: "tribe-side-actions emergency-update-actions" }, !mineUpdate && !confirmedUpdate && emergency.status === "ACTIVE" ? form({ method: "POST", action: `/emergencies/updates/${encodeURIComponent(u.id)}/confirm` }, input({ type: "hidden", name: "emergency", value: emergency.id }), button({ type: "submit", class: "tribe-action-btn" }, `✓ ${String(i18n.emergencyConfirmButton).toUpperCase()}`)) @@ -223,7 +238,8 @@ exports.singleEmergencyView = async (emergency, params = {}) => { input({ type: "file", name: "blob", accept: "image/*,video/*,audio/*,application/pdf,.torrent" }), br(), br(), button({ type: "submit", class: "create-button" }, i18n.emergencyUpdate) ) - : div({ class: "emergency-update-text" }, ...renderStyledText(u.text)) + : div({ class: "emergency-update-text" }, ...renderStyledText(u.text)), + p({ class: "card-footer" }, span({ class: "date-link" }, `${moment(u.createdAt).format("YYYY/MM/DD HH:mm")}`), userLink(u.author)) ); })) : p(i18n.emergencyNoUpdates), @@ -235,7 +251,7 @@ exports.singleEmergencyView = async (emergency, params = {}) => { ) : null ), - renderEngagement(emergency.id, null, renderCommentsSection({ action: `/emergencies/${encodeURIComponent(emergency.id)}/comments`, comments: params.comments || [], returnTo: href })) + renderCommentsSection({ action: `/emergencies/${encodeURIComponent(emergency.id)}/comments`, comments: params.comments || [], returnTo: href }) ); return template( emergency.title, diff --git a/src/views/event_view.js b/src/views/event_view.js index 20e9e1b..220277c 100644 --- a/src/views/event_view.js +++ b/src/views/event_view.js @@ -1,12 +1,12 @@ const { div, h2, p, section, button, form, a, span, textarea, br, input, label, select, option, table, tr, td, details, summary, ul, li, hr } = require("../server/node_modules/hyperaxe"); const { renderCommentsSection: renderSharedCommentsSection } = require("./comments_view"); -const { clearnetItemHref, template, i18n, renderOpinionsVoting, renderEngagement, userLink, renderStateChip, renderOpenClosedChip, renderPrivacyChip, renderLifespanChip, renderEcoTax, renderCardTop, renderCardByline, renderSpreadEditWarning, renderDocumentActions, renderInviteQrCard, renderSubscriptionBox, renderModuleStatsBy, moduleIsEmpty, renderEcoValueChip, contentDeleteAction, paged, renderEditedChip, renderMediaLockNote, renderDetailShare } = require("./main_views"); +const { clearnetItemHref, template, i18n, renderOpinionsVoting, renderEngagement, userLink, renderStateChip, renderOpenClosedChip, renderPrivacyChip, renderLifespanChip, renderEcoTax, renderSpreadButton, renderContentActions, renderSpreadEditWarning, renderDocumentActions, renderInviteQrCard, renderSubscriptionBox, renderModuleStatsBy, moduleIsEmpty, renderEcoValueChip, contentDeleteAction, paged, renderCreatedRows } = 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 { renderStyledText, safeExternalHref } = require("../backend/renderStyledText"); -const { renderMapEmbed } = require("./maps_view"); +const { renderMapLocationUrl, renderMapEmbed, renderMapLocationVisitLabel } = require("./maps_view"); const { renderReachChip, renderEncryptedChip, renderClearnetSelector, renderClearnetSwitch, renderTribeOriginChip } = require("./clearnet_view"); const userId = config.keys.id; @@ -140,21 +140,22 @@ const renderEventItem = exports.renderEventItem = (e, filter, spreadInfo, opts = const dateText = e.date ? moment(e.date).format("YYYY/MM/DD HH:mm") : ""; const isOwn = e.organizer && String(e.organizer) === String(userId); - const byline = { author: e.organizer || e.author, date: e.createdAt, chips: chips.length ? [div({ class: "card-chips-row" }, ...chips)] : [] }; - const detailHref = `/events/${encodeURIComponent(e.id)}?filter=${encodeURIComponent(currentFilter)}`; - const cardTop = opts.headerActions !== undefined - ? div({ class: "card-header activity-card-header card-top" }, renderCardByline(byline), opts.headerActions) - : origin - ? renderCardTop(e.id, href, { ...byline, spread: spreadInfo || null, reportTitle: e.title }) - : renderCardTop(e.id, detailHref, { ...byline, spread: spreadInfo || null, favKind: 'events', isFavorite: e.isFavorite, reportTitle: e.title, deleteAction: isOwn ? contentDeleteAction('event', e.id) : undefined, returnTo: `/events?filter=${encodeURIComponent(currentFilter)}` }); + const headerActions = opts.headerActions !== undefined + ? opts.headerActions + : origin ? null : 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, deleteAction: isOwn ? contentDeleteAction('event', e.id) : undefined, returnTo: `/events?filter=${encodeURIComponent(currentFilter)}` }); return div({ class: "trending-card event-card" + (isOwn ? " own-content" : "") }, - cardTop, + headerActions ? div( + { class: "card-header activity-card-header" }, + span(), + headerActions + ) : null, div({ class: "card-section event-card-body" }, div({ class: "shop-title-row" }, h2({ class: "tribe-card-title" }, href ? a({ href }, e.title || i18n.eventsTitle) : (e.title || i18n.eventsTitle) ) ), + chips.length ? div({ class: "card-chips-row" }, ...chips) : null, e.location && String(e.location).trim() ? div({ class: "card-field card-field-mb" }, span({ class: "card-label" }, `${String(i18n.eventLocation).toUpperCase()}: `), @@ -168,8 +169,7 @@ const renderEventItem = exports.renderEventItem = (e, filter, spreadInfo, opts = origin ? null : div({ class: "tribe-card-members" }, span({ class: "tribe-members-count" }, `${i18n.eventAttendees}: ${attendees.length}`) ), - ...(Array.isArray(opts.bodyExtra) ? opts.bodyExtra.filter(Boolean) : []), - null + ...(Array.isArray(opts.bodyExtra) ? opts.bodyExtra.filter(Boolean) : []) ) ); }; @@ -332,7 +332,8 @@ exports.eventView = async (events, filter, eventId, returnTo, params = {}) => { ), br(), ...renderGalleryFields(formData, currentFilter === "edit"), - ...(currentFilter === "edit" && renderMediaLockNote(formData) ? [] : [label(i18n.uploadMedia), br(), input({ type: "file", name: "blob" }), br(), br()]), + label(i18n.uploadMedia), br(), + input({ type: "file", name: "blob" }), br(), br(), br(), label(i18n.eventDateLabel), br(), @@ -458,6 +459,7 @@ exports.singleEventView = async (event, filter, comments = [], params = {}) => { renderPrivacyChip(isPrivate, i18n), renderEventStatusChip(event.status), isEncrypted ? renderEncryptedChip(i18n) : renderReachChip(isClearnet, i18n, isClearnet ? clearnetItemHref('events', event.title, event.id) : null), + isOrganizer && canClearnet ? renderClearnetSwitch('events', event.rootId || event.id, isClearnet) : null, isAttending ? renderStateChip("whole", "★", i18n.eventAttended) : null, renderLifespanChip(event.lifetime, i18n), renderEcoTax(event.msgSize, event.id), @@ -512,14 +514,15 @@ exports.singleEventView = async (event, filter, comments = [], params = {}) => { : null; const eventSide = div({ class: "tribe-side" }, - renderCardTop(event.id, returnToSelf, { arrow: false, date: event.createdAt, chips, bylineExtra: [renderEditedChip(event.id, event.rootId)], spread: params.spreads || null, author: event.organizer || event.author, favKind: 'events', isFavorite: event.isFavorite, reportTitle: event.title, deleteAction: isOrganizer ? contentDeleteAction('event', event.id) : undefined, returnTo: `/events?filter=${encodeURIComponent(currentFilter)}` }), + div({ class: "card-header activity-card-header" }, + renderContentActions(event.id, returnToSelf, { spread: params.spreads || null, author: event.organizer || event.author, favKind: 'events', isFavorite: event.isFavorite, reportTitle: event.title, deleteAction: isOrganizer ? contentDeleteAction('event', event.id) : undefined, returnTo: `/events?filter=${encodeURIComponent(currentFilter)}` }) + ), div({ class: "shop-title-row" }, h2({ class: "tribe-card-title" }, event.title) ), - renderDetailShare(event.id, returnToSelf, event.title), - isOrganizer && canClearnet ? renderClearnetSwitch('events', event.rootId || event.id, isClearnet) : null, + chips.length ? div({ class: "card-chips-row" }, ...chips) : null, renderPhotoGallery(event, 'event'), - table({ class: "tribe-info-table jobs-info-table" }, ...infoRows), + table({ class: "tribe-info-table jobs-info-table" }, ...infoRows, ...renderCreatedRows(event.createdAt, event.organizer)), event.recurring && Array.isArray(event.occurrences) && event.occurrences.length > 1 ? details({ class: "event-occurrences" }, summary({ class: "chat-thread-summary" }, `${i18n.eventUpcomingDates} (${event.occurrences.length})`), diff --git a/src/views/favorites_view.js b/src/views/favorites_view.js index 8b9cd11..4642562 100644 --- a/src/views/favorites_view.js +++ b/src/views/favorites_view.js @@ -1,6 +1,6 @@ const { form, button, div, h2, p, section, input, a, span, img } = require("../server/node_modules/hyperaxe"); -const { template, i18n, renderCardTop, renderModuleStats, CONTENT_FAV_KIND, CONTENT_TORRENTABLE, paged } = require("./main_views"); +const { template, i18n, userLink, renderContentActions, renderModuleStats, CONTENT_FAV_KIND, CONTENT_TORRENTABLE, paged } = require("./main_views"); const moment = require("../server/node_modules/moment"); const { config } = require("../server/SSB_server.js"); const { renderStyledText, safeExternalHref } = require("../backend/renderStyledText"); @@ -128,26 +128,26 @@ const renderFavoriteCard = (item, filter, spreadMap = null) => { const title = safeText(item.title) || safeText(item.name) || safeText(item.category) || safeText(item.url) || ""; - const updated = item.updatedAt && item.createdAt && moment(item.updatedAt).valueOf() !== moment(item.createdAt).valueOf() - ? span({ class: "card-byline-time" }, `${i18n.jobsUpdatedAt}: ${moment(item.updatedAt).format("YYYY/MM/DD HH:mm")}`) - : null; + const ts = item.updatedAt || item.createdAt; + const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm") : ""; const isOwn = item.author && String(item.author) === String(userId); return div( { class: "trending-card favorites-card" + (isOwn ? " own-content" : "") }, - renderCardTop(item.favId, item.viewHref, { - date: item.createdAt || item.updatedAt, - bylineExtra: [updated], - chips: [div({ class: 'card-chips-row' }, + 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' }, String(item.kind || '').toUpperCase()) ) - )], - author: item.author, - spread: (spreadMap && spreadMap.get(item.favId)) || null, - reportTitle: title, - ...(TORRENTABLE_KINDS.has(item.kind) && typeof item.url === "string" && item.url.startsWith("&") ? { torrentFrom: { blobId: item.url, name: item.title } } : {}) - }), + ), + renderContentActions(item.favId, item.viewHref, { + author: item.author, + spread: (spreadMap && spreadMap.get(item.favId)) || null, + reportTitle: title, + ...(TORRENTABLE_KINDS.has(item.kind) && typeof item.url === "string" && item.url.startsWith("&") ? { torrentFrom: { blobId: item.url, name: item.title } } : {}) + }) + ), div( { class: "card-section favorites-card-body" }, title ? div({ class: "shop-title-row" }, h2({ class: "tribe-card-title" }, item.viewHref ? a({ href: item.viewHref }, title) : title)) : null, @@ -156,6 +156,11 @@ const renderFavoriteCard = (item, filter, spreadMap = null) => { safeText(item.description) ? p(...renderStyledText(item.description)) : null, renderDetailFields(item), renderTags(item.tags), + p( + { class: "card-footer favorites-meta" }, + absDate ? span({ class: "date-link" }, `${absDate}`) : "", + item.author ? userLink(item.author) : "" + ), p( { class: "card-footer" }, form( diff --git a/src/views/feed_view.js b/src/views/feed_view.js index daf9d42..dc561ce 100644 --- a/src/views/feed_view.js +++ b/src/views/feed_view.js @@ -1,9 +1,11 @@ -const { div, h2, p, section, button, form, a, span, textarea, input, h1, label, img } = require("../server/node_modules/hyperaxe"); +const { div, h2, p, section, button, form, a, span, textarea, br, input, h1, label, img } = require("../server/node_modules/hyperaxe"); const { renderCommentsSection: renderSharedCommentsSection, renderCommentsLink } = require("./comments_view"); -const { clearnetItemHref, template, i18n, renderOpinionsVoting, userLink, renderCardTop, renderCardByline, renderEngagement, renderModuleStats, moduleIsEmpty, paged, renderDetailShare } = require("./main_views"); +const { clearnetItemHref, template, i18n, renderOpinionsVoting, userLink, renderContentActions, renderEngagement, renderModuleStats, moduleIsEmpty, paged } = require("./main_views"); const { renderReachChip, renderClearnetSelector, renderClearnetSwitch, renderTribeOriginChip } = require("./clearnet_view"); const { config } = require("../server/SSB_server.js"); -const { renderStyledText } = require("../backend/renderStyledText"); +const { renderStyledHtml, renderStyledText } = require("../backend/renderStyledText"); +const moment = require("../server/node_modules/moment"); +const { sanitizeHtml } = require('../backend/sanitizeHtml'); const FEED_TEXT_MIN = Number(config?.feed?.minLength ?? 1); const FEED_TEXT_MAX = Number(config?.feed?.maxLength ?? 280); @@ -19,9 +21,10 @@ const normalizeOptions = (opts) => { }; }; -const feedDateOf = (feed) => feed?.value?.timestamp || feed?.value?.content?.createdAt || null; - -const renderEditedBadge = (content) => content._textEdited ? span({ class: "edited-badge" }, ` · ${i18n.edited || "edited"}`) : null; +const formatDate = (feed) => { + const ts = feed?.value?.timestamp || Date.parse(feed?.value?.content?.createdAt || "") || 0; + return ts ? moment(ts).format("YYYY/MM/DD HH:mm") : ""; +}; const extractTags = (text) => { const list = (String(text || "").match(/#[A-Za-z0-9_]{1,32}/g) || []).map((t) => t.slice(1).toLowerCase()); @@ -82,6 +85,7 @@ const renderFeedComposer = ({ text = "", media = "", rows = 5, clearnet = false, { method: "POST", action: "/feed/create", enctype: "multipart/form-data" }, textarea({ name: "text", + required: true, minlength: String(FEED_TEXT_MIN), maxlength: String(FEED_TEXT_MAX), rows: String(rows), @@ -113,6 +117,7 @@ const renderFeedCard = exports.renderFeedCard = (feed, spreadMap = null, opts = const voteEntries = Object.entries(content.opinions || {}); const totalCount = voteEntries.reduce((sum, [, count]) => sum + (Number(count) || 0), 0); + const createdAt = formatDate(feed); const me = config?.keys?.id; const alreadyRefeeded = Array.isArray(content.refeeds_inhabitants) && me ? content.refeeds_inhabitants.includes(me) : false; @@ -124,27 +129,63 @@ const renderFeedCard = exports.renderFeedCard = (feed, spreadMap = null, opts = const styledNodes = renderStyledText(safeText, { zoomImages: true, ...(typeof opts.hashtagHref === 'function' ? { hashtagHref: opts.hashtagHref } : {}) }); const refeedAction = opts.refeedAction || `/feed/refeed/${encodeURIComponent(feed.key)}`; const canRefeed = opts.canRefeed !== false && !(authorId && String(authorId) === String(me)); - const feedHref = `/feed/${encodeURIComponent(feed.key)}`; - const byline = { author: authorId, date: feedDateOf(feed), bylineExtra: [renderEditedBadge(content)] }; - const cardTop = opts.headerActions !== undefined - ? div({ class: "card-header activity-card-header card-top" }, renderCardByline(byline), opts.headerActions) + const headerActions = opts.headerActions !== undefined + ? opts.headerActions : feed.tribeOrigin - ? renderCardTop(feed.key, feed.tribeOrigin.href, { ...byline, chips: [renderTribeOriginChip(feed.tribeOrigin)], spread: (spreadMap && spreadMap.get(feed.key)) || null, reportTitle: safeText }) - : renderCardTop(feed.key, feedHref, { - ...byline, - chips: feed.clearnet === true ? [renderReachChip(true, i18n, clearnetItemHref("feed", "", feed.key))] : [], + ? renderContentActions(null, feed.tribeOrigin.href) + : renderContentActions(feed.key, `/feed/${encodeURIComponent(feed.key)}`, { + author: authorId, spread: (spreadMap && spreadMap.get(feed.key)) || null, reportTitle: safeText, ...(((signerId && String(signerId) === String(me)) || (authorId && String(authorId) === String(me))) ? { deleteAction: `/feed/delete/${encodeURIComponent(feed.key)}` } : {}) }); - const chipsRow = opts.chips !== undefined ? opts.chips : null; + const chipsRow = opts.chips !== undefined + ? opts.chips + : feed.tribeOrigin ? div({ class: "card-chips-row" }, renderTribeOriginChip(feed.tribeOrigin)) : feed.clearnet === true ? div({ class: "card-chips-row" }, renderReachChip(true, i18n, clearnetItemHref("feed", "", feed.key))) : null; const engagement = opts.engagement !== undefined ? opts.engagement - : null; + : feed.tribeOrigin ? null : renderEngagement(feed.key, + renderOpinionsVoting('/feed/opinions', feed.key, content.opinions, null, content.opinions_inhabitants), + renderCommentsLink({ href: `/feed/${encodeURIComponent(feed.key)}`, count: commentCount }) + ); + + if (process.env.OASIS_MOBILE === '1') { + return div( + { class: "trending-card feed-card" + (authorId && String(authorId) === String(me) ? " own-content" : "") }, + div({ class: "card-header activity-card-header" }, span(), headerActions), + div( + { class: "card-section feed-card-body" }, + div({ class: "feed-text" }, ...styledNodes), + chipsRow, + feed.tribeOrigin ? null : div( + { class: "refeed-column" }, + h1(String(refeedsNum)), + canRefeed + ? form( + { method: "POST", action: refeedAction }, + button({ class: alreadyRefeeded ? "refeed-btn active" : "refeed-btn", type: "submit", title: i18n.refeedButton, "aria-label": i18n.refeedButton, ...(alreadyRefeeded ? { disabled: true } : {}) }, "\u25b2") + ) + : null, + alreadyRefeeded ? p({ class: "muted" }, i18n.alreadyRefeeded) : null + ), + engagement, + p( + { class: "card-footer" }, + span({ class: "date-link" }, `${createdAt}`), + userLink(authorId), + content._textEdited ? span({ class: "edited-badge" }, ` \u00b7 ${i18n.edited || "edited"}`) : null + ) + ) + ); + } return div( { class: "trending-card feed-card" + (authorId && String(authorId) === String(me) ? " own-content" : "") }, - cardTop, + div( + { class: "card-header activity-card-header" }, + span(), + headerActions + ), div( { class: "card-section feed-card-body" }, div( @@ -162,7 +203,13 @@ const renderFeedCard = exports.renderFeedCard = (feed, spreadMap = null, opts = div( { class: "feed-main" }, div({ class: "feed-text" }, ...styledNodes), - chipsRow + chipsRow, + p( + { class: "card-footer" }, + span({ class: "date-link" }, `${createdAt}`), + userLink(authorId), + content._textEdited ? span({ class: "edited-badge" }, ` · ${i18n.edited || "edited"}`) : null + ) ) ), engagement @@ -308,7 +355,7 @@ exports.singleFeedView = (feed, comments = [], params = {}) => { const safeText = rawText.trim(); const authorId = content.author || feed.value?.author || ""; const signerId = feed.value?.author || ""; - const feedHref = `/feed/${encodeURIComponent(feed.key)}`; + const createdAt = formatDate(feed); const styledNodes = renderStyledText(safeText, { zoomImages: true }); const me = config?.keys?.id; const alreadyRefeeded = Array.isArray(content.refeeds_inhabitants) && me ? content.refeeds_inhabitants.includes(me) : false; @@ -333,15 +380,16 @@ exports.singleFeedView = (feed, comments = [], params = {}) => { ), div( { class: "bookmark-item card feed-detail-card" }, - renderCardTop(feed.key, feedHref, { arrow: false, - date: feedDateOf(feed), - bylineExtra: [renderEditedBadge(content)], - chips: [renderReachChip(isClearnet, i18n, isClearnet ? clearnetItemHref("feed", "", feed.key) : null)], - spread: params.spreads || null, - author: authorId, - reportTitle: safeText, - ...(((signerId && String(signerId) === String(me)) || (authorId && String(authorId) === String(me))) ? { deleteAction: `/feed/delete/${encodeURIComponent(feed.key)}` } : {}) - }), + div({ class: "card-header activity-card-header" }, + span(), + renderContentActions(feed.key, `/feed/${encodeURIComponent(feed.key)}`, { + spread: params.spreads || null, + author: authorId, + reportTitle: safeText, + ...(((signerId && String(signerId) === String(me)) || (authorId && String(authorId) === String(me))) ? { deleteAction: `/feed/delete/${encodeURIComponent(feed.key)}` } : {}) + }) + ), + br, div( { class: "feed-row" }, div( @@ -363,8 +411,15 @@ exports.singleFeedView = (feed, comments = [], params = {}) => { tags.map((tag) => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`)) ) : null, - renderDetailShare(feed.key, feedHref, safeText), - authorId && String(authorId) === String(me) ? renderClearnetSwitch("feed", feed.rootId || feed.key, isClearnet) : null + div({ class: "card-chips-row" }, renderReachChip(isClearnet, i18n, isClearnet ? clearnetItemHref("feed", "", feed.key) : null)), + authorId && String(authorId) === String(me) ? renderClearnetSwitch("feed", feed.rootId || feed.key, isClearnet) : null, + br, + p( + { class: "card-footer" }, + span({ class: "date-link" }, `${createdAt}`), + userLink(authorId), + content._textEdited ? span({ class: "edited-badge" }, ` · ${i18n.edited || "edited"}`) : null + ) ) ) ), diff --git a/src/views/files_view.js b/src/views/files_view.js index 93c33a5..6fc9773 100644 --- a/src/views/files_view.js +++ b/src/views/files_view.js @@ -1,8 +1,8 @@ -const { form, button, div, h2, h3, p, section, input, br, a, span, textarea, select, label, option, table, tr, th, td } = require("../server/node_modules/hyperaxe"); +const { form, button, div, h2, h3, p, section, input, br, a, span, textarea, select, label, option, table, tr, th, td, progress, strong } = require("../server/node_modules/hyperaxe"); const { renderCommentsSection: renderSharedCommentsSection } = require("./comments_view"); -const { renderReachChip, renderClearnetSelector, renderClearnetSwitch, renderLicenseSelect, renderLicenseChip } = require('./clearnet_view'); +const { renderReachChip, renderClearnetSelector, renderClearnetSwitch } = require('./clearnet_view'); -const { clearnetItemHref, template, i18n, renderOpinionsVoting, renderEngagement, renderSpreadButton, renderEcoTax, renderLifespanChip, renderSpreadEditWarning, renderCardTop, renderModuleStats, moduleIsEmpty, contentDeleteAction, paged, formatSize, renderMediaLockNote, renderEditedChip, renderDetailShare } = require("./main_views"); +const { clearnetItemHref, template, i18n, renderOpinionsVoting, renderEngagement, userLink, renderSpreadButton, renderEcoTax, renderLifespanChip , renderSpreadEditWarning, renderContentActions, renderModuleStats, moduleIsEmpty, contentDeleteAction, paged } = require("./main_views"); const moment = require("../server/node_modules/moment"); const { config } = require("../server/SSB_server.js"); const { renderStyledText, safeExternalHref } = require("../backend/renderStyledText"); @@ -35,8 +35,9 @@ const renderTags = (tags) => { const renderFileOwnerActions = (filter, fileObj, params = {}) => { const returnTo = buildReturnTo(filter, params); const isAuthor = String(fileObj.author) === String(userId); + const hasOpinions = Object.keys(fileObj.opinions || {}).length > 0; - if (!isAuthor) return []; + if (!isAuthor || hasOpinions) return []; return [ form( @@ -61,6 +62,13 @@ const blobSha256Hex = (blobId) => { try { return Buffer.from(m[1], "base64").toString("hex"); } catch (_) { return ""; } }; +const formatSize = (bytes) => { + const n = Number(bytes) || 0; + if (n === 0) return "—"; + if (n < 1024) return n + " B"; + if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB"; + return (n / (1024 * 1024)).toFixed(1) + " MB"; +}; const spreadInfoOf = (t, spreadMap) => (spreadMap instanceof Map && spreadMap.get(t.key)) || null; const renderFileSeeds = (t, spreadMap) => { @@ -133,7 +141,6 @@ const renderNextcloudForm = (returnTo) => div( input({ type: "text", name: "tags", placeholder: i18n.fileTagsPlaceholder }), br(), br(), - ...renderLicenseSelect(undefined, i18n), renderClearnetSelector(false, i18n), br(), button({ type: "submit" }, i18n.nextcloudUploadButton) ) @@ -156,7 +163,7 @@ const renderFileForm = (filter, fileId, fileToEdit, params = {}) => { tribeId ? input({ type: "hidden", name: "tribeId", value: tribeId }) : null, span(i18n.fileFileLabel), br(), - (filter === "edit" && renderMediaLockNote(fileToEdit)) || input({ type: "file", name: "file", required: filter !== "edit" }), + input({ type: "file", name: "file", required: filter !== "edit" }), br(), br(), span(i18n.fileTitleLabel), @@ -177,7 +184,6 @@ const renderFileForm = (filter, fileId, fileToEdit, params = {}) => { }), br(), br(), - ...renderLicenseSelect(fileToEdit?.license, i18n), ...(clearnetAllowed ? [renderClearnetSelector(filter === "edit" ? !!fileToEdit?.clearnet : false, i18n), br()] : []), button({ type: "submit" }, filter === "edit" ? i18n.fileUpdateButton : i18n.fileCreateButton) ) @@ -291,26 +297,11 @@ exports.singleFileView = async (fileObj, filter = "all", comments = [], params = for (const a of ownerActions) sideActions.push(a); const tagsNode = renderTags(fileObj.tags); - const href = `/files/${encodeURIComponent(fileObj.key)}`; - const createdTs = fileObj.createdAt ? new Date(fileObj.createdAt).getTime() : NaN; - const updatedTs = fileObj.updatedAt ? new Date(fileObj.updatedAt).getTime() : NaN; - const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs); - const fileSide = div({ class: "tribe-side" }, - renderCardTop(fileObj.key, href, { arrow: false, - author: fileObj.author, - date: fileObj.createdAt, - chips: [ - fileObj.tribeId && fileObj.cipher ? renderEncryptedChip(i18n) : renderTransportChip(i18n), - clearnetAllowed ? renderReachChip(isClearnet, i18n, isClearnet ? clearnetItemHref('files', fileObj.title, fileObj.key) : null) : null, - fileObj.tribeId ? null : renderLicenseChip(fileObj.license), - ...chips - ], - bylineExtra: [ - renderEditedChip(fileObj.key, fileObj.rootId), - showUpdated ? span({ class: "votations-comment-date" }, `${i18n.fileUpdatedAt}: ${moment(fileObj.updatedAt).format("YYYY/MM/DD HH:mm")}`) : null - ], + const detailActions = div({ class: "card-header activity-card-header" }, + renderContentActions(fileObj.key, `/files/${encodeURIComponent(fileObj.key)}`, { spreadTitle: i18n.seedAction, + author: fileObj.author, deleteAction: String(fileObj.author) === String(userId) ? contentDeleteAction("file", fileObj.key) : null, favKind: 'files', ...(fileObj.url && fileObj.url.startsWith("&") ? { torrentFrom: { blobId: fileObj.url, name: fileObj.fileName || fileObj.title } } : {}), @@ -318,12 +309,17 @@ exports.singleFileView = async (fileObj, filter = "all", comments = [], params = spread: params.spreads || null, returnTo, reportTitle: fileObj.title - }), + }) + ); + + const fileSide = div({ class: "tribe-side" }, div({ class: "shop-title-row" }, title ? h2({ class: "tribe-card-title" }, title) : null, + fileObj.tribeId && fileObj.cipher ? renderEncryptedChip(i18n) : renderTransportChip(i18n), + clearnetAllowed ? renderReachChip(isClearnet, i18n, isClearnet ? clearnetItemHref('files', fileObj.title, fileObj.key) : null) : null, clearnetAllowed && isAuthor ? renderClearnetSwitch('files', fileObj.rootId || fileObj.key, isClearnet) : null ), - renderDetailShare(fileObj.key, href, fileObj.title), + chips.length ? div({ class: "card-chips-row" }, ...chips) : null, safeText(fileObj.description) ? p({ class: "tribe-side-description" }, ...renderStyledText(fileObj.description)) : null, @@ -332,6 +328,7 @@ exports.singleFileView = async (fileObj, filter = "all", comments = [], params = ); const fileMain = div({ class: "tribe-main" }, + detailActions, fileObj.url && fileObj.url.startsWith("&") ? div({ class: "torrent-download" }, div({ class: "torrent-oasis torrent-detail-section" }, @@ -371,9 +368,27 @@ exports.singleFileView = async (fileObj, filter = "all", comments = [], params = ) ) : p(i18n.fileNoFile), - renderEngagement(fileObj.key, - fileObj.tribeId ? null : renderOpinionsVoting('/files/opinions', fileObj.key, fileObj.opinions, returnTo, fileObj.opinions_inhabitants), - fileObj.tribeId ? null : renderFileCommentsSection(fileObj.key, comments, returnTo)) + (() => { + const createdTs = fileObj.createdAt ? new Date(fileObj.createdAt).getTime() : NaN; + const updatedTs = fileObj.updatedAt ? new Date(fileObj.updatedAt).getTime() : NaN; + const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs); + + return p( + { class: "card-footer" }, + span({ class: "date-link" }, `${moment(fileObj.createdAt).format("YYYY/MM/DD HH:mm")}`), + userLink(fileObj.author), + showUpdated + ? span( + { class: "votations-comment-date" }, + ` | ${i18n.fileUpdatedAt}: ${moment(fileObj.updatedAt).format("YYYY/MM/DD HH:mm")}` + ) + : null + ); + })(), + fileObj.tribeId ? null : renderEngagement(fileObj.key, + renderOpinionsVoting('/files/opinions', fileObj.key, fileObj.opinions, returnTo, fileObj.opinions_inhabitants), + renderFileCommentsSection(fileObj.key, comments, returnTo) + ) ); return template( diff --git a/src/views/forum_view.js b/src/views/forum_view.js index 0f91ed3..0f7be4b 100644 --- a/src/views/forum_view.js +++ b/src/views/forum_view.js @@ -4,7 +4,7 @@ const { } = require("../server/node_modules/hyperaxe"); const RAW_HTML = Symbol.for('oasis.rawHtml'); const moment = require("../server/node_modules/moment"); -const { template, i18n, userLink, renderSpreadButton, renderPrivacyChip, renderLifespanChip, renderContentActions, renderCardTop, renderEngagement, renderInviteQrCard, renderStateChip, renderSubscriptionBox, renderModuleStats, moduleIsEmpty, paged, forumReplyAnchor } = require('./main_views'); +const { template, i18n, userLink, renderSpreadButton, renderPrivacyChip, renderLifespanChip, renderContentActions, renderInviteQrCard, renderStateChip, renderSubscriptionBox, renderModuleStats, moduleIsEmpty, paged } = require('./main_views'); const { renderEncryptedChip: renderForumEncryptedChip, renderTribeOriginChip } = require('./clearnet_view'); const { config } = require('../server/SSB_server.js'); const { renderStyledText } = require('../backend/renderStyledText'); @@ -110,6 +110,7 @@ const renderForumForm = (draft = null) => label(i18n.forumMessageLabel), br(), textarea({ maxlength: String(TEXT_CAP), name: 'text', + required: true, rows: 8, placeholder: i18n.forumMessagePlaceholder }, draft ? String(draft.text || '') : ''), br(), br(), @@ -147,7 +148,7 @@ const renderThread = (nodes, level = 0, forumId, bestKey = null) => { ].filter(Boolean).join(' '); const commentBox = div( - { class: classList, id: forumReplyAnchor(m.key) }, + { class: classList, id: `msg-${String(m.key || '').replace(/[^a-zA-Z0-9]/g, '')}` }, div({ class: 'comment-header' }, span({ class: 'date-link' }, `${moment(m.timestamp).format('YYYY/MM/DD HH:mm')}`), @@ -183,6 +184,7 @@ const renderThread = (nodes, level = 0, forumId, bestKey = null) => { textarea({ maxlength: String(TEXT_CAP), name: 'message', rows: 4, + required: true, placeholder: i18n.forumMessagePlaceholder, class: 'comment-textarea' }, draftFor(m.key)), @@ -210,45 +212,34 @@ const renderForumList = (forums, currentFilter, spreadMap = new Map()) => { ...paged(visibleForums).map(f => { if (f.tribeOrigin) { return li({ class: 'mailing-archive-item' }, - renderCardTop(f.key, f.tribeOrigin.href, { - spread: (spreadMap && spreadMap.get(f.key)) || null, - reportTitle: f.title, - author: f.author, - date: f.createdAt, - chips: [ + div({ class: 'emergency-update-head mailing-archive-head' }, + div({ class: 'mailing-archive-meta' }, + a({ href: f.tribeOrigin.href, class: 'user-link' }, f.title || '—'), ALL_CATS.includes(f.category) ? a({ class: 'forum-category', href: `/forum?filter=${encodeURIComponent(f.category)}` }, `[${catLabel(f.category)}]`) : (f.category ? span({ class: 'forum-category' }, `[${tribeCatLabel(f.category)}]`) : null), + span({ class: 'card-label activity-update-counts mailing-counts' }, `👥: ${f.participants?.length || 1} · 💬 ${Math.max(0, (f.messagesCount || 1) - 1)}`), renderLifespanChip(f.lifetime, i18n), renderTribeOriginChip(f.tribeOrigin) - ] - }), - div({ class: 'mailing-archive-meta' }, - a({ href: f.tribeOrigin.href, class: 'user-link' }, f.title || '—'), - span({ class: 'card-label activity-update-counts mailing-counts' }, `👥: ${f.participants?.length || 1} · 💬 ${Math.max(0, (f.messagesCount || 1) - 1)}`) - ), - null + ) + ) ); } return li({ class: 'mailing-archive-item' }, - renderCardTop(f.key, forumHref(f), { - date: f.createdAt, - chips: [ + div({ class: 'emergency-update-head mailing-archive-head' }, + div({ class: 'mailing-archive-meta' }, + a({ href: forumHref(f), class: 'user-link' }, f.title || '—'), a({ class: 'forum-category', href: `/forum?filter=${encodeURIComponent(f.category)}` }, `[${catLabel(f.category)}]`), + forumCounts(f), f.isPrivate ? renderPrivacyChip(true, i18n) : null, f.isPrivate ? renderForumEncryptedChip(i18n) : null, renderLifespanChip(f.lifetime, i18n), f.subscriptionIn === true ? renderStateChip('mutuals', '✉', i18n.subscriptionOn) : (f.subscriptionIn === false ? renderStateChip('closed', '✉', i18n.subscriptionOff) : null) - ], - spread: spreadMap.get(f.key) || null, author: f.author, favKind: 'forum', isFavorite: f.isFavorite, reportTitle: f.title, deleteAction: f.author === userId ? `/forum/delete/${encodeURIComponent(f.key)}` : null - }), - div({ class: 'mailing-archive-meta' }, - a({ href: forumHref(f), class: 'user-link' }, f.title || '—'), - forumCounts(f) - ), - null + ), + renderContentActions(f.key, forumHref(f), { spread: spreadMap.get(f.key) || null, author: f.author, favKind: 'forum', isFavorite: f.isFavorite, reportTitle: f.title, deleteAction: f.author === userId ? `/forum/delete/${encodeURIComponent(f.key)}` : null }) + ) ); }) ); @@ -434,6 +425,7 @@ exports.singleForumView = async (forum, messagesData, currentFilter, replyId = n textarea({ maxlength: String(TEXT_CAP), name: 'message', rows: 8, + required: true, placeholder: i18n.forumMessagePlaceholder, class: 'new-message-textarea' }, draftFor(null)), br(), diff --git a/src/views/gallery_view.js b/src/views/gallery_view.js index ed4b3e5..741661c 100644 --- a/src/views/gallery_view.js +++ b/src/views/gallery_view.js @@ -1,5 +1,5 @@ const { div, a, span, img, video, input, button, label, br } = require("../server/node_modules/hyperaxe") -const { i18n, renderIcon, renderMediaLockNote } = require("./main_views") +const { i18n } = require("./main_views") const { MAX_IMAGES } = require("../models/media_gallery") const safeArr = (v) => (Array.isArray(v) ? v : []) @@ -33,31 +33,20 @@ const renderMediaThumb = (entry, alt = "") => isVideoEntry(entry) const lightboxId = (scope, itemId, index) => `${scope}-photo-${encodeURIComponent(itemId)}-${index}` let zoomSeq = 0 -const renderZoomableImage = (src, { alt = "", imgClass = "", linkClass = "", thumb = null, full = false } = {}) => { +const renderZoomableImage = (src, { alt = "", imgClass = "", linkClass = "" } = {}) => { if (!src) return null zoomSeq = (zoomSeq + 1) % 1000000 const id = `zoom-${zoomSeq}-${String(src).replace(/[^a-zA-Z0-9]/g, "").slice(-10)}` return [ - a({ href: `#${id}`, id: `${id}-src`, class: linkClass ? `zoom-link ${linkClass}` : "zoom-link" }, img({ src: thumb || src, class: imgClass, alt, loading: "lazy" })), - div({ id, class: full ? "lightbox live-lightbox" : "lightbox" }, + a({ href: `#${id}`, id: `${id}-src`, class: linkClass ? `zoom-link ${linkClass}` : "zoom-link" }, img({ src, class: imgClass, alt })), + div({ id, class: "lightbox" }, a({ href: downloadHref(src), class: "lightbox-download", title: i18n.fileShareDownload || "Download" }, "⤓"), a({ href: `#${id}-src`, class: "lightbox-close" }, "×"), - full ? a({ href: `#${id}-full`, class: "live-fullscreen-btn", title: i18n.liveFullscreen }, renderIcon("fullscreen")) : null, - img({ src, class: "lightbox-image", alt, loading: "lazy" }) - ), - full - ? div({ id: `${id}-full`, class: "lightbox live-lightbox live-fullscreen" }, - a({ href: `#${id}`, class: "lightbox-close" }, "×"), - a({ href: `#${id}`, class: "lightbox-image-link" }, img({ src, class: "lightbox-image", alt, loading: "lazy" })) - ) - : null + img({ src, class: "lightbox-image", alt }) + ) ] } -const renderDownloadButton = (src, name = "") => src - ? a({ href: `${downloadHref(src)}${name ? `&name=${encodeURIComponent(name)}` : ""}`, class: "filter-btn media-download-btn" }, `⤓ ${i18n.fileShareDownload || "Download"}`) - : null - const renderPhotoGallery = (item, scope = "media") => { const list = imagesOf(item) const clip = videoOf(item) @@ -90,17 +79,16 @@ const renderPhotoGallery = (item, scope = "media") => { const renderGalleryFields = (item = {}, isEdit = false, maxImages = MAX_IMAGES) => { const list = imagesOf(item) - const lock = isEdit ? renderMediaLockNote(item) : null return [ label(i18n.galleryImages), br(), ...list.map(entry => input({ type: "hidden", name: "keepImages", value: entry })), - lock || (list.length < maxImages + list.length < maxImages ? div({ class: "media-file-row" }, input({ type: "file", name: "images", accept: "image/*", multiple: true }), button({ type: "submit", name: "action", value: "addPhoto", attrs: { formnovalidate: "formnovalidate" }, class: "filter-btn media-add-btn" }, i18n.galleryAddPhoto) ) - : null), + : null, br(), list.length ? [ @@ -108,7 +96,7 @@ const renderGalleryFields = (item = {}, isEdit = false, maxImages = MAX_IMAGES) list.map((entry, i) => span({ class: "gallery-item" }, renderMediaThumb(entry), - lock ? null : button({ type: "submit", name: "removePhoto", value: String(i), attrs: { formnovalidate: "formnovalidate" }, class: "media-remove-btn" }, i18n.galleryRemovePhoto) + button({ type: "submit", name: "removePhoto", value: String(i), attrs: { formnovalidate: "formnovalidate" }, class: "media-remove-btn" }, i18n.galleryRemovePhoto) ) ) ), @@ -129,7 +117,5 @@ module.exports = { renderMediaThumb, renderPhotoGallery, renderGalleryFields, - renderZoomableImage, - renderDownloadButton, - downloadHref + renderZoomableImage } diff --git a/src/views/graphos_view.js b/src/views/graphos_view.js index 96dcc49..e291f6b 100644 --- a/src/views/graphos_view.js +++ b/src/views/graphos_view.js @@ -97,7 +97,7 @@ const buildGraphSvg = (me, peers, links = []) => { const zoomableGraph = (id, svg, stats = []) => [ div({ class: 'graphos-legend graphos-statsbar' }, ...stats.filter(Boolean)), div({ class: 'graphos-canvas' }, - a({ href: process.env.OASIS_MOBILE === '1' ? `#${id}-focus` : `#${id}`, id: `${id}-src`, class: 'tribe-action-btn graphos-zoom-btn' }, span({ class: 'graphos-zoom-icon' }, '\u2922'), ` ${String(i18n.graphosZoom || 'Enlarge').toUpperCase()}`), + a({ href: process.env.OASIS_MOBILE === '1' ? `#${id}-focus` : `#${id}`, id: `${id}-src`, class: 'tribe-action-btn graphos-zoom-btn' }, `\u2922 ${i18n.graphosZoom || 'Enlarge'}`), div({ class: 'graphos-canvas-svg', [RAW_HTML]: svg }) ), div({ id, class: 'lightbox graphos-lightbox' }, @@ -158,13 +158,13 @@ exports.graphosView = ({ filter, me, peers, links = [], kpis, focus = null, focu : null, ...zoomableGraph('graphos-zoom', buildGraphSvg(me, peers, links), [ kpi(i18n.graphosTotalNodes || 'Total nodes', kpis.total), - kpi(i18n.onlinePlural || i18n.online || 'Online', kpis.online), + kpi(i18n.online || 'Online', kpis.online), filter !== 'MINE' ? kpi(i18n.discovered || 'Discovered', kpis.discovered) : null, filter !== 'MINE' ? kpi(i18n.unknown || 'Unknown', kpis.unknown) : null ]), div({ class: 'graphos-legend' }, legendItem('me', i18n.graphosYou || 'You'), - legendItem('online', i18n.onlinePlural || i18n.online || 'Online'), + legendItem('online', i18n.online || 'Online'), filter !== 'MINE' ? legendItem('discovered', i18n.discovered || 'Discovered') : null, filter !== 'MINE' ? legendItem('unknown', i18n.unknown || 'Unknown') : null, span({ class: 'graphos-legend-item' }, span({ class: 'graphos-legend-line graphos-fed-line-mutual' }), span(i18n.graphosFedMutual)), diff --git a/src/views/hive_views.js b/src/views/hive_views.js index dc8f464..4b3b270 100644 --- a/src/views/hive_views.js +++ b/src/views/hive_views.js @@ -7,10 +7,9 @@ // cada integracion. Aqui solo depende de i18n, que se inyecta porque main_views // lo muta en cada peticion sin reasignarlo. -const { a, button, div, form, h2, h3, img, input, nav, p, section, span } = require("../server/node_modules/hyperaxe"); +const { a, button, div, form, h2, h3, img, input, label, nav, p, section, span } = require("../server/node_modules/hyperaxe"); const { getConfig } = require("../configs/config-manager.js"); const sharedState = require("../configs/shared-state"); -const { iconNode, iconKeyOf } = require("./mobile_icons"); // con pocos modulos activos (el workflow activistas) todo cabe en una pantalla: // sobran el filtro Personal/Social y los accesos de usuario avanzado @@ -22,13 +21,16 @@ module.exports = ({ i18n }) => { const renderMobileTopbar = () => { // solo en movil: en escritorio manda la cabecera de upstream if (process.env.OASIS_MOBILE !== '1') return ""; - // Personal/Social ya no van aqui: filtraban el lanzador, que ahora esta en la hoja - // del menu, y con muchos modulos (workflow activistas) no cabian a 360px y tapaban - // el logo. Queda el hueco para el boton de No molestar del telefono + // FORK_IA_UX v3: Personal/Community VUELVEN al topbar (ya funcionan: la hoja cerrada es + // visibility:hidden y no bloquea los taps). Llena el top y da el filtro a un toque. + const hiveFilter = (typeof global !== "undefined" && global.__OASIS_HIVE_FILTER__) || ""; return div( { class: "oasis-mobile-topbar" }, a({ class: "omt-logo", href: "/" }, img({ src: "/assets/images/snh-oasis.jpg", alt: "Oasis" })), - div({ class: "omt-menus" }), + isCompact() ? div({ class: "omt-menus" }) : div({ class: "omt-menus" }, + a({ class: hiveFilter === "personal" ? "oasis-menu-btn active" : "oasis-menu-btn", href: hiveFilter === "personal" ? "/activity" : "/activity?hive=personal" }, span(i18n.menuPersonal || "Personal")), + a({ class: hiveFilter === "community" ? "oasis-menu-btn active" : "oasis-menu-btn", href: hiveFilter === "community" ? "/activity" : "/activity?hive=community" }, span(i18n.menuSocial || "Social")) + ), a({ class: "omt-avatar", href: "/profile" }, img({ src: (typeof global !== "undefined" && global.__OASIS_SELF_AVATAR__) || "/assets/images/default-avatar.png", alt: i18n.profile || "Profile" })) ); }; @@ -40,6 +42,7 @@ module.exports = ({ i18n }) => { { key: 'activity', href: '/activity', glyph: 'ꔙ', labelKey: 'activityTitle', mod: null }, { key: 'tribes', href: '/tribes', glyph: 'ꖥ', labelKey: 'tribesTitle', mod: 'tribesMod' }, { key: 'chats', href: '/chats', glyph: 'ꖒ', labelKey: 'chatsTitle', mod: 'chatsMod' }, + { key: 'karvan', href: '/karvan', glyph: '⧗', labelKey: 'karvanTitle', mod: 'karvanMod' }, { key: 'rooms', href: '/rooms', glyph: 'ꕢ', labelKey: 'roomsTitle', mod: 'roomsMod' }, { key: 'wiki', href: '/wiki', glyph: 'ꖿ', labelKey: 'wikiTitle', mod: 'wikiMod' }, { key: 'polls', href: '/polls', glyph: 'ꖙ', labelKey: 'pollsTitle', mod: 'pollsMod' }, @@ -47,8 +50,6 @@ module.exports = ({ i18n }) => { { key: 'data', href: '/data', glyph: 'ꘑ', labelKey: 'dataTitle', mod: 'dataMod' }, { key: 'market', href: '/market', glyph: 'ꕻ', labelKey: 'marketTitle', mod: 'marketMod' }, { key: 'events', href: '/events', glyph: 'ꕆ', labelKey: 'eventsLabel', mod: 'eventsMod' }, - { key: 'phone', href: '/phone', glyph: '☏', labelKey: 'phoneTitle', mod: 'phoneMod' }, - { key: 'emergencies', href: '/emergencies', glyph: '✚', labelKey: 'emergenciesTitle', mod: 'emergenciesMod' }, { key: 'calendars', href: '/calendars', glyph: 'ꖯ', labelKey: 'calendarsTitle', mod: 'calendarsMod' }, { key: 'banking', href: '/banking', glyph: 'ꗴ', labelKey: 'bankingTitle', mod: 'bankingMod' }, { key: 'forum', href: '/forum', glyph: 'ꕒ', labelKey: 'forumTitle', mod: 'forumMod' }, @@ -85,6 +86,7 @@ module.exports = ({ i18n }) => { { key: 'inhabitants', href: '/inhabitants', glyph: 'ꖘ', labelKey: 'inhabitantsLabel', mod: null }, { key: 'cv', href: '/cv', glyph: 'ꕛ', labelKey: 'cvTitle', mod: null }, { key: 'stats', href: '/stats', glyph: 'ꕷ', labelKey: 'statistics', mod: null }, + { key: 'blockexplorer', href: '/blockexplorer', glyph: 'ꖸ', labelKey: 'blockchain', mod: null }, ]; const renderBottomBar = () => { @@ -92,60 +94,25 @@ module.exports = ({ i18n }) => { const inboxN = sharedState.getInboxCount(); const cfg = getConfig(); const modOn = (m) => !m || cfg.modules[m] === 'on' || cfg.modules[m] === undefined; - // la pestaña de la seccion abierta se marca (pastilla de color tras el icono) - let here = ''; - try { here = (require('../models/typed_log').requestScope.getStore() || {}).path || ''; } catch (_) {} - const isHere = (href) => here === href || (href !== '/' && here.startsWith(href + '/')) || (href === '/activity' && here === '/'); - // short: nombre corto de i18n_fork si el entero no cabe; el entero queda de nombre - // accesible - const bbLink = (href, ico, label, badge, short) => a( - { class: isHere(href) ? "bb-item current" : "bb-item", href, ...(isHere(href) ? { 'aria-current': 'page' } : {}), ...(short ? { 'aria-label': label } : {}) }, - iconNode("bb-ico", iconKeyOf(href), ico), + const bbLink = (href, ico, label, badge) => a( + { class: "bb-item", href }, + span({ class: "bb-ico" }, ico), (badge && badge > 0) ? span({ class: "bb-badge" }, String(badge)) : "", - span({ class: "bb-lbl" }, short || label) + span({ class: "bb-lbl" }, label) ); // barra PERSONALIZABLE: solo los pins (max 4). Editarla y Peers estan en el // lanzador (Herramientas): 7 botones abajo eran demasiados para un pulgar. const MAX = 4; - const pins = require('../models/fork_workflows').barFor(cfg) + const pins = (cfg.bottomBarPins || []) .map(key => PINNABLE_MODULES.find(m => m.key === key)) .filter(m => m && modOn(m.mod)) .slice(0, MAX); - // Publicar: el /publish del dev va siempre a crear un blog, y sin blogs (Empleo, - // Negocio) acababa en /modules. Aqui, al primer sitio de escribir que este encendido - const WRITE_TO = [['blogsMod', '/blogs?filter=CREATE'], ['feedMod', '/feed/create'], ['forumMod', '/forum?filter=create'], ['wikiMod', '/wiki?filter=create']]; - const writeTo = (WRITE_TO.find(([mod]) => modOn(mod)) || [null, '/publish'])[1]; const slots = pins.map(m => bbLink( - m.key === 'publish' ? writeTo : m.href, m.glyph, i18n[m.labelKey] || m.key, m.key === 'inbox' ? inboxN : 0, i18n[`bbShort_${m.key}`] + m.href, m.glyph, i18n[m.labelKey] || m.key, m.key === 'inbox' ? inboxN : 0 )); - // quinta pestaña fija: Menu abre desde abajo una hoja con todos los modulos (el - // lanzador). Sin JS (la CSP de la 1.3.5 no deja ninguno): un ancla #oasis-menu y - // :target. Abrir deja una entrada en el historial, asi que atras cierra la hoja. - // Cerrar va a un ancla que no existe para que la pagina no salte arriba. - // Ver docs/ESTUDIO_NAVEGACION.md - const menuLabel = i18n.bbMenu || 'Menu'; - const closeLabel = i18n.bbMenuClose || 'Close'; return nav( - { id: 'oasis-menu', class: "oasis-bottombar", attrs: { "aria-label": i18n.bbQuickActions || "Quick actions" } }, - ...slots, - a({ class: 'bb-item bb-menu', href: '#oasis-menu' }, - iconNode('bb-ico', 'modules', '▦'), - span({ class: 'bb-lbl' }, menuLabel) - ), - a({ class: 'bb-item bb-menu bb-menu-close', href: '#menu-cerrado', 'aria-label': closeLabel }, - iconNode('bb-ico', 'modules', '▦'), - span({ class: 'bb-lbl' }, menuLabel) - ), - a({ class: 'menu-sheet-backdrop', href: '#menu-cerrado', 'aria-hidden': 'true', tabindex: '-1' }), - div({ class: 'menu-sheet', role: 'dialog', 'aria-label': menuLabel }, - div({ class: 'menu-sheet-head' }, - span({ class: 'menu-sheet-title' }, menuLabel), - a({ class: 'menu-sheet-close', href: '#menu-cerrado', 'aria-label': closeLabel }, '✕') - ), - renderHiveNav() - ), - // unirse a un pub: barra fija encima de esta (mobile_pubbar.js) - require('./mobile_pubbar').pubBar() + { class: "oasis-bottombar", attrs: { "aria-label": i18n.bbQuickActions || "Quick actions" } }, + ...slots ); }; @@ -157,6 +124,7 @@ module.exports = ({ i18n }) => { { href: '/activity', glyph: 'ꔙ', labelKey: 'activityTitle', mod: null }, { href: '/feed', glyph: 'ꕿ', labelKey: 'feedTitle', mod: 'feedMod' }, { href: '/chats', glyph: 'ꖒ', labelKey: 'chatsTitle', mod: 'chatsMod' }, + { href: '/karvan', glyph: '⧗', labelKey: 'karvanTitle', mod: 'karvanMod' }, { href: '/rooms', glyph: 'ꕢ', labelKey: 'roomsTitle', mod: 'roomsMod' }, { href: '/forum', glyph: 'ꕒ', labelKey: 'forumTitle', mod: 'forumMod' }, { href: '/pads', glyph: 'ꔗ', labelKey: 'padsTitle', mod: 'padsMod' }, @@ -204,7 +172,6 @@ module.exports = ({ i18n }) => { { href: '/shops', glyph: 'ꔜ', labelKey: 'shopsTitle', mod: 'shopsMod' }, { href: '/jobs', glyph: 'ꗒ', labelKey: 'jobsTitle', mod: 'jobsMod' }, { href: '/projects', glyph: 'ꕧ', labelKey: 'projectsTitle', mod: 'projectsMod' }, - { href: '/industry', glyph: '⚙', labelKey: 'industryTitle', mod: 'industryMod' }, { href: '/housing', glyph: '⌂', labelKey: 'housingTitle', mod: 'housingMod' }, { href: '/logistics', glyph: '⇄', labelKey: 'logisticsTitle', mod: 'logisticsMod' }, { href: '/banking', glyph: 'ꗴ', labelKey: 'bankingTitle', mod: 'bankingMod' }, @@ -225,6 +192,7 @@ module.exports = ({ i18n }) => { { href: '/cipher', glyph: 'ꗄ', labelKey: 'cipher', mod: 'cipherMod' }, { href: '/ai', glyph: 'ꘜ', labelKey: 'ai', mod: 'aiMod' }, { href: '/stats', glyph: 'ꕷ', labelKey: 'statistics', mod: null, extra: true }, + { href: '/blockexplorer', glyph: 'ꖸ', labelKey: 'blockchain', mod: null, extra: true }, { href: '/modules', glyph: 'ꗣ', labelKey: 'modules', mod: null, extra: true }, { href: '/settings', glyph: '⚙', labelKey: 'settings', mod: null } ] }, ]; @@ -243,7 +211,7 @@ module.exports = ({ i18n }) => { // Personal/Social de la topbar (lo pone el middleware desde ?hive=) parte el lanzador en dos const hiveFilter = compact ? "" : ((typeof global !== "undefined" && global.__OASIS_HIVE_FILTER__) || ""); const app = (m) => a({ class: 'ln-app', href: m.href }, - iconNode('ln-ico', iconKeyOf(m.href), m.glyph), + span({ class: 'ln-ico', 'aria-hidden': 'true' }, m.glyph), span({ class: 'ln-lbl' }, i18n[m.labelKey] || m.href.slice(1))); const groups = GROUPS .filter(g => !hiveFilter || g.side === hiveFilter) @@ -263,7 +231,7 @@ module.exports = ({ i18n }) => { const cfg = getConfig(); const modOn = (m) => !m || cfg.modules[m] === 'on' || cfg.modules[m] === undefined; const MAX = 4; - const pinnedKeys = require('../models/fork_workflows').barFor(cfg); + const pinnedKeys = cfg.bottomBarPins || []; const pinned = pinnedKeys .map(key => PINNABLE_MODULES.find(m => m.key === key)) .filter(Boolean); @@ -275,14 +243,14 @@ module.exports = ({ i18n }) => { ); const pinnedRow = (m) => actionForm('remove', m.key, 'bb-manage-form', button({ type: 'submit', class: 'bb-manage-btn', 'aria-label': i18n.bbRemove || 'Remove' }, - iconNode('bb-manage-ico', iconKeyOf(m.href), m.glyph), + span({ class: 'bb-manage-ico' }, m.glyph), span({ class: 'bb-manage-lbl' }, i18n[m.labelKey] || m.key), span({ class: 'bb-manage-x' }, '✕') ) ); const addBtn = (m) => actionForm('add', m.key, 'bb-pick-form', button({ type: 'submit', class: 'bb-pick-btn' }, - iconNode('bb-pick-ico', iconKeyOf(m.href), m.glyph), + span({ class: 'bb-pick-ico' }, m.glyph), span({ class: 'bb-pick-lbl' }, i18n[m.labelKey] || m.key) ) ); @@ -311,5 +279,5 @@ module.exports = ({ i18n }) => { ); }; - return { renderMobileTopbar, renderBottomBar, renderHiveNav, PINNABLE_MODULES, bottomBarPickerView, GROUPS }; + return { renderMobileTopbar, renderBottomBar, renderHiveNav, PINNABLE_MODULES, bottomBarPickerView }; }; diff --git a/src/views/housing_view.js b/src/views/housing_view.js index c1e3e4a..a0b5d45 100644 --- a/src/views/housing_view.js +++ b/src/views/housing_view.js @@ -1,8 +1,8 @@ const { form, button, div, h2, p, section, input, label, textarea, br, a, span, select, option, img, video, table, tr, td, hr } = require("../server/node_modules/hyperaxe") const { renderCommentsSection: renderSharedCommentsSection } = require("./comments_view"); const { renderReachChip, renderClearnetSelector, renderClearnetSwitch } = require("./clearnet_view") -const { clearnetItemHref, template, i18n, renderOpenClosedChip, renderStateChip, renderVisibilityChip, renderLifespanChip, renderEcoTax, renderCardTop, renderOpinionsVoting, renderEngagement, renderSpreadEditWarning, renderModuleStatsBy, moduleIsEmpty, renderEcoValueChip, contentDeleteAction, paged, renderEditedChip, renderDetailShare } = require("./main_views") -const { blobUrl, blobIdOf, isVideoEntry, imagesOf, renderPhotoGallery, renderGalleryFields } = require("./gallery_view") +const { clearnetItemHref, template, i18n, renderOpenClosedChip, renderStateChip, renderVisibilityChip, renderLifespanChip, renderEcoTax, renderSpreadButton, renderContentActions, renderOpinionsVoting, renderEngagement, renderSpreadEditWarning, renderModuleStatsBy, moduleIsEmpty, renderEcoValueChip, contentDeleteAction, paged, renderCreatedRows } = require("./main_views") +const { blobUrl, blobIdOf, isVideoEntry, imagesOf, renderMediaThumb, renderPhotoGallery, renderGalleryFields } = require("./gallery_view") const moment = require("../server/node_modules/moment") const { config } = require("../server/SSB_server.js") const { renderStyledText } = require("../backend/renderStyledText") @@ -115,7 +115,7 @@ const renderInfoTable = (item) => { const mapNode = renderMapLocationVisitLabel(item.mapUrl) if (mapNode) pushRow(i18n.mapLocationTitle || "Map", mapNode) } - return table({ class: "tribe-info-table housing-info-table" }, ...rows) + return table({ class: "tribe-info-table housing-info-table" }, ...rows, ...renderCreatedRows(item.createdAt, item.author)) } const today = () => moment().format("YYYY-MM-DD") @@ -179,9 +179,11 @@ const renderHousingList = (items, filter, params = {}) => { item.clearnet === true && clearnetEligible(item) ? renderReachChip(true, i18n, housingClearnetHref(item)) : null ].filter(Boolean) - const itemHref = `/housing/${encodeURIComponent(item.id)}` return div({ class: "trending-card housing-card" + (isOwn ? " own-content" : "") }, - renderCardTop(item.id, itemHref, { date: item.createdAt, chips, spread: params.spreadMap && params.spreadMap.get(item.id) || null, author: item.author, favKind: 'housing', isFavorite: item.isFavorite, reportTitle: item.title, returnTo, deleteAction: isOwn ? contentDeleteAction("housing", item.id) : null }), + div({ class: "card-header activity-card-header" }, + span(), + renderContentActions(item.id, `/housing/${encodeURIComponent(item.id)}`, { spread: params.spreadMap && params.spreadMap.get(item.id) || null, author: item.author, favKind: 'housing', isFavorite: item.isFavorite, reportTitle: item.title, returnTo, deleteAction: isOwn ? contentDeleteAction("housing", item.id) : null }) + ), div({ class: "card-section housing-card-body" }, clip || (cover && isVideoEntry(cover)) ? div({ class: "tribe-card-image-wrapper housing-card-video" }, @@ -189,7 +191,7 @@ const renderHousingList = (items, filter, params = {}) => { ) : cover ? div({ class: "tribe-card-image-wrapper" }, - a({ href: itemHref }, + a({ href: `/housing/${encodeURIComponent(item.id)}` }, img({ src: blobUrl(blobIdOf(cover), 256), class: "tribe-card-hero-image", alt: "" }) ) ) @@ -197,9 +199,10 @@ const renderHousingList = (items, filter, params = {}) => { div({ class: "tribe-card-body" }, div({ class: "shop-title-row" }, h2({ class: "tribe-card-title" }, - a({ href: itemHref }, safeText(item.title) || i18n.housingTitle) + a({ href: `/housing/${encodeURIComponent(item.id)}` }, safeText(item.title) || i18n.housingTitle) ) ), + chips.length ? div({ class: "card-chips-row" }, ...chips) : null, renderStarRating(item.opinions, safeArr(item.opinions_inhabitants).length), div({ class: "price-chip" }, priceLabel(item)), safeText(item.place) @@ -216,8 +219,7 @@ const renderHousingList = (items, filter, params = {}) => { span({ class: "card-label" }, `${i18n.galleryPhotos}: `), span({ class: "card-value" }, String(imagesOf(item).length)) ) - : null, - null + : null ) ) ) @@ -439,7 +441,8 @@ exports.singleHousingView = async (item, filter = "ALL", comments = [], params = requested ? renderRequestedChip() : null, renderLifespanChip(item.lifetime, i18n), renderEcoTax(item.msgSize, item.id), - renderReachChip(isClearnet, i18n, isClearnet ? housingClearnetHref(item) : null) + renderReachChip(isClearnet, i18n, isClearnet ? housingClearnetHref(item) : null), + isAuthor && canClearnet ? renderClearnetSwitch("housing", item.rootId || item.id, isClearnet) : null ].filter(Boolean) const sideActions = [] @@ -466,14 +469,15 @@ exports.singleHousingView = async (item, filter = "ALL", comments = [], params = const cover = imagesOf(item)[0] const housingSide = div({ class: "tribe-side" }, - renderCardTop(item.id, `/housing/${encodeURIComponent(item.id)}`, { arrow: false, date: item.createdAt, chips, bylineExtra: [renderEditedChip(item.id, item.rootId)], spread: params.spreads || null, author: item.author, favKind: 'housing', isFavorite: item.isFavorite, reportTitle: item.title, returnTo, deleteAction: isAuthor ? contentDeleteAction("housing", item.id) : null }), + div({ class: "card-header activity-card-header" }, + renderContentActions(item.id, `/housing/${encodeURIComponent(item.id)}`, { spread: params.spreads || null, author: item.author, favKind: 'housing', isFavorite: item.isFavorite, reportTitle: item.title, returnTo, deleteAction: isAuthor ? contentDeleteAction("housing", item.id) : null }) + ), div({ class: "shop-title-row" }, h2({ class: "tribe-card-title" }, safeText(item.title) || i18n.housingTitle) ), - isAuthor && canClearnet ? renderClearnetSwitch("housing", item.rootId || item.id, isClearnet) : null, + chips.length ? div({ class: "card-chips-row" }, ...chips) : null, renderStarRating(item.opinions, voters.length), cover && !isVideoEntry(cover) ? img({ src: blobUrl(blobIdOf(cover), 256), class: "tribe-detail-image", alt: "" }) : null, - renderDetailShare(item.id, `/housing/${encodeURIComponent(item.id)}`, item.title), renderInfoTable(item), div({ class: "tribe-card-members" }, span({ class: "tribe-members-count" }, `${i18n.housingRequests}: ${item.requestCount || 0}`) @@ -502,7 +506,8 @@ exports.singleHousingView = async (item, filter = "ALL", comments = [], params = !isAuthor && item.everRequestedByViewer ? renderOpinionsVoting("/housing/opinions", item.id, item.opinions, returnTo, voters) : null, - renderCommentsSection(item.id, returnTo, comments)) + renderCommentsSection(item.id, returnTo, comments) + ) ) return template( diff --git a/src/views/identities_view.js b/src/views/identities_view.js index 956b54f..c1e09b2 100644 --- a/src/views/identities_view.js +++ b/src/views/identities_view.js @@ -50,22 +50,20 @@ module.exports = ({ i18n, html }) => { button({ type: "submit" }, i18n.identitiesRestart)) : null; - return section({ class: "identities" }, + return section( div({ class: "tags-header" }, h2(i18n.identitiesTitle), - p(i18n.identitiesDescription) - ), - ul({ class: "identity-list" }, names.map(row)), - form({ method: "POST", action: "/settings/identity/create", class: "identity-create" }, - input({ type: "text", name: "account", placeholder: i18n.identitiesNewName, - maxlength: "32", autocomplete: "off" }), - button({ type: "submit" }, i18n.identitiesCreate) - ), - div({ class: "identity-actions" }, - restart, + p(i18n.identitiesDescription), + ul({ class: "identity-list" }, names.map(row)), + form({ method: "POST", action: "/settings/identity/create" }, + input({ type: "text", name: "account", placeholder: i18n.identitiesNewName, + maxlength: "32", autocomplete: "off" }), + button({ type: "submit" }, i18n.identitiesCreate) + ), // sacar o meter una identidad ya existente es cosa del modulo legacy, que // es el que sabe cifrar el secret con una contraseña - a({ href: "/legacy", class: "identity-legacy" }, i18n.identitiesBackup) + p(a({ href: "/legacy", class: "identity-legacy" }, i18n.identitiesBackup)), + restart ) ); }; diff --git a/src/views/image_view.js b/src/views/image_view.js index 4befcbf..9547b7f 100644 --- a/src/views/image_view.js +++ b/src/views/image_view.js @@ -4,13 +4,11 @@ const { renderCommentsSection: renderSharedCommentsSection, renderCommentsLink } const moment = require("../server/node_modules/moment"); const { renderLicenseChip, renderLicenseSelect, renderReachChip, renderClearnetSelector, renderClearnetSwitch, renderTribeOriginChip } = require('./clearnet_view'); -const { clearnetItemHref, template, i18n, renderOpinionsVoting, renderEngagement, renderEcoTax, renderLifespanChip, renderCardTop, renderCardByline, renderSpreadEditWarning, renderModuleStats, moduleIsEmpty, contentDeleteAction, paged, blobUrlOf, renderMediaLockNote, renderEditedChip, renderDetailShare } = require("./main_views"); +const { clearnetItemHref, template, i18n, renderOpinionsVoting, renderEngagement, userLink, renderSpreadButton, renderEcoTax, renderLifespanChip, renderStateChip, renderContentActions, renderSpreadEditWarning, renderModuleStats, moduleIsEmpty, contentDeleteAction, paged, blobUrlOf } = require("./main_views"); const { config } = require("../server/SSB_server.js"); const { renderStyledText } = require("../backend/renderStyledText") const { renderMapLocationVisitLabel, renderMapEmbed } = require("./maps_view"); -const { renderZoomableImage, renderDownloadButton, downloadHref } = require("./gallery_view"); -const LIST_THUMB = 640; -const thumbOf = (url) => String(url || "").startsWith("&") ? `/image/${LIST_THUMB}/${encodeURIComponent(url)}?fit=inside` : null; +const { renderZoomableImage } = require("./gallery_view"); const userId = config.keys.id; @@ -43,7 +41,7 @@ const renderImageMedia = (imgObj, filter, params = {}) => { return imgObj?.url ? div( { class: "image-container image-container-row" }, - renderZoomableImage(src, { alt: imgObj.title || "", imgClass: "post-image", thumb: thumbOf(imgObj.url) }) + renderZoomableImage(src, { alt: imgObj.title || "", imgClass: "post-image" }) ) : p(i18n.imageNoFile); }; @@ -51,8 +49,9 @@ const renderImageMedia = (imgObj, filter, params = {}) => { const renderImageOwnerActions = (filter, imgObj, params = {}) => { const returnTo = buildReturnTo(filter, params); const isAuthor = String(imgObj.author) === String(userId); + const hasOpinions = Object.keys(imgObj.opinions || {}).length > 0; - if (!isAuthor) return []; + if (!isAuthor || hasOpinions) return []; return [ form( @@ -63,52 +62,61 @@ const renderImageOwnerActions = (filter, imgObj, params = {}) => { ]; }; -const renderImageUpdated = (imgObj) => { - const createdTs = imgObj.createdAt ? new Date(imgObj.createdAt).getTime() : NaN; - const updatedTs = imgObj.updatedAt ? new Date(imgObj.updatedAt).getTime() : NaN; - const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs); - return showUpdated - ? span({ class: "votations-comment-date" }, `${i18n.imageUpdatedAt}: ${moment(imgObj.updatedAt).format("YYYY/MM/DD HH:mm")}`) - : null; -}; - const renderImageList = exports.renderImageList = (images, filter, params = {}) => { const returnTo = buildReturnTo(filter, params); return images.length ? images.map((imgObj) => { const commentCount = typeof imgObj.commentCount === "number" ? imgObj.commentCount : 0; const title = safeText(imgObj.title); - const href = `/images/${encodeURIComponent(imgObj.key)}`; const isOwn = imgObj.author && String(imgObj.author) === String(userId); - const byline = { - author: imgObj.author, - date: imgObj.createdAt, - chips: [ - imgObj.tribeOrigin ? renderTribeOriginChip(imgObj.tribeOrigin) : imgObj.clearnet === true ? renderReachChip(true, i18n, clearnetItemHref('images', imgObj.title, imgObj.key)) : null, - renderLicenseChip(imgObj.license), - ...(typeof params.titleChips === "function" ? params.titleChips(imgObj) : []), - renderLifespanChip(imgObj.lifetime, i18n) - ], - bylineExtra: [renderImageUpdated(imgObj)] - }; - const header = typeof params.headerActions === "function" - ? div({ class: "card-header activity-card-header card-top" }, renderCardByline(byline), params.headerActions(imgObj)) + const headerActions = typeof params.headerActions === "function" + ? params.headerActions(imgObj) : imgObj.tribeOrigin - ? renderCardTop(imgObj.key, imgObj.tribeOrigin.href, { ...byline, spread: (params.spreadMap && params.spreadMap.get(imgObj.key)) || null, reportTitle: imgObj.title }) - : renderCardTop(imgObj.key, href, { ...byline, spread: (params.spreadMap && params.spreadMap.get(imgObj.key)) || params.spreads || null, favKind: 'images', torrentFrom: { blobId: imgObj.url, name: imgObj.title }, isFavorite: imgObj.isFavorite, reportTitle: imgObj.title, returnTo, deleteAction: isOwn ? contentDeleteAction("image", imgObj.key) : null }); - const titleHref = typeof params.headerActions === "function" ? null : imgObj.tribeOrigin ? imgObj.tribeOrigin.href : href; - const engagement = typeof params.engagement === "function" ? params.engagement(imgObj) : null; + ? renderContentActions(null, imgObj.tribeOrigin.href) + : renderContentActions(imgObj.key, `/images/${encodeURIComponent(imgObj.key)}`, { spread: (params.spreadMap && params.spreadMap.get(imgObj.key)) || params.spreads || null, author: imgObj.author, favKind: 'images', torrentFrom: { blobId: imgObj.url, name: imgObj.title }, isFavorite: imgObj.isFavorite, reportTitle: imgObj.title, returnTo, deleteAction: isOwn ? contentDeleteAction("image", imgObj.key) : null }); + const titleHref = typeof params.headerActions === "function" ? null : imgObj.tribeOrigin ? imgObj.tribeOrigin.href : `/images/${encodeURIComponent(imgObj.key)}`; + const engagement = typeof params.engagement === "function" + ? params.engagement(imgObj) + : imgObj.tribeOrigin ? null : renderEngagement(imgObj.key, + renderOpinionsVoting('/images/opinions', imgObj.key, imgObj.opinions, returnTo, imgObj.opinions_inhabitants), + renderCommentsLink({ href: `/images/${encodeURIComponent(imgObj.key)}`, count: commentCount }) + ); return div( { class: "trending-card image-card" + (isOwn ? " own-content" : "") }, - header, + div( + { class: "card-header activity-card-header" }, + span(), + headerActions + ), div( { class: "card-section image-card-body" }, - title ? div({ class: "shop-title-row" }, h2(titleHref ? a({ href: titleHref, class: "card-title-link" }, title) : title)) : null, + div({ class: "shop-title-row" }, title ? h2(titleHref ? a({ href: titleHref, class: "card-title-link" }, title) : title) : null, imgObj.tribeOrigin ? renderTribeOriginChip(imgObj.tribeOrigin) : imgObj.clearnet === true ? renderReachChip(true, i18n, clearnetItemHref('images', imgObj.title, imgObj.key)) : null, renderLicenseChip(imgObj.license), ...(typeof params.titleChips === "function" ? params.titleChips(imgObj) : [])), + imgObj.lifetime ? div({ class: "card-chips-row" }, + imgObj.lifetime ? renderLifespanChip(imgObj.lifetime, i18n) : null + ) : null, renderImageMedia(imgObj, filter, params), ...(typeof params.bodyExtra === "function" ? params.bodyExtra(imgObj) : []), + engagement, renderMapLocationVisitLabel(imgObj.mapUrl), - engagement + br(), + (() => { + const createdTs = imgObj.createdAt ? new Date(imgObj.createdAt).getTime() : NaN; + const updatedTs = imgObj.updatedAt ? new Date(imgObj.updatedAt).getTime() : NaN; + const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs); + + return p( + { class: "card-footer" }, + span({ class: "date-link" }, `${moment(imgObj.createdAt).format("YYYY/MM/DD HH:mm")}`), + userLink(imgObj.author), + showUpdated + ? span( + { class: "votations-comment-date" }, + ` | ${i18n.imageUpdatedAt}: ${moment(imgObj.updatedAt).format("YYYY/MM/DD HH:mm")}` + ) + : null + ); + })() ) ); }) @@ -132,7 +140,7 @@ const renderImageForm = (filter, imageId, imageToEdit, params = {}) => { input({ type: "hidden", name: "returnTo", value: returnTo }), label(i18n.imageFileLabel), br(), - (filter === "edit" && renderMediaLockNote(imageToEdit)) || input({ type: "file", name: "image", accept: "image/*", required: filter !== "edit" }), + input({ type: "file", name: "image", required: filter !== "edit" }), br(), br(), imageToEdit?.url @@ -179,18 +187,12 @@ const renderGallery = (images) => { }; const renderLightbox = (images) => - images.map((imgObj, i) => { + images.map((imgObj) => { const src = imgObj.url ? blobUrlOf(imgObj.url) : ""; - const idOf = (o) => `img-${encodeURIComponent(o.key)}`; - const prev = images[i - 1]; - const next = images[i + 1]; return div( - { id: idOf(imgObj), class: "lightbox" }, - src ? a({ href: downloadHref(src), class: "lightbox-download", title: i18n.fileShareDownload || "Download" }, "⤓") : null, - a({ href: `#${idOf(imgObj)}-src`, class: "lightbox-close" }, "×"), - prev ? a({ href: `#${idOf(prev)}`, class: "lightbox-prev", title: "←" }, "‹") : null, - next ? a({ href: `#${idOf(next)}`, class: "lightbox-next", title: "→" }, "›") : null, - img({ src, class: "lightbox-image", alt: imgObj.title || "", loading: "lazy" }) + { id: `img-${encodeURIComponent(imgObj.key)}`, class: "lightbox" }, + a({ href: `#img-${encodeURIComponent(imgObj.key)}-src`, class: "lightbox-close" }, "×"), + img({ src, class: "lightbox-image", alt: imgObj.title || "" }) ); }); @@ -313,30 +315,27 @@ exports.singleImageView = async (imageObj, filter = "all", comments = [], params for (const a of ownerActions) sideActions.push(a); const tagsNode = renderTags(imageObj.tags); - const href = `/images/${encodeURIComponent(imageObj.key)}`; - const imageSide = div({ class: "tribe-side" }, - renderCardTop(imageObj.key, href, { arrow: false, + const detailActions = div({ class: "card-header activity-card-header" }, + renderContentActions(imageObj.key, `/images/${encodeURIComponent(imageObj.key)}`, { author: imageObj.author, - date: imageObj.createdAt, - chips: [ - renderReachChip(isClearnet, i18n, isClearnet ? clearnetItemHref('images', imageObj.title, imageObj.key) : null), - renderLicenseChip(imageObj.license), - ...chips - ], - bylineExtra: [renderEditedChip(imageObj.key, imageObj.rootId), renderImageUpdated(imageObj)], deleteAction: String(imageObj.author) === String(userId) ? contentDeleteAction("image", imageObj.key) : null, favKind: 'images', torrentFrom: { blobId: imageObj.url, name: imageObj.title }, 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, - isAuthor ? renderClearnetSwitch('images', imageObj.rootId || imageObj.key, isClearnet) : null + renderReachChip(isClearnet, i18n, isClearnet ? clearnetItemHref('images', imageObj.title, imageObj.key) : null), + isAuthor ? renderClearnetSwitch('images', imageObj.rootId || imageObj.key, isClearnet) : null, + renderLicenseChip(imageObj.license) ), - renderDetailShare(imageObj.key, href, imageObj.title), + chips.length ? div({ class: "card-chips-row" }, ...chips) : null, safeText(imageObj.description) ? p({ class: "tribe-side-description" }, ...renderStyledText(imageObj.description)) : null, @@ -346,16 +345,39 @@ exports.singleImageView = async (imageObj, filter = "all", comments = [], params ); const imageMain = div({ class: "tribe-main" }, + detailActions, imageObj?.url ? div( { class: "image-container" }, - renderZoomableImage(blobUrlOf(imageObj.url), { alt: imageObj.title || "", imgClass: "media-preview", full: true }), - renderDownloadButton(blobUrlOf(imageObj.url), imageObj.title || "") + img({ + src: blobUrlOf(imageObj.url), + alt: imageObj.title || "", + class: "media-preview", + loading: "lazy" + }) ) : p(i18n.imageNoFile), + (() => { + const createdTs = imageObj.createdAt ? new Date(imageObj.createdAt).getTime() : NaN; + const updatedTs = imageObj.updatedAt ? new Date(imageObj.updatedAt).getTime() : NaN; + const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs); + + return p( + { class: "card-footer" }, + span({ class: "date-link" }, `${moment(imageObj.createdAt).format("YYYY/MM/DD HH:mm")}`), + userLink(imageObj.author), + showUpdated + ? span( + { class: "votations-comment-date" }, + ` | ${i18n.imageUpdatedAt}: ${moment(imageObj.updatedAt).format("YYYY/MM/DD HH:mm")}` + ) + : null + ); + })(), renderEngagement(imageObj.key, renderOpinionsVoting('/images/opinions', imageObj.key, imageObj.opinions, returnTo, imageObj.opinions_inhabitants), - renderImageCommentsSection(imageObj.key, comments, returnTo)) + renderImageCommentsSection(imageObj.key, comments, returnTo) + ) ); return template( @@ -370,10 +392,9 @@ exports.singleImageView = async (imageObj, filter = "all", comments = [], params form( { method: "GET", action: "/images", class: "ui-toolbar ui-toolbar--filters" }, input({ type: "hidden", name: "sort", value: sort }), - 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: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, String(i18n.imageFilterAll).toUpperCase()), ...(mediaChip("favorites") ? [button( ...(mediaChip("recent") ? [button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, String(i18n.imageFilterRecent).toUpperCase())] : []), ...(mediaChip("mine") ? [button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, String(i18n.imageFilterMine).toUpperCase())] : []), - ...(mediaChip("favorites") ? [button( { type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" }, String(i18n.imageFilterFavorites).toUpperCase() )] : []), diff --git a/src/views/industry_view.js b/src/views/industry_view.js index 21a276d..13c7a65 100644 --- a/src/views/industry_view.js +++ b/src/views/industry_view.js @@ -1,7 +1,6 @@ -const { form, button, div, h2, p, section, input, label, textarea, br, a, span, select, option, ul, li, video, audio, table, thead, tbody, tr, td, th } = require("../server/node_modules/hyperaxe") +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 { renderZoomableImage } = require("./gallery_view") -const { template, i18n, renderOpinionsVoting, renderEngagement, userLink, renderStateChip, renderLifespanChip, renderEcoTax, renderCardTop, renderSpreadEditWarning, renderSubscriptionBox, renderModuleStatsBy, moduleIsEmpty, paged, renderEditedChip, renderMediaLockNote, renderDetailShare } = require("./main_views") -const { renderCommentsSection } = require("./comments_view") +const { template, i18n, renderOpinionsVoting, renderEngagement, userLink, renderStateChip, renderLifespanChip, renderEcoTax, renderContentActions , renderSpreadEditWarning, renderSubscriptionBox, renderModuleStatsBy, renderCardMetaRow, moduleIsEmpty, paged } = require("./main_views") const moment = require("../server/node_modules/moment") const { config } = require("../server/SSB_server.js") const { renderMapEmbedWithZoom } = require("./maps_view") @@ -125,20 +124,28 @@ const renderFacilityList = exports.renderFacilityList = (facilities, filter, spr : (fc.subscriptionIn === false ? renderStateChip("closed", "✉", i18n.subscriptionOff) : null) ].filter(Boolean) return div({ class: "trending-card tribes-card industry-card" + (isOwn ? " own-content" : "") }, - renderCardTop(fc.id || fc.key, href, { date: fc.createdAt, chips, spread: spreadMap.get(fc.id || fc.key) || null, author: fc.steward, reportTitle: fc.name, deleteAction: isOwn && memberCount <= 1 ? `/industry/delete/${encodeURIComponent(fc.id)}` : null }), + div({ class: "card-header activity-card-header" }, + span(), + renderContentActions(fc.id || fc.key, href, { spread: spreadMap.get(fc.id || fc.key) || null, author: fc.steward, reportTitle: fc.name, deleteAction: isOwn && memberCount <= 1 ? `/industry/delete/${encodeURIComponent(fc.id)}` : null }) + ), div({ class: "card-section tribes-card-body" }, div({ class: "tribe-card-image-wrapper" }, - a({ href }, renderMediaBlob(fc.image, { class: "tribe-card-hero-image" }) || div({ class: "industry-card-noimage" }, span("🏭"))) + a({ href }, renderMediaBlob(fc.image, { class: "tribe-card-hero-image" }) || div({ class: "industry-card-noimage" }, span("🏭"))), + isMember + ? form({ method: "GET", action: href, class: "tribe-visit-btn-wrapper" }, + button({ type: "submit", class: "filter-btn" }, String(i18n.industryVisitButton || "VISIT").toUpperCase()) + ) + : null ), div({ class: "tribe-card-body" }, div({ class: "shop-title-row" }, h2({ class: "tribe-card-title" }, a({ href }, safeText(fc.name) || (i18n.industryTitle || "Industry"))) ), + div({ class: "card-chips-row" }, ...chips), fc.description ? p({ class: "tribe-card-description" }, safeText(fc.description).slice(0, 220)) : null, div({ class: "tribe-card-members" }, span({ class: "tribe-members-count" }, `${i18n.industryMembers || "Members"}: ${memberCount}`) ), - null ) ) ) @@ -152,7 +159,6 @@ const renderFacilityForm = (facility, mode, spreadWarning = null) => { const returnTo = "/industry?filter=MINE" const curPolicy = fc.membershipPolicy || "vote" const curSector = fc.sector || "other" - const mediaLock = isEdit ? renderMediaLockNote(fc) : null return div({ class: "div-center industry-form" }, isEdit ? spreadWarning : null, form({ @@ -171,7 +177,11 @@ const renderFacilityForm = (facility, mode, spreadWarning = null) => { br(), label(i18n.uploadMedia || "Upload media (max-size: 75MB)"), br(), - ...(mediaLock ? [mediaLock] : [input({ type: "file", name: "image" }), br(), label(i18n.uploadMedia), br(), input({ type: "file", name: "blob" })]), + input({ type: "file", name: "image" }), + br(), + label(i18n.uploadMedia), + br(), + input({ type: "file", name: "blob" }), fc.image ? div({ class: "industry-form-media" }, renderMediaBlob(fc.image, { class: "industry-hero-image" })) : null, br(), label(i18n.industrySector || "Sector"), @@ -216,15 +226,12 @@ const renderGlobalBlueprints = (blueprints, spreadMap = new Map()) => { if (!list.length) return p(i18n.industryNoBlueprints || "No blueprints yet.") return div({ class: "industry-blueprints" }, list.map((bp) => div({ class: "industry-card-wrap" }, - renderCardTop(bp.id, "/industry/blueprint/" + encodeURIComponent(bp.id), { - date: bp.createdAt, - chips: [ - renderStateChip("half", bp.outKind === "digital" ? "💾" : "📦", i18n["industryKind_" + bp.outKind] || bp.outKind), - renderStateChip("half", "⚖", String(bp.license || "copyleft").toUpperCase()) - ], - spread: spreadMap.get(bp.id) || null, author: bp.author, reportTitle: bp.name - }), + div({ class: "card-header activity-card-header" }, span(), renderContentActions(bp.id, "/industry/blueprint/" + encodeURIComponent(bp.id), { spread: spreadMap.get(bp.id) || null, author: bp.author, reportTitle: bp.name })), div({ class: "industry-blueprint-card" + (bp.author === userId ? " own-content" : "") }, + div({ class: "card-chips-row" }, + renderStateChip("half", bp.outKind === "digital" ? "💾" : "📦", i18n["industryKind_" + bp.outKind] || bp.outKind), + renderStateChip("half", "⚖", String(bp.license || "copyleft").toUpperCase()) + ), p({ class: "industry-facility-line" }, span({ class: "industry-meta-label" }, `${i18n.industryFacility || "Facility"}: `), a({ href: `/industry/${encodeURIComponent(bp.facilityId)}` }, safeText(bp.facilityName))), div({ class: "shop-title-row" }, h2({ class: "tribe-card-title" }, a({ href: `/industry/blueprint/${encodeURIComponent(bp.id)}` }, safeText(bp.name)))), bp.image ? renderMediaBlob(bp.image, { class: "post-image" }) : null, @@ -236,8 +243,7 @@ const renderGlobalBlueprints = (blueprints, spreadMap = new Map()) => { (() => { const acts = renderBlueprintGovernActions(bp.facilityId, bp, safeArr(bp.members).includes(userId) || bp.author === userId, "/industry?filter=BLUEPRINTS") return acts.length ? div({ class: "industry-actions" }, ...acts) : null - })(), - null + })() ) )) ) @@ -272,17 +278,16 @@ const renderBuildGovernActions = (b, isMember, returnTo) => { const renderBuildCard = (b, opts = {}) => { const href = `/industry/build/${encodeURIComponent(b.id)}` - const chips = [ - renderBuildStatusChip(b.status), - ...((b.blueprintKind && opts.withBlueprintChips !== false) ? [ - renderStateChip("half", b.blueprintKind === "digital" ? "💾" : "📦", i18n["industryKind_" + b.blueprintKind] || b.blueprintKind), - renderStateChip("half", "⚖", String(b.blueprintLicense || "copyleft").toUpperCase()) - ] : []) - ] const card = div({ class: "industry-blueprint-card" + (b.proposer === userId ? " own-content" : "") }, - opts.withActions ? null : div({ class: "card-chips-row" }, ...chips), + div({ class: "card-chips-row" }, + renderBuildStatusChip(b.status), + ...((b.blueprintKind && opts.withBlueprintChips !== false) ? [ + renderStateChip("half", b.blueprintKind === "digital" ? "💾" : "📦", i18n["industryKind_" + b.blueprintKind] || b.blueprintKind), + renderStateChip("half", "⚖", String(b.blueprintLicense || "copyleft").toUpperCase()) + ] : []) + ), opts.withFacility ? p({ class: "industry-facility-line" }, span({ class: "industry-meta-label" }, `${i18n.industryFacility || "Facility"}: `), a({ href: `/industry/${encodeURIComponent(b.facilityId)}` }, safeText(b.facilityName))) : null, - opts.withProposer !== false && !opts.withActions ? p(span({ class: "industry-meta-label" }, `${i18n.industryProposer || "Proposer"}: `), userLink(b.proposer)) : null, + opts.withProposer !== false ? p(span({ class: "industry-meta-label" }, `${i18n.industryProposer || "Proposer"}: `), userLink(b.proposer)) : null, div({ class: "shop-title-row" }, h2({ class: "tribe-card-title" }, a({ href }, safeText(b.title)))), b.blueprintImage ? renderMediaBlob(b.blueprintImage, { class: "post-image" }) : null, b.notes ? p({ class: "tribe-card-description" }, ...renderStyledText(safeText(b.notes))) : null, @@ -292,12 +297,11 @@ const renderBuildCard = (b, opts = {}) => { ), renderBlueprintEstimate(b, i18n.industryFinalPrice || "Final price"), p(span({ class: "industry-meta-label" }, `${i18n.industryContributions || "Contributions"}: `), String(safeArr(b.contributions).length)), - (opts.actions && opts.actions.length) ? div({ class: "industry-actions" }, ...opts.actions) : null, - null + (opts.actions && opts.actions.length) ? div({ class: "industry-actions" }, ...opts.actions) : null ) if (!opts.withActions) return card return div({ class: "industry-card-wrap" }, - renderCardTop(b.id, href, { date: b.createdAt, chips, spread: opts.spread || null, author: b.proposer, reportTitle: b.title }), + div({ class: "card-header activity-card-header" }, span(), renderContentActions(b.id, href, { spread: opts.spread || null, author: b.proposer, reportTitle: b.title })), card ) } @@ -523,15 +527,12 @@ 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" }, - renderCardTop(bp.id, `/industry/blueprint/${encodeURIComponent(bp.id)}`, { - date: bp.createdAt, - chips: [ - renderStateChip("half", bp.outKind === "digital" ? "💾" : "📦", i18n["industryKind_" + bp.outKind] || bp.outKind), - renderStateChip("half", "⚖", String(bp.license || "copyleft").toUpperCase()) - ], - spread: spreadMap.get(bp.id) || null, author: bp.author, reportTitle: bp.name - }), + div({ class: "card-header activity-card-header" }, span(), renderContentActions(bp.id, `/industry/blueprint/${encodeURIComponent(bp.id)}`, { spread: spreadMap.get(bp.id) || null, author: bp.author, reportTitle: bp.name })), div({ class: "industry-blueprint-card" }, + div({ class: "card-chips-row" }, + renderStateChip("half", bp.outKind === "digital" ? "💾" : "📦", i18n["industryKind_" + bp.outKind] || bp.outKind), + renderStateChip("half", "⚖", String(bp.license || "copyleft").toUpperCase()) + ), div({ class: "shop-title-row" }, h2({ class: "tribe-card-title" }, safeText(bp.name))), bp.image ? renderMediaBlob(bp.image, { class: "post-image" }) : null, bp.description ? p({ class: "tribe-card-description" }, ...renderStyledText(safeText(bp.description))) : (bp.outItem ? p(span({ class: "industry-meta-label" }, `${i18n.industryOutput || "Output"}: `), `${bp.outQty || 0} × ${safeText(bp.outItem)}`) : null), @@ -539,8 +540,7 @@ const renderBlueprintsSection = (fc, blueprints, isMember, spreadMap = new Map() safeArr(bp.materials).length ? div({ class: "industry-materials" }, ul({ class: "industry-materials-list" }, bp.materials.map(m => li(`${m.qty || 0} × ${safeText(m.item)}${m.price != null ? ` = ${m.price} ECO` : ""}`)))) : null, renderBlueprintEstimate(bp, i18n.industryBuildingPrice || "Building price"), safeArr(bp.skills).length ? p(span({ class: "industry-meta-label" }, `${i18n.industrySkills || "Skills"}: `), bp.skills.join(", ")) : null, - actions.length ? div({ class: "industry-actions" }, ...actions) : null, - null + actions.length ? div({ class: "industry-actions" }, ...actions) : null ) ) }) @@ -564,7 +564,7 @@ const renderBlueprintForm = (fc, bp, mode, spreadWarning = null) => { label(i18n.industryDescriptionLabel || "Description"), br(), textarea({ name: "description", rows: "4", maxlength: "2000", placeholder: i18n.industryBlueprintDescPlaceholder || "Specs, dimensions, weight, docs…" }, bp.description || ""), br(), label(i18n.uploadMedia || "Upload media (max-size: 75MB)"), - br(), (isEdit && renderMediaLockNote(bp)) || input({ type: "file", name: "image" }), + br(), input({ type: "file", name: "image" }), bp.image ? div({ class: "industry-form-media" }, renderMediaBlob(bp.image, { class: "post-image" })) : null, br(), label(i18n.industryOutputKind || "Product type"), @@ -629,11 +629,13 @@ const renderFacilityJobsSection = (fc, jobs) => { ) } -const renderFacilityChips = (fc) => { +const renderFacilitySide = (fc, returnTo, params = {}) => { const isSteward = fc.steward === userId const isMember = safeArr(fc.members).includes(userId) const isOwner = String(fc.steward || fc.author) === String(userId) - return [ + const memberCount = fc.memberCount != null ? fc.memberCount : safeArr(fc.members).length + + const chips = [ renderStateChip("whole", "🏭", sectorLabel(fc.sector)), renderStateChip("half", "⚖", policyLabel(fc.membershipPolicy)), renderLifespanChip(fc.lifetime, i18n), @@ -644,13 +646,6 @@ const renderFacilityChips = (fc) => { : renderStateChip("closed", "✉", i18n.subscriptionOff)) : null ].filter(Boolean) -} - -const renderFacilitySide = (fc, returnTo, params = {}, cardTop = null) => { - const isSteward = fc.steward === userId - const isMember = safeArr(fc.members).includes(userId) - const isOwner = String(fc.steward || fc.author) === String(userId) - const memberCount = fc.memberCount != null ? fc.memberCount : safeArr(fc.members).length const sideActions = [] for (const act of membershipActions(fc, returnTo)) sideActions.push(act) @@ -664,15 +659,15 @@ const renderFacilitySide = (fc, returnTo, params = {}, cardTop = null) => { } return div({ class: "tribe-side" }, - cardTop, 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), fc.image ? renderMediaBlob(fc.image, { class: "tribe-detail-image" }) : null, - renderDetailShare(fc.id || fc.key, `/industry/${encodeURIComponent(fc.id)}`, fc.name), fc.description ? p({ class: "tribe-side-description" }, ...renderStyledText(safeText(fc.description))) : null, table({ class: "tribe-info-table" }, 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 @@ -702,15 +697,13 @@ exports.singleFacilityView = async (facility, filter, params = {}) => { const returnTo = `/industry/${encodeURIComponent(fc.id)}?filter=${encodeURIComponent(f)}` const isMember = safeArr(fc.members).includes(userId) const memberCount = fc.memberCount != null ? fc.memberCount : safeArr(fc.members).length - const facilitySide = renderFacilitySide(fc, returnTo, params, - renderCardTop(fc.id || fc.key, `/industry/${encodeURIComponent(fc.id)}`, { arrow: false, date: fc.createdAt, chips: renderFacilityChips(fc), bylineExtra: [renderEditedChip(fc.id, fc.root)], spread: params.spreads || null, author: fc.steward, reportTitle: fc.name, deleteAction: fc.steward === userId && memberCount <= 1 ? `/industry/delete/${encodeURIComponent(fc.id)}` : null }) - ) + const facilitySide = renderFacilitySide(fc, returnTo, params) const facilityMain = div({ class: "tribe-main" }, renderBlueprintsSection(fc, params.blueprints, isMember, params.childSpreadMap || new Map()), renderBuildsSection(fc, params.builds, params.blueprints, isMember, params.childSpreadMap || new Map()), renderFacilityJobsSection(fc, params.facilityJobs), - renderEngagement(fc.id || fc.key, renderOpinionsVoting('/industry/opinions', fc.id || fc.key, fc.opinions, null, fc.opinions_inhabitants), null), + renderCardMetaRow(renderOpinionsVoting('/industry/opinions', fc.id || fc.key, fc.opinions, null, fc.opinions_inhabitants)), div({ class: "industry-section" }, h2(i18n.industryMembers || "Members"), ul({ class: "industry-members-list" }, safeArr(fc.members).map((mid) => li( @@ -724,6 +717,9 @@ exports.singleFacilityView = async (facility, filter, params = {}) => { return template( i18n.industryTitle || "Industry", section( + div({ class: "card-header activity-card-header" }, + renderContentActions(fc.id || fc.key, `/industry/${encodeURIComponent(fc.id)}`, { spread: params.spreads || null, author: fc.steward, reportTitle: fc.name, deleteAction: fc.steward === userId && memberCount <= 1 ? `/industry/delete/${encodeURIComponent(fc.id)}` : null }) + ), div({ class: "tags-header module-header-line" }, 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" }, @@ -739,7 +735,9 @@ exports.singleFacilityView = async (facility, filter, params = {}) => { exports.blueprintEditView = async (bp, fc) => template( i18n.industryTitle || "Industry", section( - renderCardTop(bp.id, `/industry/blueprint/${encodeURIComponent(bp.id)}`, { date: bp.createdAt, author: bp.author, reportTitle: bp.name }), + div({ class: "card-header activity-card-header" }, + renderContentActions(bp.id, `/industry/blueprint/${encodeURIComponent(bp.id)}`, { author: bp.author, reportTitle: bp.name }) + ), div({ class: "tags-header module-header-line" }, 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" }, @@ -754,7 +752,9 @@ exports.blueprintEditView = async (bp, fc) => template( exports.buildEditView = async (b, fc) => template( i18n.industryTitle || "Industry", section( - renderCardTop(b.id, `/industry/build/${encodeURIComponent(b.id)}`, { date: b.createdAt, author: b.proposer, reportTitle: b.title }), + div({ class: "card-header activity-card-header" }, + renderContentActions(b.id, `/industry/build/${encodeURIComponent(b.id)}`, { author: b.proposer, reportTitle: b.title }) + ), div({ class: "tags-header module-header-line" }, 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" }, @@ -786,20 +786,21 @@ exports.singleBlueprintView = async (blueprint, params = {}) => { ), section( div({ class: "shop-detail" }, - renderCardTop(bp.id, returnTo, { arrow: false, - date: bp.createdAt, - chips: [ - renderStateChip("half", bp.outKind === "digital" ? "💾" : "📦", i18n["industryKind_" + bp.outKind] || bp.outKind), - renderStateChip("half", "⚖", String(bp.license || "copyleft").toUpperCase()) - ], - bylineExtra: [renderEditedChip(bp.id, bp.rootId)], - spread: params.spreads || null, author: bp.author, reportTitle: bp.name || bp.title - }), + div({ class: "card-header activity-card-header" }, + span(), + renderContentActions(bp.id, returnTo, { 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()) + ), p({ class: "industry-facility-line" }, span({ class: "industry-meta-label" }, `${i18n.industryFacility || "Facility"}: `), a({ href: facilityHref }, safeText(bp.facilityName))), h2(safeText(bp.name)), bp.image ? div({ class: "shop-detail-media" }, renderMediaBlob(bp.image, { class: "post-image" })) : null, - renderDetailShare(bp.rootId || bp.id, returnTo, bp.name || bp.title), bp.description ? p({ class: "tribe-card-description" }, ...renderStyledText(safeText(bp.description))) : null, + div({ class: "industry-meta" }, + p(span({ class: "industry-meta-label" }, `${i18n.industryAuthor || "Author"}: `), userLink(bp.author)) + ), renderBlueprintLaborHours(bp), safeArr(bp.materials).length ? div({ class: "industry-materials" }, ul({ class: "industry-materials-list" }, bp.materials.map(m => li(`${m.qty || 0} × ${safeText(m.item)}${m.price != null ? ` = ${m.price} ECO` : ""}`)))) : null, renderBlueprintEstimate(bp, i18n.industryBuildingPrice || "Building price"), @@ -814,10 +815,6 @@ exports.singleBlueprintView = async (blueprint, params = {}) => { ))) ) : null - ), - renderEngagement(bp.rootId || bp.id, - params.opinions ? renderOpinionsVoting('/industry/blueprint/opinions', bp.rootId || bp.id, params.opinions.opinions, `/industry/blueprint/${encodeURIComponent(bp.rootId || bp.id)}`, params.opinions.opinions_inhabitants) : null, - Array.isArray(params.comments) ? renderCommentsSection({ action: `/industry/blueprint/${encodeURIComponent(bp.rootId || bp.id)}/comments`, comments: params.comments, returnTo: `/industry/blueprint/${encodeURIComponent(bp.rootId || bp.id)}` }) : null ) ) ) @@ -974,13 +971,17 @@ exports.singleBuildView = async (build, params = {}) => { ), section( div({ class: "shop-detail" }, - renderCardTop(b.id, returnTo, { arrow: false, date: b.createdAt, chips: [renderBuildStatusChip(status)], spread: params.spreads || null, author: b.proposer, reportTitle: b.title }), - b.image ? div({ class: "shop-detail-media" }, renderMediaBlob(b.image, { class: "post-image" })) : null, - renderDetailShare(b.id, returnTo, b.title), + div({ class: "card-header activity-card-header" }, + span(), + renderContentActions(b.id, returnTo, { 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" }, 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, diff --git a/src/views/inhabitants_view.js b/src/views/inhabitants_view.js index 049ede1..399059e 100644 --- a/src/views/inhabitants_view.js +++ b/src/views/inhabitants_view.js @@ -57,7 +57,7 @@ function resolvePhoto(photoField, size = 256) { } const filterLabel = (mode) => - String(i18n[mode + 'Button'] || i18n[mode + 'SectionTitle'] || i18n[String(mode).toLowerCase() + 'Button'] || i18n[String(mode).toLowerCase() + 'SectionTitle'] || mode).toUpperCase(); + String(i18n[mode + 'Button'] || i18n[mode + 'SectionTitle'] || mode).toUpperCase(); const generateFilterButtons = (filters, currentFilter, labelOf = filterLabel) => filters.map(mode => diff --git a/src/views/jobs_view.js b/src/views/jobs_view.js index 90bddb9..9d89c07 100644 --- a/src/views/jobs_view.js +++ b/src/views/jobs_view.js @@ -1,11 +1,10 @@ const { form, button, div, h2, p, section, input, label, textarea, br, a, span, select, option, img, progress, video, audio, table, tr, td, hr } = require("../server/node_modules/hyperaxe") const { renderZoomableImage } = require("./gallery_view") const { renderCommentsSection: renderSharedCommentsSection } = require("./comments_view"); -const { renderCallButton, clearnetItemHref, template, i18n, userLink, renderStateChip, renderOpenClosedChip, renderVisibilityChip, renderLifespanChip, renderEcoTax, renderCardTop, renderSpreadEditWarning, renderModuleStatsBy, moduleIsEmpty, renderEcoValueChip, contentDeleteAction, paged, renderOpinionsVoting, renderEditedChip, renderMediaLockNote, renderEngagement, renderDetailShare } = require("./main_views") -const moment = require("../server/node_modules/moment") +const { renderCallButton, clearnetItemHref, template, i18n, userLink, renderStateChip, renderOpenClosedChip, renderVisibilityChip, renderLifespanChip, renderEcoTax, renderSpreadButton, renderContentActions, renderSpreadEditWarning, renderModuleStatsBy, moduleIsEmpty, renderEcoValueChip, contentDeleteAction, paged, renderCreatedRows } = require("./main_views") const { config } = require("../server/SSB_server.js") const { renderStyledText } = require("../backend/renderStyledText") -const { renderMapEmbed, renderMapLocationVisitLabel } = require("./maps_view") +const { renderMapLocationUrl, renderMapEmbed, renderMapLocationVisitLabel } = require("./maps_view") const { resolvePhoto } = require("./inhabitants_view") const { renderReachChip, renderClearnetSelector, renderClearnetSwitch } = require("./clearnet_view") @@ -113,7 +112,7 @@ const renderJobOwnerActions = (job, returnTo) => { form( { method: "POST", action: `/jobs/status/${encodeURIComponent(job.id)}` }, input({ type: "hidden", name: "returnTo", value: returnTo }), - button({ class: isOpen ? "update-btn danger-btn" : "update-btn", type: "submit", name: "status", value: isOpen ? "CLOSED" : "OPEN" }, isOpen ? i18n.jobSetClosed : i18n.jobSetOpen) + button({ class: "status-btn", type: "submit", name: "status", value: isOpen ? "CLOSED" : "OPEN" }, isOpen ? i18n.jobSetClosed : i18n.jobSetOpen) ), form( { method: "GET", action: `/jobs/edit/${encodeURIComponent(job.id)}` }, @@ -178,7 +177,7 @@ const renderJobInfoTable = (job, opts = {}) => { return table({ class: "tribe-info-table jobs-info-table" }, ...rows) } -const renderJobExtraDetails = (job, opts = {}) => { +const renderJobExtraDetails = (job) => { const langLabel = String(job.languages || "").toUpperCase() const isExchange = String(job.job_type || "").toLowerCase() === "exchange" const skill = safeText(job.exchangeSkill) @@ -195,24 +194,9 @@ const renderJobExtraDetails = (job, opts = {}) => { pushRow(i18n.jobsHoursRequested || "Hours requested", `${Number(job.hoursRequested) || 0} h`) if (skill) pushRow(i18n.jobsExchangeSkill || "Skill", skill) } - return table({ class: "tribe-info-table jobs-info-table" }, ...rows) + return table({ class: "tribe-info-table jobs-info-table" }, ...rows, ...(opts.includeCreated ? renderCreatedRows(job.createdAt, job.author, job.updatedAt) : [])) } -const jobChipFor = (filter, censusJ, params = {}) => (x) => { - const m = x.key; - if (m === filter) return true; - if (m === "TOP") return censusJ.length > 0; - if (m === "MINE") return censusJ.some(j => String(j.author) === String(userId)); - if (m === "RECENT") return censusJ.length > 0; - if (m === "APPLIED") return censusJ.some(j => safeArr(j.subscribers).includes(userId)); - if (m === "REMOTE") return censusJ.some(j => String(j.location || "").toLowerCase() === "remote"); - if (m === "PRESENCIAL") return censusJ.some(j => String(j.location || "").toLowerCase() === "presencial"); - if (m === "FREELANCER" || m === "EMPLOYEE" || m === "EXCHANGE") return censusJ.some(j => String(j.job_type || "").toUpperCase() === m); - if (m === "OPEN" || m === "CLOSED") return censusJ.some(j => String(j.status || "OPEN").toUpperCase() === m); - if (m === "CV") return params.anyCVs === true; - return true; -}; - const renderJobList = exports.renderJobList = (jobs, filter, params = {}) => { const list = safeArr(jobs) @@ -245,22 +229,25 @@ const renderJobList = exports.renderJobList = (jobs, filter, params = {}) => { : `${fmtSalary(job.salary)} ECO` const isOwn = job.author && String(job.author) === String(userId) - const jobHref = `/jobs/${encodeURIComponent(job.id)}` return div({ class: "trending-card job-card" + (isOwn ? " own-content" : "") }, - renderCardTop(job.id, jobHref, { date: job.createdAt, chips, spread: params.spreadMap && params.spreadMap.get(job.id) || null, author: job.author, favKind: 'jobs', isFavorite: job.isFavorite, reportTitle: job.title, returnTo, deleteAction: isOwn ? contentDeleteAction("job", job.id) : null }), + div( + { class: "card-header activity-card-header" }, + span(), + 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, returnTo, deleteAction: isOwn ? contentDeleteAction("job", job.id) : null }) + ), div({ class: "card-section job-card-body" }, heroNode, div({ class: "tribe-card-body" }, div({ class: "shop-title-row" }, h2({ class: "tribe-card-title" }, - a({ href: jobHref }, safeText(job.title) || i18n.jobsTitle) + a({ href: `/jobs/${encodeURIComponent(job.id)}` }, safeText(job.title) || i18n.jobsTitle) ) ), + chips.length ? div({ class: "card-chips-row" }, ...chips) : null, div({ class: "price-chip" }, compensationText), div({ class: "tribe-card-members" }, span({ class: "tribe-members-count" }, `${i18n.jobSubscribers}: ${subs.length}`) - ), - null + ) ) ) ) @@ -320,7 +307,7 @@ const renderJobForm = (job = {}, mode = "create", spreadWarning = null, params = br(), label(i18n.jobImage), br(), - (isEdit && renderMediaLockNote(job)) || input({ type: "file", name: "image" }), + input({ type: "file", name: "image" }), br(), job.image ? renderMediaBlob(job.image) : null, br(), @@ -473,7 +460,20 @@ exports.jobsView = async (jobsOrCVs, filter = "ALL", params = {}) => { const emptyMod = moduleIsEmpty(Array.isArray(jobsOrCVs) ? jobsOrCVs : [], filter, "ALL", search || String(minSalary || "") || String(maxSalary || "")); const censusJ = Array.isArray(params.censusList) ? params.censusList : (Array.isArray(jobsOrCVs) ? jobsOrCVs : []); - const jobChip = jobChipFor(filter, censusJ, params); + const jobChip = (x) => { + const m = x.key; + if (m === filter) return true; + if (m === "TOP") return censusJ.length > 0; + if (m === "MINE") return censusJ.some(j => String(j.author) === String(userId)); + if (m === "RECENT") return censusJ.length > 0; + if (m === "APPLIED") return censusJ.some(j => safeArr(j.subscribers).includes(userId)); + if (m === "REMOTE") return censusJ.some(j => String(j.location || "").toLowerCase() === "remote"); + if (m === "PRESENCIAL") return censusJ.some(j => String(j.location || "").toLowerCase() === "presencial"); + if (m === "FREELANCER" || m === "EMPLOYEE" || m === "EXCHANGE") return censusJ.some(j => String(j.job_type || "").toUpperCase() === m); + if (m === "OPEN" || m === "CLOSED") return censusJ.some(j => String(j.status || "OPEN").toUpperCase() === m); + if (m === "CV") return params.anyCVs === true; + return true; + }; const filterObj = FILTERS.find((f) => f.key === filter) || FILTERS[0] const sectionTitle = i18n[filterObj.title] || i18n.jobsTitle @@ -632,10 +632,9 @@ exports.singleJobsView = async (job, filter = "ALL", comments = [], params = {}) job.industry ? a({ href: `/industry/${encodeURIComponent(job.industry)}` }, renderStateChip("whole", "🏭", String(i18n.industryTitle || "Industry").toUpperCase())) : null, renderLifespanChip(job.lifetime, i18n), renderEcoTax(job.msgSize, job.id), - renderReachChip(isClearnet, i18n, isClearnet ? clearnetItemHref('jobs', job.title, job.id) : null) + renderReachChip(isClearnet, i18n, isClearnet ? clearnetItemHref('jobs', job.title, job.id) : null), + isAuthor && canClearnet ? renderClearnetSwitch('jobs', job.rootId || job.id, isClearnet) : null ].filter(Boolean) - const updatedAt = job.updatedAt ? moment(job.updatedAt) : null - const showUpdated = updatedAt && updatedAt.isValid() && updatedAt.valueOf() !== new Date(job.createdAt).getTime() const nextVisibility = visibility === 'PUBLIC' ? 'HIDDEN' : 'PUBLIC' const visibilityRow = isAuthor @@ -656,24 +655,17 @@ exports.singleJobsView = async (job, filter = "ALL", comments = [], params = {}) for (const a of ownerActions) sideActions.push(a) const jobSide = div({ class: "tribe-side" }, - renderCardTop(job.id, `/jobs/${encodeURIComponent(job.id)}`, { arrow: false, - date: job.createdAt, - chips, - bylineExtra: [ - renderEditedChip(job.id, job.rootId), - showUpdated ? span({ class: "card-byline-time" }, `${i18n.jobsUpdatedAt}: ${updatedAt.format("YYYY/MM/DD HH:mm")}`) : null - ], - spread: params.spreads || null, author: job.author, favKind: 'jobs', isFavorite: job.isFavorite, reportTitle: job.title, returnTo, deleteAction: isAuthor ? contentDeleteAction("job", job.id) : null - }), + div({ class: "card-header activity-card-header" }, + renderContentActions(job.id, `/jobs/${encodeURIComponent(job.id)}`, { spread: params.spreads || null, author: job.author, favKind: 'jobs', isFavorite: job.isFavorite, reportTitle: job.title, returnTo, deleteAction: isAuthor ? contentDeleteAction("job", job.id) : null }) + ), div({ class: "shop-title-row" }, h2({ class: "tribe-card-title" }, safeText(job.title) || i18n.jobsTitle) ), - isAuthor && canClearnet ? renderClearnetSwitch('jobs', job.rootId || job.id, isClearnet) : null, + chips.length ? div({ class: "card-chips-row" }, ...chips) : null, job.image ? renderMediaBlob(job.image, { class: "tribe-detail-image" }) : null, - renderDetailShare(job.rootId || job.id, `/jobs/${encodeURIComponent(job.id)}`, job.title), - renderJobInfoTable(job, { includeVacants: true, includeSubscribers: false }), + renderJobInfoTable(job, { includeVacants: true, includeSubscribers: false, includeCreated: true }), div({ class: "tribe-card-members" }, span({ class: "tribe-members-count" }, `${i18n.jobSubscribers}: ${subs.length}`) ), @@ -700,9 +692,7 @@ exports.singleJobsView = async (job, filter = "ALL", comments = [], params = {}) renderJobSection(i18n.jobTasks, job.tasks), job.mapUrl ? div({ class: "job-section" }, renderMapEmbed(params.mapData, job.mapUrl)) : null, candidatesBlock ? candidatesBlock : null, - renderEngagement(job.rootId || job.id, - params.opinions ? renderOpinionsVoting('/jobs/opinions', job.rootId || job.id, params.opinions.opinions, `/jobs/${encodeURIComponent(job.rootId || job.id)}`, params.opinions.opinions_inhabitants) : null, - renderJobCommentsSection(job.id, returnTo, comments)) + renderJobCommentsSection(job.id, returnTo, comments) ) return template( @@ -717,7 +707,7 @@ exports.singleJobsView = async (job, filter = "ALL", comments = [], params = {}) input({ type: "hidden", name: "minSalary", value: String(params.minSalary ?? "") }), input({ type: "hidden", name: "maxSalary", value: String(params.maxSalary ?? "") }), input({ type: "hidden", name: "sort", value: safeText(params.sort || "recent") }), - ...FILTERS.filter(jobChipFor(filter, Array.isArray(params.censusList) ? params.censusList : [], params)).map((f) => + ...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.jobsCreateJob) diff --git a/src/views/karvan_view.js b/src/views/karvan_view.js new file mode 100644 index 0000000..17f6640 --- /dev/null +++ b/src/views/karvan_view.js @@ -0,0 +1,123 @@ +"use strict"; +// Vista del módulo KARVAN (mensajes temporales + WebRTC). El chat va por relay servidor (RAM, +// autodestrucción) y, si conectan pares, por data-channel WebRTC (instantáneo). El + `; + } return html; }; @@ -4391,6 +3968,7 @@ exports.commentView = async ( const renderMessage = (msg) => { const content = lodash.get(msg, "value.content", {}); const authorId = msg.value.author || "Anonymous"; + const createdAt = moment(msg.value.timestamp).format("YYYY/MM/DD HH:mm"); const mentionsText = content.text || ''; const isTribe = content.type === 'tribe-content'; const visitUrl = isTribe @@ -4406,20 +3984,23 @@ const renderMessage = (msg) => { const typeLabel = isTribe ? 'TRIBE' : 'POST'; return div({ class: 'card card-rpg mention-card' }, - renderCardTop(msg.key, visitUrl, { - author: authorId, - date: msg.value.timestamp, - chips: [div({ class: 'card-chips-row' }, - span({ class: 'pm-exposition-chip pm-exposition-whole' }, span({ class: 'pm-exposition-text' }, typeLabel)), - badge - )], - reportTitle: mentionsText - }), + div({ class: 'card-header' }, + h2({ class: 'card-label' }, `[${typeLabel}]`), + visitUrl + ? form({ method: 'GET', action: visitUrl, class: 'inline-form' }, + button({ type: 'submit', class: 'filter-btn' }, i18n.viewDetails || 'View details') + ) + : null + ), div({ class: 'card-body' }, div({ class: 'card-section' }, + badge, p({ class: 'post-text' }, ...renderStyledText(mentionsText || '[No content]')) - ), - renderEngagement(msg.key, null, null, { href: visitUrl, title: mentionsText }) + ) + ), + p({ class: 'card-footer' }, + span({ class: 'date-link' }, `${createdAt}`), + userLink(authorId) ) ); }; @@ -4451,7 +4032,7 @@ exports.privateView = async (messagesInput, filter, decrypted = null, notice = ' const qMatch = (m) => { if (!qNorm) return true const c = m.value.content || {} - const ids = [c.from, m.value.author, ...(Array.isArray(c.to) && !inboxBotOf(c) ? c.to : [])].filter(Boolean) + const ids = [c.from, m.value.author, ...(Array.isArray(c.to) ? c.to : [])].filter(Boolean) return [c.subject, c.text, ...ids, ...ids.map(id => userLinkLabel(id))] .some(v => String(v || '').toLowerCase().includes(qNorm)) } @@ -5103,7 +4684,7 @@ exports.privateView = async (messagesInput, filter, decrypted = null, notice = ' ), unreadShown.length ? form({ method: 'POST', action: '/inbox/read-all', class: 'inbox-vis-toggle' }, ...unreadShown.map(k => input({ type: 'hidden', name: 'keys', value: k })), - button({ type: 'submit', class: 'btn' }, `${i18n.inboxMarkRead || i18n.inboxMarkAllRead} (${unreadShown.length})`) + button({ type: 'submit', class: 'btn' }, `${i18n.inboxMarkAllRead} (${unreadShown.length})`) ) : null, (bulkDeletable && shownKeys.length) ? form({ method: 'POST', action: '/inbox/delete-many', class: 'inbox-vis-toggle inbox-bulk-delete' }, ...shownKeys.map(k => input({ type: 'hidden', name: 'keys', value: k })), @@ -5129,7 +4710,7 @@ exports.privateView = async (messagesInput, filter, decrypted = null, notice = ' const text = content.text || '' const sentAt = new Date(content.sentAt || msg.timestamp) const fromResolved = author - const toLinks = isNotification(msg) ? [linkAuthor(config.keys.id)] : Array.isArray(content.to) ? content.to.map(addr => linkAuthor(addr)) : [] + const toLinks = Array.isArray(content.to) ? content.to.map(addr => linkAuthor(addr)) : [] const level = threadLevel(subjectRaw) const msgSize = msgSizeBytes(msg) @@ -5309,13 +4890,22 @@ exports.threadView = ({ messages, spreadMap = null, hiddenRoot = false }) => { const rootMessage = messages[0]; const rootAuthorName = rootMessage.value.meta.author.name; + const needsPdfViewer = Array.isArray(messages) && messages.some((m) => { + const t = String(m?.value?.content?.type || "").toLowerCase(); + return t === "document"; + }); + const tpl = template( [`@${rootAuthorName}`], hiddenRoot ? p({ class: 'muted hidden-note' }, i18n.hiddenByYou) : null, div(thread(messages, spreadMap)) ); - return tpl; + return `${tpl}${ + needsPdfViewer + ? `` + : "" + }`; }; exports.supportersView = async ({ supporters, feed, name }) => { diff --git a/src/views/maps_view.js b/src/views/maps_view.js index 0399435..ff17622 100644 --- a/src/views/maps_view.js +++ b/src/views/maps_view.js @@ -4,13 +4,11 @@ const RAW_HTML = Symbol.for('oasis.rawHtml'); const { renderStyledText, renderStyledHtml, escapeHtml } = require("../backend/renderStyledText"); const moment = require("../server/node_modules/moment"); -const { renderCommentsSection } = require("./comments_view"); -const { clearnetItemHref, template, i18n, userLink, renderStateChip, renderLifespanChip, renderCardTop, renderSpreadEditWarning, renderDocumentActions, renderInviteQrCard, renderModuleStats, moduleIsEmpty, contentDeleteAction, paged, renderIcon, renderOpinionsVoting, renderEditedChip, renderMediaLockNote, renderEngagement, renderDetailShare } = require("./main_views"); +const { clearnetItemHref, template, i18n, userLink, renderStateChip, renderLifespanChip, renderSpreadButton, renderContentActions, renderSpreadEditWarning, renderDocumentActions, renderInviteQrCard, renderModuleStats, moduleIsEmpty, contentDeleteAction, paged, renderIcon } = require("./main_views"); const { renderEncryptedChip, renderReachChip, renderClearnetSelector, renderClearnetSwitch, renderTribeOriginChip } = require("./clearnet_view"); const { config } = require("../server/SSB_server.js"); const { renderMapHtml, resolveView, makeView, fitView, parseView, viewParam, parsePick, MIN_ZOOM, MAX_ZOOM } = require("../maps/map_renderer"); const { searchPlaces, nearestPlace } = require("../maps/map_data"); -const mapIsOpen = (m) => !!m && !m.tribeId && !m.encrypted && !m.contentEncrypted && String(m.mapType || '').toUpperCase() === 'OPEN'; const userId = config.keys.id; const safeArr = (v) => (Array.isArray(v) ? v : []); @@ -258,7 +256,7 @@ const renderMapForm = (filter, mapId, mapToEdit, params = {}) => { textarea({ maxlength: "5000", name: "markerLabel", placeholder: i18n.mapMarkerLabelPlaceholder, rows: "3" }, markerLabelVal), br(), br(), label(i18n.markerImageLabel || "Marker Image"), br(), - (isEdit && renderMediaLockNote(mapToEdit)) || input({ type: "file", name: "image", accept: "image/*" }), + input({ type: "file", name: "image", accept: "image/*" }), br(), br(), label(i18n.mapLatLabel), br(), input({ type: "text", name: "lat", placeholder: i18n.mapLatPlaceholder, value: latVal }), @@ -417,9 +415,12 @@ const renderMapCard = exports.renderMapCard = (mapObj, filter, params = {}) => { const isOwn = mapObj.author && String(mapObj.author) === String(userId); const href = origin ? origin.href : `/maps/${encodeURIComponent(mapObj.key)}?filter=${encodeURIComponent(filter)}`; return div({ class: "tribe-card" + (isOwn ? " own-content" : "") }, - renderCardTop(mapObj.key, href, origin - ? { author: mapObj.author, date: mapObj.createdAt, chips, reportTitle: mapObj.title } - : { date: mapObj.createdAt, chips, spread: params.spreadMap && params.spreadMap.get(mapObj.key) || null, author: mapObj.author, favKind: 'maps', isFavorite: mapObj.isFavorite, reportTitle: mapObj.title, returnTo, deleteAction: isOwn ? contentDeleteAction('map', mapObj.key) : undefined }), + div({ class: "card-header activity-card-header" }, + span(), + renderContentActions(mapObj.key, href, origin + ? { author: mapObj.author, reportTitle: mapObj.title } + : { spread: params.spreadMap && params.spreadMap.get(mapObj.key) || null, author: mapObj.author, favKind: 'maps', isFavorite: mapObj.isFavorite, reportTitle: mapObj.title, returnTo, deleteAction: isOwn ? contentDeleteAction('map', mapObj.key) : undefined }) + ), div({ class: "tribe-card-image-wrapper map-card-thumb" }, a({ href }, renderMap(thumbMarkers, null, 0, { thumb: true, singleZoom: 5 })) ), @@ -427,12 +428,12 @@ const renderMapCard = exports.renderMapCard = (mapObj, filter, params = {}) => { 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" }, ...renderStyledText(safeText(mapObj.description))) : null, div({ class: "tribe-card-members" }, span({ class: "tribe-members-count" }, `${i18n.mapMarkersTitle || "Markers"}: ${markerCount}`) - ), - null + ) ) ); }; @@ -521,32 +522,27 @@ exports.singleMapView = async (mapObj, filter = "all", params = {}) => { }) : null; const mapSide = div({ class: "tribe-side map-detail-side" }, - renderCardTop(mapObj.key, `/maps/${encodeURIComponent(mapObj.key)}`, { arrow: false, - date: mapObj.createdAt, - chips: [ - renderStateChip("whole", "🗺", String(mapObj.mapType || "").toUpperCase()), - renderEncryptedChip(i18n), - renderLifespanChip(mapObj.lifetime, i18n), - canClearnet ? renderReachChip(isClearnet, i18n, isClearnet ? mapClearnetHref(mapObj) : null) : null - ], - bylineExtra: [ - renderEditedChip(mapObj.key, mapObj.rootId), - mapObj.updatedAt && mapObj.updatedAt !== mapObj.createdAt - ? span({ class: "votations-comment-date" }, `${i18n.mapUpdatedAt}: ${moment(mapObj.updatedAt).format("YYYY/MM/DD HH:mm")}`) - : null - ], - author: mapObj.author, - favKind: 'maps', - isFavorite: mapObj.isFavorite, - spread: params.spreads || null, - returnTo, - reportTitle: mapObj.title, - deleteAction: String(mapObj.author) === String(userId) ? contentDeleteAction('map', mapObj.key) : undefined - }), + div({ class: "card-header activity-card-header" }, + renderContentActions(mapObj.key, `/maps/${encodeURIComponent(mapObj.key)}`, { + author: mapObj.author, + favKind: 'maps', + isFavorite: mapObj.isFavorite, + spread: params.spreads || null, + returnTo, + reportTitle: mapObj.title, + deleteAction: String(mapObj.author) === String(userId) ? contentDeleteAction('map', mapObj.key) : undefined + }) + ), div({ class: "shop-title-row" }, h2({ class: "tribe-card-title" }, mapObj.title || i18n.mapAllSectionTitle) ), - canClearnet && String(mapObj.author) === String(userId) ? renderClearnetSwitch("maps", mapObj.rootId || mapObj.key, isClearnet) : null, + div({ class: "card-chips-row" }, + renderStateChip("whole", "🗺", String(mapObj.mapType || "").toUpperCase()), + renderEncryptedChip(i18n), + renderLifespanChip(mapObj.lifetime, i18n), + canClearnet ? renderReachChip(isClearnet, i18n, isClearnet ? mapClearnetHref(mapObj) : null) : null, + canClearnet && String(mapObj.author) === String(userId) ? renderClearnetSwitch("maps", mapObj.rootId || mapObj.key, isClearnet) : null + ), safeText(mapObj.description) ? p({ class: "tribe-side-description" }, ...renderStyledText(safeText(mapObj.description))) : null, table({ class: "tribe-info-table jobs-info-table" }, tr( @@ -556,6 +552,13 @@ exports.singleMapView = async (mapObj, filter = "all", params = {}) => { 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), @@ -604,13 +607,14 @@ exports.singleMapView = async (mapObj, filter = "all", params = {}) => { })); const mapMain = div({ class: "tribe-main map-detail-main" }, - renderDetailShare(mapObj.rootId || mapObj.key, `/maps/${encodeURIComponent(mapObj.key)}`, mapObj.title), renderMarkersList(mapObj.markers, mapObj, { focus, view, viewHref, returnTo: pageReturnTo }), - renderMarkerForm(mapObj, returnTo, { ...params, filter }, tribeMembers, { mkLat, mkLng, view, focus }), - renderEngagement(mapObj.rootId || mapObj.key, - params.opinions ? renderOpinionsVoting('/maps/opinions', mapObj.rootId || mapObj.key, params.opinions.opinions, `/maps/${encodeURIComponent(mapObj.rootId || mapObj.key)}`, params.opinions.opinions_inhabitants) : null, - Array.isArray(params.comments) ? renderCommentsSection({ action: `/maps/${encodeURIComponent(mapObj.rootId || mapObj.key)}/comments`, comments: params.comments, returnTo: `/maps/${encodeURIComponent(mapObj.rootId || mapObj.key)}` }) : null - ) + p({ class: "card-footer" }, + span({ class: "date-link" }, `${moment(mapObj.createdAt).format("YYYY/MM/DD HH:mm")}`), + 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, filter }, tribeMembers, { mkLat, mkLng, view, focus }) ); return template(mapObj.title || i18n.mapTitle, diff --git a/src/views/market_view.js b/src/views/market_view.js index 0936b0f..99098c2 100644 --- a/src/views/market_view.js +++ b/src/views/market_view.js @@ -1,28 +1,25 @@ const { div, h2, p, section, button, form, a, span, textarea, br, input, label, select, option, img, table, tr, th, td, progress, video, audio, hr } = require("../server/node_modules/hyperaxe") const { renderZoomableImage } = require("./gallery_view") const { renderCommentsSection: renderSharedCommentsSection } = require("./comments_view"); -const { clearnetItemHref, template, i18n, userLink, renderStateChip, renderVisibilityChip, renderLifespanChip, renderEcoTax, renderSpreadEditWarning, renderOpinionsVoting, renderEngagement, renderCardTop, renderModuleStatsBy, moduleIsEmpty, renderEcoValueChip, renderWalletChip, contentDeleteAction, paged, renderEditedChip, renderMediaLockNote, renderDetailShare } = require("./main_views") -const { renderReachChip, renderClearnetSelector, renderClearnetSwitch, renderEncryptedChip } = require("./clearnet_view") +const { clearnetItemHref, template, i18n, userLink, renderStateChip, renderVisibilityChip, renderLifespanChip, renderEcoTax, renderSpreadButton, renderSpreadEditWarning, renderOpinionsVoting, renderEngagement , renderContentActions, renderModuleStatsBy, moduleIsEmpty, renderEcoValueChip, renderWalletChip, contentDeleteAction, paged } = require("./main_views") +const { renderReachChip, renderClearnetSelector, renderClearnetSwitch } = require("./clearnet_view") const opinionCategories = require("../backend/opinion_categories") const moment = require("../server/node_modules/moment") const { config } = require("../server/SSB_server.js") const { renderStyledText } = require("../backend/renderStyledText") -const { renderMapEmbedWithZoom } = require("./maps_view") +const { renderMapLocationUrl, renderMapEmbed, renderMapLocationVisitLabel, renderMapEmbedWithZoom } = require("./maps_view") -const renderMediaBlob = (value, fallbackSrc = null, attrs = {}, zoom = true) => { +const renderMediaBlob = (value, fallbackSrc = null, attrs = {}) => { if (!value) return fallbackSrc ? img({ src: fallbackSrc, ...attrs }) : null const s = String(value).trim() if (!s) return fallbackSrc ? img({ src: fallbackSrc, ...attrs }) : null - const picture = (src) => zoom - ? renderZoomableImage(src, { imgClass: attrs.class || 'post-image', alt: attrs.alt || '' }) - : img({ loading: 'lazy', src, class: attrs.class || 'post-image', alt: attrs.alt || '' }) - if (s.startsWith('&')) return picture(`/blob/${encodeURIComponent(s)}`) + if (s.startsWith('&')) return renderZoomableImage(`/blob/${encodeURIComponent(s)}`, { imgClass: attrs.class || 'post-image', alt: attrs.alt || '' }) const mVideo = s.match(/\[video:[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/) if (mVideo) return video({ controls: true, class: attrs.class || 'post-video', src: `/blob/${encodeURIComponent(mVideo[1])}`, preload: 'metadata' }) const mAudio = s.match(/\[audio:[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/) if (mAudio) return audio({ controls: true, class: attrs.class || 'post-audio', src: `/blob/${encodeURIComponent(mAudio[1])}` }) const mImg = s.match(/!\[[^\]]*\]\(\s*(&[^)\s]+\.sha256)\s*\)/) - if (mImg) return picture(`/blob/${encodeURIComponent(mImg[1])}`) + if (mImg) return renderZoomableImage(`/blob/${encodeURIComponent(mImg[1])}`, { imgClass: attrs.class || 'post-image', alt: attrs.alt || '' }) return fallbackSrc ? img({ src: fallbackSrc, ...attrs }) : null } @@ -411,7 +408,7 @@ exports.marketView = async (items, filter, itemToEdit = null, params = {}) => { br(), label(i18n.marketCreateFormImageLabel), br(), - (filter === "edit" && renderMediaLockNote(itemEdit)) || input({ type: "file", name: "image", id: "image" }), + input({ type: "file", name: "image", id: "image" }), br(), br(), label(i18n.marketItemStatus), @@ -478,12 +475,23 @@ exports.marketView = async (items, filter, itemToEdit = null, params = {}) => { filtered.length > 0 ? paged(filtered).map((item) => { const isOwner = String(item.seller) === String(userId) - const itemHref = `/market/${encodeURIComponent(item.id)}` return div({ class: "tribe-card market-tribe-card" }, - renderCardTop(item.id, itemHref, { - date: item.createdAt, - chips: [ + div({ class: "tribe-card-image-wrapper" }, + a({ href: `/market/${encodeURIComponent(item.id)}` }, + renderMediaBlob(item.image, '/assets/images/default-market.png', { class: 'tribe-card-hero-image' }) + ) + ), + div({ class: "card-header activity-card-header" }, + span(), + renderContentActions(item.id, `/market/${encodeURIComponent(item.id)}`, { spread: (params.spreadMap && params.spreadMap.get(item.id)) || null, author: item.seller, favKind: 'market', isFavorite: item.isFavorite, reportTitle: item.title, returnTo, deleteAction: isOwner ? contentDeleteAction('market', item.id) : undefined }) + ), + div({ class: "tribe-card-body" }, + div({ class: "shop-title-row" }, + h2({ class: "tribe-card-title" }, a({ href: `/market/${encodeURIComponent(item.id)}` }, item.title)) + ), + renderStarRating(item.opinions, Array.isArray(item.opinions_inhabitants) ? item.opinions_inhabitants.length : 0), + div({ class: "card-chips-row" }, String(item.visibility || "PUBLIC").toUpperCase() === "HIDDEN" ? renderVisibilityChip("HIDDEN", i18n) : null, renderStateChip("encrypted", "", String(item.item_type || "").toUpperCase()), item.item_status ? renderStateChip("whole", "", String(item.item_status).toUpperCase()) : null, @@ -491,21 +499,8 @@ exports.marketView = async (items, filter, itemToEdit = null, params = {}) => { item.industry ? a({ href: `/industry/${encodeURIComponent(item.industry)}` }, renderStateChip("whole", "🏭", String(i18n.industryTitle || "Industry").toUpperCase())) : null, renderLifespanChip(item.lifetime, i18n), item.clearnet === true ? renderReachChip(true, i18n, clearnetItemHref("market", item.title, item.id)) : null - ], - spread: (params.spreadMap && params.spreadMap.get(item.id)) || null, author: item.seller, favKind: 'market', isFavorite: item.isFavorite, reportTitle: item.title, returnTo, deleteAction: isOwner ? contentDeleteAction('market', item.id) : undefined - }), - div({ class: "tribe-card-image-wrapper" }, - a({ href: itemHref }, - renderMediaBlob(item.image, '/assets/images/default-market.png', { class: 'tribe-card-hero-image' }, false) - ) - ), - div({ class: "tribe-card-body" }, - div({ class: "shop-title-row" }, - h2({ class: "tribe-card-title" }, a({ href: itemHref }, item.title)) ), - renderStarRating(item.opinions, Array.isArray(item.opinions_inhabitants) ? item.opinions_inhabitants.length : 0), - div({ class: "price-chip" }, `${item.price} ECO`), - null + div({ class: "price-chip" }, `${item.price} ECO`) ) ) }) @@ -578,6 +573,7 @@ exports.singleMarketView = async (item, filter, comments = [], params = {}) => { td({ class: "tribe-info-label" }, labelText), td({ class: "tribe-info-value" }, valueNode) )) + pushRow(i18n.marketItemSeller, userLink(item.seller)) if (item.shopId && item.shopTitle) pushRow(i18n.marketShopLabel || "Shop", a({ href: `/shops/${encodeURIComponent(item.shopId)}`, class: "user-link" }, item.shopTitle)) if (item.deadline) pushRow(i18n.marketItemAvailable, moment(item.deadline).format("YYYY/MM/DD HH:mm")) @@ -601,17 +597,19 @@ exports.singleMarketView = async (item, filter, comments = [], params = {}) => { const marketActions = String(item.seller) === String(userId) ? renderMarketOwnerActions(item, `/market/${encodeURIComponent(item.id)}`) : [] const itemSide = div({ class: "tribe-side" }, - renderCardTop(item.id, `/market/${encodeURIComponent(item.id)}`, { arrow: false, date: item.createdAt, chips, bylineExtra: [renderEditedChip(item.id, item.rootId)], spread: params.spreads || null, author: item.seller, favKind: 'market', isFavorite: item.isFavorite, reportTitle: item.title, returnTo: `/market/${encodeURIComponent(item.id)}`, deleteAction: String(item.seller) === String(userId) ? contentDeleteAction('market', item.id) : undefined }), + div({ class: "card-header activity-card-header" }, + renderContentActions(item.id, `/market/${encodeURIComponent(item.id)}`, { spread: params.spreads || null, author: item.seller, favKind: 'market', isFavorite: item.isFavorite, reportTitle: item.title, returnTo: `/market/${encodeURIComponent(item.id)}`, deleteAction: String(item.seller) === String(userId) ? contentDeleteAction('market', item.id) : undefined }) + ), div({ class: "shop-title-row" }, h2({ class: "tribe-card-title" }, item.title) ), renderStarRating(item.opinions, Array.isArray(item.opinions_inhabitants) ? item.opinions_inhabitants.length : 0), + chips.length ? div({ class: "card-chips-row" }, ...chips) : null, String(item.seller) === String(userId) ? renderClearnetSwitch("market", item.rootId || item.id, isClearnet) : null, renderMediaBlob(item.image, "/assets/images/default-market.png"), - renderDetailShare(item.id, `/market/${encodeURIComponent(item.id)}`, item.title), + div({ class: "price-chip" }, `${item.price} ECO`), renderStockBar(item.stock, maxStock), - renderCountdownField(item), - infoRows.length ? table({ class: "tribe-info-table jobs-info-table" }, ...infoRows) : null, + table({ class: "tribe-info-table jobs-info-table" }, ...infoRows), tagsNode, visibilityRow, marketActions.length ? div({ class: "tribe-side-actions" }, ...marketActions) : null @@ -624,8 +622,8 @@ exports.singleMarketView = async (item, filter, comments = [], params = {}) => { p({ class: "tribe-side-description" }, ...renderStyledText(item.description)) ) : null, + renderCountdownField(item), item.mapUrl ? div({ class: "job-section" }, renderMapEmbedWithZoom(params.mapData, item.mapUrl, `/market/${encodeURIComponent(item.id)}`, params.zoom)) : null, - showBuy ? null : div({ class: "price-chip" }, `${item.price} ECO`), item.item_type === "auction" ? div( { class: "auction-info job-section" }, @@ -652,46 +650,20 @@ exports.singleMarketView = async (item, filter, comments = [], params = {}) => { : null, showBuy ? div( - { class: "job-section buy-box" }, + { class: "market-item actions" }, form( { method: "POST", action: `/market/buy/${encodeURIComponent(item.id)}`, class: "market-buy-form" }, input({ type: "hidden", name: "returnTo", value: "/inbox?filter=sent" }), - div({ class: "title-with-chip" }, label(i18n.shopBuyDeliveryAddress), renderEncryptedChip(i18n)), - textarea({ maxlength: "5000", name: "deliveryAddress", rows: 3, placeholder: i18n.shopBuyDeliveryAddressPlaceholder, ...(item.includesShipping ? { required: true } : {}) }), - br(), - label(i18n.shopBuyContact), - br(), - input({ type: "text", name: "contact", placeholder: i18n.shopBuyContactPlaceholder }), - br(), - label(i18n.shopBuyNotes), - br(), - textarea({ maxlength: "5000", name: "notes", rows: 2, placeholder: i18n.shopBuyNotesPlaceholder }), - br(), - div({ class: "price-chip" }, `${item.price} ECO`), - br(), - br(), button({ class: "buy-btn", type: "submit" }, i18n.marketActionsBuy) ) ) : null, - Array.isArray(params.orders) && params.orders.length - ? div({ class: "job-section" }, - h2({ class: "job-section-title" }, i18n.shopOrdersTitle), - ...params.orders.map(o => div({ class: "shop-order-card card-section" }, - div({ class: "card-chips-row" }, renderEncryptedChip(i18n)), - div({ class: "card-field" }, span({ class: "card-label" }, `${i18n.shopOrderBuyer}:`), userLink(o.buyer)), - o.deliveryAddress ? div({ class: "card-field" }, span({ class: "card-label" }, `${i18n.shopBuyDeliveryAddress}:`), span({ class: "card-value" }, String(o.deliveryAddress))) : null, - o.contact ? div({ class: "card-field" }, span({ class: "card-label" }, `${i18n.shopBuyContact}:`), span({ class: "card-value" }, String(o.contact))) : null, - o.notes ? div({ class: "card-field" }, span({ class: "card-label" }, `${i18n.shopBuyNotes}:`), span({ class: "card-value" }, String(o.notes))) : null, - p({ class: "card-footer" }, span({ class: "date-link" }, moment(o.ts).format("YYYY/MM/DD HH:mm"))) - )) - ) - : null, renderEngagement(item.id, String(item.seller) !== String(userId) && item.purchasedByViewer ? renderOpinionsVoting("/market/opinions", item.id, item.opinions, returnTo, item.opinions_inhabitants) : null, - renderMarketCommentsSection(item.id, returnTo, comments)) + renderMarketCommentsSection(item.id, returnTo, comments) + ) ) return div({ class: "tribe-details" }, itemSide, itemMain) diff --git a/src/views/melody_view.js b/src/views/melody_view.js index 30d21b6..fc82391 100644 --- a/src/views/melody_view.js +++ b/src/views/melody_view.js @@ -1,7 +1,8 @@ "use strict"; const { div, h2, p, section, audio, source, span, a, form, input, button, br, textarea, label } = require("../server/node_modules/hyperaxe"); -const { template, i18n, userLink, renderCardTop, renderEngagement, paged } = require("./main_views"); +const moment = require("../server/node_modules/moment"); +const { template, i18n, userLink, renderEcoTax, renderContentActions, paged } = require("./main_views"); const renderSequenceList = (sequence, availableIds = null) => { if (!sequence || sequence.length === 0) { @@ -97,12 +98,17 @@ const renderBcsList = (bcsAudios) => { } return div({ class: "melody-bcs-list" }, bcsAudios.map(item => div({ class: "melody-bcs-card" }, - renderCardTop(item.key, `/audios/${encodeURIComponent(item.key)}`, { date: item.createdAt, author: item.author, favKind: 'audios', isFavorite: item.isFavorite, torrentFrom: { blobId: item.url, name: item.title }, reportTitle: item.title }), - item.title - ? div({ class: "melody-bcs-head" }, - h2({ class: "melody-bcs-title" }, item.title) - ) - : null, + div({ class: "card-header activity-card-header" }, + span(), + renderContentActions(item.key, `/audios/${encodeURIComponent(item.key)}`, { author: item.author, favKind: 'audios', isFavorite: item.isFavorite, torrentFrom: { blobId: item.url, name: item.title }, reportTitle: item.title }) + ), + div({ class: "melody-bcs-head" }, + item.title ? h2({ class: "melody-bcs-title" }, item.title) : null, + div({ class: "melody-bcs-meta" }, + userLink(item.author), + span({ class: "melody-bcs-date" }, moment(item.createdAt).format("YYYY/MM/DD HH:mm")) + ) + ), item.url ? div({ class: "audio-container melody-bcs-player" }, audio({ controls: true, preload: "metadata", src: `/blob/${encodeURIComponent(item.url)}` }), @@ -115,8 +121,7 @@ const renderBcsList = (bcsAudios) => { button({ type: "submit", class: "filter-btn" }, i18n.audioTranscodeButton || "TRANSCODE") ) ), - item.description ? p({ class: "melody-bcs-desc" }, item.description) : null, - null + item.description ? p({ class: "melody-bcs-desc" }, item.description) : null )) ); }; diff --git a/src/views/mentions_view.js b/src/views/mentions_view.js index 30acc17..3ef4313 100644 --- a/src/views/mentions_view.js +++ b/src/views/mentions_view.js @@ -1,6 +1,7 @@ const { div, h2, p, section, button, form, input, span } = require("../server/node_modules/hyperaxe"); -const { template, i18n, renderCardTop, renderShareButton, renderCardMetaRow, renderModuleStats, CONTENT_SPREADABLE, CONTENT_TORRENTABLE, paged } = require("./main_views"); -const { getViewDetailsAction, commentHref, commentLabel } = require("./activity_view"); +const { template, i18n, userLink, renderContentActions, renderModuleStats, CONTENT_SPREADABLE, CONTENT_TORRENTABLE, paged } = require("./main_views"); +const { getViewDetailsAction } = require("./activity_view"); +const moment = require("../server/node_modules/moment"); const { config } = require("../server/SSB_server.js"); const { renderStyledText } = require("../backend/renderStyledText"); @@ -11,43 +12,45 @@ const excerpt = (text, max = 320) => { return s.length > max ? s.slice(0, max) + "…" : s; }; -const typeLabel = (type) => (type === 'comment' ? i18n.typeComment : i18n['type' + String(type || '').charAt(0).toUpperCase() + String(type || '').slice(1)]) || String(type || '').toUpperCase(); +const typeLabel = (type) => i18n['type' + String(type || '').charAt(0).toUpperCase() + String(type || '').slice(1)] || String(type || '').toUpperCase(); const renderMentionCard = (item, readKeys, params = {}) => { - const href = item.type === 'comment' - ? commentHref(item.comment || { rootId: item.content && item.content.root }, item.id) - : getViewDetailsAction(item.type, { id: item.id, key: item.id, author: item.author, content: item.content }); + const href = getViewDetailsAction(item.type, { id: item.id, key: item.id, author: item.author, content: item.content }); const isOwn = String(item.author) === String(userId); const read = readKeys.has(String(item.key)); return div({ class: "trending-card mention-card" + (isOwn ? " own-content" : "") }, - renderCardTop(item.id, href, { - date: item.createdAt, - chips: [div({ class: 'card-chips-row' }, + 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' }, item.comment ? String(commentLabel(item.comment)).toUpperCase() : typeLabel(item.type)) + span({ class: 'pm-exposition-text' }, typeLabel(item.type)) ), read ? null : span({ class: 'pm-exposition-chip pm-unread-chip' }, span({ class: 'pm-exposition-text' }, i18n.inboxUnreadChip)) - )], - author: item.author, - reportTitle: item.title || item.text, - spread: CONTENT_SPREADABLE.has(item.type) ? ((params.spreadMap && params.spreadMap.get(item.id)) || null) : undefined, - ...(item.type === 'torrent' || item.type === 'file' ? { spreadTitle: i18n.seedAction } : {}), - ...(CONTENT_TORRENTABLE.has(item.type) && item.content && typeof item.content.url === 'string' && item.content.url.startsWith('&') - ? { torrentFrom: { blobId: item.content.url, name: item.content.title || item.content.fileName || '' } } - : {}) - }), + ), + renderContentActions(item.id, href, { + author: item.author, + reportTitle: item.title || item.text, + spread: CONTENT_SPREADABLE.has(item.type) ? ((params.spreadMap && params.spreadMap.get(item.id)) || null) : undefined, + ...(item.type === 'torrent' || item.type === 'file' ? { spreadTitle: i18n.seedAction } : {}), + ...(CONTENT_TORRENTABLE.has(item.type) && item.content && typeof item.content.url === 'string' && item.content.url.startsWith('&') + ? { torrentFrom: { blobId: item.content.url, name: item.content.title || item.content.fileName || '' } } + : {}) + }) + ), 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" }, ...renderStyledText(excerpt(item.text))) : null, + p({ class: "card-footer" }, + span({ class: "date-link" }, `${moment(item.createdAt).format("YYYY/MM/DD HH:mm")}`), + userLink(item.author) + ), div({ class: "pm-actions mention-actions" }, form({ method: "POST", action: `/mentions/${read ? 'unread' : 'read'}/${encodeURIComponent(item.key)}`, class: "pm-action-form" }, button({ type: "submit", class: "pm-btn read-btn" }, String(read ? i18n.inboxMarkUnread : i18n.inboxMarkRead).toUpperCase()) ) ) - ), - renderCardMetaRow(renderShareButton(item.id, href, item.title || item.text)) + ) ); }; @@ -80,7 +83,7 @@ exports.mentionsView = async (items = [], filter = 'ALL', params = {}) => { renderModuleStats(items.length), unreadShown.length ? form({ method: "POST", action: "/mentions/read-all", class: "inbox-vis-toggle" }, ...unreadShown.map(k => input({ type: "hidden", name: "keys", value: k })), - button({ type: "submit", class: "btn" }, `${i18n.inboxMarkRead || i18n.inboxMarkAllRead} (${unreadShown.length})`) + button({ type: "submit", class: "btn" }, `${i18n.inboxMarkAllRead} (${unreadShown.length})`) ) : null, form({ method: "GET", action: "/mentions", class: "filter-box" }, input({ type: "hidden", name: "filter", value: filter }), diff --git a/src/views/mobile_icons.js b/src/views/mobile_icons.js deleted file mode 100644 index 20d44d7..0000000 --- a/src/views/mobile_icons.js +++ /dev/null @@ -1,107 +0,0 @@ -"use strict"; -// Iconos del lanzador y de la barra de abajo (rama movil). -// -// Antes eran letras del alfabeto vai (ꔙ ꕿ ꖒ...): no decian nada de lo que abren, -// dependian de que el movil trajera la fuente y Karvan y Nodos llevaban casi el -// mismo reloj de arena. Estos son SVG de trazo en rejilla de 24, el mismo estilo -// que los ACTION_ICONS de las tarjetas (main_views.js), y se pintan con -// currentColor, asi que cada tema les pone su color. - -const RAW_HTML = Symbol.for('oasis.rawHtml'); -const { span } = require("../server/node_modules/hyperaxe"); - -const ICONS = { - activity: '', - feed: '', - chats: '', - rooms: '', - forum: '', - pads: '', - wiki: '', - blogs: '', - opinions: '', - mentions: '', - tags: '', - trending: '', - maps: '', - graphos: '', - inhabitants: '', - fediverse: '', - images: '', - videos: '', - audios: '', - podcasts: '', - documents: '', - files: '', - bookmarks: '', - torrents: '', - games: '', - pixelia: '', - melody: '', - events: '', - calendars: '', - tasks: '', - campaigns: '', - mailing: '', - votes: '', - polls: '', - reports: '', - tribes: '', - emergencies: '', - school: '', - larp: '', - parliament: '', - courts: '', - market: '', - shops: '', - jobs: '', - projects: '', - industry: '', - housing: '', - logistics: '', - banking: '', - transfers: '', - profile: '', - agenda: '', - favorites: '', - phone: '', - cv: '', - wallet: '', - logs: '', - invites: '', - peers: '', - bar: '', - backup: '', - cipher: '', - ai: '', - stats: '', - blockexplorer: '', - modules: '', - settings: '', - search: '', - inbox: '', - publish: '', - data: '', - mic: '', - camera: '', - palette: '', - hangup: '' -}; - -// la clave sale de la ruta: /settings/bottombar es "bar" -const iconKeyOf = (href) => { - const h = String(href || '').replace(/^\//, '').split(/[?#]/)[0]; - if (h === 'settings/bottombar') return 'bar'; - return h.split('/')[0]; -}; - -const svg = (key) => ICONS[key] - ? `` - : ''; - -// span con el SVG dentro; si no hay icono para esa clave, el caracter de antes -const iconNode = (cls, key, fallback) => ICONS[key] - ? span({ class: cls + ' has-svg', 'aria-hidden': 'true', [RAW_HTML]: svg(key) }) - : span({ class: cls, 'aria-hidden': 'true' }, fallback || ''); - -module.exports = { ICONS, iconKeyOf, svg, iconNode }; diff --git a/src/views/mobile_modules_view.js b/src/views/mobile_modules_view.js deleted file mode 100644 index a58f568..0000000 --- a/src/views/mobile_modules_view.js +++ /dev/null @@ -1,90 +0,0 @@ -"use strict"; -// Pantalla de Modulos del movil (la sirve el router propio, fork_routes.js). -// -// Los modulos agrupados como en el lanzador, cada uno una fila que se toca entera con -// un interruptor, y Guardar siempre a la vista. La tabla de upstream eran 52 filas -// con casillas de 16px, unas 11 pantallas. Va en un fichero propio para no tocar -// modules_view.js, que es del dev (docs/FRONTERA_UPSTREAM.md). -// -// El formulario envia a la misma ruta de upstream (/save-modules) con los mismos -// nombres de campo (Form), asi que guardar sigue siendo cosa suya. - -const { button, div, form, h2, h3, input, label, p, section, span } = require("../server/node_modules/hyperaxe"); -const { getConfig } = require('../configs/config-manager.js'); -const { iconNode } = require('./mobile_icons'); - -// clave de i18n de cada modulo (modulesLabel / modulesDescription) -const STEMS = { - agenda: 'Agenda', ai: 'AI', aiNav: 'AINav', emergencies: 'Emergencies', mailing: 'Mailing', rooms: 'Rooms', - phone: 'Phone', logistics: 'Logistics', podcasts: 'Podcasts', campaigns: 'Campaigns', audios: 'Audios', banking: 'Banking', - bookmarks: 'Bookmarks', calendars: 'Calendars', chats: 'Chats', cipher: 'Cipher', courts: 'Courts', dev: 'Dev', - docs: 'Docs', events: 'Events', favorites: 'Favorites', blogs: 'Blogs', fediverse: 'Fediverse', feed: 'Feed', - files: 'Files', forum: 'Forum', games: 'Games', graphos: 'Graphos', housing: 'Housing', images: 'Images', - industry: 'Industry', invites: 'Invites', jobs: 'Jobs', larp: 'Larp', backup: 'Backup', - logs: 'Logs', maps: 'Map', market: 'Market', melody: 'Melody', opinions: 'Opinions', pads: 'Pads', - parliament: 'Parliament', pixelia: 'Pixelia', polls: 'Polls', projects: 'Projects', reports: 'Reports', school: 'School', - shops: 'Shops', tags: 'Tags', tasks: 'Tasks', torrents: 'Torrents', transfers: 'Transfers', trending: 'Trending', - tribes: 'Tribes', videos: 'Videos', votes: 'Votations', wallet: 'Wallet', wiki: 'Wiki' -}; -// los que no van en el movil (los apaga tambien /save-modules) -const MOBILE_UNAVAILABLE = ['ai', 'aiNav', 'dev', 'games', 'logs', 'maps', 'pixelia']; - -const moduleLabel = (i18n, name) => i18n[`modules${STEMS[name]}Label`] || name; - -const mobileModulesView = () => { - const { template, i18n, LAUNCHER_GROUPS } = require('./main_views'); - const config = getConfig().modules || {}; - const visible = Object.keys(STEMS) - .filter(name => !MOBILE_UNAVAILABLE.includes(name)) - .map(name => ({ name, label: moduleLabel(i18n, name), description: i18n[`modules${STEMS[name]}Description`] || '' })) - .sort((x, y) => String(x.label).localeCompare(String(y.label), undefined, { sensitivity: 'base' })); - const isOn = (m) => config[`${m.name}Mod`] === 'on'; - - // grupos del lanzador; Industria va con la economia y lo demas, al final - const groupOf = {}; - (LAUNCHER_GROUPS || []).forEach(g => g.mods.forEach(m => { if (m.mod) groupOf[m.mod.replace(/Mod$/, '')] = g; })); - const economy = (LAUNCHER_GROUPS || []).find(g => g.id === 'economy'); - if (economy && !groupOf.industry) groupOf.industry = economy; - const groups = []; - const byId = {}; - visible.forEach(mod => { - const g = groupOf[mod.name] || { id: 'other', titleKey: 'menuTools' }; - if (!byId[g.id]) { byId[g.id] = { g, mods: [] }; groups.push(byId[g.id]); } - byId[g.id].mods.push(mod); - }); - const order = (LAUNCHER_GROUPS || []).map(g => g.id); - const rank = (id) => { const i = order.indexOf(id); return i === -1 ? order.length : i; }; - groups.sort((x, y) => rank(x.g.id) - rank(y.g.id)); - - return template( - i18n.modules, - section(div({ class: 'tags-header module-header-line' }, - h2(i18n.modulesViewTitle), - p(String(i18n.modulesOnCount || '{on} of {total} sections on') - .replace('{on}', visible.filter(isOn).length).replace('{total}', visible.length)) - )), - section( - form({ action: '/save-modules', method: 'post', class: 'module-switches' }, - ...groups.map(({ g, mods }) => div({ class: 'module-group' }, - h3({ class: 'module-group-title' }, i18n[g.titleKey] || g.id), - ...mods.map(mod => label({ class: 'module-switch', for: `${mod.name}Mod` }, - iconNode('module-switch-ico', mod.name), - span({ class: 'module-switch-text' }, - span({ class: 'module-switch-name' }, mod.label), - span({ class: 'module-switch-desc' }, mod.description) - ), - input({ - type: 'checkbox', role: 'switch', id: `${mod.name}Mod`, name: `${mod.name}Form`, value: 'on', class: 'switch-input', - checked: isOn(mod) ? true : undefined - }) - )) - )), - div({ class: 'module-save-bar' }, - button({ type: 'submit', class: 'create-button' }, i18n.saveSettings) - ) - ) - ) - ); -}; - -module.exports = { mobileModulesView, MOBILE_UNAVAILABLE, STEMS }; diff --git a/src/views/mobile_pubbar.js b/src/views/mobile_pubbar.js deleted file mode 100644 index 8c5f6e7..0000000 --- a/src/views/mobile_pubbar.js +++ /dev/null @@ -1,114 +0,0 @@ -"use strict"; -// Barra de "uniendote a un pub" (rama movil). -// -// Al unirse a un pub, el dev acepta la invitacion y arranca una descarga de la red -// desde el pub (backupModel.startBootstrap: bajar la copia y guardarla en el movil). -// Su pantalla de bienvenida lo enseñaba recargandose entera cada 5 segundos y, si -// fallaba, solo decia el error en ingles. Aqui va una barra fija encima de la barra -// de abajo, en todas las pantallas, que dice en claro que esta pasando y por que -// falla. La pinta hive_views.js (la barra de abajo es nuestra) en cada pantalla. -// -// El estado lo lee de backupModel.restoreStatus(), que le pasa fork_routes.js al -// montarse (es una funcion del dev; no se cambia nada suyo). - -const { button, div, form, p, progress, span } = require("../server/node_modules/hyperaxe"); - -const t = () => require("./main_views").i18n; - -let source = null; -const setSource = (backupModel) => { source = backupModel; }; - -// lo terminado se enseña un rato y se puede cerrar -const SHOW_DONE_MS = 15 * 60 * 1000; -let dismissedAt = null; -const dismiss = () => { const j = source && source.restoreStatus(); dismissedAt = j && j.finishedAt; }; - -// "net:pub.example.org:8008~shs:..." -> "pub.example.org" -const hostOf = (address) => { - const m = String(address || "").match(/^[a-z]+:([^:~]+)(?::\d+)?/i); - return m ? m[1] : String(address || "").slice(0, 40); -}; - -const errorKind = (err) => { - const e = String(err || ""); - if (/snapshot not available/i.test(e)) return "nosnap"; - if (/ECONNREFUSED|ETIMEDOUT|ENOTFOUND|EHOSTUNREACH|ENETUNREACH|EAI_AGAIN|timed? ?out|socket|connect/i.test(e)) return "net"; - return "other"; -}; - -const fill = (s, vars) => String(s || "").replace(/\{(\w+)\}/g, (_, k) => (vars[k] != null ? String(vars[k]) : "")); -const num = (n) => Number(n || 0).toLocaleString("es-ES"); - -// el estado, ya en palabras: { kind, text, hint, pct, running } -const status = () => { - const job = source && typeof source.restoreStatus === "function" ? source.restoreStatus() : null; - if (!job) return null; - const i18n = t(); - if (job.source !== "snapshot") return backupStatus(job, i18n); - const pub = hostOf(job.address); - const pr = job.progress || {}; - if (job.running) { - const pct = Math.max(0, Math.min(100, Number(pr.percent) || 0)); - if (job.phase === "restore") { - return { kind: "restore", running: true, pct, - text: fill(i18n.pubBarRestore || "Saving to your phone: {pct}% · {n} messages", { pct: Math.round(pct), n: num(pr.messages) }), - hint: i18n.pubBarHint || "You can keep using the app meanwhile." }; - } - const tier = job.tier === "full" ? (i18n.pubBarTierFull || "everything before") : (i18n.pubBarTierRecent || "the latest"); - return { kind: "download", running: true, pct: null, - text: fill(i18n.pubBarDownload || "Downloading the network from {pub} ({tier})…", { pub, tier }), - hint: i18n.pubBarHint || "You can keep using the app meanwhile." }; - } - if (job.finishedAt && job.finishedAt === dismissedAt) return null; - if (job.finishedAt && Date.now() - Date.parse(job.finishedAt) > SHOW_DONE_MS) return null; - if (job.error) { - const k = errorKind(job.error); - const key = { nosnap: "pubBarErrNoSnap", net: "pubBarErrNet", other: "pubBarErrOther" }[k]; - const fallback = { - nosnap: "You joined {pub}, but it offers no copy to download at once. Posts will arrive little by little while the app is open.", - net: "Cannot reach {pub}. Check that you have Internet (wifi or data) and try again later; if it keeps failing, the pub may be down.", - other: "The download from {pub} stopped ({error}). You are in; the rest will arrive little by little." - }[k]; - return { kind: k === "nosnap" ? "warn" : "error", running: false, pct: null, - text: fill(i18n[key] || fallback, { pub, error: String(job.error).slice(0, 80) }), hint: "" }; - } - const r = job.result || {}; - return { kind: "done", running: false, pct: 100, - text: fill(i18n.pubBarDone || "You are in {pub}: {n} messages downloaded.", { pub, n: num(r.messages) }), hint: "" }; -}; - -// restaurar una copia desde archivo (pantalla de Copias): la misma barra -const backupStatus = (job, i18n) => { - const pr = job.progress || {}; - if (job.running) { - const pct = Math.max(0, Math.min(100, Number(pr.percent) || 0)); - return { kind: "restore", running: true, pct, - text: fill(i18n.pubBarBackupRestore || "Restoring your backup: {pct}% · {n} messages", { pct: Math.round(pct), n: num(pr.messages) }), - hint: i18n.pubBarHint || "You can keep using the app meanwhile." }; - } - if (job.finishedAt && job.finishedAt === dismissedAt) return null; - if (job.finishedAt && Date.now() - Date.parse(job.finishedAt) > SHOW_DONE_MS) return null; - if (job.error) return { kind: "error", running: false, pct: null, hint: "", - text: fill(i18n.pubBarBackupErr || "The backup could not be restored: {error}. Check the file and the password.", { error: String(job.error).slice(0, 80) }) }; - return { kind: "done", running: false, pct: 100, hint: "", - text: fill(i18n.pubBarBackupDone || "Backup restored: {n} messages.", { n: num((job.result || {}).messages) }) }; -}; - -// la barra, solo si hay algo que contar. Sin JS (la CSP de la 1.3.5 no deja ninguno) -// se pone al dia al cambiar de pantalla -const pubBar = () => { - const i18n = t(); - const s = status(); - if (!s) return null; - return div({ id: "pub-bar", class: `pub-bar is-${s.kind}`, role: "status", "data-running": s.running ? "1" : "0" }, - div({ class: "pub-bar-body" }, - p({ class: "pub-bar-text" }, s.text), - s.hint ? p({ class: "pub-bar-hint" }, s.hint) : null, - progress({ class: "pub-bar-meter", max: "100", ...(s.pct != null ? { value: String(s.pct) } : {}) }) - ), - form({ method: "POST", action: "/mobile/pub-status/dismiss", class: "pub-bar-close" }, - button({ type: "submit", "aria-label": i18n.pubBarClose || "Close" }, "✕")) - ); -}; - -module.exports = { setSource, status, dismiss, pubBar, errorKind, hostOf }; diff --git a/src/views/mobile_settings.js b/src/views/mobile_settings.js deleted file mode 100644 index ea1b6fe..0000000 --- a/src/views/mobile_settings.js +++ /dev/null @@ -1,276 +0,0 @@ -"use strict"; -// Ajustes del movil: los seis accesos de arriba y lo que hay detras de cada uno. -// -// Lo que se elige en la bienvenida (identidad, perfil de uso, portada, comunidad y -// saludo) tiene que poder cambiarse despues sin buscarlo. Arriba de Ajustes va una -// rejilla con esos cinco y un sexto, Colores; cada acceso baja a su bloque, y cada -// bloque guarda al tocar, igual que en la bienvenida. -// -// Identidad y saludo llevan a las pantallas del dev que ya lo hacen (/profile/edit y -// /feed/create): no se reenvia nada a sus rutas ni se toca lo que contestan. Colores -// es propio: /settings/colors y la hoja /mobile-colors.css, que va la ultima. -// -// Los colores son las variables --a26-* del skin Activista. Los skins (workflows) -// quitan lo que sobra y los colores lo dejan a gusto de cada cual; los de serie son -// los del dev. - -const crypto = require("crypto"); -const { a, button, details, div, form, img, input, label, p, section, span, summary, h2 } = require("../server/node_modules/hyperaxe"); -const { iconNode } = require("./mobile_icons"); - -const t = () => require("./main_views").i18n; -const cfgNow = () => { try { return require("../configs/config-manager.js").getConfig() || {}; } catch (_) { return {}; } }; - -// ---------- colores ---------- - -// [clave, fondo, texto, acento, extras]; la primera es la de serie y calca la APK -// oficial del dev, medida con el inspector en su 1.3.5 (fondo #121212, texto oro -// #ffd700, titulos, etiquetas y resalte naranja #ffa300 con texto #111, tarjetas #1e1e1e -// sin borde y con raya naranja a la izquierda, bordes de boton #555, campos #2a2a2a), para -// que al abrir no parezca otra app. Los extras pisan lo que si no se calcula. -// Los nombres, en i18n_fork (palette_) -const PALETTES = [ - ["oasis", "#121212", "#ffd700", "#ffa300", { - onAccent: "#111111", card: "#1e1e1e", title: "#ffa300", line: "#2a2a2a", edge: "#555555", - muted: "#ffd700", soft: "#2a2a2a", tint: "#1a1400", field: "#2a2a2a", - bar: "#161616", barBorder: "1px solid #2a2a2a", - cardBorder: "0", cardShadow: "inset 3px 0 0 0 #ffa300" }], - ["acampada", "#ffffff", "#000000", "#00e5ff"], - ["noche", "#101114", "#f2f2f2", "#00e5ff"], - ["bosque", "#f3f7ef", "#13231a", "#3ccf7e"], - ["lila", "#faf7ff", "#1d1430", "#b48cff"], - ["brasa", "#fff8f2", "#1a0f0a", "#ff6a2b"], - ["papel", "#f5efe3", "#2a2420", "#ff4f8b"], - ["contraste", "#000000", "#ffffff", "#ffff00"] -]; -// claves de antes que ahora se llaman de otra forma -const RENAMED = { oro: "oasis" }; -const DEFAULT = PALETTES[0]; - -const HEX = /^#[0-9a-f]{6}$/i; -const rgb = (hex) => [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16)); -const toHex = (c) => "#" + c.map(v => Math.round(Math.max(0, Math.min(255, v))).toString(16).padStart(2, "0")).join(""); -const mix = (x, y, k) => { const p = rgb(x), q = rgb(y); return toHex(p.map((v, i) => v + (q[i] - v) * k)); }; -const lum = (hex) => { - const [r, g, b] = rgb(hex).map(v => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }); - return 0.2126 * r + 0.7152 * g + 0.0722 * b; -}; -const contrast = (x, y) => { const p = lum(x), q = lum(y); return (Math.max(p, q) + 0.05) / (Math.min(p, q) + 0.05); }; -// negro o blanco, el que mas se lea encima -const readableOn = (hex) => contrast(hex, "#000000") >= contrast(hex, "#ffffff") ? "#000000" : "#ffffff"; - -// lo guardado, ya comprobado; sin nada guardado, la de serie -const colorState = (cfg) => { - const c = (cfg || cfgNow()).mobileColors; - if (!c || typeof c !== "object" || ![c.bg, c.fg, c.accent].every(v => HEX.test(String(v || "")))) { - return { preset: DEFAULT[0], bg: DEFAULT[1], fg: DEFAULT[2], accent: DEFAULT[3], chosen: false, fixed: false }; - } - const key = RENAMED[c.preset] || c.preset; - const preset = key === "custom" || PALETTES.some(x => x[0] === key) ? key : "custom"; - return { preset, bg: c.bg.toLowerCase(), fg: c.fg.toLowerCase(), accent: c.accent.toLowerCase(), chosen: true, fixed: c.fixed === true }; -}; - -// lo que llega del formulario -> lo que se guarda. Un texto que no se lee sobre el -// fondo se cambia por negro o blanco y se avisa (fixed) -const colorsFromBody = (body) => { - const pick = String(body.preset || ""); - const pal = PALETTES.find(x => x[0] === pick); - if (pal) return { preset: pal[0], bg: pal[1], fg: pal[2], accent: pal[3] }; - const now = colorState(); - const bg = HEX.test(String(body.bg || "")) ? body.bg.toLowerCase() : now.bg; - let fg = HEX.test(String(body.fg || "")) ? body.fg.toLowerCase() : now.fg; - const accent = HEX.test(String(body.accent || "")) ? body.accent.toLowerCase() : now.accent; - let fixed = false; - if (contrast(bg, fg) < 4.5) { fg = readableOn(bg); fixed = true; } - return { preset: "custom", bg, fg, accent, fixed }; -}; - -const PREVIEW_CSS = PALETTES.map(([k, bg, fg, ac]) => - `.pal-${k} .pal-bg{background:${bg}!important}.pal-${k} .pal-fg{background:${fg}!important}.pal-${k} .pal-ac{background:${ac}!important}`).join("\n"); - -// la hoja que va la ultima: las variables del tema con los colores elegidos y las -// muestras de las paletas (la CSP no deja estilos en linea) -const colorsCss = (cfg) => { - const c = colorState(cfg); - // sin nada guardado tambien: la de serie son los colores del dev, no los de Activista.css - const extra = ((c.preset !== "custom" && PALETTES.find(x => x[0] === c.preset)) || [])[4] || {}; - const vars = [ - `--a26-bg:${c.bg}`, `--a26-fg:${c.fg}`, `--a26-neon:${c.accent}`, - `--a26-card:${extra.card || c.bg}`, `--a26-title:${extra.title || c.fg}`, - `--a26-on-neon:${extra.onAccent || readableOn(c.accent)}`, - `--a26-soft:${extra.soft || mix(c.bg, c.fg, 0.06)}`, - `--a26-muted:${extra.muted || mix(c.fg, c.bg, 0.35)}`, - `--a26-tint:${extra.tint || mix(c.bg, c.accent, 0.2)}`, - `--a26-line:${extra.line || mix(c.bg, c.fg, 0.18)}`, - `--a26-edge:${extra.edge || mix(c.bg, c.fg, 0.5)}`, - `--a26-card-bd:${extra.cardBorder || "var(--a26-bd)"}`, - `--a26-card-shadow:${extra.cardShadow || "none"}`, - `--a26-field:${extra.field || c.bg}`, `--a26-bar:${extra.bar || c.bg}`, - `--a26-bar-bd:${extra.barBorder || "var(--a26-bd)"}`, - // rojo de texto (borrar): el de serie si se lee sobre el fondo; si no, uno claro - `--a26-danger-text:${contrast(c.bg, "#c4003b") >= 4.5 ? "#c4003b" : "#ff6b86"}`, - `color-scheme:${lum(c.bg) < 0.2 ? "dark" : "light"}` - ]; - const custom = `.pal-custom .pal-bg{background:${c.bg}!important}.pal-custom .pal-fg{background:${c.fg}!important}.pal-custom .pal-ac{background:${c.accent}!important}`; - return `:root{${vars.join(";")}}\n` + PREVIEW_CSS + "\n" + custom + "\n"; -}; -const colorsVersion = (cfg) => crypto.createHash("sha1").update(colorsCss(cfg)).digest("hex").slice(0, 10); -const colorsLink = () => require("../server/node_modules/hyperaxe").link({ rel: "stylesheet", href: `/mobile-colors.css?v=${colorsVersion()}` }); - -const swatch = (key) => span({ class: `pal-swatch pal-${key}`, "aria-hidden": "true" }, - span({ class: "pal-bg" }), span({ class: "pal-fg" }), span({ class: "pal-ac" })); - -// el selector de colores, el mismo en Ajustes y en la bienvenida -const colorsPick = (returnTo, withKeep) => { - const i18n = t(); - const c = colorState(); - const cur = c.chosen ? c.preset : DEFAULT[0]; - return div({ class: "color-pick" }, - form({ method: "POST", action: "/settings/colors", class: "ux-pick" }, - input({ type: "hidden", name: "returnTo", value: returnTo }), - p({ class: "ux-pick-hint" }, i18n.colorsHint || "Tap a palette and it is applied."), - div({ class: "pal-grid" }, - ...PALETTES.map(([k]) => button(Object.assign({ type: "submit", name: "preset", value: k, class: k === cur ? "pal-card is-current" : "pal-card" }, k === cur ? { "aria-current": "true" } : {}), - swatch(k), - span({ class: "pal-name" }, i18n[`palette_${k}`] || k), - k === cur ? span({ class: "ux-pick-now" }, i18n.uxPickCurrent || "In use") : null - )) - ), - withKeep && !c.chosen ? div({ class: "welcome-action" }, button({ type: "submit", name: "preset", value: DEFAULT[0], class: "welcome-skip-btn" }, i18n.colorsKeep || "Keep these")) : null - ), - details(Object.assign({ class: "color-custom" }, cur === "custom" ? { open: true } : {}), - summary(swatch("custom"), span(i18n.colorsCustom || "Your own")), - form({ method: "POST", action: "/settings/colors", class: "color-custom-form" }, - input({ type: "hidden", name: "returnTo", value: returnTo }), - input({ type: "hidden", name: "preset", value: "custom" }), - label({ class: "color-field" }, span(i18n.colorsBg || "Background"), input({ type: "color", name: "bg", value: c.bg })), - label({ class: "color-field" }, span(i18n.colorsFg || "Text"), input({ type: "color", name: "fg", value: c.fg })), - label({ class: "color-field" }, span(i18n.colorsAccent || "Accent"), input({ type: "color", name: "accent", value: c.accent })), - c.fixed ? p({ class: "form-hint" }, i18n.colorsFixed || "The text could not be read on that background, so it was set to black or white.") : null, - button({ type: "submit", class: "create-button" }, i18n.colorsApply || "Apply") - ) - ) - ); -}; - -// ---------- los seis accesos ---------- - -const QUICK = [ - { id: "identity", icon: "profile", key: "quick_identity" }, - { id: "workflows", icon: "modules", key: "quick_workflow" }, - { id: "ux", icon: "activity", key: "quick_ux" }, - { id: "colors", icon: "palette", key: "quick_colors" }, - { id: "community", icon: "invites", key: "quick_community" }, - { id: "greeting", icon: "publish", key: "quick_greeting" } -]; - -// la linea de debajo de cada acceso: lo que hay puesto ahora -const quickValue = (id, cfg) => { - const i18n = t(); - if (id === "identity") return global.__OASIS_SELF_NAME__ || ""; - if (id === "workflows") { - const wf = (() => { try { return require("../models/workflows_model").currentWorkflow(cfg); } catch (_) { return null; } })(); - return wf ? require("./mobile_ui").workflowName(wf) : ""; - } - if (id === "ux") { - const ux = (cfg.ux || {}).current || "blocks"; - return { blocks: i18n.uxModeMenus, ainav: i18n.uxModeAINav, chats: i18n.uxModeChats, feed: i18n.uxModeFeed, phone: i18n.phoneTitle, rooms: i18n.roomsTitle }[ux] || ""; - } - if (id === "colors") { const c = colorState(cfg); return c.preset === "custom" ? (i18n.colorsCustom || "") : (i18n[`palette_${c.preset}`] || ""); } - return ""; -}; - -const quickGrid = (cfg, present) => { - const i18n = t(); - return section({ id: "quick", class: "quick-settings" }, - div({ class: "tags-header" }, h2(i18n.quickTitle || "The basics"), p(i18n.quickHint || "What you chose when you started, one tap away.")), - div({ class: "quick-grid" }, - ...QUICK.filter(q => present.includes(q.id)).map(q => a({ href: `#${q.id}`, class: "quick-tile" }, - iconNode("quick-ico", q.icon), - span({ class: "quick-name" }, i18n[q.key] || q.id), - span({ class: "quick-now" }, quickValue(q.id, cfg)) - )) - ) - ); -}; - -// ---------- bloques nuevos de Ajustes ---------- - -const identitySection = () => { - const i18n = t(); - const avatar = global.__OASIS_SELF_AVATAR__; - const name = global.__OASIS_SELF_NAME__; - return section({ id: "identity" }, - div({ class: "tags-header" }, h2(i18n.quick_identity || i18n.welcomeStepIdentity), p(i18n.welcomeHintIdentity)), - div({ class: "quick-identity" }, - avatar ? img({ class: "quick-identity-avatar", src: avatar, alt: "" }) : null, - span({ class: "quick-identity-name" }, name || i18n.quickNoName || "No name yet") - ), - a({ href: "/profile/edit", class: "create-button" }, i18n.quickEditProfile || "Change name and picture") - ); -}; - -const colorsSection = () => { - const i18n = t(); - return section({ id: "colors" }, - div({ class: "tags-header" }, h2(i18n.quick_colors || "Colours")), - colorsPick("/settings#colors", false) - ); -}; - -const communitySection = () => { - const i18n = t(); - return section({ id: "community" }, - div({ class: "tags-header" }, h2(i18n.quick_community || i18n.welcomeStepCommunity), p(i18n.welcomeHintCommunity)), - div({ class: "quick-links" }, - a({ href: "/invites", class: "create-button" }, i18n.quickJoinPub || i18n.invites), - a({ href: "/peers", class: "filter-btn" }, i18n.quickSeePeers || i18n.peers) - ) - ); -}; - -const greetingSection = () => { - const i18n = t(); - return section({ id: "greeting" }, - div({ class: "tags-header" }, h2(i18n.quick_greeting || i18n.welcomeStepIntro), p(i18n.welcomeHintIntro)), - a({ href: "/feed/create", class: "create-button" }, i18n.quickWriteGreeting || "Write a greeting") - ); -}; - -// recibe las secciones de settings_view (pestaña Oasis) y devuelve la lista del movil: -// la rejilla arriba, los bloques nuevos metidos en su sitio y el orden de los accesos -const ORDER = ["quick", "update", "language", "identity", "workflows", "ux", "colors", "community", "greeting", "home-page"]; -const withQuick = (parts) => { - const cfg = cfgNow(); - const mods = cfg.modules || {}; - const extra = [ - identitySection(), - colorsSection(), - mods.invitesMod !== "off" ? communitySection() : null, - mods.feedMod !== "off" ? greetingSection() : null - ].filter(Boolean); - // el selector de skin no va: en el movil hay uno solo (Activista) - const list = parts.filter(el => el && el.id !== "theme").concat(extra); - const ids = list.map(el => el && el.id); - list.push(quickGrid(cfg, ids)); - const rank = (el) => { const i = ORDER.indexOf(el && el.id); return i === -1 ? ORDER.length : i; }; - return list.map((el, i) => ({ el, i })).sort((x, y) => rank(x.el) - rank(y.el) || x.i - y.i).map(x => x.el); -}; - -// ---------- bienvenida ---------- - -// Colores entra como paso, despues del tipo de portada. No es un paso del dev, asi -// que el "hecho" sale de si ya hay colores guardados -const welcomeWithColors = (usable) => { - if (usable.includes("colors")) return usable; - const out = usable.slice(); - const at = out.indexOf("ux"); - out.splice(at === -1 ? out.length : at + 1, 0, "colors"); - return out; -}; -const welcomeDone = (steps) => Object.assign({}, steps, { colors: colorState().chosen }); - -module.exports = { - PALETTES, colorState, colorsFromBody, colorsCss, colorsLink, colorsPick, - withQuick, welcomeWithColors, welcomeDone, contrast -}; diff --git a/src/views/mobile_ui.js b/src/views/mobile_ui.js deleted file mode 100644 index e088889..0000000 --- a/src/views/mobile_ui.js +++ /dev/null @@ -1,270 +0,0 @@ -"use strict"; -// Piezas de interfaz de la rama movil que se enchufan en vistas de upstream. -// -// Las vistas (vote_view, tribes_view, settings_view, main_views, welcome_view) son del -// dev y se reescriben a menudo (tribes_view en 23 de 39 releases, main_views en 31). -// Aqui vive la logica; en cada vista queda una llamada con la bandera de movil, que es -// lo unico que hay que volver a poner si el dev la reescribe. -// Ver docs/FRONTERA_UPSTREAM.md. -// -// i18n se lee al llamar (main_views lo cambia en cada peticion y este fichero se carga -// antes que el). - -const { a, button, details, div, form, img, input, label, nav, p, progress, span, summary } = require("../server/node_modules/hyperaxe"); -const { iconNode, iconKeyOf } = require("./mobile_icons"); - -const isMobile = () => process.env.OASIS_MOBILE === '1'; -const t = () => require('./main_views').i18n; - -// ---------- votaciones ---------- - -// tipos de votacion: el servidor ya guarda las opciones que se le pasen (campo -// options de /votes/create); aqui solo se ofrecen las que usan las asambleas -const VOTE_KINDS = [ - { key: 'classic', options: null }, - { key: 'traffic', options: ['GREEN', 'YELLOW', 'RED'] }, - { key: 'scale', options: ['1', '2', '3', '4', '5'] } -]; - -const voteKindPicker = (current) => { - const i18n = t(); - const cur = String(current || ''); - return div({ class: 'vote-kind' }, - label({ class: 'vote-kind-title' }, i18n.voteKindLabel || 'Type of vote'), - ...VOTE_KINDS.map((k, i) => { - const value = k.options ? k.options.join(',') : ''; - const checked = cur ? cur === value : i === 0; - return label({ class: 'vote-kind-option' }, - input(Object.assign({ type: 'radio', name: 'options', value }, checked ? { checked: 'checked' } : {})), - span({ class: 'vote-kind-name' }, i18n['voteKind_' + k.key] || k.key), - span({ class: 'vote-kind-hint' }, i18n['voteKindHint_' + k.key] || '') - ); - }) - ); -}; - -// resultados como barras, una por opcion: la tabla de 6 columnas no cabia y partia -// las palabras por silabas -const voteBars = (voteOptions, baseCounts, voteLabel) => { - const total = voteOptions.reduce((s, o) => s + (Number(baseCounts[o]) || 0), 0); - return div({ class: 'vote-bars' }, - ...voteOptions.map((opt) => { - const n = Number(baseCounts[opt]) || 0; - return div({ class: `vote-bar vote-bar-${String(opt).toLowerCase()}` }, - span({ class: 'vote-bar-label' }, voteLabel(opt)), - span({ class: 'vote-bar-count' }, String(n)), - progress({ class: 'vote-bar-meter', max: String(Math.max(total, 1)), value: String(n) }) - ); - }) - ); -}; - -// quien crea una votacion no vota (regla de votes_model.js, no se toca): se explica -const voteOwnNote = () => p({ class: 'vote-own-note' }, t().voteOwnNote || 'You created this vote, so you cannot vote in it.'); - -// ---------- tribus ---------- - -// una fila por tribu: con 12 comisiones la lista de tarjetas con la imagen generica -// eran 10 pantallas. La imagen solo sale si la tribu tiene una propia -const tribeRow = (tr, { own, imageNode }) => { - const i18n = t(); - const href = `/tribe/${encodeURIComponent(tr.id)}`; - const members = tr.memberCount != null ? tr.memberCount : (tr.members || []).length; - const facts = [ - tr.isAnonymous ? i18n.tribePrivate : i18n.tribePublic, - tr.inviteMode === 'open' ? i18n.tribeOpen : i18n.tribeStrict, - `${members} ${String(i18n.tribeMembersCount || '').toLowerCase()}` - ].filter(Boolean).join(' · '); - return a({ href, class: 'tribe-row' + (own ? ' own-content' : '') }, - tr.image && imageNode ? imageNode : iconNode('tribe-row-thumb tribe-row-icon', 'tribes'), - span({ class: 'tribe-row-text' }, - span({ class: 'tribe-row-title' }, tr.title), - span({ class: 'tribe-row-facts' }, facts), - tr.description ? span({ class: 'tribe-row-desc' }, String(tr.description).slice(0, 90)) : null - ), - span({ class: 'tribe-row-go', 'aria-hidden': 'true' }, '›') - ); -}; - -// secciones de una tribu: 25 botones a todo lo ancho eran varias pantallas antes del -// contenido. Tira de fichas con lo que mas usa una comision y el resto en "Mas" -const TRIBE_MAIN_TABS = ['activity', 'inhabitants', 'governance', 'votations', 'forum', 'chats', 'events', 'pads']; -const tribeTabs = (tribe, section, sections) => { - const i18n = t(); - const all = sections.flatMap(g => g.items); - const tab = (s) => a({ href: `/tribe/${encodeURIComponent(tribe.id)}?section=${encodeURIComponent(s.key)}#tribe-tabs`, class: section === s.key ? 'tribe-tab is-current' : 'tribe-tab', ...(section === s.key ? { 'aria-current': 'page' } : {}) }, s.label); - const main = all.filter(s => TRIBE_MAIN_TABS.includes(s.key)); - const rest = all.filter(s => !TRIBE_MAIN_TABS.includes(s.key)); - const currentRest = rest.find(s => s.key === section); - return nav({ id: 'tribe-tabs', class: 'tribe-tabs', 'aria-label': i18n.tribeSectionsLabel || 'Sections' }, - div({ class: 'tribe-tabs-strip' }, ...(currentRest ? [tab(currentRest)] : []), ...main.map(tab)), - details({ class: 'tribe-tabs-more' }, - summary(i18n.tribeSectionsMore || 'More'), - div({ class: 'tribe-tabs-grid' }, ...rest.filter(s => s !== currentRest).map(tab)) - ) - ); -}; - -// ---------- ajustes ---------- - -// primero los seis accesos y lo que se toca (idioma, identidad, uso, portada, -// colores, aspecto) y despues lo tecnico; ver mobile_settings.js -const settingsOrder = (parts) => require('./mobile_settings').withQuick(parts); - -// cada idioma un boton que se aplica al tocarlo (antes desplegable y otro boton) -const LANGS = [['Español', 'es'], ['Català', 'ca'], ['Euskara', 'eu'], ['Galego', 'gl'], ['English', 'en'], ['Français', 'fr'], - ['Português', 'pt'], ['Italiano', 'it'], ['Deutsch', 'de'], ['Русский', 'ru'], ['العربية', 'ar'], ['हिन्दी', 'hi'], ['中文', 'zh']]; -// el idioma se lee al pintar: settings_view desestructura selectedLanguage al cargar y -// se queda con el valor de entonces (siempre "en") -const languagePick = () => { - const selected = require('./main_views').getLanguage(); - return form({ action: '/language', method: 'post', class: 'lang-pick' }, - ...LANGS.map(([name, code]) => button({ type: 'submit', name: 'language', value: code, class: code === selected ? 'lang-chip is-current' : 'lang-chip', lang: code }, name)) - ); -}; - -// tipo de portada (ux): cada tarjeta es un boton que guarda al tocarlo. Antes eran -// radios y un boton de guardar debajo de seis tarjetas grandes, fuera de pantalla. -// Mismos modos y mismas condiciones que settings_view y welcome_view; las rutas -// (/settings/ux, /welcome/ux) son las del dev -const uxPick = (action, cfg, withHint) => { - const i18n = t(); - const mods = (cfg && cfg.modules) || {}; - const ux = cfg && cfg.ux && cfg.ux.current; - const modes = [ - ['blocks', i18n.uxModeMenus || 'Blocks', true], - ['ainav', i18n.uxModeAINav || 'AI', mods.aiNavMod === 'on'], - ['chats', i18n.uxModeChats || 'Conversations', mods.chatsMod === 'on'], - ['feed', i18n.uxModeFeed || 'Microblogging', true], - ['phone', i18n.phoneTitle || 'Phone', mods.phoneMod !== 'off'], - ['rooms', i18n.roomsTitle || 'Rooms', mods.roomsMod !== 'off'] - ].filter(m => m[2]); - const cur = modes.some(m => m[0] === ux) ? ux : 'blocks'; - return form({ method: 'POST', action, class: 'ux-pick' }, - withHint ? p({ class: 'ux-pick-hint' }, i18n.uxPickHint || 'Tap one and it is saved. You can change it any time in Settings.') : null, - div({ class: 'ux-pick-grid' }, - ...modes.map(([value, title]) => button(Object.assign({ type: 'submit', name: 'ux', value, class: value === cur ? 'ux-pick-card is-current' : 'ux-pick-card' }, value === cur ? { 'aria-current': 'true' } : {}), - img({ src: `/assets/images/ux-${value}.png`, class: 'ux-pick-shot', alt: '' }), - span({ class: 'ux-pick-label' }, title), - value === cur ? span({ class: 'ux-pick-now' }, i18n.uxPickCurrent || 'In use') : null - )) - ) - ); -}; - -// perfil de uso (workflow), que en el movil es el skin: un boton por opcion con -// para que sirve, cuantos modulos enciende y su barra de abajo. Primero Minimo, -// Acampada y Activista (fork_workflows.LADDER); el resto de menos a mas modulos -const workflowName = (k) => { - const i18n = t(); - if (k === 'full') return i18n.welcomeWorkflow_full || k; - return i18n[`wfNameMobile_${k}`] || i18n[`workflow_${k}`] || k; -}; -const workflowPick = (action, current, keys, withHint) => { - const i18n = t(); - const fw = require('../models/fork_workflows'); - const { PINNABLE_MODULES } = require('./main_views'); - const rank = (k) => { const i = fw.LADDER.indexOf(k); return i === -1 ? fw.LADDER.length : i; }; - const list = keys.slice().sort((x, y) => rank(x) - rank(y) || fw.moduleCount(x) - fw.moduleCount(y)); - const barOf = (k) => (fw.BARS[k] || []).map(key => (PINNABLE_MODULES || []).find(m => m.key === key)).filter(Boolean); - return form({ method: 'POST', action, class: 'ux-pick wf-pick' }, - withHint ? p({ class: 'ux-pick-hint' }, i18n.uxPickHint || 'Tap one and it is saved. You can change it any time in Settings.') : null, - div({ class: 'ux-pick-grid' }, - ...list.map(k => button(Object.assign({ type: 'submit', name: 'workflow', value: k, class: k === current ? 'ux-pick-card is-current' : 'ux-pick-card' }, k === current ? { 'aria-current': 'true' } : {}), - span({ class: 'ux-pick-text' }, - span({ class: 'ux-pick-label' }, workflowName(k)), - i18n[`wfHint_${k}`] ? span({ class: 'ux-pick-sub' }, i18n[`wfHint_${k}`]) : null, - span({ class: 'wf-count' }, String(i18n.wfModuleCount || '{n} modules').replace('{n}', String(fw.moduleCount(k)))), - barOf(k).length ? span({ class: 'wf-bar', 'aria-hidden': 'true' }, ...barOf(k).map(m => iconNode('wf-bar-ico', iconKeyOf(m.href), m.glyph))) : null), - k === current ? span({ class: 'ux-pick-now' }, i18n.uxPickCurrent || 'In use') : null - )) - ) - ); -}; - -// pasos de la bienvenida en el movil: el backend del dev quita el de uso en el movil; -// aqui vuelve, el segundo, justo despues de la identidad (/welcome/workflow es suyo) -// y Colores despues del tipo de portada (mobile_settings.js) -const welcomeSteps = (usable) => { - const out = usable.slice(); - if (!out.includes('workflow')) { - const at = out.indexOf('profile'); - out.splice(at === -1 ? 0 : at + 1, 0, 'workflow'); - } - return require('./mobile_settings').welcomeWithColors(out); -}; - -// ---------- actividad ---------- - -// las fichas de tipo de actividad van plegadas tras un boton Filtrar; por defecto se -// ve todo y con un filtro puesto el boton dice cual. Desde la 1.3.5 la cabecera del dev -// (apkModbar en main_views) se lleva la primera caja .activity-filter-chips a su barra -// fija: por eso el boton (casilla + etiqueta) y las fichas van dentro de esa misma caja -// y viajan juntos. Sin JS. En escritorio, las fichas tal cual -const activityFilter = (filter, types, chips) => { - if (!isMobile()) return chips; - const i18n = t(); - const cur = filter && filter !== 'all' ? ((types || []).find(x => x.type === filter) || {}).label : null; - chips['class'] = 'mobile-filter-chips'; - return div({ class: cur ? 'activity-filter-chips mobile-filter is-filtered' : 'activity-filter-chips mobile-filter' }, - input({ type: 'checkbox', id: 'mobile-filter-toggle', class: 'mobile-filter-toggle', autocomplete: 'off' }), - label({ for: 'mobile-filter-toggle', class: 'mobile-filter-btn' }, - span({ class: 'activity-filter-name' }, i18n.activityFilter || 'Filter'), - cur ? span({ class: 'activity-filter-now' }, cur) : null), - chips - ); -}; - -// ---------- acciones de tarjeta ---------- - -// borrar pide un segundo toque: la X abre "¿Borrar? Si, borrar" y solo ese boton -// envia. Sin JS, con
. hidden: los que lleve el formulario -const deleteConfirm = (action, hidden) => { - const i18n = t(); - return details({ class: 'content-action-confirm' }, - summary({ class: 'btn-singleview btn-delete', title: i18n.chatDelete, 'aria-label': i18n.chatDelete }, require('./main_views').renderIcon('close')), - div({ class: 'confirm-bar' }, - span({ class: 'confirm-question' }, i18n.deleteConfirmQuestion || 'Delete it for good?'), - form({ method: 'POST', action, class: 'content-action-form' }, hidden || null, - button({ type: 'submit', class: 'confirm-yes' }, i18n.deleteConfirmYes || 'Yes, delete')) - ) - ); -}; - -// ---------- pie y aviso de bienvenida ---------- - -// una linea: con cuanta gente estas sincronizando, dicho en claro, y la version. La -// clave, HcT/HcH y la licencia estan en Perfil, Estadisticas y Ajustes -const footerLine = (syncedPeers, version) => { - const i18n = t(); - const n = Number(syncedPeers) || 0; - const text = n > 0 - ? String(i18n.footerPeersSome || '{n} peers in sync').replace('{n}', String(n)) - : (i18n.footerPeersNone || 'Not syncing with anyone yet'); - return div({ class: 'oasis-footer oasis-footer-mobile' }, - a({ href: '/peers', class: n > 0 ? 'footer-sync is-on' : 'footer-sync' }, - span({ class: 'footer-sync-dot', 'aria-hidden': 'true' }), - span(text) - ), - span({ class: 'footer-version' }, `Oasis ${version} · GPLv3`) - ); -}; - -// el aviso de "pon tu avatar" solo en la portada (en cada pagina se comia una quinta -// parte de la pantalla) y diciendo para que es -const welcomeBannerHere = (here) => ['', '/', '/activity'].includes(here); -const welcomeBannerText = () => t().welcomeBannerTextMobile || t().welcomeBannerText; -const welcomeBannerAction = () => t().welcomeBannerActionMobile || t().welcomeBannerAction; - -// colores y Ajustes: viven en mobile_settings.js; aqui se reexportan para que los -// enganches de las vistas del dev pasen todos por mobile_ui -const colorsLink = () => require('./mobile_settings').colorsLink(); -const colorsPick = (returnTo, withKeep) => require('./mobile_settings').colorsPick(returnTo, withKeep); -const welcomeDone = (steps) => require('./mobile_settings').welcomeDone(steps); - -module.exports = { - colorsLink, colorsPick, welcomeDone, - isMobile, VOTE_KINDS, voteKindPicker, voteBars, voteOwnNote, tribeRow, tribeTabs, - settingsOrder, languagePick, uxPick, workflowPick, workflowName, activityFilter, welcomeSteps, deleteConfirm, footerLine, - welcomeBannerHere, welcomeBannerText, welcomeBannerAction -}; diff --git a/src/views/modules_view.js b/src/views/modules_view.js index 58efeef..362d54f 100644 --- a/src/views/modules_view.js +++ b/src/views/modules_view.js @@ -39,6 +39,7 @@ const modulesView = () => { { name: 'industry', label: i18n.modulesIndustryLabel, description: i18n.modulesIndustryDescription }, { name: 'invites', label: i18n.modulesInvitesLabel, description: i18n.modulesInvitesDescription }, { name: 'jobs', label: i18n.modulesJobsLabel, description: i18n.modulesJobsDescription }, + { name: 'karvan', label: i18n.modulesKarvanLabel, description: i18n.modulesKarvanDescription }, { name: 'larp', label: i18n.modulesLarpLabel, description: i18n.modulesLarpDescription }, { name: 'backup', label: i18n.modulesBackupLabel, description: i18n.modulesBackupDescription }, { name: 'logs', label: i18n.modulesLogsLabel, description: i18n.modulesLogsDescription }, @@ -149,7 +150,7 @@ const modulesView = () => { ), div( { class: "save-button-container" }, - button({ type: "submit" }, i18n.saveSettings) + button({ type: "submit", class: "submit-button" }, i18n.saveSettings) ) ) ) diff --git a/src/views/opinions_view.js b/src/views/opinions_view.js index 0413076..cb020b5 100644 --- a/src/views/opinions_view.js +++ b/src/views/opinions_view.js @@ -1,7 +1,7 @@ -const { div, h2, p, section, button, form, a, video: videoHyperaxe, audio: audioHyperaxe, input, table, tr, th, td, br, span, details, summary } = require("../server/node_modules/hyperaxe"); +const { div, h2, p, section, button, form, a, img, video: videoHyperaxe, audio: audioHyperaxe, input, table, tr, th, td, br, span, details, summary } = require("../server/node_modules/hyperaxe"); const RAW_HTML = Symbol.for('oasis.rawHtml'); const moment = require("../server/node_modules/moment"); -const { template, i18n, userLink, renderCardTop, renderShareButton, renderVotesSummary, renderModuleStats, renderCardMetaRow, renderCommentsSummary, commentsOpenHref, CONTENT_FAV_KIND, CONTENT_TORRENTABLE, paged, renderPdfDocument } = require('./main_views'); +const { template, i18n, userLink, renderSpreadButton, renderContentActions, renderVotesSummary, renderModuleStats, renderCardMetaRow, CONTENT_FAV_KIND, CONTENT_TORRENTABLE, paged } = require('./main_views'); const { renderZoomableImage } = require('./gallery_view'); const { config } = require('../server/SSB_server.js'); const { renderStyledText, renderStyledHtml, safeExternalHref } = require('../backend/renderStyledText'); @@ -9,9 +9,6 @@ const opinionCategories = require('../backend/opinion_categories'); const { sanitizeHtml } = require('../backend/sanitizeHtml'); -const opinionFilterLabel = (mode) => - String(i18n[mode + 'Button'] || i18n['vote' + mode.charAt(0).toUpperCase() + mode.slice(1)] || mode).toUpperCase(); - const detailHref = (type, key) => { switch (type) { case 'bookmark': return `/bookmarks/${encodeURIComponent(key)}`; @@ -38,10 +35,6 @@ const detailHref = (type, key) => { case 'podcastEpisode': return `/podcasts/episode/${encodeURIComponent(key)}`; case 'campaign': return `/campaigns/${encodeURIComponent(key)}`; case 'logisticsRoute': return `/logistics/${encodeURIComponent(key)}`; - case 'wikiPage': return `/wiki/${encodeURIComponent(key)}`; - case 'map': return `/maps/${encodeURIComponent(key)}`; - case 'industryBlueprint': return `/industry/blueprint/${encodeURIComponent(key)}`; - case 'job': return `/jobs/${encodeURIComponent(key)}`; default: return null; } }; @@ -267,7 +260,7 @@ const renderContentHtml = (content, key) => { ] : null, div({ class: 'card-field' }, - renderPdfDocument(`/blob/${encodeURIComponent(content.url)}`, { title: content.title }) + div({ class: 'pdf-viewer-container', 'data-pdf-url': `/blob/${encodeURIComponent(content.url)}` }) ) ) ); @@ -402,28 +395,28 @@ exports.opinionsView = (items, filter, spreadMap = new Map(), q = '', allItems = const voteEntries = Object.entries(c.opinions || {}); const total = voteEntries.reduce((sum, [, v]) => sum + v, 0); const voted = c.opinions_inhabitants?.includes(config.keys.id); + const created = moment(item.value.timestamp).format("YYYY/MM/DD HH:mm"); const allCats = opinionCategories; const isOwn = item.value.author && String(item.value.author) === String(config.keys.id); - const commentTotal = params.commentCounts instanceof Map ? (params.commentCounts.get(key) || 0) : 0; - const shareTitle = c.title || c.question || c.concept || c.name || c.contentWarning || ''; return div( { class: 'trending-card opinions-card' + (isOwn ? ' own-content' : '') }, - renderCardTop(key, detailHref(c.type, key), { - date: item.value.timestamp, - chips: [span({ class: 'pm-exposition-chip pm-exposition-whole' }, + div({ class: 'card-header activity-card-header' }, + span({ class: 'pm-exposition-chip pm-exposition-whole' }, span({ class: 'pm-exposition-text' }, String(c.type === 'post' ? (i18n.typePost || 'BLOG') : (c.type || '')).toUpperCase()) - )], - spread: spreadMap.get(key) || null, - author: item.value.author, - reportTitle: shareTitle, - ...(CONTENT_TORRENTABLE.has(c.type) && typeof c.url === 'string' && c.url.startsWith('&') - ? { torrentFrom: { blobId: c.url, name: c.title || c.fileName || '' } } - : {}), - ...(CONTENT_FAV_KIND[c.type] - ? { favKind: CONTENT_FAV_KIND[c.type], isFavorite: params.favIndex instanceof Map ? params.favIndex.get(String(key)) === CONTENT_FAV_KIND[c.type] : false, returnTo: `/opinions?filter=${encodeURIComponent(filter)}` } - : {}) - }), + ), + renderContentActions(key, detailHref(c.type, key), { + spread: spreadMap.get(key) || null, + author: item.value.author, + reportTitle: c.title || c.question || c.concept || c.name || c.contentWarning || '', + ...(CONTENT_TORRENTABLE.has(c.type) && typeof c.url === 'string' && c.url.startsWith('&') + ? { torrentFrom: { blobId: c.url, name: c.title || c.fileName || '' } } + : {}), + ...(CONTENT_FAV_KIND[c.type] + ? { favKind: CONTENT_FAV_KIND[c.type], isFavorite: params.favIndex instanceof Map ? params.favIndex.get(String(key)) === CONTENT_FAV_KIND[c.type] : false, returnTo: `/opinions?filter=${encodeURIComponent(filter)}` } + : {}) + }) + ), div( { class: 'card-section opinions-card-body' }, contentHtml @@ -431,7 +424,7 @@ exports.opinionsView = (items, filter, spreadMap = new Map(), q = '', allItems = renderVotesSummary(c.opinions), renderCardMetaRow( details({ class: 'opinions-voting-collapse' }, - summary({ class: Object.values(c.opinions || {}).some(n => Number(n) > 0) ? 'opinions-summary engage-on' : 'opinions-summary' }, + 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' }, @@ -446,13 +439,16 @@ exports.opinionsView = (items, filter, spreadMap = new Map(), q = '', allItems = }) ) ), - renderCommentsSummary(commentsOpenHref(detailHref(c.type, key), commentTotal), commentTotal), - renderShareButton(key, detailHref(c.type, key), shareTitle) + p({ class: 'card-footer' }, + span({ class: 'date-link' }, `${created}`), + userLink(item.value.author) + ) ) ); }) .filter(Boolean); + const hasDocuments = items.some(item => item.value.content?.type === 'document'); const header = div({ class: 'tags-header module-header-line' }, h2(title), p(i18n.shareYourOpinions) @@ -467,7 +463,7 @@ exports.opinionsView = (items, filter, spreadMap = new Map(), q = '', allItems = 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' }, opinionFilterLabel(mode)) + button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, String(i18n[mode + 'Button'] || mode).toUpperCase()) ) ) ), @@ -475,7 +471,7 @@ exports.opinionsView = (items, filter, spreadMap = new Map(), q = '', allItems = opinionCategories.positive.slice(0, 5).filter(mode => presentCats.has(mode)).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' }, opinionFilterLabel(mode)) + button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, String(i18n[mode + 'Button'] || mode).toUpperCase()) ) ) ), @@ -483,7 +479,7 @@ exports.opinionsView = (items, filter, spreadMap = new Map(), q = '', allItems = opinionCategories.positive.slice(5, 10).filter(mode => presentCats.has(mode)).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' }, opinionFilterLabel(mode)) + button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, String(i18n[mode + 'Button'] || mode).toUpperCase()) ) ) ), @@ -491,7 +487,7 @@ exports.opinionsView = (items, filter, spreadMap = new Map(), q = '', allItems = opinionCategories.positive.slice(10, 15).filter(mode => presentCats.has(mode)).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' }, opinionFilterLabel(mode)) + button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, String(i18n[mode + 'Button'] || mode).toUpperCase()) ) ) ) @@ -500,7 +496,7 @@ exports.opinionsView = (items, filter, spreadMap = new Map(), q = '', allItems = opinionCategories.constructive.slice(0, 5).filter(mode => presentCats.has(mode)).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' }, opinionFilterLabel(mode)) + button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, String(i18n[mode + 'Button'] || mode).toUpperCase()) ) ) ), @@ -508,7 +504,7 @@ exports.opinionsView = (items, filter, spreadMap = new Map(), q = '', allItems = opinionCategories.constructive.slice(5, 11).filter(mode => presentCats.has(mode)).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' }, opinionFilterLabel(mode)) + button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, String(i18n[mode + 'Button'] || mode).toUpperCase()) ) ) ), @@ -516,7 +512,7 @@ exports.opinionsView = (items, filter, spreadMap = new Map(), q = '', allItems = opinionCategories.moderation.slice(0, 5).filter(mode => presentCats.has(mode)).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' }, opinionFilterLabel(mode)) + button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, String(i18n[mode + 'Button'] || mode).toUpperCase()) ) ) ), @@ -524,7 +520,7 @@ exports.opinionsView = (items, filter, spreadMap = new Map(), q = '', allItems = opinionCategories.moderation.slice(5, 10).filter(mode => presentCats.has(mode)).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' }, opinionFilterLabel(mode)) + button({ type: 'submit', class: filter === mode ? 'filter-btn active' : 'filter-btn' }, String(i18n[mode + 'Button'] || mode).toUpperCase()) ) ) ) @@ -547,6 +543,8 @@ exports.opinionsView = (items, filter, spreadMap = new Map(), q = '', allItems = ) ); - return html; + return `${html}${hasDocuments + ? `` + : ''}`; }; diff --git a/src/views/pads_view.js b/src/views/pads_view.js index 4d212c5..26eae79 100644 --- a/src/views/pads_view.js +++ b/src/views/pads_view.js @@ -1,5 +1,5 @@ -const { div, h2, h4, p, section, button, form, a, span, textarea, input, label, select, option, table, tr, td, details, summary } = require("../server/node_modules/hyperaxe") -const { template, i18n, userLink, renderStateChip, renderLifespanChip, renderCardTop, renderEngagement, renderInviteQrCard, renderSubscriptionBox, renderModuleStatsBy, moduleIsEmpty, contentDeleteAction, paged, renderDetailShare } = require("./main_views") +const { div, h2, h3, h4, p, section, button, form, a, span, br, textarea, input, label, select, option, table, tr, td, details, summary } = require("../server/node_modules/hyperaxe") +const { template, i18n, userLink, renderStateChip, renderLifespanChip, renderSpreadButton , renderContentActions, renderInviteQrCard, renderSubscriptionBox, renderModuleStatsBy, moduleIsEmpty, contentDeleteAction, paged } = require("./main_views") const { renderEncryptedChip } = require("./clearnet_view") const moment = require("../server/node_modules/moment") const { config } = require("../server/SSB_server.js") @@ -109,20 +109,24 @@ const renderPadCard = exports.renderPadCard = (pad, filter, spreadInfo) => { ? renderStateChip("mutuals", "✉", i18n.subscriptionOn) : (pad.subscriptionIn === false ? renderStateChip("closed", "✉", i18n.subscriptionOff) : null) ].filter(Boolean) - const href = `/pads/${encodeURIComponent(pad.rootId)}` return div({ class: "tribe-card" }, - renderCardTop(pad.rootId || pad.key, href, { date: pad.createdAt, chips, spread: spreadInfo || null, author: pad.author, favKind: 'pads', isFavorite: pad.isFavorite, reportTitle: pad.title, deleteAction: String(pad.author) === String(userId) ? contentDeleteAction('pad', pad.rootId || pad.key) : undefined }), + div({ class: "card-header activity-card-header" }, + span(), + renderContentActions(pad.rootId || pad.key, `/pads/${encodeURIComponent(pad.rootId)}`, { spread: spreadInfo || null, author: pad.author, favKind: 'pads', isFavorite: pad.isFavorite, reportTitle: pad.title, deleteAction: String(pad.author) === String(userId) ? contentDeleteAction('pad', pad.rootId || pad.key) : undefined }) + ), div({ class: "tribe-card-body" }, div({ class: "shop-title-row" }, h2({ class: "tribe-card-title" }, - a({ href }, pad.title || "\u2014") + a({ href: `/pads/${encodeURIComponent(pad.rootId)}` }, pad.title || "\u2014") ) ), - p({ class: "job-meta-line" }, `${i18n.padDeadlineLabel || "Deadline"}: ${pad.deadline ? moment(pad.deadline).format("YYYY/MM/DD HH:mm") : "-"}`), + chips.length ? div({ class: "card-chips-row" }, ...chips) : null, + pad.deadline + ? p({ class: "job-meta-line" }, `${i18n.padDeadlineLabel || "Deadline"}: ${moment(pad.deadline).format("YYYY/MM/DD HH:mm")}`) + : null, div({ class: "tribe-card-members" }, span({ class: "tribe-members-count" }, `${i18n.padMembersLabel || "Members"}: ${pad.members.length}`) - ), - null + ) ) ) } @@ -284,25 +288,32 @@ exports.singlePadView = async (pad, entries, params) => { ].filter(Boolean) const padSide = div({ class: "tribe-side" }, - renderCardTop(pad.rootId, returnTo, { arrow: false, - date: pad.createdAt, - chips: detailChips, - author: pad.author, - favKind: 'pads', - isFavorite: pad.isFavorite, - spread: (params && params.spreads) || null, - returnTo, - reportTitle: pad.title, - deleteAction: isAuthor ? contentDeleteAction('pad', pad.rootId) : undefined - }), - topButtons.length ? div({ class: "tribe-side-actions wiki-actions-top pad-actions-top" }, ...topButtons) : null, + div({ class: "tribe-side-actions wiki-actions-top pad-actions-top" }, + span({ class: "wiki-actions-left" }, + div({ class: "card-header activity-card-header" }, + renderContentActions(pad.rootId, returnTo, { + author: pad.author, + favKind: 'pads', + isFavorite: pad.isFavorite, + spread: (params && params.spreads) || null, + returnTo, + reportTitle: pad.title, + deleteAction: isAuthor ? contentDeleteAction('pad', pad.rootId) : undefined + }) + ) + ), + ...topButtons + ), div({ class: "shop-title-row" }, h2({ class: "tribe-card-title" }, pad.title || "\u2014") ), - renderDetailShare(pad.rootId, returnTo, pad.title), - (isRestrictedInviteOnly || !pad.deadline) ? null : div({ class: "pad-meta" }, - span({ class: "card-label" }, `${i18n.padDeadlineLabel || "Deadline"}: `), - span(moment(pad.deadline).format("YYYY/MM/DD HH:mm")) + detailChips.length ? div({ class: "card-chips-row" }, ...detailChips) : null, + div({ class: "pad-meta" }, + span({ class: "card-label" }, `${i18n.padCreated || "Created"}: `), + span(moment(pad.createdAt).format("YYYY/MM/DD HH:mm")), + (isRestrictedInviteOnly || !pad.deadline) ? null : span({ class: "card-label" }, `${i18n.padDeadlineLabel || "Deadline"}: `), + (isRestrictedInviteOnly || !pad.deadline) ? null : span(moment(pad.deadline).format("YYYY/MM/DD HH:mm")), + isRestrictedInviteOnly ? null : userLink(pad.author) ), tags, (isRestrictedInviteOnly || !inviteActions.length) ? null : div({ class: "tribe-side-actions" }, ...inviteActions), @@ -326,8 +337,7 @@ exports.singlePadView = async (pad, entries, params) => { div({ class: "pad-members-chips" }, ...pad.members.map(m => span({ class: "pad-member-chip" }, span({ class: "pad-author-swatch " + memberColorClass(pad.members, m) }), userLink(m))) ) - ), - null + ) ) let canonicalEntries = entries diff --git a/src/views/parliament_view.js b/src/views/parliament_view.js index ee04564..0558668 100644 --- a/src/views/parliament_view.js +++ b/src/views/parliament_view.js @@ -1,17 +1,8 @@ const { form, button, div, h2, p, section, input, label, br, a, span, table, thead, tbody, tr, th, td, textarea, select, option, ul, li, img } = require('../server/node_modules/hyperaxe'); const RAW_HTML = Symbol.for('oasis.rawHtml'); const moment = require("../server/node_modules/moment"); -const { template, i18n, userLink, paged, renderEngagement, renderCardTop } = require('./main_views'); +const { template, i18n, userLink, paged } = require('./main_views'); const { renderStyledText } = require('../backend/renderStyledText'); -const { renderCommentsSection } = require('./comments_view'); - -const commentsBlock = (comments, item, returnTo, title) => { - const root = item && item.rootId; - const commentsNode = root && comments instanceof Map ? renderCommentsSection({ action: `/parliament/comments/${encodeURIComponent(root)}`, comments: comments.get(root) || [], returnTo }) : null; - return renderEngagement(root || (item && item.id), null, commentsNode, { href: returnTo, title }); -}; - -const parliamentTop = (item, href, date) => renderCardTop(null, href, { author: item.proposer, date: date || item.createdAt }); const { seatPositions } = require('../models/parliament_model').hemicycle; @@ -487,7 +478,7 @@ const ProposalForm = () => ) ); -const ProposalsList = (proposals, comments = null) => { +const ProposalsList = (proposals) => { if (!proposals || !proposals.length) return null; const cards = proposals.map(pItem => { const titleNode = pItem && pItem.voteId @@ -498,7 +489,17 @@ const ProposalsList = (proposals, comments = null) => { : (i18n.parliamentProposalOnTrackNo || 'BELOW THRESHOLD'); return div( { class: 'card' }, - parliamentTop(pItem, '/parliament?filter=proposals'), + br(), + div( + { class: 'card-field' }, + span({ class: 'card-label' }, i18n.parliamentThProposalDate.toUpperCase() + ': '), + span({ class: 'card-value' }, fmt(pItem.createdAt)) + ), + div( + { class: 'card-field' }, + span({ class: 'card-label' }, i18n.parliamentLawProposer.toUpperCase() + ': '), + span({ class: 'card-value' }, userLink(pItem.proposer)) + ), div( { class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentGovMethod.toUpperCase() + ': '), @@ -549,8 +550,7 @@ const ProposalsList = (proposals, comments = null) => { { method: 'GET', action: `/votes/${encodeURIComponent(pItem.voteId)}` }, button({ type: 'submit', class: 'buy-btn' }, i18n.parliamentVoteAction) ) - : null, - commentsBlock(comments, pItem, '/parliament?filter=proposals', pItem.title || '') + : null ); }); return div( @@ -565,12 +565,13 @@ const FutureLawsList = (rows) => { const cards = rows.map(pItem => div( { class: 'card' }, - parliamentTop(pItem, '/parliament?filter=proposals'), + br(), div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentGovMethod.toUpperCase() + ': '), span({ class: 'card-value' }, pItem.method)), + div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentThProposalDate.toUpperCase() + ': '), span({ class: 'card-value' }, fmt(pItem.createdAt))), + div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentLawProposer.toUpperCase() + ': '), span({ class: 'card-value' }, userLink(pItem.proposer))), h2(pItem.title || ''), p(...renderStyledText(pItem.description || '')), - ...campaignBlock(pItem), - commentsBlock(null, pItem, '/parliament?filter=proposals', pItem.title || '') + ...campaignBlock(pItem) ) ); return div( @@ -618,7 +619,20 @@ const RevocationsList = (revocations) => { : (i18n.parliamentProposalOnTrackNo || 'BELOW THRESHOLD'); return div( { class: 'card' }, - parliamentTop(pItem, '/parliament?filter=revocations'), + br(), + div( + { class: 'card-field' }, + span({ class: 'card-label' }, i18n.parliamentThProposalDate.toUpperCase() + ': '), + span({ class: 'card-value' }, fmt(pItem.createdAt)) + ), + div( + { class: 'card-field' }, + span({ class: 'card-label' }, i18n.parliamentLawProposer.toUpperCase() + ': '), + span( + { class: 'card-value' }, + userLink(pItem.proposer) + ) + ), div( { class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentGovMethod + ': '), @@ -669,8 +683,7 @@ const RevocationsList = (revocations) => { { method: 'GET', action: `/votes/${encodeURIComponent(pItem.voteId)}` }, button({ type: 'submit', class: 'buy-btn' }, i18n.parliamentVoteAction) ) - : null, - commentsBlock(null, pItem, '/parliament?filter=revocations', pItem.title || pItem.lawTitle || '') + : null ); }); return div( @@ -685,11 +698,12 @@ const FutureRevocationsList = (rows) => { const cards = rows.map(pItem => div( { class: 'card' }, - parliamentTop(pItem, '/parliament?filter=revocations'), + br(), pItem.method ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentGovMethod.toUpperCase() + ': '), span({ class: 'card-value' }, pItem.method)) : null, + div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentThProposalDate.toUpperCase() + ': '), span({ class: 'card-value' }, fmt(pItem.createdAt))), + div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentLawProposer.toUpperCase() + ': '), span({ class: 'card-value' }, userLink(pItem.proposer))), h2(pItem.title || pItem.lawTitle || ''), - p(pItem.reasons || ''), - commentsBlock(null, pItem, '/parliament?filter=revocations', pItem.title || pItem.lawTitle || '') + p(pItem.reasons || '') ) ); return div( @@ -729,7 +743,7 @@ const LawsStats = (laws = [], revocatedCount = 0) => { ); }; -const LawsList = (laws, comments = null) => { +const LawsList = (laws) => { if (!laws || !laws.length) return NoLaws(); const cards = laws.map(l => { const total = Number((l.votes && (l.votes.total || l.votes.TOTAL)) || 0); @@ -738,13 +752,14 @@ const LawsList = (laws, comments = null) => { const showMetricsFlag = showVoteMetrics(l.method); return div( { class: 'card' }, - parliamentTop(l, '/parliament?filter=laws', l.enactedAt), + br(), div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentGovMethod + ': ').toUpperCase()), span({ class: 'card-value' }, l.method)), + div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentLawEnacted + ': ').toUpperCase()), span({ class: 'card-value' }, fmt(l.enactedAt))), + div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.parliamentLawProposer.toUpperCase() + ': '), span({ class: 'card-value' }, userLink(l.proposer))), h2(l.question || ''), p(l.description || ''), showMetricsFlag ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentVotesNeeded + ': ').toUpperCase()), span({ class: 'card-value' }, String(needed))) : null, - showMetricsFlag ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentVotesSlashTotal + ': ').toUpperCase()), span({ class: 'card-value' }, `${yes}/${total}`)) : null, - commentsBlock(comments, l, '/parliament?filter=laws', l.question || '') + showMetricsFlag ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.parliamentVotesSlashTotal + ': ').toUpperCase()), span({ class: 'card-value' }, `${yes}/${total}`)) : null ); }); return div( @@ -1006,12 +1021,12 @@ const CandidaturesSection = (governmentCard, candidatures, leaderMeta, electionQ ); }; -const ProposalsSection = (governmentCard, proposals, futureLaws, canPropose, comments = null) => { +const ProposalsSection = (governmentCard, proposals, futureLaws, canPropose) => { const has = proposals && proposals.length > 0; const fl = FutureLawsList(futureLaws || []); if (!has && canPropose) return div(h2(i18n.parliamentGovernmentCard), GovHeader(governmentCard || {}), ProposalForm(), fl); if (!has && !canPropose) return div(h2(i18n.parliamentGovernmentCard), GovHeader(governmentCard || {}), NoProposals(), fl); - return div(h2(i18n.parliamentGovernmentCard), GovHeader(governmentCard || {}), ProposalForm(), ProposalsList(paged(proposals), comments), fl); + return div(h2(i18n.parliamentGovernmentCard), GovHeader(governmentCard || {}), ProposalForm(), ProposalsList(paged(proposals)), fl); }; const RevocationsSection = (governmentCard, laws, revocations, futureRevocations) => @@ -1052,8 +1067,7 @@ const parliamentView = async (state) => { revocationsEnactedCount, historicalMetas = {}, leadersMetas = {}, - inhabitantsTotal, - comments = null + inhabitantsTotal } = state; const fallbackGov = { @@ -1082,7 +1096,7 @@ const parliamentView = async (state) => { const LawsSectionWrap = () => div( LawsStats(laws || [], revocationsEnactedCount || 0), - LawsList(paged(laws || []), comments) + LawsList(paged(laws || [])) ); return template( @@ -1098,7 +1112,7 @@ const parliamentView = async (state) => { section( filter === 'government' ? GovernmentCard(gov, powerMeta, hemicycle || null, seatsMode || 'election', houseNames || {}) : null, filter === 'candidatures' ? CandidaturesSection(gov, candidatures, leaderMeta, electionQuorum) : null, - filter === 'proposals' ? ProposalsSection(gov, proposals, futureLaws, canPropose, comments) : null, + filter === 'proposals' ? ProposalsSection(gov, proposals, futureLaws, canPropose) : null, filter === 'laws' ? LawsSectionWrap() : null, filter === 'revocations' ? RevocationsSection(gov, laws, revocations, futureRevocations) : null, filter === 'historical' ? div(HistoricalGovsSummary(historical || []), HistoricalList(paged(historical || []), historicalMetas)) : null, diff --git a/src/views/peers_view.js b/src/views/peers_view.js index b3c8761..f2bd5b1 100644 --- a/src/views/peers_view.js +++ b/src/views/peers_view.js @@ -268,7 +268,7 @@ const peersView = async ({ onlinePeers, discoveredPeers, unknownPeers, lanBroadc const tab = tabs.includes(filter) ? filter : 'status'; const tabLabel = { status: i18n.status || 'Status', connections: i18n.peerConnectionsTitle || 'Connections', lan: i18n.peersTabLan, health: i18n.peersTabHealth }; const statusToolbar = peersToolbar('status', onlineCount + discoveredCount + unknownCount, [ - { label: i18n.onlinePlural || i18n.online, count: onlineCount }, + { label: i18n.online, count: onlineCount }, { label: i18n.discovered, count: discoveredCount }, { label: i18n.unknown, count: unknownCount } ]); diff --git a/src/views/phone_view.js b/src/views/phone_view.js index 87727b4..96acdfd 100644 --- a/src/views/phone_view.js +++ b/src/views/phone_view.js @@ -276,7 +276,7 @@ exports.phoneView = ({ available = false, state = null, pams = [], history = [], span({ class: 'inbox-filters-label' }, i18n.inboxFiltersLabel || 'Filters:'), unheard.length ? form({ method: 'POST', action: '/phone/heard-all', class: 'inbox-vis-toggle' }, ...unheard.map(k => input({ type: 'hidden', name: 'keys', value: k })), - button({ type: 'submit', class: 'btn' }, `${i18n.inboxMarkRead || i18n.inboxMarkAllRead} (${unheard.length})`) + button({ type: 'submit', class: 'btn' }, `${i18n.inboxMarkAllRead} (${unheard.length})`) ) : null, deletable.length ? form({ method: 'POST', action: '/phone/delete-shown', class: 'inbox-vis-toggle inbox-bulk-delete' }, ...(showRecords ? records.map(m => input({ type: 'hidden', name: 'keys', value: m.key })) : []), diff --git a/src/views/podcasts_view.js b/src/views/podcasts_view.js index f95643e..4c7141f 100644 --- a/src/views/podcasts_view.js +++ b/src/views/podcasts_view.js @@ -1,14 +1,14 @@ -const { div, h2, h3, p, section, button, form, a, span, br, textarea, input, label, select, option, img, audio: audioHyperaxe, video: videoHyperaxe } = require("../server/node_modules/hyperaxe"); -const { clearnetItemHref, template, i18n, renderStateChip, renderCardTop, renderSubscriptionBox, renderModuleStats, renderOpinionsVoting, renderEngagement, moduleIsEmpty, contentDeleteAction, paged, renderMediaLockNote, renderEditedChip, renderDetailShare } = require("./main_views"); +const { div, h2, h3, p, section, button, form, a, span, br, textarea, input, label, select, option, img, table, tr, td, audio: audioHyperaxe, video: videoHyperaxe } = require("../server/node_modules/hyperaxe"); +const { clearnetItemHref, template, i18n, userLink, renderStateChip, renderContentActions, renderSubscriptionBox, renderModuleStats, renderOpinionsVoting, renderEngagement, moduleIsEmpty, contentDeleteAction, paged } = require("./main_views"); const { renderCommentsSection } = require("./comments_view"); const { renderStyledText } = require("../backend/renderStyledText"); -const { renderReachChip, renderClearnetSelector, renderClearnetSwitch, renderLicenseSelect, renderLicenseChip } = require("./clearnet_view"); +const { renderReachChip, renderClearnetSelector, renderClearnetSwitch } = require("./clearnet_view"); +const moment = require("../server/node_modules/moment"); const { config } = require("../server/SSB_server.js"); const userId = config.keys.id; -const BASE_FILTERS = ["ALL", "RECENT", "MINE", "FAVORITES", "TOP", "VIEWERS"]; -const SORTS = ["recent", "oldest", "top"]; +const BASE_FILTERS = ["ALL", "RECENT", "MINE", "TOP", "VIEWERS"]; const CATEGORIES = ["NEWS", "MUSIC", "TALK", "EDUCATION", "TECH", "CULTURE", "COMMUNITY", "OASIS", "OTHER"]; const cap = (s) => s.charAt(0) + s.slice(1).toLowerCase(); @@ -16,6 +16,7 @@ const filterLabel = (f) => String(i18n[`podcastFilter${cap(f)}`] || f).toUpperCa const catLabel = (c) => String(i18n[`podcastCategory${cap(c)}`] || c).toUpperCase(); const channelHref = (ch) => `/podcasts/${encodeURIComponent(ch.id)}`; const episodeHref = (ep) => `/podcasts/episode/${encodeURIComponent(ep.id)}`; +const fmt = (d) => moment(d).format("YYYY/MM/DD HH:mm"); const blobSrc = (m) => `/blob/${encodeURIComponent(m.blobId)}`; const renderTags = (tags) => (Array.isArray(tags) && tags.length) @@ -31,13 +32,21 @@ const renderCover = (ch, cls = "podcast-cover") => { const countsLine = (ch) => div({ class: "tribe-card-members" }, span({ class: "tribe-members-count" }, `${i18n.podcastPlaysLabel}: ${ch.playCount || 0}`) ); +const infoTable = (item) => 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" }, fmt(item.createdAt)) + ), + tr( + td({ class: "tribe-info-value", colspan: "4" }, userLink(item.author)) + ) +); const podcastChipFor = (mode, census) => { if (mode === "ALL") return true; if (!Array.isArray(census)) return true; if (mode === "MINE") return census.some(c => String(c.author) === String(userId)); if (mode === "RECENT") return census.length > 0; - if (mode === "FAVORITES") return census.some(c => c.isFavorite); if (mode === "TOP") return census.some(c => (c.opinionCount || 0) > 0 || (c.spreadCount || 0) > 0); if (mode === "VIEWERS") return census.some(c => (c.playCount || 0) > 0); if (CATEGORIES.includes(mode)) return census.some(c => c.category === mode); @@ -47,12 +56,15 @@ exports.podcastChipFor = podcastChipFor; const renderChannelCard = (ch, params = {}) => div({ class: "tribe-card podcast-card" }, - renderCardTop(ch.id, channelHref(ch), { date: ch.createdAt, chips: [renderStateChip("neutral", "", catLabel(ch.category)), ch.clearnet === true ? renderReachChip(true, i18n, clearnetItemHref('podcasts', ch.title, ch.id)) : null], author: ch.author, favKind: "podcasts", isFavorite: ch.isFavorite, reportTitle: ch.title, spread: (params.spreadMap && params.spreadMap.get(ch.id)) || null, deleteAction: String(ch.author) === String(userId) ? contentDeleteAction("podcast", ch.id) : undefined }), + div({ class: "card-header activity-card-header" }, + span(), + renderContentActions(ch.id, channelHref(ch), { author: ch.author, favKind: "podcasts", isFavorite: ch.isFavorite, reportTitle: ch.title, spread: (params.spreadMap && params.spreadMap.get(ch.id)) || null, deleteAction: String(ch.author) === String(userId) ? contentDeleteAction("podcast", ch.id) : undefined }) + ), div({ class: "tribe-card-body" }, ch.cover && ch.cover.blobId ? a({ href: channelHref(ch) }, renderCover(ch, "podcast-card-cover")) : null, div({ class: "shop-title-row" }, h2({ class: "tribe-card-title" }, a({ href: channelHref(ch) }, ch.title || "—"))), - countsLine(ch), - null + div({ class: "card-chips-row" }, renderStateChip("neutral", "", catLabel(ch.category)), ch.clearnet === true ? renderReachChip(true, i18n, clearnetItemHref('podcasts', ch.title, ch.id)) : null), + countsLine(ch) ) ); @@ -81,12 +93,11 @@ const renderChannelForm = (ch) => label(i18n.podcastDescriptionLabel), br(), textarea({ name: "description", rows: 5, maxlength: "3000", placeholder: i18n.podcastDescriptionPlaceholder }, ch ? ch.description : ""), br(), label(i18n.podcastCoverLabel), br(), - (ch && renderMediaLockNote(ch)) || input({ type: "file", name: "cover", accept: "image/*,video/*" }), br(), br(), + input({ type: "file", name: "cover", accept: "image/*,video/*" }), br(), br(), label(i18n.podcastCategoryLabel), br(), select({ name: "category" }, ...CATEGORIES.map(c => option({ value: c, ...((ch ? ch.category : "TALK") === c ? { selected: true } : {}) }, catLabel(c)))), br(), br(), label(i18n.podcastTagsLabel), br(), input({ type: "text", name: "tags", maxlength: "200", placeholder: i18n.podcastTagsPlaceholder, value: ch ? ch.tags.join(", ") : "" }), br(), br(), - ...renderLicenseSelect(ch ? ch.license : undefined, i18n), renderClearnetSelector(ch ? !!ch.clearnet : false, i18n), br(), button({ type: "submit", class: "create-button" }, ch ? i18n.podcastUpdate : i18n.podcastCreate) ) @@ -98,12 +109,10 @@ const renderEpisodeForm = (ch, ep) => form({ method: "POST", action: ep ? `/podcasts/episode/update/${encodeURIComponent(ep.id)}` : `/podcasts/${encodeURIComponent(ch.id)}/episodes`, enctype: "multipart/form-data" }, label(i18n.podcastEpisodeTitleLabel), br(), input({ type: "text", name: "title", maxlength: "120", required: true, placeholder: i18n.podcastEpisodeTitlePlaceholder, value: ep ? ep.title : "" }), br(), - label(i18n.podcastEpisodeNumberLabel), br(), - input({ type: "number", name: "number", min: "1", step: "1", value: ep ? String(ep.number || "") : "" }), br(), label(i18n.podcastEpisodeDescriptionLabel), br(), textarea({ name: "description", rows: 6, maxlength: "5000", placeholder: i18n.podcastEpisodeDescriptionPlaceholder }, ep ? ep.description : ""), br(), label(i18n.podcastEpisodeAudioLabel), br(), - (ep && renderMediaLockNote(ep)) || input({ type: "file", name: "media", accept: "audio/*,video/*", ...(ep ? {} : { required: true }) }), br(), br(), + input({ type: "file", name: "media", accept: "audio/*,video/*", ...(ep ? {} : { required: true }) }), br(), br(), label(i18n.podcastTagsLabel), br(), input({ type: "text", name: "tags", maxlength: "200", placeholder: i18n.podcastTagsPlaceholder, value: ep ? ep.tags.join(", ") : "" }), br(), br(), button({ type: "submit", class: "create-button" }, ep ? i18n.podcastUpdate : i18n.podcastEpisodePublish) @@ -135,10 +144,7 @@ exports.podcastsView = async (channels, filter = "ALL", params = {}) => { form({ method: "GET", action: "/podcasts", class: "filter-box" }, input({ type: "hidden", name: "filter", value: f }), input({ type: "text", name: "q", value: q, placeholder: i18n.podcastSearchPlaceholder, class: "filter-box__input" }), - div({ class: "filter-box__controls" }, - select({ name: "sort", class: "filter-box__select" }, - ...SORTS.map(s => option({ value: s, ...((params.sort || "recent") === s ? { selected: true } : {}) }, i18n[`audioSort${s.charAt(0).toUpperCase()}${s.slice(1)}`]))), - button({ type: "submit", class: "filter-box__button" }, i18n.searchButton)) + div({ class: "filter-box__controls" }, button({ type: "submit", class: "filter-box__button" }, i18n.searchButton)) ) ), div({ class: "tribe-grid" }, list.length ? paged(list).map(c => renderChannelCard(c, params)) : p(i18n.podcastNoItems)) @@ -153,11 +159,9 @@ const episodeChips = (ep) => ep.opinionCount > 0 ? renderStateChip("mutuals", "ꔍ", String(ep.opinionCount)) : null ); -const episodeTop = (ep, params = {}, returnTo) => - renderCardTop(ep.id, episodeHref(ep), { +const episodeActions = (ep, params = {}, returnTo) => + renderContentActions(ep.id, episodeHref(ep), { author: ep.author, - date: ep.createdAt, - chips: [episodeChips(ep)], favKind: "podcasts", isFavorite: ep.isFavorite, reportTitle: ep.title, @@ -171,10 +175,15 @@ const episodeRow = (ch, ep, params = {}, returnTo) => div({ class: "podcast-episode-row" }, span({ class: "podcast-episode-number" }, `#${ep.number}`), div({ class: "podcast-episode-main" }, - episodeTop(ep, params, returnTo), - a({ href: episodeHref(ep), class: "podcast-episode-title" }, ep.title), + div({ class: "emergency-update-head podcast-episode-head" }, + div({ class: "podcast-episode-head-left" }, + a({ href: episodeHref(ep), class: "podcast-episode-title" }, ep.title), + episodeChips(ep) + ), + episodeActions(ep, params, returnTo) + ), ep.description ? p({ class: "tribe-side-description" }, ...renderStyledText(ep.description)) : null, - null + p({ class: "card-footer" }, span({ class: "date-link" }, fmt(ep.createdAt))) ) ); @@ -201,12 +210,15 @@ exports.singleChannelView = async (ch, params = {}) => { ) : null; const side = div({ class: "tribe-side" }, - renderCardTop(ch.id, href, { arrow: false, date: ch.createdAt, chips: [renderReachChip(!!ch.clearnet, i18n, ch.clearnet ? clearnetItemHref('podcasts', ch.title, ch.id) : null), renderLicenseChip(ch.license), renderStateChip("neutral", "", catLabel(ch.category))], bylineExtra: [renderEditedChip(ch.tipId, ch.rootId)], author: ch.author, favKind: "podcasts", isFavorite: ch.isFavorite, reportTitle: ch.title, spread: params.spread || null, returnTo: href, deleteAction: isAuthor ? contentDeleteAction("podcast", ch.id) : undefined }), - div({ class: "shop-title-row" }, h2({ class: "tribe-card-title" }, ch.title)), + div({ class: "card-header activity-card-header" }, + renderContentActions(ch.id, href, { author: ch.author, favKind: "podcasts", isFavorite: ch.isFavorite, reportTitle: ch.title, spread: params.spread || null, returnTo: href, deleteAction: isAuthor ? contentDeleteAction("podcast", ch.id) : undefined }) + ), + div({ class: "shop-title-row" }, h2({ class: "tribe-card-title" }, ch.title), renderReachChip(!!ch.clearnet, i18n, ch.clearnet ? clearnetItemHref('podcasts', ch.title, ch.id) : null)), isAuthor ? renderClearnetSwitch("podcasts", ch.rootId || ch.id, !!ch.clearnet) : null, + div({ class: "card-chips-row" }, renderStateChip("neutral", "", catLabel(ch.category))), ch.cover && ch.cover.blobId ? a({ href: channelHref(ch), class: "podcast-cover-link" }, renderCover(ch, "podcast-cover podcast-cover-large")) : null, - renderDetailShare(ch.id, href, ch.title), ch.description ? p({ class: "tribe-side-description" }, ...renderStyledText(ch.description)) : null, + infoTable(ch), countsLine(ch), isAuthor ? div({ class: "tribe-side-actions podcast-episode-add" }, @@ -223,11 +235,16 @@ exports.singleChannelView = async (ch, params = {}) => { isAuthor && mode === "EPISODE" ? renderEpisodeForm(ch, null) : null, allEpisodes.length ? div({ class: "podcast-featured" }, - episodeTop(allEpisodes[0], params, href), - h2({ class: "tribe-card-title" }, a({ href: episodeHref(allEpisodes[0]) }, `#${allEpisodes[0].number} · ${allEpisodes[0].title}`)), + div({ class: "emergency-update-head podcast-episode-head" }, + div({ class: "podcast-episode-head-left" }, + h2({ class: "tribe-card-title" }, a({ href: episodeHref(allEpisodes[0]) }, `#${allEpisodes[0].number} · ${allEpisodes[0].title}`)), + episodeChips(allEpisodes[0]) + ), + episodeActions(allEpisodes[0], params, href) + ), renderPlayer(allEpisodes[0]), allEpisodes[0].description ? p({ class: "tribe-side-description" }, ...renderStyledText(allEpisodes[0].description)) : null, - null + p({ class: "card-footer" }, span({ class: "date-link" }, fmt(allEpisodes[0].createdAt))) ) : null, div({ class: "card-section podcast-episodes", id: "episodes" }, @@ -271,25 +288,29 @@ exports.singleEpisodeView = async (ep, params = {}) => { ] : []; const side = div({ class: "tribe-side" }, - renderCardTop(ep.id, href, { arrow: false, date: ep.createdAt, chips: [renderReachChip(!!ch.clearnet, i18n, ch.clearnet ? clearnetItemHref('podcasts', ch.title, ch.id) : null), renderLicenseChip(ch.license), episodeChips(ep)], bylineExtra: [renderEditedChip(ep.tipId, ep.rootId)], author: ep.author, favKind: "podcasts", isFavorite: ep.isFavorite, reportTitle: ep.title, spread: params.spread || null, returnTo: href, torrentFrom: { blobId: ep.media && ep.media.blobId, name: ep.title }, deleteAction: isAuthor ? contentDeleteAction("podcastEpisode", ep.id) : undefined }), - div({ class: "shop-title-row" }, h2({ class: "tribe-card-title" }, a({ href: channelHref(ch), class: "user-link" }, ch.title || i18n.podcastsTitle))), + div({ class: "card-header activity-card-header" }, + renderContentActions(ep.id, href, { author: ep.author, favKind: "podcasts", isFavorite: ep.isFavorite, reportTitle: ep.title, spread: params.spread || null, returnTo: href, torrentFrom: { blobId: ep.media && ep.media.blobId, name: ep.title }, deleteAction: isAuthor ? contentDeleteAction("podcastEpisode", ep.id) : undefined }) + ), + div({ class: "shop-title-row" }, h2({ class: "tribe-card-title" }, a({ href: channelHref(ch), class: "user-link" }, ch.title || i18n.podcastsTitle)), renderReachChip(!!ch.clearnet, i18n, ch.clearnet ? clearnetItemHref('podcasts', ch.title, ch.id) : null)), ch.cover && ch.cover.blobId ? a({ href: channelHref(ch), class: "podcast-cover-link" }, renderCover(ch, "podcast-cover podcast-cover-large")) : null, - renderDetailShare(ep.id, href, ep.title), ep.description ? p({ class: "tribe-side-description" }, ...renderStyledText(ep.description)) : null, + infoTable(ep), renderTags(ep.tags), ownerActions.length ? div({ class: "tribe-side-actions owner-actions" }, ...ownerActions) : null ); const main = div({ class: "tribe-main" }, div({ class: "emergency-update-head podcast-episode-head" }, div({ class: "podcast-episode-head-left" }, - h2({ class: "tribe-card-title" }, `#${ep.number} · ${ep.title}`) + h2({ class: "tribe-card-title" }, `#${ep.number} · ${ep.title}`), + episodeChips(ep) ), headActions.length ? div({ class: "tribe-side-actions emergency-update-actions" }, ...headActions) : null ), renderPlayer(ep), renderEngagement(ep.id, renderOpinionsVoting("/podcasts/opinions", ep.id, ep.opinions, href, ep.opinions_inhabitants), - renderCommentsSection({ action: `/podcasts/episode/${encodeURIComponent(ep.id)}/comments`, comments: params.comments || [], returnTo: href })), + renderCommentsSection({ action: `/podcasts/episode/${encodeURIComponent(ep.id)}/comments`, comments: params.comments || [], returnTo: href }) + ), isAuthor && mode === "EDIT" ? renderEpisodeForm(ch, ep) : null ); return template( diff --git a/src/views/polls_view.js b/src/views/polls_view.js index 1f82cd9..61461fd 100644 --- a/src/views/polls_view.js +++ b/src/views/polls_view.js @@ -1,9 +1,9 @@ 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, renderCardTop, renderStateChip, renderLifespanChip, renderSpreadEditWarning, renderModuleStatsBy, moduleIsEmpty, contentDeleteAction, paged, renderEditedChip, renderMediaLockNote, renderDetailShare } = require("./main_views"); +const { template, i18n, userLink, renderOpinionsVoting, renderEngagement, renderSpreadButton, renderContentActions, renderStateChip, renderLifespanChip, renderSpreadEditWarning, renderModuleStatsBy, moduleIsEmpty, contentDeleteAction, paged, renderCreatedRows } = require("./main_views"); const moment = require("../server/node_modules/moment"); const { config } = require("../server/SSB_server.js"); -const { MAX_OPTIONS, MAX_OPTION_LENGTH } = require("../models/polls_model_limits"); +const { MAX_OPTIONS, MIN_OPTIONS, MAX_OPTION_LENGTH } = require("../models/polls_model_limits"); const userId = config.keys.id; @@ -103,15 +103,18 @@ const renderPollCard = (poll, filter, spreadInfo, basePath = "/polls") => { renderLifespanChip(poll.lifetime, i18n) ].filter(Boolean); return div({ class: "tribe-card poll-card" + (isOwn ? " own-content" : "") }, - renderCardTop(poll.id, href, { date: poll.createdAt, chips, spread: spreadInfo || null, author: poll.author, favKind: 'polls', isFavorite: poll.isFavorite, reportTitle: poll.question, deleteAction: isOwn ? contentDeleteAction('poll', poll.id) : undefined }), + div({ class: "card-header activity-card-header" }, + span(), + renderContentActions(poll.id, href, { spread: spreadInfo || null, author: poll.author, favKind: 'polls', isFavorite: poll.isFavorite, reportTitle: poll.question, deleteAction: isOwn ? contentDeleteAction('poll', poll.id) : undefined }) + ), div({ class: "tribe-card-body" }, div({ class: "shop-title-row" }, h2({ class: "tribe-card-title" }, a({ href }, poll.question)) ), + chips.length ? div({ class: "card-chips-row" }, ...chips) : null, div({ class: "tribe-card-members" }, span({ class: "tribe-members-count" }, `${i18n.pollVoters}: ${poll.totalVoters}`) - ), - null + ) ) ); }; @@ -119,8 +122,6 @@ const renderPollCard = (poll, filter, spreadInfo, basePath = "/polls") => { const renderCreateForm = (poll = null, params = {}) => { const isEdit = !!poll; const action = isEdit ? `/polls/update/${encodeURIComponent(poll.id)}` : "/polls/create"; - const lock = isEdit ? renderMediaLockNote(poll) : null; - const ro = lock ? { readonly: true } : {}; return section( params.spreadWarning || null, div({ class: "div-center audio-form" }, @@ -128,7 +129,7 @@ const renderCreateForm = (poll = null, params = {}) => { form({ method: "POST", action }, label(i18n.pollQuestion), br(), - input({ type: "text", name: "question", required: true, maxlength: "300", value: isEdit ? poll.question : "", placeholder: i18n.pollQuestionPlaceholder, ...ro }), + input({ type: "text", name: "question", required: true, maxlength: "300", value: isEdit ? poll.question : "", placeholder: i18n.pollQuestionPlaceholder }), br(), br(), label(i18n.pollOptions), br(), @@ -136,10 +137,8 @@ const renderCreateForm = (poll = null, params = {}) => { name: "options", rows: String(MAX_OPTIONS), required: true, - maxlength: String(MAX_OPTIONS * (MAX_OPTION_LENGTH + 1)), - ...ro + maxlength: String(MAX_OPTIONS * (MAX_OPTION_LENGTH + 1)) }, isEdit ? poll.options.join("\n") : ""), - lock, br(), div({ class: "poll-switch" }, input({ type: "hidden", name: "anonymous", value: "0" }), @@ -274,10 +273,12 @@ exports.singlePollView = async (poll, comments = [], params = {}) => { : null; const pollSide = div({ class: "tribe-side" }, - renderCardTop(poll.id, href, { arrow: false, date: poll.createdAt, chips, bylineExtra: [renderEditedChip(poll.tipId, poll.rootId)], spread: params.spreads || null, author: poll.author, favKind: 'polls', isFavorite: poll.isFavorite, returnTo: href, reportTitle: poll.question, deleteAction: isOwn ? contentDeleteAction('poll', poll.id) : undefined }), + div({ class: "card-header activity-card-header" }, + renderContentActions(poll.id, href, { spread: params.spreads || null, author: poll.author, favKind: 'polls', isFavorite: poll.isFavorite, returnTo: href, reportTitle: poll.question, deleteAction: isOwn ? contentDeleteAction('poll', poll.id) : undefined }) + ), div({ class: "shop-title-row" }, h2({ class: "tribe-card-title" }, poll.question)), - renderDetailShare(poll.id, href, poll.question), - table({ class: "tribe-info-table jobs-info-table" }, ...infoRows), + chips.length ? div({ class: "card-chips-row" }, ...chips) : null, + table({ class: "tribe-info-table jobs-info-table" }, ...infoRows, ...renderCreatedRows(poll.createdAt, poll.author)), tagsNode, div({ class: "tribe-card-members" }, span({ class: "tribe-members-count" }, `${i18n.pollVoters}: ${poll.totalVoters}`) @@ -307,9 +308,10 @@ exports.singlePollView = async (poll, comments = [], params = {}) => { : (poll.voters.length ? div({ class: "card-assigned-list" }, ...poll.voters.map(v => userLink(v))) : null), - renderEngagement(poll.id, - poll.tribeId ? null : renderOpinionsVoting('/polls/opinions', poll.id, poll.opinions, href, poll.opinions_inhabitants), - poll.tribeId ? null : renderComments(poll, comments, basePath)) + poll.tribeId ? null : renderEngagement(poll.id, + renderOpinionsVoting('/polls/opinions', poll.id, poll.opinions, href, poll.opinions_inhabitants), + renderComments(poll, comments, basePath) + ) ); return template( diff --git a/src/views/projects_view.js b/src/views/projects_view.js index a5e58c9..df75a33 100644 --- a/src/views/projects_view.js +++ b/src/views/projects_view.js @@ -1,11 +1,11 @@ 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 { renderZoomableImage } = require("./gallery_view") const { renderCommentsSection: renderSharedCommentsSection } = require("./comments_view"); -const { clearnetItemHref, template, i18n, renderOpinionsVoting, renderEngagement, userLink, renderStateChip, renderLifespanChip, renderEcoTax, renderCardTop, renderSpreadEditWarning, renderSubscriptionBox, renderModuleStatsBy, moduleIsEmpty, renderEcoValueChip, contentDeleteAction, paged, renderEditedChip, renderMediaLockNote, renderDetailShare } = require("./main_views") +const { clearnetItemHref, template, i18n, renderOpinionsVoting, renderEngagement, userLink, renderStateChip, renderLifespanChip, renderEcoTax, renderSpreadButton, renderContentActions, renderSpreadEditWarning, renderSubscriptionBox, renderModuleStatsBy, moduleIsEmpty, renderEcoValueChip, contentDeleteAction, paged, renderCreatedRows } = require("./main_views") const moment = require("../server/node_modules/moment") const { config } = require("../server/SSB_server.js") const { renderStyledText } = require("../backend/renderStyledText") -const { renderMapEmbedWithZoom } = require("./maps_view") +const { renderMapLocationUrl, renderMapEmbed, renderMapLocationVisitLabel, renderMapEmbedWithZoom } = require("./maps_view") const { renderReachChip, renderClearnetSelector, renderClearnetSwitch } = require("./clearnet_view") const renderMediaBlob = (value, attrs = {}) => { @@ -414,7 +414,11 @@ const renderProjectList = exports.renderProjectList = (projects, filter, spreadM const isOwn = pr.author && String(pr.author) === String(userId) return div({ class: "trending-card project-card" + (isOwn ? " own-content" : "") }, - renderCardTop(pr.id || pr.key, `/projects/${encodeURIComponent(pr.id)}`, { date: pr.createdAt, chips, spread: spreadMap.get(pr.id || pr.key) || null, author: pr.author, favKind: 'projects', isFavorite: pr.isFavorite, reportTitle: pr.title, deleteAction: isOwn ? contentDeleteAction('project', pr.id || pr.key) : undefined }), + div( + { class: "card-header activity-card-header" }, + span(), + 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, deleteAction: isOwn ? contentDeleteAction('project', pr.id || pr.key) : undefined }) + ), div({ class: "card-section project-card-body" }, heroNode, div({ class: "shop-title-row" }, @@ -422,6 +426,7 @@ const renderProjectList = exports.renderProjectList = (projects, filter, spreadM a({ href: `/projects/${encodeURIComponent(pr.id)}` }, pr.title || i18n.projectsTitle) ) ), + chips.length ? div({ class: "card-chips-row" }, ...chips) : null, goal > 0 ? div({ class: "price-chip" }, `${pr.goal} ECO`) : null, @@ -430,8 +435,7 @@ const renderProjectList = exports.renderProjectList = (projects, filter, spreadM : null, div({ class: "tribe-card-members" }, span({ class: "tribe-members-count" }, `${i18n.projectFollowers}: ${followersCount(pr)}`) - ), - null + ) ) ) }) @@ -445,7 +449,6 @@ const renderProjectForm = (project, mode, spreadWarning = null) => { const deadlineValue = pr.deadline ? moment(pr.deadline).format("YYYY-MM-DDTHH:mm") : "" const milestoneMax = deadlineValue || undefined const returnTo = "/projects?filter=MINE" - const mediaLock = isEdit ? renderMediaLockNote(pr) : null return div( { class: "div-center project-form" }, @@ -467,7 +470,10 @@ const renderProjectForm = (project, mode, spreadWarning = null) => { br(), label(i18n.projectImage), br(), - ...(mediaLock ? [mediaLock] : [input({ type: "file", name: "image" }), br(), label(i18n.uploadMedia), br(), input({ type: "file", name: "blob" })]), + input({ type: "file", name: "image" }), + br(), + label(i18n.uploadMedia), br(), + input({ type: "file", name: "blob" }), br(), pr.image ? renderMediaBlob(pr.image) : null, br(), @@ -598,6 +604,7 @@ exports.singleProjectView = async (project, filter, comments, params = {}) => { renderLifespanChip(pr.lifetime, i18n), renderEcoTax(pr.msgSize, pr.id || pr.key), renderReachChip(isClearnet, i18n, isClearnet ? clearnetItemHref('projects', pr.title, pr.id || pr.key) : null), + isAuthor && canClearnet ? renderClearnetSwitch('projects', pr.rootId || pr.id || pr.key, isClearnet) : null, pr.subscription ? ((isAuthor || pr.subscription.subscribed === true) ? renderStateChip("mutuals", "✉", i18n.subscriptionOn) @@ -610,11 +617,11 @@ exports.singleProjectView = async (project, filter, comments, params = {}) => { sideActions.push(isFollower ? form({ method: "POST", action: `/projects/unfollow/${encodeURIComponent(pr.id)}` }, input({ type: "hidden", name: "returnTo", value: returnTo }), - button({ type: "submit", class: "tribe-action-btn danger-btn" }, String(i18n.projectUnfollowButton).toUpperCase()) + button({ type: "submit", class: "unsubscribe-btn" }, i18n.projectUnfollowButton) ) : form({ method: "POST", action: `/projects/follow/${encodeURIComponent(pr.id)}` }, input({ type: "hidden", name: "returnTo", value: returnTo }), - button({ type: "submit", class: "tribe-action-btn" }, String(i18n.projectFollowButton).toUpperCase()) + button({ type: "submit", class: "subscribe-btn" }, i18n.projectFollowButton) ) ) } @@ -643,17 +650,19 @@ exports.singleProjectView = async (project, filter, comments, params = {}) => { } const projectSide = div({ class: "tribe-side" }, - renderCardTop(pr.id || pr.key, `/projects/${encodeURIComponent(pr.id)}`, { arrow: false, date: pr.createdAt, chips, bylineExtra: [renderEditedChip(pr.id, pr.rootId)], spread: params.spreads || null, author: pr.author, favKind: 'projects', isFavorite: pr.isFavorite, reportTitle: pr.title, returnTo: `/projects/${encodeURIComponent(pr.id)}`, deleteAction: isAuthor ? contentDeleteAction('project', pr.id || pr.key) : undefined }), + div({ class: "card-header activity-card-header" }, + renderContentActions(pr.id || pr.key, `/projects/${encodeURIComponent(pr.id)}`, { spread: params.spreads || null, author: pr.author, favKind: 'projects', isFavorite: pr.isFavorite, reportTitle: pr.title, returnTo: `/projects/${encodeURIComponent(pr.id)}`, deleteAction: isAuthor ? contentDeleteAction('project', pr.id || pr.key) : undefined }) + ), div({ class: "shop-title-row" }, h2({ class: "tribe-card-title" }, pr.title) ), - isAuthor && canClearnet ? renderClearnetSwitch('projects', pr.rootId || pr.id || pr.key, isClearnet) : null, + chips.length ? div({ class: "card-chips-row" }, ...chips) : null, pr.image ? renderMediaBlob(pr.image, { class: "tribe-detail-image" }) : null, - renderDetailShare(pr.id || pr.key, `/projects/${encodeURIComponent(pr.id)}`, pr.title), div({ class: "price-chip" }, `${pr.goal || 0} ECO`), div({ class: "job-price-line card-salary" }, `${i18n.projectFollowers}: ${followersCount(pr)}`), renderProgressBlock(i18n.projectProgress + ":", `${pct}%`, pct, 100), goal > 0 ? renderProgressBlock(i18n.projectFunding + ":", `${fundingPct}%`, fundingPct, 100) : null, + table({ class: "tribe-info-table" }, ...renderCreatedRows(pr.createdAt, pr.author)), sideActions.length ? div({ class: "tribe-side-actions" }, ...sideActions) : null, pr.subscription ? renderSubscriptionBox({ @@ -688,7 +697,8 @@ exports.singleProjectView = async (project, filter, comments, params = {}) => { action: `/projects/${encodeURIComponent(pr.id || pr.key)}/comments`, comments, returnTo: `/projects/${encodeURIComponent(pr.id || pr.key)}` - })), + }) + ), ) return template( diff --git a/src/views/report_view.js b/src/views/report_view.js index e71d220..3dc904f 100644 --- a/src/views/report_view.js +++ b/src/views/report_view.js @@ -1,6 +1,6 @@ -const { div, h2, p, section, button, form, a, textarea, br, input, span, label, select, option, video, audio, table, tr, td } = require("../server/node_modules/hyperaxe"); +const { hr, 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 { renderCommentsSection: renderSharedCommentsSection } = require("./comments_view"); -const { template, i18n, renderOpinionsVoting, renderEngagement, renderStateChip, renderLifespanChip, renderEcoTax, renderSpreadEditWarning, renderCardTop, renderDocumentActions, renderModuleStatsBy, moduleIsEmpty, contentDeleteAction, paged, renderEditedChip, renderDetailShare } = require("./main_views"); +const { template, i18n, renderOpinionsVoting, renderEngagement, renderStateChip, renderLifespanChip, renderEcoTax, renderSpreadButton, renderSpreadEditWarning, renderContentActions, renderDocumentActions, renderModuleStatsBy, moduleIsEmpty, contentDeleteAction, paged, renderCreatedRows } = require("./main_views"); const { renderPhotoGallery, renderGalleryFields, renderZoomableImage } = require("./gallery_view"); const { renderTribeOriginChip } = require("./clearnet_view"); const { config } = require("../server/SSB_server.js"); @@ -50,6 +50,27 @@ const SEVERITY_BY_FILTER = { const opt = (value, isSelected, text) => option(Object.assign({ value }, isSelected ? { ...("selected" ? { selected: true } : {})} : {}), text); +const hasAnyTemplateValue = (t) => { + if (!t || typeof t !== "object") return false; + return Object.values(t).some((v) => String(v || "").trim()); +}; + +const renderCardField = (labelText, value = "") => + div( + { class: "card-field" }, + span({ class: "card-label" }, labelText), + span({ class: "card-value" }, ...renderStyledText(String(value ?? ""))) + ); + +const renderStackedTextField = (lbl, val) => + String(val || "").trim() + ? div( + { class: "card-field card-field-stacked" }, + span({ class: "card-label" }, lbl), + span({ class: "card-value" }, ...renderStyledText(String(val))) + ) + : null; + const renderReportStatusSetter = (report) => { const st = normalizeStatus(report && report.status ? report.status : "OPEN"); return form( @@ -66,6 +87,61 @@ const renderReportStatusSetter = (report) => { ); }; +const renderTemplateDetails = (report) => { + const category = normU(report.category); + const t = report.template && typeof report.template === "object" ? report.template : {}; + if (!hasAnyTemplateValue(t)) return null; + + const renderValueField = (lbl, val) => + String(val || "").trim() + ? renderCardField(lbl, String(val)) + : null; + + if (category === "BUGS") { + return div( + { class: "report-template" }, + h2({ class: "report-template-title" }, i18n.reportsBugTemplateTitle), + renderStackedTextField(i18n.reportsStepsToReproduceLabel + ":", t.stepsToReproduce), + renderStackedTextField(i18n.reportsExpectedBehaviorLabel + ":", t.expectedBehavior), + renderStackedTextField(i18n.reportsActualBehaviorLabel + ":", t.actualBehavior), + renderStackedTextField(i18n.reportsEnvironmentLabel + ":", t.environment), + renderValueField(i18n.reportsReproduceRateLabel + ":", t.reproduceRate) + ); + } + + if (category === "FEATURES") { + return div( + { class: "report-template" }, + h2({ class: "report-template-title" }, i18n.reportsFeatureTemplateTitle), + renderStackedTextField(i18n.reportsProblemStatementLabel + ":", t.problemStatement), + renderStackedTextField(i18n.reportsUserStoryLabel + ":", t.userStory), + renderStackedTextField(i18n.reportsAcceptanceCriteriaLabel + ":", t.acceptanceCriteria) + ); + } + + if (category === "ABUSE") { + return div( + { class: "report-template" }, + h2({ class: "report-template-title" }, i18n.reportsAbuseTemplateTitle), + renderStackedTextField(i18n.reportsWhatHappenedLabel + ":", t.whatHappened), + renderStackedTextField(i18n.reportsReportedUserLabel + ":", t.reportedUser), + renderStackedTextField(i18n.reportsEvidenceLinksLabel + ":", t.evidenceLinks) + ); + } + + if (category === "CONTENT") { + return div( + { class: "report-template" }, + h2({ class: "report-template-title" }, i18n.reportsContentTemplateTitle), + renderStackedTextField(i18n.reportsContentLocationLabel + ":", t.contentLocation), + renderStackedTextField(i18n.reportsWhyInappropriateLabel + ":", t.whyInappropriate), + renderStackedTextField(i18n.reportsEvidenceLinksLabel + ":", t.evidenceLinks) + ); + } + + return null; +}; + const renderReportCommentsSection = (reportId, comments = []) => { return renderSharedCommentsSection({ action: `/reports/${encodeURIComponent(reportId)}/comments`, @@ -74,6 +150,81 @@ const renderReportCommentsSection = (reportId, comments = []) => { }); }; +const renderTemplateForCategory = (category, templateData = {}) => { + const cat = normU(category || "FEATURES"); + const t = templateData && typeof templateData === "object" ? templateData : {}; + const tval = (k) => String(t[k] || ""); + const reproduceRateVal = normU(t.reproduceRate || ""); + + if (cat === "BUGS") { + return div( + { class: "report-template-block" }, + h2({ class: "report-template-title" }, i18n.reportsBugTemplateTitle), + label(i18n.reportsStepsToReproduceLabel), + br(), + textarea({ maxlength: "5000", name: "stepsToReproduce", rows: "4", placeholder: i18n.reportsStepsToReproducePlaceholder }, tval("stepsToReproduce")), + br(), + label(i18n.reportsExpectedBehaviorLabel), + br(), + textarea({ maxlength: "5000", name: "expectedBehavior", rows: "3", placeholder: i18n.reportsExpectedBehaviorPlaceholder }, tval("expectedBehavior")), + br(), + label(i18n.reportsActualBehaviorLabel), + br(), + textarea({ maxlength: "5000", name: "actualBehavior", rows: "3", placeholder: i18n.reportsActualBehaviorPlaceholder }, tval("actualBehavior")), + br(), + label(i18n.reportsEnvironmentLabel), + br(), + textarea({ maxlength: "5000", name: "environment", rows: "3", placeholder: i18n.reportsEnvironmentPlaceholder }, tval("environment")), + br(), + label(i18n.reportsReproduceRateLabel), + br(), + select( + { name: "reproduceRate" }, + opt("", !reproduceRateVal, i18n.reportsReproduceRateUnknown), + opt("ALWAYS", reproduceRateVal === "ALWAYS", i18n.reportsReproduceRateAlways), + opt("OFTEN", reproduceRateVal === "OFTEN", i18n.reportsReproduceRateOften), + opt("SOMETIMES", reproduceRateVal === "SOMETIMES", i18n.reportsReproduceRateSometimes), + opt("RARELY", reproduceRateVal === "RARELY", i18n.reportsReproduceRateRarely), + opt("UNABLE", reproduceRateVal === "UNABLE", i18n.reportsReproduceRateUnable) + ) + ); + } + + if (cat === "CONTENT") { + return div( + { class: "report-template-block" }, + h2({ class: "report-template-title" }, i18n.reportsContentTemplateTitle), + label(i18n.reportsContentLocationLabel), + br(), + textarea({ maxlength: "5000", name: "contentLocation", rows: "3", placeholder: i18n.reportsContentLocationPlaceholder }, tval("contentLocation")), + br(), + label(i18n.reportsWhyInappropriateLabel), + br(), + textarea({ maxlength: "5000", name: "whyInappropriate", rows: "4", placeholder: i18n.reportsWhyInappropriatePlaceholder }, tval("whyInappropriate")), + br(), + label(i18n.reportsEvidenceLinksLabel), + br(), + textarea({ maxlength: "5000", name: "evidenceLinks", rows: "3", placeholder: i18n.reportsEvidenceLinksPlaceholder }, tval("evidenceLinks")) + ); + } + + return div( + { class: "report-template-block" }, + h2({ class: "report-template-title" }, i18n.reportsFeatureTemplateTitle), + label(i18n.reportsProblemStatementLabel), + br(), + textarea({ maxlength: "5000", name: "problemStatement", rows: "4", placeholder: i18n.reportsProblemStatementPlaceholder }, tval("problemStatement")), + br(), + label(i18n.reportsUserStoryLabel), + br(), + textarea({ maxlength: "5000", name: "userStory", rows: "3", placeholder: i18n.reportsUserStoryPlaceholder }, tval("userStory")), + br(), + label(i18n.reportsAcceptanceCriteriaLabel), + br(), + textarea({ maxlength: "5000", name: "acceptanceCriteria", rows: "4", placeholder: i18n.reportsAcceptanceCriteriaPlaceholder }, tval("acceptanceCriteria")) + ); +}; + const renderReportStatusChip = (status) => { const s = normalizeStatus(status); const variant = @@ -131,21 +282,21 @@ const renderReportCard = (report, userId, currentFilter = "all", spreadInfo) => origin ? renderTribeOriginChip(origin) : null ].filter(Boolean); - const href = `/reports/${encodeURIComponent(report.id)}`; return div({ class: "tribe-card report-card" }, - origin - ? renderCardTop(report.id, origin.href, { author: report.author, date: report.createdAt, chips, spread: spreadInfo || null, reportTitle: report.title }) - : renderCardTop(report.id, href, { date: report.createdAt, chips, spread: spreadInfo || null, author: report.author, favKind: 'reports', isFavorite: report.isFavorite, reportTitle: report.title, report: false, deleteAction: String(report.author) === String(userId) ? contentDeleteAction('report', report.id) : undefined }), + origin ? null : 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, deleteAction: String(report.author) === String(userId) ? contentDeleteAction('report', report.id) : undefined }) + ), div({ class: "tribe-card-body" }, div({ class: "shop-title-row" }, h2({ class: "tribe-card-title" }, - a({ href: origin ? origin.href : href }, report.title || i18n.reportsTitle) + a({ href: origin ? origin.href : `/reports/${encodeURIComponent(report.id)}` }, report.title || i18n.reportsTitle) ) ), + chips.length ? div({ class: "card-chips-row" }, ...chips) : null, origin ? null : div({ class: "tribe-card-members" }, span({ class: "tribe-members-count" }, `${i18n.reportsConfirmations}: ${confirmations.length}`) - ), - null + ) ) ); }; @@ -213,7 +364,10 @@ exports.reportView = async (reports, filter, reportId, createCategory, params = ); 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(formData.severity || "low"); + const hiddenDescription = String(reportToEdit?.description || ""); return template( title, @@ -271,25 +425,30 @@ exports.reportView = async (reports, filter, reportId, createCategory, params = filter === "edit" ? await renderSpreadEditWarning(reportToEdit && (reportToEdit.id || reportToEdit.key)) : null, filter === "create" ? [ + form( + { id: "report-category-form", method: "GET", action: "/reports" }, + input({ type: "hidden", name: "filter", value: "create" }), + div({ class: "apply-row" }, + select( + { name: "category", class: "report-category-select" }, + opt("FEATURES", selectedCategory === "FEATURES", i18n.reportsCategoryFeatures), + opt("BUGS", selectedCategory === "BUGS", i18n.reportsCategoryBugs), + opt("CONTENT", selectedCategory === "CONTENT", i18n.reportsCategoryContent) + ), + button({ type: "submit", class: "create-button" }, applyLabel) + ) + ), + hr({ class: "form-sep" }), + h2({ class: "report-category-fixed" }, selectedCategory), form( { 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: "" }), label(i18n.reportsTitleLabel), br(), - input({ type: "text", name: "title", maxlength: "100", required: true, value: formData.title || params.prefillTitle || "" }), - br(), - label(i18n.description), - br(), - textarea({ maxlength: "5000", name: "description", rows: "6" }, formData.description || ""), - br(), - label(i18n.reportsCategory), - br(), - select( - { name: "category", required: true }, - opt("FEATURES", selectedCategory === "FEATURES", i18n.reportsCategoryFeatures), - opt("BUGS", selectedCategory === "BUGS", i18n.reportsCategoryBugs), - opt("CONTENT", selectedCategory === "CONTENT", i18n.reportsCategoryContent) - ), + input({ type: "text", name: "title", maxlength: "100", required: true, value: params.prefillTitle || "" }), br(), + renderTemplateForCategory(selectedCategory, params.draft || {}), br(), ...renderGalleryFields(formData, false, 8), label(i18n.uploadMedia), br(), @@ -319,10 +478,7 @@ exports.reportView = async (reports, filter, reportId, createCategory, params = br(), input({ type: "text", name: "title", maxlength: "100", required: true, value: reportToEdit?.title || "" }), br(), - label(i18n.description), - br(), - textarea({ maxlength: "5000", name: "description", rows: "6" }, reportToEdit?.description || ""), - br(), + input({ type: "hidden", name: "description", value: hiddenDescription }), label(i18n.reportsCategory), br(), select( @@ -332,6 +488,7 @@ exports.reportView = async (reports, filter, reportId, createCategory, params = opt("CONTENT", selectedCategory === "CONTENT", i18n.reportsCategoryContent) ), br(), + renderTemplateForCategory(selectedCategory, selectedTemplate), br(), ...renderGalleryFields(reportToEdit || {}, true, 8), label("Tags"), @@ -365,6 +522,7 @@ exports.singleReportView = async (report, filter, comments = [], params = {}) => const btnClass = (v) => (filter === v ? "filter-btn active" : "filter-btn"); const confirmations = Array.isArray(report.confirmations) ? report.confirmations : []; const isAuthor = String(report.author) === String(userId); + const details = renderTemplateDetails(report); const chips = [ renderReportStatusChip(report.status), @@ -375,7 +533,7 @@ exports.singleReportView = async (report, filter, comments = [], params = {}) => ].filter(Boolean); const sideActions = []; - if (!isAuthor && !confirmations.includes(userId)) sideActions.push(form({ method: "POST", action: `/reports/confirm/${encodeURIComponent(report.id)}` }, + sideActions.push(form({ method: "POST", action: `/reports/confirm/${encodeURIComponent(report.id)}` }, button({ type: "submit", class: "tribe-action-btn" }, i18n.reportsConfirmButton) )); const ownerActions = []; @@ -402,17 +560,19 @@ exports.singleReportView = async (report, filter, comments = [], params = {}) => pushRow(i18n.reportsCategory, report.category); const reportSide = div({ class: "tribe-side" }, - renderCardTop(report.id, `/reports/${encodeURIComponent(report.id)}`, { arrow: false, date: report.createdAt, chips, bylineExtra: [renderEditedChip(report.id, report.rootId)], spread: params.spreads || null, author: report.author, favKind: 'reports', isFavorite: report.isFavorite, reportTitle: report.title, report: false, returnTo: `/reports/${encodeURIComponent(report.id)}`, deleteAction: isAuthor ? contentDeleteAction('report', report.id) : undefined }), + div({ class: "card-header activity-card-header" }, + renderContentActions(report.id, `/reports/${encodeURIComponent(report.id)}`, { spread: params.spreads || null, author: report.author, favKind: 'reports', isFavorite: report.isFavorite, reportTitle: report.title, report: false, returnTo: `/reports/${encodeURIComponent(report.id)}`, deleteAction: isAuthor ? contentDeleteAction('report', report.id) : undefined }) + ), div({ class: "shop-title-row" }, h2({ class: "tribe-card-title" }, report.title) ), - renderDetailShare(report.id, `/reports/${encodeURIComponent(report.id)}`, report.title), - table({ class: "tribe-info-table jobs-info-table" }, ...infoRows), + chips.length ? div({ class: "card-chips-row" }, ...chips) : null, + table({ class: "tribe-info-table jobs-info-table" }, ...infoRows, ...renderCreatedRows(report.createdAt, report.author)), tagsNode, div({ class: "tribe-card-members" }, span({ class: "tribe-members-count" }, `${i18n.reportsConfirmations}: ${confirmations.length}`) ), - sideActions.length ? div({ class: "tribe-side-actions report-confirm-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}: `), @@ -429,12 +589,7 @@ exports.singleReportView = async (report, filter, comments = [], params = {}) => const opinionsBar = renderOpinionsVoting('/reports/opinions', report.id, report.opinions, null, report.opinions_inhabitants); const reportMain = div({ class: "tribe-main" }, - String(report.description || "").trim() - ? div({ class: "job-section" }, - h2({ class: "job-section-title" }, i18n.description), - p({ class: "tribe-side-description" }, ...renderStyledText(String(report.description))) - ) - : null, + details ? div({ class: "job-section" }, details) : null, renderPhotoGallery(report, 'report'), renderEngagement(report.id, opinionsBar, renderReportCommentsSection(report.id, comments)) ); @@ -454,7 +609,8 @@ exports.singleReportView = async (report, filter, comments = [], params = {}) => ...(reportChip("features") ? [button({ type: "submit", name: "filter", value: "features", class: btnClass("features") }, String(i18n.reportsFilterFeatures).toUpperCase())] : []), ...(reportChip("bugs") ? [button({ type: "submit", name: "filter", value: "bugs", class: btnClass("bugs") }, String(i18n.reportsFilterBugs).toUpperCase())] : []), ...(reportChip("content") ? [button({ type: "submit", name: "filter", value: "content", class: btnClass("content") }, String(i18n.reportsFilterContent).toUpperCase())] : []), - ...(reportChip("confirmed") ? [button({ type: "submit", name: "filter", value: "confirmed", class: btnClass("confirmed") }, String(i18n.reportsFilterConfirmed).toUpperCase())] : []) + ...(reportChip("confirmed") ? [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" }, @@ -465,8 +621,7 @@ exports.singleReportView = async (report, filter, comments = [], params = {}) => ...(reportChip("sev_low") ? [button({ type: "submit", name: "filter", value: "sev_low", class: btnClass("sev_low") }, String(i18n.reportsSeverityLow).toUpperCase())] : []), ...(reportChip("sev_medium") ? [button({ type: "submit", name: "filter", value: "sev_medium", class: btnClass("sev_medium") }, String(i18n.reportsSeverityMedium).toUpperCase())] : []), ...(reportChip("sev_high") ? [button({ type: "submit", name: "filter", value: "sev_high", class: btnClass("sev_high") }, String(i18n.reportsSeverityHigh).toUpperCase())] : []), - ...(reportChip("sev_critical") ? [button({ type: "submit", name: "filter", value: "sev_critical", class: btnClass("sev_critical") }, String(i18n.reportsSeverityCritical).toUpperCase())] : []), - button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.reportsCreateButton) + ...(reportChip("sev_critical") ? [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/rooms_view.js b/src/views/rooms_view.js index 734c113..ba9efc7 100644 --- a/src/views/rooms_view.js +++ b/src/views/rooms_view.js @@ -1,5 +1,5 @@ -const { div, h2, p, section, button, form, a, span, br, hr, label, textarea, input, select, option, img, details, summary, ul, li } = require("../server/node_modules/hyperaxe") -const { clearnetItemHref, template, i18n, userLink, renderStateChip, renderLifespanChip, renderCardTop, renderEngagement, renderInviteQrCard, renderModuleStatsBy, moduleIsEmpty, liveClock, renderPhoneChip, contentDeleteAction, paged, renderVideoGrid, renderLiveFeed, renderIcon, renderDetailShare } = require("./main_views") +const { div, h2, p, section, button, form, a, span, br, hr, label, textarea, input, select, option, img, table, tr, td, details, summary, ul, li } = require("../server/node_modules/hyperaxe") +const { clearnetItemHref, template, i18n, userLink, renderStateChip, renderLifespanChip, renderContentActions, renderInviteQrCard, renderModuleStatsBy, moduleIsEmpty, liveClock, renderPhoneChip, contentDeleteAction, paged, renderVideoGrid, renderLiveFeed, renderIcon } = require("./main_views") const { renderEncryptedChip, renderTransportChip, renderReachChip, renderClearnetSelector, renderClearnetSwitch } = require("./clearnet_view") const { renderStyledText } = require("../backend/renderStyledText") const moment = require("../server/node_modules/moment") @@ -87,20 +87,16 @@ const renderRoomItem = (room, params = {}) => { const occ = inside && Number.isFinite(Number(params.live.count)) ? { count: Number(params.live.count), max: params.live.max } : occOf(params, room) const canJoin = !room.isClosed && !inside && (room.type === "OPEN" || room.tribeId || String(room.author) === String(userId) || safeArr(room.members).includes(userId)) return li({ class: "mailing-archive-item room-item" }, - renderCardTop(room.rootId, roomHref(room), { - date: room.createdAt, - chips: [ + div({ class: "emergency-update-head mailing-archive-head" }, + div({ class: "mailing-archive-meta" }, + a({ href: roomHref(room), class: "user-link" }, room.title || "—"), + span({ class: "card-label activity-update-counts mailing-counts" }, `👥: ${safeArr(room.members).length}`), ...roomChips(room, occ, inside), room.clearnet === true && clearnetEligible(room) ? renderReachChip(true, i18n, roomClearnetHref(room)) : null, inside ? renderStateChip("whole", "ꘒ", String(i18n.roomYouAreIn).toUpperCase()) : null - ], - spread: (params.spreadMap && params.spreadMap.get(room.rootId)) || null, author: room.author, favKind: "rooms", isFavorite: room.isFavorite, reportTitle: room.title, deleteAction: String(room.author) === String(userId) ? contentDeleteAction("room", room.rootId) : undefined - }), - div({ class: "mailing-archive-meta" }, - a({ href: roomHref(room), class: "user-link" }, room.title || "—"), - span({ class: "card-label activity-update-counts mailing-counts" }, `👥: ${safeArr(room.members).length}`) - ), - null + ), + renderContentActions(room.rootId, roomHref(room), { spread: (params.spreadMap && params.spreadMap.get(room.rootId)) || null, author: room.author, favKind: "rooms", isFavorite: room.isFavorite, reportTitle: room.title, deleteAction: String(room.author) === String(userId) ? contentDeleteAction("room", room.rootId) : undefined }) + ) ) } @@ -224,13 +220,6 @@ const renderLiveControls = (room, live) => div({ class: "room-live-controls" }, button({ type: "submit", class: "tribe-action-btn room-cam-btn-on room-stream-go", title: i18n.roomStreamGo }, "▶") ) : null, - live.streaming == null && live.screenAvailable - ? form({ method: "POST", action: "/rooms/stream", class: "phone-action-form" }, - input({ type: "hidden", name: "screen", value: "1" }), - input({ type: "hidden", name: "returnTo", value: `${roomHref(room)}#room-video` }), - button({ type: "submit", class: "tribe-action-btn", title: i18n.roomScreenWarning }, String(i18n.roomScreen).toUpperCase()) - ) - : null, form({ method: "POST", action: "/rooms/hand", class: "phone-action-form" }, input({ type: "hidden", name: "hand", value: live.hand ? "0" : "1" }), input({ type: "hidden", name: "returnTo", value: roomHref(room) }), @@ -299,7 +288,7 @@ const renderLivePanel = (room, live, occ, canJoin = false) => { you ? null : silenceForm(id, silenced) ) return div({ class: "phone-call-panel room-live-panel" }, - head(div({ class: "card-chips-row" }, renderEncryptedChip(i18n), isPublic ? renderReachChip(true, i18n, roomClearnetHref(room)) : null, renderLiveChip({ count: live.count, max: live.max }), live.joinedAt ? span({ class: "phone-call-clock" }, liveClock(live.joinedAt)) : null, live.sharingScreen ? span({ class: "room-stream-chip", title: i18n.roomScreenWarning }, "🖥 " + String(i18n.roomScreenSharing)) : live.streaming != null ? span({ class: "room-stream-chip", title: live.streaming || "" }, "▶ " + String(live.streaming || i18n.roomStreaming)) : null), renderLiveControls(room, live)), + head(div({ class: "card-chips-row" }, renderEncryptedChip(i18n), isPublic ? renderReachChip(true, i18n, roomClearnetHref(room)) : null, renderLiveChip({ count: live.count, max: live.max }), live.joinedAt ? span({ class: "phone-call-clock" }, liveClock(live.joinedAt)) : null, live.streaming != null ? span({ class: "room-stream-chip", title: live.streaming || "" }, "▶ " + String(live.streaming || i18n.roomStreaming)) : null), renderLiveControls(room, live)), live.grid ? (safeArr(live.gridOrder).length ? div({ id: "room-video", class: safeArr(live.gridOrder).length === 1 ? "video-grid video-grid-one video-grid-solo" : "video-grid video-grid-one" }, @@ -378,32 +367,30 @@ exports.singleRoomView = async (room, params = {}) => { : [] const side = div({ class: "tribe-side" }, - renderCardTop(room.rootId, roomHref(room), { arrow: false, - date: room.createdAt, - chips: [ - ...roomChips(room, live ? { count: live.count, max: live.max } : occ, !!live), - canClearnet ? renderReachChip(isClearnet, i18n, isClearnet ? roomClearnetHref(room) : null) : null - ], - spread: params.spreads || null, author: room.author, favKind: "rooms", isFavorite: room.isFavorite, returnTo: roomHref(room), reportTitle: room.title, deleteAction: isAuthor ? contentDeleteAction("room", room.rootId) : undefined - }), + div({ class: "card-header activity-card-header" }, + renderContentActions(room.rootId, roomHref(room), { spread: params.spreads || null, author: room.author, favKind: "rooms", isFavorite: room.isFavorite, returnTo: roomHref(room), reportTitle: room.title, deleteAction: isAuthor ? contentDeleteAction("room", room.rootId) : undefined }) + ), div({ class: "shop-title-row" }, h2({ class: "tribe-card-title" }, restricted ? i18n.roomStatusInviteOnly : (room.title || "—"))), - isAuthor && canClearnet ? div({ class: "card-chips-row" }, renderClearnetSwitch("rooms", room.rootId, isClearnet)) : null, + div({ class: "card-chips-row" }, + ...roomChips(room, live ? { count: live.count, max: live.max } : occ, !!live), + canClearnet ? renderReachChip(isClearnet, i18n, isClearnet ? roomClearnetHref(room) : null) : null, + isAuthor && canClearnet ? renderClearnetSwitch("rooms", room.rootId, isClearnet) : null + ), restricted ? null : renderCover(room, "tribe-detail-image"), - renderDetailShare(room.rootId, roomHref(room), room.title), restricted || !room.description ? null : p({ class: "tribe-side-description" }, ...renderStyledText(room.description)), + 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(room.createdAt).format("YYYY/MM/DD HH:mm")) + ), + restricted ? null : tr(td({ class: "tribe-info-value", colspan: "4" }, userLink(room.author))) + ), live || restricted ? div({ class: "tribe-card-members" }, span({ class: "tribe-members-count" }, `${i18n.roomParticipants}: ${safeArr(room.members).length}`) ) : null, inviteActions.length ? div({ class: "tribe-side-actions" }, ...inviteActions) : null, - !isAuthor && !room.tribeId && safeArr(room.members).includes(userId) - ? div({ class: "tribe-side-actions" }, - form({ method: "POST", action: `/rooms/leave/${encodeURIComponent(room.rootId)}` }, - button({ type: "submit", class: "tribe-action-btn danger-btn" }, i18n.tribeLeaveButton) - ) - ) - : null, isAuthor && !room.isClosed ? div({ class: "tribe-side-actions housing-status-row" }, span({ class: "card-label" }, `${i18n.statusLabel || "Status"}: `), @@ -420,8 +407,7 @@ exports.singleRoomView = async (room, params = {}) => { restricted || !safeArr(room.tags).length ? null : div({ class: "card-tags" }, ...room.tags.map(t => a({ href: `/search?query=%23${encodeURIComponent(t)}`, class: "tag-link" }, `#${t}`))), - ownerActions.length ? div({ class: "tribe-side-actions owner-actions" }, ...ownerActions) : null, - null + ownerActions.length ? div({ class: "tribe-side-actions owner-actions" }, ...ownerActions) : null ) const main = div({ class: "tribe-main" }, diff --git a/src/views/school_view.js b/src/views/school_view.js index bce2527..eb6c031 100644 --- a/src/views/school_view.js +++ b/src/views/school_view.js @@ -1,8 +1,7 @@ const { div, h2, h3, p, section, button, form, a, span, textarea, br, input, label, select, option, table, tr, td, th, details, summary, datalist, progress, hr } = require("../server/node_modules/hyperaxe") const RAW_HTML = Symbol.for('oasis.rawHtml'); const moment = require("../server/node_modules/moment"); -const { clearnetItemHref, template, i18n, userLink, renderStateChip, renderCardTop, renderOpinionsVoting, renderEngagement, renderInviteQrCard, renderSubscriptionBox, renderModuleStatsBy, moduleIsEmpty, renderEcoValueChip, renderWalletChip, contentDeleteAction, paged, renderPdfDocument, renderEditedChip, renderMediaLockNote, renderDetailShare } = require("./main_views") -const { renderCommentsSection } = require("./comments_view") +const { clearnetItemHref, template, i18n, userLink, renderStateChip, renderContentActions, renderOpinionsVoting, renderEngagement, renderInviteQrCard, renderSubscriptionBox, renderModuleStatsBy, moduleIsEmpty, renderEcoValueChip, renderWalletChip, contentDeleteAction, paged } = require("./main_views") const opinionCategories = require("../backend/opinion_categories") const { config } = require("../server/SSB_server.js") const { renderStyledText, renderStyledHtml } = require("../backend/renderStyledText") @@ -88,20 +87,18 @@ const renderCourseCard = (course, filter, params = {}) => { const url = `/school/course/${encodeURIComponent(course.id)}` const isOwn = String(course.author) === String(userId) return div({ class: "tribe-card" }, - renderCardTop(course.id, url, { - date: course.createdAt, - chips: [renderCourseChips(course, params.subscriptions && (String(course.author) === String(userId) || safeArr(course.students).includes(userId)) ? { in: params.subscriptions.mine.has(course.rootId || course.id) || String(course.author) === String(userId) } : null)], - spread: (params.spreadMap && params.spreadMap.get(course.id)) || null, - author: course.author, - favKind: 'school', - isFavorite: course.isFavorite, - reportTitle: course.title, - returnTo: buildReturnTo(filter, params), - deleteAction: isOwn ? contentDeleteAction('schoolCourse', course.id) : undefined - }), - course.startDate ? div({ class: "tribe-card-members" }, - span({ class: "tribe-members-count" }, `${i18n.schoolStartDate}: ${moment(course.startDate).format("YYYY/MM/DD HH:mm")}`) - ) : null, + div({ class: "card-header activity-card-header" }, + span(), + renderContentActions(course.id, url, { + spread: (params.spreadMap && params.spreadMap.get(course.id)) || null, + author: course.author, + favKind: 'school', + isFavorite: course.isFavorite, + reportTitle: course.title, + returnTo: buildReturnTo(filter, params), + deleteAction: isOwn ? contentDeleteAction('schoolCourse', course.id) : undefined + }) + ), div({ class: "tribe-card-image-wrapper" }, a({ href: url }, renderMediaBlob(course.image, '/assets/images/default-avatar.png', { class: 'tribe-card-hero-image' }) @@ -110,11 +107,14 @@ const renderCourseCard = (course, filter, params = {}) => { div({ class: "tribe-card-body" }, h2({ class: "tribe-card-title" }, a({ href: url }, course.title)), renderStarRating(course.opinions, safeArr(course.opinions_inhabitants).length), + renderCourseChips(course, params.subscriptions && (String(course.author) === String(userId) || safeArr(course.students).includes(userId)) ? { in: params.subscriptions.mine.has(course.rootId || course.id) || String(course.author) === String(userId) } : null), course.description ? p({ class: "tribe-card-description" }, ...renderStyledText(course.description)) : null, div({ class: "tribe-card-members" }, span({ class: "tribe-members-count" }, `${i18n.schoolStudents}: ${course.students.length}`) ), - null + course.startDate ? div({ class: "tribe-card-members" }, + span({ class: "tribe-members-count" }, `${i18n.schoolStartDate}: ${moment(course.startDate).format("YYYY/MM/DD HH:mm")}`) + ) : null ) ) } @@ -147,7 +147,7 @@ const renderCourseForm = (filter, course = {}, params = {}) => { label(i18n.description), br, textarea({ name: "description", rows: 5, required: true, maxlength: "2000", placeholder: i18n.schoolCourseDescriptionPlaceholder }, course.description || ""), br, label(i18n.blogImage), br, - (isEdit && renderMediaLockNote(course)) || input({ type: "file", name: "image", accept: "image/*" }), br(), br(), + input({ type: "file", name: "image", accept: "image/*" }), br(), br(), label(i18n.schoolTags), br, input({ type: "text", name: "tags", placeholder: i18n.schoolTagsPlaceholder, value: Array.isArray(course.tags) ? course.tags.join(", ") : (course.tags || "") }), br, ...(reach === "PAID" @@ -396,45 +396,42 @@ exports.singleCourseView = async (course, lessons = [], certificates = [], param const nextLesson = isStudent ? lessons.find(l => !l.locked && !l.completed) : null const courseSide = div({ class: "tribe-side" }, - renderCardTop(course.id, returnTo, { arrow: false, - date: course.createdAt, - chips: [ - course.visibility === "INVITE" ? renderStateChip("whole", "\uD83D\uDD11", i18n.chatStatusInviteOnly) : null, - course.status === "CLOSED" - ? renderStateChip("closed", "✗", i18n.schoolClosed) - : renderStateChip("mutuals", "✓", i18n.schoolOngoing), - renderPriceChip(course), - renderReachChip(isClearnet, i18n, isClearnet ? clearnetItemHref('school', course.title, course.id) : null), - courseApproved ? renderStateChip("mutuals", "🎓", i18n.schoolApproved) : null, - params.subscription && (isTeacher || isStudent) - ? ((isTeacher || params.subscription.subscribed === true) - ? renderStateChip("mutuals", "\u2709", i18n.subscriptionOn) - : renderStateChip("closed", "\u2709", i18n.subscriptionOff)) - : null - ], - bylineExtra: [renderEditedChip(course.id, course.rootId)], - spread: params.spreads || null, - author: course.author, - favKind: 'school', - isFavorite: course.isFavorite, - returnTo, - reportTitle: course.title, - deleteAction: isTeacher ? contentDeleteAction('schoolCourse', course.id) : undefined - }), + div({ class: "card-header activity-card-header" }, + renderContentActions(course.id, returnTo, { spread: params.spreads || null, author: course.author, favKind: 'school', isFavorite: course.isFavorite, returnTo, reportTitle: course.title, deleteAction: isTeacher ? contentDeleteAction('schoolCourse', course.id) : undefined }) + ), h2({ class: "tribe-card-title" }, course.title), renderStarRating(course.opinions, safeArr(course.opinions_inhabitants).length), - isTeacher && canClearnet ? renderClearnetSwitch('school', course.rootId || course.id, isClearnet) : null, + div({ class: "card-chips-row" }, + course.visibility === "INVITE" ? renderStateChip("whole", "\uD83D\uDD11", i18n.chatStatusInviteOnly) : null, + course.status === "CLOSED" + ? renderStateChip("closed", "✗", i18n.schoolClosed) + : renderStateChip("mutuals", "✓", i18n.schoolOngoing), + renderPriceChip(course), + renderReachChip(isClearnet, i18n, isClearnet ? clearnetItemHref('school', course.title, course.id) : null), + isTeacher && canClearnet ? renderClearnetSwitch('school', course.rootId || course.id, isClearnet) : null, + courseApproved ? renderStateChip("mutuals", "🎓", i18n.schoolApproved) : null, + params.subscription && (isTeacher || isStudent) + ? ((isTeacher || params.subscription.subscribed === true) + ? renderStateChip("mutuals", "\u2709", i18n.subscriptionOn) + : renderStateChip("closed", "\u2709", i18n.subscriptionOff)) + : null + ), renderMediaBlob(course.image, '/assets/images/default-avatar.png', { class: 'tribe-detail-image' }), - renderDetailShare(course.id, returnTo, course.title), course.description ? p({ class: "tribe-side-description" }, ...renderStyledText(course.description)) : null, - course.startDate - ? table({ class: "tribe-info-table" }, - tr( - td({ class: "tribe-info-label" }, i18n.schoolStartDate), - td({ class: "tribe-info-value", colspan: "3" }, moment(course.startDate).format("YYYY/MM/DD HH:mm")) - ) - ) - : null, + table({ class: "tribe-info-table" }, + tr( + td({ class: "tribe-info-label" }, i18n.schoolCreatedAt), + td({ class: "tribe-info-value", colspan: "3" }, moment(course.createdAt).format("YYYY/MM/DD HH:mm")) + ), + course.startDate ? tr( + td({ class: "tribe-info-label" }, i18n.schoolStartDate), + td({ class: "tribe-info-value", colspan: "3" }, moment(course.startDate).format("YYYY/MM/DD HH:mm")) + ) : null, + tr( + td({ class: "tribe-info-label" }, i18n.schoolTeacher), + td({ class: "tribe-info-value", colspan: "3" }, userLink(course.author)) + ) + ), div({ class: "tribe-card-members" }, span({ class: "tribe-members-count" }, `${i18n.schoolStudents}: ${course.students.length}`) ), @@ -744,10 +741,8 @@ exports.singleCourseView = async (course, lessons = [], certificates = [], param isStudent && !isTeacher ? renderOpinionsVoting('/school/opinions', course.id, course.opinions, returnTo, course.opinions_inhabitants) : null, - null), - Array.isArray(params.comments) - ? renderCommentsSection({ action: `/school/course/${encodeURIComponent(course.rootId || course.id)}/comments`, comments: params.comments, returnTo: `/school/course/${encodeURIComponent(course.rootId || course.id)}` }) - : null + null + ) ) ) ) @@ -768,10 +763,9 @@ exports.clearnetCourseView = async (course, lessons = []) => { .cn-course-section h2{color:var(--fg);font-size:18px;text-transform:uppercase;letter-spacing:2px;margin:24px 0 10px;padding-bottom:6px;border-bottom:1px solid var(--border)} .cn-course-section p{color:var(--fg-soft);white-space:pre-wrap;line-height:1.6;font-size:15px} .cn-lesson{background:var(--bg-sub);border:1px solid var(--border);border-radius:8px;padding:14px 18px;margin-bottom:12px} -.cn-lesson-head{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px 12px;margin:0 0 8px 0} -.cn-lesson h3{color:var(--fg);margin:0;font-size:16px} -.cn-lesson-text{color:var(--fg-soft);line-height:1.6;font-size:14px;margin:0} -.cn-lesson-date{color:var(--fg-dim);font-size:12px} +.cn-lesson h3{color:var(--fg);margin:0 0 8px 0;font-size:16px} +.cn-lesson p{color:var(--fg-soft);white-space:pre-wrap;line-height:1.6;font-size:14px;margin:0} +.cn-lesson-date{color:var(--fg-dim);font-size:12px;margin-top:8px} ` const body = `

${title}

@@ -786,8 +780,9 @@ exports.clearnetCourseView = async (course, lessons = []) => { ${desc ? `

${esc(i18n.description)}

${desc}

` : ""} ${visibleLessons.length ? `

${esc(i18n.schoolLessons)} (${visibleLessons.length})

${visibleLessons.map(lesson => `
-

${esc(lesson.title || "")}

${lesson.sessionDate ? `📅 ${esc(new Date(lesson.sessionDate).toISOString().slice(0, 10))}` : ""}
-
${renderRichText(lesson.text || "")}
+

${esc(lesson.title || "")}

+

${esc(lesson.text || "")}

+ ${lesson.sessionDate ? `
📅 ${esc(new Date(lesson.sessionDate).toISOString().slice(0, 10))}
` : ""}
`).join("")}
` : ""} ` return renderClearnetPage({ @@ -810,7 +805,7 @@ const renderMaterial = (material, course, isTeacher) => { else if (ref && ref.length === 4) { const kind = ref[1], name = ref[2], blobId = ref[3] const url = `/blob/${encodeURIComponent(blobId)}` - if (kind === "pdf") body = renderPdfDocument(url, { title: name || "PDF", preview: true }) + if (kind === "pdf") body = div({ class: "pdf-viewer-container", id: `pdf-${material.id.slice(1, 9)}`, "data-pdf-url": url }, a({ href: url }, `📎 ${name || "PDF"}`)) else if (kind === "image") body = img({ src: url, alt: name, class: "school-material-image" }) else if (kind === "video") body = video({ controls: true, src: url, class: "post-video", preload: 'metadata' }) else if (kind === "audio") body = audio({ controls: true, src: url }) @@ -838,29 +833,31 @@ exports.singleLessonView = async (course, lesson, materials = [], params = {}) = const lessonUrl = `/school/lesson/${encodeURIComponent(course.id)}/${encodeURIComponent(lesson.id)}` const lessonSide = div({ class: "tribe-side" }, - renderCardTop(lesson.id, lessonUrl, { arrow: false, - date: lesson.createdAt, - chips: [renderCourseChips(course)], - author: lesson.author || course.author, - reportTitle: lesson.title || course.title, - returnTo: courseUrl, - deleteAction: String(lesson.author || course.author) === String(userId) ? `/school/lesson/delete/${encodeURIComponent(course.id)}/${encodeURIComponent(lesson.id)}` : undefined - }), + div({ class: "card-header activity-card-header" }, + renderContentActions(lesson.id, lessonUrl, { + author: lesson.author || course.author, + reportTitle: lesson.title || course.title, + returnTo: courseUrl, + deleteAction: String(lesson.author || course.author) === String(userId) ? `/school/lesson/delete/${encodeURIComponent(course.id)}/${encodeURIComponent(lesson.id)}` : undefined + }) + ), h2({ class: "tribe-card-title" }, a({ href: courseUrl }, course.title)), + renderCourseChips(course), renderMediaBlob(course.image, '/assets/images/default-avatar.png', { class: 'tribe-detail-image' }), - renderDetailShare(lesson.id, lessonUrl, lesson.title || course.title), - lesson.unit || lesson.sessionDate - ? table({ class: "tribe-info-table" }, - lesson.unit ? tr( - td({ class: "tribe-info-label" }, i18n.schoolLessonUnit), - td({ class: "tribe-info-value", colspan: "3" }, lesson.unit) - ) : null, - lesson.sessionDate ? tr( - td({ class: "tribe-info-label" }, i18n.schoolSessionDate), - td({ class: "tribe-info-value", colspan: "3" }, moment(lesson.sessionDate).format("YYYY/MM/DD HH:mm")) - ) : null - ) - : null, + table({ class: "tribe-info-table" }, + lesson.unit ? tr( + td({ class: "tribe-info-label" }, i18n.schoolLessonUnit), + td({ class: "tribe-info-value", colspan: "3" }, lesson.unit) + ) : null, + lesson.sessionDate ? tr( + td({ class: "tribe-info-label" }, i18n.schoolSessionDate), + td({ class: "tribe-info-value", colspan: "3" }, moment(lesson.sessionDate).format("YYYY/MM/DD HH:mm")) + ) : null, + tr( + td({ class: "tribe-info-label" }, i18n.schoolTeacher), + td({ class: "tribe-info-value", colspan: "3" }, userLink(course.author)) + ) + ), isStudent ? div({ class: "tribe-side-actions" }, form({ method: "POST", action: `/school/lesson/complete/${encodeURIComponent(course.id)}/${encodeURIComponent(lesson.id)}` }, @@ -920,8 +917,7 @@ exports.singleLessonView = async (course, lesson, materials = [], params = {}) = button({ type: "submit" }, i18n.schoolAddMaterial) ) ) - : null, - null + : null ) const tpl = template( @@ -930,7 +926,8 @@ exports.singleLessonView = async (course, lesson, materials = [], params = {}) = section(renderModeButtons("all", false, (params && params.modesAvail) || null)), section(div({ class: "tribe-details" }, lessonSide, lessonMain)) ) - return tpl + const hasPdf = safeArr(materials).some(m => !m.locked && /\[pdf:/.test(String(m.media || ""))) + return hasPdf ? `${tpl}` : tpl } exports.schoolInvitePage = (code) => { diff --git a/src/views/search_view.js b/src/views/search_view.js index de02589..e494b3d 100644 --- a/src/views/search_view.js +++ b/src/views/search_view.js @@ -1,6 +1,6 @@ -const { form, button, div, h2, p, section, input, label, select, option, img, audio: audioHyperaxe, video: videoHyperaxe, table, br, td, tr, th, a, span } = require("../server/node_modules/hyperaxe"); +const { form, button, div, h2, p, section, input, label, select, option, img, audio: audioHyperaxe, video: videoHyperaxe, table, hr, hd, br, td, tr, th, a, span } = require("../server/node_modules/hyperaxe"); const RAW_HTML = Symbol.for('oasis.rawHtml'); -const { template, i18n, userLink, renderCardTop, renderShareButton, renderCommentsPill, renderOpinionsPill, renderCardMetaRow, renderModuleStats, CONTENT_FAV_KIND, CONTENT_SPREADABLE, CONTENT_TORRENTABLE, paged, currentPerPage, LIST_PAGE_SIZES, pageSizeLabel, renderPdfDocument } = require('./main_views'); +const { template, i18n, userLink, renderContentActions, renderModuleStats, CONTENT_FAV_KIND, CONTENT_SPREADABLE, CONTENT_TORRENTABLE, paged, currentPerPage, LIST_PAGE_SIZES, pageSizeLabel } = require('./main_views'); const moment = require("../server/node_modules/moment"); const { renderStyledHtml, safeExternalHref } = require('../backend/renderStyledText'); const { renderStyledText } = require('../backend/renderStyledText'); @@ -138,6 +138,7 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types = } }; + let hasDocument = false; const blobImg = (value) => { if (!value) return null; @@ -298,7 +299,11 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types = br(), content.description ? div({ class: 'card-field' }, span({ class: 'card-value' }, content.description)) : null, br(), - renderPdfDocument(`/blob/${encodeURIComponent(content.url)}`, { title: content.title }), + div({ + id: `pdf-container-${content.key || content.url}`, + class: 'pdf-viewer-container', + 'data-pdf-url': `/blob/${encodeURIComponent(content.url)}` + }), br(), content.tags && content.tags.length ? div({ class: 'card-tags' }, content.tags.map(tag => @@ -622,7 +627,7 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types = } }; - const typeLabel = (type) => String((type === 'comment' ? i18n.typeComment : i18n['type' + String(type || '').charAt(0).toUpperCase() + String(type || '').slice(1)]) || type || '').toUpperCase(); + const typeLabel = (type) => String(i18n['type' + String(type || '').charAt(0).toUpperCase() + String(type || '').slice(1)] || type || '').toUpperCase(); const totalResults = Object.values(results).reduce((n, msgs) => n + (Array.isArray(msgs) ? msgs.length : 0), 0); const resultStats = renderModuleStats(totalResults, Object.entries(results) @@ -636,43 +641,69 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types = { class: "search-result-group" }, ...msgs.map((msg) => { const content = msg.value.content || {}; + const created = moment(msg.timestamp).format("YYYY/MM/DD HH:mm"); + if (content.type === 'document') hasDocument = true; const contentHtml = renderContentHtml(content); - const bylineAuthor = content.type === 'market' ? content.seller - : content.type === 'event' ? content.organizer - : content.type === 'transfer' ? content.from - : content.type === 'post' || content.type === 'about' ? msg.value.author - : content.type === 'votes' ? content.createdBy - : content.type === 'shop' || content.type === 'shopProduct' || content.type === 'chat' || content.type === 'gameScore' ? (content.author || content.player || msg.value.author) - : content.author; + let author; + let authorUrl = '#'; + if (content.type === 'market') { + author = content.seller || i18n.anonymous || "Anonymous"; + authorUrl = `/author/${encodeURIComponent(content.seller)}`; + } else if (content.type === 'event') { + author = content.organizer || i18n.anonymous || "Anonymous"; + authorUrl = `/author/${encodeURIComponent(content.organizer)}`; + } else if (content.type === 'transfer') { + author = content.from || i18n.anonymous || "Anonymous"; + authorUrl = `/author/${encodeURIComponent(content.from)}`; + } else if (content.type === 'post' || content.type === 'about') { + author = msg.value.author || i18n.anonymous || "Anonymous"; + authorUrl = `/author/${encodeURIComponent(msg.value.author)}`; + } else if (content.type === 'report') { + author = content.author || i18n.anonymous || "Anonymous"; + authorUrl = `/author/${encodeURIComponent(content.author || 'Anonymous')}`; + } else if (content.type === 'votes') { + author = content.createdBy || i18n.anonymous || "Anonymous"; + authorUrl = `/author/${encodeURIComponent(content.createdBy || 'Anonymous')}`; + } else if (content.type === 'shop' || content.type === 'shopProduct' || content.type === 'chat' || content.type === 'gameScore') { + author = content.author || content.player || msg.value.author || i18n.anonymous || "Anonymous"; + authorUrl = `/author/${encodeURIComponent(content.author || content.player || msg.value.author || 'Anonymous')}`; + } else { + author = content.author; + authorUrl = `/author/${encodeURIComponent(content.author || 'Anonymous')}`; + } const contentId = msg.key; const isOwn = msg.value.author && String(msg.value.author) === String(userId); - const viewHref = getViewDetailsActionForSearch(content.type, contentId, content); - const shareTitle = content.title || content.question || content.concept || ''; return div({ class: 'trending-card search-card' + (isOwn ? ' own-content' : '') }, [ - renderCardTop(contentId, viewHref, { - date: msg.timestamp || msg.value.timestamp, - bylineAuthor: typeof bylineAuthor === 'string' && bylineAuthor.startsWith('@') ? bylineAuthor : undefined, - chips: [div({ class: 'card-chips-row' }, + 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' }, String(i18n['type' + String(content.type || '').charAt(0).toUpperCase() + String(content.type || '').slice(1)] || content.type || '').toUpperCase()) ) - )], - author: msg.value.author, - reportTitle: shareTitle, - spread: CONTENT_SPREADABLE.has(content.type) ? ((spreadMap instanceof Map && spreadMap.get(contentId)) || null) : undefined, - ...(CONTENT_TORRENTABLE.has(content.type) && typeof content.url === 'string' && content.url.startsWith('&') - ? { torrentFrom: { blobId: content.url, name: content.title || content.fileName || '' } } - : {}), - ...(key !== 'comment' && CONTENT_FAV_KIND[content.type] - ? { favKind: CONTENT_FAV_KIND[content.type], isFavorite: favIndex instanceof Map ? favIndex.get(String(contentId)) === CONTENT_FAV_KIND[content.type] : false, returnTo: `/search?query=${encodeURIComponent(hashtag ? `#${hashtag}` : String(query || ''))}` } - : {}) - }), - div({ class: 'card-section search-card-body' }, - contentHtml + ), + renderContentActions(contentId, getViewDetailsActionForSearch(content.type, contentId, content), { + author: msg.value.author, + reportTitle: content.title || content.question || content.concept || '', + spread: CONTENT_SPREADABLE.has(content.type) ? ((spreadMap instanceof Map && spreadMap.get(contentId)) || null) : undefined, + ...(CONTENT_TORRENTABLE.has(content.type) && typeof content.url === 'string' && content.url.startsWith('&') + ? { torrentFrom: { blobId: content.url, name: content.title || content.fileName || '' } } + : {}), + ...(CONTENT_FAV_KIND[content.type] + ? { favKind: CONTENT_FAV_KIND[content.type], isFavorite: favIndex instanceof Map ? favIndex.get(String(contentId)) === CONTENT_FAV_KIND[content.type] : false, returnTo: `/search?query=${encodeURIComponent(hashtag ? `#${hashtag}` : String(query || ''))}` } + : {}) + }) ), - renderCardMetaRow(renderOpinionsPill(contentId, viewHref), renderCommentsPill(contentId, viewHref), renderShareButton(contentId, viewHref, shareTitle)) + div({ class: 'card-section search-card-body' }, + contentHtml, + author + ? p({ class: 'card-footer' }, + span({ class: 'date-link' }, `${created}`), + (authorUrl && authorUrl !== '#' && authorUrl.startsWith('/author/')) + ? userLink(decodeURIComponent(authorUrl.replace(/^\/author\//, ''))) + : a({ href: authorUrl, class: 'user-link' }, `${author}`) + ): null + ) ]); }) ) @@ -696,6 +727,12 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types = ) ); + if (hasDocument) { + html += ` + + `; + } + return html; }; diff --git a/src/views/settings_view.js b/src/views/settings_view.js index 2ae0c33..df412f4 100644 --- a/src/views/settings_view.js +++ b/src/views/settings_view.js @@ -1,14 +1,11 @@ const { form, button, div, h2, h3, p, section, select, option, input, br, a, label, span, img, strong, table, tr, td, ul, li } = require("../server/node_modules/hyperaxe"); const fs = require('fs'); const path = require('path'); -const { getConfig, CONNECTIONS_MIN, CONNECTIONS_MAX } = require('../configs/config-manager.js'); -const mainViews = require('./main_views'); -const { template, i18n, setLanguage, INBOX_BOT_ORDER, inboxBotLabel, THEMES, userLink } = mainViews; -const { MOBILE_THEMES } = mainViews; +const { getConfig } = require('../configs/config-manager.js'); +const { template, selectedLanguage, i18n, setLanguage, INBOX_BOT_ORDER, inboxBotLabel, THEMES, userLink } = require('./main_views'); const i18nBase = require("../client/assets/translations/i18n"); const moment = require("../server/node_modules/moment"); const { WORKFLOWS, currentWorkflow } = require('../models/workflows_model'); -const mobileUi = require('./mobile_ui'); const html = require("../server/node_modules/hyperaxe"); // selector de identidades: la implementacion vive en ./identities_view.js const { renderIdentities } = require('./identities_view')({ i18n, html }); @@ -61,23 +58,7 @@ const settingsView = (params) => { const modOn = (name) => (currentConfig.modules || {})[`${name}Mod`] === 'on'; const isMobile = process.env.OASIS_MOBILE === '1'; - // movil: skins y workflows como tarjetas que se aplican al tocarlas (un boton - // de envio por opcion); el desplegable de escritorio quedaba enterrado y dejaba - // poner temas de escritorio, que en el movil se quedan sin maqueta - const skinPicker = () => section({ id: "theme" }, - div({ class: "tags-header" }, h2(i18n.theme), p(i18n.skinIntroMobile)), - form({ action: "/settings/theme", method: "post", class: "skin-pick" }, - ...MOBILE_THEMES.map(([value, name]) => button({ type: "submit", name: "theme", value, class: theme === value ? "skin-card is-current" : "skin-card" }, - img({ src: `/assets/images/theme-${value.toLowerCase()}.png`, alt: "", class: "skin-shot" }), - span({ class: "skin-name" }, name))) - ) - ); - const workflowPicker = () => section({ id: "workflows" }, - div({ class: "tags-header" }, h2(i18n.welcomeStepUseMobile || i18n.workflowsTitle), p(i18n.workflowsDescriptionMobile)), - mobileUi.workflowPick("/settings/workflow", activeWorkflow, WORKFLOWS.map(w => w.key)) - ); - - const workflowSection = isMobile ? workflowPicker() : section({ id: "workflows" }, + const workflowSection = section({ id: "workflows" }, div({ class: "tags-header" }, h2(i18n.workflowsTitle), p(i18n.workflowsDescription), @@ -95,7 +76,7 @@ const settingsView = (params) => { ); const languageOption = (longName, shortName) => { - return shortName === mainViews.selectedLanguage + return shortName === selectedLanguage ? option({ value: shortName, selected: true }, longName) : option({ value: shortName }, longName); }; @@ -147,7 +128,7 @@ const settingsView = (params) => { div({ class: "tags-header" }, h2(i18n.language), p(i18n.languageDescription), - isMobile ? mobileUi.languagePick() : form( + form( { action: "/language", method: "post" }, select({ name: "language" }, [ languageOption("English", "en"), @@ -174,7 +155,7 @@ const settingsView = (params) => { div({ class: "tags-header" }, h2(i18n.uxModeTitle || "UX"), p(i18n.uxModeDescription || "Select which UX navigation mode you want for your GUI."), - isMobile ? mobileUi.uxPick("/settings/ux", currentConfig) : form( + form( { action: "/settings/ux", method: "POST" }, (() => { const aiNavEnabled = currentConfig.modules && currentConfig.modules.aiNavMod === 'on'; @@ -200,7 +181,7 @@ const settingsView = (params) => { ) ) ), - isMobile ? skinPicker() : section({ id: "theme" }, + section({ id: "theme" }, div({ class: "tags-header" }, h2(i18n.theme), p(i18n.themeIntro), @@ -314,17 +295,6 @@ const settingsView = (params) => { ) ) ), - section({ id: "connections" }, - div({ class: "tags-header" }, - h2(i18n.settingsConnectionsTitle), - p(i18n.settingsConnectionsDesc), - form( - { action: "/settings/connections", method: "POST" }, - input({ type: "number", id: "connections", name: "connections", min: CONNECTIONS_MIN, max: CONNECTIONS_MAX, step: 1, required: true, value: currentConfig.connections }), br(), br(), - button({ type: "submit" }, i18n.saveSettings) - ) - ) - ), modOn('phone') ? section({ id: "phone" }, div({ class: "tags-header" }, h2(i18n.phoneTitle), @@ -685,7 +655,7 @@ const settingsView = (params) => { modOn('fediverse') ? a({ class: filter === 'multiverse' ? 'filter-btn active' : 'filter-btn', href: '/settings?filter=multiverse' }, String(i18n.multiverse).toUpperCase()) : null ) ), - ...(filter === 'multiverse' ? multiverseParts : filter === 'content' ? contentParts : (isMobile ? mobileUi.settingsOrder(oasisParts) : oasisParts)) + ...(filter === 'multiverse' ? multiverseParts : filter === 'content' ? contentParts : oasisParts) ); }; diff --git a/src/views/shops_view.js b/src/views/shops_view.js index 59b69f3..ce324ef 100644 --- a/src/views/shops_view.js +++ b/src/views/shops_view.js @@ -1,10 +1,10 @@ const { div, h2, p, section, button, form, a, span, textarea, br, input, label, select, option, img, progress, video, audio, table, tr, td, hr } = require("../server/node_modules/hyperaxe") const { renderCommentsSection: renderSharedCommentsSection } = require("./comments_view"); -const { renderCallButton, clearnetItemHref, template, i18n, userLink, renderStateChip, renderLifespanChip, renderOpinionsVoting, renderEngagement, renderInviteQrCard, renderSpreadEditWarning, renderCardTop, renderSubscriptionBox, renderModuleStats, renderModuleStatsBy, moduleIsEmpty, renderEcoValueChip, renderWalletChip, contentDeleteAction, paged, renderEditedChip, renderMediaLockNote, renderDetailShare } = require("./main_views") +const { renderCallButton, clearnetItemHref, template, i18n, userLink, renderStateChip, renderLifespanChip, renderSpreadButton, renderOpinionsVoting, renderEngagement, renderInviteQrCard , renderSpreadEditWarning, renderContentActions, renderSubscriptionBox, renderModuleStats, renderModuleStatsBy, moduleIsEmpty, renderEcoValueChip, renderWalletChip, contentDeleteAction, paged } = require("./main_views") const moment = require("../server/node_modules/moment") const { config } = require("../server/SSB_server.js") const { renderStyledText } = require("../backend/renderStyledText") -const { renderMapEmbed } = require("./maps_view") +const { renderMapLocationUrl, renderMapEmbed, renderMapLocationVisitLabel } = require("./maps_view") const opinionCategories = require("../backend/opinion_categories") const { renderReachChip, renderClearnetSelector, renderClearnetSwitch, renderClearnetUrlBlock, renderClearnetPage, renderClearnetSearchForm, renderEncryptedChip, blobUrl: cnBlobUrl, escapeHtml: cnEscapeHtml, renderRichText: cnRichText, renderKindTag: cnKindTag } = require("./clearnet_view") @@ -72,12 +72,20 @@ const renderShopCard = exports.renderShopCard = (shop, filter, params = {}) => { const returnTo = buildReturnTo(filter, params) const isAuthor = String(shop.author) === String(userId) - const shopHref = `/shops/${encodeURIComponent(shop.key)}` - return div({ class: "tribe-card" }, - renderCardTop(shop.key, shopHref, { - date: shop.createdAt, - chips: [ + 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, returnTo, deleteAction: isAuthor ? contentDeleteAction('shop', shop.key) : undefined }) + ), + 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' }) + ) + ), + div({ class: "tribe-card-body" }, + div({ class: "shop-title-row" }, + h2({ class: "tribe-card-title" }, a({ href: `/shops/${encodeURIComponent(shop.key)}` }, shop.title || i18n.shopUntitled)) + ), + div({ class: "card-chips-row" }, shop.visibility === "CLOSED" ? renderStateChip("closed", "✗", i18n.shopClosed) : renderStateChip("mutuals", "✓", i18n.shopOpen), @@ -86,22 +94,10 @@ const renderShopCard = exports.renderShopCard = (shop, filter, params = {}) => { shop.subscriptionIn === true ? renderStateChip("mutuals", "✉", i18n.subscriptionOn) : (shop.subscriptionIn === false ? renderStateChip("closed", "✉", i18n.subscriptionOff) : null) - ], - spread: params.spreadMap && params.spreadMap.get(shop.key) || null, author: shop.author, favKind: 'shops', isFavorite: shop.isFavorite, reportTitle: shop.title, returnTo, deleteAction: isAuthor ? contentDeleteAction('shop', shop.key) : undefined - }), - div({ class: "tribe-card-image-wrapper" }, - a({ href: shopHref }, - renderMediaBlob(shop.image, '/assets/images/default-avatar.png', { class: 'tribe-card-hero-image' }) - ) - ), - div({ class: "tribe-card-body" }, - div({ class: "shop-title-row" }, - h2({ class: "tribe-card-title" }, a({ href: shopHref }, shop.title || i18n.shopUntitled)) ), div({ class: "tribe-card-members" }, span({ class: "tribe-members-count" }, `${i18n.shopProducts}: ${shop.productCount || 0}`) - ), - null + ) ) ) } @@ -113,7 +109,9 @@ const renderProductCard = (product, shopId, returnTo, params = {}) => { const productUrl = `/shops/product/${encodeURIComponent(product.key)}?shopId=${encodeURIComponent(shopId)}` return div({ class: "shop-product-card" }, - renderCardTop(product.key, `/shops/product/${encodeURIComponent(product.key)}`, { date: product.createdAt, spread: params.spreadMap && params.spreadMap.get(product.key) || null, author: product.author, favKind: 'shopProducts', isFavorite: product.isFavorite, reportTitle: product.title, returnTo, deleteAction: isAuthor ? contentDeleteAction('shopProduct', product.key) : undefined }), + div({ class: "card-header activity-card-header" }, + renderContentActions(product.key, `/shops/product/${encodeURIComponent(product.key)}`, { spread: params.spreadMap && params.spreadMap.get(product.key) || null, author: product.author, favKind: 'shopProducts', isFavorite: product.isFavorite, reportTitle: product.title, returnTo, deleteAction: isAuthor ? contentDeleteAction('shopProduct', product.key) : undefined }) + ), product.image ? div({ class: "shop-product-media" }, a({ href: productUrl }, renderMediaBlob(product.image))) : null, div({ class: "shop-product-body" }, product.shopTitle ? p(a({ href: `/shops/${encodeURIComponent(shopId)}`, class: "user-link" }, product.shopTitle)) : null, @@ -134,8 +132,7 @@ const renderProductCard = (product, shopId, returnTo, params = {}) => { actions.push(a({ href: productUrl, class: "buy-btn" }, i18n.marketActionsBuy || i18n.shopBuy)); } return actions.length ? div({ class: "shop-product-actions" }, ...actions) : null; - })(), - null + })() ) ) } @@ -215,7 +212,7 @@ const renderProductForm = (shopId, product = {}, isEdit = false, returnTo = "", label(i18n.shopProductStock), br, input({ type: "number", name: "stock", min: "0", value: product.stock !== undefined ? product.stock : 1 }), br(), br(), label(i18n.blogImage || "Upload media (max-size: 75MB)"), br, - (isEdit && renderMediaLockNote(product)) || input({ type: "file", name: "image", accept: "image/*,video/*" }), br(), br(), + input({ type: "file", name: "image", accept: "image/*,video/*" }), br(), br(), input({ type: "hidden", name: "featured", value: "0" }), label(i18n.shopProductFeatured), input({ id: "featured", type: "checkbox", name: "featured", value: "1", class: "meme-checkbox", ...(product.featured ? { checked: true } : {}) }), @@ -292,31 +289,33 @@ exports.singleShopView = async (shop, filter, products = [], comments = [], para products = safeArr(products) const shopSide = div({ class: "tribe-side" }, - renderCardTop(shop.key, `/shops/${encodeURIComponent(shop.key)}`, { arrow: false, - date: shop.createdAt, - chips: [ - shop.visibility === "CLOSED" - ? renderStateChip("closed", "✗", i18n.shopClosed) - : renderStateChip("mutuals", "✓", i18n.shopOpen), - shop.encrypted ? renderStateChip("encrypted", "🔒", i18n.encryptedChipLabel || "E2E") : null, - renderLifespanChip(shop.lifetime, i18n), - renderReachChip(isClearnet, i18n, isClearnet ? clearnetItemHref('shops', shop.title, shop.key) : null), - shop.subscription - ? ((isAuthor || shop.subscription.subscribed === true) - ? renderStateChip("mutuals", "✉", i18n.subscriptionOn) - : renderStateChip("closed", "✉", i18n.subscriptionOff)) - : null - ], - spread: params.spreads || null, author: shop.author, favKind: 'shops', isFavorite: shop.isFavorite, reportTitle: shop.title, deleteAction: isAuthor ? contentDeleteAction('shop', shop.key) : undefined - }), + div({ class: "card-header activity-card-header" }, + renderContentActions(shop.key, `/shops/${encodeURIComponent(shop.key)}`, { spread: params.spreads || null, author: shop.author, favKind: 'shops', isFavorite: shop.isFavorite, reportTitle: shop.title, deleteAction: isAuthor ? contentDeleteAction('shop', shop.key) : undefined }) + ), div({ class: "shop-title-row" }, h2({ class: "tribe-card-title" }, shop.title || i18n.shopUntitled) ), - isAuthor && canClearnet ? renderClearnetSwitch('shops', shop.rootId || shop.key, isClearnet) : null, + div({ class: "card-chips-row" }, + shop.visibility === "CLOSED" + ? renderStateChip("closed", "✗", i18n.shopClosed) + : renderStateChip("mutuals", "✓", i18n.shopOpen), + shop.encrypted ? renderStateChip("encrypted", "🔒", i18n.encryptedChipLabel || "E2E") : null, + renderLifespanChip(shop.lifetime, i18n), + renderReachChip(isClearnet, i18n, isClearnet ? clearnetItemHref('shops', shop.title, shop.key) : null), + isAuthor && canClearnet ? renderClearnetSwitch('shops', shop.rootId || shop.key, isClearnet) : null, + shop.subscription + ? ((isAuthor || shop.subscription.subscribed === true) + ? renderStateChip("mutuals", "✉", i18n.subscriptionOn) + : renderStateChip("closed", "✉", i18n.subscriptionOff)) + : null + ), renderMediaBlob(shop.image, '/assets/images/default-avatar.png', { class: 'tribe-detail-image' }), - renderDetailShare(shop.key, `/shops/${encodeURIComponent(shop.key)}`, shop.title), shop.description ? p({ class: "tribe-side-description" }, ...renderStyledText(shop.description)) : null, table({ class: "tribe-info-table" }, + tr( + td({ class: "tribe-info-label" }, i18n.shopCreatedAt || "CREATED"), + td({ class: "tribe-info-value", colspan: "3" }, moment(shop.createdAt).format("YYYY/MM/DD HH:mm")) + ), shop.location ? tr( td({ class: "tribe-info-label" }, i18n.shopLocation), td({ class: "tribe-info-value", colspan: "3" }, ...renderStyledText(shop.location)) @@ -328,7 +327,10 @@ 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" }, ...renderStyledText(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" }, @@ -397,8 +399,7 @@ exports.singleShopView = async (shop, filter, products = [], comments = [], para : 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, - null + : null ) const shopMain = div({ class: "tribe-main" }, @@ -433,13 +434,15 @@ exports.singleProductView = async (product, shop, comments = [], params = {}) => const stock = Number(product.stock) || 0 const productSide = div({ class: "tribe-side" }, - renderCardTop(product.key, `/shops/product/${encodeURIComponent(product.key)}`, { arrow: false, date: product.createdAt, bylineExtra: [renderEditedChip(product.key, product.rootId)], spread: params.spreads || null, author: product.author, favKind: 'shopProducts', isFavorite: product.isFavorite, returnTo, reportTitle: product.title, deleteAction: isAuthor ? contentDeleteAction('shopProduct', product.key) : undefined }), + div({ class: "card-header activity-card-header" }, + renderContentActions(product.key, `/shops/product/${encodeURIComponent(product.key)}`, { spread: params.spreads || null, author: product.author, favKind: 'shopProducts', isFavorite: product.isFavorite, returnTo, reportTitle: product.title, deleteAction: isAuthor ? contentDeleteAction('shopProduct', product.key) : undefined }) + ), 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, - renderDetailShare(product.key, `/shops/product/${encodeURIComponent(product.key)}`, product.title), + div({ class: "price-chip" }, `${Number(product.price || 0).toFixed(6)} ECO`), div({ class: "confirmations-block stock-block" }, div({ class: "card-field" }, span({ class: "card-label" }, `${i18n.shopProductStock}: `), @@ -447,6 +450,15 @@ exports.singleProductView = async (product, shop, comments = [], params = {}) => ), 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}`) @@ -463,8 +475,6 @@ exports.singleProductView = async (product, shop, comments = [], params = {}) => : null ) - const canBuy = !isAuthor && stock > 0 - const priceChip = div({ class: "price-chip" }, `${Number(product.price || 0).toFixed(6)} ECO`) const productMain = div({ class: "tribe-main" }, product.description ? div({ class: "job-section" }, @@ -472,12 +482,13 @@ exports.singleProductView = async (product, shop, comments = [], params = {}) => p({ class: "tribe-side-description" }, ...renderStyledText(product.description)) ) : null, - canBuy ? null : priceChip, - canBuy - ? div({ class: "job-section buy-box" }, + !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 }), - div({ class: "title-with-chip" }, label(i18n.shopBuyDeliveryAddress || "Delivery address"), renderEncryptedChip(i18n)), + 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({ maxlength: "5000", name: "deliveryAddress", required: true, rows: 3, placeholder: i18n.shopBuyDeliveryAddressPlaceholder || "" }), br(), br(), @@ -490,8 +501,6 @@ exports.singleProductView = async (product, shop, comments = [], params = {}) => br(), textarea({ maxlength: "5000", name: "notes", rows: 2, placeholder: i18n.shopBuyNotesPlaceholder || "" }), br(), - priceChip, - br(), br(), button({ type: "submit", class: "buy-btn" }, i18n.marketActionsBuy || i18n.shopBuy) ) @@ -501,7 +510,8 @@ exports.singleProductView = async (product, shop, comments = [], params = {}) => !isAuthor && params.canRate ? renderOpinionsVoting('/shops/product/opinions', product.key, product.opinions, returnTo, product.opinions_inhabitants) : null, - renderCommentsSection(product.key, returnTo, comments)) + renderCommentsSection(product.key, returnTo, comments) + ) ) return template( @@ -631,12 +641,10 @@ exports.clearnetShopView = async (shop, products = []) => { const n = Number(v); return Number.isFinite(n) ? n.toFixed(6) : '0.000000'; }; - const shopHref = clearnetItemHref('shops', shop.title, shop.key); const productCards = (products || []).filter(p => Number(p.stock) > 0 || p.featured).map(prod => { const pImg = cnBlobUrl(prod.image); const pKind = /^\[video:/.test(String(prod.image || '')) ? 'video' : /^\[audio:/.test(String(prod.image || '')) ? 'audio' : 'img'; return `
- ${pImg ? (pKind === 'img' ? `` : `<${pKind} class="cn-product-img" src="${pImg}" controls preload="metadata">`) : ''}

${cnEscapeHtml(prod.title || '')}

${prod.description ? `

${cnRichText(prod.description)}

` : ''} @@ -655,9 +663,7 @@ exports.clearnetShopView = async (shop, products = []) => { .cn-shop-meta{display:flex;gap:12px;flex-wrap:wrap;background:var(--bg-sub);border:1px solid var(--border);border-radius:8px;padding:10px 14px;font-size:13px;color:var(--fg-soft)} .cn-shop-meta-item{display:inline-flex;align-items:center;gap:6px} .cn-products{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:16px} -.cn-product{position:relative;background:var(--bg-elev);border:1px solid var(--border);border-radius:8px;padding:16px;transition:border-color .15s ease} -.cn-product-cover{position:absolute;inset:0;z-index:2;border-radius:8px} -.cn-product video,.cn-product audio{position:relative;z-index:3} +.cn-product{background:var(--bg-elev);border:1px solid var(--border);border-radius:8px;padding:16px;transition:border-color .15s ease} .cn-product:hover{border-color:var(--fg)} .cn-product-img{width:100%;height:180px;object-fit:cover;border-radius:6px;margin-bottom:10px;background:#000;border:1px solid var(--border)} .cn-product-title{color:var(--fg);font-size:16px;margin:0 0 8px 0;font-weight:600} @@ -693,48 +699,6 @@ exports.clearnetShopView = async (shop, products = []) => { }); }; -exports.clearnetProductView = async (shop, product) => { - const shopHref = clearnetItemHref('shops', shop.title, shop.key); - const price = Number.isFinite(Number(product.price)) ? Number(product.price).toFixed(6) : '0.000000'; - const media = cnBlobUrl(product.image); - const kind = /^\[video:/.test(String(product.image || '')) ? 'video' : /^\[audio:/.test(String(product.image || '')) ? 'audio' : 'img'; - const mediaHtml = !media ? '' : kind === 'img' - ? `${cnEscapeHtml(product.title || '')}` - : `<${kind} class="cn-product-page-img" src="${media}" controls preload="metadata">`; - const extraCss = ` -.cn-product-back{display:inline-block;margin-bottom:16px;color:var(--fg-soft);text-decoration:none} -.cn-product-back:hover{color:var(--fg)} -.cn-product-page{display:flex;gap:24px;flex-wrap:wrap;align-items:flex-start} -.cn-product-page-img{display:block;max-width:420px;width:100%;border:1px solid var(--border);border-radius:8px;background:#000} -.cn-product-page-body{flex:1 1 320px;min-width:0} -.cn-product-page-title{color:var(--fg);font-size:26px;font-weight:700;margin:0 0 12px 0} -.cn-product-page-desc{color:var(--fg-soft);line-height:1.6;margin:0 0 16px 0} -.cn-product-page-price{color:var(--fg);background:var(--bg-sub);border:1px solid var(--fg);display:inline-block;padding:6px 12px;border-radius:4px;font-weight:bold;font-size:16px} -.cn-product-page-stock{color:var(--fg-dim);font-size:13px;margin:10px 0 0 0;text-transform:uppercase;letter-spacing:1px} -`; - const body = ` - ← ${cnEscapeHtml(shop.title || i18n.shopBackToShop)} -
- ${mediaHtml} -
-

${cnEscapeHtml(product.title || '')}

- ${product.description ? `
${cnRichText(product.description)}
` : ''} - ${price} ECO -

${cnEscapeHtml(i18n.shopProductStock)}: ${Number(product.stock) || 0}

-
-
-`; - return renderClearnetPage({ - title: `${product.title || 'Product'} | ${shop.title || 'Shop'} | Oasis`, - ogTitle: product.title || 'Oasis', - ogDescription: product.description || '', - ogImage: kind === 'img' ? media : null, - extraCss, - body, - hubFeedId: shop.author || null - }); -}; - exports.renderShopInvitePage = (code) => { const pageContent = div({ class: "invite-page" }, h2(i18n.tribeInviteCodeText, code), diff --git a/src/views/task_view.js b/src/views/task_view.js index d35ce2a..089e534 100644 --- a/src/views/task_view.js +++ b/src/views/task_view.js @@ -1,7 +1,7 @@ -const { div, h2, p, section, button, form, input, select, option, a, br, textarea, label, span, table, tr, td, video } = require("../server/node_modules/hyperaxe"); +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, renderEngagement, userLink, renderStateChip, renderPrivacyChip, renderLifespanChip, renderEcoTax, renderSpreadEditWarning, renderCardTop, renderCardByline, renderDocumentActions, renderModuleStatsBy, moduleIsEmpty, contentDeleteAction, paged, renderEditedChip, renderDetailShare } = require("./main_views"); +const { template, i18n, renderOpinionsVoting, renderEngagement, userLink, renderStateChip, renderPrivacyChip, renderLifespanChip, renderEcoTax, renderSpreadButton, renderSpreadEditWarning, renderContentActions, renderDocumentActions, renderModuleStatsBy, moduleIsEmpty, contentDeleteAction, paged, renderCreatedRows } = require("./main_views"); const { renderPhotoGallery, renderGalleryFields, imagesOf, renderZoomableImage } = require("./gallery_view"); const { renderTribeOriginChip } = require("./clearnet_view"); const { config } = require("../server/SSB_server.js"); @@ -131,14 +131,19 @@ const renderTaskItem = exports.renderTaskItem = (task, filter, spreadInfo, opts const isPrivate = String(task.isPublic || "").toUpperCase() === "PRIVATE"; const isAuthor = String(task.author) === String(userId); const origin = task.tribeOrigin || null; - const detailHref = `/tasks/${encodeURIComponent(task.id)}`; - const href = opts.href !== undefined ? opts.href : (origin ? origin.href : detailHref); + const href = opts.href !== undefined ? opts.href : (origin ? origin.href : `/tasks/${encodeURIComponent(task.id)}`); const cover = origin ? null : imagesOf(task)[0] const heroNode = cover ? div({ class: "tribe-card-image-wrapper" }, - a({ href: detailHref }, + a({ href: `/tasks/${encodeURIComponent(task.id)}` }, renderTaskMediaBlob(cover, { class: "tribe-card-hero-image" }) + ), + div({ class: "tribe-visit-btn-wrapper" }, + 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; @@ -152,15 +157,12 @@ const renderTaskItem = exports.renderTaskItem = (task, filter, spreadInfo, opts ...(Array.isArray(opts.extraChips) ? opts.extraChips : []) ].filter(Boolean); - const topOpts = { author: task.author, date: task.createdAt, chips }; - const cardTop = opts.headerActions !== undefined - ? div({ class: "card-header activity-card-header card-top" }, renderCardByline(topOpts), opts.headerActions) - : origin - ? renderCardTop(task.id, origin.href, { ...topOpts, spread: spreadInfo || null, reportTitle: task.title }) - : renderCardTop(task.id, detailHref, { ...topOpts, spread: spreadInfo || null, favKind: 'tasks', isFavorite: task.isFavorite, reportTitle: task.title, returnTo: `/tasks?filter=${encodeURIComponent(currentFilter)}`, deleteAction: isAuthor ? contentDeleteAction('task', task.id) : undefined }); + const headerActions = opts.headerActions !== undefined + ? opts.headerActions + : origin ? null : renderContentActions(task.id, `/tasks/${encodeURIComponent(task.id)}`, { spread: spreadInfo || null, author: task.author, favKind: 'tasks', isFavorite: task.isFavorite, reportTitle: task.title, returnTo: `/tasks?filter=${encodeURIComponent(currentFilter)}`, deleteAction: isAuthor ? contentDeleteAction('task', task.id) : undefined }); return div({ class: "tribe-card task-card" + (isAuthor ? " own-content" : "") }, - cardTop, + headerActions ? div({ class: "card-header activity-card-header" }, span(), headerActions) : null, heroNode, div({ class: "tribe-card-body" }, div({ class: "shop-title-row" }, @@ -168,11 +170,11 @@ const renderTaskItem = exports.renderTaskItem = (task, filter, spreadInfo, opts href ? a({ href }, task.title || i18n.tasksTitle) : (task.title || i18n.tasksTitle) ) ), + chips.length ? div({ class: "card-chips-row" }, ...chips) : null, origin ? null : div({ class: "tribe-card-members" }, span({ class: "tribe-members-count" }, `${i18n.taskAssignedTo}: ${assignees.length}`) ), - ...(Array.isArray(opts.bodyExtra) ? opts.bodyExtra.filter(Boolean) : []), - null + ...(Array.isArray(opts.bodyExtra) ? opts.bodyExtra.filter(Boolean) : []) ) ); }; @@ -411,13 +413,15 @@ 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" }, - renderCardTop(task.id, `/tasks/${encodeURIComponent(task.id)}`, { arrow: false, date: task.createdAt, chips, bylineExtra: [renderEditedChip(task.id, task.rootId)], spread: params.spreads || null, author: task.author, favKind: 'tasks', isFavorite: task.isFavorite, reportTitle: task.title, deleteAction: isAuthor ? contentDeleteAction('task', task.id) : undefined }), + div({ class: "card-header activity-card-header" }, + renderContentActions(task.id, `/tasks/${encodeURIComponent(task.id)}`, { spread: params.spreads || null, author: task.author, favKind: 'tasks', isFavorite: task.isFavorite, reportTitle: task.title, deleteAction: isAuthor ? contentDeleteAction('task', task.id) : undefined }) + ), div({ class: "shop-title-row" }, h2({ class: "tribe-card-title" }, task.title) ), - renderDetailShare(task.id, `/tasks/${encodeURIComponent(task.id)}`, task.title), + chips.length ? div({ class: "card-chips-row" }, ...chips) : null, renderPhotoGallery(task, 'task'), - table({ class: "tribe-info-table jobs-info-table" }, ...infoRows), + table({ class: "tribe-info-table jobs-info-table" }, ...infoRows, ...renderCreatedRows(task.createdAt, task.author)), assignNode ? div({ class: "tribe-side-actions housing-status-row task-assign-row" }, ...assignNode) : null, isAuthor ? renderTaskStatusRow(task, returnToSelf) : null, tagsNode, diff --git a/src/views/torrents_view.js b/src/views/torrents_view.js index a7a98dd..7f50d31 100644 --- a/src/views/torrents_view.js +++ b/src/views/torrents_view.js @@ -1,8 +1,8 @@ -const { form, button, div, h2, h3, p, section, input, br, a, span, textarea, select, label, option, table, tr, th, td, progress, details, summary, ul, li } = require("../server/node_modules/hyperaxe"); +const { form, button, div, h2, h3, p, section, input, br, a, span, textarea, select, label, option, table, tr, th, td, progress, strong } = require("../server/node_modules/hyperaxe"); const { renderCommentsSection: renderSharedCommentsSection } = require("./comments_view"); -const { renderReachChip, renderClearnetSelector, renderClearnetSwitch, renderTribeOriginChip, renderLicenseSelect, renderLicenseChip } = require('./clearnet_view'); +const { renderReachChip, renderClearnetSelector, renderClearnetSwitch, renderTribeOriginChip } = require('./clearnet_view'); -const { clearnetItemHref, template, i18n, renderOpinionsVoting, renderEngagement, renderSpreadButton, renderEcoTax, renderLifespanChip, renderSpreadEditWarning, renderCardTop, renderModuleStats, moduleIsEmpty, renderTorrentDownload, torrentDownloadHref, renderTorrentSourceDownload, contentDeleteAction, paged, formatSize, renderMediaLockNote, renderEditedChip, renderDetailShare } = require("./main_views"); +const { clearnetItemHref, template, i18n, renderOpinionsVoting, renderEngagement, userLink, renderSpreadButton, renderEcoTax, renderLifespanChip , renderSpreadEditWarning, renderContentActions, renderModuleStats, moduleIsEmpty, renderTorrentDownload, torrentDownloadHref, renderTorrentSourceDownload, contentDeleteAction, paged } = require("./main_views"); const moment = require("../server/node_modules/moment"); const { config } = require("../server/SSB_server.js"); const { renderStyledText } = require("../backend/renderStyledText"); @@ -35,17 +35,20 @@ const renderTags = (tags) => { const renderTorrentOwnerActions = (filter, torrentObj, params = {}) => { const returnTo = buildReturnTo(filter, params); const isAuthor = String(torrentObj.author) === String(userId); + const hasOpinions = Object.keys(torrentObj.opinions || {}).length > 0; if (!isAuthor) return []; const items = []; - items.push( - form( - { method: "GET", action: `/torrents/edit/${encodeURIComponent(torrentObj.key)}` }, - input({ type: "hidden", name: "returnTo", value: returnTo }), - button({ class: "update-btn", type: "submit" }, i18n.torrentUpdateButton) - ) - ); + if (!hasOpinions) { + items.push( + form( + { method: "GET", action: `/torrents/edit/${encodeURIComponent(torrentObj.key)}` }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + button({ class: "update-btn", type: "submit" }, i18n.torrentUpdateButton) + ) + ); + } return items; }; @@ -64,6 +67,13 @@ const blobSha256Hex = (blobId) => { try { return Buffer.from(m[1], "base64").toString("hex"); } catch (_) { return ""; } }; +const formatSize = (bytes) => { + const n = Number(bytes) || 0; + if (n === 0) return "—"; + if (n < 1024) return n + " B"; + if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB"; + return (n / (1024 * 1024)).toFixed(1) + " MB"; +}; const spreadInfoOf = (t, spreadMap) => (spreadMap instanceof Map && spreadMap.get(t.key)) || null; const renderTorrentSeeds = (t, spreadMap) => { @@ -148,7 +158,7 @@ const renderTorrentForm = (filter, torrentId, torrentToEdit, params = {}) => { : null, fromBlob ? null : span(i18n.torrentFileLabel), fromBlob ? null : br(), - fromBlob ? null : ((filter === "edit" && renderMediaLockNote(torrentToEdit)) || input({ type: "file", name: "torrent", accept: ".torrent", required: filter !== "edit" })), + fromBlob ? null : input({ type: "file", name: "torrent", accept: ".torrent", required: filter !== "edit" }), fromBlob ? null : br(), fromBlob ? null : br(), span(i18n.torrentTitleLabel), @@ -169,7 +179,6 @@ const renderTorrentForm = (filter, torrentId, torrentToEdit, params = {}) => { }), br(), br(), - ...renderLicenseSelect(torrentToEdit?.license, i18n), ...(clearnetAllowed ? [renderClearnetSelector(filter === "edit" ? !!torrentToEdit?.clearnet : false, i18n), br()] : []), button({ type: "submit" }, filter === "edit" ? i18n.torrentUpdateButton : i18n.torrentCreateButton) ) @@ -355,38 +364,28 @@ exports.singleTorrentView = async (torrentObj, filter = "all", comments = [], pa for (const a of ownerActions) sideActions.push(a); const tagsNode = renderTags(torrentObj.tags); - const href = `/torrents/${encodeURIComponent(torrentObj.key)}`; - const createdTs = torrentObj.createdAt ? new Date(torrentObj.createdAt).getTime() : NaN; - const updatedTs = torrentObj.updatedAt ? new Date(torrentObj.updatedAt).getTime() : NaN; - const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs); - const torrentSide = div({ class: "tribe-side" }, - renderCardTop(torrentObj.key, href, { arrow: false, - author: torrentObj.author, - date: torrentObj.createdAt, - chips: [ - torrentObj.tribeId && torrentObj.cipher ? renderEncryptedChip(i18n) : renderTransportChip(i18n), - clearnetAllowed ? renderReachChip(isClearnet, i18n, isClearnet ? clearnetItemHref('torrents', torrentObj.title, torrentObj.key) : null) : null, - torrentObj.tribeId ? null : renderLicenseChip(torrentObj.license), - ...chips - ], - bylineExtra: [ - renderEditedChip(torrentObj.key, torrentObj.rootId), - showUpdated ? span({ class: "votations-comment-date" }, `${i18n.torrentUpdatedAt}: ${moment(torrentObj.updatedAt).format("YYYY/MM/DD HH:mm")}`) : null - ], + const detailActions = div({ class: "card-header activity-card-header" }, + renderContentActions(torrentObj.key, `/torrents/${encodeURIComponent(torrentObj.key)}`, { spreadTitle: i18n.seedAction, + author: torrentObj.author, favKind: 'torrents', isFavorite: torrentObj.isFavorite, spread: params.spreads || null, returnTo, reportTitle: torrentObj.title, deleteAction: String(torrentObj.author) === String(userId) ? contentDeleteAction('torrent', torrentObj.key) : undefined - }), + }) + ); + + const torrentSide = div({ class: "tribe-side" }, div({ class: "shop-title-row" }, title ? h2({ class: "tribe-card-title" }, title) : null, + torrentObj.tribeId && torrentObj.cipher ? renderEncryptedChip(i18n) : renderTransportChip(i18n), + clearnetAllowed ? renderReachChip(isClearnet, i18n, isClearnet ? clearnetItemHref('torrents', torrentObj.title, torrentObj.key) : null) : null, clearnetAllowed && isAuthor ? renderClearnetSwitch('torrents', torrentObj.rootId || torrentObj.key, isClearnet) : null ), - renderDetailShare(torrentObj.key, href, torrentObj.title), + chips.length ? div({ class: "card-chips-row" }, ...chips) : null, safeText(torrentObj.description) ? p({ class: "tribe-side-description" }, ...renderStyledText(torrentObj.description)) : null, @@ -395,6 +394,7 @@ exports.singleTorrentView = async (torrentObj, filter = "all", comments = [], pa ); const torrentMain = div({ class: "tribe-main" }, + detailActions, torrentObj.url && torrentObj.url.startsWith("&") ? div({ class: "torrent-download" }, renderOasisDownload(torrentObj, params), @@ -402,29 +402,36 @@ exports.singleTorrentView = async (torrentObj, filter = "all", comments = [], pa h3({ class: "torrent-section-title" }, "Torrent"), table({ class: "tribe-info-table torrent-file-info" }, tr(td({ class: "tribe-info-label" }, i18n.fileShareFileLabel), td({ class: "tribe-info-value" }, `${String(torrentObj.title || "download").replace(/\.torrent$/i, "")}.torrent`)), - torrentObj.source ? null : tr(td({ class: "tribe-info-label" }, i18n.torrentSizeLabel), td({ class: "tribe-info-value" }, formatSize(params.torrentInfo ? params.torrentInfo.length : torrentObj.size))), - params.torrentInfo ? tr(td({ class: "tribe-info-label" }, i18n.torrentInfoHash), td({ class: "tribe-info-value" }, span({ class: "bank-address-code" }, params.torrentInfo.infoHash))) : null, - params.torrentInfo ? tr(td({ class: "tribe-info-label" }, i18n.torrentFilesLabel), td({ class: "tribe-info-value" }, String(params.torrentInfo.fileCount))) : null, + torrentObj.source ? null : tr(td({ class: "tribe-info-label" }, i18n.torrentSizeLabel), td({ class: "tribe-info-value" }, formatSize(torrentObj.size))), torrentObj.cipher ? tr(td({ class: "tribe-info-label" }, i18n.encryptedChipLabel || "E2E"), td({ class: "tribe-info-value" }, "AES-256-GCM")) : tr(td({ class: "tribe-info-label" }, "SHA-256"), td({ class: "tribe-info-value" }, span({ class: "bank-address-code" }, blobSha256Hex(torrentObj.url)))) ), - params.torrentInfo && params.torrentInfo.fileCount > 1 - ? details({ class: "torrent-file-list" }, - summary(`${i18n.torrentFilesLabel} (${params.torrentInfo.fileCount})`), - ul(...params.torrentInfo.files.map(f => li(span(f.path), " · ", span({ class: "votations-comment-date" }, formatSize(f.length))))) - ) - : null, - renderTorrentDownload(torrentObj.cipher ? `/torrents/${encodeURIComponent(torrentObj.key)}/file` : torrentDownloadHref(torrentObj.url, torrentObj.title)), - params.torrentInfo && params.torrentInfo.magnet - ? a({ href: params.torrentInfo.magnet, class: "filter-btn torrent-magnet-link", rel: "noopener" }, `\u2B07 ${i18n.torrentMagnet}`) - : null + renderTorrentDownload(torrentObj.cipher ? `/torrents/${encodeURIComponent(torrentObj.key)}/file` : torrentDownloadHref(torrentObj.url, torrentObj.title)) ) ) : p(i18n.torrentNoFile), - renderEngagement(torrentObj.key, - torrentObj.tribeId ? null : renderOpinionsVoting('/torrents/opinions', torrentObj.key, torrentObj.opinions, returnTo, torrentObj.opinions_inhabitants), - torrentObj.tribeId ? null : renderTorrentCommentsSection(torrentObj.key, comments, returnTo)) + (() => { + const createdTs = torrentObj.createdAt ? new Date(torrentObj.createdAt).getTime() : NaN; + const updatedTs = torrentObj.updatedAt ? new Date(torrentObj.updatedAt).getTime() : NaN; + const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs); + + return p( + { class: "card-footer" }, + span({ class: "date-link" }, `${moment(torrentObj.createdAt).format("YYYY/MM/DD HH:mm")}`), + userLink(torrentObj.author), + showUpdated + ? span( + { class: "votations-comment-date" }, + ` | ${i18n.torrentUpdatedAt}: ${moment(torrentObj.updatedAt).format("YYYY/MM/DD HH:mm")}` + ) + : null + ); + })(), + torrentObj.tribeId ? null : renderEngagement(torrentObj.key, + renderOpinionsVoting('/torrents/opinions', torrentObj.key, torrentObj.opinions, returnTo, torrentObj.opinions_inhabitants), + renderTorrentCommentsSection(torrentObj.key, comments, returnTo) + ) ); return template( @@ -439,16 +446,14 @@ exports.singleTorrentView = async (torrentObj, filter = "all", comments = [], pa form( { method: "GET", action: "/torrents", class: "ui-toolbar ui-toolbar--filters" }, input({ type: "hidden", name: "sort", value: sort }), - 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: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, String(i18n.torrentFilterAll).toUpperCase()), ...(mediaChip("favorites") ? [button( ...(mediaChip("recent") ? [button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, String(i18n.torrentFilterRecent).toUpperCase())] : []), ...(mediaChip("mine") ? [button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, String(i18n.torrentFilterMine).toUpperCase())] : []), - ...(mediaChip("favorites") ? [button( { type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" }, String(i18n.torrentFilterFavorites).toUpperCase() )] : []), button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, String(i18n.torrentFilterTop).toUpperCase()), - ...(params.isPublic ? [] : [button({ type: "submit", name: "filter", value: "downloads", class: "filter-btn" }, String(i18n.torrentDownloads).toUpperCase())]), button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.torrentCreateButton) ) ), diff --git a/src/views/transfer_view.js b/src/views/transfer_view.js index bb34942..f712fe6 100644 --- a/src/views/transfer_view.js +++ b/src/views/transfer_view.js @@ -1,6 +1,6 @@ const { hr, div, h2, p, section, button, form, a, input, br, span, label, select, option, progress, table, tr, td } = require("../server/node_modules/hyperaxe") const { getConfig } = require('../configs/config-manager.js'); -const { template, i18n, renderOpinionsVoting, renderEngagement, userLink, renderStateChip, renderLifespanChip, renderCardTop, renderModuleStatsBy, moduleIsEmpty, renderEcoValueChip, renderWalletChip, contentDeleteAction, paged, renderEditedChip, renderMediaLockNote, renderDetailShare } = require("./main_views") +const { template, i18n, renderOpinionsVoting, renderEngagement, userLink, renderStateChip, renderLifespanChip, renderEcoTax, renderSpreadButton, renderContentActions, renderModuleStatsBy, moduleIsEmpty, renderEcoValueChip, renderWalletChip, contentDeleteAction, paged, renderCreatedRows } = require("./main_views") const { renderCommentsSection: renderSharedCommentsSection } = require("./comments_view") const moment = require("../server/node_modules/moment") const { config } = require("../server/SSB_server.js") @@ -171,22 +171,25 @@ const generateTransferCard = (transfer, filter, params = {}) => { const isOwn = transfer.from && String(transfer.from) === String(userId) const canDelete = isOwn && String(transfer.status || "").toUpperCase() === "UNCONFIRMED" && !isExpired && confirmedCount < required - const transferHref = `/transfers/${encodeURIComponent(transfer.id)}` return div({ class: "trending-card transfer-card" + (isOwn ? " own-content" : "") }, - renderCardTop(transfer.id, transferHref, { date: transfer.createdAt, chips, spread: params.spreadMap && params.spreadMap.get(transfer.id) || null, author: transfer.from, favKind: 'transfers', isFavorite: transfer.isFavorite, reportTitle: transfer.concept, returnTo, deleteAction: canDelete ? contentDeleteAction('transfer', transfer.id) : undefined }), + div( + { class: "card-header activity-card-header" }, + span(), + renderContentActions(transfer.id, `/transfers/${encodeURIComponent(transfer.id)}`, { spread: params.spreadMap && params.spreadMap.get(transfer.id) || null, author: transfer.from, favKind: 'transfers', isFavorite: transfer.isFavorite, reportTitle: transfer.concept, returnTo, deleteAction: canDelete ? contentDeleteAction('transfer', transfer.id) : undefined }) + ), div({ class: "card-section transfer-card-body" }, div({ class: "shop-title-row" }, h2({ class: "tribe-card-title" }, - a({ href: transferHref }, transferTitle(transfer)) + a({ href: `/transfers/${encodeURIComponent(transfer.id)}` }, transferTitle(transfer)) ) ), + chips.length ? div({ class: "card-chips-row" }, ...chips) : null, !isUbi && dl && dl.isValid() ? p({ class: "time-chip" }, dl.format("YYYY/MM/DD HH:mm")) : null, cat !== "TRUST" ? div({ class: "tribe-card-members" }, span({ class: "price-chip" }, fmtAmountWithUnit(transfer))) - : null, - null + : null ) ) } @@ -269,8 +272,6 @@ exports.transferView = async (transfers, filter, transferId, params = {}) => { const isForm = normalizedFilter === "create" || normalizedFilter === "edit" const transferToEdit = normalizedFilter === "edit" ? (list.find(t => t.id === transferId) || {}) : (params.prefill || {}) const returnToForForm = buildReturnTo("all", {}) - const opinionLock = normalizedFilter === "edit" ? renderMediaLockNote(transferToEdit) : null - const ro = opinionLock ? { readonly: true } : {} const validCategories = ["ECONOMIC", "TIME", "TRUST"] const paramCategoryRaw = safeText(params.category || "").toUpperCase() const selectedCategory = @@ -351,8 +352,7 @@ exports.transferView = async (transfers, filter, transferId, params = {}) => { normalizedFilter === "create" ? input({ type: "hidden", name: "category", value: selectedCategory }) : null, label(i18n.transfersToUser), br(), - input({ type: "text", name: "to", required: true, pattern: "^@[A-Za-z0-9+/]+={0,2}\\.ed25519$", title: i18n.transfersToUserValidation, value: transferToEdit.to || "", ...ro }), - opinionLock, + input({ type: "text", name: "to", required: true, pattern: "^@[A-Za-z0-9+/]+={0,2}\\.ed25519$", title: i18n.transfersToUserValidation, value: transferToEdit.to || "" }), br(), label(i18n.transfersConcept), br(), @@ -376,7 +376,7 @@ exports.transferView = async (transfers, filter, transferId, params = {}) => { : [ label(`${i18n.transfersAmount} (${amountUnitLabel})`), br(), - input({ type: "number", name: "amount", step: "0.000001", required: true, min: "0.000001", value: transferToEdit.amount || "", ...ro }), + input({ type: "number", name: "amount", step: "0.000001", required: true, min: "0.000001", value: transferToEdit.amount || "" }), renderEcoValueChip(), br(), br() ], @@ -494,30 +494,23 @@ exports.singleTransferView = async (transfer, filter, params = {}) => { if (!isUbi) pushRow(i18n.transfersDeadline, dl && dl.isValid() ? dl.format("YYYY/MM/DD HH:mm") : "") pushRow(i18n.transfersStatus, i18n[statusKey(transfer.status)] || String(transfer.status || "")) - const updatedAt = transfer.updatedAt ? moment(transfer.updatedAt) : null - const showUpdated = updatedAt && updatedAt.isValid() && updatedAt.valueOf() !== new Date(transfer.createdAt).getTime() - const transferSide = div({ class: "tribe-side" }, - renderCardTop(transfer.id, `/transfers/${encodeURIComponent(transfer.id)}`, { arrow: false, - date: transfer.createdAt, - chips, - bylineExtra: [ - renderEditedChip(transfer.id, transfer.rootId), - showUpdated ? span({ class: "card-byline-time" }, `${i18n.jobsUpdatedAt}: ${updatedAt.format("YYYY/MM/DD HH:mm")}`) : null - ], - spread: params.spreads || null, - author: transfer.from, - favKind: 'transfers', - isFavorite: transfer.isFavorite, - returnTo, - reportTitle: transfer.concept, - deleteAction: canDelete ? contentDeleteAction('transfer', transfer.id) : undefined - }), + div({ class: "card-header activity-card-header" }, + renderContentActions(transfer.id, `/transfers/${encodeURIComponent(transfer.id)}`, { + spread: params.spreads || null, + author: transfer.from, + favKind: 'transfers', + isFavorite: transfer.isFavorite, + returnTo, + reportTitle: transfer.concept, + deleteAction: canDelete ? contentDeleteAction('transfer', transfer.id) : undefined + }) + ), div({ class: "shop-title-row" }, h2({ class: "tribe-card-title" }, transferTitle(transfer)) ), - renderDetailShare(transfer.id, `/transfers/${encodeURIComponent(transfer.id)}`, transfer.concept), - table({ class: "tribe-info-table" }, ...infoRows), + chips.length ? div({ class: "card-chips-row" }, ...chips) : null, + table({ class: "tribe-info-table" }, ...renderCreatedRows(transfer.createdAt, transfer.from, transfer.updatedAt)), tagsNode, div({ class: "tribe-card-members" }, span({ class: "tribe-members-count" }, `${i18n.transfersConfirmations}: ${confirmedCount}/${required}`) @@ -552,7 +545,8 @@ exports.singleTransferView = async (transfer, filter, params = {}) => { action: `/transfers/${encodeURIComponent(transfer.id)}/comments`, comments: params.comments || [], returnTo - })) + }) + ) ) return template( diff --git a/src/views/trending_view.js b/src/views/trending_view.js index 52ec7b0..9a6a518 100644 --- a/src/views/trending_view.js +++ b/src/views/trending_view.js @@ -1,7 +1,7 @@ -const { div, h2, p, section, button, form, a, br, input, table, tr, th, td, video: videoHyperaxe, audio: audioHyperaxe, span, details, summary } = require("../server/node_modules/hyperaxe"); +const { div, h2, p, section, button, form, a, textarea, br, input, table, tr, th, td, img, video: videoHyperaxe, audio: audioHyperaxe, span, details, summary} = require("../server/node_modules/hyperaxe"); const RAW_HTML = Symbol.for('oasis.rawHtml'); const moment = require("../server/node_modules/moment"); -const { template, i18n, renderCardTop, renderShareButton, renderVotesSummary, renderModuleStats, renderCardMetaRow, renderCommentsSummary, commentsOpenHref, renderTorrentDownload, renderTorrentSourceDownload, renderFileDownloads, torrentDownloadHref, CONTENT_FAV_KIND, CONTENT_TORRENTABLE, paged, renderPdfDocument } = require('./main_views'); +const { template, i18n, userLink, renderSpreadButton, renderContentActions, renderVotesSummary, renderModuleStats, renderCardMetaRow, renderTorrentDownload, renderTorrentSourceDownload, renderFileDownloads, torrentDownloadHref, CONTENT_FAV_KIND, CONTENT_TORRENTABLE, paged } = require('./main_views'); const { renderStyledHtml, safeExternalHref } = require('../backend/renderStyledText'); const { renderZoomableImage } = require('./gallery_view'); const { config } = require('../server/SSB_server.js'); @@ -12,7 +12,7 @@ const { sanitizeHtml } = require('../backend/sanitizeHtml'); const userId = config.keys.id; const capFirst = (s) => String(s || '').charAt(0).toUpperCase() + String(s || '').slice(1); -const typeLabel = (type) => i18n['type' + capFirst(type)] || require('./activity_view').rootTypeLabel(type) || type; +const typeLabel = (type) => i18n['type' + capFirst(type)] || type; const filterButton = (mode, currentFilter) => form({ method: 'GET', action: '/trending' }, @@ -28,6 +28,7 @@ const voteLabelFor = (cat) => const renderTrendingCard = (item, votes, categories, seenTitles, spreadMap = new Map(), params = {}) => { const c = item.value.content; + const created = moment(item.value.timestamp).format("YYYY/MM/DD HH:mm"); let contentHtml; @@ -110,7 +111,7 @@ const renderTrendingCard = (item, votes, categories, seenTitles, spreadMap = new div({ class: 'card-section document' }, t ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.documentTitleLabel + ':'), span({ class: 'card-value' }, t)) : "", description ? [span({ class: 'card-label' }, i18n.documentDescriptionLabel + ":"), p(...renderStyledText(description))] : null, - renderPdfDocument(`/blob/${encodeURIComponent(url)}`, { title: t }) + div({ id: `pdf-container-${item.key}`, class: 'pdf-viewer-container', 'data-pdf-url': `/blob/${encodeURIComponent(url)}` }) ) ); } else if (c.type === 'industry') { @@ -250,35 +251,32 @@ const renderTrendingCard = (item, votes, categories, seenTitles, spreadMap = new logisticsRoute: 'logistics', poll: 'polls', schoolCourse: 'school/course', - wikiPage: 'wiki', - map: 'maps', - job: 'jobs', industryBlueprint: 'industry/blueprint' }; const detailHref = detailPaths[c.type] ? `/${detailPaths[c.type]}/${encodeURIComponent(item.key)}` : `/thread/${encodeURIComponent(item.key)}`; const isOwn = item.value.author && String(item.value.author) === String(userId); - const commentTotal = params.commentCounts instanceof Map ? (params.commentCounts.get(item.key) || 0) : 0; - const shareTitle = c.title || c.question || c.concept || c.contentWarning || ''; return div( { class: 'trending-card' + (isOwn ? ' own-content' : '') }, - renderCardTop(item.key, detailHref, { - date: item.value.timestamp, - chips: [span({ class: 'pm-exposition-chip pm-exposition-whole' }, + div( + { class: 'card-header activity-card-header' }, + span({ class: 'pm-exposition-chip pm-exposition-whole' }, span({ class: 'pm-exposition-text' }, String(typeLabel(c.type) || '').toUpperCase()) - )], - spread: spreadMap.get(item.key) || null, - author: item.value.author, - reportTitle: shareTitle, - ...(CONTENT_TORRENTABLE.has(c.type) && typeof c.url === 'string' && c.url.startsWith('&') - ? { torrentFrom: { blobId: c.url, name: c.title || c.fileName || '' } } - : {}), - ...(CONTENT_FAV_KIND[c.type] - ? { favKind: CONTENT_FAV_KIND[c.type], isFavorite: params.favIndex instanceof Map ? params.favIndex.get(String(item.key)) === CONTENT_FAV_KIND[c.type] : false, returnTo: params.returnTo } - : {}) - }), + ), + renderContentActions(item.key, detailHref, { + spread: spreadMap.get(item.key) || null, + author: item.value.author, + reportTitle: c.title || c.question || c.concept || c.contentWarning || '', + ...(CONTENT_TORRENTABLE.has(c.type) && typeof c.url === 'string' && c.url.startsWith('&') + ? { torrentFrom: { blobId: c.url, name: c.title || c.fileName || '' } } + : {}), + ...(CONTENT_FAV_KIND[c.type] + ? { favKind: CONTENT_FAV_KIND[c.type], isFavorite: params.favIndex instanceof Map ? params.favIndex.get(String(item.key)) === CONTENT_FAV_KIND[c.type] : false, returnTo: params.returnTo } + : {}) + }) + ), div( { class: 'card-section trending-card-body' }, contentHtml @@ -286,7 +284,7 @@ const renderTrendingCard = (item, votes, categories, seenTitles, spreadMap = new renderVotesSummary(c.opinions), renderCardMetaRow( details({ class: 'opinions-voting-collapse' }, - summary({ class: Object.values(c.opinions || {}).some(n => Number(n) > 0) ? 'opinions-summary engage-on' : 'opinions-summary' }, + 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( @@ -301,8 +299,11 @@ const renderTrendingCard = (item, votes, categories, seenTitles, spreadMap = new ) ) ), - renderCommentsSummary(commentsOpenHref(detailHref, commentTotal), commentTotal), - renderShareButton(item.key, detailHref, shareTitle) + p( + { class: 'card-footer' }, + span({ class: 'date-link' }, `${created}`), + userLink(item.value.author) + ) ) ); }; @@ -316,8 +317,7 @@ exports.trendingView = (items, filter, categories = opinionCategories, spreadMap ['votes', 'event', 'task', 'report'], ['feed', 'post', 'project', 'industry', 'shopProduct', 'transfer'], ['audio', 'bookmark', 'document', 'file', 'image', 'torrent', 'video'], - ['podcast', 'podcastEpisode', 'campaign', 'logisticsRoute'], - ['wikiPage', 'map', 'industryBlueprint', 'job'] + ['podcast', 'podcastEpisode', 'campaign', 'logisticsRoute'] ]; let filteredItems = items.filter(item => { @@ -369,11 +369,12 @@ exports.trendingView = (items, filter, categories = opinionCategories, spreadMap categories, seenDocumentTitles, spreadMap, - { favIndex: params.favIndex, commentCounts: params.commentCounts, returnTo: `/trending?filter=${encodeURIComponent(filter)}` } + { favIndex: params.favIndex, returnTo: `/trending?filter=${encodeURIComponent(filter)}` } ) ) .filter(Boolean); + const hasDocument = filteredItems.some(item => item.value.content.type === 'document'); let html = template( title, @@ -405,6 +406,12 @@ exports.trendingView = (items, filter, categories = opinionCategories, spreadMap ) ); + if (hasDocument) { + html += ` + + `; + } + return html; }; diff --git a/src/views/tribes_view.js b/src/views/tribes_view.js index 5ecc38b..5c2a3fa 100644 --- a/src/views/tribes_view.js +++ b/src/views/tribes_view.js @@ -1,10 +1,9 @@ const { div, h2, h3, p, section, button, form, a, input, img, label, select, option, br, textarea, h1, span, nav, ul, li, video, audio, table, tr, td, thead, tbody, th } = require("../server/node_modules/hyperaxe"); const { TEXT_CAP } = require('../backend/long_text'); const moment = require("../server/node_modules/moment"); -const { template, i18n, userLink, renderStateChip, renderPrivacyChip, renderLifespanChip, renderModeChip, renderInviteQrCard, renderContentActions, renderCardTop, renderSubscriptionBox, renderModuleStats, renderModuleStatsBy, moduleIsEmpty, renderTorrentDownload, torrentDownloadHref, contentDeleteAction, clearnetSlugFor, renderEngagement, renderOpinionsVoting, renderVotesSummary, paged, CLEARNET_HUB_CSS, renderPdfDocument, renderDetailShare } = require('./main_views'); +const { template, i18n, userLink, renderStateChip, renderPrivacyChip, renderLifespanChip, renderModeChip, renderInviteQrCard, renderContentActions, renderSubscriptionBox, renderModuleStats, renderModuleStatsBy, moduleIsEmpty, renderTorrentDownload, torrentDownloadHref, contentDeleteAction, clearnetSlugFor, renderEngagement, renderOpinionsVoting, renderVotesSummary, paged, CLEARNET_HUB_CSS } = require('./main_views'); const { renderTribeWikiSection } = require('./wiki_view'); -const mobileUi = require('./mobile_ui'); -const { renderEncryptedChip: renderTribeEncryptedChip, renderLicenseSelect, renderReachChip, renderClearnetPage, escapeHtml, renderRichText, blobUrl: cnBlobUrl, paginateClearnet, renderClearnetPager, CLEARNET_PAGER_CSS, renderClearnetPdfFrame, CLEARNET_PDF_CSS } = require('./clearnet_view'); +const { renderEncryptedChip: renderTribeEncryptedChip, renderLicenseSelect, renderReachChip, renderClearnetPage, escapeHtml, renderRichText, blobUrl: cnBlobUrl, paginateClearnet, renderClearnetPager, CLEARNET_PAGER_CSS } = require('./clearnet_view'); const { renderPollCard } = require('./polls_view'); const { renderFeedCard } = require('./feed_view'); const { renderEventItem } = require('./event_view'); @@ -77,7 +76,7 @@ const renderTribeReachListChip = (tribe, item, threadsById) => { const parent = item.contentType === 'forum-reply' && threadsById ? threadsById.get(item.parentId) : null; const level = reachLevelOf(parent || item); if (!tribe.reachAllowed && level === 'tribe') return null; - return renderTribeReachChip(level, item.tribeName ? null : tribe); + return div({ class: 'card-chips-row' }, renderTribeReachChip(level, item.tribeName ? null : tribe)); }; const DEFAULT_HASH_ENC = "%260000000000000000000000000000000000000000000%3D.sha256"; @@ -287,7 +286,7 @@ exports.tribesView = async (tribes, filter, tribeId, query = {}, allTribes = nul br, input({ type: 'text', name: 'location', id: 'location', placeholder: i18n.tribeLocationPlaceholder, value: tribeToEdit.location || '' }), br, - label(process.env.OASIS_MOBILE === '1' ? (i18n.tribeMapUrlLabel || 'Map link (optional)') : (i18n.mapLocationTitle || 'Map Location')), + label(i18n.mapLocationTitle || 'Map Location'), br, input({ type: 'text', name: 'mapUrl', placeholder: i18n.mapUrlPlaceholder || '/maps/MAP_ID', value: tribeToEdit.mapUrl || '' }), br, @@ -305,7 +304,6 @@ exports.tribesView = async (tribes, filter, tribeId, query = {}, allTribes = nul option({ value: 'true', selected: tribeToEdit.isAnonymous === true ? 'selected' : undefined }, i18n.tribePrivate), option({ value: 'false', selected: tribeToEdit.isAnonymous === false ? 'selected' : undefined }, i18n.tribePublic) ), - !isSubEdit && mobileUi.isMobile() ? p({ class: 'form-hint' }, i18n.tribeHintPrivacy) : null, isSubEdit ? null : br(), isSubEdit ? null : br(), label({ for: 'inviteMode' }, i18n.tribeModeLabel), @@ -314,7 +312,6 @@ exports.tribesView = async (tribes, filter, tribeId, query = {}, allTribes = nul option({ value: 'strict', selected: tribeToEdit.inviteMode === 'strict' ? 'selected' : undefined }, i18n.tribeStrict), option({ value: 'open', selected: tribeToEdit.inviteMode === 'open' ? 'selected' : undefined }, i18n.tribeOpen) ), - mobileUi.isMobile() ? p({ class: 'form-hint' }, i18n.tribeHintMode) : null, br(), br(), button({ type: 'submit' }, isEdit ? i18n.tribeUpdateButton : i18n.tribeCreateButton) ) @@ -325,34 +322,32 @@ exports.tribesView = async (tribes, filter, tribeId, query = {}, allTribes = nul const shownTribes = filter === 'create' || filter === 'edit' ? [] : paged(sorted); const tribeCards = shownTribes.map(t => { - if (mobileUi.isMobile()) return mobileUi.tribeRow(t, { own: String(t.author) === String(userId), imageNode: t.image ? renderMediaBlob(t.image, null, { class: 'tribe-row-thumb' }) : null }); const parentTribe = t.parentTribeId ? allT.find(p => p.id === t.parentTribeId) : null; const isOwn = String(t.author) === String(userId); - const tribeHref = `/tribe/${encodeURIComponent(t.id)}`; return div({ class: 'trending-card tribes-card' + (isOwn ? ' own-content' : '') }, - renderCardTop(t.id, tribeHref, { - date: t.createdAt, - chips: [ - t.isAnonymous ? renderPrivacyChip(true, i18n) : renderPrivacyChip(false, i18n), - t.isAnonymous ? renderTribeEncryptedChip(i18n) : null, - renderModeChip(t.inviteMode, i18n), - renderLifespanChip(t.lifetime, i18n), - t.subscriptionIn === true - ? renderStateChip('mutuals', '\u2709', i18n.subscriptionOn) - : (t.subscriptionIn === false ? renderStateChip('closed', '\u2709', i18n.subscriptionOff) : null) - ], - author: t.author, reportTitle: t.title, deleteAction: isOwn && !isLarpHouseTribe(t) ? contentDeleteAction('tribe', t.id) : undefined - }), + div({ class: 'card-header activity-card-header' }, + span(), + renderContentActions(t.id, `/tribe/${encodeURIComponent(t.id)}`, { author: t.author, reportTitle: t.title, deleteAction: isOwn && !isLarpHouseTribe(t) ? contentDeleteAction('tribe', t.id) : undefined }) + ), div({ class: 'card-section tribes-card-body' }, div({ class: 'tribe-card-image-wrapper' }, - a({ href: tribeHref }, + a({ href: `/tribe/${encodeURIComponent(t.id)}` }, renderMediaBlob(t.image, '/assets/images/default-tribe.png', { class: 'tribe-card-hero-image' }) ) ), div({ class: 'tribe-card-body' }, div({ class: 'shop-title-row' }, - h2({ class: 'tribe-card-title' }, a({ href: tribeHref }, t.title)) + h2({ class: 'tribe-card-title' }, a({ href: `/tribe/${encodeURIComponent(t.id)}` }, t.title)) + ), + div({ class: 'card-chips-row' }, + t.isAnonymous ? renderPrivacyChip(true, i18n) : renderPrivacyChip(false, i18n), + t.isAnonymous ? renderTribeEncryptedChip(i18n) : null, + renderModeChip(t.inviteMode, i18n), + renderLifespanChip(t.lifetime, i18n), + t.subscriptionIn === true + ? renderStateChip('mutuals', '\u2709', i18n.subscriptionOn) + : (t.subscriptionIn === false ? renderStateChip('closed', '\u2709', i18n.subscriptionOff) : null) ), t.description ? p({ class: 'tribe-card-description' }, ...renderStyledText(t.description)) : null, renderMapLocationVisitLabel(t.mapUrl), @@ -364,8 +359,7 @@ exports.tribesView = async (tribes, filter, tribeId, query = {}, allTribes = nul ) : null, div({ class: 'tribe-card-members' }, span({ class: 'tribe-members-count' }, `${i18n.tribeMembersCount}: ${t.memberCount != null ? t.memberCount : t.members.length}`) - ), - null + ) ) ) ); @@ -382,7 +376,7 @@ exports.tribesView = async (tribes, filter, tribeId, query = {}, allTribes = nul : filter === 'gallery' ? renderGallery(shownTribes) : tribeCards.length > 0 - ? div({ class: mobileUi.isMobile() ? 'tribe-list' : 'tribe-grid' }, tribeCards) + ? div({ class: 'tribe-grid' }, tribeCards) : div({ class: "no-content-box" }, p(i18n.noTribes)) ), ...renderLightbox(shownTribes) @@ -415,7 +409,6 @@ const renderSectionNav = (tribe, section) => { { items: [{ key: 'images', label: i18n.tribeSectionImages || 'IMAGES' }, { key: 'audios', label: i18n.tribeSectionAudios || 'AUDIOS' }, { key: 'videos', label: i18n.tribeSectionVideos || 'VIDEOS' }, { key: 'documents', label: i18n.tribeSectionDocuments || 'DOCUMENTS' }, { key: 'files', label: i18n.tribeSectionFiles || 'FILES' }, { key: 'bookmarks', label: i18n.tribeSectionBookmarks || 'BOOKMARKS' }] }, { items: [{ key: 'tags', label: i18n.tribeSectionTags || 'TAGS' }, { key: 'search', label: i18n.tribeSectionSearch }] }, ]; - if (mobileUi.isMobile()) return mobileUi.tribeTabs(tribe, section, sections); return div({ class: 'tribe-section-nav no-border' }, sections.map(g => div({ class: 'tribe-section-group no-border' }, @@ -494,17 +487,22 @@ const activityMediaTypeName = (mt) => { const renderTribeActivitySection = (tribe, sectionData) => { const { activities } = sectionData || { activities: [] }; if (activities.length === 0) return div({ class: 'tribe-content-list' }, p(i18n.tribeActivityEmpty)); + const tribeUrl = `/tribe/${encodeURIComponent(tribe.id)}`; const threadsById = reachIndexOf(activities); return div({ class: 'tribe-content-list tribe-content-list-spaced' }, paged(activities).map(item => { if (item.encrypted) return div({ class: 'card card-rpg' }, div({ class: 'tribe-card-body' }, p({ class: 'tribe-meta-label' }, i18n.tribeContentEncrypted || 'Encrypted content'))); if (item.contentType === 'chatThread') { const replies = Array.isArray(item.replies) ? item.replies : []; + const tDate = item.timestamp ? moment(item.timestamp).format("YYYY/MM/DD HH:mm") : ''; const headerText = item.tribeName - ? `${String(i18n.typeChat || 'CHAT').toUpperCase()} · THREAD · ${item.tribeName}` - : `${String(i18n.typeChat || 'CHAT').toUpperCase()} · THREAD`; + ? `[${String(i18n.typeChat || 'CHAT').toUpperCase()} · THREAD · ${item.tribeName}]` + : `[${String(i18n.typeChat || 'CHAT').toUpperCase()} · THREAD]`; return div({ class: 'card card-rpg tribe-card-padded chat-thread' }, - renderCardTop(item.id, item.directUrl, { ...tribeItemOpts(tribe, item, isTribeMember(tribe), item.title || item.description), author: item.author, authorName: item.authorName, date: item.timestamp, chips: [tribeTypeChip(headerText)] }), + div({ class: 'card-header' }, + h2({ class: 'card-label' }, headerText), + a({ href: item.directUrl, class: 'tribe-action-btn' }, i18n.viewDetails || 'View Details') + ), div({ class: 'tribe-card-body' }, item.title ? div({ class: 'card-field' }, span({ class: 'card-value' }, item.title)) : null, tribeExcerpt(tribe, item.description), @@ -516,16 +514,20 @@ const renderTribeActivitySection = (tribe, sectionData) => { ) )) ), - null + p({ class: 'card-footer' }, + span({ class: 'date-link' }, `${tDate}`), + userLink(item.author, item.authorName) + ) ); } + const date = item.timestamp ? moment(item.timestamp).format("YYYY/MM/DD HH:mm") : ''; const typeLabel = item.contentType === 'media' && item.mediaType ? activityMediaTypeName(item.mediaType) : contentTypeName(item.contentType); const headerText = item.tribeName - ? `${String(typeLabel).toUpperCase()} · ${item.tribeName}` - : String(typeLabel).toUpperCase(); - const itemHref = tribeItemHref(tribe, item); + ? `[${String(typeLabel).toUpperCase()} · ${item.tribeName}]` + : `[${String(typeLabel).toUpperCase()}]`; + const targetSection = activitySectionForItem(item); const blobUrl = item.contentType === 'media' ? toBlobUrl(item.image) : null; const mediaContent = item.contentType === 'media' && item.mediaType === 'image' && blobUrl @@ -535,20 +537,32 @@ const renderTribeActivitySection = (tribe, sectionData) => { : item.contentType === 'media' && item.mediaType === 'video' && blobUrl ? video({ src: blobUrl, controls: true, class: 'tribe-media-thumb', preload: 'metadata' }) : item.contentType === 'media' && item.mediaType === 'document' && blobUrl - ? renderPdfDocument(blobUrl, { title: item.title }) + ? a({ href: blobUrl, target: '_blank', rel: 'noopener noreferrer', class: 'tribe-action-btn' }, i18n.readDocument || 'Read Document') : item.contentType === 'media' && item.mediaType === 'bookmark' && (item.url || item.description) ? a({ href: safeExternalHref(item.url || item.description), target: '_blank', rel: 'noopener noreferrer', class: 'tribe-action-btn' }, item.url || item.description) : null; return div({ class: 'card card-rpg tribe-card-padded' }, - renderCardTop(item.id, itemHref, { ...tribeItemOpts(tribe, item, isTribeMember(tribe), item.title || item.description), author: item.author, authorName: item.authorName, date: item.timestamp, chips: [div({ class: 'card-chips-row' }, tribeTypeChip(headerText), renderTribeReachListChip(tribe, item, threadsById))] }), + div({ class: 'card-header' }, + h2({ class: 'card-label' }, headerText), + item.directUrl + ? a({ href: item.directUrl, class: 'tribe-action-btn' }, i18n.viewDetails || 'View Details') + : form({ method: 'GET', action: tribeUrl }, + input({ type: 'hidden', name: 'section', value: targetSection }), + button({ type: 'submit', class: 'tribe-action-btn' }, i18n.viewDetails || 'View Details') + ) + ), div({ class: 'tribe-card-body' }, + renderTribeReachListChip(tribe, item, threadsById), item.title ? div({ class: 'card-field' }, - a({ href: itemHref, class: 'card-value' }, item.title) + span({ class: 'card-value' }, item.title) ) : null, mediaContent, item.description && !(item.contentType === 'media' && item.mediaType === 'bookmark' && item.description === item.url) ? tribeExcerpt(tribe, item.description) : null ), - null + p({ class: 'card-footer' }, + span({ class: 'date-link' }, `${date}`), + userLink(item.author, item.authorName) + ) ); }) ); @@ -561,25 +575,25 @@ const renderTribeTrendingSection = (tribe, sectionData, query) => { const tribeUrl = `/tribe/${encodeURIComponent(tribe.id)}`; const periodBtn = (key, label) => form({ method: 'GET', action: tribeUrl }, input({ type: 'hidden', name: 'section', value: 'trending' }), input({ type: 'hidden', name: 'period', value: key }), button({ type: 'submit', class: period === key ? 'filter-btn active' : 'filter-btn' }, label)); return div({ class: 'tribe-content-list' }, + div({ class: 'tribe-content-header' }, h2(i18n.tribeSectionTrending)), div({ class: 'tribe-filter-bar' }, periodBtn('day', i18n.tribeTrendingPeriodDay), periodBtn('week', i18n.tribeTrendingPeriodWeek), periodBtn('all', i18n.tribeTrendingPeriodAll)), items.length === 0 ? p(i18n.tribeTrendingEmpty) : paged(items.map((item, idx) => [item, idx])).map(([item, idx]) => { if (item.encrypted) return div({ class: 'tribe-content-card' }, div({ class: 'tribe-content-meta' }, span(`#${idx + 1}`)), p({ class: 'tribe-meta-label' }, i18n.tribeContentEncrypted || 'Encrypted content')); - const itemHref = tribeItemHref(tribe, item); - const itemTitle = item.title || item.description; return div({ class: 'tribe-content-card' }, - renderCardTop(item.id, itemHref, { ...tribeItemOpts(tribe, item, isTribeMember(tribe), item.title || item.description), author: item.author, date: item.createdAt, chips: [div({ class: 'card-chips-row' }, tribeTypeChip(contentTypeName(item.contentType)), renderTribeReachListChip(tribe, item, null))] }), div({ class: 'tribe-content-meta' }, span(`#${idx + 1}`), + span({ class: 'tribe-badge' }, contentTypeName(item.contentType)), span(`${i18n.tribeTrendingEngagement}: ${engagementScore(item)}`), (() => { const entries = Object.entries(item.opinions || {}); if (!entries.length) return null; const top = entries.reduce((a, b) => b[1] > a[1] ? b : a); return span(`| ${i18n.tribeTopCategory}: ${i18n['opinionCat' + top[0].charAt(0).toUpperCase() + top[0].slice(1)] || top[0]}`); })() ), - itemTitle ? h2(a({ href: itemHref }, itemTitle)) : null, + renderTribeReachListChip(tribe, item, null), + item.title ? h2(item.title) : item.description ? h2(item.description) : null, div({ class: 'tribe-content-meta' }, item.refeeds ? span(`${i18n.tribeActivityRefeed}: ${item.refeeds}`) : null, Array.isArray(item.attendees) && item.attendees.length ? span(`${i18n.tribeEventAttendees}: ${item.attendees.length}`) : null ), - null + p({ class: 'tribe-meta-label' }, userLink(item.author)) ); }) ); }; @@ -589,6 +603,10 @@ const renderTribeTagsSection = (tribe, sectionData, query) => { const sortedTags = tagsList.slice().sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag)); const tagHref = tribeTagHref(tribe); return section( + div({ class: 'tags-header module-header-line' }, + h2(i18n.tribeSectionTags || i18n.tagsTitle || 'TAGS'), + p(i18n.tagsDescription || '') + ), div({ class: 'tags-list' }, tagsList.length === 0 ? p(i18n.tagsNoItems || i18n.tribeTagsEmpty || 'No tags yet') @@ -621,25 +639,29 @@ const renderTribeSearchSection = (tribe, sectionData, query) => { } const stats = renderModuleStats(results.length, [...typeCounts].map(([label, count]) => ({ label, count })).sort((x, y) => y.count - x.count || x.label.localeCompare(y.label))); return div({ class: 'tribe-content-list' }, + div({ class: 'tags-header module-header-line' }, h2(i18n.tribeSectionSearch), p(hashtag ? i18n.hashtagDescription : i18n.searchDescriptionLabel)), renderSearchForm({ action: tribeUrl, query: sq, queryName: 'q', hidden: [['section', 'search']] }), sq.length > 0 && sq.length < 2 ? p(i18n.tribeSearchMinChars) : null, sq.length >= 2 ? div( stats ? div({ class: 'filters activity-filter-chips activity-toolbar-row search-stats-row' }, stats) : null, results.length === 0 ? p(i18n.tribeSearchEmpty) : - paged(results).map(item => { - const itemHref = tribeItemHref(tribe, item); - return div({ class: 'card card-rpg' }, - renderCardTop(item.id, itemHref, { ...tribeItemOpts(tribe, item, isTribeMember(tribe), item.title || item.description), author: item.author, date: item.createdAt, chips: [div({ class: 'card-chips-row' }, tribeTypeChip(contentTypeName(item.contentType)), renderTribeReachListChip(tribe, item, threadsById))] }), - div({ class: 'card-body' }, - item.title ? div({ class: 'card-field' }, - span({ class: 'card-label' }, (i18n.title || 'Title') + ':'), - a({ href: itemHref, class: 'card-value' }, item.title) - ) : null, - tribeExcerpt(tribe, item.description) - ), - null - ); - }) + paged(results).map(item => div({ class: 'card card-rpg' }, + div({ class: 'card-header' }, + h2({ class: 'card-label' }, `[${String(contentTypeName(item.contentType)).toUpperCase()}]`) + ), + div({ class: 'card-body' }, + renderTribeReachListChip(tribe, item, threadsById), + item.title ? div({ class: 'card-field' }, + span({ class: 'card-label' }, (i18n.title || 'Title') + ':'), + span({ class: 'card-value' }, item.title) + ) : null, + tribeExcerpt(tribe, item.description) + ), + p({ class: 'card-footer' }, + span({ class: 'date-link' }, moment(item.createdAt).format("YYYY/MM/DD HH:mm")), + userLink(item.author) + ) + )) ) : null ); }; @@ -739,6 +761,9 @@ const openClosedModes = (isClosed) => ({ ...BASE_MODES, open: it => !isClosed(it const openClosedStats = (items, isClosed, openLabel, closedLabel) => renderModuleStatsBy(items, it => isClosed(it) ? 'CLOSED' : 'OPEN', [{ value: 'OPEN', label: openLabel }, { value: 'CLOSED', label: closedLabel }]); +const renderTribeSectionHeader = (title, description) => + div({ class: 'tags-header module-header-line' }, h2(title), description ? p(description) : null); + const renderTribeModeChips = (tribe, sectionKey, { modes = [], filter = 'all', labelOf = (m) => m, visible = null, emptyMod = false, create = null }) => div({ class: 'mode-buttons-row' }, ...(emptyMod ? [] : modes.filter(m => !visible || visible(m)).map(m => @@ -784,32 +809,20 @@ const renderTribeReachControls = (tribe, item, returnTo) => { return levelSwitch || openedBy ? div({ class: 'card-chips-row' }, levelSwitch, openedBy) : null; }; -const tribeItemOpts = (tribe, item, member, title) => ({ - author: item.author, - reportTitle: title || item.title || item.description, - deleteAction: member && isMineItem(item) && item.id ? `${tribeUrlOf(tribe)}/content/delete/${encodeURIComponent(item.id)}` : undefined -}); +const tribeItemActions = (tribe, item, member, title) => + renderContentActions(item.id, null, { + author: item.author, + reportTitle: title || item.title || item.description, + deleteAction: member && isMineItem(item) && item.id ? `${tribeUrlOf(tribe)}/content/delete/${encodeURIComponent(item.id)}` : undefined + }); -const tribeItemActions = (tribe, item, member, title) => renderContentActions(item.id, null, tribeItemOpts(tribe, item, member, title)); - -const tribeCardActions = (tribe, item, member, title) => renderContentActions(item.id, null, { ...tribeItemOpts(tribe, item, member, title), menu: true }); - -const tribeItemHref = (tribe, item) => item.directUrl - || (item.contentType === 'forum' ? sectionHref(tribe, 'forum', { thread: item.id }) - : item.contentType === 'forum-reply' ? sectionHref(tribe, 'forum', { thread: item.parentId }) - : sectionHref(tribe, activitySectionForItem(item))); - -const tribeTypeChip = (text) => span({ class: 'pm-exposition-chip pm-exposition-whole' }, span({ class: 'pm-exposition-text' }, String(text).toUpperCase())); - -const forumCategoryChip = (item) => item.category ? span({ class: 'forum-category' }, `[${forumCatLabel(item.category)}]`) : null; - -const tribeEngagement = (tribe, item, member, returnTo, withShare = true) => { - const share = withShare ? { href: tribeItemHref(tribe, item), title: item.title || item.description } : null; +const tribeEngagement = (tribe, item, member, returnTo) => { if (member) { return renderEngagement(item.id, renderOpinionsVoting(null, item.id, item.opinions, returnTo, item.opinions_inhabitants, - (id, category) => `${tribeUrlOf(tribe)}/content/${encodeURIComponent(id)}/opinion/${category}`), null, share); + (id, category) => `${tribeUrlOf(tribe)}/content/${encodeURIComponent(id)}/opinion/${category}`)); } - return renderEngagement(item.id, renderVotesSummary(item.opinions), null, share); + const summary = renderVotesSummary(item.opinions); + return summary ? renderEngagement(item.id, summary) : null; }; const toFeedShape = (m) => ({ @@ -834,10 +847,11 @@ const renderFeedTribeView = (feedItems, tribe, query = {}) => { const q = String(query.q || '').trim(); const isCreate = query.action === 'create' && member; const tribeUrl = tribeUrlOf(tribe); + const header = renderTribeSectionHeader(isCreate ? i18n.createFeedTitle : i18n.tribeSectionFeed, i18n.FeedshareYourOpinions); const emptyMod = !isCreate && moduleIsEmpty(feed, filter, 'ALL', q); const visible = tribeChipVisible(feed, filter, { RECENT: isRecentItem, MINE: isMineItem, ALL: () => true, TOP: () => true }); const chips = renderTribeModeChips(tribe, 'feed', { modes: FEED_MODES, filter: isCreate ? 'CREATE' : filter, labelOf: m => i18n[`${m}Button`], visible, emptyMod, create: member ? { label: i18n.createFeedTitle || 'Create Feed' } : null }); - if (isCreate) return div({ class: 'tribe-content-list' }, chips, renderFeedComposeForm(tribe)); + if (isCreate) return div({ class: 'tribe-content-list' }, header, chips, renderFeedComposeForm(tribe)); const items = applyTribeFilter(feed, filter.toLowerCase(), q, BASE_MODES, it => Number(it.refeeds || 0)); const returnTo = sectionHref(tribe, 'feed', { filter, q }); const banner = query.sent ? div({ class: 'feed-success-msg' }, p('✓ ' + (i18n.feedPublishedSuccess || i18n.tribeFeedSent))) : null; @@ -848,12 +862,13 @@ const renderFeedTribeView = (feedItems, tribe, query = {}) => { hashtagHref: tribeTagHref(tribe), refeedAction: `${tribeUrl}/refeed/${encodeURIComponent(m.id)}`, canRefeed: member, - headerActions: tribeCardActions(tribe, m, member, m.description), + headerActions: tribeItemActions(tribe, m, member, m.description), chips: chip || controls ? [chip ? div({ class: 'card-chips-row' }, chip) : null, controls] : null, engagement: tribeEngagement(tribe, m, member, returnTo) }); }).filter(Boolean); return div({ class: 'tribe-content-list' }, + header, banner, chips, emptyMod ? null : renderTribeSearchRow(tribe, 'feed', filter, q, renderModuleStats(items.length), i18n.feedSearchPlaceholder), @@ -877,7 +892,7 @@ const renderTribeEventCard = (tribe, e, member, returnTo) => { const attending = asList(e.attendees).includes(userId); return renderEventItem({ ...e, organizer: e.author, isPublic: reachLevelOf(e) === 'tribe' ? 'private' : 'public' }, 'all', null, { href: null, - headerActions: tribeCardActions(tribe, e, member, e.title), + headerActions: tribeItemActions(tribe, e, member, e.title), extraChips: [tribeReachChipFor(tribe, e)], bodyExtra: [ tribeDescription(e.description), @@ -901,11 +916,12 @@ const renderEventsSection = (tribe, items, query = {}) => { const q = String(query.q || '').trim(); const isCreate = query.action === 'create' && member; const tribeUrl = tribeUrlOf(tribe); + const header = renderTribeSectionHeader(i18n.tribeSectionEvents, i18n.eventsDescription); const emptyMod = !isCreate && moduleIsEmpty(events, filter, 'all', q); const chips = renderTribeModeChips(tribe, 'events', { modes: EVENT_MODE_LIST, filter: isCreate ? 'create' : filter, labelOf: m => i18n[`eventFilter${capFirst(m)}`], visible: tribeChipVisible(events, filter, EVENT_MODES), emptyMod, create: member ? { label: i18n.eventCreateButton } : null }); if (isCreate) { const today = new Date().toISOString().split('T')[0]; - return div({ class: 'tribe-content-list' }, chips, + return div({ class: 'tribe-content-list' }, header, chips, div({ class: 'event-form' }, form({ method: 'POST', action: `${tribeUrl}/events/create` }, label(i18n.eventTitleLabel), br(), @@ -925,6 +941,7 @@ const renderEventsSection = (tribe, items, query = {}) => { const list = applyTribeFilter(events, filter, q, EVENT_MODES); const returnTo = sectionHref(tribe, 'events', { filter, q }); return div({ class: 'tribe-content-list' }, + header, chips, emptyMod ? null : renderTribeSearchRow(tribe, 'events', filter, q, openClosedStats(list, e => tribeStatusOf(e) === 'CLOSED', i18n.eventStatusOpen, i18n.eventStatusClosed), i18n.eventSearchPlaceholder), list.length ? div({ class: 'jobs-grid' }, ...paged(list).map(e => renderTribeEventCard(tribe, e, member, returnTo))) : renderTribeEmpty(i18n.noevents || i18n.tribeEventsEmpty) @@ -954,7 +971,7 @@ const renderTribeTaskCard = (tribe, t, member, returnTo) => { const assignRow = member ? renderTaskAssignAction(t, assigned, returnTo, `${tribeUrl}/tasks/assign/${encodeURIComponent(t.id)}`) : null; return renderTaskItem({ ...t, isPublic: reachLevelOf(t) === 'tribe' ? 'PRIVATE' : 'PUBLIC' }, 'all', null, { href: null, - headerActions: tribeCardActions(tribe, t, member, t.title), + headerActions: tribeItemActions(tribe, t, member, t.title), extraChips: [tribeReachChipFor(tribe, t)], bodyExtra: [ tribeDescription(t.description), @@ -974,11 +991,12 @@ const renderTasksSection = (tribe, items, query = {}) => { const q = String(query.q || '').trim(); const isCreate = query.action === 'create' && member; const tribeUrl = tribeUrlOf(tribe); + const header = renderTribeSectionHeader(i18n.tribeSectionTasks, i18n.tasksDescription); const emptyMod = !isCreate && moduleIsEmpty(tasks, filter, 'all', q); const chips = renderTribeModeChips(tribe, 'tasks', { modes: TASK_MODE_LIST, filter: isCreate ? 'create' : filter, labelOf: taskModeLabel, visible: tribeChipVisible(tasks, filter, TASK_MODES), emptyMod, create: member ? { label: i18n.taskCreateButton } : null }); if (isCreate) { const today = new Date().toISOString().split('T')[0]; - return div({ class: 'tribe-content-list' }, chips, + return div({ class: 'tribe-content-list' }, header, chips, div({ class: 'task-form' }, form({ method: 'POST', action: `${tribeUrl}/tasks/create` }, label(i18n.taskTitleLabel), br(), @@ -1000,6 +1018,7 @@ const renderTasksSection = (tribe, items, query = {}) => { const list = applyTribeFilter(tasks, filter, q, TASK_MODES); const returnTo = sectionHref(tribe, 'tasks', { filter, q }); return div({ class: 'tribe-content-list' }, + header, chips, emptyMod ? null : renderTribeSearchRow(tribe, 'tasks', filter, q, renderModuleStatsBy(list, tribeStatusOf, [{ value: 'OPEN', label: i18n.taskStatusOpen }, { value: 'IN-PROGRESS', label: i18n.taskStatusInProgress }, { value: 'CLOSED', label: i18n.taskStatusClosed }]), i18n.taskSearchPlaceholder), list.length ? div({ class: 'jobs-grid' }, ...paged(list).map(t => renderTribeTaskCard(tribe, t, member, returnTo))) : renderTribeEmpty(i18n.notasks || i18n.tribeTasksEmpty) @@ -1024,7 +1043,7 @@ const renderTribeVotationCard = (tribe, v, member, returnTo) => { const hasVoted = Object.values(v.votes || {}).some(arr => asList(arr).includes(userId)); return renderVoteListItem(shaped, shaped.options, 'all', null, { href: null, - headerActions: tribeCardActions(tribe, v, member, v.title), + headerActions: tribeItemActions(tribe, v, member, v.title), extraChips: [tribeReachChipFor(tribe, v)], bodyExtra: [ tribeDescription(v.description), @@ -1049,11 +1068,12 @@ const renderVotationsSection = (tribe, items, query = {}) => { const q = String(query.q || '').trim(); const isCreate = query.action === 'create' && member; const tribeUrl = tribeUrlOf(tribe); + const header = renderTribeSectionHeader(i18n.tribeSectionVotations, i18n.votationsDescription); const emptyMod = !isCreate && moduleIsEmpty(votations, filter, 'all', q); const chips = renderTribeModeChips(tribe, 'votations', { modes: VOTE_MODE_LIST, filter: isCreate ? 'create' : filter, labelOf: m => i18n[`voteFilter${capFirst(m)}`], visible: tribeChipVisible(votations, filter, VOTE_MODES), emptyMod, create: member ? { label: i18n.voteCreateButton } : null }); if (isCreate) { const today = new Date().toISOString().split('T')[0]; - return div({ class: 'tribe-content-list' }, chips, + return div({ class: 'tribe-content-list' }, header, chips, div({ class: 'vote-form' }, form({ method: 'POST', action: `${tribeUrl}/votations/create` }, h2(i18n.voteQuestionLabel), @@ -1076,6 +1096,7 @@ const renderVotationsSection = (tribe, items, query = {}) => { const list = applyTribeFilter(votations, filter, q, VOTE_MODES); const returnTo = sectionHref(tribe, 'votations', { filter, q }); return div({ class: 'tribe-content-list' }, + header, chips, emptyMod ? null : renderTribeSearchRow(tribe, 'votations', filter, q, openClosedStats(list, v => tribeStatusOf(v) === 'CLOSED', i18n.voteStatusOpen, i18n.voteStatusClosed), i18n.votesSearchPlaceholder), list.length ? div({ class: 'jobs-grid' }, ...paged(list).map(v => renderTribeVotationCard(tribe, v, member, returnTo))) : renderTribeEmpty(i18n.novotes || i18n.tribeVotationsEmpty) @@ -1097,10 +1118,11 @@ const renderTribePollsSection = (tribe, items, query = {}) => { const q = String(query.q || '').trim(); const isCreate = query.action === 'create' && member; const tribeUrl = tribeUrlOf(tribe); + const header = renderTribeSectionHeader(i18n.pollsTitle, i18n.pollsDescription); const emptyMod = !isCreate && moduleIsEmpty(polls, filter, 'ALL', q); const chips = renderTribeModeChips(tribe, 'polls', { modes: POLL_MODE_LIST, filter: isCreate ? 'CREATE' : filter, labelOf: pollModeLabel, visible: tribeChipVisible(polls, filter, POLL_MODES), emptyMod, create: member ? { label: i18n.pollCreateButton } : null }); if (isCreate) { - return div({ class: 'tribe-content-list' }, chips, + return div({ class: 'tribe-content-list' }, header, chips, div({ class: 'div-center audio-form' }, h2(i18n.pollCreateTitle), form({ method: 'POST', action: `${tribeUrl}/polls/create` }, @@ -1126,6 +1148,7 @@ const renderTribePollsSection = (tribe, items, query = {}) => { const kept = polls.filter(pl => pred(pl) && matchesTribeQuery(pl, q)); const list = filter === 'TOP' ? [...kept].sort((a, b) => (b.totalVoters || 0) - (a.totalVoters || 0) || tsOf(b) - tsOf(a)) : sortRecentFirst(kept); return div({ class: 'tribe-content-list' }, + header, chips, emptyMod ? null : renderTribeSearchRow(tribe, 'polls', filter, q, openClosedStats(list, pl => tribeStatusOf(pl) === 'CLOSED', i18n.pollStatusOpen, i18n.pollStatusClosed), i18n.pollSearchPlaceholder), list.length ? div({ class: 'jobs-grid' }, ...paged(list).map(pl => renderPollCard(pl, filter, null))) : renderTribeEmpty(i18n.pollsNoItems) @@ -1176,7 +1199,7 @@ const renderForumThreadDetail = (tribe, allItems, threadId, member) => { span({ class: 'date-link' }, `${moment(thread.createdAt).format("YYYY/MM/DD HH:mm")}`), userLink(thread.author) ), - tribeEngagement(tribe, thread, member, returnTo, false) + tribeEngagement(tribe, thread, member, returnTo) ) ), member @@ -1216,6 +1239,7 @@ const renderForumSection = (tribe, items, query = {}) => { const q = String(query.q || '').trim(); const isCreate = query.action === 'create' && member; const tribeUrl = tribeUrlOf(tribe); + const header = renderTribeSectionHeader(i18n.tribeSectionForum, i18n.forumDescription); const replyCount = new Map(); allItems.filter(i => i.contentType === 'forum-reply').forEach(r => replyCount.set(r.parentId, (replyCount.get(r.parentId) || 0) + 1)); const forumScore = (t) => (t.refeeds || 0) + (replyCount.get(t.id) || 0); @@ -1227,7 +1251,7 @@ const renderForumSection = (tribe, items, query = {}) => { const chips = renderTribeModeChips(tribe, 'forum', { modes: ['all', 'recent', 'mine', 'top', ...FORUM_CATEGORIES], filter: isCreate ? 'create' : filter, labelOf, visible, emptyMod, create: member ? { label: i18n.forumCreateButton } : null }); if (isCreate) { - return div({ class: 'tribe-content-list' }, chips, + return div({ class: 'tribe-content-list' }, header, chips, div({ class: 'forum-form' }, form({ method: 'POST', action: `${tribeUrl}/forum/create` }, label(i18n.forumCategoryLabel), br(), @@ -1244,7 +1268,7 @@ const renderForumSection = (tribe, items, query = {}) => { } if (query.thread) { - return div({ class: 'tribe-content-list' }, chips, + return div({ class: 'tribe-content-list' }, header, chips, form({ method: 'GET', action: tribeUrl, class: 'forum-back-form' }, input({ type: 'hidden', name: 'section', value: 'forum' }), button({ type: 'submit', class: 'tribe-action-btn' }, i18n.walletBack) @@ -1255,20 +1279,23 @@ const renderForumSection = (tribe, items, query = {}) => { const list = applyTribeFilter(threads, filter, q, modes, forumScore); return div({ class: 'tribe-content-list' }, + header, chips, emptyMod ? null : renderTribeSearchRow(tribe, 'forum', filter, q, renderModuleStats(list.length), i18n.forumSearchPlaceholder), list.length ? ul({ class: 'mailing-archive' }, ...paged(list).map(t => { const participants = new Set([t.author, ...allItems.filter(i => i.contentType === 'forum-reply' && i.parentId === t.id).map(r => r.author)].filter(Boolean)); - const threadHref = sectionHref(tribe, 'forum', { thread: t.id }); return li({ class: 'mailing-archive-item' }, - renderCardTop(t.id, null, { ...tribeItemOpts(tribe, t, member, t.title), date: t.createdAt, chips: [forumCategoryChip(t), tribeReachChipFor(tribe, t)] }), - div({ class: 'mailing-archive-meta' }, - a({ href: threadHref, class: 'user-link' }, t.title || '—'), - span({ class: 'card-label activity-update-counts mailing-counts' }, `👥: ${participants.size} · 💬 ${replyCount.get(t.id) || 0} · ▲ ${t.refeeds || 0}`) - ), - null + div({ class: 'emergency-update-head mailing-archive-head' }, + div({ class: 'mailing-archive-meta' }, + a({ href: sectionHref(tribe, 'forum', { thread: t.id }), class: 'user-link' }, t.title || '—'), + t.category ? span({ class: 'forum-category' }, `[${forumCatLabel(t.category)}]`) : null, + span({ class: 'card-label activity-update-counts mailing-counts' }, `👥: ${participants.size} · 💬 ${replyCount.get(t.id) || 0} · ▲ ${t.refeeds || 0}`), + tribeReachChipFor(tribe, t) + ), + tribeItemActions(tribe, t, member, t.title) + ) ); }) ) @@ -1293,6 +1320,7 @@ const renderTribeMediaTypeSection = (tribe, items, query = {}, mediaType) => { const q = String(query.q || '').trim(); const isCreate = query.action === 'create' && member; const tribeUrl = tribeUrlOf(tribe); + const header = renderTribeSectionHeader(i18n[cfg.title], i18n[cfg.description]); const emptyMod = !isCreate && moduleIsEmpty(media, filter, 'all', q); const chips = renderTribeModeChips(tribe, cfg.key, { modes: ['all', 'recent', 'mine', 'top'], filter: isCreate ? 'create' : filter, labelOf: m => i18n[`${cfg.filter}${capFirst(m)}`], visible: tribeChipVisible(media, filter, BASE_MODES), emptyMod, create: member ? { label: i18n[cfg.create] } : null }); @@ -1325,7 +1353,7 @@ const renderTribeMediaTypeSection = (tribe, items, query = {}, mediaType) => { tribe.reachAllowed ? renderTribeReachSelector() : null, br(), button({ type: 'submit' }, i18n[cfg.create]) ); - return div({ class: 'tribe-content-list' }, chips, div({ class: `div-center ${cfg.formClass}` }, formNode)); + return div({ class: 'tribe-content-list' }, header, chips, div({ class: `div-center ${cfg.formClass}` }, formNode)); } const list = applyTribeFilter(media, filter, q, BASE_MODES).map(toMediaShape); @@ -1333,12 +1361,13 @@ const renderTribeMediaTypeSection = (tribe, items, query = {}, mediaType) => { const params = { q, hideLastVisit: true, - headerActions: (m) => tribeCardActions(tribe, m, member, m.title), + headerActions: (m) => tribeItemActions(tribe, m, member, m.title), titleChips: (m) => [tribeReachChipFor(tribe, m)], bodyExtra: (m) => [tribeDescription(m.description && m.description !== m.url ? m.description : null), renderTribeReachControls(tribe, m, returnTo)], engagement: (m) => tribeEngagement(tribe, m, member, returnTo) }; return div({ class: 'tribe-content-list' }, + header, chips, emptyMod ? null : renderTribeSearchRow(tribe, cfg.key, filter, q, renderModuleStats(list.length), i18n[cfg.search]), list.length ? div({ class: cfg.wrapper }, cfg.list(paged(list), filter, params)) : renderTribeEmpty(i18n[cfg.empty] || i18n.tribeMediaEmpty) @@ -1352,6 +1381,7 @@ const renderStandaloneSection = (tribe, sectionKey, items, query, cfg) => { const q = String(query.q || '').trim(); const modes = cfg.modes || BASE_MODES; const modeList = cfg.modeList || ['all', 'recent', 'mine']; + const header = renderTribeSectionHeader(cfg.title, cfg.description); const emptyMod = moduleIsEmpty(list, filter, 'all', q); const chips = renderTribeModeChips(tribe, sectionKey, { modes: modeList, filter, labelOf: cfg.labelOf, visible: tribeChipVisible(list, filter, modes), emptyMod, @@ -1359,6 +1389,7 @@ const renderStandaloneSection = (tribe, sectionKey, items, query, cfg) => { }); const shown = applyTribeFilter(list, filter, q, modes, cfg.score || (() => 0)); return div({ class: 'tribe-content-list' }, + header, chips, emptyMod ? null : renderTribeSearchRow(tribe, sectionKey, filter, q, cfg.stats ? cfg.stats(shown) : renderModuleStats(shown.length), cfg.placeholder), shown.length ? cfg.render(paged(shown), member) : renderTribeEmpty(cfg.empty) @@ -1449,6 +1480,7 @@ const renderTribeWikiSectionPage = (tribe, pages, query = {}) => { const emptyMod = moduleIsEmpty(list, filter, 'all', q); const shown = applyTribeFilter(list, filter, q, BASE_MODES, () => 0); const toolbar = div({ class: 'tribe-content-list' }, + renderTribeSectionHeader(i18n.tribeSectionWiki || 'WIKI', i18n.wikiDescription), div({ class: 'mode-buttons-row' }, ...(emptyMod ? [] : [ ...['all', 'recent', 'mine'].filter(tribeChipVisible(list, filter, BASE_MODES)).map(m => @@ -1489,6 +1521,7 @@ const renderTribeRoomsSectionPage = (tribe, sectionData, query = {}) => { const emptyMod = moduleIsEmpty(rooms, filter, 'all', q); const shown = applyTribeFilter(rooms, filter, q, modes, () => 0); const toolbar = div({ class: 'tribe-content-list' }, + renderTribeSectionHeader(i18n.tribeSectionRooms || 'ROOMS', i18n.roomsDescription), renderTribeModeChips(tribe, 'rooms', { modes: ['all', 'recent', 'mine', 'live', 'open', 'closed'], filter, labelOf: m => i18n[`roomFilter${capFirst(m)}`], visible: tribeChipVisible(rooms, filter, modes), emptyMod, create: member ? { action: '/rooms', hidden: { filter: 'create', tribeId: tribe.id }, label: i18n.tribeRoomCreate } : null @@ -1505,6 +1538,7 @@ const renderInhabitantsSection = (tribe, members, query = {}) => { ? resolved.filter(u => [u.name, u.id, u.description].some(v => String(v || '').toLowerCase().includes(search))) : resolved; return div({ class: 'tribe-content-list' }, + renderTribeSectionHeader(i18n.tribeSectionInhabitants, i18n.discoverPeople), resolved.length ? renderTribeSearchRow(tribe, 'inhabitants', null, search, renderModuleStats(shown.length), i18n.searchInhabitantsPlaceholder, 'search') : null, shown.length ? div({ class: 'inhabitants-list inhabitants-grid' }, ...paged(shown).map(u => renderInhabitantCard(u, 'all', userId, false))) @@ -1643,20 +1677,12 @@ exports.tribeView = async (tribe, userIdParam, query, section, sectionData) => { const hasOpenInvite = !!tribe.openInviteCode; const canManageOpenInvite = hasOpenInvite && (tribe.openInviteBy === userId || isAuthorOfTribe); const hasSideActions = canGenerateInvite || showOwnerControls || canLeave || (isOutsider && !showInviteTop); - const tribeHref = `/tribe/${encodeURIComponent(tribe.id)}`; const tribeDetails = div({ class: 'tribe-details' }, div({ class: 'tribe-side' }, - renderCardTop(tribe.id, tribeHref, { arrow: false, spread: tribe.isAnonymous ? undefined : (tribe.spreads || null), - date: tribe.createdAt, - chips: [ - tribe.isAnonymous ? renderPrivacyChip(true, i18n) : renderPrivacyChip(false, i18n), - tribe.isAnonymous ? renderTribeEncryptedChip(i18n) : null, - renderModeChip(tribe.inviteMode, i18n), - renderLifespanChip(tribe.lifetime, i18n) - ], - author: tribe.author, reportTitle: tribe.title, deleteAction: showOwnerControls ? contentDeleteAction('tribe', tribe.id) : undefined - }), + div({ class: 'card-header activity-card-header' }, + renderContentActions(tribe.id, `/tribe/${encodeURIComponent(tribe.id)}`, { author: tribe.author, reportTitle: tribe.title, deleteAction: showOwnerControls ? contentDeleteAction('tribe', tribe.id) : undefined }) + ), tribe.parentTribe ? div({ class: 'tribe-parent-box' }, h2({ class: 'tribe-info-label' }, i18n.tribeMainTribeLabel || 'MAIN TRIBE'), @@ -1665,17 +1691,29 @@ exports.tribeView = async (tribe, userIdParam, query, section, sectionData) => { ) ) : null, - renderDetailShare(tribe.id, tribeHref, tribe.title), div({ class: 'shop-title-row' }, h2({ class: 'tribe-card-title' }, tribe.title) ), + div({ class: 'card-chips-row' }, + tribe.isAnonymous ? renderPrivacyChip(true, i18n) : renderPrivacyChip(false, i18n), + tribe.isAnonymous ? renderTribeEncryptedChip(i18n) : null, + renderModeChip(tribe.inviteMode, i18n), + renderLifespanChip(tribe.lifetime, i18n) + ), renderMediaBlob(imageSrc, '/assets/images/default-tribe.png', { alt: tribe.title, class: 'tribe-detail-image' }), - tribe.location ? table({ class: 'tribe-info-table' }, + table({ class: 'tribe-info-table' }, tr( + td({ class: 'tribe-info-label' }, i18n.tribeCreatedAt || 'CREATED'), + td({ class: 'tribe-info-value', colspan: '3' }, moment(tribe.createdAt).format("YYYY/MM/DD HH:mm")) + ), + tr( + td({ class: 'tribe-info-value tribe-author-cell', colspan: '4' }, userLink(tribe.author)) + ), + tribe.location ? tr( td({ class: 'tribe-info-label' }, i18n.tribeLocationLabel || 'LOCATION'), td({ class: 'tribe-info-value', colspan: '3' }, ...renderStyledText(tribe.location)) - ) - ) : null, + ) : null + ), h2({ class: 'tribe-members-count' }, `${i18n.tribeMembersCount}: ${tribe.memberCount != null ? tribe.memberCount : tribe.members.length}`), (isOutsider && hasOpenInvite) ? div({ class: 'tribe-side-actions tribe-side-center' }, @@ -1780,7 +1818,6 @@ exports.tribeView = async (tribe, userIdParam, query, section, sectionData) => { )) : null ) : null, - null ), div({ class: 'tribe-main' }, query.inviteCode ? div({ class: 'card card-rpg tribe-banner' }, @@ -1795,7 +1832,7 @@ exports.tribeView = async (tribe, userIdParam, query, section, sectionData) => { pageTitle, tribeDetails ); - return page; + return section === 'documents' ? `${page}` : page; }; exports.clearnetTribeView = async ({ tribe, items, names = {}, slug, page = 1, filterType = '', filterAuthor = '' }) => { @@ -1841,26 +1878,22 @@ exports.clearnetTribeView = async ({ tribe, items, names = {}, slug, page = 1, f : mt === 'image' ? `${title}` : mt === 'audio' ? `` : mt === 'video' ? `` - : mt === 'document' ? renderClearnetPdfFrame(blob, it.title) - : ['file', 'torrent'].includes(mt) ? `⇩ ${title || escapeHtml(kind)}` + : ['document', 'file', 'torrent'].includes(mt) ? `⇩ ${title || escapeHtml(kind)}` : ''; const url = mt === 'bookmark' ? String(it.url || '') : ''; const href = url ? safeExternalHref(url) : ''; const bookmark = !url ? '' : href ? `${escapeHtml(url)}` : `${escapeHtml(url)}`; - const chip = (d) => `${escapeHtml(String(d))}`; - const chips = [ - it.status ? (statusI18n()[it.status] || it.status) : '', + const details = [ it.date ? `${i18n.tribeEventDate || 'Date'}: ${it.date}` : '', it.deadline ? `${i18n.tribeTaskDeadline || 'Deadline'}: ${it.deadline}` : '', - it.location ? `${i18n.tribeEventLocation || 'Location'}: ${it.location}` : '' - ].filter(Boolean).map(chip).join(''); - const details = [ + it.location ? `${i18n.tribeEventLocation || 'Location'}: ${it.location}` : '', + it.status ? (statusI18n()[it.status] || it.status) : '', it.contentType === 'map' && Number.isFinite(Number(it.lat)) ? `📍 ${Number(it.lat).toFixed(4)}, ${Number(it.lng).toFixed(4)}` : '', ...(it.contentType === 'map' && Array.isArray(it.markers) ? it.markers.map(mk => `📍 ${Number(mk.lat).toFixed(4)}, ${Number(mk.lng).toFixed(4)}${mk.label ? ` · ${mk.label}` : ''}`) : []), ...(it.contentType === 'calendar' && Array.isArray(it.dates) ? it.dates.map(d => `📅 ${day(d.date)}${d.label ? ` · ${d.label}` : ''}`) : []) - ].filter(Boolean).map(chip).join(''); + ].filter(Boolean).map(d => `${escapeHtml(String(d))}`).join(''); const options = it.contentType === 'votation' && Array.isArray(it.options) && it.options.length ? `
    ${it.options.map(o => `
  • ${escapeHtml(String(o))}
  • `).join('')}
` : ''; @@ -1869,15 +1902,12 @@ exports.clearnetTribeView = async ({ tribe, items, names = {}, slug, page = 1, f ? `
${escapeHtml(String(i18n.forumMessages || i18n.tribeForumReplies || 'MESSAGES').toUpperCase())}: ${replies.length}
${replies.map(r => `
${escapeHtml(who(r.author))}${day(r.createdAt) ? ` · ${escapeHtml(day(r.createdAt))}` : ''}

${renderRichText(r.description || '')}

`).join('')}
` : ''; const text = it.description && it.description !== url ? renderRichText(it.description) : ''; - const pdfFrames = (Array.isArray(it.pdfs) ? it.pdfs : []).map(p => renderClearnetPdfFrame(cnBlobUrl(p.blob), p.name)).join(''); const when = day(it.createdAt); return `
[${escapeHtml(String(kind).toUpperCase())}]${when ? `📅 ${escapeHtml(when)}` : ''}${it.author ? `${escapeHtml(who(it.author))}` : ''}
- ${chips ? `
${chips}
` : ''} ${title ? `

${it.href ? `${title}` : title}

` : ''} ${media} ${text ? `

${text}

` : ''} - ${pdfFrames} ${bookmark} ${details ? `
${details}
` : ''} ${options} @@ -1921,7 +1951,7 @@ exports.clearnetTribeView = async ({ tribe, items, names = {}, slug, page = 1, f ogTitle: name, ogDescription: t.description || '', ogImage: cover, - extraCss: extraCss + CLEARNET_PDF_CSS, + extraCss, body }); }; @@ -2111,6 +2141,10 @@ const rulesBlock = (tribe, rules, isCreator) => div({}, const renderGovernance = (tribe, data) => { const { filter, term, candidatures, rules, leaders, isCreator, canPublishToGlobal, alreadyPublishedThisGlobalCycle, hasElectedCandidate } = data || {}; const f = filter || 'government'; + const header = div({ class: 'tags-header module-header-line' }, + h2(i18n.tribeSectionGovernance || 'GOVERNANCE'), + p(i18n.tribeGovernanceDesc || 'Internal governance for this tribe. Propose candidatures, debate rules, elect leaders — mirrors the global Parliament.') + ); let body; if (f === 'government') body = governmentCard(tribe, term, leaders); else if (f === 'candidatures') body = candidaturesBlock(tribe, candidatures, alreadyPublishedThisGlobalCycle); @@ -2135,7 +2169,7 @@ const renderGovernance = (tribe, data) => { } const showPublish = !!(canPublishToGlobal && hasElectedCandidate && !alreadyPublishedThisGlobalCycle); - return div({ class: 'tribe-governance' }, governanceFilterBar(tribe.id, f, showPublish), body); + return div({ class: 'tribe-governance' }, header, governanceFilterBar(tribe.id, f, showPublish), body); }; exports.renderGovernance = renderGovernance; diff --git a/src/views/video_view.js b/src/views/video_view.js index 5da1eeb..8c64b43 100644 --- a/src/views/video_view.js +++ b/src/views/video_view.js @@ -11,6 +11,7 @@ const { video: videoHyperaxe, span, textarea, + label, select, option } = require("../server/node_modules/hyperaxe"); @@ -18,8 +19,7 @@ const { renderCommentsSection: renderSharedCommentsSection, renderCommentsLink } const moment = require("../server/node_modules/moment"); const { renderLicenseChip, renderLicenseSelect, renderReachChip, renderClearnetSelector, renderClearnetSwitch, renderTribeOriginChip } = require('./clearnet_view'); -const { clearnetItemHref, template, i18n, renderOpinionsVoting, renderEngagement, renderEcoTax, renderLifespanChip, renderCardTop, renderCardByline, renderSpreadEditWarning, renderModuleStats, moduleIsEmpty, contentDeleteAction, paged, blobUrlOf, renderMediaLockNote, renderEditedChip, renderDetailShare } = require("./main_views"); -const { renderDownloadButton } = require("./gallery_view"); +const { clearnetItemHref, template, i18n, renderOpinionsVoting, renderEngagement, userLink, renderSpreadButton, renderEcoTax, renderLifespanChip, renderContentActions, renderSpreadEditWarning, renderModuleStats, moduleIsEmpty, contentDeleteAction, paged, blobUrlOf } = require("./main_views"); const { config } = require("../server/SSB_server.js"); const { renderStyledText } = require("../backend/renderStyledText") const { renderMapLocationVisitLabel, renderMapEmbed } = require("./maps_view"); @@ -64,17 +64,20 @@ const renderVideoPlayer = (videoObj) => const renderVideoOwnerActions = (filter, videoObj, params = {}) => { const returnTo = buildReturnTo(filter, params); const isAuthor = String(videoObj.author) === String(userId); + const hasOpinions = Object.keys(videoObj.opinions || {}).length > 0; if (!isAuthor) return []; const items = []; - items.push( - form( - { method: "GET", action: `/videos/edit/${encodeURIComponent(videoObj.key)}` }, - input({ type: "hidden", name: "returnTo", value: returnTo }), - button({ class: "update-btn", type: "submit" }, i18n.videoUpdateButton) - ) - ); + if (!hasOpinions) { + items.push( + form( + { method: "GET", action: `/videos/edit/${encodeURIComponent(videoObj.key)}` }, + input({ type: "hidden", name: "returnTo", value: returnTo }), + button({ class: "update-btn", type: "submit" }, i18n.videoUpdateButton) + ) + ); + } return items; }; @@ -87,15 +90,6 @@ const renderVideoCommentsSection = (videoId, comments = [], returnTo = null) => }); }; -const renderVideoUpdated = (videoObj) => { - const createdTs = videoObj.createdAt ? new Date(videoObj.createdAt).getTime() : NaN; - const updatedTs = videoObj.updatedAt ? new Date(videoObj.updatedAt).getTime() : NaN; - const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs); - return showUpdated - ? span({ class: "votations-comment-date" }, `${i18n.videoUpdatedAt}: ${moment(videoObj.updatedAt).format("YYYY/MM/DD HH:mm")}`) - : null; -}; - const renderVideoList = exports.renderVideoList = (videos, filter, params = {}) => { const returnTo = buildReturnTo(filter, params); @@ -103,37 +97,53 @@ 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 href = `/videos/${encodeURIComponent(videoObj.key)}`; const isOwn = videoObj.author && String(videoObj.author) === String(userId); - const byline = { - author: videoObj.author, - date: videoObj.createdAt, - chips: [ - videoObj.tribeOrigin ? renderTribeOriginChip(videoObj.tribeOrigin) : videoObj.clearnet === true ? renderReachChip(true, i18n, clearnetItemHref('videos', videoObj.title, videoObj.key)) : null, - renderLicenseChip(videoObj.license), - ...(typeof params.titleChips === "function" ? params.titleChips(videoObj) : []), - renderLifespanChip(videoObj.lifetime, i18n) - ], - bylineExtra: [renderVideoUpdated(videoObj)] - }; - const header = typeof params.headerActions === "function" - ? div({ class: "card-header activity-card-header card-top" }, renderCardByline(byline), params.headerActions(videoObj)) + const headerActions = typeof params.headerActions === "function" + ? params.headerActions(videoObj) : videoObj.tribeOrigin - ? renderCardTop(videoObj.key, videoObj.tribeOrigin.href, { ...byline, spread: (params.spreadMap && params.spreadMap.get(videoObj.key)) || null, reportTitle: videoObj.title }) - : renderCardTop(videoObj.key, href, { ...byline, spread: (params.spreadMap && params.spreadMap.get(videoObj.key)) || params.spreads || null, favKind: 'videos', torrentFrom: { blobId: videoObj.url, name: videoObj.title }, isFavorite: videoObj.isFavorite, reportTitle: videoObj.title, returnTo, deleteAction: isOwn ? contentDeleteAction('video', videoObj.key) : undefined }); - const titleHref = typeof params.headerActions === "function" ? null : videoObj.tribeOrigin ? videoObj.tribeOrigin.href : href; - const engagement = typeof params.engagement === "function" ? params.engagement(videoObj) : null; + ? renderContentActions(null, videoObj.tribeOrigin.href) + : renderContentActions(videoObj.key, `/videos/${encodeURIComponent(videoObj.key)}`, { spread: (params.spreadMap && params.spreadMap.get(videoObj.key)) || params.spreads || null, author: videoObj.author, favKind: 'videos', torrentFrom: { blobId: videoObj.url, name: videoObj.title }, isFavorite: videoObj.isFavorite, reportTitle: videoObj.title, returnTo, deleteAction: isOwn ? contentDeleteAction('video', videoObj.key) : undefined }); + const titleHref = typeof params.headerActions === "function" ? null : videoObj.tribeOrigin ? videoObj.tribeOrigin.href : `/videos/${encodeURIComponent(videoObj.key)}`; + const engagement = typeof params.engagement === "function" + ? params.engagement(videoObj) + : videoObj.tribeOrigin ? null : renderEngagement(videoObj.key, + renderOpinionsVoting('/videos/opinions', videoObj.key, videoObj.opinions, returnTo, videoObj.opinions_inhabitants), + renderCommentsLink({ href: `/videos/${encodeURIComponent(videoObj.key)}`, count: commentCount }) + ); return div( { class: "trending-card video-card" + (isOwn ? " own-content" : "") }, - header, + div( + { class: "card-header activity-card-header" }, + span(), + headerActions + ), div( { class: "card-section video-card-body" }, - title ? div({ class: "shop-title-row" }, h2(titleHref ? a({ href: titleHref, class: "card-title-link" }, title) : title)) : null, + div({ class: "shop-title-row" }, title ? h2(titleHref ? a({ href: titleHref, class: "card-title-link" }, title) : title) : null, videoObj.tribeOrigin ? renderTribeOriginChip(videoObj.tribeOrigin) : videoObj.clearnet === true ? renderReachChip(true, i18n, clearnetItemHref('videos', videoObj.title, videoObj.key)) : null, renderLicenseChip(videoObj.license), ...(typeof params.titleChips === "function" ? params.titleChips(videoObj) : [])), + videoObj.lifetime ? div({ class: "card-chips-row" }, renderLifespanChip(videoObj.lifetime, i18n)) : null, renderVideoPlayer(videoObj), ...(typeof params.bodyExtra === "function" ? params.bodyExtra(videoObj) : []), + engagement, renderMapLocationVisitLabel(videoObj.mapUrl), - engagement + br(), + (() => { + const createdTs = videoObj.createdAt ? new Date(videoObj.createdAt).getTime() : NaN; + const updatedTs = videoObj.updatedAt ? new Date(videoObj.updatedAt).getTime() : NaN; + const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs); + + return p( + { class: "card-footer" }, + span({ class: "date-link" }, `${moment(videoObj.createdAt).format("YYYY/MM/DD HH:mm")}`), + userLink(videoObj.author), + showUpdated + ? span( + { class: "votations-comment-date" }, + ` | ${i18n.videoUpdatedAt}: ${moment(videoObj.updatedAt).format("YYYY/MM/DD HH:mm")}` + ) + : null + ); + })() ) ); }) @@ -155,7 +165,7 @@ const renderVideoForm = (filter, videoId, videoToEdit, params = {}) => { input({ type: "hidden", name: "returnTo", value: returnTo }), span(i18n.videoFileLabel), br(), - (filter === "edit" && renderMediaLockNote(videoToEdit)) || input({ type: "file", name: "video", accept: "video/*", required: filter !== "edit" }), + input({ type: "file", name: "video", required: filter !== "edit" }), br(), br(), span(i18n.videoTitleLabel), @@ -293,30 +303,27 @@ exports.singleVideoView = async (videoObj, filter = "all", comments = [], params for (const a of ownerActions) sideActions.push(a); const tagsNode = renderTags(videoObj.tags); - const href = `/videos/${encodeURIComponent(videoObj.key)}`; - const videoSide = div({ class: "tribe-side" }, - renderCardTop(videoObj.key, href, { arrow: false, + const detailActions = div({ class: "card-header activity-card-header" }, + renderContentActions(videoObj.key, `/videos/${encodeURIComponent(videoObj.key)}`, { author: videoObj.author, - date: videoObj.createdAt, - chips: [ - renderReachChip(isClearnet, i18n, isClearnet ? clearnetItemHref('videos', videoObj.title, videoObj.key) : null), - renderLicenseChip(videoObj.license), - ...chips - ], - bylineExtra: [renderEditedChip(videoObj.key, videoObj.rootId), renderVideoUpdated(videoObj)], favKind: 'videos', torrentFrom: { blobId: videoObj.url, name: videoObj.title }, isFavorite: videoObj.isFavorite, spread: params.spreads || null, returnTo, reportTitle: videoObj.title, deleteAction: String(videoObj.author) === String(userId) ? contentDeleteAction('video', videoObj.key) : undefined - }), + }) + ); + + const videoSide = div({ class: "tribe-side" }, div({ class: "shop-title-row" }, title ? h2({ class: "tribe-card-title" }, title) : null, - isAuthor ? renderClearnetSwitch('videos', videoObj.rootId || videoObj.key, isClearnet) : null + renderReachChip(isClearnet, i18n, isClearnet ? clearnetItemHref('videos', videoObj.title, videoObj.key) : null), + isAuthor ? renderClearnetSwitch('videos', videoObj.rootId || videoObj.key, isClearnet) : null, + renderLicenseChip(videoObj.license) ), - renderDetailShare(videoObj.key, href, videoObj.title), + chips.length ? div({ class: "card-chips-row" }, ...chips) : null, safeText(videoObj.description) ? p({ class: "tribe-side-description" }, ...renderStyledText(videoObj.description)) : null, @@ -326,11 +333,29 @@ exports.singleVideoView = async (videoObj, filter = "all", comments = [], params ); const videoMain = div({ class: "tribe-main" }, + detailActions, renderVideoPlayer(videoObj), - videoObj?.url ? renderDownloadButton(blobUrlOf(videoObj.url), videoObj.title || "") : null, + (() => { + const createdTs = videoObj.createdAt ? new Date(videoObj.createdAt).getTime() : NaN; + const updatedTs = videoObj.updatedAt ? new Date(videoObj.updatedAt).getTime() : NaN; + const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs); + + return p( + { class: "card-footer" }, + span({ class: "date-link" }, `${moment(videoObj.createdAt).format("YYYY/MM/DD HH:mm")}`), + userLink(videoObj.author), + showUpdated + ? span( + { class: "votations-comment-date" }, + ` | ${i18n.videoUpdatedAt}: ${moment(videoObj.updatedAt).format("YYYY/MM/DD HH:mm")}` + ) + : null + ); + })(), renderEngagement(videoObj.key, renderOpinionsVoting('/videos/opinions', videoObj.key, videoObj.opinions, returnTo, videoObj.opinions_inhabitants), - renderVideoCommentsSection(videoObj.key, comments, returnTo)) + renderVideoCommentsSection(videoObj.key, comments, returnTo) + ) ); return template( @@ -345,10 +370,9 @@ exports.singleVideoView = async (videoObj, filter = "all", comments = [], params form( { method: "GET", action: "/videos", class: "ui-toolbar ui-toolbar--filters" }, input({ type: "hidden", name: "sort", value: sort }), - 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: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, String(i18n.videoFilterAll).toUpperCase()), ...(mediaChip("favorites") ? [button( ...(mediaChip("recent") ? [button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, String(i18n.videoFilterRecent).toUpperCase())] : []), ...(mediaChip("mine") ? [button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, String(i18n.videoFilterMine).toUpperCase())] : []), - ...(mediaChip("favorites") ? [button( { type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" }, String(i18n.videoFilterFavorites).toUpperCase() )] : []), diff --git a/src/views/vote_view.js b/src/views/vote_view.js index f9cb791..72df6f1 100644 --- a/src/views/vote_view.js +++ b/src/views/vote_view.js @@ -1,10 +1,9 @@ -const { div, h2, p, section, button, form, a, br, input, table, tr, th, td, label, span } = require("../server/node_modules/hyperaxe"); +const { div, h2, p, section, button, form, a, textarea, br, input, table, tr, th, td, label, span } = require("../server/node_modules/hyperaxe"); const { renderCommentsSection: renderSharedCommentsSection } = require("./comments_view"); -const { template, i18n, renderOpinionsVoting, renderEngagement, renderOpenClosedChip, renderLifespanChip, renderEcoTax, renderCardTop, renderCardByline, renderSpreadEditWarning, renderDocumentActions, renderModuleStatsBy, moduleIsEmpty, contentDeleteAction, paged, renderEditedChip, renderMediaLockNote, renderDetailShare } = require("./main_views"); +const { template, i18n, renderOpinionsVoting, renderEngagement, renderOpenClosedChip, renderLifespanChip, renderEcoTax, renderSpreadButton, renderContentActions, renderSpreadEditWarning, renderDocumentActions, renderModuleStatsBy, moduleIsEmpty, contentDeleteAction, paged, renderCreatedRows } = require("./main_views"); const moment = require("../server/node_modules/moment"); const { config } = require("../server/SSB_server.js"); const { renderTribeOriginChip } = require("./clearnet_view"); -const mobileUi = require("./mobile_ui"); const userId = config.keys.id; const VOTE_QUORUM = 2; @@ -22,7 +21,7 @@ const computeVoteOutcome = (baseCounts, voteOptions, totalVotesNum) => { const topOpts = opts.filter((o) => (baseCounts[o] || 0) === maxCount); if (maxCount === 0 || topOpts.length > 1) return noResult; const winner = topOpts[0]; - const variant = (winner === "YES" || winner === "GREEN") ? "yes" : (winner === "NO" || winner === "RED") ? "no" : "pending"; + const variant = winner === "YES" ? "yes" : winner === "NO" ? "no" : "pending"; return { text: voteLabel(winner), variant }; }; @@ -39,9 +38,9 @@ const statusLabel = (s) => { return up; }; -const { isVoteEditLocked } = require("../models/votes_model"); +const { isVoteLocked } = require("../models/votes_model"); const isVoteAuthor = (v) => !!(v && v.createdBy && String(v.createdBy) === String(userId)); -const canModifyVote = (v) => isVoteAuthor(v) && !isVoteEditLocked(v); +const canModifyVote = (v) => isVoteAuthor(v) && !isVoteLocked(v); const renderVoteOwnerActions = (v, returnTo) => { const showUpdateButton = canModifyVote(v); @@ -106,28 +105,28 @@ const renderVoteListItem = exports.renderVoteListItem = (v, voteOptionsDefault, const returnTo = `/votes?filter=${encodeURIComponent(activeFilter || "all")}`; const totalOpinions = Object.values(v.opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0); const isOwn = isVoteAuthor(v); - const topOpts = { author: v.createdBy, date: v.createdAt, chips }; - const cardTop = opts.headerActions !== undefined - ? div({ class: "card-header activity-card-header card-top" }, renderCardByline(topOpts), opts.headerActions) - : origin - ? renderCardTop(v.id, origin.href, { ...topOpts, spread: spreadInfo || null, reportTitle: v.question }) - : renderCardTop(v.id, `/votes/${encodeURIComponent(v.id)}`, { ...topOpts, spread: spreadInfo || null, favKind: 'votes', isFavorite: v.isFavorite, reportTitle: v.question, returnTo, deleteAction: canModifyVote(v) ? contentDeleteAction('votes', v.id) : undefined }); + const headerActions = opts.headerActions !== undefined + ? opts.headerActions + : origin ? null : renderContentActions(v.id, `/votes/${encodeURIComponent(v.id)}`, { spread: spreadInfo || null, author: v.createdBy, favKind: 'votes', isFavorite: v.isFavorite, reportTitle: v.question, returnTo, deleteAction: canModifyVote(v) ? contentDeleteAction('votes', v.id) : undefined }); return div({ class: "trending-card vote-card" + (isOwn ? " own-content" : "") }, - cardTop, + headerActions ? div({ class: "card-header activity-card-header" }, + span(), + headerActions + ) : null, div({ class: "card-section vote-card-body" }, div({ class: "shop-title-row" }, h2({ class: "tribe-card-title" }, href ? a({ href }, v.question || i18n.votationsTitle) : (v.question || i18n.votationsTitle) ) ), + chips.length ? div({ class: "card-chips-row" }, ...chips) : null, v.deadline ? p({ class: "time-chip" }, moment(v.deadline).format("YYYY/MM/DD HH:mm")) : null, origin ? null : div({ class: "tribe-card-members" }, span({ class: "tribe-members-count" }, `${i18n.eventAttendees}: ${totalVotesNum}`) ), origin ? null : div({ class: `job-meta-line vote-outcome vote-outcome-${outcome.variant}` }, `${i18n.voteResults || "Results"}: ${outcome.text}`), - ...(Array.isArray(opts.bodyExtra) ? opts.bodyExtra.filter(Boolean) : []), - null + ...(Array.isArray(opts.bodyExtra) ? opts.bodyExtra.filter(Boolean) : []) ) ); }; @@ -195,12 +194,14 @@ const renderVoteDetail = (v, voteOptionsDefault, firstRow, secondRow, mode, acti pushRow(i18n.voteStatus, statusLabel(v.status)); const voteSide = div({ class: "tribe-side" }, - renderCardTop(v.id, `/votes/${encodeURIComponent(v.id)}`, { arrow: false, date: v.createdAt, chips, bylineExtra: [renderEditedChip(v.id, v.rootId)], spread: params.spreads || null, author: v.createdBy, favKind: 'votes', isFavorite: v.isFavorite, reportTitle: v.question, deleteAction: canModifyVote(v) ? contentDeleteAction('votes', v.id) : undefined }), + div({ class: "card-header activity-card-header" }, + renderContentActions(v.id, `/votes/${encodeURIComponent(v.id)}`, { spread: params.spreads || null, author: v.createdBy, favKind: 'votes', isFavorite: v.isFavorite, reportTitle: v.question, deleteAction: canModifyVote(v) ? contentDeleteAction('votes', v.id) : undefined }) + ), div({ class: "shop-title-row" }, h2({ class: "tribe-card-title" }, v.question) ), - renderDetailShare(v.id, `/votes/${encodeURIComponent(v.id)}`, v.question), - table({ class: "tribe-info-table jobs-info-table" }, ...infoRows), + chips.length ? div({ class: "card-chips-row" }, ...chips) : null, + table({ class: "tribe-info-table jobs-info-table" }, ...infoRows, ...renderCreatedRows(v.createdAt, v.createdBy)), tagsNode, div({ class: "tribe-card-members" }, span({ class: "tribe-members-count" }, `${i18n.eventAttendees}: ${totalVotesNum}`) @@ -212,7 +213,6 @@ const renderVoteDetail = (v, voteOptionsDefault, firstRow, secondRow, mode, acti const voteButtonsNode = renderVoteButtons(v, voteOptions, firstRow, secondRow, returnTo); const voteMain = div({ class: "tribe-main" }, - mobileUi.isMobile() && !voteButtonsNode && isVoteAuthor(v) && normalizeStatus(v.status) === "OPEN" ? mobileUi.voteOwnNote() : null, voteButtonsNode ? div({ class: "job-section" }, h2({ class: "job-section-title" }, i18n.voteCastTitle || "Cast Vote"), @@ -224,7 +224,7 @@ const renderVoteDetail = (v, voteOptionsDefault, firstRow, secondRow, mode, acti `${i18n.voteResults || "Results"}: `, span({ class: `vote-outcome vote-outcome-${outcome.variant}` }, outcome.text) ), - mobileUi.isMobile() ? mobileUi.voteBars(voteOptions, baseCounts, voteLabel) : div({ class: "vote-table" }, + div({ class: "vote-table" }, table( tr(...voteOptions.map((opt) => th(voteLabel(opt)))), tr(...voteOptions.map((opt) => td(baseCounts[opt]))) @@ -298,7 +298,6 @@ exports.voteView = async (votes, mode, voteId, comments = [], activeFilterParam, : (voteToEdit.deadline ? moment(voteToEdit.deadline).format("YYYY-MM-DDTHH:mm") : ""); const questionValue = draft.question !== undefined && draft.question !== "" ? String(draft.question) : (voteToEdit.question || ""); const tagsValue = draft.tags ? String(draft.tags) : editTags.join(", "); - const opinionLock = mode === "edit" ? renderMediaLockNote(voteToEdit) : null; return template( title, @@ -340,7 +339,7 @@ exports.voteView = async (votes, mode, voteId, comments = [], activeFilterParam, { action: mode === "edit" ? `/votes/update/${encodeURIComponent(voteId)}` : "/votes/create", method: "POST" }, input({ type: "hidden", name: "returnTo", value: listReturnTo }), h2(i18n.voteQuestionLabel), - input({ type: "text", name: "question", id: "question", required: true, value: questionValue, ...(opinionLock ? { readonly: true } : {}) }), opinionLock, br(), br(), + input({ type: "text", name: "question", id: "question", required: true, value: questionValue }), br(), br(), label(i18n.voteDeadlineLabel), br(), input({ type: "datetime-local", @@ -352,7 +351,6 @@ exports.voteView = async (votes, mode, voteId, comments = [], activeFilterParam, }), br(), br(), label(i18n.voteTagsLabel), br(), input({ type: "text", name: "tags", id: "tags", value: tagsValue }), br(), br(), - mobileUi.isMobile() && mode === "create" ? mobileUi.voteKindPicker(draft.options) : null, button({ type: "submit" }, mode === "edit" ? i18n.voteUpdateButton : i18n.voteCreateButton) ) ) diff --git a/src/views/wallet_view.js b/src/views/wallet_view.js index 1a8c97e..6c99485 100644 --- a/src/views/wallet_view.js +++ b/src/views/wallet_view.js @@ -2,8 +2,6 @@ const { form, button, div, h2, p, section, input, span, table, thead, tbody, tr, const moment = require("../server/node_modules/moment"); const { template, i18n, userLink, renderWalletChip, renderStateChip, paged } = require('./main_views'); -const ECOIN_SOURCE_URL = 'https://github.com/epsylon/ecoin'; - const TX_CATEGORY_CHIPS = { receive: ["mutuals", "\u2B07"], send: ["whole", "\u2B06"], @@ -215,11 +213,7 @@ exports.walletErrorView = async (error) => { const header = div({ class: 'tags-header module-header-line' }, h2(i18n.walletTitle), p(i18n.walletDescription), renderWalletChip()); const w = (require('../configs/config-manager.js').getConfig() || {}).wallet || {}; if (![w.url, w.user, w.pass].every(v => String(v || '').trim())) { - return template(i18n.walletTitle, section(header, div({ class: "wallet-error" }, h2(i18n.walletStatus), p(i18n.walletNotConfigured), - div({ class: "wallet-error-actions" }, - a(process.env.OASIS_MOBILE === '1' ? { href: require('../backend/updater.js').ecoinWalletDownloadUrl(), class: "update-download-link" } : { href: ECOIN_SOURCE_URL, class: "update-download-link", target: "_blank", rel: "noopener noreferrer" }, i18n.walletDownloadEcoin), - a({ href: '/settings#wallet', class: "update-download-link" }, i18n.walletConfiguration) - )))); + return template(i18n.walletTitle, section(header, div({ class: "wallet-error" }, h2(i18n.walletStatus), p(i18n.walletNotConfigured, ' ', a({ href: '/settings#wallet' }, i18n.walletConfiguration)), process.env.OASIS_MOBILE === '1' ? div({ class: "wallet-error-actions" }, a({ href: require('../backend/updater.js').ecoinWalletDownloadUrl(), class: "update-download-link" }, i18n.walletDownloadEcoin)) : null))); } return template( i18n.walletTitle, diff --git a/src/views/welcome_view.js b/src/views/welcome_view.js index 808fa10..f9bc874 100644 --- a/src/views/welcome_view.js +++ b/src/views/welcome_view.js @@ -3,7 +3,6 @@ const { template, i18n, WIZARD_THEMES } = require("./main_views") const { renderRestoreStatus } = require("./backup_view") const { renderSuggestedPub } = require("./invites_view") const { config } = require("../server/SSB_server.js") -const mobileUi = require("./mobile_ui") const { WIZARD_WORKFLOWS } = require("../models/workflows_model") const FEED_TEXT_MIN = Number(config?.feed?.minLength ?? 1) @@ -17,8 +16,6 @@ const SECTIONS = { community: ["welcomeStepCommunity", "welcomeHintCommunity"], greeting: ["welcomeStepIntro", "welcomeHintIntro"] } -if (mobileUi.isMobile()) SECTIONS.workflow = ["welcomeStepUseMobile", "welcomeHintUseMobile"] -if (mobileUi.isMobile()) SECTIONS.colors = ["quick_colors", "welcomeHintColors"] const submitRow = (text) => div({ class: "welcome-action" }, button({ type: "submit" }, text)) @@ -41,7 +38,6 @@ const stepAction = (key, profile, status) => { let cfg = {} try { cfg = require("../configs/config-manager.js").getConfig() || {} } catch (_) {} const cur = ["ainav", "chats", "feed", "phone", "rooms"].includes(cfg.ux?.current) ? cfg.ux.current : "blocks" - if (mobileUi.isMobile()) return mobileUi.uxPick("/welcome/ux", cfg, true) return form({ method: "POST", action: "/welcome/ux", class: "welcome-ux-form" }, div({ class: "welcome-ux-grid" }, pickCard("ux", "blocks", cur, i18n.uxModeMenus, "/assets/images/ux-blocks.png"), @@ -56,7 +52,6 @@ const stepAction = (key, profile, status) => { } if (key === "workflow") { const selected = WIZARD_WORKFLOWS.includes(status.workflow) ? status.workflow : "full" - if (mobileUi.isMobile()) return mobileUi.workflowPick("/welcome/workflow", WIZARD_WORKFLOWS.includes(status.workflow) ? status.workflow : null, WIZARD_WORKFLOWS, true) return form({ method: "POST", action: "/welcome/workflow", class: "welcome-select-form" }, select({ name: "workflow" }, WIZARD_WORKFLOWS.map(k => option({ value: k, ...(k === selected ? { selected: true } : {}) }, k === "full" ? i18n.welcomeWorkflow_full : (i18n[`workflow_${k}`] || k))) @@ -64,7 +59,6 @@ const stepAction = (key, profile, status) => { submitRow(i18n.welcomeStepWorkflowAction) ) } - if (key === "colors") return mobileUi.colorsPick("/welcome#step-colors", true) if (key === "theme") return form({ method: "POST", action: "/welcome/theme", class: "welcome-ux-form" }, div({ class: "welcome-ux-grid" }, WIZARD_THEMES.map(([value, name]) => pickCard("theme", value, status.theme, name, `/assets/images/theme-${value.toLowerCase()}.png`)) @@ -107,8 +101,8 @@ const stepAction = (key, profile, status) => { } exports.welcomeView = async (status, profile = {}) => { - const steps = mobileUi.isMobile() ? mobileUi.welcomeDone(status.steps || {}) : (status.steps || {}) - const usable = mobileUi.isMobile() ? mobileUi.welcomeSteps(status.usable || []) : (status.usable || []) + const steps = status.steps || {} + const usable = status.usable || [] const opened = usable.find(key => !steps[key]) const html = template( i18n.welcomeTitle, @@ -117,7 +111,7 @@ exports.welcomeView = async (status, profile = {}) => { h2(i18n.welcomeTitle), p(i18n.welcomeDescription) ), - status.pending && !mobileUi.isMobile() + status.pending ? form({ method: "POST", action: "/welcome/dismiss", class: "welcome-action" }, input({ type: "hidden", name: "returnTo", value: "/" }), button({ type: "submit" }, i18n.welcomeSkip) @@ -141,13 +135,7 @@ exports.welcomeView = async (status, profile = {}) => { ), stepAction(key, profile || {}, status) ) - )), - // movil: "Ahora no" al final y discreto (arriba era lo primero que se tocaba) - status.pending && mobileUi.isMobile() - ? section(form({ method: "POST", action: "/welcome/dismiss", class: "welcome-action welcome-skip-end" }, - input({ type: "hidden", name: "returnTo", value: "/" }), - button({ type: "submit", class: "welcome-skip-btn" }, i18n.welcomeSkip))) - : null + )) ) return status.bootstrap && status.bootstrap.running ? html.replace('', '') : html } diff --git a/src/views/wiki_view.js b/src/views/wiki_view.js index 902047b..4caba70 100644 --- a/src/views/wiki_view.js +++ b/src/views/wiki_view.js @@ -1,8 +1,8 @@ -const { div, h2, h3, p, section, button, form, a, span, br, input, label, select, option, ul, li, img } = require("../server/node_modules/hyperaxe"); -const { clearnetItemHref, template, i18n, userLink, renderStateChip, renderCardTop, renderSubscriptionBox, renderModuleStats, renderOpinionsVoting, paged, renderEngagement, renderDetailShare } = require("./main_views"); +const { div, h2, h3, p, section, button, form, a, span, br, textarea, input, label, select, option, ul, li, img } = require("../server/node_modules/hyperaxe"); +const { clearnetItemHref, template, i18n, userLink, renderStateChip, renderContentActions, renderSubscriptionBox, renderModuleStats, moduleIsEmpty, renderCardMetaRow, paged } = require("./main_views"); const { renderEncryptedChip, renderReachChip, renderClearnetSelector, renderClearnetSwitch, renderLicenseChip, renderLicenseSelect, renderTribeOriginChip } = require("./clearnet_view"); const { renderStyledText, richTextarea } = require("../backend/renderStyledText"); -const { renderCommentsSection } = require("./comments_view"); +const { WIKILINK_RE, slugify, linkTarget } = require("../models/wiki_model"); const moment = require("../server/node_modules/moment"); const { config } = require("../server/SSB_server.js"); @@ -89,16 +89,18 @@ const renderTags = (tags) => (Array.isArray(tags) && tags.length) const renderPageCard = (page, params = {}) => { const origin = page.tribeOrigin || null; const href = origin ? origin.href : pageHref(page.tribeId ? page.id : page.slug, page.tribeId); - const chips = [...(origin ? [renderTribeOriginChip(origin)] : pageChips(page)), !origin && page.clearnet === true ? reachChip(page) : null, renderLicenseChip(page.license)]; return div({ class: "tribe-card wiki-card" }, - renderCardTop(page.id, href, origin - ? { date: page.createdAt, chips, author: page.author, reportTitle: page.title } - : { date: page.createdAt, chips, author: page.author, reportTitle: page.title, favKind: 'wiki', isFavorite: page.isFavorite, spread: (params.spreadMap && params.spreadMap.get(page.id)) || null, deleteAction: !page.tribeId && String(page.author) === String(userId) ? `/wiki/delete/${encodeURIComponent(page.id)}` : undefined }), + div({ class: "card-header activity-card-header" }, + span(), + renderContentActions(page.id, href, origin + ? { author: page.author, reportTitle: page.title } + : { author: page.author, reportTitle: page.title, favKind: 'wiki', isFavorite: page.isFavorite, spread: (params.spreadMap && params.spreadMap.get(page.id)) || null, deleteAction: !page.tribeId && String(page.author) === String(userId) ? `/wiki/delete/${encodeURIComponent(page.id)}` : undefined }) + ), div({ class: "tribe-card-body" }, page.image ? a({ href }, img({ loading: 'lazy', class: "wiki-card-cover", src: `/blob/${encodeURIComponent(page.image)}`, alt: page.title || "" })) : null, div({ class: "shop-title-row" }, h2({ class: "tribe-card-title" }, a({ href }, page.title || "—"))), - origin && params.withBody && page.body ? renderWikiBody(page.body) : null, - null + div({ class: "card-chips-row" }, ...(origin ? [renderTribeOriginChip(origin)] : pageChips(page)), !origin && page.clearnet === true ? reachChip(page) : null, renderLicenseChip(page.license)), + origin && params.withBody && page.body ? renderWikiBody(page.body) : null ) ); }; @@ -300,7 +302,9 @@ exports.wikiPageView = async (page, params = {}) => { const subscriptionNodes = params.subscription ? renderSubscriptionBox({ target: page.id, scope: "wiki", subscribed: params.subscription.subscribed, count: params.subscription.count, isOwner: page.isOwner, returnTo: base, inline: true, compact: true }) : null; + const barChips = [reachChip(page), statusChip(page)].filter(Boolean); const actions = div({ class: "tribe-side-actions wiki-actions-top" }, + span({ class: "wiki-actions-left" }, ...barChips), page.isOwner && !tribeId && !page.encrypted ? renderClearnetSwitch("wiki", page.rootId || page.id, !!page.clearnet) : null, ...(subscriptionNodes && subscriptionNodes.length ? subscriptionNodes : []), page.canEdit && !version @@ -333,18 +337,21 @@ exports.wikiPageView = async (page, params = {}) => { section(renderHeader(params.tribe || null), renderFilters("all", "", { ...params, emptyMod: census.length === 0 }, census)), section( div({ class: "trending-card wiki-page" }, - renderCardTop(page.id, base, { arrow: false, - date: page.createdAt, - chips: [reachChip(page), statusChip(page), renderLicenseChip(page.license), ...pageChips(page, params, { withStatus: false })], - author: page.author, reportTitle: page.title, favKind: 'wiki', isFavorite: page.isFavorite, spread: params.spread || null, deleteAction: page.isOwner && !tribeId ? `/wiki/delete/${encodeURIComponent(page.id)}` : undefined - }), + div({ class: "card-header activity-card-header" }, + span(), + renderContentActions(page.id, base, { author: page.author, reportTitle: page.title, favKind: 'wiki', isFavorite: page.isFavorite, spread: params.spread || null, deleteAction: page.isOwner && !tribeId ? `/wiki/delete/${encodeURIComponent(page.id)}` : undefined }) + ), div({ class: "card-section" }, actions, - div({ class: "shop-title-row" }, h2({ class: "wiki-page-title" }, shownTitle)), + div({ class: "shop-title-row" }, h2({ class: "wiki-page-title" }, shownTitle), renderLicenseChip(page.license)), + (() => { const rest = pageChips(page, params, { withStatus: false }); return rest.length ? div({ class: "card-chips-row" }, ...rest) : null; })(), version ? p({ class: "wiki-version-notice" }, `${i18n.wikiViewingVersion} (${moment(version.createdAt).format("YYYY/MM/DD HH:mm")}) — `, a({ href: base }, i18n.wikiBackToPage)) : null, diffBlock || renderWikiBody(shownBody, { missing: page.missingLinks, tribeId }), renderTags(page.tags), - renderDetailShare(page.id, base, page.title) + renderCardMetaRow(p({ class: "card-footer" }, + span({ class: "date-link" }, `${moment(page.createdAt).format("YYYY/MM/DD HH:mm")}`), + userLink(page.author) + )) ), ), div({ class: "card-section wiki-backlinks" }, @@ -352,10 +359,6 @@ exports.wikiPageView = async (page, params = {}) => { page.linkedPages && page.linkedPages.length ? ul(...page.linkedPages.map(b => li(a({ href: pageHref(b.tribeId ? b.id : b.slug, b.tribeId) }, b.title)))) : p(i18n.wikiNoLinkedPages) - ), - renderEngagement(page.id, - params.opinions ? renderOpinionsVoting('/wiki/opinions', page.id, params.opinions.opinions, `/wiki/${encodeURIComponent(page.id)}`, params.opinions.opinions_inhabitants) : null, - Array.isArray(params.comments) ? renderCommentsSection({ action: `/wiki/${encodeURIComponent(page.id)}/comments`, comments: params.comments, returnTo: `/wiki/${encodeURIComponent(page.id)}` }) : null ) ) ); diff --git a/test/karvan_model.test.js b/test/karvan_model.test.js new file mode 100644 index 0000000..9b26b5e --- /dev/null +++ b/test/karvan_model.test.js @@ -0,0 +1,183 @@ +"use strict"; +// Tests del módulo KARVAN (salas efímeras + mensajes temporales + señalización WebRTC). +// Sin framework (Oasis no tiene): `node test/karvan_model.test.js` o `npm test`. +// Cubre: creación/listado de salas, mensajes efímeros, buffer circular, truncado, validación, +// AUTODESTRUCCIÓN por TTL (idle y absoluto), desalojo por límite, y el mailbox de señalización. + +const assert = require("assert"); +const path = require("path"); +const makeModel = require(path.join(__dirname, "..", "src", "models", "karvan_model.js")); + +let passed = 0, failed = 0; +const wait = (ms) => new Promise((r) => setTimeout(r, ms)); +async function test(name, fn) { + try { await fn(); passed++; console.log("ok - " + name); } + catch (e) { failed++; console.log("FAIL - " + name + "\n " + (e && e.message)); } +} + +(async () => { + await test("createRoom: id de 18 hex, count 0, expira en el futuro", () => { + const m = makeModel(); + const r = m.createRoom({ title: "hola" }); + assert.strictEqual(typeof r.id, "string"); + assert.strictEqual(r.id.length, 18); + assert.strictEqual(r.title, "hola"); + assert.strictEqual(r.count, 0); + assert.ok(r.expiresAt > r.createdAt); + }); + + await test("postMessage + listMessages: seq incremental y contenido", () => { + const m = makeModel(); + const r = m.createRoom({}); + const msg = m.postMessage(r.id, { from: "alice", text: "hi" }); + assert.strictEqual(msg.seq, 1); + assert.strictEqual(msg.from, "alice"); + assert.strictEqual(msg.text, "hi"); + const list = m.listMessages(r.id, 0); + assert.strictEqual(list.length, 1); + assert.strictEqual(list[0].text, "hi"); + }); + + await test("listMessages(since) filtra por seq", () => { + const m = makeModel(); + const r = m.createRoom({}); + m.postMessage(r.id, { from: "a", text: "1" }); + m.postMessage(r.id, { from: "a", text: "2" }); + assert.strictEqual(m.listMessages(r.id, 1).length, 1); // solo seq > 1 + assert.strictEqual(m.listMessages(r.id, 0).length, 2); + }); + + await test("rechaza texto vacío y sala inexistente (devuelve null)", () => { + const m = makeModel(); + const r = m.createRoom({}); + assert.strictEqual(m.postMessage(r.id, { from: "a", text: "" }), null); + assert.strictEqual(m.postMessage("noexiste", { from: "a", text: "x" }), null); + assert.strictEqual(m.listMessages("noexiste", 0), null); + }); + + await test("trunca texto a 2000 y 'from' a 80", () => { + const m = makeModel(); + const r = m.createRoom({}); + const big = "x".repeat(3000); + const msg = m.postMessage(r.id, { from: "y".repeat(200), text: big }); + assert.strictEqual(msg.text.length, 2000); + assert.strictEqual(msg.from.length, 80); + }); + + await test("buffer circular: máx 250 en RAM, seq sigue creciendo", () => { + const m = makeModel(); + const r = m.createRoom({}); + for (let i = 1; i <= 260; i++) m.postMessage(r.id, { from: "a", text: "m" + i }); + const all = m.listMessages(r.id, 0); + assert.strictEqual(all.length, 250); // solo los últimos 250 + assert.strictEqual(all[all.length - 1].seq, 260); // el seq no se reinicia + assert.strictEqual(all[0].seq, 11); // los 10 primeros se descartaron + }); + + await test("ttlMinutes fija expiresAt y se capa a 24h", () => { + const m = makeModel(); + const r5 = m.createRoom({ ttlMinutes: 5 }); + const dur = r5.expiresAt - r5.createdAt; + assert.ok(Math.abs(dur - 5 * 60000) < 50); + const rBig = m.createRoom({ ttlMinutes: 999999 }); + assert.ok(rBig.expiresAt - rBig.createdAt <= 24 * 60 * 60 * 1000); + }); + + await test("señalización: getSignals excluye los propios y respeta 'to'", () => { + const m = makeModel(); + const r = m.createRoom({}); + m.postSignal(r.id, { from: "A", to: "", payload: { k: "hello" } }); // broadcast de A + m.postSignal(r.id, { from: "B", to: "A", payload: { k: "desc" } }); // dirigido a A + const forA = m.getSignals(r.id, { forId: "A", since: 0 }); + // A no debe ver su propio broadcast; sí el dirigido a A + assert.strictEqual(forA.signals.length, 1); + assert.strictEqual(forA.signals[0].from, "B"); + // los miembros incluyen a B pero no a A (uno mismo) + assert.ok(forA.members.indexOf("B") !== -1); + assert.ok(forA.members.indexOf("A") === -1); + // para B: ve el broadcast de A pero no el dirigido a A + const forB = m.getSignals(r.id, { forId: "B", since: 0 }); + assert.strictEqual(forB.signals.length, 1); + assert.strictEqual(forB.signals[0].payload.k, "hello"); + }); + + await test("AUTODESTRUCCIÓN por TTL de inactividad (idle)", async () => { + const m = makeModel({ idleTtlMs: 40, absTtlMs: 100000 }); + const r = m.createRoom({}); + assert.ok(m.getRoom(r.id)); // existe + await wait(90); // > idle (40ms) sin actividad + assert.strictEqual(m.getRoom(r.id), null); // se autodestruyó + assert.strictEqual(m.listMessages(r.id, 0), null); + }); + + await test("la actividad RESETEA el idle (no se destruye si hay tráfico)", async () => { + const m = makeModel({ idleTtlMs: 60, absTtlMs: 100000 }); + const r = m.createRoom({}); + await wait(40); m.postMessage(r.id, { from: "a", text: "keepalive" }); // resetea a los 40ms + await wait(40); // 80ms totales pero el idle se reseteó -> sigue viva + assert.ok(m.getRoom(r.id), "debe seguir viva tras actividad"); + await wait(90); // ahora sí, sin actividad > idle + assert.strictEqual(m.getRoom(r.id), null); + }); + + await test("AUTODESTRUCCIÓN por TTL absoluto (no lo resetea la actividad)", async () => { + const m = makeModel({ idleTtlMs: 100000, absTtlMs: 60 }); + const r = m.createRoom({}); + await wait(30); m.postMessage(r.id, { from: "a", text: "hola" }); // actividad, NO resetea abs + await wait(60); // 90ms > abs (60ms) + assert.strictEqual(m.getRoom(r.id), null); + }); + + await test("desalojo por MAX_ROOMS (100): la más antigua se descarta", () => { + const m = makeModel(); + const ids = []; + for (let i = 0; i < 101; i++) ids.push(m.createRoom({ title: "r" + i }).id); + assert.strictEqual(m.listRooms().length, 100); // capado a 100 + assert.strictEqual(m.getRoom(ids[0]), null); // la primera (más antigua) fuera + assert.ok(m.getRoom(ids[100])); // la última sigue + }); + + await test("adoptRoom: espejo local con id dado, idempotente, rechaza id inválido", () => { + const m = makeModel(); + const id = "abc123def456abc789"; + const r = m.adoptRoom({ id, title: "espejo" }); + assert.strictEqual(r.id, id); + assert.strictEqual(r.title, "espejo"); + assert.ok(m.getRoom(id)); + const again = m.adoptRoom({ id, title: "otro" }); // idempotente + assert.strictEqual(again.id, id); + assert.strictEqual(m.listRooms().filter((x) => x.id === id).length, 1); + assert.strictEqual(m.adoptRoom({ id: "NO-hex!" }), null); // id inválido + assert.ok(m.postMessage(id, { from: "a", text: "hola" }), "se puede postear en la adoptada"); + }); + + await test("cap de miembros: no crece más allá de MAX_MEMBERS (50)", () => { + const m = makeModel(); + const r = m.createRoom({}); + for (let i = 0; i < 80; i++) m.postSignal(r.id, { from: "peer" + i, to: "", payload: { k: "hi" } }); + const g = m.getSignals(r.id, { forId: "nadie", since: 0 }); + assert.ok(g.members.length <= 50, "miembros capados a 50, got " + g.members.length); + }); + + await test("rechaza payload de señal gigante (>16KB), acepta pequeño", () => { + const m = makeModel(); + const r = m.createRoom({}); + assert.strictEqual(m.postSignal(r.id, { from: "a", to: "b", payload: { blob: "x".repeat(20000) } }), null); + assert.ok(m.postSignal(r.id, { from: "a", to: "b", payload: { sdp: "small" } }), "payload pequeño pasa"); + }); + + await test("feeds remotos: addRemoteFeed / getRemoteFeeds / anyRemoteRooms + validación", () => { + const m = makeModel(); + const r = m.createRoom({}); + assert.strictEqual(m.anyRemoteRooms(), false); + const feed = "@" + "A".repeat(43) + "=.ed25519"; + assert.strictEqual(m.addRemoteFeed(r.id, feed), true); + assert.deepStrictEqual(m.getRemoteFeeds(r.id), [feed]); + assert.strictEqual(m.anyRemoteRooms(), true); + assert.strictEqual(m.addRemoteFeed(r.id, "no-es-feed"), false); // formato inválido + assert.strictEqual(m.addRemoteFeed("noexiste", feed), false); // sala inexistente + }); + + console.log("\n" + passed + " passed, " + failed + " failed"); + process.exit(failed ? 1 : 0); +})();