diff --git a/AGENTS.md b/AGENTS.md index 659c24c..d09d751 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -230,7 +230,7 @@ Live routes under `/admin` (see `AdminSections`): `/admin/bots`, `/admin/workspa - Routines, Files, Usage, Activity and Help are the `RailPages` (`src/dict/rail-pages-dict.ts`), opened from the rail's user popover (which lists them, then Settings and Sign out). On them the rail shows its own aside (`(partials)/rail-pages-list.tsx`): a back link to the bots, then those pages, like settings. A page carries `super_admin_only` or a `can_view` rule (Usage is `canManageCrew`), and `canSeeRailItem` (`src/utils/rbac/can-see-rail-item.ts`, tested) decides both the popover and the aside, so a page is never offered in one and hidden in the other - `/admin/settings/backups` - super admin only (listed in `SettingsPages`), two groups. Database snapshots: create, list, restore, download and delete in `db/.backups` (`bun-sqlite backup`); "Restore from file" beside "Create backup" opens `upload-restore-modal.tsx` (upload a SQLite database file, which is saved to `.backups` then restored with `bun-sqlite import `, since `restore` is interactive). Whole-data archives: `write-data-backup-archive.ts` tars all of `DATA_DIR` (database, media, computer homes, bot and workspace files, MCP installs) into `BACKUP_ARCHIVE_DIR` as `cobble-data-.tar.gz` (via `tar -czf` into a `.part` name, renamed on success so a cut-off run is never listed, with a `--transform` putting every member under one root folder named after the archive), keeping the newest `AppData.DataBackupsKept` and never touching `db/.backups`; its own "Restore from file" uploads a tar.gz (`upload-restore-data.ts`, gzip header checked first). `restore-data-backup.ts` extracts an archive to a temp dir, requires exactly one root folder holding a `db` folder (`findArchiveRoot`, since an upload is saved under a fresh timestamp and its inner name no longer matches the file name), takes a safety archive before touching anything (`write-data-backup-archive.ts`, not the locked `create-data-backup.ts`), empties `DATA_DIR` (the snapshots included) and copies the archive's in, then `restart-app.ts` restarts the process through the docker socket after the response lands (`process.exit`, the container's restart policy brings it back), because the open SQLite handle points at the replaced file. Row actions are icon-only (`RowIconButton`, `src/components/general/row-icon-button.tsx`): restore, download, delete. `BACKUP_ARCHIVE_DIR` is a `ProcessOnlyEnvKeys` start-up path (the installer mounts `$COBBLE_HOME/backups-archives` there; outside docker it falls back to `~/.cobble/backups`). Every backup and restore, of either kind, runs one at a time via `with-backup-run-lock.ts`. Helpers in `src/functions/backend/backups/`, API routes in `api/admin/backups/` - `/admin/settings/database` - super admin only (listed in `SettingsPages`), a read-only browser of the install's SQLite database, no writes anywhere in it. `grabDatabaseTables` reads the catalog once (every table and its columns, SQLite's own `sqlite_*` tables and sqlite-vec's `bot_file_vectors_*` shadow tables left out) and adds each table's `COUNT(*)` and its size from `dbstat` (index pages folded back onto their table). `grabTableRecords` then returns one page (`AppData.DatabaseBrowserPageSize`, 50) of the open table's records newest key first, every value already cut in SQL at `AppData.DatabaseBrowserCellMaxChars` (400) so a table of long texts never ships a megabyte of JSON, and a `truncated` flag per cell; the search matches over the concatenation of every column's text (`buildSearchClause`: every word, or the whole phrase), which is what also works on a virtual table, where `MATCH` and `LIKE` over a vector blob do not. A record's key is its single primary key column, else its rowid, else nothing (a virtual table with no primary key reads as plain cells); clicking a cell's row opens `grabTableRecord` for that key, which returns the record whole up to `AppData.DatabaseBrowserValueMaxChars` (20000), the answer to a cell cut at three lines. `clampPage` puts a page past the end on the last one. The page's state (table, search, mode, page) lives in the address through `databaseHref`, so a table or a search can be linked to; the open table's first page comes with the page props and anything else loads through the API. Helpers in `src/functions/backend/database/` (`build-table-catalog-sql`, `build-table-sizes-sql`, `build-records-sql`, `build-search-clause`, `resolve-table-key`, `build-record-row`, `quote-sql-identifier`), API `api/admin/database/records` and `record` (GET only, `grabSuperAdminUser`), zod in `src/schemas/database-schema.ts`, page under `src/pages/admin/settings/database/` -- `/admin/settings` (live) - redirects to `SettingsDefaultPath` (`/admin/settings/general`). Sub pages: `general/` (theme: System/Light/Dark per device via `useThemePreference`, stored in `localStorage[AppData.ThemeStorageKey]`, the same key `ThemeInitScript` and twui's `ColorSchemeSelector` read; applies instantly, no save bar; for super admins the prompt appendix (`prompt-section.tsx`, `system-prompt-append-form.tsx`) saving `api/admin/settings/system-prompt-append` into the `bot_system_prompt_append` app setting, and the run limits (`run-limits-section.tsx`, `run-limits-form.tsx`) saving `api/admin/settings/run-limits` into the `harness_max_tool_steps` and `bot_reply_max_tool_steps` app settings (`grabRunLimitsOverview`, `run_limits` page prop, 0 clearing a limit back to its code default); and, for `canManageCrew`, the bot file quota: `storage-section.tsx`, `storage-quota-form.tsx` saving `api/admin/settings/storage-quota` into `app_settings`; and, for everyone, the running version: `version-section.tsx`, `version-line.tsx` showing `pageProps.running_version` from `runningVersion` (`src/utils/running-version.ts`: `COBBLE_VERSION`, the release tag the image was built with, else `package.json`'s version, in development too, with `(development)` after it and no such suffix in production), `profile/` (current user profile), `notifications/` (this device's Web Push switch, a test send, and every device the user subscribed), `change-password/` (set/change own password) `models/` (super admin only, `super_admin_only` in the dict hides it from the rail: model providers and which model each harness uses) and `environment/` (super admin only: app settings and the envs bots and computers use, per scope), each wrapped in `SettingsShell` (`(partials)/settings-shell.tsx`: 64px pane header like the conversation header, then one centered column). Content is built from `SettingsGroup` blocks (label + description left, controls right, hairline-separated, no cards) and ends with the sticky `SettingsSaveBar` (Save enabled only when dirty, Discard, one-line status). Saves don't reload the page. Keep save notices in component state: `useFormInit` clears `status` whenever the form changes. `bots/` (the crew: chat, edit with `BotForm`, models, delete a worker via `api/admin/bots/delete-bot`), `workspaces/` (open, create through the Add flow, delete), `computers/` (state and resources, live; super admins create, edit with `AddComputerForm`, retry, delete), `mcps/`, `tools/` and `skills/` (see MCPs, custom tools and skills: super admins add, edit, refresh or try, delete; admins only set Access, `extension-access-modal.tsx`; add, edit, access and try open in modals (`AddModalShell`). A skill has no status: nothing connects or runs, so `skill-row.tsx` puts its slug, who gets it (`extension-access-label.ts`) and its description where a server's or tool's status line goes), each MCP server or tool row with a status line under it (`extension-status-line.tsx`: connected or not, last run worked or failed, when, and why), those five for `canManageCrew` only (`grab-crew-settings-server-props.ts`, `can_view` in the dict) and `users/` (for `canListUsers`: super admins and admins; the dict's `can_view` gates it in the rail; add with a password (`canCreateUser`), edit (`canUpdateUser`), both with the one `UserForm` (`admin/settings/users/(partials)/user-form/`, `useFormInit`, photo included, saved in one request), revoke (adds `revoked`, `canRevokeUser`), delete (`canRevokeUser`'s rules, never yourself), pick their user types from `UserTypes` (`user-types-select.tsx`: only the ones `filterAssignableUserTypes` allows, never `UneditableUserTypes`; at least one, since login needs a type; `saveUser` checks each grant and removal with `userTypeAssignment`; the super admin's types are never shown or changed there, and they can't be revoked; the password is entered twice, `SaveUserRequestSchema` checks they match), delete with everything theirs; `api/admin/users/save-user`, `delete-user`). The lists share `SettingsList`, `SettingsListRow`, `SettingsRowAction` and `use-settings-list`. Sub pages are declared once in `src/dict/settings-pages-dict.ts`; on every `/admin/settings/*` page the rail swaps the crew for a back link plus that list. Add a sub page by adding its folder and a dict entry +- `/admin/settings` (live) - redirects to `SettingsDefaultPath` (`/admin/settings/general`). Sub pages: `general/` (theme: System/Light/Dark per device via `useThemePreference`, stored in `localStorage[AppData.ThemeStorageKey]`, the same key `ThemeInitScript` and twui's `ColorSchemeSelector` read; applies instantly, no save bar; for super admins the prompt appendix (`prompt-section.tsx`, `system-prompt-append-form.tsx`) saving `api/admin/settings/system-prompt-append` into the `bot_system_prompt_append` app setting, and the run limits (`run-limits-section.tsx`, `run-limits-form.tsx`) saving `api/admin/settings/run-limits` into the `harness_max_tool_steps` and `bot_reply_max_tool_steps` app settings (`grabRunLimitsOverview`, `run_limits` page prop, 0 clearing a limit back to its code default); and, for `canManageCrew`, the bot file quota: `storage-section.tsx`, `storage-quota-form.tsx` saving `api/admin/settings/storage-quota` into `app_settings`; and, for everyone, the running version: `version-section.tsx`, `version-line.tsx` showing `pageProps.running_version` from `runningVersion` (`src/utils/running-version.ts`: `COBBLE_VERSION`, the release tag the image was built with, else `package.json`'s version, in development too, with `(development)` after it and no such suffix in production), `profile/` (current user profile), `notifications/` (this device's Web Push switch, a test send, and every device the user subscribed), `change-password/` (set/change own password) `models/` (super admin only, `super_admin_only` in the dict hides it from the rail: model providers and which model each harness uses) and `environment/` (super admin only: app settings and the envs bots and computers use, per scope), each wrapped in `SettingsShell` (`(partials)/settings-shell.tsx`: 64px pane header like the conversation header, then one centered column that opens with the breadcrumb trail, `src/components/general/admin-breadcrumbs.tsx`: twui's `Breadcrumbs` reading `Home`, the sections and the page off `pageProps.url.pathname`, the last crumb named by the page's own `title` so a record reads `Scout` rather than `3` and `/admin/logs` reads `Activity`; `hasBreadcrumbTrail` drops it only on the crew of bots, which is home, and nothing outside `/admin` gets one). Content is built from `SettingsGroup` blocks (label + description left, controls right, hairline-separated, no cards) and ends with the sticky `SettingsSaveBar` (Save enabled only when dirty, Discard, one-line status). Saves don't reload the page. Keep save notices in component state: `useFormInit` clears `status` whenever the form changes. `bots/` (the crew: chat, edit with `BotForm`, models, delete a worker via `api/admin/bots/delete-bot`), `workspaces/` (open, create through the Add flow, delete), `computers/` (state and resources, live; super admins create, edit with `AddComputerForm`, retry, delete), `mcps/`, `tools/` and `skills/` (see MCPs, custom tools and skills: super admins add, edit, refresh or try, delete; admins only set Access, `extension-access-modal.tsx`; add, edit, access and try open in modals (`AddModalShell`). A skill has no status: nothing connects or runs, so `skill-row.tsx` puts its slug, who gets it (`extension-access-label.ts`) and its description where a server's or tool's status line goes), each MCP server or tool row with a status line under it (`extension-status-line.tsx`: connected or not, last run worked or failed, when, and why), those five for `canManageCrew` only (`grab-crew-settings-server-props.ts`, `can_view` in the dict) and `users/` (for `canListUsers`: super admins and admins; the dict's `can_view` gates it in the rail; add with a password (`canCreateUser`), edit (`canUpdateUser`), both with the one `UserForm` (`admin/settings/users/(partials)/user-form/`, `useFormInit`, photo included, saved in one request), revoke (adds `revoked`, `canRevokeUser`), delete (`canRevokeUser`'s rules, never yourself), pick their user types from `UserTypes` (`user-types-select.tsx`: only the ones `filterAssignableUserTypes` allows, never `UneditableUserTypes`; at least one, since login needs a type; `saveUser` checks each grant and removal with `userTypeAssignment`; the super admin's types are never shown or changed there, and they can't be revoked; the password is entered twice, `SaveUserRequestSchema` checks they match), delete with everything theirs; `api/admin/users/save-user`, `delete-user`). The lists share `SettingsList`, `SettingsListRow`, `SettingsRowAction` and `use-settings-list`. Sub pages are declared once in `src/dict/settings-pages-dict.ts`; on every `/admin/settings/*` page the rail swaps the crew for a back link plus that list. Add a sub page by adding its folder and a dict entry Every `/admin/*` page renders in the bots layout (`src/layouts/bots/`); there is no separate admin layout or aside. The rail header's Add icon (super admins and admins only, like the rail's "+" buttons) opens the Add flow (`(partials)/add-flow.tsx`, `AddFlowContext`): a bot, a workspace or (super admins) a computer, each form in a modal stacked over the choice, so closing it goes back there (the Bots and Workspaces labels have a "+" that opens the form directly; in a workspace's rail, "Workspace bots" has a "+" that opens `link-bots-modal.tsx` instead: the crew's workers not in it yet (`api/admin/workspaces/linkable-bots`), each added with `add-bot`, which pushes `server:bot-created`, and "New bot" stacking the bot form over it); on a workspace's pages new bots and computers join it. The rail shows the crew (`(partials)/rail-crew-list.tsx`, needs `bots` and `workspaces` in page props): the coordinator, the latest `AppData.RailRecentBots` workers (plus any open, unread or needing you, `pick-rail-bots`), "See all bots" with a few stones opening every bot in a modal, then Workspaces with a "+". On `/admin/workspaces/*` it shows the workspace's crew instead (`(partials)/rail-workspace-list.tsx`: back link, the workspace, then only the bots attached to it, "Workspace bots"; the coordinator stays in the main crew rail, and on a workspace bot's page the back link returns to the workspace page), except on `/admin/settings/*` where it shows "Back to bots" plus the settings sub pages (`(partials)/rail-settings-list.tsx`). Page heroes use the shared `AdminHero` component (`src/components/general/admin-hero.tsx`). diff --git a/src/components/general/admin-breadcrumbs.tsx b/src/components/general/admin-breadcrumbs.tsx new file mode 100644 index 0000000..b7870c9 --- /dev/null +++ b/src/components/general/admin-breadcrumbs.tsx @@ -0,0 +1,64 @@ +import Breadcrumbs from "@moduletrace/twui/elements/Breadcrumbs"; +import Stack from "@moduletrace/twui/layout/Stack"; +import { ChevronRight } from "lucide-react"; +import { AdminHomePath } from "@/src/dict/admin-sections-dict"; +import hasBreadcrumbTrail from "@/src/utils/has-breadcrumb-trail"; +import Divider from "@moduletrace/twui/layout/Divider"; +import { twMerge } from "tailwind-merge"; + +type Props = { + /** + * The address the page is at, e.g. `/admin/settings/bots/3`. + */ + pathname?: string | null; + /** + * The page's own name, for the last crumb: what the address can't say, + * since its last segment is an id (`Scout`, not `3`) or a slug that + * differs from the name the page shows (`MCPs`, not `Mcps`). + */ + title?: string; +}; + +/** + * The trail above a page's content: `Home`, then every section the address + * passes through, then the page itself, named by `title`. + * + * It is `twui`'s `Breadcrumbs`, which reads the trail off the path, so the + * sections come from the address and only the page's own name is given + * here; `hasBreadcrumbTrail` says which pages get one at all. + */ +export default function AdminBreadcrumbs({ pathname, title }: Props) { + if (!hasBreadcrumbTrail({ pathname })) return null; + + return ( + + + } + /> + + + ); +} diff --git a/src/pages/admin/settings/(partials)/admin-breadcrumbs.render.test.tsx b/src/pages/admin/settings/(partials)/admin-breadcrumbs.render.test.tsx new file mode 100644 index 0000000..b5e0b1a --- /dev/null +++ b/src/pages/admin/settings/(partials)/admin-breadcrumbs.render.test.tsx @@ -0,0 +1,94 @@ +import { describe, expect, test } from "bun:test"; +import { renderToString } from "react-dom/server"; +import type { AppContextObject, PagePropsType } from "@/src/types"; +import { AppContext } from "@/src/pages/__root"; +import SettingsShell from "./settings-shell"; + +type RenderProps = { + pathname: string; + /** + * The page's own title, the name the header shows. + */ + title: string; +}; + +function render({ pathname, title }: RenderProps) { + const pageProps: PagePropsType = { + url: { pathname } as PagePropsType["url"], + }; + const context = { pageProps } as AppContextObject; + + return renderToString( + + + PAGE_CONTENT + + , + ); +} + +describe("SettingsShell breadcrumbs", () => { + test("a top-level page shows Home and itself", () => { + const html = render({ pathname: "/admin/routines", title: "Routines" }); + + expect(html).toContain('aria-label="Breadcrumb"'); + expect(html).toContain(">Home<"); + expect(html).toContain(">Routines<"); + expect(html).toContain('href="/admin/routines"'); + }); + + test("a settings page walks through Settings up to the page", () => { + const html = render({ + pathname: "/admin/settings/general", + title: "General", + }); + + expect(html).toContain('href="/admin"'); + expect(html).toContain('href="/admin/settings"'); + expect(html).toContain('href="/admin/settings/general"'); + }); + + test("a record page reads its name, not the id in its address", () => { + const html = render({ + pathname: "/admin/settings/bots/3", + title: "Scout", + }); + + expect(html).toContain(">Scout<"); + expect(html).not.toContain(">3<"); + }); + + test("a page whose slug differs from its name shows the name", () => { + const html = render({ + pathname: "/admin/settings/change-password", + title: "Change password", + }); + + expect(html).toContain(">Change password<"); + expect(html).not.toContain(">Change Password<"); + }); + + test("Activity keeps its name though its address says logs", () => { + const html = render({ pathname: "/admin/logs", title: "Activity" }); + + expect(html).toContain(">Activity<"); + expect(html).not.toContain(">Logs<"); + }); + + test("the trail sits above the page's content, right after the header", () => { + const html = render({ pathname: "/admin/routines", title: "Routines" }); + + expect(html.indexOf('aria-label="Breadcrumb"')).toBeLessThan( + html.indexOf("PAGE_CONTENT"), + ); + }); + + test("the crew of bots, which is home, has no trail above it", () => { + const html = render({ pathname: "/admin/bots", title: "Bots" }); + + expect(html).not.toContain('aria-label="Breadcrumb"'); + }); +}); diff --git a/src/pages/admin/settings/(partials)/settings-breadcrumbs.tsx b/src/pages/admin/settings/(partials)/settings-breadcrumbs.tsx deleted file mode 100644 index 940a77a..0000000 --- a/src/pages/admin/settings/(partials)/settings-breadcrumbs.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { useContext } from "react"; -import Breadcrumbs from "@moduletrace/twui/elements/Breadcrumbs"; -import Stack from "@moduletrace/twui/layout/Stack"; -import { ChevronRight } from "lucide-react"; -import { AppContext } from "@/src/pages/__root"; - -type Props = { - /** - * The record's name, shown where the URL has its id. - */ - title: string; -}; - -/** - * The hierarchy above a nested settings page: Settings / Bots / Scout. - * Built from the path, with the record's name replacing its id. - */ -export default function SettingsBreadcrumbs({ title }: Props) { - const { pageProps } = useContext(AppContext); - const pathname = pageProps?.url?.pathname; - - if (!pathname) return null; - - return ( - - - } - /> - - ); -} diff --git a/src/pages/admin/settings/(partials)/settings-shell.tsx b/src/pages/admin/settings/(partials)/settings-shell.tsx index d3fe085..ac09d0a 100644 --- a/src/pages/admin/settings/(partials)/settings-shell.tsx +++ b/src/pages/admin/settings/(partials)/settings-shell.tsx @@ -1,7 +1,8 @@ -import type { ReactNode } from "react"; +import { useContext, type ReactNode } from "react"; import { twMerge } from "tailwind-merge"; import Stack from "@moduletrace/twui/layout/Stack"; -import SettingsBreadcrumbs from "./settings-breadcrumbs"; +import AdminBreadcrumbs from "@/src/components/general/admin-breadcrumbs"; +import { AppContext } from "@/src/pages/__root"; import SettingsPaneHeader from "./settings-pane-header"; type Props = { @@ -12,10 +13,6 @@ type Props = { * A wider column, for tables (activity). */ wide?: boolean; - /** - * A nested record page's name, for its breadcrumb trail. - */ - breadcrumb_title?: string; /** * Controls at the end of the pane header, e.g. a settings link. */ @@ -23,17 +20,19 @@ type Props = { }; /** - * The settings pane beside the rail: a 64px header like the conversation - * header, then one readable column that scrolls on its own. + * The pane beside the rail: a 64px header like the conversation header, + * then one readable column that scrolls on its own, opening with the + * breadcrumb trail above the page. */ export default function SettingsShell({ title, description, children, wide, - breadcrumb_title, action, }: Props) { + const { pageProps } = useContext(AppContext); + return ( - {breadcrumb_title ? ( - - ) : null} + {/** + * The page's own `title` names the last crumb: the + * address would say the id of a record, or a slug that + * differs from the name the page shows. + */} + {children} diff --git a/src/pages/admin/settings/bots/[bot_id]/(sections)/bot-settings-section.tsx b/src/pages/admin/settings/bots/[bot_id]/(sections)/bot-settings-section.tsx index 31cd9bc..84700bd 100644 --- a/src/pages/admin/settings/bots/[bot_id]/(sections)/bot-settings-section.tsx +++ b/src/pages/admin/settings/bots/[bot_id]/(sections)/bot-settings-section.tsx @@ -39,7 +39,6 @@ export default function BotSettingsSection() { ? "Coordinates your crew and hands work to it" : "A worker bot.") } - breadcrumb_title={name} > {is_super_admin ? ( diff --git a/src/pages/admin/settings/mcps/index.tsx b/src/pages/admin/settings/mcps/index.tsx index 08a19e9..f9952c8 100644 --- a/src/pages/admin/settings/mcps/index.tsx +++ b/src/pages/admin/settings/mcps/index.tsx @@ -4,7 +4,10 @@ import settingsPageMeta from "../(functions)/settings-page-meta"; export default function AdminSettingsMcpsPage() { return ( - + ); diff --git a/src/pages/admin/settings/users/[user_id]/(sections)/user-settings-section.tsx b/src/pages/admin/settings/users/[user_id]/(sections)/user-settings-section.tsx index ef06c63..6dfc70a 100644 --- a/src/pages/admin/settings/users/[user_id]/(sections)/user-settings-section.tsx +++ b/src/pages/admin/settings/users/[user_id]/(sections)/user-settings-section.tsx @@ -39,7 +39,6 @@ export default function UserSettingsSection() { description={ types_title || person.email || "A user on this install." } - breadcrumb_title={name} > diff --git a/src/utils/has-breadcrumb-trail.test.ts b/src/utils/has-breadcrumb-trail.test.ts new file mode 100644 index 0000000..d69ead3 --- /dev/null +++ b/src/utils/has-breadcrumb-trail.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from "bun:test"; +import hasBreadcrumbTrail from "./has-breadcrumb-trail"; + +describe("hasBreadcrumbTrail", () => { + test("a top-level page has one, so a lone page never goes without", () => { + expect(hasBreadcrumbTrail({ pathname: "/admin/routines" })).toBe(true); + }); + + test("a nested page has one", () => { + expect(hasBreadcrumbTrail({ pathname: "/admin/settings/general" })).toBe( + true, + ); + expect(hasBreadcrumbTrail({ pathname: "/admin/settings/bots/3" })).toBe( + true, + ); + }); + + test("the crew of bots is home, so it has none", () => { + expect(hasBreadcrumbTrail({ pathname: "/admin/bots" })).toBe(false); + expect(hasBreadcrumbTrail({ pathname: "/admin/bots/" })).toBe(false); + }); + + test("the address that only redirects to home has none either", () => { + expect(hasBreadcrumbTrail({ pathname: "/admin" })).toBe(false); + expect(hasBreadcrumbTrail({ pathname: "/admin/" })).toBe(false); + }); + + test("a folder in the files browser keeps the trail of its page", () => { + expect( + hasBreadcrumbTrail({ + pathname: "/admin/files?volume=bot&owner=1&path=reports", + }), + ).toBe(true); + }); + + test("nothing outside the admin pages has one", () => { + expect(hasBreadcrumbTrail({ pathname: "/auth/login" })).toBe(false); + expect(hasBreadcrumbTrail({ pathname: "/" })).toBe(false); + expect(hasBreadcrumbTrail({ pathname: "" })).toBe(false); + expect(hasBreadcrumbTrail({})).toBe(false); + expect(hasBreadcrumbTrail({ pathname: null })).toBe(false); + }); + + test("a path that merely starts like admin does not count", () => { + expect(hasBreadcrumbTrail({ pathname: "/administrator" })).toBe(false); + }); +}); diff --git a/src/utils/has-breadcrumb-trail.ts b/src/utils/has-breadcrumb-trail.ts new file mode 100644 index 0000000..9e41faf --- /dev/null +++ b/src/utils/has-breadcrumb-trail.ts @@ -0,0 +1,31 @@ +import { AdminHomePath } from "@/src/dict/admin-sections-dict"; + +type Params = { + pathname?: string | null; +}; + +/** + * Whether an address has a trail above it: `Home`, then every section it + * passes through, then the page. `twui`'s `Breadcrumbs` draws the trail + * itself from the path, so this only answers which pages get one. + * + * The crew of bots is where the app lands, and its own address would + * read `Home / Bots`, both the same page, so it has none. Neither does + * anything outside the admin pages, since everything else redirects to + * the bots. + * + * The trailing slash and the query string are ignored, so a folder opened + * in the files browser keeps the trail of the page it is on. + */ +export default function hasBreadcrumbTrail({ pathname }: Params): boolean { + if (!pathname) return false; + + const path = pathname.split("?")[0]!.replace(/\/$/, ""); + + if (path != "/admin" && !path.startsWith("/admin/")) return false; + + /** + * `/admin` only redirects to the crew of bots, which is home. + */ + return path != AdminHomePath && path != "/admin"; +}