diff --git a/packages/admin-next/src/components/admin-shell.test.tsx b/packages/admin-next/src/components/admin-shell.test.tsx
new file mode 100644
index 0000000..eb58092
--- /dev/null
+++ b/packages/admin-next/src/components/admin-shell.test.tsx
@@ -0,0 +1,79 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
+import type { AdminActions } from "../server/actions.js";
+import type { ResourceInfo } from "../types.js";
+import { AdminShell } from "./admin-shell.js";
+
+const resources: ResourceInfo[] = [
+ {
+ id: "posts",
+ name: "Posts",
+ description: "Published content.",
+ icon: "file-text",
+ type: "table",
+ dataUrl: "/admin/resources/posts/action?action=view",
+ defaultAction: "view",
+ supportedActions: [],
+ },
+];
+
+const noopActions: AdminActions = {
+ listResources: vi.fn(),
+ getSchema: vi.fn(),
+ fetchAction: vi.fn(),
+ fetchUrl: vi.fn(),
+ submitAction: vi.fn(),
+};
+
+function renderShell({
+ initialSidebarCollapsed,
+}: { initialSidebarCollapsed?: boolean } = {}) {
+ return render(
+ ,
+ );
+}
+
+afterEach(() => {
+ cleanup();
+ window.localStorage.clear();
+ document.cookie = "ag_sidebar_collapsed=; path=/; max-age=0";
+});
+
+describe("AdminShell", () => {
+ it("uses the server-provided collapsed sidebar state on initial render", () => {
+ renderShell({ initialSidebarCollapsed: true });
+
+ expect(screen.getByRole("button", { name: "Expand sidebar" })).toBeTruthy();
+ });
+
+ it("prefers localStorage over the server-provided sidebar state", () => {
+ window.localStorage.setItem("ag:sidebar-collapsed", "true");
+
+ renderShell({ initialSidebarCollapsed: false });
+
+ expect(screen.getByRole("button", { name: "Expand sidebar" })).toBeTruthy();
+ });
+
+ it("restores the persisted collapsed sidebar state after remount", async () => {
+ renderShell();
+
+ fireEvent.click(screen.getByRole("button", { name: "Collapse sidebar" }));
+
+ expect(window.localStorage.getItem("ag:sidebar-collapsed")).toBe("true");
+ expect(document.cookie).toContain("ag_sidebar_collapsed=true");
+ expect(screen.getByRole("button", { name: "Expand sidebar" })).toBeTruthy();
+
+ cleanup();
+ renderShell();
+
+ await waitFor(() => {
+ expect(screen.getByRole("button", { name: "Expand sidebar" })).toBeTruthy();
+ });
+ });
+});
diff --git a/packages/admin-next/src/components/admin-shell.tsx b/packages/admin-next/src/components/admin-shell.tsx
index ea36ff5..5c9f731 100644
--- a/packages/admin-next/src/components/admin-shell.tsx
+++ b/packages/admin-next/src/components/admin-shell.tsx
@@ -1,7 +1,9 @@
"use client";
import * as React from "react";
+import * as Dialog from "@radix-ui/react-dialog";
import * as Icons from "lucide-react";
+import Link from "next/link";
import type {
ActionType,
DetailResponse,
@@ -32,6 +34,41 @@ export interface AdminShellProps {
title?: React.ReactNode;
/** Custom content rendered at the top-right of the app header. */
headerActions?: React.ReactNode;
+ /** Initial desktop sidebar state read by the server to avoid hydration flicker. */
+ initialSidebarCollapsed?: boolean;
+}
+
+const SIDEBAR_COLLAPSED_STORAGE_KEY = "ag:sidebar-collapsed";
+const SIDEBAR_COLLAPSED_COOKIE_KEY = "ag_sidebar_collapsed";
+
+function readPersistedSidebarCollapsed(): boolean | null {
+ if (typeof window === "undefined") return null;
+ try {
+ const value = window.localStorage.getItem(SIDEBAR_COLLAPSED_STORAGE_KEY);
+ if (value === "true") return true;
+ if (value === "false") return false;
+ return null;
+ } catch {
+ return null;
+ }
+}
+
+function persistSidebarCollapsed(collapsed: boolean): void {
+ try {
+ window.localStorage.setItem(
+ SIDEBAR_COLLAPSED_STORAGE_KEY,
+ String(collapsed),
+ );
+ } catch {
+ // Ignore storage failures; the in-memory collapsed state still works.
+ }
+ try {
+ document.cookie = `${SIDEBAR_COLLAPSED_COOKIE_KEY}=${String(
+ collapsed,
+ )}; path=/; max-age=31536000; SameSite=Lax`;
+ } catch {
+ // Ignore cookie failures; localStorage still covers client remounts.
+ }
}
/** Look up a lucide icon by its kebab/pascal name, with a sane fallback. */
@@ -56,65 +93,209 @@ export function AdminShell({
error,
title,
headerActions,
+ initialSidebarCollapsed = false,
}: AdminShellProps): React.JSX.Element {
+ const [sidebarCollapsed, setSidebarCollapsed] = React.useState(() =>
+ readPersistedSidebarCollapsed() ?? initialSidebarCollapsed,
+ );
+ const activeResource = resources.find((r) => r.id === activeResourceId);
+ const pageTitle = activeResource?.name ?? "Resources";
+ const toggleSidebarCollapsed = React.useCallback(() => {
+ setSidebarCollapsed((collapsed) => {
+ const next = !collapsed;
+ persistSidebarCollapsed(next);
+ return next;
+ });
+ }, []);
+
return (
-
-
- {title ?? "Admin"}
- {headerActions ? (
- {headerActions}
- ) : null}
-
+
-
);
}
+function SidebarContent({
+ basePath,
+ resources,
+ activeResourceId,
+ title,
+ onNavigate,
+ collapsed,
+}: {
+ basePath: string;
+ resources: ResourceInfo[];
+ activeResourceId?: string;
+ title?: React.ReactNode;
+ onNavigate?: () => void;
+ collapsed?: boolean;
+}): React.JSX.Element {
+ return (
+
+
+
+
+
+
+ {title ?? "Admin"}
+
+
+
+
+ );
+}
+
+function MobileSidebar({
+ basePath,
+ resources,
+ activeResourceId,
+ title,
+}: {
+ basePath: string;
+ resources: ResourceInfo[];
+ activeResourceId?: string;
+ title?: React.ReactNode;
+}): React.JSX.Element {
+ const [open, setOpen] = React.useState(false);
+
+ return (
+
+
+
+
+
+
+
+ Navigation
+ setOpen(false)}
+ />
+
+
+
+ );
+}
+
function Dashboard({
resources,
basePath,
@@ -127,7 +308,7 @@ function Dashboard({
Resources
diff --git a/packages/admin-next/src/components/resource-view.test.tsx b/packages/admin-next/src/components/resource-view.test.tsx
index eb28654..66b855f 100644
--- a/packages/admin-next/src/components/resource-view.test.tsx
+++ b/packages/admin-next/src/components/resource-view.test.tsx
@@ -1,9 +1,15 @@
-import { describe, expect, it, vi } from "vitest";
-import { render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { fireEvent, render, screen } from "@testing-library/react";
import type { ResourceInfo, TableSchema } from "../types.js";
import type { AdminActions } from "../server/actions.js";
import { ResourceView } from "./resource-view.js";
+const routerPush = vi.hoisted(() => vi.fn());
+
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ push: routerPush }),
+}));
+
const schema: TableSchema = {
uiType: "table",
type: "view",
@@ -18,6 +24,10 @@ const noopActions: AdminActions = {
submitAction: vi.fn(),
};
+afterEach(() => {
+ routerPush.mockReset();
+});
+
describe("ResourceView", () => {
// A form/custom resource can return a nil SupportedActions slice, which
// serializes to JSON null. Rendering must not crash on `.some` of null.
@@ -83,4 +93,34 @@ describe("ResourceView", () => {
expect(screen.getByText("+ Create")).toBeTruthy();
});
+
+ it("uses the Next router for row navigation", () => {
+ render(
+ ,
+ );
+
+ fireEvent.click(screen.getByRole("row", { name: "Open row 1" }));
+
+ expect(routerPush).toHaveBeenCalledWith("/admin/posts/1");
+ });
});
diff --git a/packages/admin-next/src/components/resource-view.tsx b/packages/admin-next/src/components/resource-view.tsx
index 3741f6e..04cca10 100644
--- a/packages/admin-next/src/components/resource-view.tsx
+++ b/packages/admin-next/src/components/resource-view.tsx
@@ -1,6 +1,8 @@
"use client";
import * as React from "react";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
import type {
ActionButton,
ActionType,
@@ -37,6 +39,7 @@ interface SheetState {
export function ResourceView(props: ResourceViewProps): React.JSX.Element {
const { resource, resourceId, schema, actions } = props;
+ const router = useRouter();
const [data, setData] = React.useState(
props.initialData,
);
@@ -133,6 +136,7 @@ export function ResourceView(props: ResourceViewProps): React.JSX.Element {
new URL(url, "http://x").searchParams.get("after") ?? undefined;
refresh(after);
}}
+ onRowNavigate={(url) => router.push(url)}
/>
)}
>
@@ -185,12 +189,12 @@ function ResourceDetail({
return (
diff --git a/packages/admin-next/src/components/sheet.tsx b/packages/admin-next/src/components/sheet.tsx
index db7d6cc..710944c 100644
--- a/packages/admin-next/src/components/sheet.tsx
+++ b/packages/admin-next/src/components/sheet.tsx
@@ -17,11 +17,10 @@ export function Sheet({ open, title, onClose, children }: SheetProps): React.JSX
return (
!o && onClose()}>
-
+
diff --git a/packages/admin-next/src/server/admin-app.tsx b/packages/admin-next/src/server/admin-app.tsx
index d96da33..a49715c 100644
--- a/packages/admin-next/src/server/admin-app.tsx
+++ b/packages/admin-next/src/server/admin-app.tsx
@@ -1,4 +1,5 @@
import * as React from "react";
+import { cookies } from "next/headers";
import type {
ActionType,
DetailResponse,
@@ -11,6 +12,8 @@ import { AdminShell } from "../components/admin-shell.js";
import type { AdminActions } from "./actions.js";
import { clientFor, type ResolvedAdminConfig } from "./config.js";
+const SIDEBAR_COLLAPSED_COOKIE_KEY = "ag_sidebar_collapsed";
+
export interface AdminAppProps {
config: ResolvedAdminConfig;
/** Server actions bound to the same config (from a "use server" module). */
@@ -57,6 +60,8 @@ export async function AdminApp({
}: AdminAppProps): Promise {
const { slug } = await params;
const sp = (await searchParams) ?? {};
+ const initialSidebarCollapsed =
+ (await cookies()).get(SIDEBAR_COLLAPSED_COOKIE_KEY)?.value === "true";
const client = await clientFor(config);
let resources: ResourceInfo[] = [];
@@ -109,6 +114,7 @@ export async function AdminApp({
error={error}
title={title}
headerActions={headerActions}
+ initialSidebarCollapsed={initialSidebarCollapsed}
/>
);
}
diff --git a/packages/admin-next/src/theme.css b/packages/admin-next/src/theme.css
index 665d5da..f366d07 100644
--- a/packages/admin-next/src/theme.css
+++ b/packages/admin-next/src/theme.css
@@ -96,7 +96,15 @@
.ag-form input[type="email"],
.ag-form input[type="number"],
.ag-form input[type="url"],
+.ag-form input[type="password"],
+.ag-form input[type="search"],
+.ag-form input[type="tel"],
.ag-form input[type="color"],
+.ag-form input[type="date"],
+.ag-form input[type="datetime-local"],
+.ag-form input[type="month"],
+.ag-form input[type="time"],
+.ag-form input[type="week"],
.ag-form textarea,
.ag-form select {
width: 100%;
@@ -108,6 +116,23 @@
font-size: 0.875rem;
}
+.ag-form input[type="date"],
+.ag-form input[type="datetime-local"],
+.ag-form input[type="month"],
+.ag-form input[type="time"],
+.ag-form input[type="week"] {
+ min-height: 2.25rem;
+ line-height: 1.25rem;
+}
+
+.ag-form input[type="date"]::-webkit-calendar-picker-indicator,
+.ag-form input[type="datetime-local"]::-webkit-calendar-picker-indicator,
+.ag-form input[type="month"]::-webkit-calendar-picker-indicator,
+.ag-form input[type="time"]::-webkit-calendar-picker-indicator,
+.ag-form input[type="week"]::-webkit-calendar-picker-indicator {
+ cursor: pointer;
+}
+
.ag-form .form-group {
margin-bottom: 0.875rem;
}
@@ -127,3 +152,89 @@
padding: 0;
margin: 0.25rem 0 0;
}
+
+.ag-sheet-overlay[data-state="open"] {
+ animation: ag-sheet-overlay-in 160ms ease-out;
+}
+
+.ag-sheet-overlay[data-state="closed"] {
+ animation: ag-sheet-overlay-out 120ms ease-in;
+}
+
+.ag-sheet-content[data-state="open"] {
+ animation: ag-sheet-slide-in 220ms cubic-bezier(0.16, 1, 0.3, 1);
+}
+
+.ag-sheet-content[data-state="closed"] {
+ animation: ag-sheet-slide-out 160ms ease-in;
+}
+
+.ag-mobile-sidebar-overlay[data-state="open"] {
+ animation: ag-sheet-overlay-in 160ms ease-out;
+}
+
+.ag-mobile-sidebar-overlay[data-state="closed"] {
+ animation: ag-sheet-overlay-out 120ms ease-in;
+}
+
+.ag-mobile-sidebar[data-state="open"] {
+ animation: ag-mobile-sidebar-in 220ms cubic-bezier(0.16, 1, 0.3, 1);
+}
+
+.ag-mobile-sidebar[data-state="closed"] {
+ animation: ag-mobile-sidebar-out 160ms ease-in;
+}
+
+@keyframes ag-sheet-overlay-in {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+
+@keyframes ag-sheet-overlay-out {
+ from {
+ opacity: 1;
+ }
+ to {
+ opacity: 0;
+ }
+}
+
+@keyframes ag-sheet-slide-in {
+ from {
+ transform: translateX(100%);
+ }
+ to {
+ transform: translateX(0);
+ }
+}
+
+@keyframes ag-sheet-slide-out {
+ from {
+ transform: translateX(0);
+ }
+ to {
+ transform: translateX(100%);
+ }
+}
+
+@keyframes ag-mobile-sidebar-in {
+ from {
+ transform: translateX(-100%);
+ }
+ to {
+ transform: translateX(0);
+ }
+}
+
+@keyframes ag-mobile-sidebar-out {
+ from {
+ transform: translateX(0);
+ }
+ to {
+ transform: translateX(-100%);
+ }
+}