header: per-route page titles + a centered, click-to-rename tab title
Each dashboard route now sets its own document.title (route→title map in usePageTitleSync, mounted in DashboardLayout). The title also renders centered in the top header; clicking it edits inline and updates the browser tab live — per-tab only, reset on navigation/refresh (not persisted). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ import { Background } from './Background';
|
|||||||
import { Header } from './Header';
|
import { Header } from './Header';
|
||||||
import { Dock, ALL_DOCK_ITEMS, DEFAULT_DOCK_PATHS } from './Dock';
|
import { Dock, ALL_DOCK_ITEMS, DEFAULT_DOCK_PATHS } from './Dock';
|
||||||
import { useIsTouch } from './useIsTouch';
|
import { useIsTouch } from './useIsTouch';
|
||||||
|
import { usePageTitleSync } from '@/state/usePageTitle';
|
||||||
|
|
||||||
type DashboardLayoutProps = {
|
type DashboardLayoutProps = {
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
@@ -10,6 +11,7 @@ type DashboardLayoutProps = {
|
|||||||
export function DashboardLayout({ children }: DashboardLayoutProps) {
|
export function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||||
const { items: visibleItems } = useDock(ALL_DOCK_ITEMS, DEFAULT_DOCK_PATHS);
|
const { items: visibleItems } = useDock(ALL_DOCK_ITEMS, DEFAULT_DOCK_PATHS);
|
||||||
const isTouch = useIsTouch();
|
const isTouch = useIsTouch();
|
||||||
|
usePageTitleSync();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative overflow-hidden h-dvh outline-none inset-0">
|
<div className="relative overflow-hidden h-dvh outline-none inset-0">
|
||||||
|
|||||||
@@ -8,8 +8,39 @@ import { useIsTouch } from '../useIsTouch';
|
|||||||
import { UserMenu } from './UserMenu';
|
import { UserMenu } from './UserMenu';
|
||||||
import { JobsIndicator } from './JobsIndicator';
|
import { JobsIndicator } from './JobsIndicator';
|
||||||
import { RescanButton } from '../Rescan/RescanButton';
|
import { RescanButton } from '../Rescan/RescanButton';
|
||||||
|
import { usePageTitle } from '@/state/usePageTitle';
|
||||||
// import { BugReportButton } from '../BugReport/BugReportButton';
|
// import { BugReportButton } from '../BugReport/BugReportButton';
|
||||||
|
|
||||||
|
// Center title: click to rename this browser tab (per-tab, not persisted).
|
||||||
|
function EditablePageTitle() {
|
||||||
|
const [title, setTitle] = usePageTitle();
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
|
||||||
|
if (editing) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={title}
|
||||||
|
onChange={(ev) => setTitle(ev.target.value)}
|
||||||
|
onBlur={() => setEditing(false)}
|
||||||
|
onKeyDown={(ev) => {
|
||||||
|
if (ev.key === 'Enter' || ev.key === 'Escape') setEditing(false);
|
||||||
|
}}
|
||||||
|
className="pointer-events-auto max-w-[50vw] border-b border-white/50 bg-transparent text-center text-sm font-semibold text-white outline-none md:text-base"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => setEditing(true)}
|
||||||
|
title="Click to rename this tab"
|
||||||
|
className="pointer-events-auto max-w-[50vw] cursor-text truncate text-sm font-semibold text-white/90 transition-colors hover:text-white md:text-base"
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type HeaderProps = {
|
type HeaderProps = {
|
||||||
dockItems?: DockItem[];
|
dockItems?: DockItem[];
|
||||||
};
|
};
|
||||||
@@ -24,9 +55,13 @@ export function Header({ dockItems }: HeaderProps) {
|
|||||||
return (
|
return (
|
||||||
<header className="fixed z-10 w-full">
|
<header className="fixed z-10 w-full">
|
||||||
<div
|
<div
|
||||||
className="shrink-0 border-b backdrop-blur-xl shadow-lg px-3 py-2 md:px-6 md:py-3 flex items-center justify-between"
|
className="relative shrink-0 border-b backdrop-blur-xl shadow-lg px-3 py-2 md:px-6 md:py-3 flex items-center justify-between"
|
||||||
style={{ backgroundColor: 'rgba(255, 255, 255, 0.25)', borderColor: 'rgba(255, 255, 255, 0.35)' }}
|
style={{ backgroundColor: 'rgba(255, 255, 255, 0.25)', borderColor: 'rgba(255, 255, 255, 0.35)' }}
|
||||||
>
|
>
|
||||||
|
{/* Centered, editable page title */}
|
||||||
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||||
|
<EditablePageTitle />
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{isTouch && dockItems && (
|
{isTouch && dockItems && (
|
||||||
<button onClick={() => setOpen(true)} className="p-1.5 rounded-md hover:bg-white/20 transition-colors">
|
<button onClick={() => setOpen(true)} className="p-1.5 rounded-md hover:bg-white/20 transition-colors">
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useLocation } from 'react-router';
|
||||||
|
import { useGlobal } from 'hooks/useGlobal';
|
||||||
|
|
||||||
|
type TitleRule = { match: (p: string) => boolean; title: string };
|
||||||
|
|
||||||
|
// Default page title per route — most specific first.
|
||||||
|
const RULES: TitleRule[] = [
|
||||||
|
{ match: (p) => p === '/', title: 'Home' },
|
||||||
|
{ match: (p) => p.startsWith('/settings/ai'), title: 'AI Settings' },
|
||||||
|
{ match: (p) => p.startsWith('/settings/profile'), title: 'Profile' },
|
||||||
|
{ match: (p) => p.startsWith('/settings/integrations'), title: 'Integrations' },
|
||||||
|
{ match: (p) => p.startsWith('/settings/apps'), title: 'App Settings' },
|
||||||
|
{ match: (p) => p.startsWith('/settings'), title: 'Settings' },
|
||||||
|
{ match: (p) => p.startsWith('/chat'), title: 'Chat' },
|
||||||
|
{ match: (p) => p.startsWith('/email'), title: 'Email' },
|
||||||
|
{ match: (p) => p.startsWith('/files'), title: 'Files' },
|
||||||
|
{ match: (p) => p.startsWith('/code-editor'), title: 'Code Editor' },
|
||||||
|
{ match: (p) => p.startsWith('/projects'), title: 'Projects' },
|
||||||
|
{ match: (p) => p.startsWith('/task-logs'), title: 'Task Logs' },
|
||||||
|
{ match: (p) => p.startsWith('/tasks'), title: 'Tasks' },
|
||||||
|
{ match: (p) => p.startsWith('/jobs'), title: 'Jobs' },
|
||||||
|
{ match: (p) => p.startsWith('/skills'), title: 'Skills' },
|
||||||
|
{ match: (p) => p.startsWith('/processes'), title: 'Processes' },
|
||||||
|
{ match: (p) => p.startsWith('/dashboards'), title: 'Dashboards' },
|
||||||
|
{ match: (p) => p.startsWith('/automation') || p.startsWith('/new-automation'), title: 'Automation' },
|
||||||
|
{ match: (p) => p.startsWith('/terminal'), title: 'Terminal' },
|
||||||
|
{ match: (p) => p.startsWith('/browser'), title: 'Browser' },
|
||||||
|
{ match: (p) => p.startsWith('/desktop'), title: 'Desktop' },
|
||||||
|
{ match: (p) => p.startsWith('/plans'), title: 'Plans' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function titleForPath(pathname: string): string {
|
||||||
|
return RULES.find((r) => r.match(pathname))?.title ?? 'Officer';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The current page title — shared (header ↔ tab), editable, per-tab (not persisted). */
|
||||||
|
export const usePageTitle = () => useGlobal<string>('PAGE_TITLE', 'Officer');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mount once in the dashboard layout: reset the title to the route default on navigation, and mirror
|
||||||
|
* whatever the title is into the browser tab. Editing the title in the header just sets this value,
|
||||||
|
* so the tab updates live; navigating away resets it (no persistence, by design).
|
||||||
|
*/
|
||||||
|
export function usePageTitleSync(): void {
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
const [title, setTitle] = usePageTitle();
|
||||||
|
|
||||||
|
// Route default — depends only on the path, so an in-place header edit is never clobbered.
|
||||||
|
useEffect(() => {
|
||||||
|
setTitle(titleForPath(pathname));
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = title || 'Officer';
|
||||||
|
}, [title]);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user