diff --git a/src/backend/backend.js b/src/backend/backend.js index 2b9ac3a..56e4430 100644 --- a/src/backend/backend.js +++ b/src/backend/backend.js @@ -370,7 +370,21 @@ const refreshWalletReady = async (force = false) => { } catch (_) { return ready; } }; +// en el movil, al abrir una seccion apagada se avisa en /modules de cual era y se +// ofrece activarla (antes era un salto mudo a la tabla). La app es de una persona, +// asi que basta con recordar el ultimo bloqueo unos segundos +let lastBlockedMod = null; const checkMod = (ctx, mod) => { + const ok = checkModRaw(ctx, mod); + if (!ok && process.env.OASIS_MOBILE === '1' && ctx.method === 'GET') lastBlockedMod = { mod, path: ctx.path, at: Date.now() }; + return ok; +}; +const takeBlockedMod = () => { + const b = lastBlockedMod && Date.now() - lastBlockedMod.at < 15000 ? lastBlockedMod : null; + lastBlockedMod = null; + return b; +}; +const checkModRaw = (ctx, mod) => { const cfg = getConfig(); const serverValue = cfg.modules?.[mod]; if (serverValue === 'off') return false; @@ -4677,7 +4691,18 @@ router .get("/modules", async (ctx) => { const modules = ['blogs', 'polls', 'karvan', 'fediverse', 'invites', 'wallet', 'backup', 'dev', 'cipher', 'bookmarks', 'calendars', 'chats', 'videos', 'docs', 'audios', 'tags', 'images', 'maps', 'trending', 'events', 'tasks', 'market', 'tribes', 'larp', 'votes', 'reports', 'opinions', 'pads', 'transfers', 'feed', 'pixelia', 'melody', 'agenda', 'favorites', 'ai', 'forum', 'games', 'housing', 'jobs', 'projects', 'industry', 'shops', 'banking', 'school', 'parliament', 'courts']; const cfg = getConfig().modules; - ctx.body = modulesView(modules.reduce((acc, m) => { acc[`${m}Mod`] = cfg[`${m}Mod`]; return acc; }, {})); + ctx.body = modulesView(modules.reduce((acc, m) => { acc[`${m}Mod`] = cfg[`${m}Mod`]; return acc; }, {}), { blocked: takeBlockedMod(), saved: ctx.query.saved === '1' }); + }) + // activar un solo modulo desde el aviso de /modules y volver a donde se queria ir + .post("/modules/enable", koaBody(), async (ctx) => { + if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; } + const mod = String(ctx.request.body.mod || '').replace(/Mod$/, ''); + if (!workflowsModel.ALL_MODULES.includes(mod)) { ctx.redirect('/modules'); return; } + const cfg = getConfig(); + cfg.modules[`${mod}Mod`] = 'on'; + saveConfig(cfg); + const back = String(ctx.request.body.returnTo || ''); + ctx.redirect(isLocalPath(back) ? back : `/${mod}`); }) .get('/ai', async (ctx) => { if (!checkMod(ctx, 'aiMod')) return ctx.redirect('/modules'); @@ -15198,7 +15223,7 @@ router MOBILE_UNAVAILABLE_MODS.forEach(mod => cfg.modules[`${mod}Mod`] = 'off'); } saveConfig(cfg); - ctx.redirect(`/modules`); + ctx.redirect(process.env.OASIS_MOBILE === '1' ? `/modules?saved=1` : `/modules`); }) .post("/settings/ai", koaBody(), async (ctx) => { const aiPrompt = String(ctx.request.body.ai_prompt || "").trim(); diff --git a/src/client/assets/styles/mobile_ux.css b/src/client/assets/styles/mobile_ux.css new file mode 100644 index 0000000..1a3369c --- /dev/null +++ b/src/client/assets/styles/mobile_ux.css @@ -0,0 +1,413 @@ +/* Piezas de usabilidad de la rama movil, para los dos skins (Acampada y OasisMobile). + * + * Va la ultima, detras de karvan.css. Los colores salen de las variables de + * Acampada (--a26-*) si el tema las define y, si no, de la paleta oscura con oro + * de OasisMobile; asi cada pieza nueva se ve bien en los dos sin duplicarla. + * + * Letra: Anton (--ux-title) solo para titulos y para el boton principal de cada + * pantalla; todo lo demas (fichas, botones secundarios, etiquetas) en la letra del + * sistema en negrita. Antes habia Anton en botones, fichas, cabeceras de tabla y + * pestañas, y la pantalla parecia un cartel. + * + * Espacios: rejilla de 4/8. Zonas tactiles de 44px como minimo. */ + +:root { + --ux-bg: var(--a26-bg, #121212); + --ux-fg: var(--a26-fg, #f2f2f2); + --ux-accent: var(--a26-neon, #ffd700); + --ux-on-accent: #000; + --ux-soft: var(--a26-soft, #1e1e1e); + --ux-muted: var(--a26-muted, #a8a8a8); + --ux-line: var(--a26-fg, #3a3a3a); + --ux-radius: var(--a26-radius, 12px); + --ux-title: var(--a26-tit, inherit); + --ux-text: var(--a26-txt, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif); + --ux-danger: #c4003b; + --ux-ok: #0a8f3c; + --ux-warn: #c98a00; +} + +/* ---------- letra unificada ---------- */ +/* fichas de filtro, pestañas y botones secundarios: letra del sistema */ +.main-column :is(.tribe-mode-buttons, .mode-buttons, .mode-buttons-row, .filter-group, .filters) .filter-btn:not(.create-button), +.main-column .filter-box__button, .main-column .doc-export-actions .filter-btn, +.main-column .update-btn, .main-column .tribe-action-btn, .lang-chip, .kc-btn, +.main-column table th, .card-chips-row > span, .pm-exposition-text { + font-family: var(--ux-text) !important; + font-weight: 700 !important; + letter-spacing: .02em !important; +} +.main-column :is(.tribe-mode-buttons, .mode-buttons, .mode-buttons-row, .filter-group, .filters) .filter-btn:not(.create-button) { + font-size: 15px !important; + text-transform: uppercase; +} +/* los formularios de cada modulo (crear tribu, votacion, sala...): etiquetas como + texto, no como titulos; el h2 de la pregunta de una votacion era Anton de 28px */ +.main-column .vote-form h2, .main-column form > label:not(.module-switch):not(.vote-kind-option), .main-column form label[for]:not(.module-switch) { + font-family: var(--ux-text) !important; + font-size: 15px !important; + font-weight: 700 !important; + text-transform: none !important; + letter-spacing: 0 !important; + color: var(--ux-fg) !important; +} +.form-hint { + margin: 6px 0 14px !important; + font-size: 14px !important; + line-height: 1.4; + color: var(--ux-muted) !important; +} + +/* el boton de enviar de los formularios: a todo lo ancho, abajo, que se vea que es + el final. Antes unos centrados, otros a la izquierda y otros enteros */ +.main-column :is(.vote-form, .tribe-form, .karvan-create, .karvan-compose, .karvan-invite) form > button[type="submit"], +.main-column :is(.vote-form, .karvan-create, .karvan-compose, .karvan-invite) > form > button[type="submit"], +.main-column form[action$="/tribes/create"][method="post"] > button[type="submit"], +.main-column form[action*="/tribes/update/"] > button[type="submit"] { + display: block !important; + width: 100% !important; + margin: 8px 0 0 !important; +} + +/* ---------- iconos SVG (lanzador, barra, modulos, Karvan) ---------- */ +.has-svg { display: inline-flex !important; align-items: center; justify-content: center; } +.has-svg svg { width: 100%; height: 100%; display: block; } +.ln-ico.has-svg { padding: 15px !important; } +.ln-ico.has-svg svg { width: 30px; height: 30px; } +.oasis-bottombar .bb-ico.has-svg { + width: 56px !important; height: 30px !important; padding: 3px 0 !important; + border-radius: 16px !important; font-size: 0 !important; +} +.oasis-bottombar .bb-ico.has-svg svg { width: 24px; height: 24px; } +.bb-manage-ico.has-svg, .bb-pick-ico.has-svg { width: 24px; height: 24px; } + +/* ---------- barra de abajo: la pestaña actual con pastilla, como en Android ---------- */ +.oasis-bottombar a.bb-item.current .bb-ico.has-svg { background: var(--ux-accent) !important; color: var(--ux-on-accent) !important; } +.oasis-bottombar .bb-lbl { font-family: var(--ux-text) !important; } +/* con el teclado abierto la ventana encoge: la barra se esconde y el formulario + recupera ese tercio de pantalla */ +@media (max-height: 520px) { + .oasis-bottombar { display: none !important; } + body { padding-bottom: 0 !important; } +} + +/* ---------- aviso de bienvenida: una linea, no un bloque ---------- */ +.welcome-banner { + display: flex !important; align-items: center !important; gap: 10px !important; + padding: 8px 8px 8px 14px !important; min-height: 0 !important; +} +.welcome-banner .update-banner-icon { display: none !important; } +.welcome-banner .update-banner-text { flex: 1 1 auto; font-size: 15px !important; line-height: 1.3 !important; margin: 0 !important; } +.welcome-banner .welcome-banner-start { + flex: 0 0 auto; font-size: 16px !important; min-height: 40px !important; + padding: 6px 14px !important; margin: 0 !important; width: auto !important; +} +.welcome-banner .welcome-banner-close { margin: 0 !important; flex: 0 0 auto; } +.welcome-banner .welcome-banner-close-btn { min-height: 40px !important; width: 36px !important; margin: 0 !important; } + +/* ---------- pie de una linea ---------- */ +.oasis-footer.oasis-footer-mobile { + display: flex !important; flex-direction: column; align-items: center; gap: 4px; + padding: 14px 16px 18px !important; margin-top: 24px !important; text-align: center; + font-size: 13px !important; +} +.oasis-footer-mobile .footer-sync { + display: inline-flex; align-items: center; gap: 8px; min-height: 36px; + font-weight: 700; text-decoration: none !important; color: var(--ux-fg) !important; +} +.oasis-footer-mobile .footer-sync-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--ux-muted); } +.oasis-footer-mobile .footer-sync.is-on .footer-sync-dot { background: var(--ux-ok); } +.oasis-footer-mobile .footer-version { color: var(--ux-muted) !important; } + +/* ---------- acciones de tarjeta: borrar con confirmacion ---------- */ +.content-action-confirm { display: inline-flex; flex-wrap: wrap; justify-content: flex-end; } +.content-action-confirm > summary { list-style: none; cursor: pointer; } +.content-action-confirm > summary::-webkit-details-marker { display: none; } +.content-action-confirm[open] { flex-basis: 100%; order: 99; } +.content-action-confirm[open] > summary { background: var(--ux-danger) !important; color: #fff !important; border-color: var(--ux-danger) !important; } +.content-action-confirm .confirm-bar { + flex-basis: 100%; display: flex; align-items: center; justify-content: flex-end; gap: 10px; + margin-top: 8px; padding: 8px 8px 8px 12px; border: 2px solid var(--ux-danger); border-radius: var(--ux-radius); +} +.content-action-confirm .confirm-question { flex: 1 1 auto; font-weight: 700; font-size: 15px; text-align: left; } +.content-action-confirm form { margin: 0 !important; width: auto !important; } +.main-column .content-action-confirm .confirm-yes, .content-action-confirm .confirm-yes { + background: var(--ux-danger) !important; color: #fff !important; border-color: var(--ux-danger) !important; + box-shadow: none !important; min-height: 44px !important; margin: 0 !important; width: auto !important; + font-family: var(--ux-text) !important; font-weight: 700 !important; font-size: 15px !important; +} + +/* ---------- lista de tribus: una fila por tribu ---------- */ +.main-column .tribe-list { display: flex !important; flex-direction: column; gap: 10px; padding: 0 !important; } +.main-column a.tribe-row { + display: flex !important; flex-wrap: nowrap !important; align-items: center; gap: 12px; width: auto !important; + padding: 10px 12px !important; min-height: 72px; + border: 2px solid var(--ux-line) !important; border-radius: var(--ux-radius) !important; + background: var(--ux-bg) !important; color: var(--ux-fg) !important; text-decoration: none !important; +} +.main-column a.tribe-row:active { background: var(--ux-accent) !important; color: var(--ux-on-accent) !important; } +.tribe-row .tribe-row-thumb { + flex: 0 0 52px; width: 52px !important; height: 52px !important; border-radius: 10px !important; + object-fit: cover; border: 2px solid var(--ux-line) !important; margin: 0 !important; +} +.tribe-row .tribe-row-icon { padding: 11px; background: var(--ux-soft); } +.tribe-row .tribe-row-text { flex: 1 1 0; min-width: 0; overflow: hidden; display: flex; flex-direction: column; gap: 2px; } +.tribe-row .tribe-row-text > span { display: block; max-width: 100%; margin: 0 !important; padding: 0 !important; line-height: 1.3; } +.tribe-row .tribe-row-title { + font-family: var(--ux-title) !important; font-size: 21px; line-height: 1.1; text-transform: uppercase; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.tribe-row .tribe-row-facts { font-size: 13px; font-weight: 700; color: var(--ux-muted); } +.tribe-row .tribe-row-desc { font-size: 14px; color: var(--ux-fg); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.tribe-row .tribe-row-go { flex: 0 0 auto; font-size: 28px; line-height: 1; color: var(--ux-muted); } + +/* ---------- votaciones ---------- */ +.vote-bars { display: flex; flex-direction: column; gap: 10px; margin: 8px 0 4px; } +.vote-bar { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 4px 10px; } +.vote-bar-label { font-weight: 700; font-size: 15px; } +.vote-bar-count { font-weight: 700; font-size: 15px; min-width: 2ch; text-align: right; } +.vote-bar-meter { + grid-column: 1 / -1; width: 100%; height: 12px; appearance: none; -webkit-appearance: none; + border: 2px solid var(--ux-line); border-radius: 8px; overflow: hidden; background: var(--ux-soft); +} +.vote-bar-meter::-webkit-progress-bar { background: var(--ux-soft); } +.vote-bar-meter::-webkit-progress-value { background: var(--ux-fg); } +.vote-bar-meter::-moz-progress-bar { background: var(--ux-fg); } +.vote-bar-yes .vote-bar-meter::-webkit-progress-value, .vote-bar-green .vote-bar-meter::-webkit-progress-value { background: var(--ux-ok); } +.vote-bar-no .vote-bar-meter::-webkit-progress-value, .vote-bar-red .vote-bar-meter::-webkit-progress-value { background: var(--ux-danger); } +.vote-bar-yellow .vote-bar-meter::-webkit-progress-value { background: var(--ux-warn); } +.vote-own-note { + padding: 12px 14px !important; border-left: 4px solid var(--ux-accent) !important; + background: var(--ux-soft) !important; border-radius: 0 10px 10px 0; font-size: 15px !important; +} +/* botones de votar: rejilla de dos columnas, cada opcion un boton grande */ +.main-column .vote-buttons-row-single { display: grid !important; grid-template-columns: 1fr 1fr; gap: 10px !important; } +.main-column .vote-buttons-row-single form { margin: 0 !important; width: 100% !important; } +.main-column .vote-buttons-row-single button { + width: 100% !important; min-height: 52px !important; margin: 0 !important; + font-family: var(--ux-text) !important; font-weight: 700 !important; font-size: 16px !important; +} +.main-column .vote-buttons-row-single button[value="YES"], .main-column .vote-buttons-row-single button[value="GREEN"] { border-color: var(--ux-ok) !important; box-shadow: 0 4px 0 var(--ux-ok) !important; } +.main-column .vote-buttons-row-single button[value="NO"], .main-column .vote-buttons-row-single button[value="RED"] { border-color: var(--ux-danger) !important; box-shadow: 0 4px 0 var(--ux-danger) !important; } +.main-column .vote-buttons-row-single button[value="YELLOW"] { border-color: var(--ux-warn) !important; box-shadow: 0 4px 0 var(--ux-warn) !important; } +/* tipo de votacion: tarjetas de radio */ +.vote-kind { display: flex; flex-direction: column; gap: 10px; margin: 4px 0 18px; } +.vote-kind-title { font-weight: 700; font-size: 15px; } +.vote-kind-option { + display: grid !important; grid-template-columns: auto 1fr; gap: 2px 12px; align-items: center; + padding: 12px 14px; border: 2px solid var(--ux-line); border-radius: var(--ux-radius); cursor: pointer; +} +.main-column .vote-kind-option input[type="radio"] { grid-row: 1 / span 2; width: 22px !important; height: 22px !important; min-height: 0 !important; margin: 0 !important; accent-color: var(--ux-fg); } +.vote-kind-option:has(input:checked) { background: var(--ux-accent); color: var(--ux-on-accent); } +.vote-kind-option:has(input:checked) .vote-kind-hint { color: var(--ux-on-accent); } +.vote-kind-name { font-weight: 700; font-size: 17px; } +.vote-kind-hint { font-size: 14px; color: var(--ux-muted); } + +/* ---------- modulos: interruptores agrupados ---------- */ +.module-switches { display: flex; flex-direction: column; gap: 22px; padding-bottom: 8px; } +.module-group { display: flex; flex-direction: column; gap: 8px; } +.module-group-title { + font-family: var(--ux-title) !important; font-weight: 400 !important; font-size: 20px !important; + text-transform: uppercase; margin: 0 0 4px !important; padding-bottom: 4px; border-bottom: 3px solid var(--ux-line); +} +.main-column label.module-switch { + display: grid !important; grid-template-columns: 40px minmax(0, 1fr) 52px; align-items: center; gap: 12px; + padding: 10px 12px; min-height: 64px; cursor: pointer; + border: 2px solid var(--ux-line); border-radius: var(--ux-radius); + font-family: var(--ux-text) !important; text-transform: none !important; letter-spacing: 0 !important; +} +.module-switch-ico { width: 40px; height: 40px; padding: 8px; border-radius: 10px; background: var(--ux-soft); color: var(--ux-fg); } +.module-switch-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; } +.module-switch-name { font-weight: 700; font-size: 17px; } +.module-switch-desc { font-size: 13px; color: var(--ux-muted); line-height: 1.3; } +.module-switch:has(.switch-input:checked) { border-color: var(--ux-fg); } +/* el interruptor: la casilla de siempre con forma de pastilla */ +.main-column label.module-switch input.switch-input[type="checkbox"] { + appearance: none; -webkit-appearance: none; flex: 0 0 auto; + width: 52px !important; height: 30px !important; min-height: 0 !important; margin: 0 !important; padding: 0 !important; + border: 2px solid var(--ux-line) !important; border-radius: 16px !important; background: var(--ux-soft) !important; + position: relative; cursor: pointer; transition: background .15s ease-out; +} +.main-column label.module-switch input.switch-input[type="checkbox"]::after { + content: ""; position: absolute; top: 3px; left: 3px; width: 20px; height: 20px; border-radius: 50%; + background: var(--ux-muted); transition: transform .15s ease-out, background .15s ease-out; +} +.main-column label.module-switch input.switch-input[type="checkbox"]:checked { background: var(--ux-accent) !important; } +.main-column label.module-switch input.switch-input[type="checkbox"]:checked::after { transform: translateX(22px); background: var(--ux-on-accent); } +/* Guardar siempre a la vista, encima de la barra de abajo */ +.module-save-bar { + position: sticky; bottom: calc(76px + env(safe-area-inset-bottom, 0px)); z-index: 5; + padding: 10px 0; background: var(--ux-bg); +} +.module-save-bar .create-button { width: 100% !important; margin: 0 !important; } +.module-saved { + padding: 10px 14px !important; margin: 0 0 14px !important; font-weight: 700; + border-left: 4px solid var(--ux-ok) !important; background: var(--ux-soft) !important; border-radius: 0 10px 10px 0; +} +/* la seccion apagada a la que se queria entrar */ +.main-column div.module-blocked { + display: grid; grid-template-columns: 48px 1fr; gap: 12px; align-items: center; + padding: 14px; margin: 0 0 20px; border: 3px solid var(--ux-line); border-radius: var(--ux-radius); background: var(--ux-soft); +} +.module-blocked-ico { width: 48px; height: 48px; padding: 10px; border-radius: 12px; background: var(--ux-bg); } +.module-blocked-text { margin: 0 !important; font-weight: 700; font-size: 17px; } +.module-blocked form { grid-column: 1 / -1; margin: 0 !important; } +.module-blocked .create-button { width: 100% !important; margin: 0 !important; } + +/* ---------- Ajustes: idiomas como fichas de un toque ---------- */ +.lang-pick { display: grid !important; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 12px; } +.main-column .lang-pick .lang-chip, .lang-pick .lang-chip { + width: 100% !important; min-height: 48px !important; margin: 0 !important; padding: 6px 10px !important; + font-size: 16px !important; text-transform: none !important; box-shadow: none !important; + border: 2px solid var(--ux-line) !important; background: var(--ux-bg) !important; color: var(--ux-fg) !important; +} +.main-column .lang-pick .lang-chip.is-current, .lang-pick .lang-chip.is-current { background: var(--ux-accent) !important; color: var(--ux-on-accent) !important; } + +/* ---------- Karvan: barra de llamada con nombre en cada boton ---------- */ +.karvan-call-bar { display: grid !important; grid-template-columns: repeat(4, 1fr); gap: 8px !important; } +.main-column .karvan-call-bar .kc-btn, .karvan-call-bar .kc-btn { + display: flex !important; flex-direction: column; align-items: center; justify-content: center; gap: 4px; + width: 100% !important; min-height: 64px !important; padding: 8px 4px !important; margin: 0 !important; + font-size: 13px !important; text-transform: none !important; box-shadow: none !important; +} +.kc-btn .kc-ico { width: 24px; height: 24px; } +.kc-btn .kc-lbl { line-height: 1.1; } +.main-column .karvan-call-bar .kc-btn-start { background: var(--ux-ok) !important; color: #fff !important; border-color: var(--ux-ok) !important; } +.main-column .karvan-call-bar .kc-btn.on { background: var(--ux-accent) !important; color: var(--ux-on-accent) !important; } +.main-column .karvan-call-bar .kc-btn-stop { color: var(--ux-danger) !important; border-color: var(--ux-danger) !important; } +.kc-status:not(:empty) { margin-top: 10px; padding: 8px 12px; border-radius: 10px; background: var(--ux-soft); font-size: 14px; font-weight: 700; } +.karvan-invite { margin-top: 24px !important; padding-top: 16px !important; border-top: 2px solid var(--ux-line) !important; } +.karvan-invite-lbl { display: block; margin-bottom: 6px; font-weight: 700; } + +/* ---------- foco visible para teclado y lectores ---------- */ +a:focus-visible, button:focus-visible, summary:focus-visible, label.module-switch:focus-within { + outline: 3px solid var(--ux-accent) !important; outline-offset: 2px; +} + +@media (prefers-reduced-motion: reduce) { + .main-column label.module-switch input.switch-input[type="checkbox"], .main-column label.module-switch input.switch-input[type="checkbox"]::after { transition: none; } +} + +/* ---------- buscador de cada modulo: campo y lupa en la misma linea ---------- */ +/* solo cuando el unico control es el boton: los de Transferencias llevan rangos y + desplegables y siguen en columna */ +.main-column form.filter-box:not(:has(.filter-box__select, .filter-box__number, .transfer-range)) { + display: flex !important; flex-direction: row !important; flex-wrap: nowrap !important; + align-items: stretch !important; gap: 8px !important; width: 100% !important; margin: 0 0 12px !important; +} +.main-column form.filter-box:not(:has(.filter-box__select, .filter-box__number, .transfer-range)) .filter-box__input { + flex: 1 1 auto !important; min-width: 0 !important; width: 0 !important; height: 48px !important; margin: 0 !important; +} +.main-column form.filter-box:not(:has(.filter-box__select, .filter-box__number, .transfer-range)) .filter-box__controls { + flex: 0 0 auto !important; width: auto !important; margin: 0 !important; +} +.main-column form.filter-box .filter-box__button { min-height: 48px !important; margin: 0 !important; } +/* el recuento (TOTAL: 12 · PUBLICA: 12) como una linea discreta, no un bloque */ +.main-column .activity-toolbar-row { display: flex !important; flex-direction: column !important; align-items: stretch !important; gap: 6px !important; } +.main-column .module-stats, .main-column [class*="module-stats"] { font-size: 13px !important; color: var(--ux-muted) !important; text-align: left !important; margin: 0 !important; } + +/* el boton de crear de cada modulo a todo lo ancho, tambien cuando va solo en su + formulario (en Votaciones salia estrecho y en Tribus entero) */ +.main-column .filters > form:has(.create-button), .main-column .mode-buttons > form:has(.create-button) { width: 100% !important; } + +/* jerga fuera del movil: la tasa ECO (microgramos de CO2) no se entendia y su ficha + salia ilegible; sigue en Estadisticas */ +.main-column .eco-tax-chip, .main-column [class*="eco-tax-chip"] { display: none !important; } +/* etiquetas (#asamblea) sin caja negra detras */ +.main-column .card-tags, .main-column .tribe-side .card-tags { background: transparent !important; padding: 0 !important; margin: 8px 0 !important; } + +/* acciones secundarias de una ficha (actualizar, PDF, compartir): una rejilla de + dos columnas, sin los huecos de 80px que dejaban los formularios sueltos */ +.main-column :is(.tribe-side-actions, .doc-export-actions) { + display: grid !important; grid-template-columns: 1fr 1fr; gap: 8px !important; margin: 12px 0 !important; padding: 0 !important; +} +.main-column :is(.tribe-side-actions, .doc-export-actions) form { margin: 0 !important; width: 100% !important; padding: 0 !important; } +.main-column :is(.tribe-side-actions, .doc-export-actions) :is(button, .filter-btn, a) { + width: 100% !important; min-height: 44px !important; margin: 0 !important; padding: 6px 8px !important; + font-family: var(--ux-text) !important; font-weight: 700 !important; font-size: 14px !important; box-shadow: none !important; +} +.main-column .tribe-side-actions + .doc-export-actions { margin-top: 0 !important; } +/* resultado de la votacion: ficha de color legible en los dos skins */ +.main-column .vote-outcome { + display: inline-block; padding: 2px 10px !important; border-radius: 8px; + font-family: var(--ux-text) !important; font-size: 15px !important; font-weight: 700 !important; + background: var(--ux-soft) !important; color: var(--ux-fg) !important; vertical-align: middle; +} +.main-column .vote-outcome-yes { background: var(--ux-ok) !important; color: #fff !important; } +.main-column .vote-outcome-no { background: var(--ux-danger) !important; color: #fff !important; } +.main-column .job-section-title:has(.vote-outcome) { display: flex !important; align-items: center; gap: 8px; flex-wrap: wrap; } + +/* ---------- margenes: uno solo, de 14px ---------- */ +/* se sumaban capa a capa (contenido 20 + columna 10 + ficha 24 + lateral 20 = 74px + por lado) y el texto de una votacion o una tribu se quedaba en 264 de 412px */ +body .main-content { padding-left: 14px !important; padding-right: 14px !important; } +body .main-content > .main-column { padding-left: 0 !important; padding-right: 0 !important; } +.main-column :is(.tribe-details, .tribe-side, .tribe-main, .job-section, .vote-form, .tribe-section, .tribe-content) { + padding-left: 0 !important; padding-right: 0 !important; margin-left: 0 !important; margin-right: 0 !important; + width: 100% !important; max-width: 100% !important; box-sizing: border-box; +} +.main-column .tribe-details { display: flex !important; flex-direction: column !important; gap: 18px; } +/* dentro de una ficha, un solo ritmo: 10px entre bloques (antes hueco de 16 + los + margenes de cada bloque + 20 de relleno, unos 70px entre secciones) */ +.main-column .tribe-side { gap: 10px !important; padding-top: 0 !important; padding-bottom: 0 !important; } +.main-column .tribe-side > *, .main-column .tribe-main > * { margin-top: 0 !important; margin-bottom: 0 !important; } +.main-column .tribe-main { display: flex !important; flex-direction: column; gap: 14px !important; } +.main-column .tribe-card-members { text-align: left !important; padding: 0 !important; font-weight: 700; } +.main-column :is(.tribe-side-actions, .doc-export-actions) > :only-child { grid-column: 1 / -1; } +/* la imagen generica de tribu no aporta nada y ocupaba 300px: solo se ve la propia */ +.main-column img[src*="default-tribe"] { display: none !important; } +/* etiquetas del lanzador largas (Configuracion): se parten con guion */ +.ln-lbl { hyphens: auto; -webkit-hyphens: auto; font-size: 12.5px !important; } + +/* ---------- secciones de una tribu: tira de fichas deslizable + "Mas" ---------- */ +.main-column .tribe-tabs { margin: 4px 0 12px !important; } +.tribe-tabs-strip { + display: flex; gap: 8px; overflow-x: auto; scrollbar-width: none; -webkit-overflow-scrolling: touch; + margin: 0; padding: 4px 0 8px; scroll-snap-type: x proximity; +} +.tribe-tabs-strip::-webkit-scrollbar { display: none; } +.main-column a.tribe-tab, .tribe-tabs-more > summary { + flex: 0 0 auto; scroll-snap-align: start; display: inline-flex; align-items: center; justify-content: center; + min-height: 40px; padding: 4px 14px; border: 2px solid var(--ux-line) !important; border-radius: 20px !important; + background: var(--ux-bg) !important; color: var(--ux-fg) !important; text-decoration: none !important; + font-family: var(--ux-text) !important; font-weight: 700 !important; font-size: 14px !important; white-space: nowrap; + text-transform: uppercase; letter-spacing: .02em; +} +.main-column a.tribe-tab.is-current { background: var(--ux-accent) !important; color: var(--ux-on-accent) !important; } +.tribe-tabs-more > summary { list-style: none; cursor: pointer; margin-top: 2px; } +.tribe-tabs-more > summary::-webkit-details-marker { display: none; } +.tribe-tabs-more > summary::after { content: " ▾"; } +.tribe-tabs-more[open] > summary::after { content: " ▴"; } +.tribe-tabs-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 10px; } +.tribe-tabs-grid .tribe-tab { width: 100%; } +/* filas etiqueta / valor de las fichas (plazo, estado, creado el): en dos columnas */ +.main-column table.tribe-info-table tr { display: grid !important; grid-template-columns: minmax(96px, 38%) 1fr; align-items: center; } +.main-column table.tribe-info-table td { display: block !important; border: 0 !important; padding: 8px 10px !important; min-width: 0; overflow: hidden; text-overflow: ellipsis; } +.main-column table.tribe-info-table tr + tr { border-top: 1px solid #ccc !important; } + +/* bienvenida: "Ahora no" al final, como enlace discreto */ +.main-column .welcome-skip-end { text-align: center; margin: 8px 0 0 !important; } +.main-column .welcome-skip-end .welcome-skip-btn { + background: transparent !important; border: 0 !important; box-shadow: none !important; + font-family: var(--ux-text) !important; font-weight: 700 !important; font-size: 15px !important; + text-transform: none !important; text-decoration: underline; color: var(--ux-muted) !important; width: auto !important; +} +/* buzon y otros: los botones de filtro sueltos (Mas recientes, Marcar como leido) + en la letra de texto, como el resto de fichas */ +.main-column .inbox-filters button, .main-column [class*="inbox"] .filter-btn:not(.create-button), +.main-column form[action^="/inbox"] button:not(.create-button) { + font-family: var(--ux-text) !important; font-weight: 700 !important; font-size: 15px !important; letter-spacing: .02em !important; +} + +/* ---------- regla general de letra para botones ---------- */ +/* todo boton en letra del sistema; Anton solo en el principal de cada pantalla + (el negro, .create-button) y en las tarjetas de aspecto */ +.main-column :is(button, input[type="submit"], input[type="button"], .filter-btn, a.filter-btn):not(.create-button):not(.welcome-banner-start):not(.skin-card):not(.workflow-card):not(.btn-singleview) { + font-family: var(--ux-text) !important; + font-weight: 700 !important; + letter-spacing: .02em !important; +} +.main-column :is(button, input[type="submit"]):not(.create-button):not(.filter-btn):not(.btn-singleview):not(.chat-react-btn):not(.lang-chip):not(.kc-btn):not(.confirm-yes):not(.welcome-skip-btn) { + font-size: 16px !important; +} +.main-column form.welcome-skip-end { display: flex !important; justify-content: center !important; } diff --git a/src/client/assets/translations/i18n_fork.js b/src/client/assets/translations/i18n_fork.js index 6baf961..6635ddb 100644 --- a/src/client/assets/translations/i18n_fork.js +++ b/src/client/assets/translations/i18n_fork.js @@ -17,6 +17,47 @@ module.exports = { en: { + // usabilidad (19/10/2026): votaciones, borrar, modulos, Karvan, pie + tribeSectionsMore: "More", + tribeSectionsLabel: "Sections", + voteYes: "Yes", + voteNo: "No", + voteAbstention: "Abstain", + voteGreen: "Green · agree", + voteYellow: "Yellow · doubts", + voteRed: "Red · against", + voteOwnNote: "You created this vote, so you cannot vote in it: whoever proposes stays neutral.", + voteKindLabel: "Type of vote", + voteKind_classic: "Yes / No", + voteKindHint_classic: "Yes, no, abstain and the usual options", + voteKind_traffic: "Traffic light", + voteKindHint_traffic: "Green agree · yellow doubts · red against, like in the assembly", + voteKind_scale: "Thermometer", + voteKindHint_scale: "From 1 (cold) to 5 (hot)", + deleteConfirmQuestion: "Delete it for good?", + deleteConfirmYes: "Yes, delete", + welcomeBannerTextMobile: "Add your name and photo so people know it is you.", + welcomeBannerActionMobile: "Set up", + footerPeersNone: "Not syncing with anyone yet", + footerPeersSome: "Syncing with {n}", + tribeMapUrlLabel: "Map link (optional)", + tribeHintPrivacy: "Private: only members see it, and it is encrypted. Public: the whole network sees it.", + tribeHintMode: "Open: any member can invite people. Strict: only whoever created it can invite.", + moduleBlockedText: "{name} is turned off.", + moduleBlockedAction: "Turn on {name}", + modulesOnCount: "{on} of {total} sections on", + modulesSaved: "Saved.", + karvanVoiceOn: "Voice on", + karvanCamOn: "Camera on", + karvanNoCam: "No camera permission: the call goes on with voice only.", + karvanNoMic: "No microphone permission. You can allow it in the phone settings; the chat still works.", + karvanMic: "Mic", + karvanCam: "Camera", + karvanHangUp: "Hang up", + karvanInviteLabel: "Invite someone", + karvanInviteHint: "Paste their @id: it is on their profile, under their name.", + karvanRelayHuman: "Through your node", + karvanP2pHuman: "Direct and encrypted", // skins y workflows en el movil (settings_view.js) skinIntroMobile: "Pick how Oasis looks on your phone. It applies as soon as you tap it.", workflowsDescriptionMobile: "A workflow sets which modules are on. On the phone your skin stays as it is.", @@ -85,6 +126,47 @@ module.exports = { spreadMore: "more" }, es: { + // usabilidad (19/10/2026): votaciones, borrar, modulos, Karvan, pie + tribeSectionsMore: "Más", + tribeSectionsLabel: "Secciones", + voteYes: "Sí", + voteNo: "No", + voteAbstention: "Abstención", + voteGreen: "Verde · de acuerdo", + voteYellow: "Amarillo · dudas", + voteRed: "Rojo · en contra", + voteOwnNote: "Has creado esta votación, así que no puedes votar en ella: quien propone se queda neutral.", + voteKindLabel: "Tipo de votación", + voteKind_classic: "Sí / No", + voteKindHint_classic: "Sí, no, abstención y las opciones de siempre", + voteKind_traffic: "Semáforo", + voteKindHint_traffic: "Verde de acuerdo · amarillo dudas · rojo en contra, como en asamblea", + voteKind_scale: "Termómetro", + voteKindHint_scale: "Del 1 (frío) al 5 (caliente)", + deleteConfirmQuestion: "¿Borrar para siempre?", + deleteConfirmYes: "Sí, borrar", + welcomeBannerTextMobile: "Pon tu nombre y tu foto para que te reconozcan.", + welcomeBannerActionMobile: "Ponerlos", + footerPeersNone: "Aún no sincronizas con nadie", + footerPeersSome: "Sincronizando con {n}", + tribeMapUrlLabel: "Enlace del mapa (opcional)", + tribeHintPrivacy: "Privada: solo la ven sus miembros y va cifrada. Pública: la ve toda la red.", + tribeHintMode: "Abierta: cualquier miembro puede invitar. Estricta: solo invita quien la creó.", + moduleBlockedText: "{name} está desactivado.", + moduleBlockedAction: "Activar {name}", + modulesOnCount: "{on} de {total} secciones activas", + modulesSaved: "Guardado.", + karvanVoiceOn: "Voz activa", + karvanCamOn: "Cámara activa", + karvanNoCam: "Sin permiso de cámara: la llamada sigue solo con voz.", + karvanNoMic: "Sin permiso de micrófono. Puedes darlo en los ajustes del móvil; el chat sigue funcionando.", + karvanMic: "Micro", + karvanCam: "Cámara", + karvanHangUp: "Colgar", + karvanInviteLabel: "Invitar a alguien", + karvanInviteHint: "Pega su @id: está en su perfil, debajo del nombre.", + karvanRelayHuman: "A través de tu nodo", + karvanP2pHuman: "Directo y cifrado", // skins y workflows en el movil (settings_view.js) skinIntroMobile: "Elige cómo se ve Oasis en tu móvil. Se aplica al tocarlo.", workflowsDescriptionMobile: "Un workflow fija qué módulos están activos. En el móvil tu skin se queda como está.", @@ -159,6 +241,47 @@ module.exports = { totalspreads: "Réplicas totales" }, fr: { + // usabilidad (19/10/2026): votaciones, borrar, modulos, Karvan, pie + tribeSectionsMore: "Plus", + tribeSectionsLabel: "Sections", + voteYes: "Oui", + voteNo: "Non", + voteAbstention: "Abstention", + voteGreen: "Vert · d'accord", + voteYellow: "Jaune · doutes", + voteRed: "Rouge · contre", + voteOwnNote: "Vous avez créé ce vote, vous ne pouvez donc pas y voter : qui propose reste neutre.", + voteKindLabel: "Type de vote", + voteKind_classic: "Oui / Non", + voteKindHint_classic: "Oui, non, abstention et les options habituelles", + voteKind_traffic: "Feu tricolore", + voteKindHint_traffic: "Vert d'accord · jaune doutes · rouge contre, comme en assemblée", + voteKind_scale: "Thermomètre", + voteKindHint_scale: "De 1 (froid) à 5 (chaud)", + deleteConfirmQuestion: "Supprimer définitivement ?", + deleteConfirmYes: "Oui, supprimer", + welcomeBannerTextMobile: "Ajoutez votre nom et votre photo pour qu'on vous reconnaisse.", + welcomeBannerActionMobile: "Ajouter", + footerPeersNone: "Aucune synchronisation pour l'instant", + footerPeersSome: "Synchronisé avec {n}", + tribeMapUrlLabel: "Lien de la carte (facultatif)", + tribeHintPrivacy: "Privée : seuls ses membres la voient, et elle est chiffrée. Publique : tout le réseau la voit.", + tribeHintMode: "Ouverte : tout membre peut inviter. Stricte : seule la personne qui l'a créée invite.", + moduleBlockedText: "{name} est désactivé.", + moduleBlockedAction: "Activer {name}", + modulesOnCount: "{on} sections actives sur {total}", + modulesSaved: "Enregistré.", + karvanVoiceOn: "Voix active", + karvanCamOn: "Caméra active", + karvanNoCam: "Pas d'accès à la caméra : l'appel continue en voix seule.", + karvanNoMic: "Pas d'accès au micro. Vous pouvez l'autoriser dans les réglages du téléphone ; le chat fonctionne toujours.", + karvanMic: "Micro", + karvanCam: "Caméra", + karvanHangUp: "Raccrocher", + karvanInviteLabel: "Inviter quelqu'un", + karvanInviteHint: "Collez son @id : il est sur son profil, sous son nom.", + karvanRelayHuman: "Via votre nœud", + karvanP2pHuman: "Direct et chiffré", // skins y workflows en el movil (settings_view.js) skinIntroMobile: "Choisis l'apparence d'Oasis sur ton téléphone. Elle s'applique dès que tu touches.", workflowsDescriptionMobile: "Un workflow fixe les modules actifs. Sur le téléphone, ton thème ne change pas.", @@ -227,6 +350,47 @@ module.exports = { spreadMore: "plus" }, eu: { + // usabilidad (19/10/2026): votaciones, borrar, modulos, Karvan, pie + tribeSectionsMore: "Gehiago", + tribeSectionsLabel: "Atalak", + voteYes: "Bai", + voteNo: "Ez", + voteAbstention: "Abstentzioa", + voteGreen: "Berdea · ados", + voteYellow: "Horia · zalantzak", + voteRed: "Gorria · aurka", + voteOwnNote: "Bozketa hau zuk sortu duzu, beraz ezin duzu bozkatu: proposatzen duena neutral geratzen da.", + voteKindLabel: "Bozketa mota", + voteKind_classic: "Bai / Ez", + voteKindHint_classic: "Bai, ez, abstentzioa eta ohiko aukerak", + voteKind_traffic: "Semaforoa", + voteKindHint_traffic: "Berdea ados · horia zalantzak · gorria aurka, batzarrean bezala", + voteKind_scale: "Termometroa", + voteKindHint_scale: "1etik (hotza) 5era (beroa)", + deleteConfirmQuestion: "Betiko ezabatu?", + deleteConfirmYes: "Bai, ezabatu", + welcomeBannerTextMobile: "Jarri zure izena eta argazkia ezagutu zaitzaten.", + welcomeBannerActionMobile: "Jarri", + footerPeersNone: "Oraindik ez zaude inorekin sinkronizatzen", + footerPeersSome: "{n}-rekin sinkronizatzen", + tribeMapUrlLabel: "Maparen esteka (aukerakoa)", + tribeHintPrivacy: "Pribatua: kideek bakarrik ikusten dute, eta zifratuta dago. Publikoa: sare osoak ikusten du.", + tribeHintMode: "Irekia: edozein kidek gonbida dezake. Zorrotza: sortu zuenak bakarrik gonbidatzen du.", + moduleBlockedText: "{name} desaktibatuta dago.", + moduleBlockedAction: "Aktibatu {name}", + modulesOnCount: "{total} ataletatik {on} aktibo", + modulesSaved: "Gordeta.", + karvanVoiceOn: "Ahotsa piztuta", + karvanCamOn: "Kamera piztuta", + karvanNoCam: "Kamerarako baimenik ez: deiak ahotsarekin bakarrik jarraitzen du.", + karvanNoMic: "Mikrofonorako baimenik ez. Mugikorraren ezarpenetan eman dezakezu; txatak funtzionatzen jarraitzen du.", + karvanMic: "Mikroa", + karvanCam: "Kamera", + karvanHangUp: "Eskegi", + karvanInviteLabel: "Norbait gonbidatu", + karvanInviteHint: "Itsatsi bere @id-a: bere profilean dago, izenaren azpian.", + karvanRelayHuman: "Zure nodoaren bidez", + karvanP2pHuman: "Zuzena eta zifratua", // skins y workflows en el movil (settings_view.js) skinIntroMobile: "Aukeratu nolakoa den Oasis zure mugikorrean. Ukitu bezain laster aplikatzen da.", workflowsDescriptionMobile: "Workflow batek zein modulu dauden aktibo finkatzen du. Mugikorrean zure itxura ez da aldatzen.", @@ -295,6 +459,47 @@ module.exports = { spreadMore: "gehiago" }, de: { + // usabilidad (19/10/2026): votaciones, borrar, modulos, Karvan, pie + tribeSectionsMore: "Mehr", + tribeSectionsLabel: "Bereiche", + voteYes: "Ja", + voteNo: "Nein", + voteAbstention: "Enthaltung", + voteGreen: "Grün · einverstanden", + voteYellow: "Gelb · Zweifel", + voteRed: "Rot · dagegen", + voteOwnNote: "Du hast diese Abstimmung erstellt und kannst daher nicht abstimmen: wer vorschlägt, bleibt neutral.", + voteKindLabel: "Art der Abstimmung", + voteKind_classic: "Ja / Nein", + voteKindHint_classic: "Ja, nein, Enthaltung und die üblichen Optionen", + voteKind_traffic: "Ampel", + voteKindHint_traffic: "Grün einverstanden · gelb Zweifel · rot dagegen, wie im Plenum", + voteKind_scale: "Thermometer", + voteKindHint_scale: "Von 1 (kalt) bis 5 (heiß)", + deleteConfirmQuestion: "Endgültig löschen?", + deleteConfirmYes: "Ja, löschen", + welcomeBannerTextMobile: "Füge deinen Namen und dein Foto hinzu, damit man dich erkennt.", + welcomeBannerActionMobile: "Einrichten", + footerPeersNone: "Noch mit niemandem synchronisiert", + footerPeersSome: "Synchronisiert mit {n}", + tribeMapUrlLabel: "Kartenlink (optional)", + tribeHintPrivacy: "Privat: nur Mitglieder sehen sie, und sie ist verschlüsselt. Öffentlich: das ganze Netz sieht sie.", + tribeHintMode: "Offen: jedes Mitglied kann einladen. Streng: nur wer sie erstellt hat, lädt ein.", + moduleBlockedText: "{name} ist ausgeschaltet.", + moduleBlockedAction: "{name} einschalten", + modulesOnCount: "{on} von {total} Bereichen aktiv", + modulesSaved: "Gespeichert.", + karvanVoiceOn: "Stimme an", + karvanCamOn: "Kamera an", + karvanNoCam: "Keine Kameraberechtigung: der Anruf läuft nur mit Stimme weiter.", + karvanNoMic: "Keine Mikrofonberechtigung. Du kannst sie in den Telefoneinstellungen erlauben; der Chat funktioniert weiter.", + karvanMic: "Mikro", + karvanCam: "Kamera", + karvanHangUp: "Auflegen", + karvanInviteLabel: "Jemanden einladen", + karvanInviteHint: "Füge die @id ein: sie steht im Profil, unter dem Namen.", + karvanRelayHuman: "Über deinen Knoten", + karvanP2pHuman: "Direkt und verschlüsselt", // skins y workflows en el movil (settings_view.js) skinIntroMobile: "Wähle, wie Oasis auf deinem Handy aussieht. Es wird beim Antippen übernommen.", workflowsDescriptionMobile: "Ein Workflow legt fest, welche Module aktiv sind. Auf dem Handy bleibt dein Skin, wie er ist.", @@ -363,6 +568,47 @@ module.exports = { spreadMore: "mehr" }, it: { + // usabilidad (19/10/2026): votaciones, borrar, modulos, Karvan, pie + tribeSectionsMore: "Altro", + tribeSectionsLabel: "Sezioni", + voteYes: "Sì", + voteNo: "No", + voteAbstention: "Astensione", + voteGreen: "Verde · d'accordo", + voteYellow: "Giallo · dubbi", + voteRed: "Rosso · contrario", + voteOwnNote: "Hai creato tu questa votazione, quindi non puoi votare: chi propone resta neutrale.", + voteKindLabel: "Tipo di votazione", + voteKind_classic: "Sì / No", + voteKindHint_classic: "Sì, no, astensione e le opzioni di sempre", + voteKind_traffic: "Semaforo", + voteKindHint_traffic: "Verde d'accordo · giallo dubbi · rosso contrario, come in assemblea", + voteKind_scale: "Termometro", + voteKindHint_scale: "Da 1 (freddo) a 5 (caldo)", + deleteConfirmQuestion: "Eliminare per sempre?", + deleteConfirmYes: "Sì, elimina", + welcomeBannerTextMobile: "Aggiungi nome e foto perché ti riconoscano.", + welcomeBannerActionMobile: "Aggiungi", + footerPeersNone: "Non sincronizzi ancora con nessuno", + footerPeersSome: "In sincronia con {n}", + tribeMapUrlLabel: "Link della mappa (facoltativo)", + tribeHintPrivacy: "Privata: la vedono solo i membri ed è cifrata. Pubblica: la vede tutta la rete.", + tribeHintMode: "Aperta: qualsiasi membro può invitare. Rigida: invita solo chi l'ha creata.", + moduleBlockedText: "{name} è disattivato.", + moduleBlockedAction: "Attiva {name}", + modulesOnCount: "{on} sezioni attive su {total}", + modulesSaved: "Salvato.", + karvanVoiceOn: "Voce attiva", + karvanCamOn: "Fotocamera attiva", + karvanNoCam: "Nessun permesso per la fotocamera: la chiamata continua solo in voce.", + karvanNoMic: "Nessun permesso per il microfono. Puoi darlo nelle impostazioni del telefono; la chat continua a funzionare.", + karvanMic: "Micro", + karvanCam: "Fotocamera", + karvanHangUp: "Riaggancia", + karvanInviteLabel: "Invita qualcuno", + karvanInviteHint: "Incolla il suo @id: è nel suo profilo, sotto il nome.", + karvanRelayHuman: "Tramite il tuo nodo", + karvanP2pHuman: "Diretto e cifrato", // skins y workflows en el movil (settings_view.js) skinIntroMobile: "Scegli come appare Oasis sul tuo telefono. Si applica appena lo tocchi.", workflowsDescriptionMobile: "Un workflow stabilisce quali moduli sono attivi. Sul telefono la tua skin resta com'è.", @@ -431,6 +677,47 @@ module.exports = { spreadMore: "altro" }, pt: { + // usabilidad (19/10/2026): votaciones, borrar, modulos, Karvan, pie + tribeSectionsMore: "Mais", + tribeSectionsLabel: "Secções", + voteYes: "Sim", + voteNo: "Não", + voteAbstention: "Abstenção", + voteGreen: "Verde · de acordo", + voteYellow: "Amarelo · dúvidas", + voteRed: "Vermelho · contra", + voteOwnNote: "Criaste esta votação, por isso não podes votar nela: quem propõe fica neutro.", + voteKindLabel: "Tipo de votação", + voteKind_classic: "Sim / Não", + voteKindHint_classic: "Sim, não, abstenção e as opções de sempre", + voteKind_traffic: "Semáforo", + voteKindHint_traffic: "Verde de acordo · amarelo dúvidas · vermelho contra, como em assembleia", + voteKind_scale: "Termómetro", + voteKindHint_scale: "De 1 (frio) a 5 (quente)", + deleteConfirmQuestion: "Apagar para sempre?", + deleteConfirmYes: "Sim, apagar", + welcomeBannerTextMobile: "Põe o teu nome e a tua foto para que te reconheçam.", + welcomeBannerActionMobile: "Pôr", + footerPeersNone: "Ainda não sincronizas com ninguém", + footerPeersSome: "A sincronizar com {n}", + tribeMapUrlLabel: "Ligação do mapa (opcional)", + tribeHintPrivacy: "Privada: só os membros a veem e vai cifrada. Pública: toda a rede a vê.", + tribeHintMode: "Aberta: qualquer membro pode convidar. Estrita: só convida quem a criou.", + moduleBlockedText: "{name} está desativado.", + moduleBlockedAction: "Ativar {name}", + modulesOnCount: "{on} de {total} secções ativas", + modulesSaved: "Guardado.", + karvanVoiceOn: "Voz ativa", + karvanCamOn: "Câmara ativa", + karvanNoCam: "Sem permissão de câmara: a chamada continua só com voz.", + karvanNoMic: "Sem permissão de microfone. Podes dá-la nas definições do telemóvel; o chat continua a funcionar.", + karvanMic: "Micro", + karvanCam: "Câmara", + karvanHangUp: "Desligar", + karvanInviteLabel: "Convidar alguém", + karvanInviteHint: "Cola o @id da pessoa: está no perfil, por baixo do nome.", + karvanRelayHuman: "Através do teu nó", + karvanP2pHuman: "Direto e cifrado", // skins y workflows en el movil (settings_view.js) skinIntroMobile: "Escolhe como o Oasis aparece no teu telemóvel. Aplica-se ao tocar.", workflowsDescriptionMobile: "Um workflow define que módulos estão ativos. No telemóvel o teu tema fica como está.", @@ -499,6 +786,47 @@ module.exports = { spreadMore: "mais" }, zh: { + // usabilidad (19/10/2026): votaciones, borrar, modulos, Karvan, pie + tribeSectionsMore: "更多", + tribeSectionsLabel: "板块", + voteYes: "赞成", + voteNo: "反对", + voteAbstention: "弃权", + voteGreen: "绿 · 同意", + voteYellow: "黄 · 有疑问", + voteRed: "红 · 反对", + voteOwnNote: "这是你发起的投票,所以你不能投票:发起人保持中立。", + voteKindLabel: "投票类型", + voteKind_classic: "是 / 否", + voteKindHint_classic: "赞成、反对、弃权及常用选项", + voteKind_traffic: "红绿灯", + voteKindHint_traffic: "绿同意 · 黄有疑问 · 红反对,和集会上一样", + voteKind_scale: "温度计", + voteKindHint_scale: "从 1(冷)到 5(热)", + deleteConfirmQuestion: "永久删除?", + deleteConfirmYes: "是,删除", + welcomeBannerTextMobile: "添加你的名字和照片,让大家认出你。", + welcomeBannerActionMobile: "设置", + footerPeersNone: "尚未与任何人同步", + footerPeersSome: "正在与 {n} 个节点同步", + tribeMapUrlLabel: "地图链接(可选)", + tribeHintPrivacy: "私密:只有成员能看到,并且加密。公开:整个网络都能看到。", + tribeHintMode: "开放:任何成员都可以邀请。严格:只有创建者可以邀请。", + moduleBlockedText: "{name} 已关闭。", + moduleBlockedAction: "开启 {name}", + modulesOnCount: "已开启 {on}/{total} 个板块", + modulesSaved: "已保存。", + karvanVoiceOn: "语音已开", + karvanCamOn: "摄像头已开", + karvanNoCam: "没有摄像头权限:通话仅以语音继续。", + karvanNoMic: "没有麦克风权限。可以在手机设置中允许;聊天仍可使用。", + karvanMic: "麦克风", + karvanCam: "摄像头", + karvanHangUp: "挂断", + karvanInviteLabel: "邀请某人", + karvanInviteHint: "粘贴对方的 @id:在其个人资料中,名字下方。", + karvanRelayHuman: "通过你的节点", + karvanP2pHuman: "直连且加密", // skins y workflows en el movil (settings_view.js) skinIntroMobile: "选择 Oasis 在手机上的外观。点一下就会生效。", workflowsDescriptionMobile: "工作流决定启用哪些模块。在手机上,你的外观保持不变。", @@ -567,6 +895,47 @@ module.exports = { spreadMore: "更多" }, ar: { + // usabilidad (19/10/2026): votaciones, borrar, modulos, Karvan, pie + tribeSectionsMore: "المزيد", + tribeSectionsLabel: "الأقسام", + voteYes: "نعم", + voteNo: "لا", + voteAbstention: "امتناع", + voteGreen: "أخضر · موافق", + voteYellow: "أصفر · شكوك", + voteRed: "أحمر · ضد", + voteOwnNote: "أنت من أنشأ هذا التصويت، لذا لا يمكنك التصويت فيه: من يقترح يبقى محايدًا.", + voteKindLabel: "نوع التصويت", + voteKind_classic: "نعم / لا", + voteKindHint_classic: "نعم، لا، امتناع والخيارات المعتادة", + voteKind_traffic: "إشارة المرور", + voteKindHint_traffic: "أخضر موافق · أصفر شكوك · أحمر ضد، كما في الجمعية", + voteKind_scale: "مقياس الحرارة", + voteKindHint_scale: "من 1 (بارد) إلى 5 (ساخن)", + deleteConfirmQuestion: "حذف نهائيًا؟", + deleteConfirmYes: "نعم، احذف", + welcomeBannerTextMobile: "أضف اسمك وصورتك ليتعرّف عليك الآخرون.", + welcomeBannerActionMobile: "إضافة", + footerPeersNone: "لا مزامنة مع أحد بعد", + footerPeersSome: "مزامنة مع {n}", + tribeMapUrlLabel: "رابط الخريطة (اختياري)", + tribeHintPrivacy: "خاصة: يراها أعضاؤها فقط وهي مشفّرة. عامة: تراها الشبكة كلها.", + tribeHintMode: "مفتوحة: يمكن لأي عضو أن يدعو. صارمة: لا يدعو إلا من أنشأها.", + moduleBlockedText: "{name} مُعطّل.", + moduleBlockedAction: "تفعيل {name}", + modulesOnCount: "{on} من {total} أقسام مفعّلة", + modulesSaved: "تم الحفظ.", + karvanVoiceOn: "الصوت مفعّل", + karvanCamOn: "الكاميرا مفعّلة", + karvanNoCam: "لا إذن للكاميرا: تستمر المكالمة بالصوت فقط.", + karvanNoMic: "لا إذن للميكروفون. يمكنك السماح به من إعدادات الهاتف؛ الدردشة تعمل.", + karvanMic: "ميكروفون", + karvanCam: "كاميرا", + karvanHangUp: "إنهاء", + karvanInviteLabel: "ادعُ شخصًا", + karvanInviteHint: "الصق معرّفه @id: تجده في ملفه تحت اسمه.", + karvanRelayHuman: "عبر عقدتك", + karvanP2pHuman: "مباشر ومشفّر", // skins y workflows en el movil (settings_view.js) skinIntroMobile: "اختر مظهر Oasis على هاتفك. يُطبَّق بمجرد اللمس.", workflowsDescriptionMobile: "يحدد سير العمل الوحدات المفعّلة. على الهاتف يبقى مظهرك كما هو.", @@ -635,6 +1004,47 @@ module.exports = { spreadMore: "المزيد" }, hi: { + // usabilidad (19/10/2026): votaciones, borrar, modulos, Karvan, pie + tribeSectionsMore: "और", + tribeSectionsLabel: "खंड", + voteYes: "हाँ", + voteNo: "नहीं", + voteAbstention: "तटस्थ", + voteGreen: "हरा · सहमत", + voteYellow: "पीला · संदेह", + voteRed: "लाल · विरोध", + voteOwnNote: "यह मतदान आपने बनाया है, इसलिए आप इसमें वोट नहीं दे सकते: प्रस्ताव रखने वाला तटस्थ रहता है।", + voteKindLabel: "मतदान का प्रकार", + voteKind_classic: "हाँ / नहीं", + voteKindHint_classic: "हाँ, नहीं, तटस्थ और सामान्य विकल्प", + voteKind_traffic: "ट्रैफ़िक लाइट", + voteKindHint_traffic: "हरा सहमत · पीला संदेह · लाल विरोध, सभा की तरह", + voteKind_scale: "थर्मामीटर", + voteKindHint_scale: "1 (ठंडा) से 5 (गरम) तक", + deleteConfirmQuestion: "हमेशा के लिए हटाएँ?", + deleteConfirmYes: "हाँ, हटाएँ", + welcomeBannerTextMobile: "अपना नाम और फ़ोटो जोड़ें ताकि लोग आपको पहचानें।", + welcomeBannerActionMobile: "जोड़ें", + footerPeersNone: "अभी किसी से सिंक नहीं", + footerPeersSome: "{n} से सिंक हो रहा है", + tribeMapUrlLabel: "नक्शे का लिंक (वैकल्पिक)", + tribeHintPrivacy: "निजी: केवल सदस्य देखते हैं, और यह एन्क्रिप्टेड है। सार्वजनिक: पूरा नेटवर्क देखता है।", + tribeHintMode: "खुला: कोई भी सदस्य आमंत्रित कर सकता है। सख़्त: केवल बनाने वाला आमंत्रित करता है।", + moduleBlockedText: "{name} बंद है।", + moduleBlockedAction: "{name} चालू करें", + modulesOnCount: "{total} में से {on} खंड चालू", + modulesSaved: "सहेजा गया।", + karvanVoiceOn: "आवाज़ चालू", + karvanCamOn: "कैमरा चालू", + karvanNoCam: "कैमरे की अनुमति नहीं: कॉल केवल आवाज़ से जारी है।", + karvanNoMic: "माइक्रोफ़ोन की अनुमति नहीं। फ़ोन की सेटिंग में दे सकते हैं; चैट चलती रहेगी।", + karvanMic: "माइक", + karvanCam: "कैमरा", + karvanHangUp: "कॉल काटें", + karvanInviteLabel: "किसी को आमंत्रित करें", + karvanInviteHint: "उनका @id चिपकाएँ: यह उनकी प्रोफ़ाइल में नाम के नीचे है।", + karvanRelayHuman: "आपके नोड के ज़रिए", + karvanP2pHuman: "सीधा और एन्क्रिप्टेड", // skins y workflows en el movil (settings_view.js) skinIntroMobile: "चुनें कि आपके फ़ोन पर Oasis कैसा दिखे। छूते ही लागू हो जाता है।", workflowsDescriptionMobile: "वर्कफ़्लो तय करता है कि कौन-से मॉड्यूल चालू हैं। फ़ोन पर आपकी स्किन वैसी ही रहती है।", @@ -703,6 +1113,47 @@ module.exports = { spreadMore: "और" }, ru: { + // usabilidad (19/10/2026): votaciones, borrar, modulos, Karvan, pie + tribeSectionsMore: "Ещё", + tribeSectionsLabel: "Разделы", + voteYes: "Да", + voteNo: "Нет", + voteAbstention: "Воздержаться", + voteGreen: "Зелёный · за", + voteYellow: "Жёлтый · сомнения", + voteRed: "Красный · против", + voteOwnNote: "Вы создали это голосование, поэтому не можете в нём голосовать: кто предлагает, остаётся нейтральным.", + voteKindLabel: "Тип голосования", + voteKind_classic: "Да / Нет", + voteKindHint_classic: "Да, нет, воздержаться и обычные варианты", + voteKind_traffic: "Светофор", + voteKindHint_traffic: "Зелёный за · жёлтый сомнения · красный против, как на собрании", + voteKind_scale: "Термометр", + voteKindHint_scale: "От 1 (холодно) до 5 (горячо)", + deleteConfirmQuestion: "Удалить навсегда?", + deleteConfirmYes: "Да, удалить", + welcomeBannerTextMobile: "Добавьте имя и фото, чтобы вас узнавали.", + welcomeBannerActionMobile: "Добавить", + footerPeersNone: "Пока ни с кем не синхронизировано", + footerPeersSome: "Синхронизация с {n}", + tribeMapUrlLabel: "Ссылка на карту (необязательно)", + tribeHintPrivacy: "Закрытое: видят только участники, и оно зашифровано. Открытое: видит вся сеть.", + tribeHintMode: "Открытое: приглашать может любой участник. Строгое: приглашает только создатель.", + moduleBlockedText: "{name} выключен.", + moduleBlockedAction: "Включить {name}", + modulesOnCount: "Включено {on} из {total} разделов", + modulesSaved: "Сохранено.", + karvanVoiceOn: "Голос включён", + karvanCamOn: "Камера включена", + karvanNoCam: "Нет доступа к камере: звонок продолжается только голосом.", + karvanNoMic: "Нет доступа к микрофону. Его можно разрешить в настройках телефона; чат продолжает работать.", + karvanMic: "Микрофон", + karvanCam: "Камера", + karvanHangUp: "Завершить", + karvanInviteLabel: "Пригласить кого-нибудь", + karvanInviteHint: "Вставьте его @id: он в профиле, под именем.", + karvanRelayHuman: "Через ваш узел", + karvanP2pHuman: "Напрямую и зашифровано", // skins y workflows en el movil (settings_view.js) skinIntroMobile: "Выберите, как Oasis выглядит на телефоне. Применяется сразу по нажатию.", workflowsDescriptionMobile: "Рабочий процесс задаёт, какие модули включены. На телефоне ваше оформление не меняется.", diff --git a/src/client/public/js/karvan.js b/src/client/public/js/karvan.js index 04a4797..666885a 100644 --- a/src/client/public/js/karvan.js +++ b/src/client/public/js/karvan.js @@ -117,7 +117,7 @@ function setLive(on) { if (!liveTxt) return; - liveTxt.textContent = on ? "🔒 P2P (WebRTC)" : "server relay"; + liveTxt.textContent = on ? (liveWrap && liveWrap.getAttribute("data-txt-p2p") || "🔒 P2P (WebRTC)") : (liveWrap && liveWrap.getAttribute("data-txt-relay") || "server relay"); if (liveWrap) liveWrap.className = "karvan-live" + (on ? " on" : ""); } @@ -173,7 +173,9 @@ // --- media / videollamada (Fase 1) --- function setCallActive(on) { if (callRoot) callRoot.className = "karvan-call" + (on ? " active" : ""); } function setStatus(msg) { if (statusEl) statusEl.textContent = msg || ""; } - function statusText() { var p = []; if (micActive) p.push("mic ON"); if (camActive) p.push("cam ON"); return p.length ? p.join(" · ") : ""; } + // textos traducidos: los pone la vista en data-txt-* de #karvan-call + function txt(k, def) { return (callRoot && callRoot.getAttribute("data-txt-" + k)) || def; } + function statusText() { var p = []; if (micActive) p.push(txt("mic", "Voice on")); if (camActive) p.push(txt("cam", "Camera on")); return p.length ? p.join(" · ") : ""; } function updateBtns() { if (btnMic) btnMic.className = "kc-btn" + (micActive ? " on" : ""); if (btnCam) btnCam.className = "kc-btn" + (camActive ? " on" : ""); @@ -215,7 +217,7 @@ } function onMediaErr(kind, x) { var name = (x && x.name) || "", msg = (x && x.message) || String(x); - if (name === "NotAllowedError" || /denied|permission/i.test(msg)) setStatus("⛔ Cámara/micro denegados (en el móvil, pendiente del wrapper). El chat sigue."); + if (name === "NotAllowedError" || /denied|permission/i.test(msg)) setStatus(kind === "video" ? txt("nocam", "No camera permission: the call goes on with voice only.") : txt("nomic", "No microphone permission. You can allow it in the phone settings; the chat still works.")); else if (name === "NotFoundError") setStatus("⚠ No hay " + (kind === "audio" ? "micrófono" : "cámara") + "."); else if (name === "NotReadableError") setStatus("⚠ Dispositivo ocupado."); else setStatus("⚠ " + (name || "error") + ": " + msg); @@ -230,16 +232,13 @@ setCallActive(true); updateBtns(); setStatus(statusText()); }).catch(function (x) { onMediaErr(kind, x); }); } + // Llamar empieza solo con voz: pedir la camara de entrada hacia que, si se + // negaba, no arrancara nada aunque el micro estuviera permitido. El video se + // anade despues con su boton function startCall() { if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { setStatus("⚠ Cámara/micro no disponibles aquí."); return; } - setStatus("📞 pidiendo cámara y micro…"); - navigator.mediaDevices.getUserMedia({ audio: true, video: true }).then(function (s) { - s.getTracks().forEach(addLocalTrack); - micActive = true; camActive = true; setCallActive(true); updateBtns(); setStatus(statusText()); - }).catch(function (x) { - if (x && x.name === "NotFoundError") { getMedia("audio"); return; } // sin cámara → intenta solo audio - onMediaErr("call", x); - }); + if (micActive) return; + getMedia("audio"); } function hangUp() { if (localStream) localStream.getTracks().forEach(function (t) { t.stop(); localStream.removeTrack(t); }); diff --git a/src/models/workflows_model.js b/src/models/workflows_model.js index 85a1230..2f777cf 100644 --- a/src/models/workflows_model.js +++ b/src/models/workflows_model.js @@ -72,11 +72,13 @@ const WORKFLOWS = [ 'tags', 'trending', 'tribes', 'larp', 'polls', 'opinions', 'invites'] }, { - // lo justo para organizarse: hablar, escribir juntos y compartir lo que pasa + // lo justo para organizarse: hablar, escribir juntos, compartir lo que pasa + // y decidir (tribus = comisiones, parlamento y votaciones para la asamblea) key: 'activists', theme: 'Purple-SNH', homePage: 'activity', - modules: ['chats', 'karvan', 'pads', 'forum', 'images', 'videos', 'wiki', 'blogs', 'feed', 'invites', 'backup'] + modules: ['chats', 'karvan', 'pads', 'forum', 'images', 'videos', 'wiki', 'blogs', 'feed', 'invites', 'backup', + 'tribes', 'parliament', 'votes', 'events'] }, { key: 'mobile', diff --git a/src/views/hive_views.js b/src/views/hive_views.js index 4b3b270..73b3e95 100644 --- a/src/views/hive_views.js +++ b/src/views/hive_views.js @@ -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 }; }; diff --git a/src/views/karvan_view.js b/src/views/karvan_view.js index 17f6640..d727f42 100644 --- a/src/views/karvan_view.js +++ b/src/views/karvan_view.js @@ -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" diff --git a/src/views/main_views.js b/src/views/main_views.js index faf4d70..3788b6e 100644 --- a/src/views/main_views.js +++ b/src/views/main_views.js @@ -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
+ 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 }), diff --git a/src/views/mobile_icons.js b/src/views/mobile_icons.js new file mode 100644 index 0000000..fcaa24f --- /dev/null +++ b/src/views/mobile_icons.js @@ -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: '', + feed: '', + chats: '', + karvan: '', + rooms: '', + forum: '', + pads: '', + wiki: '', + blogs: '', + opinions: '', + mentions: '', + tags: '', + trending: '', + maps: '', + graphos: '', + inhabitants: '', + fediverse: '', + images: '', + videos: '', + audios: '', + podcasts: '', + documents: '', + files: '', + bookmarks: '', + torrents: '', + games: '', + pixelia: '', + melody: '', + events: '', + calendars: '', + tasks: '', + campaigns: '', + mailing: '', + votes: '', + polls: '', + reports: '', + tribes: '', + emergencies: '', + school: '', + larp: '', + parliament: '', + courts: '', + market: '', + shops: '', + jobs: '', + projects: '', + industry: '', + housing: '', + logistics: '', + banking: '', + transfers: '', + profile: '', + agenda: '', + favorites: '', + phone: '', + cv: '', + wallet: '', + logs: '', + invites: '', + peers: '', + bar: '', + backup: '', + cipher: '', + ai: '', + stats: '', + blockexplorer: '', + modules: '', + settings: '', + search: '', + inbox: '', + publish: '', + data: '', + mic: '', + camera: '', + hangup: '' +}; + +// la clave sale de la ruta: /settings/bottombar es "bar" +const iconKeyOf = (href) => { + const h = String(href || '').replace(/^\//, '').split(/[?#]/)[0]; + if (h === 'settings/bottombar') return 'bar'; + return h.split('/')[0]; +}; + +const svg = (key) => ICONS[key] + ? `` + : ''; + +// span con el SVG dentro; si no hay icono para esa clave, el caracter de antes +const iconNode = (cls, key, fallback) => ICONS[key] + ? span({ class: cls + ' has-svg', 'aria-hidden': 'true', [RAW_HTML]: svg(key) }) + : span({ class: cls, 'aria-hidden': 'true' }, fallback || ''); + +module.exports = { ICONS, iconKeyOf, svg, iconNode }; diff --git a/src/views/modules_view.js b/src/views/modules_view.js index 362d54f..4122479 100644 --- a/src/views/modules_view.js +++ b/src/views/modules_view.js @@ -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; diff --git a/src/views/settings_view.js b/src/views/settings_view.js index ded6bc8..ae28daa 100644 --- a/src/views/settings_view.js +++ b/src/views/settings_view.js @@ -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)) ); }; diff --git a/src/views/tribes_view.js b/src/views/tribes_view.js index 5c2a3fa..455fe3e 100644 --- a/src/views/tribes_view.js +++ b/src/views/tribes_view.js @@ -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' }, diff --git a/src/views/vote_view.js b/src/views/vote_view.js index 72df6f1..2d2ae20 100644 --- a/src/views/vote_view.js +++ b/src/views/vote_view.js @@ -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) ) ) diff --git a/src/views/welcome_view.js b/src/views/welcome_view.js index f9bc874..9b11066 100644 --- a/src/views/welcome_view.js +++ b/src/views/welcome_view.js @@ -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('', '') : html }