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 `
${renderRichText(n.text)}
`).join("")}