Something went wrong. Try again.
ATProto-based AI Assistant Chat interface.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152import { useLoaderData, useLocation } from '@tanstack/react-router'import { type ReactNode, useEffect } from 'react'import { cn } from './cn.ts'import { Logo } from './ui/Logo.tsx'import { SidebarToggle } from './ui/SidebarToggle.tsx'import { useSidebar } from './useSidebar.ts'
interface SettingsProps { /** Links to each section. */ nav?: ReactNode children?: ReactNode}
interface ChatsProps { /** The chat list. */ nav?: ReactNode /** The page's frame while its code loads, its nav a skeleton of what will fill it. */ frame?: boolean children?: ReactNode}
/* * The sidebar, after Linear's: dense one-line rows of small text on the frame. On a narrow screen it * is a drawer over the page, closed until opened, which closes again once a link in it is followed. */
/** The narrow screen's drawer, closed on each navigation. */function useDrawer() { const sidebar = useSidebar() const pathname = useLocation({ select: (location) => location.pathname }) const { closeDrawer } = sidebar // biome-ignore lint/correctness/useExhaustiveDependencies: closing follows each navigation. useEffect(() => closeDrawer(), [pathname]) return sidebar}
interface BackdropProps { open: boolean onClose: () => void}
/** The dimmed page behind an open drawer, which closes it when pressed. */function Backdrop(props: BackdropProps) { if (!props.open) return null return ( <button type="button" aria-label="Close sidebar" onClick={props.onClose} className="fixed inset-0 z-19 wide:hidden animate-fade-in cursor-default bg-backdrop motion-reduce:animate-none" /> )}
/** * Settings and admin, whose nav sits inside the panel beside the section. The panel fills the * window, with the nav at its start and the section centered in the rest, so the nav stays put from * section to section. The nav sits as far from the window's edge as the chat list does, with the * chat's gutter on the other side. */export function SettingsSidebarLayout(props: SettingsProps) { const drawer = useDrawer() return ( <div data-drawer-open={drawer.drawerOpen || undefined} className="flex h-dvh"> <main className="relative min-w-0 flex-1 overflow-y-auto bg-panel ps-sidebar-pad pe-gutter max-wide:px-gutter max-wide:pt-gutter"> {/* On a narrow screen the toggle opens the page, a gutter from the panel's corner like the section under it, and stays there as the page scrolls, since a sticky element keeps clear of its scroll container's padding. */} <div className="sticky top-0 z-3 mb-3 wide:hidden w-fit"> <SidebarToggle className="border border-line bg-raised" /> </div> <div className="flex gap-12"> <nav className={cn( sidebarClass, 'wide:sticky wide:top-0 wide:max-h-dvh wide:w-settings-nav wide:flex-none wide:self-start overflow-y-auto wide:bg-transparent wide:px-0 wide:pt-2.5 wide:pb-4', )} > {props.nav} </nav> <Backdrop open={drawer.drawerOpen} onClose={drawer.closeDrawer} /> {/* A section pads its own foot, so its Save bar can stick to the panel's very bottom. */} <div className="min-w-0 flex-1">{props.children}</div> </div> </main> </div> )}
/** The chats, whose sidebar is a column beside the panel that collapses off the window's edge. */export function ChatsLayout(props: ChatsProps) { const drawer = useDrawer() return ( // Whether the sidebar is collapsed is an attribute on the root, set before the first paint. <div data-drawer-open={drawer.drawerOpen || undefined} aria-busy={props.frame || undefined} className="flex h-dvh min-h-0 overflow-x-clip" > <nav className={cn( sidebarClass, 'wide:in-data-sidebar-collapsed:invisible wide:in-data-sidebar-collapsed:-ms-sidebar wide:w-sidebar wide:flex-none overflow-hidden wide:in-data-sidebar-collapsed:opacity-0', // Collapsing slides the sidebar off the window's edge, fading as it goes, while the panel // widens into its room. 'wide:transition-[margin-inline-start,opacity,visibility] wide:duration-(--dur-slide) wide:ease-slide', )} > {/* The logo and the toggle, on one row as tall as the chat's header. */} <header className="-mt-2.5 flex h-bar flex-none items-center gap-0.5"> <AppLogo /> <SidebarToggle className="ms-auto" /> </header> {props.nav} </nav> <Backdrop open={drawer.drawerOpen} onClose={drawer.closeDrawer} /> {/* A size container, so the conversation's header can reach the panel's edges, and the block its absolutely placed parts are placed in, so none of them can stretch the page past the panel's scroll. */} <main className="@container relative flex min-w-0 flex-1 flex-col overflow-y-auto border-divider in-data-sidebar-collapsed:border-transparent border-s-2 bg-panel transition-[border-color] duration-(--dur-slide) ease-slide max-wide:border-none"> <MainBar /> {props.children} </main> </div> )}
const sidebarClass = cn( 'flex flex-col gap-0.5 overscroll-contain bg-chrome px-sidebar-pad pt-sidebar-pad pb-3 text-ui', // On a narrow screen, a drawer over the page that stays visible until it has slid away. 'motion-reduce:transition-none max-wide:in-data-drawer-open:visible max-wide:invisible max-wide:fixed max-wide:inset-y-0 max-wide:start-0 max-wide:z-20 max-wide:w-drawer max-wide:-translate-x-full max-wide:in-data-drawer-open:translate-x-0 max-wide:shadow-float max-wide:transition-[translate,visibility] max-wide:duration-(--dur-slide) max-wide:ease-slide rtl:max-wide:in-data-drawer-open:translate-x-0 rtl:max-wide:translate-x-full',)
/** * The toggle in the panel, for when the sidebar is hidden: collapsed on a wide screen, or a closed * drawer on a narrow one. On a wide screen it fades in once the sidebar, with its own toggle, has * mostly slid away, and fades out at once as the sidebar comes back. */function MainBar() { return ( <div className="sticky top-0 z-3 h-0"> <SidebarToggle className="wide:not-in-data-sidebar-collapsed:invisible absolute start-3 top-[calc((var(--spacing-bar)-var(--spacing-toggle))/2)] border border-line bg-raised wide:not-in-data-sidebar-collapsed:opacity-0 transition-[opacity,visibility] wide:in-data-sidebar-collapsed:delay-[calc(var(--dur-slide)/2)] duration-(--dur-feedback) motion-reduce:transition-none" /> </div> )}
/** The app's logo, named for screen readers by the app's name. */function AppLogo() { const name = useLoaderData({ from: '__root__', select: (data) => data.appName }) return <Logo label={name ?? ''} className="h-5" />}