mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-18 19:44:21 +00:00
feat(web): show sub-entity name in document title on detail pages
Detail pages (plugin / MCP / pipeline / knowledge base / skill) only showed the type in the tab title. Drive the /home document title from HomeLayout, which has the selected entity name via context: '<entity> · <type> · LangBot' when a sub-entity is open, '<type> · LangBot' otherwise. The top-level hook now skips /home and only handles login/register/reset-password/wizard. Type label falls back to a route-derived i18n key on direct page loads.
This commit is contained in:
@@ -42,6 +42,7 @@ import {
|
|||||||
PluginInstallTaskProvider,
|
PluginInstallTaskProvider,
|
||||||
PluginInstallProgressDialog,
|
PluginInstallProgressDialog,
|
||||||
} from '@/app/home/plugins/components/plugin-install-task';
|
} from '@/app/home/plugins/components/plugin-install-task';
|
||||||
|
import { setDocumentTitle } from '@/hooks/useDocumentTitle';
|
||||||
|
|
||||||
// Routes that belong to the "Extensions" section
|
// Routes that belong to the "Extensions" section
|
||||||
const EXTENSIONS_ROUTES = [
|
const EXTENSIONS_ROUTES = [
|
||||||
@@ -52,6 +53,28 @@ const EXTENSIONS_ROUTES = [
|
|||||||
'/home/plugin-pages',
|
'/home/plugin-pages',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Map a /home route to the i18n key for its type-level title. Used as a robust
|
||||||
|
// fallback for the document title on direct page loads, before the sidebar's
|
||||||
|
// onSelectedChange has populated the local `title` state. Detail routes reuse
|
||||||
|
// the section key (prefix match), e.g. /home/mcp?id=... -> mcp.title.
|
||||||
|
const HOME_TITLE_KEYS: { match: (path: string) => boolean; key: string }[] = [
|
||||||
|
{ match: (p) => p.startsWith('/home/monitoring'), key: 'monitoring.title' },
|
||||||
|
{ match: (p) => p.startsWith('/home/bots'), key: 'bots.title' },
|
||||||
|
{ match: (p) => p.startsWith('/home/pipelines'), key: 'pipelines.title' },
|
||||||
|
{
|
||||||
|
match: (p) => p.startsWith('/home/add-extension'),
|
||||||
|
key: 'sidebar.addExtension',
|
||||||
|
},
|
||||||
|
{ match: (p) => p.startsWith('/home/extensions'), key: 'plugins.title' },
|
||||||
|
{ match: (p) => p.startsWith('/home/mcp'), key: 'mcp.title' },
|
||||||
|
{ match: (p) => p.startsWith('/home/knowledge'), key: 'knowledge.title' },
|
||||||
|
{ match: (p) => p.startsWith('/home/skills'), key: 'skills.title' },
|
||||||
|
{
|
||||||
|
match: (p) => p.startsWith('/home/plugin-pages'),
|
||||||
|
key: 'sidebar.pluginPages',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
function isExtensionsRoute(pathname: string): boolean {
|
function isExtensionsRoute(pathname: string): boolean {
|
||||||
return EXTENSIONS_ROUTES.some(
|
return EXTENSIONS_ROUTES.some(
|
||||||
(route) => pathname === route || pathname.startsWith(route + '/'),
|
(route) => pathname === route || pathname.startsWith(route + '/'),
|
||||||
@@ -147,6 +170,20 @@ function HomeLayoutInner({ children }: { children: React.ReactNode }) {
|
|||||||
: t('sidebar.home');
|
: t('sidebar.home');
|
||||||
const sectionLink = isExtensions ? '/home/extensions' : '/home/monitoring';
|
const sectionLink = isExtensions ? '/home/extensions' : '/home/monitoring';
|
||||||
|
|
||||||
|
// Drive the browser tab title for the /home section. The type-level label
|
||||||
|
// prefers the sidebar-provided `title`, falling back to a route-derived key on
|
||||||
|
// direct page loads. When a sub-entity (plugin / MCP / pipeline / KB / skill)
|
||||||
|
// is open, its name is prepended: "<entity> · <type> · LangBot".
|
||||||
|
useEffect(() => {
|
||||||
|
const routeEntry = HOME_TITLE_KEYS.find((e) => e.match(pathname));
|
||||||
|
const fallbackType =
|
||||||
|
routeEntry && t(routeEntry.key) !== routeEntry.key
|
||||||
|
? t(routeEntry.key)
|
||||||
|
: null;
|
||||||
|
const typeLabel = title || fallbackType;
|
||||||
|
setDocumentTitle(detailEntityName, typeLabel);
|
||||||
|
}, [pathname, title, detailEntityName, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
<Suspense fallback={<div />}>
|
<Suspense fallback={<div />}>
|
||||||
|
|||||||
@@ -4,56 +4,62 @@ import { useTranslation } from 'react-i18next';
|
|||||||
|
|
||||||
const APP_NAME = 'LangBot';
|
const APP_NAME = 'LangBot';
|
||||||
|
|
||||||
// Map a route path to the i18n key used for its document title. Detail routes
|
// Map a route path to the i18n key used for its (type-level) document title.
|
||||||
// reuse the section key (e.g. /home/bots and any future /home/bots/:id both
|
// Reuses existing translation keys so titles stay in sync with the sidebar and
|
||||||
// resolve to bots.title). Reuses existing translation keys so titles stay in
|
// page headers across all locales. The /home/* section is intentionally NOT
|
||||||
// sync with the sidebar and page headers across all locales.
|
// listed here: those titles are driven from inside HomeLayout, which has access
|
||||||
|
// to the currently-selected sub-entity name (detailEntityName) via context and
|
||||||
|
// renders "<entity> · <type> · LangBot".
|
||||||
const ROUTE_TITLE_KEYS: { match: (path: string) => boolean; key: string }[] = [
|
const ROUTE_TITLE_KEYS: { match: (path: string) => boolean; key: string }[] = [
|
||||||
{ match: (p) => p === '/login', key: 'common.login' },
|
{ match: (p) => p === '/login', key: 'common.login' },
|
||||||
{ match: (p) => p === '/register', key: 'register.title' },
|
{ match: (p) => p === '/register', key: 'register.title' },
|
||||||
{ match: (p) => p === '/reset-password', key: 'resetPassword.title' },
|
{ match: (p) => p === '/reset-password', key: 'resetPassword.title' },
|
||||||
{ match: (p) => p === '/wizard', key: 'sidebar.quickStart' },
|
{ match: (p) => p === '/wizard', key: 'sidebar.quickStart' },
|
||||||
{ match: (p) => p.startsWith('/home/monitoring'), key: 'monitoring.title' },
|
|
||||||
{ match: (p) => p.startsWith('/home/bots'), key: 'bots.title' },
|
|
||||||
{ match: (p) => p.startsWith('/home/pipelines'), key: 'pipelines.title' },
|
|
||||||
{
|
|
||||||
match: (p) => p.startsWith('/home/add-extension'),
|
|
||||||
key: 'sidebar.addExtension',
|
|
||||||
},
|
|
||||||
{ match: (p) => p.startsWith('/home/extensions'), key: 'plugins.title' },
|
|
||||||
{ match: (p) => p.startsWith('/home/mcp'), key: 'mcp.title' },
|
|
||||||
{ match: (p) => p.startsWith('/home/knowledge'), key: 'knowledge.title' },
|
|
||||||
{ match: (p) => p.startsWith('/home/skills'), key: 'skills.title' },
|
|
||||||
{
|
|
||||||
match: (p) => p.startsWith('/home/plugin-pages'),
|
|
||||||
key: 'sidebar.pluginPages',
|
|
||||||
},
|
|
||||||
// /home (and anything else under it) falls back to the dashboard.
|
|
||||||
{ match: (p) => p.startsWith('/home'), key: 'monitoring.title' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Keeps document.title in sync with the current route, formatted as
|
* Builds a "<...parts> · LangBot" document title from the given page-name parts,
|
||||||
* "<page> · LangBot". On routes with no specific mapping (or before i18n is
|
* dropping empties. Falls back to the bare app name when no parts resolve.
|
||||||
* ready) it falls back to the bare app name. Re-runs on navigation and on
|
*/
|
||||||
* language change so the title is always localized.
|
export function buildDocumentTitle(
|
||||||
|
...parts: (string | null | undefined)[]
|
||||||
|
): string {
|
||||||
|
const clean = parts.filter((p): p is string => !!p && p.trim().length > 0);
|
||||||
|
return clean.length > 0 ? `${clean.join(' · ')} · ${APP_NAME}` : APP_NAME;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Imperatively set the document title. Centralized so the format stays
|
||||||
|
* consistent across the top-level layout and the home layout.
|
||||||
|
*/
|
||||||
|
export function setDocumentTitle(
|
||||||
|
...parts: (string | null | undefined)[]
|
||||||
|
): void {
|
||||||
|
document.title = buildDocumentTitle(...parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top-level document-title driver for routes OUTSIDE the /home section
|
||||||
|
* (login, register, reset-password, wizard, and any unmapped route). The /home
|
||||||
|
* section manages its own title from HomeLayout so it can include the selected
|
||||||
|
* sub-entity name. Re-runs on navigation and language change so the title stays
|
||||||
|
* localized.
|
||||||
*/
|
*/
|
||||||
export function useDocumentTitle() {
|
export function useDocumentTitle() {
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Home routes are handled by HomeLayout (it has the entity name in context).
|
||||||
|
if (pathname.startsWith('/home')) return;
|
||||||
|
|
||||||
const entry = ROUTE_TITLE_KEYS.find((e) => e.match(pathname));
|
const entry = ROUTE_TITLE_KEYS.find((e) => e.match(pathname));
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
document.title = APP_NAME;
|
document.title = APP_NAME;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const pageName = t(entry.key);
|
const pageName = t(entry.key);
|
||||||
// Guard against an unresolved key (returns the key itself) leaking into the
|
// Guard against an unresolved key (t returns the key itself).
|
||||||
// title; fall back to the bare app name in that case.
|
setDocumentTitle(pageName !== entry.key ? pageName : null);
|
||||||
document.title =
|
|
||||||
pageName && pageName !== entry.key
|
|
||||||
? `${pageName} · ${APP_NAME}`
|
|
||||||
: APP_NAME;
|
|
||||||
}, [pathname, t, i18n.language]);
|
}, [pathname, t, i18n.language]);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user