feat(mobile): paginador CSS-only en agenda, logs, inhabitants,

trending, calendars, banking, blockchain

- Nuevo helper src/views/mobile_pager.js — renderMobilePager(opts)
  genera el paginador con radios + flechas + cells de 2 botones.
- activity_view: refactor para usar el helper.
- agenda_view, logs_view, inhabitants_view, trending_view,
  calendars_view, banking_views, blockchain_view: añaden el
  mobilePager y marcan el contenedor desktop con .actpager-desktop-only.
- mobile.css: .actpager-desktop-only se oculta en móvil junto al
  grid antiguo de activity.

modules_view (4 botones) y stats_view (3 modos) no necesitan
paginación: caben en una fila.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
SITO 2026-05-09 01:35:37 +02:00
parent bb04d4cb64
commit 13161b2158
10 changed files with 204 additions and 68 deletions

View file

@ -399,8 +399,9 @@ textarea, input, select {
font-size: 16px !important;
}
/* En móvil ocultamos el grid antiguo; usa el paginador (.actpager) */
.activity-filter-grid {
/* En móvil ocultamos los grids de filtros antiguos; se usa el paginador (.actpager) */
.activity-filter-grid,
.actpager-desktop-only {
display: none !important;
}

View file

@ -1701,46 +1701,14 @@ exports.activityView = (actions, filter, userId, q = '') => {
});
}
// Mobile paginator (CSS-only, no JS): 2 buttons per page with arrow labels
const buildBtn = (cls, t, lab) => form({ method: 'GET', action: '/activity', class: cls },
input({ type: 'hidden', name: 'filter', value: t }),
button({ type: 'submit', class: filter === t ? 'filter-btn active' : 'filter-btn' }, lab)
);
const PAGE_SIZE = 2;
const totalPages = Math.ceil(activityTypes.length / PAGE_SIZE);
const pageCells = [];
for (let i = 0; i < totalPages; i++) {
const slice = activityTypes.slice(i * PAGE_SIZE, (i + 1) * PAGE_SIZE);
pageCells.push(div({ class: 'actpager-cell' },
slice.map(({ type, label }) => buildBtn('actpager-form', type, label))
));
}
const radios = [];
for (let i = 0; i < totalPages; i++) {
const attrs = { type: 'radio', name: 'actp', id: `actp${i}`, class: `actpager-radio actpager-r${i}` };
if (i === 0) attrs.checked = 'checked';
radios.push(input(attrs));
}
const haxe = require('../server/node_modules/hyperaxe');
const arrowSets = [];
for (let i = 0; i < totalPages; i++) {
const prev = i > 0
? haxe.label({ for: `actp${i - 1}`, class: 'actpager-arrow' }, '')
: haxe.span({ class: 'actpager-arrow disabled' }, '');
const next = i < totalPages - 1
? haxe.label({ for: `actp${i + 1}`, class: 'actpager-arrow' }, '')
: haxe.span({ class: 'actpager-arrow disabled' }, '');
arrowSets.push(div({ class: `actpager-arrows actpager-a${i}` }, prev, next));
}
const mobilePager = section({ class: 'actpager', style: 'display:none' },
...radios,
div({ class: 'actpager-frame' },
div({ class: 'actpager-clip' },
div({ class: 'actpager-row' }, ...pageCells)
),
div({ class: 'actpager-controls' }, ...arrowSets)
)
);
const { renderMobilePager } = require('./mobile_pager');
const mobilePager = renderMobilePager({
items: activityTypes,
idPrefix: 'actp',
formAction: '/activity',
currentFilter: filter,
pageSize: 2
});
let html = template(
title,

View file

@ -202,6 +202,29 @@ const renderAgendaItem = (item, userId, filter) => {
exports.agendaView = async (data, filter) => {
const { items = [], counts: _c = {} } = data || {};
const counts = { all: 0, open: 0, closed: 0, events: 0, tasks: 0, reports: 0, tribes: 0, jobs: 0, market: 0, projects: 0, transfers: 0, calendars: 0, discarded: 0, ..._c };
const { renderMobilePager } = require('./mobile_pager');
const agendaFilters = [
{ type: 'all', label: `${i18n.agendaFilterAll} (${counts.all})` },
{ type: 'open', label: `${i18n.agendaFilterOpen} (${counts.open})` },
{ type: 'closed', label: `${i18n.agendaFilterClosed} (${counts.closed})` },
{ type: 'events', label: `${i18n.agendaFilterEvents} (${counts.events})` },
{ type: 'tasks', label: `${i18n.agendaFilterTasks} (${counts.tasks})` },
{ type: 'reports', label: `${i18n.agendaFilterReports} (${counts.reports})` },
{ type: 'tribes', label: `${i18n.agendaFilterTribes} (${counts.tribes})` },
{ type: 'jobs', label: `${i18n.agendaFilterJobs} (${counts.jobs})` },
{ type: 'market', label: `${i18n.agendaFilterMarket} (${counts.market})` },
{ type: 'projects', label: `${i18n.agendaFilterProjects} (${counts.projects})` },
{ type: 'calendars', label: `${i18n.agendaFilterCalendars || 'CALENDARS'} (${counts.calendars})` },
{ type: 'transfers', label: `${i18n.agendaFilterTransfers} (${counts.transfers})` },
{ type: 'discarded', label: `DISCARDED (${counts.discarded})` }
];
const mobilePager = renderMobilePager({
items: agendaFilters,
idPrefix: 'agp',
formAction: '/agenda',
currentFilter: filter,
pageSize: 2
});
return template(
i18n.agendaTitle,
section(
@ -209,7 +232,8 @@ exports.agendaView = async (data, filter) => {
h2(i18n.agendaTitle),
p(i18n.agendaDescription)
),
div({ class: 'filters' },
mobilePager,
div({ class: 'filters actpager-desktop-only' },
form({ method: 'GET', action: '/agenda' },
button({ type: 'submit', name: 'filter', value: 'all', class: filter === 'all' ? 'filter-btn active' : 'filter-btn' },
`${i18n.agendaFilterAll} (${counts.all})`),

View file

@ -312,13 +312,22 @@ const renderAddresses = (data, userId) => {
);
};
const renderBankingView = (data, filter, userId, isPub) =>
template(
const renderBankingView = (data, filter, userId, isPub) => {
const { renderMobilePager } = require('./mobile_pager');
const bankingFilters = ["overview","exchange","mine","pending","closed","claimed","expired","epochs","rules","addresses"];
const items = bankingFilters.map(m => ({ type: m, label: (FILTER_LABELS[m] || m).toUpperCase() }));
const bankingMobilePager = renderMobilePager({
items, idPrefix: 'bnkp', formAction: '/banking', currentFilter: filter, pageSize: 2
});
return template(
i18n.banking,
section(
div({ class: "tags-header" }, h2(i18n.banking), p(i18n.bankingDescription)),
data.flash ? div({ class: "flash-banner" }, p(flashText(data.flash) || data.flash)) : null,
generateFilterButtons(["overview","exchange","mine","pending","closed","claimed","expired","epochs","rules","addresses"], filter, "/banking"),
bankingMobilePager,
div({ class: "actpager-desktop-only" },
generateFilterButtons(bankingFilters, filter, "/banking")
),
filter === "overview"
? div(
renderOverviewSummaryTable(data.summary || {}, data.rules),
@ -339,6 +348,7 @@ const renderBankingView = (data, filter, userId, isPub) =>
)
)
);
};
const renderSingleAllocationView = (alloc, userId) => {
if (!alloc) return template(i18n.banking, section(div(p(i18n.bankNoAllocations))));

View file

@ -296,7 +296,21 @@ const renderSingleBlockView = (block, filter = 'recent', userId, search = {}, vi
h2(i18n.blockchain),
p(i18n.blockchainDescription)
),
div({ class: 'mode-buttons-row' },
(() => {
const { renderMobilePager } = require('./mobile_pager');
const allFilters = [...BASE_FILTERS, ...CAT_BLOCK1, ...CAT_BLOCK2, ...CAT_BLOCK3, ...CAT_BLOCK4];
return renderMobilePager({
items: allFilters.map(m => ({ type: m, label: (FILTER_LABELS[m] || m).toUpperCase() })),
idPrefix: 'bxp',
formAction: '/blockexplorer',
currentFilter: filter,
pageSize: 2,
extraHidden: Object.fromEntries(
(search && typeof search === 'object' ? Object.entries(search) : []).filter(([_,v]) => v != null && v !== '')
)
});
})(),
div({ class: 'mode-buttons-row actpager-desktop-only' },
div({ class: 'filter-column' },
generateFilterButtons(BASE_FILTERS, filter, '/blockexplorer', search)
),

View file

@ -26,20 +26,27 @@ const renderCalendarFavoriteToggle = (cal, returnTo) =>
button({ type: "submit", class: "tribe-action-btn" }, cal.isFavorite ? (i18n.calendarRemoveFavorite || "Remove Favorite") : (i18n.calendarAddFavorite || "Add Favorite"))
)
const renderModeButtons = (currentFilter) =>
div({ class: "tribe-mode-buttons" },
["all", "mine", "recent", "favorites", "open", "closed"].map(f =>
const renderModeButtons = (currentFilter) => {
const { renderMobilePager } = require('./mobile_pager');
const filters = ["all", "mine", "recent", "favorites", "open", "closed"];
const items = filters.map(f => ({ type: f, label: i18n[`calendarFilter${f.charAt(0).toUpperCase() + f.slice(1)}`] || f.toUpperCase() }));
return [
renderMobilePager({ items, idPrefix: 'calp', formAction: '/calendars', currentFilter, pageSize: 2 }),
div({ class: "tribe-mode-buttons actpager-desktop-only" },
filters.map(f =>
form({ method: "GET", action: "/calendars" },
input({ type: "hidden", name: "filter", value: f }),
button({ type: "submit", class: currentFilter === f ? "filter-btn active" : "filter-btn" },
i18n[`calendarFilter${f.charAt(0).toUpperCase() + f.slice(1)}`] || f.toUpperCase())
)
),
form({ method: "GET", action: "/calendars" },
input({ type: "hidden", name: "filter", value: f }),
button({ type: "submit", class: currentFilter === f ? "filter-btn active" : "filter-btn" },
i18n[`calendarFilter${f.charAt(0).toUpperCase() + f.slice(1)}`] || f.toUpperCase())
input({ type: "hidden", name: "filter", value: "create" }),
button({ type: "submit", class: "create-button" }, i18n.calendarCreate || "Create Calendar")
)
),
form({ method: "GET", action: "/calendars" },
input({ type: "hidden", name: "filter", value: "create" }),
button({ type: "submit", class: "create-button" }, i18n.calendarCreate || "Create Calendar")
)
)
];
}
const renderStatus = (cal) => {
if (cal.isClosed) return span({ class: "pad-status-closed" }, i18n.calendarStatusClosed || "CLOSED")

View file

@ -223,7 +223,17 @@ exports.inhabitantsView = async (inhabitants, filter, query, currentUserId) => {
button({ type: 'submit' }, i18n.applyFilters)
)
),
div({ class: 'inhabitant-action' },
(() => {
const { renderMobilePager } = require('./mobile_pager');
return renderMobilePager({
items: filters.map(mode => ({ type: mode, label: i18n[mode + 'Button'] || i18n[mode + 'SectionTitle'] || mode })),
idPrefix: 'inhp',
formAction: '/inhabitants',
currentFilter: filter,
pageSize: 2
});
})(),
div({ class: 'inhabitant-action actpager-desktop-only' },
...generateFilterButtons(filters, filter)
),
filter === 'GALLERY'

View file

@ -18,14 +18,26 @@ const filterLabel = (f) => {
return map[f] || f.toUpperCase();
};
const renderFilterBar = (current) =>
div({ class: "logs-toolbar" },
form({ method: "GET", action: "/logs", class: "logs-toolbar-inline" },
FILTERS.map(f =>
button({
type: "submit", name: "filter", value: f,
class: current === f ? "filter-btn active" : "filter-btn"
}, filterLabel(f))
const renderFilterBar = (current) => {
const { renderMobilePager } = require('./mobile_pager');
const items = FILTERS.map(f => ({ type: f, label: filterLabel(f) }));
const mobilePager = renderMobilePager({
items,
idPrefix: 'logp',
formAction: '/logs',
currentFilter: current,
pageSize: 2
});
return div({ class: "logs-toolbar" },
mobilePager,
div({ class: "actpager-desktop-only logs-toolbar-desktop" },
form({ method: "GET", action: "/logs", class: "logs-toolbar-inline" },
FILTERS.map(f =>
button({
type: "submit", name: "filter", value: f,
class: current === f ? "filter-btn active" : "filter-btn"
}, filterLabel(f))
)
)
),
form({ method: "GET", action: "/logs", class: "logs-toolbar-inline" },
@ -36,6 +48,7 @@ const renderFilterBar = (current) =>
button({ type: "submit", class: "create-button" }, i18n.logsExport || 'Export Logs')
)
);
};
const renderSearchBox = (current, search) => {
const q = search || {};

View file

@ -0,0 +1,76 @@
"use strict";
const { div, input, span, label, form, button, section, a } = require('../server/node_modules/hyperaxe');
function renderMobilePager(opts) {
const items = Array.isArray(opts.items) ? opts.items : [];
const idPrefix = opts.idPrefix || 'actp';
const formAction = opts.formAction || '';
const currentFilter = opts.currentFilter;
const pageSize = Number.isFinite(opts.pageSize) ? opts.pageSize : 2;
const paramName = opts.paramName || 'filter';
const buttonClass = opts.buttonClass || 'filter-btn';
const useLinks = !!opts.useLinks;
const linkBuilder = typeof opts.linkBuilder === 'function' ? opts.linkBuilder : null;
const extraHidden = opts.extraHidden && typeof opts.extraHidden === 'object' ? opts.extraHidden : null;
if (!items.length) return null;
const totalPages = Math.ceil(items.length / pageSize);
const radios = [];
for (let i = 0; i < totalPages; i++) {
const attrs = { type: 'radio', name: idPrefix, id: `${idPrefix}${i}`, class: `actpager-radio actpager-r${i}` };
if (i === 0) attrs.checked = 'checked';
radios.push(input(attrs));
}
const cells = [];
for (let i = 0; i < totalPages; i++) {
const slice = items.slice(i * pageSize, (i + 1) * pageSize);
cells.push(div({ class: 'actpager-cell' },
slice.map(item => {
const t = item.type ?? item.value;
const lbl = item.label ?? item.text ?? String(t);
const isActive = item.active != null ? !!item.active : (currentFilter === t);
const cls = `${buttonClass}${isActive ? ' active' : ''}`;
if (useLinks || linkBuilder) {
const href = linkBuilder ? linkBuilder(item) : (item.href || `${formAction}?${paramName}=${encodeURIComponent(t)}`);
return a({ href, class: cls }, lbl);
}
const hiddens = [input({ type: 'hidden', name: paramName, value: t })];
if (extraHidden) {
for (const k of Object.keys(extraHidden)) {
hiddens.push(input({ type: 'hidden', name: k, value: extraHidden[k] }));
}
}
return form({ method: 'GET', action: formAction, class: 'actpager-form' },
...hiddens,
button({ type: 'submit', class: cls }, lbl)
);
})
));
}
const arrowSets = [];
for (let i = 0; i < totalPages; i++) {
const prev = i > 0
? label({ for: `${idPrefix}${i - 1}`, class: 'actpager-arrow' }, '')
: span({ class: 'actpager-arrow disabled' }, '');
const next = i < totalPages - 1
? label({ for: `${idPrefix}${i + 1}`, class: 'actpager-arrow' }, '')
: span({ class: 'actpager-arrow disabled' }, '');
arrowSets.push(div({ class: `actpager-arrows actpager-a${i}` }, prev, next));
}
return section({ class: 'actpager', style: 'display:none' },
...radios,
div({ class: 'actpager-frame' },
div({ class: 'actpager-clip' },
div({ class: 'actpager-row' }, ...cells)
),
div({ class: 'actpager-controls' }, ...arrowSets)
)
);
}
module.exports = { renderMobilePager };

View file

@ -277,12 +277,25 @@ exports.trendingView = (items, filter, categories = opinionCategories) => {
const hasDocument = filteredItems.some(item => item.value.content.type === 'document');
const allTrendingFilters = [
...baseFilters.map(m => ({ type: m, label: i18n[m + 'Button'] || m })),
...contentFilters.flat().map(m => ({ type: m, label: i18n[m + 'Button'] || m }))
];
const { renderMobilePager } = require('./mobile_pager');
const trendingMobilePager = renderMobilePager({
items: allTrendingFilters,
idPrefix: 'trp',
formAction: '/trending',
currentFilter: filter,
pageSize: 2
});
let html = template(
title,
section(
header,
trendingMobilePager,
div(
{ class: 'mode-buttons' },
{ class: 'mode-buttons actpager-desktop-only' },
generateFilterButtons(baseFilters, filter, '/trending'),
...contentFilters.map(row =>
div({ style: 'display:flex;flex-direction:column;gap:8px;' },