/ web / src / contexts / PageHeaderProvider.tsx
PageHeaderProvider.tsx
  1  import { useLayoutEffect, useMemo, useState, type ReactNode } from "react";
  2  import { useLocation } from "react-router-dom";
  3  import { PageHeaderContext } from "./page-header-context";
  4  import { resolvePageTitle } from "@/lib/resolve-page-title";
  5  import { cn } from "@/lib/utils";
  6  import { useI18n } from "@/i18n";
  7  
  8  export function PageHeaderProvider({
  9    children,
 10    pluginTabs,
 11  }: {
 12    children: ReactNode;
 13    pluginTabs: { path: string; label: string }[];
 14  }) {
 15    const { pathname } = useLocation();
 16    const { t } = useI18n();
 17    const [titleOverride, setTitleOverride] = useState<string | null>(null);
 18    const [afterTitle, setAfterTitle] = useState<ReactNode>(null);
 19    const [end, setEnd] = useState<ReactNode>(null);
 20  
 21    // Clear any per-page title / toolbar slots when the path changes. Child routes
 22    // re-fill these on mount via usePageHeader.
 23    /* eslint-disable react-hooks/set-state-in-effect */
 24    useLayoutEffect(() => {
 25      setTitleOverride(null);
 26      setAfterTitle(null);
 27      setEnd(null);
 28    }, [pathname]);
 29    /* eslint-enable react-hooks/set-state-in-effect */
 30  
 31    const defaultTitle = useMemo(
 32      () => resolvePageTitle(pathname, t, pluginTabs),
 33      [pathname, t, pluginTabs],
 34    );
 35    const displayTitle = titleOverride ?? defaultTitle;
 36  
 37    const isChatRoute = pathname === "/chat" || pathname === "/chat/";
 38  
 39    const value = useMemo(
 40      () => ({
 41        setAfterTitle,
 42        setEnd,
 43        setTitle: setTitleOverride,
 44      }),
 45      [],
 46    );
 47  
 48    return (
 49      <PageHeaderContext.Provider value={value}>
 50        <div className="flex min-h-0 w-full min-w-0 flex-1 flex-col overflow-hidden">
 51          <header
 52            className={cn(
 53              "z-1 w-full shrink-0",
 54              "box-border h-14 min-h-14",
 55              "border-b border-current/20",
 56              "bg-background-base/40 backdrop-blur-sm",
 57              "overflow-hidden",
 58              "sm:min-h-0",
 59            )}
 60            role="banner"
 61          >
 62            <div
 63              className={cn(
 64                "flex h-full w-full min-w-0 flex-1 gap-2 px-3 py-2 sm:gap-3 sm:px-6 sm:py-0",
 65                isChatRoute
 66                  ? "flex-row items-center"
 67                  : "flex-col justify-center sm:flex-row sm:items-center",
 68              )}
 69            >
 70              <div className="flex min-w-0 flex-1 items-center gap-2 sm:gap-3">
 71                <h1
 72                  className="font-expanded min-w-0 truncate text-sm font-bold tracking-[0.08em] text-midground"
 73                  style={{ mixBlendMode: "plus-lighter" }}
 74                >
 75                  {displayTitle}
 76                </h1>
 77                {afterTitle}
 78              </div>
 79  
 80              {end ? (
 81                <div
 82                  className={cn(
 83                    "flex min-w-0 justify-end sm:max-w-md sm:flex-1",
 84                    isChatRoute ? "w-auto shrink-0" : "w-full",
 85                  )}
 86                >
 87                  {end}
 88                </div>
 89              ) : null}
 90            </div>
 91          </header>
 92  
 93          <main
 94            className={cn(
 95              "min-h-0 w-full min-w-0 flex-1 flex flex-col",
 96              isChatRoute
 97                ? "overflow-hidden"
 98                : "overflow-y-auto overflow-x-hidden [scrollbar-gutter:stable]",
 99            )}
100          >
101            {children}
102          </main>
103        </div>
104      </PageHeaderContext.Provider>
105    );
106  }