frontera con upstream: lo del movil sale de los ficheros del dev
docs/FRONTERA_UPSTREAM.md mide como cambia upstream (39 releases de la 1.0.1 a la 1.3.3; backend.js en 34, main_views en 31, OasisMobile.css en 21) y fija que se toca: backend, middleware, modelos, server y traducciones de upstream, no; vistas de upstream, solo una linea de gancho; hojas, vistas, router y modelos propios, si. Fase A, lo del 19/10: - backend.js, workflows_model.js y modules_view.js vuelven a como estaban - seccion apagada, /modules/enable y ?saved=1: en el router propio, que envuelve las redirecciones de upstream en vez de cambiar checkMod - Modulos del movil en views/mobile_modules_view.js, servida por el router propio - workflow propio "acampada" registrado desde models/fork_workflows.js en vez de redefinir "activists" - votaciones, tribus, ajustes, borrar, pie y bienvenida: la logica en views/mobile_ui.js; en cada vista de upstream queda la llamada
This commit is contained in:
parent
9b391cfc3d
commit
d04d99ece2
14 changed files with 571 additions and 267 deletions
144
docs/FRONTERA_UPSTREAM.md
Normal file
144
docs/FRONTERA_UPSTREAM.md
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
# Frontera con upstream: que se puede tocar y que no
|
||||
|
||||
Medido el 19/10/2026 sobre el repositorio del dev (code.03c8.net/krakenslab/oasis),
|
||||
de la release 1.0.1 (29/08) a la 1.3.3 (18/10), y comparado con la rama PRUEBAS.
|
||||
Los scripts estan en el historial de la sesion; para repetirlo basta clonar upstream y
|
||||
contar con `git log --numstat` por fichero.
|
||||
|
||||
## 1. Como trabaja el dev
|
||||
|
||||
- **Una release casi cada dia**: 39 entre la 1.0.1 y la 1.3.3, en siete semanas.
|
||||
Cada una es un solo commit ("Oasis release X.Y.Z"), sin historia intermedia: no
|
||||
hay forma de coger un cambio suelto, siempre llega todo junto.
|
||||
- **El backend crece sin parar**: `backend.js` paso de ~9.900 a ~15.400 lineas y se
|
||||
toca en 34 de 39 releases. La cadena de middleware (el array `middleware` del final)
|
||||
ha ganado capas en este tiempo: contexto por peticion (`requestScope`), idioma de
|
||||
primera ejecucion, CSP, etc.
|
||||
- **Ya piensa en el movil**: tiene su propio tema `OasisMobile.css`, usa la misma
|
||||
bandera `process.env.OASIS_MOBILE` que la rama y tiene workflows `activists` y
|
||||
`mobile`. Va en nuestra direccion; cuanto mas nos pisemos con el, peor.
|
||||
- **Las clases CSS cambian, pero poco a poco**: de la 1.0.1 a la 1.2.0 aparecen 335
|
||||
clases y desaparecen 98; hasta la 1.3.1, +304 / -94; de la 1.3.1 a la 1.3.3, +47 / -8.
|
||||
Unas 100 clases muertas cada tres semanas, sobre ~2.200: un 5 %.
|
||||
|
||||
Ficheros que mas toca (releases que lo tocan de 39 / lineas movidas):
|
||||
|
||||
| Fichero | Releases | Lineas |
|
||||
|---|---|---|
|
||||
| `src/backend/backend.js` | 34 | 11.621 |
|
||||
| `src/server/package.json` + lock | 34 | 22.700 |
|
||||
| `src/client/assets/styles/style.css` | 32 | 2.522 |
|
||||
| `translations/oasis_*.js` (los 11) | 31 cada uno | ~2.000 cada uno |
|
||||
| `src/views/main_views.js` | 31 | 2.634 |
|
||||
| `src/views/activity_view.js` | 27 | 1.183 |
|
||||
| `src/views/tribes_view.js` | 23 | 2.675 |
|
||||
| `src/client/assets/themes/OasisMobile.css` | 21 | 3.040 |
|
||||
| `src/views/settings_view.js` | 17 | 849 |
|
||||
| `src/views/vote_view.js` | 14 | 155 |
|
||||
| `src/configs/config-manager.js` | 11 | 120 |
|
||||
| `src/client/assets/styles/mobile.css` | 10 | 514 |
|
||||
| `src/views/parliament_view.js` | 10 | 282 |
|
||||
| `src/models/workflows_model.js` | 8 | 133 |
|
||||
| `src/views/welcome_view.js` | 8 | 440 |
|
||||
| `src/server/SSB_server.js` | 7 | 183 |
|
||||
| `src/models/votes_model.js` | 4 | 33 |
|
||||
| `src/backend/request_guards.js` (CSP) | 4 | 111 |
|
||||
|
||||
## 2. El mapa
|
||||
|
||||
### Rojo: no se toca
|
||||
|
||||
Cada linea nuestra aqui es un conflicto casi seguro en la siguiente release, y ademas
|
||||
cambia el comportamiento de la red, no solo como se ve.
|
||||
|
||||
- `src/backend/backend.js`: rutas y, sobre todo, la cadena de `middleware`.
|
||||
- `src/models/*` de upstream: lo que se publica en el log SSB y como se cuenta. Si dos
|
||||
nodos cuentan distinto (votos, tribus, gobierno), la red se rompe. Ejemplo: "quien
|
||||
crea una votacion no vota" es una regla de `votes_model.js`; cambiarla en la rama
|
||||
haria que nuestro movil y un escritorio dieran resultados distintos.
|
||||
- `src/server/*` (arranque de SSB, `ssb_config`, `package.json` y dependencias).
|
||||
- `src/backend/request_guards.js` (CSP: no hay estilos ni scripts en linea, y asi
|
||||
debe seguir).
|
||||
- `translations/oasis_*.js`: se reescriben en 31 de 39 releases.
|
||||
- `src/client/assets/styles/style.css` y `mobile.css`: son del dev.
|
||||
- `src/client/assets/themes/*.css` de upstream, **incluido `OasisMobile.css`**.
|
||||
|
||||
### Ambar: solo un gancho de una linea, con la bandera de movil
|
||||
|
||||
Las vistas de upstream (`main_views.js`, `tribes_view.js`, `vote_view.js`,
|
||||
`settings_view.js`...). Se reescriben a menudo, asi que lo que la rama necesite ahi va
|
||||
como **una llamada a una funcion nuestra**, envuelta en
|
||||
`process.env.OASIS_MOBILE === '1'`, y la logica vive en un fichero propio. Si el dev
|
||||
reescribe la vista, se vuelve a poner una linea, no se reconstruye una funcion.
|
||||
|
||||
### Verde: nuestro, sin limite
|
||||
|
||||
- Hojas propias: `themes/Acampada.css`, `styles/hive_acampada.css`,
|
||||
`styles/mobile_ux.css`, `styles/karvan.css`.
|
||||
- Vistas propias: `hive_views.js`, `mobile_icons.js`, `karvan_view.js`,
|
||||
`identities_view.js` y las que se creen (`mobile_*.js`).
|
||||
- El router propio `src/backend/karvan_routes.js`: va justo antes del de upstream en la
|
||||
cadena y ve todas las peticiones. Puede atender rutas nuevas, o envolver una ruta de
|
||||
upstream (dejarla hacer y retocar la respuesta) sin editarla.
|
||||
- Traducciones propias en `i18n_fork.js` (se mezclan encima de las de upstream).
|
||||
- Modelos propios (`karvan_model.js`) y JS de cliente propio (`public/js/karvan.js`).
|
||||
- Todo `android/`.
|
||||
|
||||
## 3. Como esta la rama hoy
|
||||
|
||||
43 ficheros de upstream modificados y 22 propios. Lo que mas pesa:
|
||||
|
||||
| Fichero de upstream | Lineas distintas | Riesgo |
|
||||
|---|---|---|
|
||||
| `themes/OasisMobile.css` | 4.873 | **Alto**: es casi otro fichero. El dev lo toca en 21/39 releases y cada vez hay que elegir entre su version y la nuestra. |
|
||||
| `views/main_views.js` | 173 | Alto (31/39) |
|
||||
| `backend/backend.js` | 133 (21 trozos) | Alto (34/39) |
|
||||
| `configs/oasis-config.json` | 135 | Medio: es la config de serie del movil |
|
||||
| `backend/blobHandler.js` | 190 | Medio (limpieza de fotos) |
|
||||
| `backend/updater.js` | 133 | Bajo (3/39) |
|
||||
| `views/modules_view.js` | 91 | Bajo (5/39) |
|
||||
| `views/parliament_view.js`, `settings_view.js`, `vote_view.js`, `tribes_view.js` | 50-75 cada una | Medio-alto |
|
||||
| `models/workflows_model.js` | 31 | Medio: **redefine el workflow `activists` del dev** |
|
||||
| `server/ssb_config.js`, `SSB_server.js` | 40 | Medio (multicuenta) |
|
||||
|
||||
Las hojas propias y `OasisMobile.css` llevan selectores a clases que ya no pinta nadie:
|
||||
61 en `OasisMobile.css`, 29 en `hive_acampada.css`, 5 en `Acampada.css`. No rompen
|
||||
nada, pero son peso muerto y confunden.
|
||||
|
||||
## 4. Reglas para cada cambio
|
||||
|
||||
1. **Primero CSS.** Si se arregla con una hoja propia, se arregla ahi.
|
||||
2. **Si hace falta HTML distinto**: funcion en un fichero propio + una linea en la vista
|
||||
de upstream, con la bandera de movil.
|
||||
3. **Si hace falta una ruta o retocar una respuesta**: en el router propio. Nunca una
|
||||
ruta nueva ni un middleware en `backend.js`.
|
||||
4. **Nunca cambiar lo que se publica ni como se cuenta** (modelos de upstream).
|
||||
5. **Textos nuevos** en `i18n_fork.js`, en los 11 idiomas.
|
||||
6. **Nada en linea**: la CSP no deja `style=""` ni `<script>` en la pagina.
|
||||
7. Antes de integrar una release, repetir esta medicion y mirar si alguna clase de la
|
||||
que dependemos ha desaparecido.
|
||||
|
||||
## 5. Plan para sanear la rama
|
||||
|
||||
- **Fase A (hecha el 19/10)**: lo del 19/10 que se metio en ficheros del dev sale a
|
||||
ficheros propios. Ver la seccion 6.
|
||||
- **Fase B: `OasisMobile.css`.** Devolverle la version del dev y llevar nuestra maqueta
|
||||
a una hoja propia (`styles/mobile_base.css`) que carguen Acampada y el skin
|
||||
OasisMobile de la rama. Es el mayor foco de conflictos.
|
||||
- **Fase C: los 21 trozos antiguos de `backend.js`** (exportar copia de seguridad,
|
||||
avatar de la topbar, `#t=0.1` en los videos, filtro del lanzador...) pasan al router
|
||||
propio como envoltorios.
|
||||
- **Fase D: workflows.** Devolver `activists` al del dev y registrar los nuestros
|
||||
(`acampada`) desde un fichero propio, empujandolos a los arrays que exporta
|
||||
`workflows_model.js`.
|
||||
- **Fase E: limpieza** de selectores muertos en las hojas propias.
|
||||
|
||||
## 6. Fase A: lo que se ha movido
|
||||
|
||||
| Antes (en ficheros del dev) | Ahora |
|
||||
|---|---|
|
||||
| `backend.js`: `checkMod` envuelto para recordar la seccion apagada | El router propio mira las redirecciones a `/modules` de vuelta |
|
||||
| `backend.js`: ruta `POST /modules/enable` y `?saved=1` tras guardar | Router propio |
|
||||
| `modules_view.js`: vista de Modulos del movil | `views/mobile_modules_view.js`, servida por el router propio |
|
||||
| `workflows_model.js`: cuatro modulos mas en `activists` | Workflow propio `acampada` registrado desde `models/fork_workflows.js` |
|
||||
| `vote_view.js`, `tribes_view.js`, `settings_view.js`, `main_views.js`, `welcome_view.js`: funciones nuevas | Funciones en `views/mobile_ui.js`; en la vista de upstream queda la llamada |
|
||||
|
|
@ -370,21 +370,7 @@ 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;
|
||||
|
|
@ -4691,18 +4677,7 @@ 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; }, {}), { 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}`);
|
||||
ctx.body = modulesView(modules.reduce((acc, m) => { acc[`${m}Mod`] = cfg[`${m}Mod`]; return acc; }, {}));
|
||||
})
|
||||
.get('/ai', async (ctx) => {
|
||||
if (!checkMod(ctx, 'aiMod')) return ctx.redirect('/modules');
|
||||
|
|
@ -15223,7 +15198,7 @@ router
|
|||
MOBILE_UNAVAILABLE_MODS.forEach(mod => cfg.modules[`${mod}Mod`] = 'off');
|
||||
}
|
||||
saveConfig(cfg);
|
||||
ctx.redirect(process.env.OASIS_MOBILE === '1' ? `/modules?saved=1` : `/modules`);
|
||||
ctx.redirect(`/modules`);
|
||||
})
|
||||
.post("/settings/ai", koaBody(), async (ctx) => {
|
||||
const aiPrompt = String(ctx.request.body.ai_prompt || "").trim();
|
||||
|
|
|
|||
|
|
@ -20,6 +20,44 @@ const { stripDangerousTags } = require("./sanitizeHtml");
|
|||
const { buildIceServers } = require("./turnCredentials");
|
||||
const karvanModel = require('../models/karvan_model')({}); // FORK_IA_UX: salas efímeras en RAM (mensajes temporales + señalización WebRTC)
|
||||
const { karvanView, karvanRoomView } = require("../views/karvan_view");
|
||||
const compose = require("../server/node_modules/koa-compose");
|
||||
const { isLocalPath } = require("./request_guards");
|
||||
const { ALL_MODULES } = require("../models/workflows_model");
|
||||
const { mobileModulesView, STEMS } = require("../views/mobile_modules_view");
|
||||
require("../models/fork_workflows").register();
|
||||
|
||||
const isMobile = () => process.env.OASIS_MOBILE === '1';
|
||||
|
||||
// Seccion apagada: upstream responde con una redireccion a /modules sin decir de
|
||||
// donde venia. Este envoltorio la ve pasar y anota que modulo era, para que la
|
||||
// pantalla de Modulos del movil lo diga y ofrezca activarlo. La app es de una
|
||||
// persona, asi que basta con recordar el ultimo unos segundos.
|
||||
const SEGMENT_MOD = { tribe: 'tribes', vote: 'votes', document: 'docs', documents: 'docs', blog: 'blogs', event: 'events',
|
||||
task: 'tasks', calendar: 'calendars', image: 'images', video: 'videos', audio: 'audios', bookmark: 'bookmarks',
|
||||
file: 'files', poll: 'polls', report: 'reports', project: 'projects', shop: 'shops', job: 'jobs', campaign: 'campaigns',
|
||||
podcast: 'podcasts', room: 'rooms', map: 'maps', transfer: 'transfers', pad: 'pads', chat: 'chats', torrent: 'torrents' };
|
||||
const modOfPath = (p) => {
|
||||
const seg = String(p || '').split('/')[1] || '';
|
||||
const mod = STEMS[seg] ? seg : SEGMENT_MOD[seg];
|
||||
return mod && ALL_MODULES.includes(mod) ? mod : null;
|
||||
};
|
||||
let lastBlocked = null;
|
||||
const takeBlocked = () => {
|
||||
const b = lastBlocked && Date.now() - lastBlocked.at < 15000 ? lastBlocked : null;
|
||||
lastBlocked = null;
|
||||
return b;
|
||||
};
|
||||
const wrapUpstream = async (ctx, next) => {
|
||||
await next();
|
||||
if (!isMobile() || ctx.status < 300 || ctx.status >= 400) return;
|
||||
const to = String(ctx.response.get('Location') || '');
|
||||
// guardar en Modulos: volver con el aviso de guardado
|
||||
if (ctx.method === 'POST' && ctx.path === '/save-modules') { ctx.redirect('/modules?saved=1'); return; }
|
||||
if (ctx.method === 'GET' && ctx.path !== '/modules' && /^(https?:\/\/[^/]+)?\/modules$/.test(to)) {
|
||||
const mod = modOfPath(ctx.path);
|
||||
if (mod) lastBlocked = { mod, path: ctx.path, at: Date.now() };
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = ({ cooler, pull, pmModel, checkMod, getViewerId,
|
||||
getConfig, saveConfig, isLoopbackRequest, sendErrorPage }) => {
|
||||
|
|
@ -78,6 +116,22 @@ async function karvanInviteAuthor(roomId) {
|
|||
|
||||
const forkRouter = new koaRouter();
|
||||
forkRouter
|
||||
// Modulos en el movil: la vista propia (mobile_modules_view.js); en escritorio, la de upstream
|
||||
.get('/modules', async (ctx, next) => {
|
||||
if (!isMobile()) return next();
|
||||
ctx.body = mobileModulesView({ blocked: takeBlocked(), saved: ctx.query.saved === '1' });
|
||||
})
|
||||
// activar un solo modulo desde el aviso de Modulos 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 (!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}`);
|
||||
})
|
||||
// Configuracion ICE para las llamadas. Solo loopback: la credencial TURN va
|
||||
// firmada con el secreto del nodo y no debe salir de la maquina.
|
||||
.get('/karvan/ice', async (ctx) => {
|
||||
|
|
@ -233,5 +287,5 @@ async function karvanInviteAuthor(roomId) {
|
|||
setTimeout(() => process.exit(0), 500);
|
||||
});
|
||||
|
||||
return forkRouter.routes();
|
||||
return compose([wrapUpstream, forkRouter.routes()]);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
module.exports = {
|
||||
en: {
|
||||
// usabilidad (19/10/2026): votaciones, borrar, modulos, Karvan, pie
|
||||
workflow_acampada: "Camp",
|
||||
tribeSectionsMore: "More",
|
||||
tribeSectionsLabel: "Sections",
|
||||
voteYes: "Yes",
|
||||
|
|
@ -127,6 +128,7 @@ module.exports = {
|
|||
},
|
||||
es: {
|
||||
// usabilidad (19/10/2026): votaciones, borrar, modulos, Karvan, pie
|
||||
workflow_acampada: "Acampada",
|
||||
tribeSectionsMore: "Más",
|
||||
tribeSectionsLabel: "Secciones",
|
||||
voteYes: "Sí",
|
||||
|
|
@ -242,6 +244,7 @@ module.exports = {
|
|||
},
|
||||
fr: {
|
||||
// usabilidad (19/10/2026): votaciones, borrar, modulos, Karvan, pie
|
||||
workflow_acampada: "Campement",
|
||||
tribeSectionsMore: "Plus",
|
||||
tribeSectionsLabel: "Sections",
|
||||
voteYes: "Oui",
|
||||
|
|
@ -351,6 +354,7 @@ module.exports = {
|
|||
},
|
||||
eu: {
|
||||
// usabilidad (19/10/2026): votaciones, borrar, modulos, Karvan, pie
|
||||
workflow_acampada: "Kanpaldia",
|
||||
tribeSectionsMore: "Gehiago",
|
||||
tribeSectionsLabel: "Atalak",
|
||||
voteYes: "Bai",
|
||||
|
|
@ -460,6 +464,7 @@ module.exports = {
|
|||
},
|
||||
de: {
|
||||
// usabilidad (19/10/2026): votaciones, borrar, modulos, Karvan, pie
|
||||
workflow_acampada: "Camp",
|
||||
tribeSectionsMore: "Mehr",
|
||||
tribeSectionsLabel: "Bereiche",
|
||||
voteYes: "Ja",
|
||||
|
|
@ -569,6 +574,7 @@ module.exports = {
|
|||
},
|
||||
it: {
|
||||
// usabilidad (19/10/2026): votaciones, borrar, modulos, Karvan, pie
|
||||
workflow_acampada: "Accampata",
|
||||
tribeSectionsMore: "Altro",
|
||||
tribeSectionsLabel: "Sezioni",
|
||||
voteYes: "Sì",
|
||||
|
|
@ -678,6 +684,7 @@ module.exports = {
|
|||
},
|
||||
pt: {
|
||||
// usabilidad (19/10/2026): votaciones, borrar, modulos, Karvan, pie
|
||||
workflow_acampada: "Acampada",
|
||||
tribeSectionsMore: "Mais",
|
||||
tribeSectionsLabel: "Secções",
|
||||
voteYes: "Sim",
|
||||
|
|
@ -787,6 +794,7 @@ module.exports = {
|
|||
},
|
||||
zh: {
|
||||
// usabilidad (19/10/2026): votaciones, borrar, modulos, Karvan, pie
|
||||
workflow_acampada: "营地",
|
||||
tribeSectionsMore: "更多",
|
||||
tribeSectionsLabel: "板块",
|
||||
voteYes: "赞成",
|
||||
|
|
@ -896,6 +904,7 @@ module.exports = {
|
|||
},
|
||||
ar: {
|
||||
// usabilidad (19/10/2026): votaciones, borrar, modulos, Karvan, pie
|
||||
workflow_acampada: "مخيم",
|
||||
tribeSectionsMore: "المزيد",
|
||||
tribeSectionsLabel: "الأقسام",
|
||||
voteYes: "نعم",
|
||||
|
|
@ -1005,6 +1014,7 @@ module.exports = {
|
|||
},
|
||||
hi: {
|
||||
// usabilidad (19/10/2026): votaciones, borrar, modulos, Karvan, pie
|
||||
workflow_acampada: "शिविर",
|
||||
tribeSectionsMore: "और",
|
||||
tribeSectionsLabel: "खंड",
|
||||
voteYes: "हाँ",
|
||||
|
|
@ -1114,6 +1124,7 @@ module.exports = {
|
|||
},
|
||||
ru: {
|
||||
// usabilidad (19/10/2026): votaciones, borrar, modulos, Karvan, pie
|
||||
workflow_acampada: "Лагерь",
|
||||
tribeSectionsMore: "Ещё",
|
||||
tribeSectionsLabel: "Разделы",
|
||||
voteYes: "Да",
|
||||
|
|
|
|||
38
src/models/fork_workflows.js
Normal file
38
src/models/fork_workflows.js
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
"use strict";
|
||||
// Workflows propios de la rama, registrados sin editar workflows_model.js.
|
||||
//
|
||||
// workflows_model.js es del dev (lo toca en 8 de cada 39 releases) y ya trae un
|
||||
// workflow "activists" con unos 50 modulos. En vez de redefinirlo, la rama anade
|
||||
// los suyos empujandolos a los arrays que ese fichero exporta; getWorkflow,
|
||||
// currentWorkflow y el asistente de bienvenida los leen de ahi.
|
||||
// Ver docs/FRONTERA_UPSTREAM.md.
|
||||
|
||||
const wf = require('./workflows_model');
|
||||
|
||||
const FORK_WORKFLOWS = [
|
||||
{
|
||||
// una acampada: hablar, escribir juntos, compartir lo que pasa y decidir
|
||||
// (tribus = comisiones, parlamento y votaciones para la asamblea)
|
||||
key: 'acampada',
|
||||
theme: 'Acampada',
|
||||
homePage: 'activity',
|
||||
modules: ['chats', 'karvan', 'pads', 'forum', 'images', 'videos', 'wiki', 'blogs', 'feed', 'invites', 'backup',
|
||||
'tribes', 'parliament', 'votes', 'events']
|
||||
}
|
||||
];
|
||||
|
||||
let done = false;
|
||||
const register = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
for (const w of FORK_WORKFLOWS) {
|
||||
if (wf.WORKFLOWS.some(x => x.key === w.key)) continue;
|
||||
wf.WORKFLOWS.push(w);
|
||||
wf.WORKFLOW_KEYS.push(w.key);
|
||||
// en el movil, la primera opcion del asistente
|
||||
if (process.env.OASIS_MOBILE === '1') wf.WIZARD_WORKFLOWS.unshift(w.key);
|
||||
else wf.WIZARD_WORKFLOWS.push(w.key);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { FORK_WORKFLOWS, register };
|
||||
|
|
@ -72,13 +72,11 @@ const WORKFLOWS = [
|
|||
'tags', 'trending', 'tribes', 'larp', 'polls', 'opinions', 'invites']
|
||||
},
|
||||
{
|
||||
// lo justo para organizarse: hablar, escribir juntos, compartir lo que pasa
|
||||
// y decidir (tribus = comisiones, parlamento y votaciones para la asamblea)
|
||||
// lo justo para organizarse: hablar, escribir juntos y compartir lo que pasa
|
||||
key: 'activists',
|
||||
theme: 'Purple-SNH',
|
||||
homePage: 'activity',
|
||||
modules: ['chats', 'karvan', 'pads', 'forum', 'images', 'videos', 'wiki', 'blogs', 'feed', 'invites', 'backup',
|
||||
'tribes', 'parliament', 'votes', 'events']
|
||||
modules: ['chats', 'karvan', 'pads', 'forum', 'images', 'videos', 'wiki', 'blogs', 'feed', 'invites', 'backup']
|
||||
},
|
||||
{
|
||||
key: 'mobile',
|
||||
|
|
|
|||
|
|
@ -386,22 +386,14 @@ 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;
|
||||
|
||||
// en el movil borrar pide un segundo toque: la X abre "¿Borrar? Si, borrar" y
|
||||
// solo ese boton envia. Sin JS, con <details>
|
||||
const deleteForm = (cls, label) => form({ method: 'POST', action: o.deleteAction, class: cls },
|
||||
o.returnTo && o.returnTo !== viewHref ? input({ type: 'hidden', name: 'returnTo', value: o.returnTo }) : null,
|
||||
label
|
||||
);
|
||||
const deleteBtn = !o.deleteAction ? null
|
||||
: process.env.OASIS_MOBILE === '1'
|
||||
? details({ class: 'content-action-confirm' },
|
||||
summary({ class: 'btn-singleview btn-delete', title: i18n.chatDelete, 'aria-label': i18n.chatDelete }, renderIcon('close')),
|
||||
div({ class: 'confirm-bar' },
|
||||
span({ class: 'confirm-question' }, i18n.deleteConfirmQuestion || 'Delete it for good?'),
|
||||
deleteForm('content-action-form', button({ type: 'submit', class: 'confirm-yes' }, i18n.deleteConfirmYes || 'Yes, delete'))
|
||||
)
|
||||
)
|
||||
: deleteForm('content-action-form content-action-delete', button({ type: 'submit', class: 'btn-singleview btn-delete', title: i18n.chatDelete }, renderIcon('close')));
|
||||
const deleteBtn = o.deleteAction && process.env.OASIS_MOBILE === '1'
|
||||
? require('./mobile_ui').deleteConfirm(o.deleteAction, o.returnTo && o.returnTo !== viewHref ? input({ type: 'hidden', name: 'returnTo', value: o.returnTo }) : null)
|
||||
: 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;
|
||||
|
||||
const hideBtn = blockId && o.hide !== false && !(o.author && String(o.author) === String(myId))
|
||||
? form({ method: 'POST', action: `/hide/${encodeURIComponent(blockId)}`, class: 'content-action-form' },
|
||||
|
|
@ -413,7 +405,7 @@ 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
|
||||
// movil: 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);
|
||||
};
|
||||
|
|
@ -984,22 +976,7 @@ 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`)
|
||||
);
|
||||
}
|
||||
|
||||
if (process.env.OASIS_MOBILE === '1') return require('./mobile_ui').footerLine(syncedPeers, pkgVersion);
|
||||
return div(
|
||||
{ class: "oasis-footer" },
|
||||
div(
|
||||
|
|
@ -2048,7 +2025,6 @@ 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" }),
|
||||
|
|
@ -2219,15 +2195,13 @@ 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;
|
||||
const mobileUi = require('./mobile_ui');
|
||||
if (mobileUi.isMobile() && !mobileUi.welcomeBannerHere(here)) return null;
|
||||
return div(
|
||||
{ class: "update-banner welcome-banner" },
|
||||
span({ class: "update-banner-icon" }, "🌴"),
|
||||
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),
|
||||
span({ class: "update-banner-text" }, mobileUi.isMobile() ? mobileUi.welcomeBannerText() : i18n.welcomeBannerText),
|
||||
a({ href: "/welcome", class: "create-button welcome-banner-start" }, mobileUi.isMobile() ? mobileUi.welcomeBannerAction() : i18n.welcomeBannerAction),
|
||||
form(
|
||||
{ method: "POST", action: "/welcome/dismiss", class: "welcome-banner-close" },
|
||||
button({ type: "submit", class: "welcome-banner-close-btn" }, "✕")
|
||||
|
|
@ -2443,7 +2417,6 @@ 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" }),
|
||||
|
|
|
|||
103
src/views/mobile_modules_view.js
Normal file
103
src/views/mobile_modules_view.js
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
"use strict";
|
||||
// Pantalla de Modulos del movil (la sirve el router propio, karvan_routes.js).
|
||||
//
|
||||
// Los modulos agrupados como en el lanzador, cada uno una fila que se toca entera con
|
||||
// un interruptor, y Guardar siempre a la vista. La tabla de upstream eran 52 filas
|
||||
// con casillas de 16px, unas 11 pantallas. Va en un fichero propio para no tocar
|
||||
// modules_view.js, que es del dev (docs/FRONTERA_UPSTREAM.md).
|
||||
//
|
||||
// El formulario envia a la misma ruta de upstream (/save-modules) con los mismos
|
||||
// nombres de campo (<modulo>Form), asi que guardar sigue siendo cosa suya.
|
||||
|
||||
const { button, div, form, h2, h3, input, label, p, section, span } = require("../server/node_modules/hyperaxe");
|
||||
const { getConfig } = require('../configs/config-manager.js');
|
||||
const { iconNode } = require('./mobile_icons');
|
||||
|
||||
// clave de i18n de cada modulo (modules<Stem>Label / modules<Stem>Description)
|
||||
const STEMS = {
|
||||
agenda: 'Agenda', ai: 'AI', aiNav: 'AINav', emergencies: 'Emergencies', mailing: 'Mailing', rooms: 'Rooms',
|
||||
phone: 'Phone', logistics: 'Logistics', podcasts: 'Podcasts', campaigns: 'Campaigns', audios: 'Audios', banking: 'Banking',
|
||||
bookmarks: 'Bookmarks', calendars: 'Calendars', chats: 'Chats', cipher: 'Cipher', courts: 'Courts', dev: 'Dev',
|
||||
docs: 'Docs', events: 'Events', favorites: 'Favorites', blogs: 'Blogs', fediverse: 'Fediverse', feed: 'Feed',
|
||||
files: 'Files', forum: 'Forum', games: 'Games', graphos: 'Graphos', housing: 'Housing', images: 'Images',
|
||||
industry: 'Industry', invites: 'Invites', jobs: 'Jobs', karvan: 'Karvan', larp: 'Larp', backup: 'Backup',
|
||||
logs: 'Logs', maps: 'Map', market: 'Market', melody: 'Melody', opinions: 'Opinions', pads: 'Pads',
|
||||
parliament: 'Parliament', pixelia: 'Pixelia', polls: 'Polls', projects: 'Projects', reports: 'Reports', school: 'School',
|
||||
shops: 'Shops', tags: 'Tags', tasks: 'Tasks', torrents: 'Torrents', transfers: 'Transfers', trending: 'Trending',
|
||||
tribes: 'Tribes', videos: 'Videos', votes: 'Votations', wallet: 'Wallet', wiki: 'Wiki'
|
||||
};
|
||||
// los que no van en el movil (los apaga tambien /save-modules)
|
||||
const MOBILE_UNAVAILABLE = ['ai', 'aiNav', 'dev', 'games', 'logs', 'maps', 'pixelia'];
|
||||
|
||||
const moduleLabel = (i18n, name) => i18n[`modules${STEMS[name]}Label`] || name;
|
||||
|
||||
const mobileModulesView = (opts = {}) => {
|
||||
const { template, i18n, LAUNCHER_GROUPS } = require('./main_views');
|
||||
const config = getConfig().modules || {};
|
||||
const visible = Object.keys(STEMS)
|
||||
.filter(name => !MOBILE_UNAVAILABLE.includes(name))
|
||||
.map(name => ({ name, label: moduleLabel(i18n, name), description: i18n[`modules${STEMS[name]}Description`] || '' }))
|
||||
.sort((x, y) => String(x.label).localeCompare(String(y.label), undefined, { sensitivity: 'base' }));
|
||||
const isOn = (m) => config[`${m.name}Mod`] === 'on';
|
||||
|
||||
// grupos del lanzador; Industria va con la economia y lo demas, al final
|
||||
const groupOf = {};
|
||||
(LAUNCHER_GROUPS || []).forEach(g => g.mods.forEach(m => { if (m.mod) groupOf[m.mod.replace(/Mod$/, '')] = g; }));
|
||||
const economy = (LAUNCHER_GROUPS || []).find(g => g.id === 'economy');
|
||||
if (economy && !groupOf.industry) groupOf.industry = economy;
|
||||
const groups = [];
|
||||
const byId = {};
|
||||
visible.forEach(mod => {
|
||||
const g = groupOf[mod.name] || { id: 'other', titleKey: 'menuTools' };
|
||||
if (!byId[g.id]) { byId[g.id] = { g, mods: [] }; groups.push(byId[g.id]); }
|
||||
byId[g.id].mods.push(mod);
|
||||
});
|
||||
const order = (LAUNCHER_GROUPS || []).map(g => g.id);
|
||||
const rank = (id) => { const i = order.indexOf(id); return i === -1 ? order.length : i; };
|
||||
groups.sort((x, y) => rank(x.g.id) - rank(y.g.id));
|
||||
|
||||
const blocked = opts.blocked && visible.find(m => m.name === 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}', visible.filter(isOn).length).replace('{total}', visible.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: isOn(mod) ? true : undefined
|
||||
})
|
||||
))
|
||||
)),
|
||||
div({ class: 'module-save-bar' },
|
||||
button({ type: 'submit', class: 'create-button' }, i18n.saveSettings)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
module.exports = { mobileModulesView, MOBILE_UNAVAILABLE, STEMS };
|
||||
171
src/views/mobile_ui.js
Normal file
171
src/views/mobile_ui.js
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
"use strict";
|
||||
// Piezas de interfaz de la rama movil que se enchufan en vistas de upstream.
|
||||
//
|
||||
// Las vistas (vote_view, tribes_view, settings_view, main_views, welcome_view) son del
|
||||
// dev y se reescriben a menudo (tribes_view en 23 de 39 releases, main_views en 31).
|
||||
// Aqui vive la logica; en cada vista queda una llamada con la bandera de movil, que es
|
||||
// lo unico que hay que volver a poner si el dev la reescribe.
|
||||
// Ver docs/FRONTERA_UPSTREAM.md.
|
||||
//
|
||||
// i18n se lee al llamar (main_views lo cambia en cada peticion y este fichero se carga
|
||||
// antes que el).
|
||||
|
||||
const { a, button, details, div, form, input, label, nav, p, progress, span, summary } = require("../server/node_modules/hyperaxe");
|
||||
const { iconNode } = require("./mobile_icons");
|
||||
|
||||
const isMobile = () => process.env.OASIS_MOBILE === '1';
|
||||
const t = () => require('./main_views').i18n;
|
||||
|
||||
// ---------- votaciones ----------
|
||||
|
||||
// tipos de votacion: el servidor ya guarda las opciones que se le pasen (campo
|
||||
// options de /votes/create); aqui solo se ofrecen las que usan las asambleas
|
||||
const VOTE_KINDS = [
|
||||
{ key: 'classic', options: null },
|
||||
{ key: 'traffic', options: ['GREEN', 'YELLOW', 'RED'] },
|
||||
{ key: 'scale', options: ['1', '2', '3', '4', '5'] }
|
||||
];
|
||||
|
||||
const voteKindPicker = (current) => {
|
||||
const i18n = t();
|
||||
const cur = String(current || '');
|
||||
return div({ class: 'vote-kind' },
|
||||
label({ class: 'vote-kind-title' }, i18n.voteKindLabel || 'Type of vote'),
|
||||
...VOTE_KINDS.map((k, i) => {
|
||||
const value = k.options ? k.options.join(',') : '';
|
||||
const checked = cur ? cur === value : i === 0;
|
||||
return label({ class: 'vote-kind-option' },
|
||||
input(Object.assign({ type: 'radio', name: 'options', value }, checked ? { checked: 'checked' } : {})),
|
||||
span({ class: 'vote-kind-name' }, i18n['voteKind_' + k.key] || k.key),
|
||||
span({ class: 'vote-kind-hint' }, i18n['voteKindHint_' + k.key] || '')
|
||||
);
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
// resultados como barras, una por opcion: la tabla de 6 columnas no cabia y partia
|
||||
// las palabras por silabas
|
||||
const voteBars = (voteOptions, baseCounts, voteLabel) => {
|
||||
const total = voteOptions.reduce((s, o) => s + (Number(baseCounts[o]) || 0), 0);
|
||||
return div({ class: 'vote-bars' },
|
||||
...voteOptions.map((opt) => {
|
||||
const n = Number(baseCounts[opt]) || 0;
|
||||
return div({ class: `vote-bar vote-bar-${String(opt).toLowerCase()}` },
|
||||
span({ class: 'vote-bar-label' }, voteLabel(opt)),
|
||||
span({ class: 'vote-bar-count' }, String(n)),
|
||||
progress({ class: 'vote-bar-meter', max: String(Math.max(total, 1)), value: String(n) })
|
||||
);
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
// quien crea una votacion no vota (regla de votes_model.js, no se toca): se explica
|
||||
const voteOwnNote = () => p({ class: 'vote-own-note' }, t().voteOwnNote || 'You created this vote, so you cannot vote in it.');
|
||||
|
||||
// ---------- tribus ----------
|
||||
|
||||
// una fila por tribu: con 12 comisiones la lista de tarjetas con la imagen generica
|
||||
// eran 10 pantallas. La imagen solo sale si la tribu tiene una propia
|
||||
const tribeRow = (tr, { own, imageNode }) => {
|
||||
const i18n = t();
|
||||
const href = `/tribe/${encodeURIComponent(tr.id)}`;
|
||||
const members = tr.memberCount != null ? tr.memberCount : (tr.members || []).length;
|
||||
const facts = [
|
||||
tr.isAnonymous ? i18n.tribePrivate : i18n.tribePublic,
|
||||
tr.inviteMode === 'open' ? i18n.tribeOpen : i18n.tribeStrict,
|
||||
`${members} ${String(i18n.tribeMembersCount || '').toLowerCase()}`
|
||||
].filter(Boolean).join(' · ');
|
||||
return a({ href, class: 'tribe-row' + (own ? ' own-content' : '') },
|
||||
tr.image && imageNode ? imageNode : iconNode('tribe-row-thumb tribe-row-icon', 'tribes'),
|
||||
span({ class: 'tribe-row-text' },
|
||||
span({ class: 'tribe-row-title' }, tr.title),
|
||||
span({ class: 'tribe-row-facts' }, facts),
|
||||
tr.description ? span({ class: 'tribe-row-desc' }, String(tr.description).slice(0, 90)) : null
|
||||
),
|
||||
span({ class: 'tribe-row-go', 'aria-hidden': 'true' }, '›')
|
||||
);
|
||||
};
|
||||
|
||||
// secciones de una tribu: 25 botones a todo lo ancho eran varias pantallas antes del
|
||||
// contenido. Tira de fichas con lo que mas usa una comision y el resto en "Mas"
|
||||
const TRIBE_MAIN_TABS = ['activity', 'inhabitants', 'governance', 'votations', 'forum', 'chats', 'events', 'pads'];
|
||||
const tribeTabs = (tribe, section, sections) => {
|
||||
const i18n = t();
|
||||
const all = sections.flatMap(g => g.items);
|
||||
const tab = (s) => a({ href: `/tribe/${encodeURIComponent(tribe.id)}?section=${encodeURIComponent(s.key)}`, class: section === s.key ? 'tribe-tab is-current' : 'tribe-tab', ...(section === s.key ? { 'aria-current': 'page' } : {}) }, s.label);
|
||||
const main = all.filter(s => TRIBE_MAIN_TABS.includes(s.key));
|
||||
const rest = all.filter(s => !TRIBE_MAIN_TABS.includes(s.key));
|
||||
const currentRest = rest.find(s => s.key === section);
|
||||
return nav({ class: 'tribe-tabs', 'aria-label': i18n.tribeSectionsLabel || 'Sections' },
|
||||
div({ class: 'tribe-tabs-strip' }, ...(currentRest ? [tab(currentRest)] : []), ...main.map(tab)),
|
||||
details({ class: 'tribe-tabs-more' },
|
||||
summary(i18n.tribeSectionsMore || 'More'),
|
||||
div({ class: 'tribe-tabs-grid' }, ...rest.filter(s => s !== currentRest).map(tab))
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
// ---------- ajustes ----------
|
||||
|
||||
// primero lo que se toca (idioma, aspecto, tipo de uso, portada) y despues lo tecnico;
|
||||
// se reordena por id de seccion, sin tocar las secciones
|
||||
const SETTINGS_FIRST = ['update', 'language', 'theme', 'workflows', 'home-page'];
|
||||
const settingsOrder = (parts) => {
|
||||
const list = parts.filter(Boolean);
|
||||
const rank = (el) => { const i = SETTINGS_FIRST.indexOf(el && el.id); return i === -1 ? SETTINGS_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);
|
||||
};
|
||||
|
||||
// cada idioma un boton que se aplica al tocarlo (antes desplegable y otro boton)
|
||||
const LANGS = [['Español', 'es'], ['Català', 'ca'], ['Euskara', 'eu'], ['Galego', 'gl'], ['English', 'en'], ['Français', 'fr'],
|
||||
['Português', 'pt'], ['Italiano', 'it'], ['Deutsch', 'de'], ['Русский', 'ru'], ['العربية', 'ar'], ['हिन्दी', 'hi'], ['中文', 'zh']];
|
||||
const languagePick = (selected) => form({ action: '/language', method: 'post', class: 'lang-pick' },
|
||||
...LANGS.map(([name, code]) => button({ type: 'submit', name: 'language', value: code, class: code === selected ? 'lang-chip is-current' : 'lang-chip', lang: code }, name))
|
||||
);
|
||||
|
||||
// ---------- acciones de tarjeta ----------
|
||||
|
||||
// borrar pide un segundo toque: la X abre "¿Borrar? Si, borrar" y solo ese boton
|
||||
// envia. Sin JS, con <details>. hidden: los <input type=hidden> que lleve el formulario
|
||||
const deleteConfirm = (action, hidden) => {
|
||||
const i18n = t();
|
||||
return details({ class: 'content-action-confirm' },
|
||||
summary({ class: 'btn-singleview btn-delete', title: i18n.chatDelete, 'aria-label': i18n.chatDelete }, require('./main_views').renderIcon('close')),
|
||||
div({ class: 'confirm-bar' },
|
||||
span({ class: 'confirm-question' }, i18n.deleteConfirmQuestion || 'Delete it for good?'),
|
||||
form({ method: 'POST', action, class: 'content-action-form' }, hidden || null,
|
||||
button({ type: 'submit', class: 'confirm-yes' }, i18n.deleteConfirmYes || 'Yes, delete'))
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
// ---------- pie y aviso de bienvenida ----------
|
||||
|
||||
// una linea: con cuanta gente estas sincronizando, dicho en claro, y la version. La
|
||||
// clave, HcT/HcH y la licencia estan en Perfil, Estadisticas y Ajustes
|
||||
const footerLine = (syncedPeers, version) => {
|
||||
const i18n = t();
|
||||
const n = Number(syncedPeers) || 0;
|
||||
const text = n > 0
|
||||
? String(i18n.footerPeersSome || '{n} peers in sync').replace('{n}', String(n))
|
||||
: (i18n.footerPeersNone || 'Not syncing with anyone yet');
|
||||
return div({ class: 'oasis-footer oasis-footer-mobile' },
|
||||
a({ href: '/peers', class: n > 0 ? 'footer-sync is-on' : 'footer-sync' },
|
||||
span({ class: 'footer-sync-dot', 'aria-hidden': 'true' }),
|
||||
span(text)
|
||||
),
|
||||
span({ class: 'footer-version' }, `Oasis ${version} · GPLv3`)
|
||||
);
|
||||
};
|
||||
|
||||
// el aviso de "pon tu avatar" solo en la portada (en cada pagina se comia una quinta
|
||||
// parte de la pantalla) y diciendo para que es
|
||||
const welcomeBannerHere = (here) => ['', '/', '/activity'].includes(here);
|
||||
const welcomeBannerText = () => t().welcomeBannerTextMobile || t().welcomeBannerText;
|
||||
const welcomeBannerAction = () => t().welcomeBannerActionMobile || t().welcomeBannerAction;
|
||||
|
||||
module.exports = {
|
||||
isMobile, VOTE_KINDS, voteKindPicker, voteBars, voteOwnNote, tribeRow, tribeTabs,
|
||||
settingsOrder, languagePick, deleteConfirm, footerLine,
|
||||
welcomeBannerHere, welcomeBannerText, welcomeBannerAction
|
||||
};
|
||||
|
|
@ -1,74 +1,9 @@
|
|||
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 { form, button, div, h2, p, section, table, tr, td, input, a, br, ul, li, span, strong } = require("../server/node_modules/hyperaxe");
|
||||
const { template, i18n } = require('./main_views');
|
||||
const { getConfig } = require('../configs/config-manager.js');
|
||||
const { PRESETS: PRESET_LIST } = require('../models/workflows_model');
|
||||
|
||||
// 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 modulesView = () => {
|
||||
const config = getConfig().modules;
|
||||
const modules = [
|
||||
{ name: 'agenda', label: i18n.modulesAgendaLabel, description: i18n.modulesAgendaDescription },
|
||||
|
|
@ -141,8 +76,6 @@ const modulesView = (_states, opts = {}) => {
|
|||
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;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ const { template, selectedLanguage, i18n, setLanguage, INBOX_BOT_ORDER, inboxBot
|
|||
const i18nBase = require("../client/assets/translations/i18n");
|
||||
const moment = require("../server/node_modules/moment");
|
||||
const { WORKFLOWS, currentWorkflow } = require('../models/workflows_model');
|
||||
const mobileUi = require('./mobile_ui');
|
||||
const html = require("../server/node_modules/hyperaxe");
|
||||
// selector de identidades: la implementacion vive en ./identities_view.js
|
||||
const { renderIdentities } = require('./identities_view')({ i18n, html });
|
||||
|
|
@ -139,15 +140,6 @@ 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,
|
||||
|
|
@ -156,13 +148,7 @@ const settingsView = (params) => {
|
|||
div({ class: "tags-header" },
|
||||
h2(i18n.language),
|
||||
p(i18n.languageDescription),
|
||||
// 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(
|
||||
isMobile ? mobileUi.languagePick(selectedLanguage) : form(
|
||||
{ action: "/language", method: "post" },
|
||||
select({ name: "language" }, [
|
||||
languageOption("English", "en"),
|
||||
|
|
@ -689,7 +675,7 @@ const settingsView = (params) => {
|
|||
modOn('fediverse') ? a({ class: filter === 'multiverse' ? 'filter-btn active' : 'filter-btn', href: '/settings?filter=multiverse' }, String(i18n.multiverse).toUpperCase()) : null
|
||||
)
|
||||
),
|
||||
...(filter === 'multiverse' ? multiverseParts : filter === 'content' ? contentParts : (isMobile ? mobileOrder(oasisParts) : oasisParts))
|
||||
...(filter === 'multiverse' ? multiverseParts : filter === 'content' ? contentParts : (isMobile ? mobileUi.settingsOrder(oasisParts) : oasisParts))
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +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, details, summary } = 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 } = 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 mobileUi = require('./mobile_ui');
|
||||
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');
|
||||
|
|
@ -305,7 +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 && mobileUi.isMobile() ? p({ class: 'form-hint' }, i18n.tribeHintPrivacy) : null,
|
||||
isSubEdit ? null : br(),
|
||||
isSubEdit ? null : br(),
|
||||
label({ for: 'inviteMode' }, i18n.tribeModeLabel),
|
||||
|
|
@ -314,7 +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,
|
||||
mobileUi.isMobile() ? p({ class: 'form-hint' }, i18n.tribeHintMode) : null,
|
||||
br(), br(),
|
||||
button({ type: 'submit' }, isEdit ? i18n.tribeUpdateButton : i18n.tribeCreateButton)
|
||||
)
|
||||
|
|
@ -324,32 +324,8 @@ 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);
|
||||
if (mobileUi.isMobile()) return mobileUi.tribeRow(t, { own: String(t.author) === String(userId), imageNode: t.image ? renderMediaBlob(t.image, null, { class: 'tribe-row-thumb' }) : null });
|
||||
const parentTribe = t.parentTribeId ? allT.find(p => p.id === t.parentTribeId) : null;
|
||||
|
||||
const isOwn = String(t.author) === String(userId);
|
||||
|
|
@ -404,7 +380,7 @@ exports.tribesView = async (tribes, filter, tribeId, query = {}, allTribes = nul
|
|||
: filter === 'gallery'
|
||||
? renderGallery(shownTribes)
|
||||
: tribeCards.length > 0
|
||||
? div({ class: process.env.OASIS_MOBILE === '1' ? 'tribe-list' : 'tribe-grid' }, tribeCards)
|
||||
? div({ class: mobileUi.isMobile() ? 'tribe-list' : 'tribe-grid' }, tribeCards)
|
||||
: div({ class: "no-content-box" }, p(i18n.noTribes))
|
||||
),
|
||||
...renderLightbox(shownTribes)
|
||||
|
|
@ -437,23 +413,7 @@ 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))
|
||||
)
|
||||
);
|
||||
}
|
||||
if (mobileUi.isMobile()) return mobileUi.tribeTabs(tribe, section, sections);
|
||||
return div({ class: 'tribe-section-nav no-border' },
|
||||
sections.map(g =>
|
||||
div({ class: 'tribe-section-group no-border' },
|
||||
|
|
|
|||
|
|
@ -1,21 +1,13 @@
|
|||
const { div, h2, p, section, button, form, a, textarea, br, input, table, tr, th, td, label, span, progress } = require("../server/node_modules/hyperaxe");
|
||||
const { div, h2, p, section, button, form, a, textarea, br, input, table, tr, th, td, label, span } = require("../server/node_modules/hyperaxe");
|
||||
const { renderCommentsSection: renderSharedCommentsSection } = require("./comments_view");
|
||||
const { template, i18n, renderOpinionsVoting, renderEngagement, renderOpenClosedChip, renderLifespanChip, renderEcoTax, renderSpreadButton, renderContentActions, renderSpreadEditWarning, renderDocumentActions, renderModuleStatsBy, moduleIsEmpty, contentDeleteAction, paged, renderCreatedRows } = require("./main_views");
|
||||
const moment = require("../server/node_modules/moment");
|
||||
const { config } = require("../server/SSB_server.js");
|
||||
const { renderTribeOriginChip } = require("./clearnet_view");
|
||||
const mobileUi = require("./mobile_ui");
|
||||
|
||||
const userId = config.keys.id;
|
||||
const VOTE_QUORUM = 2;
|
||||
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 : [];
|
||||
|
||||
|
|
@ -166,22 +158,6 @@ 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) => {
|
||||
|
|
@ -237,12 +213,8 @@ 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,
|
||||
mobileUi.isMobile() && !voteButtonsNode && isVoteAuthor(v) && normalizeStatus(v.status) === "OPEN" ? mobileUi.voteOwnNote() : null,
|
||||
voteButtonsNode
|
||||
? div({ class: "job-section" },
|
||||
h2({ class: "job-section-title" }, i18n.voteCastTitle || "Cast Vote"),
|
||||
|
|
@ -254,7 +226,7 @@ const renderVoteDetail = (v, voteOptionsDefault, firstRow, secondRow, mode, acti
|
|||
`${i18n.voteResults || "Results"}: `,
|
||||
span({ class: `vote-outcome vote-outcome-${outcome.variant}` }, outcome.text)
|
||||
),
|
||||
isMobile ? renderVoteBars(voteOptions, baseCounts) : div({ class: "vote-table" },
|
||||
mobileUi.isMobile() ? mobileUi.voteBars(voteOptions, baseCounts, voteLabel) : div({ class: "vote-table" },
|
||||
table(
|
||||
tr(...voteOptions.map((opt) => th(voteLabel(opt)))),
|
||||
tr(...voteOptions.map((opt) => td(baseCounts[opt])))
|
||||
|
|
@ -277,22 +249,6 @@ 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];
|
||||
|
||||
|
|
@ -397,7 +353,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,
|
||||
mobileUi.isMobile() && mode === "create" ? mobileUi.voteKindPicker(draft.options) : null,
|
||||
button({ type: "submit" }, mode === "edit" ? i18n.voteUpdateButton : i18n.voteCreateButton)
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ const { template, i18n, WIZARD_THEMES } = require("./main_views")
|
|||
const { renderRestoreStatus } = require("./backup_view")
|
||||
const { renderSuggestedPub } = require("./invites_view")
|
||||
const { config } = require("../server/SSB_server.js")
|
||||
const mobileUi = require("./mobile_ui")
|
||||
const { WIZARD_WORKFLOWS } = require("../models/workflows_model")
|
||||
|
||||
const FEED_TEXT_MIN = Number(config?.feed?.minLength ?? 1)
|
||||
|
|
@ -104,15 +105,6 @@ 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(
|
||||
|
|
@ -120,7 +112,12 @@ exports.welcomeView = async (status, profile = {}) => {
|
|||
h2(i18n.welcomeTitle),
|
||||
p(i18n.welcomeDescription)
|
||||
),
|
||||
mobile ? null : skipForm
|
||||
status.pending && !mobileUi.isMobile()
|
||||
? form({ method: "POST", action: "/welcome/dismiss", class: "welcome-action" },
|
||||
input({ type: "hidden", name: "returnTo", value: "/" }),
|
||||
button({ type: "submit" }, i18n.welcomeSkip)
|
||||
)
|
||||
: null
|
||||
),
|
||||
status.complete
|
||||
? section(
|
||||
|
|
@ -140,7 +137,12 @@ exports.welcomeView = async (status, profile = {}) => {
|
|||
stepAction(key, profile || {}, status)
|
||||
)
|
||||
)),
|
||||
mobile && skipForm ? section(skipForm) : null
|
||||
// movil: "Ahora no" al final y discreto (arriba era lo primero que se tocaba)
|
||||
status.pending && mobileUi.isMobile()
|
||||
? section(form({ method: "POST", action: "/welcome/dismiss", class: "welcome-action welcome-skip-end" },
|
||||
input({ type: "hidden", name: "returnTo", value: "/" }),
|
||||
button({ type: "submit", class: "welcome-skip-btn" }, i18n.welcomeSkip)))
|
||||
: null
|
||||
)
|
||||
return status.bootstrap && status.bootstrap.running ? html.replace('</head>', '<meta http-equiv="refresh" content="5"></head>') : html
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue