react-resource-view
Admin layout
AdminLayout: a ready-made decoratorComponent — sidebar, top bar, a bottom nav on mobile, and full-width views.
What it is#
A scope's decoratorComponent wraps every view of that scope — the navigation and the page heading around them. Writing one from scratch is a sidebar, a top bar and a mobile navigation that every project ends up rewriting; AdminLayout is that shell, ready to point a scope at.
See it running
The playground's whole back office is this template, configured in a line: users, companies, a blog, a catalogue and a task board, every screen a URL. The link opens the tasks — the resource drawn full width, next to screens that keep the column.
Installing it#
It ships with the package — nothing more to install than the installation already did, styles included:
import { AdminLayout, createAdminLayout } from "react-resource-view"Pointing a scope at it#
Nothing beyond a menu is required — the sidebar, the top bar and the mobile navigation all read it — and menu: "auto" builds it from the scope's resources, one entry each. The scope's label, when it has one, opens the breadcrumb in the top bar: it is the name of the application.
import { AdminLayout } from "react-resource-view"
export const adminScope: ScopeInterface = {
name: "admin",
// The first link of the breadcrumb, in the top bar.
label: "Back office",
decoratorComponent: AdminLayout,
// The one place a resource's scope is written: their links lead here.
resources: [articles, authors],
// One entry per resource, in order.
menu: "auto",
defaultViewResourceContextParams: { resourceId: articles["@id"] },
}The navigation#
The menu is data, and the template draws it three ways: the sidebar on a desktop, the bottom bar on a phone, and the tabs under the page header. A resource is put in it as it is: the entry reads the name, the icon and the link off the resource — declared once, on the resource.
menu: [
// A resource is an entry: its name, its icon and its link.
overview,
tasks,
// A section: a collapsible group in the sidebar, a drawer on a phone.
{
name: "Catalogue",
icon: ShoppingBag,
items: [products, orders],
},
// A group whose pages are tabs under the page header rather than
// entries of the sidebar.
{
name: "Settings",
icon: Settings,
subNavigation: true,
items: [profile, billing],
},
]- An entry with
itemsis a section: a collapsible group in the sidebar, a drawer from the bottom bar. - With
subNavigation: true, the group opens on its first page and the header shows the sibling pages as tabs instead of nesting them in the sidebar. hidden: truekeeps an entry out of all three — see scopes & menu.
An entry stays lit on everything inside what it points at: the list of a resource, one of its records, the form editing it.
A logo, a search, a user menu#
decoratorComponent only ever receives children, so AdminLayout itself takes no props there. createAdminLayout builds a configured variant instead, with a logo and top bar content baked in:
import { createAdminLayout } from "react-resource-view"
export const adminScope: ScopeInterface = {
name: "admin",
decoratorComponent: createAdminLayout({
logo: <MyLogo />,
// A search field, a ⌘K palette, a user menu — anything.
topBarEnd: <UserMenu />,
}),
resources: [/* … */],
menu: "auto",
}The template has no search of its own: topBarEnd is the slot for one, a ⌘K palette included, and it stays on every screen of the scope. The playground puts its link to the builder there.
The bottom of the sidebar#
Help, support, documentation — the links an application keeps out of the way of its pages go in a block pinned at the bottom of the sidebar. footerMenu takes menu entries, drawn like the scope's menu, under an optional footerMenuTitle:
import { BookOpen, Github, Mail } from "lucide-react"
import { createAdminLayout } from "react-resource-view"
decoratorComponent: createAdminLayout({
footerMenuTitle: "Need help?",
footerMenu: [
// An absolute URL opens in a new tab…
{ name: "Documentation", icon: BookOpen, href: "https://example.com/docs" },
{ name: "GitHub", icon: Github, href: "https://github.com/acme/app" },
// …a mailto: hands over to the mail client…
{ name: "Contact", icon: Mail, href: "mailto:support@example.com" },
// …and a page of the application stays in it. Built outside a scope's
// menu, an entry names the scope its link leads into.
createItemMenuWithResource({ resource: faq, scope: "admin" }),
],
})For something else than links — a plan badge, a card, a button — sidebarFooter takes any node:
decoratorComponent: createAdminLayout({
// Anything at all, under the footerMenu when there is one.
sidebarFooter: <PlanBadge />,
})Without either, the sidebar has no footer at all. On a phone, both go behind a last entry of the bottom bar, named after footerMenuTitle — « More » without one — which opens them in a drawer. The playground's back office has a « Need help? » block of this kind.
The page header#
On a record, the header says which one is on screen and how to get back: a link to the list, the record's name as the title, the action and its id underneath, and the actions still worth offering — edit from the read page, delete from either. The same name ends the breadcrumb in the top bar.
The name is read from title, name or label; a resource — or one of its views — names another field with titleField. Each part of the header is a slot a view can fill:
views: {
update: {
// The field that names an account, instead of `title` or `name`.
titleField: "email",
components: {
// Either of them, or both — the rest of the header stays.
title: () => <h2>…</h2>,
actions: () => <PublishButton />,
// Or the whole header, tabs included.
// navigation: () => <MyHeader />,
},
},
}Full width#
Views are drawn in a column of constrained width — the right measure for a form or a record, too narrow for a board, a calendar, a timeline or a table with a dozen columns. A view declaring fullWidth takes the whole page instead; the side margins stay, and a phone is laid out as before.
createViewResource<Task>("tasks", {
path: "/api/tasks",
// Every action of the resource, across the whole page…
fullWidth: true,
views: {
list: {
layouts: [
layout.board({ groupBy: "status", columns: STATUSES }),
layout.calendar({ dateField: "dueDate" }),
],
},
// …but its forms, which keep the column.
create: { fullWidth: false },
update: { fullWidth: false },
},
})The option is read at three levels, and the most specific one wins: the list layout on screen, then the action's entry in views, then the root of the resource. Nothing declared means the column, so a resource that never mentions it is drawn exactly as before. For a single layout of a list:
layouts: [
// Only the board is wide; the table stays in the column.
layout.board({ groupBy: "status", columns: STATUSES, fullWidth: true }),
layout.table(),
]A decoratorComponent of your own honours the same option through isFullWidthView, which resolves it for the view on screen:
import { isFullWidthView, useCurrentViewResourceContext } from "react-resource-view"
function MyLayout({ children }: { children: ReactNode }) {
const wide = isFullWidthView(useCurrentViewResourceContext())
return <main className={wide ? "px-6" : "mx-auto max-w-5xl px-6"}>{children}</main>
}With the rest of the declaration
The option is also listed with the rest of a resource's declaration, under declaring a resource.
Theme and dark mode#
Every surface of the template is drawn with the shadcn tokens — background, muted, border, primary… — so it takes the palette of an application that has a shadcn theme, and follows its .dark class. Without one, the stylesheet imported at installation supplies a light and a dark palette of its own.
Responsive by default#
Below the md breakpoint the sidebar is replaced with a bottom navigation bar; a menu entry with children opens a drawer instead of a nested menu, and the bottom of the sidebar goes behind a last « more » entry. Nothing to configure — AdminLayout switches on the viewport width itself.
The building blocks#
Each piece is exported on its own, for a shell that doesn't match this shape:
AdminSidebarNav— the desktop sidebar, built from the scope's menu, with its footer.AdminTopBar— sticky top bar: the sidebar toggle, the breadcrumb, and a slot for the rest.AdminHeader— page title, the record's actions, and the sub-navigation tabs a menu group opts into.AdminMobileNav— bottom navigation bar, in place of the sidebar on narrow screens.
Reference#
| Name | Type | Default | Description |
|---|---|---|---|
logo | ReactNode | — | Rendered in the sidebar header on desktop, and in the top bar on mobile. |
topBarEnd | ReactNode | — | Rendered at the end of the top bar — a search field, a user menu… |
footerMenu | MenuItemInterface[] | — | Links pinned at the bottom of the sidebar, drawn like the menu. An absolute href opens in a new tab. On mobile, in the drawer of the bottom bar's last entry. |
footerMenuTitle | string | — | Heading above footerMenu — “Need help?”; names the last entry of the bottom bar on mobile. |
sidebarFooter | ReactNode | — | Pinned at the bottom of the sidebar, under footerMenu. On mobile, in the same drawer. |
And, read off the scope and its resources rather than passed as props:
| Name | Type | Default | Description |
|---|---|---|---|
scope.menu | "auto" | (ViewResource | MenuItemInterface)[] | — | The sidebar, the bottom bar and the header's tabs. |
scope.label | string | — | The first link of the breadcrumb: the application's name. |
titleField | string | — | The field naming a record in the header and the breadcrumb. |
components | { title?, actions?, navigation? } | — | Replace a part of the page header, or all of it. |
fullWidth | boolean | false | The whole width of the page — at the root of the resource, on views.<action> or on a layout of the list. |