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:
@@ -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}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}/`))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user