Configurable bottom bar and empty-state helper

- Bottom bar: replace Activity with a + slot to pin a module; hide slots for disabled modules.
- Add a reusable empty-state helper and the inbox empty state.
- Mobile styles and translations for the above.
This commit is contained in:
s1to 2026-07-09 16:44:17 +02:00
parent 851042e576
commit 9c100ed773
13 changed files with 252 additions and 10 deletions

View file

@ -112,6 +112,42 @@ const renderContentActions = (msgId, viewHref) => {
};
exports.renderContentActions = renderContentActions;
const emptyState = ({ icon, title, text, ctaHref, ctaLabel }) => div(
{ class: "empty-state" },
icon ? span({ class: "empty-state-icon" }, icon) : "",
title ? p({ class: "empty-state-title" }, title) : "",
text ? p({ class: "empty-state-text" }, text) : "",
(ctaHref && ctaLabel) ? a({ class: "empty-state-cta filter-btn", href: ctaHref }, ctaLabel) : ""
);
exports.emptyState = emptyState;
exports.bottomBarPickerView = () => {
const cfg = getConfig();
const modOn = (m) => !m || cfg.modules[m] === 'on' || cfg.modules[m] === undefined;
const current = cfg.bottomBarPin || '';
const pick = (key, glyph, labelText) => form({ method: 'POST', action: '/settings/bottombar', class: 'bb-pick-form' },
input({ type: 'hidden', name: 'pin', value: key }),
button({ type: 'submit', class: current === key ? 'bb-pick-btn active' : 'bb-pick-btn' },
span({ class: 'bb-pick-ico' }, glyph),
span({ class: 'bb-pick-lbl' }, labelText)
)
);
const items = exports.PINNABLE_MODULES.filter(m => modOn(m.mod)).map(m => pick(m.key, m.glyph, i18n[m.labelKey] || m.key));
return template(
i18n.bbPickTitle || 'Pin a module',
section(
div({ class: 'tags-header' },
h2(i18n.bbPickTitle || 'Pin a module'),
p(i18n.bbPickDesc || 'Choose a module for the + slot in the bottom bar.')
),
div({ class: 'bb-pick-grid' },
pick('', '✕', i18n.bbPickNone || 'None'),
...items
)
)
);
};
exports.renderStateChip = renderStateChip;
exports.renderOpenClosedChip = renderOpenClosedChip;
exports.renderVisibilityChip = renderVisibilityChip;
@ -1080,24 +1116,76 @@ const renderMobileTopbar = () => div(
a({ class: "omt-avatar", href: "/profile" }, img({ src: (typeof global !== "undefined" && global.__OASIS_SELF_AVATAR__) || "/assets/images/default-avatar.png", alt: i18n.profile || "Profile" }))
);
const PINNABLE_MODULES = [
{ key: 'tribes', href: '/tribes', glyph: 'ꖥ', labelKey: 'tribesTitle', mod: 'tribesMod' },
{ key: 'chats', href: '/chats', glyph: 'ꖒ', labelKey: 'chatsTitle', mod: 'chatsMod' },
{ key: 'market', href: '/market', glyph: 'ꕻ', labelKey: 'marketTitle', mod: 'marketMod' },
{ key: 'events', href: '/events', glyph: 'ꕆ', labelKey: 'eventsLabel', mod: 'eventsMod' },
{ key: 'calendars', href: '/calendars', glyph: 'ꖯ', labelKey: 'calendarsTitle', mod: 'calendarsMod' },
{ key: 'banking', href: '/banking', glyph: 'ꗴ', labelKey: 'bankingTitle', mod: 'bankingMod' },
{ key: 'forum', href: '/forum', glyph: 'ꕒ', labelKey: 'forumTitle', mod: 'forumMod' },
{ key: 'videos', href: '/videos', glyph: 'ꗟ', labelKey: 'videosLabel', mod: 'videosMod' },
{ key: 'images', href: '/images', glyph: 'ꕥ', labelKey: 'imagesLabel', mod: 'imagesMod' },
{ key: 'audios', href: '/audios', glyph: 'ꔿ', labelKey: 'audiosLabel', mod: 'audiosMod' },
{ key: 'documents', href: '/documents', glyph: 'ꕨ', labelKey: 'docsLabel', mod: 'docsMod' },
{ key: 'bookmarks', href: '/bookmarks', glyph: 'ꔪ', labelKey: 'bookmarksLabel', mod: 'bookmarksMod' },
{ key: 'maps', href: '/maps', glyph: 'ꔌ', labelKey: 'mapsLabel', mod: 'mapsMod' },
{ key: 'trending', href: '/trending', glyph: 'ꗝ', labelKey: 'trendingLabel', mod: 'trendingMod' },
{ key: 'jobs', href: '/jobs', glyph: 'ꗒ', labelKey: 'jobsTitle', mod: 'jobsMod' },
{ key: 'agenda', href: '/agenda', glyph: 'ꗤ', labelKey: 'agendaTitle', mod: 'agendaMod' },
{ key: 'wallet', href: '/wallet', glyph: '❄', labelKey: 'wallet', mod: 'walletMod' },
{ key: 'tasks', href: '/tasks', glyph: 'ꖧ', labelKey: 'tasksTitle', mod: 'tasksMod' },
{ key: 'torrents', href: '/torrents', glyph: 'ꖅ', labelKey: 'torrentsLabel', mod: 'torrentsMod' },
{ key: 'tags', href: '/tags', glyph: 'ꖶ', labelKey: 'tagsLabel', mod: 'tagsMod' },
{ key: 'pads', href: '/pads', glyph: 'ꔗ', labelKey: 'padsTitle', mod: 'padsMod' },
{ key: 'projects', href: '/projects', glyph: 'ꕧ', labelKey: 'projectsTitle', mod: 'projectsMod' },
{ key: 'shops', href: '/shops', glyph: 'ꔜ', labelKey: 'shopsTitle', mod: 'shopsMod' },
{ key: 'transfers', href: '/transfers', glyph: 'ꘉ', labelKey: 'transfersTitle', mod: 'transfersMod' },
{ key: 'feed', href: '/feed', glyph: 'ꕿ', labelKey: 'feedTitle', mod: 'feedMod' },
{ key: 'pixelia', href: '/pixelia', glyph: 'ꔘ', labelKey: 'pixeliaTitle', mod: 'pixeliaMod' },
{ key: 'melody', href: '/melody', glyph: '♪', labelKey: 'melodyTitle', mod: 'melodyMod' },
{ key: 'games', href: '/games', glyph: 'ꕇ', labelKey: 'gamesTitle', mod: 'gamesMod' },
{ key: 'votes', href: '/votes', glyph: 'ꔰ', labelKey: 'votationsTitle', mod: 'votesMod' },
{ key: 'reports', href: '/reports', glyph: 'ꕥ', labelKey: 'reportsTitle', mod: 'reportsMod' },
{ key: 'opinions', href: '/opinions', glyph: 'ꔍ', labelKey: 'opinionsTitle', mod: 'opinionsMod' },
{ key: 'parliament',href: '/parliament',glyph: 'ꗞ', labelKey: 'parliamentTitle',mod: 'parliamentMod' },
{ key: 'courts', href: '/courts', glyph: 'ꖻ', labelKey: 'courtsTitle', mod: 'courtsMod' },
{ key: 'larp', href: '/larp', glyph: '✦', labelKey: 'larpTitle', mod: 'larpMod' },
{ key: 'ai', href: '/ai', glyph: 'ꘜ', labelKey: 'ai', mod: 'aiMod' },
{ key: 'cipher', href: '/cipher', glyph: 'ꗄ', labelKey: 'cipher', mod: 'cipherMod' },
{ key: 'fediverse', href: '/fediverse', glyph: 'ꗵ', labelKey: 'fediverseTimeline', mod: 'fediverseMod' },
{ key: 'inhabitants', href: '/inhabitants', glyph: 'ꖘ', labelKey: 'inhabitantsLabel', mod: null },
{ key: 'cv', href: '/cv', glyph: 'ꕛ', labelKey: 'cvTitle', mod: null },
{ key: 'stats', href: '/stats', glyph: 'ꕷ', labelKey: 'statistics', mod: null },
{ key: 'blockexplorer', href: '/blockexplorer', glyph: 'ꖸ', labelKey: 'blockchain', mod: null },
];
exports.PINNABLE_MODULES = PINNABLE_MODULES;
const renderBottomBar = () => {
const inboxN = sharedState.getInboxCount();
const cfg = getConfig();
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),
(badge && badge > 0) ? span({ class: "bb-badge" }, String(badge)) : "",
span({ class: "bb-lbl" }, label)
);
const pinned = PINNABLE_MODULES.find(m => m.key === (cfg.bottomBarPin || '') && modOn(m.mod));
const pinSlot = pinned
? bbLink(pinned.href, pinned.glyph, i18n[pinned.labelKey] || pinned.key)
: a({ class: "bb-item bb-add", href: "/settings/bottombar" },
span({ class: "bb-ico" }, "+"),
span({ class: "bb-lbl" }, i18n.bbAdd || "Add"));
const pair = [ bbLink("/peers", "⧖", i18n.peers || "Peers") ];
if (modOn('invitesMod')) pair.push(bbLink("/invites", "ꔮ", i18n.invites || "Invites"));
return nav(
{ class: "oasis-bottombar", style: "display:none", "aria-label": "Quick actions" },
bbLink("/search", "ꔅ", i18n.searchTitle || "Buscar"),
bbLink("/inbox", "☂", i18n.inbox || "Inbox", inboxN),
bbLink("/publish", "❂", i18n.publish || "Write"),
bbLink("/activity", "ꔙ", i18n.activityTitle || "Activity"),
div({ class: "bb-pair" },
bbLink("/peers", "⧖", i18n.peers || "Peers"),
bbLink("/invites", "ꔮ", i18n.invites || "Invites")
)
{ class: "oasis-bottombar", style: "display:none", "aria-label": i18n.bbQuickActions || "Quick actions" },
bbLink("/search", "ꔅ", i18n.searchTitle || "Search"),
bbLink("/inbox", "☂", i18n.inbox || "Inbox", inboxN),
bbLink("/publish", "❂", i18n.publish || "Write"),
pinSlot,
div({ class: "bb-pair" }, ...pair)
);
};
@ -3402,7 +3490,7 @@ exports.privateView = async (messagesInput, filter, decrypted = null) => {
threadGroups[tid].push(msg)
}
if (!threadOrder.length) return p({ class: 'empty' }, i18n.noPrivateMessages)
if (!threadOrder.length) return emptyState({ icon: "✉", title: i18n.noPrivateMessages, text: i18n.emptyConnectHint, ctaHref: "/invites", ctaLabel: i18n.emptyConnectCta })
return threadOrder.map(tid => {
const msgs = threadGroups[tid]