Make Cobble a local install: setup flow, bots-first admin, settings

Local, password-only auth:
- No public pages; everything outside /admin and /auth redirects in
- First run (no users) sends every page to /auth/signup, which creates
  the super admin (user #1) once and then locks
- Login is email/username + password; removed the emailed-code flow,
  /auth/sso and email password reset
- Google login kept for existing accounts only, never creates them
- seed-super-admin now requires SEED_SUPER_ADMIN_PASSWORD

Bots-first admin:
- /admin and closed sections (people, logs, backups) redirect to
  /admin/bots; only bots and settings are live (AdminSections)
- Removed the old admin layout; every /admin page uses the bots rail
- Coordinator bot created at signup/seed and by /admin/bots
  (ensureCoordinatorBot), which redirects to its chat
- bots.system_prompt (markdown) with a default coordinator prompt
- seed-bots reuses the existing coordinator

Settings:
- Sub pages general (default), profile, change-password, listed in the
  rail with a back link; unknown settings paths redirect to general
- Redesigned settings UI: pane header, grouped rows, sticky save bar
- General: System/Light/Dark theme, saved per device

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-18 17:49:25 +01:00
co-authored by Claude Opus 5.5
parent 3286b80029
commit 694c83346f
159 changed files with 1891 additions and 2964 deletions
@@ -1,101 +0,0 @@
import {
ArchiveRestore,
Bot,
DatabaseBackup,
KeyRound,
LayoutDashboard,
LogOut,
ScrollText,
Settings,
UserRound,
Users,
type LucideIcon,
} from "lucide-react";
export type AdminAsideLinkItem = {
title: string;
url: string;
Icon: LucideIcon;
strict?: boolean;
super_admin_only?: boolean;
/**
* Only shown when `AUTH_LOGIN_METHOD` is `password`.
*/
password_method_only?: boolean;
/**
* Sub pages rendered as a collapsible list under this link. With fewer
* than two visible children the item renders as a plain link.
*/
children?: AdminAsideLinkItem[];
};
export type AdminAsideGroup = {
label: string;
links: AdminAsideLinkItem[];
};
export const AdminAsideGroups: AdminAsideGroup[] = [
{
label: "Workspace",
links: [
{
title: "Dashboard",
url: "/admin",
Icon: LayoutDashboard,
strict: true,
},
{ title: "Bots", url: "/admin/bots", Icon: Bot },
{ title: "People", url: "/admin/people", Icon: Users },
],
},
{
label: "System",
links: [
{
title: "Logs",
url: "/admin/logs",
Icon: ScrollText,
super_admin_only: true,
},
{
title: "Backups",
url: "/admin/backups",
Icon: DatabaseBackup,
super_admin_only: true,
children: [
{
title: "Backups",
url: "/admin/backups",
Icon: DatabaseBackup,
strict: true,
},
{
title: "Restore",
url: "/admin/backups/restore",
Icon: ArchiveRestore,
},
],
},
{
title: "Settings",
url: "/admin/settings",
Icon: Settings,
children: [
{
title: "Profile",
url: "/admin/settings",
Icon: UserRound,
strict: true,
},
{
title: "Password",
url: "/admin/settings/password",
Icon: KeyRound,
password_method_only: true,
},
],
},
{ title: "Logout", url: "/auth/logout", Icon: LogOut },
],
},
];
@@ -1,19 +0,0 @@
import type { TWUI_LINK_LIST_LINK_OBJECT } from "@moduletrace/twui/elements/LinkList";
import type { AdminAsideLinkItem } from "../(dict)/admin-aside-groups-dict";
type Params = {
link: AdminAsideLinkItem;
};
export default function adminAsideLinkToTwuiLink({
link,
}: Params): TWUI_LINK_LIST_LINK_OBJECT {
const { title, url, strict, Icon } = link;
return {
title,
url,
strict,
icon: <Icon size={16} strokeWidth={1.75} />,
};
}
@@ -1,36 +0,0 @@
import type { LoginMethod } from "@/src/types";
import type { AdminAsideLinkItem } from "../(dict)/admin-aside-groups-dict";
type Params = {
links: AdminAsideLinkItem[];
is_super_admin: boolean;
login_method: LoginMethod;
};
/**
* Drops links (and nested children) the current user/site config should not
* see.
*/
export default function filterAdminAsideLinks({
links,
is_super_admin,
login_method,
}: Params): AdminAsideLinkItem[] {
return links
.filter((link) => is_super_admin || !link.super_admin_only)
.filter(
(link) => login_method == "password" || !link.password_method_only,
)
.map((link) =>
link.children
? {
...link,
children: filterAdminAsideLinks({
links: link.children,
is_super_admin,
login_method,
}),
}
: link,
);
}
@@ -1,90 +0,0 @@
import React from "react";
import { Menu, X } from "lucide-react";
import AdminAsideLinks from "./admin-aside-links-dict";
import Row from "@moduletrace/twui/layout/Row";
import LogoIcon from "@/src/components/general/logo-icon";
import { twMerge } from "tailwind-merge";
import Stack from "@moduletrace/twui/layout/Stack";
import MobileNavThemeRow from "@/src/components/general/mobile-nav-theme-row";
export default function MobileNav() {
const [open, setOpen] = React.useState(false);
React.useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("keydown", onKey);
return () => {
document.body.style.overflow = prev;
document.removeEventListener("keydown", onKey);
};
}, [open]);
return (
<div className="flex xl:hidden">
<button
type="button"
aria-label="Open menu"
aria-expanded={open}
className="cursor-pointer p-1"
onClick={() => setOpen(true)}
>
<Menu size={22} strokeWidth={1.75} />
</button>
{open ? (
<>
<div
className="fixed inset-0 z-200 bg-dark/40 backdrop-blur-sm"
onClick={() => setOpen(false)}
aria-hidden
/>
<aside
data-admin-nav
className={twMerge(
"fixed top-0 left-0 bottom-0 z-210",
"w-[min(280px,85vw)] bg-light dark:bg-black",
"flex flex-col shadow-xl",
)}
role="dialog"
aria-modal="true"
aria-label="Admin navigation"
>
<Row className="w-full justify-between items-center px-6 h-16 flex-nowrap">
<a href="/" onClick={() => setOpen(false)}>
<LogoIcon icon_size={28} />
</a>
<button
type="button"
aria-label="Close menu"
className="cursor-pointer p-1"
onClick={() => setOpen(false)}
>
<X size={22} strokeWidth={1.75} />
</button>
</Row>
<Stack
className="w-full grow overflow-y-auto px-3 py-4"
onClick={(e) => {
if ((e.target as HTMLElement).closest("a")) {
setOpen(false);
}
}}
>
<AdminAsideLinks />
</Stack>
<MobileNavThemeRow className="px-6 py-4" />
</aside>
</>
) : null}
</div>
);
}
@@ -1,70 +0,0 @@
import { useContext, useState } from "react";
import { ChevronDown } from "lucide-react";
import { twMerge } from "tailwind-merge";
import Button from "@moduletrace/twui/layout/Button";
import LinkList from "@moduletrace/twui/elements/LinkList";
import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack";
import { AppContext } from "@/src/pages/__root";
import type { AdminAsideLinkItem } from "../(dict)/admin-aside-groups-dict";
import adminAsideLinkToTwuiLink from "../(functions)/admin-aside-link-to-twui-link";
type Props = {
link: AdminAsideLinkItem;
};
export default function AdminAsideLinkDropdown({ link }: Props) {
const { pageProps } = useContext(AppContext);
const { title, url, Icon, children } = link;
const is_current = Boolean(pageProps?.url?.pathname?.startsWith(url));
const [open, setOpen] = useState(is_current);
return (
<Stack className="w-full items-stretch gap-0.5!">
<Button
title={title}
variant="ghost"
color="gray"
aria-expanded={open}
className={twMerge(
"w-full justify-start px-3 py-2 font-normal",
"text-slate-600 hover:bg-slate-900/5 hover:text-slate-900 hover:opacity-100",
"dark:text-slate-400 dark:hover:bg-white/5 dark:hover:text-white",
is_current ? "text-slate-900 dark:text-white" : "",
)}
buttonContentProps={{ className: "w-full" }}
onClick={() => setOpen(!open)}
>
<Row className="w-full flex-nowrap">
<Icon
size={16}
strokeWidth={1.75}
className={
is_current
? "text-accent"
: "text-slate-400 dark:text-slate-500"
}
/>
{title}
<ChevronDown
size={14}
strokeWidth={1.75}
className={twMerge(
"ml-auto text-slate-400 transition-transform",
open ? "rotate-180" : "",
)}
/>
</Row>
</Button>
{open ? (
<LinkList
links={(children || []).map((child) =>
adminAsideLinkToTwuiLink({ link: child }),
)}
className="flex-col items-stretch gap-0.5 ml-5 pl-2 border-l border-slate-200 dark:border-white/10"
/>
) : null}
</Stack>
);
}
@@ -1,21 +0,0 @@
import LinkList from "@moduletrace/twui/elements/LinkList";
import type { AdminAsideLinkItem } from "../(dict)/admin-aside-groups-dict";
import adminAsideLinkToTwuiLink from "../(functions)/admin-aside-link-to-twui-link";
import AdminAsideLinkDropdown from "./admin-aside-link-dropdown";
type Props = {
link: AdminAsideLinkItem;
};
export default function AdminAsideLinkEntry({ link }: Props) {
if (link.children && link.children.length > 1) {
return <AdminAsideLinkDropdown link={link} />;
}
return (
<LinkList
links={[adminAsideLinkToTwuiLink({ link })]}
className="flex-col items-stretch"
/>
);
}
@@ -1,41 +0,0 @@
import Stack from "@moduletrace/twui/layout/Stack";
import Span from "@moduletrace/twui/layout/Span";
import useLoginMethod from "@/src/hooks/use-login-method";
import type { AdminAsideGroup } from "../(dict)/admin-aside-groups-dict";
import filterAdminAsideLinks from "../(functions)/filter-admin-aside-links";
import AdminAsideLinkEntry from "./admin-aside-link-entry";
type Props = AdminAsideGroup & {
is_super_admin: boolean;
};
export default function AdminAsideLinkGroup({
label,
links,
is_super_admin,
}: Props) {
const login_method = useLoginMethod();
const visible_links = filterAdminAsideLinks({
links,
is_super_admin,
login_method,
});
if (!visible_links[0]) {
return null;
}
return (
<Stack className="w-full items-stretch gap-2!">
<Span className="px-3 text-xs font-medium text-slate-400">
{label}
</Span>
<Stack className="w-full items-stretch gap-0.5!">
{visible_links.map((link) => (
<AdminAsideLinkEntry key={link.title} link={link} />
))}
</Stack>
</Stack>
);
}
@@ -1,29 +0,0 @@
import Stack from "@moduletrace/twui/layout/Stack";
import { AppContext } from "@/src/pages/__root";
import { useContext } from "react";
import checkUserAccess from "@/src/functions/backend/auth/check-user-access";
import { AdminAsideGroups } from "../(dict)/admin-aside-groups-dict";
import AdminAsideLinkGroup from "./admin-aside-link-group";
export default function AdminAsideLinks() {
const { pageProps } = useContext(AppContext);
const { user, user_types } = pageProps || {};
if (!user?.logged_in_status) {
return null;
}
const is_super_admin = Boolean(checkUserAccess({ user_types }).success);
return (
<Stack className="w-full items-stretch gap-7!">
{AdminAsideGroups.map((group) => (
<AdminAsideLinkGroup
key={group.label}
{...group}
is_super_admin={is_super_admin}
/>
))}
</Stack>
);
}
@@ -1,42 +0,0 @@
import { AppContext } from "@/src/pages/__root";
import { useContext } from "react";
import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack";
import Span from "@moduletrace/twui/layout/Span";
import Img from "@moduletrace/twui/layout/Img";
import type { User } from "@/src/types";
export default function AdminAsideUser() {
const { pageProps } = useContext(AppContext);
const user = pageProps?.user as User | undefined;
if (!user?.logged_in_status) {
return null;
}
const full_name = [user.first_name, user.last_name]
.filter(Boolean)
.join(" ");
return (
<a href="/admin/settings" className="block w-full">
<Row className="w-full flex-nowrap gap-3! rounded-default px-3 py-2.5 hover:bg-slate-900/5 dark:hover:bg-white/5 transition-colors">
<Img
alt={full_name}
src={user.image_thumbnail ?? "/icons/man.png"}
size={32}
circle
className="bg-slate-300 dark:bg-slate-700 shrink-0"
/>
<Stack className="gap-0! min-w-0">
<Span className="text-sm font-medium text-foreground-light dark:text-foreground-dark truncate max-w-[150px]">
{full_name}
</Span>
<Span className="text-xs text-slate-500 dark:text-slate-400 truncate max-w-[150px]">
{user.email}
</Span>
</Stack>
</Row>
</a>
);
}
@@ -1,32 +0,0 @@
import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack";
import Logo from "@/src/components/general/logo";
import AdminAsideLinks from "./admin-aside-links-dict";
import AdminAsideUser from "./admin-aside-user";
import { twMerge } from "tailwind-merge";
type Props = {
className?: string;
};
export default function AdminAside({ className }: Props) {
return (
<aside
data-admin-nav
className={twMerge(
"w-64 shrink-0 sticky top-0 h-screen flex-col",
className,
)}
>
<Row className="h-16 px-6 flex-nowrap">
<Logo icon_size={26} />
</Row>
<nav className="w-full grow overflow-y-auto px-3 py-4">
<AdminAsideLinks />
</nav>
<Stack className="w-full items-stretch p-3 border-t border-slate-200 dark:border-white/10">
<AdminAsideUser />
</Stack>
</aside>
);
}
-28
View File
@@ -1,28 +0,0 @@
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import ColorSchemeSelector from "@moduletrace/twui/elements/ColorSchemeSelector";
import { SiteData } from "@/src/data/site-data";
export default function Footer() {
/**
* Render component
*/
return (
<footer className="w-full px-5 xl:px-10 py-5 border-t border-slate-200 dark:border-white/10">
<Row className="w-full justify-between">
<Span className="text-xs text-slate-400">
&copy; {new Date().getFullYear()} {SiteData["SiteName"]}
</Span>
<Row className="items-center gap-4!">
<Span className="text-xs text-slate-400">
Admin console
</Span>
<ColorSchemeSelector
className="text-slate-400 hover:text-foreground-light dark:hover:text-foreground-dark transition-colors"
size="small"
/>
</Row>
</Row>
</footer>
);
}
-46
View File
@@ -1,46 +0,0 @@
import Row from "@moduletrace/twui/layout/Row";
import Button from "@moduletrace/twui/layout/Button";
import HeaderUser from "../../main/(partials)/header-user";
import LogoIcon from "@/src/components/general/logo-icon";
import MobileNav from "../(partials)/MobileNav";
import { ArrowUpRight } from "lucide-react";
import { twMerge } from "tailwind-merge";
type Props = {};
export default function Header({}: Props) {
/**
* Render component
*/
return (
<header className="h-16 border-b border-slate-200 dark:border-white/10">
<Row
className={twMerge(
"w-full justify-between h-full px-4 xl:px-10",
"flex-nowrap",
)}
>
<Row className="flex-nowrap gap-3!">
<MobileNav />
<a href="/" className="flex xl:hidden">
<LogoIcon icon_size={28} />
</a>
</Row>
<Row className="flex-nowrap gap-3!">
<Button
title="View site"
href="/"
variant="ghost"
color="gray"
size="small"
afterIcon={<ArrowUpRight size={14} />}
className="text-sm hidden sm:flex"
>
View site
</Button>
<HeaderUser />
</Row>
</Row>
</header>
);
}
-28
View File
@@ -1,28 +0,0 @@
import React from "react";
import Header from "./(sections)/Header";
import Footer from "./(sections)/Footer";
import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack";
import AdminAside from "./(partials)/admin-aside";
/* -------------------------------------------------------------------------- */
/* Main Layout Functional Component */
/* -------------------------------------------------------------------------- */
// Main Layout Component
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<Row className="w-full items-stretch gap-0! flex-nowrap min-h-screen bg-light dark:bg-black">
<AdminAside className="hidden xl:flex" />
<Stack className="grow min-w-0 items-stretch gap-0! xl:py-2 xl:pr-2">
<Stack className="grow items-stretch gap-0! bg-white dark:bg-background-dark xl:rounded-default-xl xl:border border-slate-200 dark:border-white/10 shadow-sm shadow-slate-900/5 dark:shadow-none">
<Header />
<main className="grow items-start" id="admin-main">
{children}
</main>
<Footer />
</Stack>
</Stack>
</Row>
);
}
@@ -1,10 +0,0 @@
export type AuthLegalLinkType = {
title: string;
url: string;
};
export const AuthLegalLinks: AuthLegalLinkType[] = [
{ title: "Terms", url: "/terms" },
{ title: "Privacy", url: "/privacy" },
{ title: "Contact", url: "/contact" },
];
@@ -1,8 +1,6 @@
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import { SiteData } from "@/src/data/site-data";
import { AuthLegalLinks } from "../(data)/auth-legal-links";
import AuthLegalLink from "./auth-legal-link";
export default function AuthBottomBar() {
return (
@@ -10,9 +8,6 @@ export default function AuthBottomBar() {
<Span className="text-xs text-slate-400 dark:text-slate-500">
&copy; {new Date().getFullYear()} {SiteData["SiteName"]}
</Span>
{AuthLegalLinks.map((link) => (
<AuthLegalLink key={link.url} {...link} />
))}
</Row>
);
}
@@ -1,13 +0,0 @@
import Link from "@moduletrace/twui/layout/Link";
import type { AuthLegalLinkType } from "../(data)/auth-legal-links";
export default function AuthLegalLink({ title, url }: AuthLegalLinkType) {
return (
<Link
href={url}
className="text-xs border-none text-slate-400 dark:text-slate-500"
>
{title}
</Link>
);
}
+1 -1
View File
@@ -10,7 +10,7 @@ type Props = {
/**
* Distraction-free layout for `/auth/*`: logo and theme switch on top,
* centered content, legal links at the bottom. No site header or footer.
* centered content, copyright at the bottom. No site header or footer.
*/
export default function AuthLayout({ children }: Props): React.JSX.Element {
return (
@@ -6,6 +6,7 @@ import {
SlidersHorizontal,
type LucideIcon,
} from "lucide-react";
import { SettingsDefaultPath } from "@/src/dict/settings-pages-dict";
export type RailUserMenuItem = {
title: string;
@@ -26,9 +27,9 @@ export type RailUserMenuItem = {
* destinations are only reachable from here.
*/
export const RailUserMenuItems: RailUserMenuItem[] = [
{ title: "Activity", url: "/admin/logs", Icon: Activity },
{ title: "Activity", Icon: Activity },
{ title: "Routines", Icon: Clock },
{ title: "Settings", url: "/admin/settings", Icon: SlidersHorizontal },
{ title: "Settings", url: SettingsDefaultPath, Icon: SlidersHorizontal },
{ title: "Help", Icon: CircleHelp },
{ title: "Sign out", url: "/auth/logout", Icon: LogOut, destructive: true },
];
@@ -0,0 +1,25 @@
import Link from "@moduletrace/twui/layout/Link";
import Span from "@moduletrace/twui/layout/Span";
import { ArrowLeft } from "lucide-react";
import { AdminHomePath } from "@/src/dict/admin-sections-dict";
/**
* Top of the rail on settings pages, returning to the conversations. Same
* 48px row as a bot.
*/
export default function RailBackLink() {
return (
<Link
href={AdminHomePath}
title="Back to bots"
className="flex shrink-0 h-12 items-center gap-3 rounded-default px-1 lg:px-3 border-0 leading-normal text-ink hover:opacity-100 hover:bg-sunken transition-colors duration-[var(--m-fast)] focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper"
>
<Span className="size-8 shrink-0 flex items-center justify-center mx-auto lg:mx-0">
<ArrowLeft size={18} aria-hidden />
</Span>
<Span className="hidden lg:inline text-sm font-bold leading-5 text-ink">
Back to bots
</Span>
</Link>
);
}
@@ -0,0 +1,35 @@
import type { BotWithLastMessage } from "@/src/types";
import CoordinatorRow from "./coordinator-row";
import BotRow from "./bot-row";
type Props = {
bots: BotWithLastMessage[];
selected_id?: number;
};
/**
* The pinned coordinator, then the crew.
*/
export default function RailCrewList({ bots, selected_id }: Props) {
const coordinator = bots.find((bot) => bot.kind === "coordinator");
const workers = bots.filter((bot) => bot.kind !== "coordinator");
return (
<>
{coordinator ? (
<CoordinatorRow
bot={coordinator}
bots={bots}
selected={coordinator.id === selected_id}
/>
) : null}
{workers.map((bot) => (
<BotRow
key={bot.id}
bot={bot}
selected={bot.id === selected_id}
/>
))}
</>
);
}
+9 -2
View File
@@ -4,7 +4,14 @@ import Wordmark from "@/src/components/general/wordmark";
import LogoIcon from "@/src/components/general/logo-icon";
import NewBotButton from "./new-bot-button";
export default function RailHeader() {
type Props = {
/**
* Off on settings pages, where adding a bot is out of place.
*/
show_new_bot?: boolean;
};
export default function RailHeader({ show_new_bot = true }: Props) {
return (
<Row className="h-[72px] shrink-0 gap-0! flex-nowrap items-start justify-center lg:justify-between px-0 lg:pl-5 lg:pr-6 pt-5">
<Link
@@ -14,7 +21,7 @@ export default function RailHeader() {
<Wordmark height={15} className="hidden lg:block" />
<LogoIcon icon_size={28} img_props={{ className: "lg:hidden" }} />
</Link>
<NewBotButton />
{show_new_bot ? <NewBotButton /> : null}
</Row>
);
}
@@ -0,0 +1,35 @@
import Link from "@moduletrace/twui/layout/Link";
import Span from "@moduletrace/twui/layout/Span";
import { twMerge } from "tailwind-merge";
import type { SettingsPage } from "@/src/dict/settings-pages-dict";
type Props = SettingsPage & {
selected?: boolean;
};
/**
* A settings sub page in the rail. Same 48px row and selected state as a
* bot row; icon only when the rail collapses.
*/
export default function RailSettingsLink({ title, url, Icon, selected }: Props) {
return (
<Link
href={url}
title={title}
aria-current={selected ? "page" : undefined}
className={twMerge(
"flex shrink-0 h-12 items-center gap-3 rounded-default px-1 lg:px-3 border-0 leading-normal text-ink",
"hover:opacity-100 hover:bg-sunken transition-colors duration-[var(--m-fast)]",
"focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper",
selected && "bg-ink/5 hover:bg-ink/5",
)}
>
<Span className="size-8 shrink-0 flex items-center justify-center mx-auto lg:mx-0">
<Icon size={18} aria-hidden />
</Span>
<Span className="hidden lg:inline text-sm font-bold leading-5 text-ink">
{title}
</Span>
</Link>
);
}
@@ -0,0 +1,32 @@
import Divider from "@moduletrace/twui/layout/Divider";
import { SettingsPages } from "@/src/dict/settings-pages-dict";
import RailBackLink from "./rail-back-link";
import RailSettingsLink from "./rail-settings-link";
type Props = {
pathname?: string;
};
/**
* Settings mode of the rail: back to the bots, then the settings sub pages.
*/
export default function RailSettingsList({ pathname }: Props) {
const current = pathname?.replace(/\/$/, "");
return (
<>
<RailBackLink />
<Divider className="shrink-0 mx-1 lg:mx-3 my-2 w-auto border-hairline dark:border-hairline" />
{SettingsPages.map((page) => (
<RailSettingsLink
key={page.url}
{...page}
selected={
current == page.url ||
Boolean(current?.startsWith(`${page.url}/`))
}
/>
))}
</>
);
}
+16 -23
View File
@@ -2,43 +2,36 @@ import { useContext } from "react";
import Stack from "@moduletrace/twui/layout/Stack";
import { AppContext } from "@/src/pages/__root";
import RailHeader from "../(partials)/rail-header";
import CoordinatorRow from "../(partials)/coordinator-row";
import BotRow from "../(partials)/bot-row";
import RailCrewList from "../(partials)/rail-crew-list";
import RailSettingsList from "../(partials)/rail-settings-list";
import RailUserRow from "../(partials)/rail-user-row";
/**
* Left rail: wordmark, the pinned coordinator, then the crew. Collapses to a
* 64px icon rail below 1024px.
* Left rail: wordmark, then the crew (pinned coordinator first). On
* `/admin/settings/*` the crew is swapped for a back link and the settings
* sub pages. Collapses to a 64px icon rail below 1024px.
*/
export default function BotsRail() {
const { pageProps } = useContext(AppContext);
const bots = pageProps?.bots || [];
const selected_id = pageProps?.bot?.id;
const coordinator = bots.find((bot) => bot.kind === "coordinator");
const workers = bots.filter((bot) => bot.kind !== "coordinator");
const pathname = pageProps?.url?.pathname;
const is_settings_page = Boolean(pathname?.startsWith("/admin/settings"));
return (
<Stack
className="w-16 lg:w-[260px] shrink-0 h-full gap-0! items-stretch border-0 border-r border-solid border-hairline"
aria-label="Bots"
aria-label={is_settings_page ? "Settings" : "Bots"}
>
<RailHeader />
<RailHeader show_new_bot={!is_settings_page} />
<Stack className="grow min-h-0 overflow-y-auto gap-0! items-stretch px-2 lg:px-3 pt-1">
{coordinator ? (
<CoordinatorRow
bot={coordinator}
bots={bots}
selected={coordinator.id === selected_id}
{is_settings_page ? (
<RailSettingsList pathname={pathname} />
) : (
<RailCrewList
bots={pageProps?.bots || []}
selected_id={pageProps?.bot?.id || undefined}
/>
) : null}
{workers.map((bot) => (
<BotRow
key={bot.id}
bot={bot}
selected={bot.id === selected_id}
/>
))}
)}
</Stack>
<RailUserRow />
</Stack>
+2 -2
View File
@@ -3,8 +3,8 @@ import Row from "@moduletrace/twui/layout/Row";
import BotsRail from "./(sections)/bots-rail";
/**
* Full-bleed chat shell for `/admin/bots/*`: the rail of bot conversations,
* then the page (conversation + working-on panel). No cards, no shadows;
* Full-bleed shell for every `/admin/*` page: the rail of bot conversations,
* then the page (conversation + working-on panel, or settings). No cards, no shadows;
* regions are separated by 1px hairlines.
*/
export default function BotsLayout({
+2 -2
View File
@@ -35,8 +35,8 @@ export default function HeaderUser() {
<LinkList
links={[
{
title: "Dashboard",
url: "/admin",
title: "Bots",
url: "/admin/bots",
},
{
component: <Divider />,