movil: usabilidad tras la prueba en el Pixel

Lo que salio de recorrer la app como alguien de la acampada:

- borrar (tribus y todo lo que usa renderContentActions) pide un segundo
  toque; la X va la ultima de la fila
- Karvan: Llamar empieza solo con voz (si se negaba la camara no arrancaba
  nada); botones con icono y nombre; textos traducidos; ayuda para invitar
- votaciones: tipo Si/No, semaforo o termometro (el servidor ya aceptaba
  options); resultados en barras; aviso a quien la crea de que no puede votar
- workflow activistas con tribus, parlamento, votaciones y eventos
- seccion apagada: /modules dice cual era y la activa de un toque
- Modulos en el movil: agrupados como el lanzador, interruptores y Guardar fijo
- tribus en filas compactas; secciones de una tribu en tira de fichas + Mas
- lanzador y barra con iconos SVG en vez de letras vai
- Ajustes: idioma y aspecto primero; idiomas de un toque
- aviso de bienvenida solo en la portada y en una linea; Ahora no al final
- pie de una linea con el estado de sincronizacion en claro
- mobile_ux.css para los dos skins: un margen de 14px (eran 74 por lado en
  las fichas), Anton solo en titulos y boton principal, buscador en una linea,
  la barra de abajo se esconde con el teclado
This commit is contained in:
SITO 2026-10-19 12:37:58 +02:00
parent 2e3ff1e79f
commit 4dd573662c
14 changed files with 1279 additions and 54 deletions

View file

@ -10,6 +10,7 @@
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
@ -96,7 +97,7 @@ module.exports = ({ i18n }) => {
const modOn = (m) => !m || cfg.modules[m] === 'on' || cfg.modules[m] === undefined;
const bbLink = (href, ico, label, badge) => a(
{ class: "bb-item", href },
span({ class: "bb-ico" }, ico),
iconNode("bb-ico", iconKeyOf(href), ico),
(badge && badge > 0) ? span({ class: "bb-badge" }, String(badge)) : "",
span({ class: "bb-lbl" }, label)
);
@ -211,7 +212,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 },
span({ class: 'ln-ico', 'aria-hidden': 'true' }, m.glyph),
iconNode('ln-ico', iconKeyOf(m.href), m.glyph),
span({ class: 'ln-lbl' }, i18n[m.labelKey] || m.href.slice(1)));
const groups = GROUPS
.filter(g => !hiveFilter || g.side === hiveFilter)
@ -243,14 +244,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' },
span({ class: 'bb-manage-ico' }, m.glyph),
iconNode('bb-manage-ico', iconKeyOf(m.href), 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' },
span({ class: 'bb-pick-ico' }, m.glyph),
iconNode('bb-pick-ico', iconKeyOf(m.href), m.glyph),
span({ class: 'bb-pick-lbl' }, i18n[m.labelKey] || m.key)
)
);
@ -279,5 +280,5 @@ module.exports = ({ i18n }) => {
);
};
return { renderMobileTopbar, renderBottomBar, renderHiveNav, PINNABLE_MODULES, bottomBarPickerView };
return { renderMobileTopbar, renderBottomBar, renderHiveNav, PINNABLE_MODULES, bottomBarPickerView, GROUPS };
};

View file

@ -6,6 +6,11 @@
const { div, section, h2, h3, p, a, form, input, button, span, ul, li, script, label, video } =
require("../server/node_modules/hyperaxe");
const { template, i18n } = require("./main_views");
const { iconNode } = require("./mobile_icons");
// boton de la barra de llamada: icono y su nombre debajo (antes 🎤 🎥 ⛔ sin texto)
const callBtn = (id, cls, icon, text) =>
button({ type: "button", class: "kc-btn " + cls, id }, iconNode("kc-ico", icon), span({ class: "kc-lbl" }, text));
const fmtExpires = (expiresAt) => {
const mins = Math.max(0, Math.round((expiresAt - Date.now()) / 60000));
@ -77,23 +82,27 @@ exports.karvanRoomView = (room, messages, params = {}) =>
span({ class: "karvan-room-title" }, room.title || "room " + room.id.slice(0, 6)),
span({ class: "karvan-expire", id: "karvan-expire" }, "⏳ " + fmtExpires(room.expiresAt))
),
div({ class: "karvan-live", id: "karvan-live" },
div({ class: "karvan-live", id: "karvan-live",
"data-txt-relay": i18n.karvanRelayHuman || "server relay", "data-txt-p2p": i18n.karvanP2pHuman || "🔒 P2P (WebRTC)" },
span({ class: "kl-dot" }),
span({ class: "kl-txt", id: "karvan-live-txt" }, i18n.karvanRelay || "server relay")
span({ class: "kl-txt", id: "karvan-live-txt" }, i18n.karvanRelayHuman || i18n.karvanRelay || "server relay")
),
// Fase 1: panel de videollamada (media P2P sobre el mismo WebRTC del chat). La galería
// aparece al iniciar; los botones cablean cámara/micro. En el móvil actual getUserMedia
// fallará hasta que el wrapper conceda permisos (Fases 2/3) → se avisa en #kc-status.
div({ class: "karvan-call", id: "karvan-call" },
div({ class: "karvan-call", id: "karvan-call",
"data-txt-mic": i18n.karvanVoiceOn || "Voice on", "data-txt-cam": i18n.karvanCamOn || "Camera on",
"data-txt-nocam": i18n.karvanNoCam || "No camera permission: the call goes on with voice only.",
"data-txt-nomic": i18n.karvanNoMic || "No microphone permission. You can allow it in the phone settings; the chat still works." },
div({ class: "karvan-videos", id: "karvan-videos" },
video({ id: "karvan-local", class: "kc-video kc-local", muted: "muted", autoplay: "autoplay", playsinline: "playsinline" }),
div({ class: "kc-remote", id: "karvan-remote" })
),
div({ class: "karvan-call-bar" },
button({ type: "button", class: "kc-btn kc-btn-start", id: "kc-start" }, "📞 " + (i18n.karvanCall || "Call")),
button({ type: "button", class: "kc-btn", id: "kc-mic", "aria-label": "mic" }, "🎤"),
button({ type: "button", class: "kc-btn", id: "kc-cam", "aria-label": "camera" }, "🎥"),
button({ type: "button", class: "kc-btn kc-btn-stop", id: "kc-stop", "aria-label": "hang up" }, "⛔")
callBtn("kc-start", "kc-btn-start", "phone", i18n.karvanCall || "Call"),
callBtn("kc-mic", "", "mic", i18n.karvanMic || "Mic"),
callBtn("kc-cam", "", "camera", i18n.karvanCam || "Camera"),
callBtn("kc-stop", "kc-btn-stop", "hangup", i18n.karvanHangUp || "Hang up")
),
div({ class: "kc-status", id: "kc-status" }, "")
),
@ -105,7 +114,9 @@ exports.karvanRoomView = (room, messages, params = {}) =>
),
// Fase 2: invitar a un contacto por su feed id → le llega al inbox un enlace a esta sala
form({ class: "karvan-invite", method: "POST", action: "/karvan/" + room.id + "/invite" },
input({ type: "text", name: "to", placeholder: i18n.karvanInvitePlaceholder || "Invite a contact by @feed-id…", autocomplete: "off", spellcheck: "false" }),
label({ class: "karvan-invite-lbl", for: "karvan-invite-to" }, i18n.karvanInviteLabel || "Invite someone"),
input({ type: "text", name: "to", id: "karvan-invite-to", placeholder: i18n.karvanInvitePlaceholder || "Invite a contact by @feed-id…", autocomplete: "off", spellcheck: "false" }),
p({ class: "form-hint" }, i18n.karvanInviteHint || "Paste their @id: it is on their profile, under their name."),
button({ type: "submit" }, i18n.karvanInvite || "Invite")
),
params.invite === "ok"

View file

@ -386,12 +386,22 @@ const renderContentActions = (msgId, viewHref, opts = {}) => {
? a({ href: `/phone?filter=create&to=${encodeURIComponent(o.author)}`, class: 'btn-singleview btn-call', title: i18n.phoneCallTooltip }, renderIcon('call'))
: null;
const deleteBtn = o.deleteAction
? form({ method: 'POST', action: o.deleteAction, class: 'content-action-form content-action-delete' },
o.returnTo && o.returnTo !== viewHref ? input({ type: 'hidden', name: 'returnTo', value: o.returnTo }) : null,
button({ type: 'submit', class: 'btn-singleview btn-delete', title: i18n.chatDelete }, renderIcon('close'))
)
: null;
// en el movil borrar pide un segundo toque: la X abre "¿Borrar? Si, borrar" y
// solo ese boton envia. Sin JS, con <details>
const deleteForm = (cls, label) => form({ method: 'POST', action: o.deleteAction, class: cls },
o.returnTo && o.returnTo !== viewHref ? input({ type: 'hidden', name: 'returnTo', value: o.returnTo }) : null,
label
);
const deleteBtn = !o.deleteAction ? null
: process.env.OASIS_MOBILE === '1'
? details({ class: 'content-action-confirm' },
summary({ class: 'btn-singleview btn-delete', title: i18n.chatDelete, 'aria-label': i18n.chatDelete }, renderIcon('close')),
div({ class: 'confirm-bar' },
span({ class: 'confirm-question' }, i18n.deleteConfirmQuestion || 'Delete it for good?'),
deleteForm('content-action-form', button({ type: 'submit', class: 'confirm-yes' }, i18n.deleteConfirmYes || 'Yes, delete'))
)
)
: deleteForm('content-action-form content-action-delete', button({ type: 'submit', class: 'btn-singleview btn-delete', title: i18n.chatDelete }, renderIcon('close')));
const hideBtn = blockId && o.hide !== false && !(o.author && String(o.author) === String(myId))
? form({ method: 'POST', action: `/hide/${encodeURIComponent(blockId)}`, class: 'content-action-form' },
@ -403,6 +413,8 @@ const renderContentActions = (msgId, viewHref, opts = {}) => {
: null;
if (!pinBtn && !spreadBtn && !chainBtn && !contentBtn && !reportBtn && !pmBtn && !callBtn && !deleteBtn && !donateBtn && !torrentBtn && !hideBtn) return null;
// en el movil lo que mas se usa (abrir, enlace) va primero y borrar al final, lejos del pulgar
if (process.env.OASIS_MOBILE === '1') return div({ class: 'content-actions' }, contentBtn, linkBtn, pinBtn, spreadBtn, pmBtn, callBtn, donateBtn, reportBtn, hideBtn, deleteBtn);
return div({ class: 'content-actions' }, deleteBtn, spreadBtn, pinBtn, reportBtn, hideBtn, donateBtn, pmBtn, callBtn, torrentBtn, linkBtn, chainBtn, contentBtn);
};
exports.renderContentActions = renderContentActions;
@ -972,6 +984,22 @@ const renderFooter = () => {
const lastSyncLabel = lastSyncTs ? moment(lastSyncTs).fromNow() : '–';
const lastActivity = sharedState.getLastActivity ? sharedState.getLastActivity() : null;
// en el movil, 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
if (process.env.OASIS_MOBILE === '1') {
const n = Number(syncedPeers) || 0;
const peersText = 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(peersText)
),
span({ class: "footer-version" }, `Oasis ${pkgVersion} · GPLv3`)
);
}
return div(
{ class: "oasis-footer" },
div(
@ -1880,6 +1908,7 @@ const renderMobileTopbar = forkNav.renderMobileTopbar;
const renderBottomBar = forkNav.renderBottomBar;
const renderHiveNav = forkNav.renderHiveNav;
exports.PINNABLE_MODULES = forkNav.PINNABLE_MODULES;
exports.LAUNCHER_GROUPS = forkNav.GROUPS;
exports.renderHiveNav = renderHiveNav;
// se resuelve al llamarse, cuando template ya esta definido mas abajo
exports.bottomBarPickerView = () => forkNav.bottomBarPickerView(template);
@ -2019,6 +2048,8 @@ const template = (titlePrefix, ...elements) => {
// el modulo Karvan trae su propia hoja: asi se ve igual en cualquier tema
// y tambien en escritorio, donde no se carga OasisMobile.css
link({ rel: "stylesheet", href: `/assets/styles/karvan.css?v=${assetVersion()}` }),
// movil: piezas de usabilidad de la rama para los dos skins, la ultima para mandar
process.env.OASIS_MOBILE === '1' ? link({ rel: "stylesheet", href: `/assets/styles/mobile_ux.css?v=${assetVersion()}` }) : null,
link({ rel: "icon", href: "/assets/images/favicon.svg" }),
meta({ charset: "utf-8" }),
meta({ name: "description", content: i18n.oasisDescription }),
@ -2188,11 +2219,15 @@ const template = (titlePrefix, ...elements) => {
let here = '';
try { here = (require('../models/typed_log').requestScope.getStore() || {}).path || ''; } catch (_) {}
if (here === '/welcome' || here.startsWith('/welcome/')) return null;
// en el movil solo en la portada: en cada pagina se comia una quinta parte
// de la pantalla. Y el texto dice para que es, no solo "Empezar"
const mobile = process.env.OASIS_MOBILE === '1';
if (mobile && !['', '/', '/activity'].includes(here)) return null;
return div(
{ class: "update-banner welcome-banner" },
span({ class: "update-banner-icon" }, "🌴"),
span({ class: "update-banner-text" }, i18n.welcomeBannerText),
a({ href: "/welcome", class: "create-button welcome-banner-start" }, i18n.welcomeBannerAction),
span({ class: "update-banner-text" }, mobile ? (i18n.welcomeBannerTextMobile || i18n.welcomeBannerText) : i18n.welcomeBannerText),
a({ href: "/welcome", class: "create-button welcome-banner-start" }, mobile ? (i18n.welcomeBannerActionMobile || i18n.welcomeBannerAction) : i18n.welcomeBannerAction),
form(
{ method: "POST", action: "/welcome/dismiss", class: "welcome-banner-close" },
button({ type: "submit", class: "welcome-banner-close-btn" }, "✕")
@ -2408,6 +2443,8 @@ exports.ainavHomeView = ({ recentTags = [] } = {}) => {
// el modulo Karvan trae su propia hoja: asi se ve igual en cualquier tema
// y tambien en escritorio, donde no se carga OasisMobile.css
link({ rel: "stylesheet", href: `/assets/styles/karvan.css?v=${assetVersion()}` }),
// movil: piezas de usabilidad de la rama para los dos skins, la ultima para mandar
process.env.OASIS_MOBILE === '1' ? link({ rel: "stylesheet", href: `/assets/styles/mobile_ux.css?v=${assetVersion()}` }) : null,
link({ rel: "icon", href: "/assets/images/favicon.svg" }),
meta({ charset: "utf-8" }),
meta({ name: "description", content: i18n.oasisDescription }),

107
src/views/mobile_icons.js Normal file
View file

@ -0,0 +1,107 @@
"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: '<polyline points="3 12 7 12 10 5 14 19 17 12 21 12"/>',
feed: '<path d="M4 6h16M4 12h16M4 18h10"/>',
chats: '<path d="M21 11.5a8.5 8.5 0 0 1-12.3 7.6L3 20.5l1.4-5A8.5 8.5 0 1 1 21 11.5z"/>',
karvan: '<path d="M21 11.5a8.5 8.5 0 0 1-12.3 7.6L3 20.5l1.4-5A8.5 8.5 0 1 1 21 11.5z"/><path d="M12 7.5v4l2.5 2"/>',
rooms: '<path d="M6 21V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v17M3 21h18"/><circle cx="14.5" cy="12" r="1"/>',
forum: '<path d="M14 9a2 2 0 0 1-2 2H6l-3 3V4a2 2 0 0 1 2-2h7a2 2 0 0 1 2 2z"/><path d="M18 9h1a2 2 0 0 1 2 2v10l-3-3h-6a2 2 0 0 1-2-2v-1"/>',
pads: '<path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><path d="M14 3v6h6M8 13h8M8 17h5"/>',
wiki: '<path d="M2 4h6a4 4 0 0 1 4 4v13a3 3 0 0 0-3-3H2z"/><path d="M22 4h-6a4 4 0 0 0-4 4v13a3 3 0 0 1 3-3h7z"/>',
blogs: '<path d="M16.5 3.5l4 4L8 20H4v-4z"/><path d="M13.5 6.5l4 4"/>',
opinions: '<polygon points="12 3 14.8 8.8 21 9.6 16.5 14 17.6 20.3 12 17.3 6.4 20.3 7.5 14 3 9.6 9.2 8.8"/>',
mentions: '<circle cx="12" cy="12" r="4"/><path d="M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-4 8"/>',
tags: '<path d="M4 9h16M4 15h16M10 3L8 21M16 3l-2 18"/>',
trending: '<polyline points="3 17 9 11 13 15 21 7"/><polyline points="15 7 21 7 21 13"/>',
maps: '<path d="M12 21s-7-6.2-7-11a7 7 0 0 1 14 0c0 4.8-7 11-7 11z"/><circle cx="12" cy="10" r="2.5"/>',
graphos: '<circle cx="5" cy="6" r="2"/><circle cx="19" cy="6" r="2"/><circle cx="12" cy="18" r="2"/><path d="M7 6h10M6 8l5 8M18 8l-5 8"/>',
inhabitants: '<circle cx="9" cy="8" r="3.5"/><path d="M2 21a7 7 0 0 1 14 0M16 4.5a3.5 3.5 0 0 1 0 7M22 21a7 7 0 0 0-4-6.3"/>',
fediverse: '<circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18"/>',
images: '<rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="9" cy="10" r="2"/><path d="M21 16l-5-5-9 9"/>',
videos: '<rect x="2" y="6" width="14" height="12" rx="2"/><path d="M16 10l6-3v10l-6-3z"/>',
audios: '<path d="M9 18V5l11-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="17" cy="16" r="3"/>',
podcasts: '<rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 11a7 7 0 0 0 14 0M12 18v4M8 22h8"/>',
documents: '<path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><path d="M14 3v6h6"/>',
files: '<path d="M3 6a2 2 0 0 1 2-2h4l2 3h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>',
bookmarks: '<path d="M6 3h12v18l-6-4-6 4z"/>',
torrents: '<line x1="7" y1="20" x2="7" y2="4"/><polyline points="3 8 7 4 11 8"/><line x1="17" y1="4" x2="17" y2="20"/><polyline points="13 16 17 20 21 16"/>',
games: '<rect x="2" y="7" width="20" height="11" rx="5"/><path d="M7 10.5v4M5 12.5h4"/><circle cx="15.5" cy="11.5" r="1"/><circle cx="18" cy="14" r="1"/>',
pixelia: '<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M3 15h18M9 3v18M15 3v18"/>',
melody: '<circle cx="7" cy="17" r="3"/><path d="M10 17V4l8 3"/>',
events: '<rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 10h18M8 3v4M16 3v4"/><circle cx="12" cy="15.5" r="1.5"/>',
calendars: '<rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 10h18M8 3v4M16 3v4M7.5 14h2M14.5 14h2M7.5 17.5h2"/>',
tasks: '<path d="M3.5 6l2 2 3.5-3.5M3.5 14l2 2 3.5-3.5M12 7h9M12 15h9"/>',
campaigns: '<path d="M3 10v4h4l8 5V5L7 10z"/><path d="M19 9a4 4 0 0 1 0 6"/>',
mailing: '<rect x="3" y="5" width="18" height="14" rx="2"/><polyline points="3 7 12 13 21 7"/>',
votes: '<path d="M4 12h16v8H4z"/><path d="M8 12V4h8v8"/><path d="M10 8l1.5 1.5L14 7"/>',
polls: '<path d="M4 20V10M10 20V4M16 20v-7M2 20h20"/>',
reports: '<line x1="5" y1="21" x2="5" y2="3"/><path d="M5 4h12l-2.5 4.5L17 13H5"/>',
tribes: '<path d="M2 20h20M5 20l7-14 7 14M10 20l2-5 2 5"/>',
emergencies: '<path d="M12 3l10 18H2z"/><path d="M12 10v4M12 17.5v.5"/>',
school: '<path d="M2 9l10-5 10 5-10 5z"/><path d="M6 11v5c3 2.5 9 2.5 12 0v-5M22 9v6"/>',
larp: '<path d="M12 3l2 7 7 2-7 2-2 7-2-7-7-2 7-2z"/>',
parliament: '<path d="M3 21h18M4 10h16M12 3l9 5H3z"/><path d="M6 10v8M10 10v8M14 10v8M18 10v8"/>',
courts: '<path d="M12 3v18M7 21h10M5 7h14M5 7l-3 7a3 3 0 0 0 6 0zM19 7l-3 7a3 3 0 0 0 6 0z"/>',
market: '<path d="M6 7h12l1 14H5z"/><path d="M9 10V6a3 3 0 0 1 6 0v4"/>',
shops: '<path d="M3 9l2-5h14l2 5"/><path d="M3 9a3 3 0 0 0 6 0 3 3 0 0 0 6 0 3 3 0 0 0 6 0"/><path d="M5 12v9h14v-9M10 21v-5h4v5"/>',
jobs: '<rect x="3" y="7" width="18" height="13" rx="2"/><path d="M9 7V5a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2M3 13h18"/>',
projects: '<rect x="3" y="4" width="5" height="16" rx="1"/><rect x="10" y="4" width="5" height="10" rx="1"/><rect x="17" y="4" width="4" height="13" rx="1"/>',
industry: '<path d="M2 21h20M4 21V11l5 3V11l5 3V7l6 4v10"/><path d="M8 17h1M13 17h1M18 17h1"/>',
housing: '<path d="M3 11l9-7 9 7"/><path d="M5 10v10h14V10M10 20v-6h4v6"/>',
logistics: '<path d="M2 6h12v10H2zM14 10h4l3 3v3h-7"/><circle cx="6" cy="18" r="2"/><circle cx="17" cy="18" r="2"/>',
banking: '<rect x="2" y="5" width="20" height="14" rx="2"/><path d="M2 10h20M6 15h4"/>',
transfers: '<path d="M4 8h16M16 4l4 4-4 4M20 16H4M8 12l-4 4 4 4"/>',
profile: '<circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/>',
agenda: '<rect x="5" y="4" width="14" height="17" rx="2"/><path d="M9 4V3h6v1M9 11h6M9 15h4"/>',
favorites: '<path d="M12 20s-8-5-8-11a4.5 4.5 0 0 1 8-2.8A4.5 4.5 0 0 1 20 9c0 6-8 11-8 11z"/>',
phone: '<path d="M5 3h4l2 5-2.5 1.5a11 11 0 0 0 6 6L16 13l5 2v4a2 2 0 0 1-2 2A17 17 0 0 1 3 5a2 2 0 0 1 2-2z"/>',
cv: '<rect x="3" y="5" width="18" height="14" rx="2"/><circle cx="9" cy="11" r="2"/><path d="M6 16a3 3 0 0 1 6 0M14 10h4M14 14h4"/>',
wallet: '<path d="M3 7a2 2 0 0 1 2-2h13v4"/><rect x="3" y="7" width="18" height="13" rx="2"/><path d="M16 13.5h2"/>',
logs: '<path d="M8 6h13M8 12h13M8 18h13M3.5 6h.01M3.5 12h.01M3.5 18h.01"/>',
invites: '<circle cx="9" cy="8" r="4"/><path d="M2 21a7 7 0 0 1 14 0M19 8v6M16 11h6"/>',
peers: '<circle cx="12" cy="12" r="2"/><path d="M8.5 8.5a5 5 0 0 0 0 7M15.5 8.5a5 5 0 0 1 0 7M5.5 5.5a9.2 9.2 0 0 0 0 13M18.5 5.5a9.2 9.2 0 0 1 0 13"/>',
bar: '<path d="M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6"/>',
backup: '<path d="M12 3v12M7 10l5 5 5-5"/><path d="M4 17v3h16v-3"/>',
cipher: '<rect x="4" y="11" width="16" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/>',
ai: '<rect x="6" y="6" width="12" height="12" rx="2"/><path d="M9 2v4M15 2v4M9 18v4M15 18v4M2 9h4M2 15h4M18 9h4M18 15h4"/>',
stats: '<path d="M21 12A9 9 0 1 1 12 3v9z"/><path d="M15 3.5A9 9 0 0 1 20.5 9H15z"/>',
blockexplorer: '<path d="M12 2.5l8.5 4.75v9.5L12 21.5l-8.5-4.75v-9.5z"/><polyline points="3.5 7.25 12 12 20.5 7.25"/><line x1="12" y1="12" x2="12" y2="21.5"/>',
modules: '<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/>',
settings: '<circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M4.9 4.9L7 7M17 17l2.1 2.1M2 12h3M19 12h3M4.9 19.1L7 17M17 7l2.1-2.1"/>',
search: '<circle cx="11" cy="11" r="7"/><path d="M21 21l-5-5"/>',
inbox: '<path d="M3 13l3-8h12l3 8v6a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1z"/><path d="M3 13h5l1 3h6l1-3h5"/>',
publish: '<path d="M12 5v14M5 12h14"/>',
data: '<ellipse cx="12" cy="5" rx="8" ry="3"/><path d="M4 5v14c0 1.7 3.6 3 8 3s8-1.3 8-3V5M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3"/>',
mic: '<rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 11a7 7 0 0 0 14 0M12 18v4"/>',
camera: '<rect x="2" y="6" width="14" height="12" rx="2"/><path d="M16 10l6-3v10l-6-3z"/>',
hangup: '<path d="M3 15.5c5-4.7 13-4.7 18 0l-2.5 2.5-3-1.5v-2.5a10 10 0 0 0-7 0v2.5l-3 1.5z"/>'
};
// 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]
? `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">${ICONS[key]}</svg>`
: '';
// 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 };

View file

@ -1,9 +1,74 @@
const { form, button, div, h2, p, section, table, tr, td, input, a, br, ul, li, span, strong } = require("../server/node_modules/hyperaxe");
const { template, i18n } = require('./main_views');
const { form, button, div, h2, h3, p, section, table, tr, td, input, a, br, ul, li, span, strong, label } = require("../server/node_modules/hyperaxe");
const { template, i18n, LAUNCHER_GROUPS } = require('./main_views');
const { iconNode } = require('./mobile_icons');
const { getConfig } = require('../configs/config-manager.js');
const { PRESETS: PRESET_LIST } = require('../models/workflows_model');
const modulesView = () => {
// en el movil: 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 52 filas
// con casillas de 16px eran 11 pantallas
const mobileModulesView = (visibleModules, moduleStates, opts) => {
const groupOf = {};
(LAUNCHER_GROUPS || []).forEach(g => g.mods.forEach(m => { if (m.mod) groupOf[m.mod.replace(/Mod$/, '')] = g; }));
// lo que no sale en el lanzador: Industria va con la economia; lo demas, al final
const economy = (LAUNCHER_GROUPS || []).find(g => g.id === 'economy');
if (economy && !groupOf.industry) groupOf.industry = economy;
const groups = [];
const byId = {};
visibleModules.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));
const on = visibleModules.filter(m => moduleStates[`${m.name}Mod`] === 'on').length;
const blocked = opts.blocked && visibleModules.find(m => `${m.name}Mod` === opts.blocked.mod);
const blockedBox = blocked ? div({ class: 'module-blocked' },
iconNode('module-blocked-ico', blocked.name),
p({ class: 'module-blocked-text' }, String(i18n.moduleBlockedText || '{name} is turned off.').replace('{name}', blocked.label)),
form({ method: 'POST', action: '/modules/enable' },
input({ type: 'hidden', name: 'mod', value: blocked.name }),
input({ type: 'hidden', name: 'returnTo', value: opts.blocked.path || `/${blocked.name}` }),
button({ type: 'submit', class: 'create-button' }, String(i18n.moduleBlockedAction || 'Turn on {name}').replace('{name}', blocked.label))
)
) : null;
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}', on).replace('{total}', visibleModules.length))
)),
blockedBox,
opts.saved ? p({ class: 'module-saved', role: 'status' }, i18n.modulesSaved || 'Saved.') : null,
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: moduleStates[`${mod.name}Mod`] === 'on' ? true : undefined
})
))
)),
div({ class: 'module-save-bar' },
button({ type: 'submit', class: 'create-button' }, i18n.saveSettings)
)
)
)
);
};
const modulesView = (_states, opts = {}) => {
const config = getConfig().modules;
const modules = [
{ name: 'agenda', label: i18n.modulesAgendaLabel, description: i18n.modulesAgendaDescription },
@ -76,6 +141,8 @@ const modulesView = () => {
return acc;
}, {});
if (isMobile) return mobileModulesView(visibleModules, moduleStates, opts);
const activeModulesCount = visibleModules.filter(mod => moduleStates[`${mod.name}Mod`] === 'on').length;
const totalModulesCount = visibleModules.length;
const disabledModulesCount = totalModulesCount - activeModulesCount;

View file

@ -139,6 +139,15 @@ const settingsView = (params) => {
);
}
// movil: primero lo que se toca (idioma, aspecto, tipo de uso, portada) y despues
// lo tecnico (identidades, red, indices...). Se reordena por id, sin tocar las secciones
const MOBILE_FIRST = ['update', 'language', 'theme', 'workflows', 'home-page'];
const mobileOrder = (parts) => {
const list = parts.filter(Boolean);
const rank = (el) => { const i = MOBILE_FIRST.indexOf(el && el.id); return i === -1 ? MOBILE_FIRST.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);
};
const filter = params.filter === 'multiverse' && modOn('fediverse') ? 'multiverse' : params.filter === 'content' ? 'content' : 'oasis';
const oasisParts = [
updatePanel,
@ -147,7 +156,13 @@ const settingsView = (params) => {
div({ class: "tags-header" },
h2(i18n.language),
p(i18n.languageDescription),
form(
// movil: cada idioma es un boton que se aplica al tocarlo (antes desplegable,
// elegir y otro boton mas abajo)
isMobile ? form({ action: "/language", method: "post", class: "lang-pick" },
...[["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"]]
.map(([name, code]) => button({ type: "submit", name: "language", value: code, class: code === selectedLanguage ? "lang-chip is-current" : "lang-chip", lang: code }, name))
) : form(
{ action: "/language", method: "post" },
select({ name: "language" }, [
languageOption("English", "en"),
@ -674,7 +689,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 : oasisParts)
...(filter === 'multiverse' ? multiverseParts : filter === 'content' ? contentParts : (isMobile ? mobileOrder(oasisParts) : oasisParts))
);
};

View file

@ -1,8 +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 { 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, details, summary } = 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, renderSubscriptionBox, renderModuleStats, renderModuleStatsBy, moduleIsEmpty, renderTorrentDownload, torrentDownloadHref, contentDeleteAction, clearnetSlugFor, renderEngagement, renderOpinionsVoting, renderVotesSummary, paged, CLEARNET_HUB_CSS } = require('./main_views');
const { renderTribeWikiSection } = require('./wiki_view');
const { iconNode } = require('./mobile_icons');
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');
@ -286,7 +287,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(i18n.mapLocationTitle || 'Map Location'),
label(process.env.OASIS_MOBILE === '1' ? (i18n.tribeMapUrlLabel || 'Map link (optional)') : (i18n.mapLocationTitle || 'Map Location')),
br,
input({ type: 'text', name: 'mapUrl', placeholder: i18n.mapUrlPlaceholder || '/maps/MAP_ID', value: tribeToEdit.mapUrl || '' }),
br,
@ -304,6 +305,7 @@ 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 && process.env.OASIS_MOBILE === '1' ? p({ class: 'form-hint' }, i18n.tribeHintPrivacy || 'Private: only members see it, and it is encrypted. Public: the whole network sees it.') : null,
isSubEdit ? null : br(),
isSubEdit ? null : br(),
label({ for: 'inviteMode' }, i18n.tribeModeLabel),
@ -312,6 +314,7 @@ 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)
),
process.env.OASIS_MOBILE === '1' ? p({ class: 'form-hint' }, i18n.tribeHintMode || 'Open: any member can invite people. Strict: only whoever created it can invite.') : null,
br(), br(),
button({ type: 'submit' }, isEdit ? i18n.tribeUpdateButton : i18n.tribeCreateButton)
)
@ -321,7 +324,32 @@ exports.tribesView = async (tribes, filter, tribeId, query = {}, allTribes = nul
const shownTribes = filter === 'create' || filter === 'edit' ? [] : paged(sorted);
// en el movil, 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, y borrar/compartir estan dentro de la tribu
const tribeRow = (t) => {
const href = `/tribe/${encodeURIComponent(t.id)}`;
const members = t.memberCount != null ? t.memberCount : (t.members || []).length;
const facts = [
t.isAnonymous ? i18n.tribePrivate : i18n.tribePublic,
t.inviteMode === 'open' ? i18n.tribeOpen : i18n.tribeStrict,
`${members} ${String(i18n.tribeMembersCount || '').toLowerCase()}`
].filter(Boolean).join(' · ');
return a({ href, class: 'tribe-row' + (String(t.author) === String(userId) ? ' own-content' : '') },
t.image
? renderMediaBlob(t.image, null, { class: 'tribe-row-thumb' })
: iconNode('tribe-row-thumb tribe-row-icon', 'tribes'),
span({ class: 'tribe-row-text' },
span({ class: 'tribe-row-title' }, t.title),
span({ class: 'tribe-row-facts' }, facts),
t.description ? span({ class: 'tribe-row-desc' }, String(t.description).slice(0, 90)) : null
),
span({ class: 'tribe-row-go', 'aria-hidden': 'true' }, '›')
);
};
const tribeCards = shownTribes.map(t => {
if (process.env.OASIS_MOBILE === '1') return tribeRow(t);
const parentTribe = t.parentTribeId ? allT.find(p => p.id === t.parentTribeId) : null;
const isOwn = String(t.author) === String(userId);
@ -376,7 +404,7 @@ exports.tribesView = async (tribes, filter, tribeId, query = {}, allTribes = nul
: filter === 'gallery'
? renderGallery(shownTribes)
: tribeCards.length > 0
? div({ class: 'tribe-grid' }, tribeCards)
? div({ class: process.env.OASIS_MOBILE === '1' ? 'tribe-list' : 'tribe-grid' }, tribeCards)
: div({ class: "no-content-box" }, p(i18n.noTribes))
),
...renderLightbox(shownTribes)
@ -409,6 +437,23 @@ 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 }] },
];
// movil: 25 botones a todo lo ancho eran varias pantallas antes del contenido.
// Una tira de fichas con lo que mas usa una comision y el resto en "Mas"
if (process.env.OASIS_MOBILE === '1') {
const all = sections.flatMap(g => g.items);
const MAIN = ['activity', 'inhabitants', 'governance', 'votations', 'forum', 'chats', 'events', 'pads'];
const tab = (s) => a({ href: `/tribe/${encodeURIComponent(tribe.id)}?section=${encodeURIComponent(s.key)}`, class: section === s.key ? 'tribe-tab is-current' : 'tribe-tab', ...(section === s.key ? { 'aria-current': 'page' } : {}) }, s.label);
const main = all.filter(s => MAIN.includes(s.key));
const rest = all.filter(s => !MAIN.includes(s.key));
const currentRest = rest.find(s => s.key === section);
return nav({ 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))
)
);
}
return div({ class: 'tribe-section-nav no-border' },
sections.map(g =>
div({ class: 'tribe-section-group no-border' },

View file

@ -1,4 +1,4 @@
const { div, h2, p, section, button, form, a, textarea, 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, progress } = require("../server/node_modules/hyperaxe");
const { renderCommentsSection: renderSharedCommentsSection } = require("./comments_view");
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");
@ -7,6 +7,15 @@ const { renderTribeOriginChip } = require("./clearnet_view");
const userId = config.keys.id;
const VOTE_QUORUM = 2;
const isMobile = process.env.OASIS_MOBILE === "1";
// tipos de votacion en el movil: el servidor ya guarda las opciones que se le
// pasen (campo options), 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 safeArray = (v) => Array.isArray(v) ? v : [];
@ -21,7 +30,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" ? "yes" : winner === "NO" ? "no" : "pending";
const variant = (winner === "YES" || winner === "GREEN") ? "yes" : (winner === "NO" || winner === "RED") ? "no" : "pending";
return { text: voteLabel(winner), variant };
};
@ -157,6 +166,22 @@ exports.renderVoteOptionsBlock = (v, voteOptions, action, returnTo, canVote) =>
];
};
// en el movil los resultados son barras, una por opcion: la tabla de 6 columnas
// no cabia y partia las palabras por silabas
const renderVoteBars = (voteOptions, baseCounts) => {
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) })
);
})
);
};
const renderVoteDetail = (v, voteOptionsDefault, firstRow, secondRow, mode, activeFilter, params = {}) => {
const voteOptions = Array.isArray(v.options) && v.options.length ? v.options : voteOptionsDefault;
const baseCounts = voteOptions.reduce((acc, opt) => {
@ -212,7 +237,12 @@ const renderVoteDetail = (v, voteOptionsDefault, firstRow, secondRow, mode, acti
const voteButtonsNode = renderVoteButtons(v, voteOptions, firstRow, secondRow, returnTo);
const ownOpenNote = isMobile && !voteButtonsNode && isVoteAuthor(v) && normalizeStatus(v.status) === "OPEN"
? p({ class: "vote-own-note" }, i18n.voteOwnNote || "You created this vote, so you cannot vote in it.")
: null;
const voteMain = div({ class: "tribe-main" },
ownOpenNote,
voteButtonsNode
? div({ class: "job-section" },
h2({ class: "job-section-title" }, i18n.voteCastTitle || "Cast Vote"),
@ -224,7 +254,7 @@ const renderVoteDetail = (v, voteOptionsDefault, firstRow, secondRow, mode, acti
`${i18n.voteResults || "Results"}: `,
span({ class: `vote-outcome vote-outcome-${outcome.variant}` }, outcome.text)
),
div({ class: "vote-table" },
isMobile ? renderVoteBars(voteOptions, baseCounts) : div({ class: "vote-table" },
table(
tr(...voteOptions.map((opt) => th(voteLabel(opt)))),
tr(...voteOptions.map((opt) => td(baseCounts[opt])))
@ -247,6 +277,22 @@ const renderCommentsSection = (voteId, comments, activeFilter) => {
});
};
const renderVoteKindPicker = (current) => {
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] || "")
);
})
);
};
exports.voteView = async (votes, mode, voteId, comments = [], activeFilterParam, params = {}) => {
const list = Array.isArray(votes) ? votes : [votes];
@ -351,6 +397,7 @@ 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(),
isMobile && mode === "create" ? renderVoteKindPicker(draft.options) : null,
button({ type: "submit" }, mode === "edit" ? i18n.voteUpdateButton : i18n.voteCreateButton)
)
)

View file

@ -104,6 +104,15 @@ exports.welcomeView = async (status, profile = {}) => {
const steps = status.steps || {}
const usable = status.usable || []
const opened = usable.find(key => !steps[key])
// en el movil "Ahora no" va al final y discreto: arriba y a todo lo ancho era lo
// primero que se tocaba y la gente se saltaba la bienvenida entera
const mobile = process.env.OASIS_MOBILE === "1"
const skipForm = status.pending
? form({ method: "POST", action: "/welcome/dismiss", class: mobile ? "welcome-action welcome-skip-end" : "welcome-action" },
input({ type: "hidden", name: "returnTo", value: "/" }),
button({ type: "submit", class: mobile ? "welcome-skip-btn" : undefined }, i18n.welcomeSkip)
)
: null
const html = template(
i18n.welcomeTitle,
section(
@ -111,12 +120,7 @@ exports.welcomeView = async (status, profile = {}) => {
h2(i18n.welcomeTitle),
p(i18n.welcomeDescription)
),
status.pending
? form({ method: "POST", action: "/welcome/dismiss", class: "welcome-action" },
input({ type: "hidden", name: "returnTo", value: "/" }),
button({ type: "submit" }, i18n.welcomeSkip)
)
: null
mobile ? null : skipForm
),
status.complete
? section(
@ -135,7 +139,8 @@ exports.welcomeView = async (status, profile = {}) => {
),
stepAction(key, profile || {}, status)
)
))
)),
mobile && skipForm ? section(skipForm) : null
)
return status.bootstrap && status.bootstrap.running ? html.replace('</head>', '<meta http-equiv="refresh" content="5"></head>') : html
}