From 78512d705ba8c8e17bd83095a8320f1d3c4e85b1 Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Tue, 7 Jul 2026 04:54:19 +0800 Subject: [PATCH 1/6] feat: refine admin shell layout --- .../admin-next/src/components/admin-shell.tsx | 231 ++++++++++++++---- packages/admin-next/src/components/sheet.tsx | 5 +- packages/admin-next/src/theme.css | 111 +++++++++ 3 files changed, 296 insertions(+), 51 deletions(-) diff --git a/packages/admin-next/src/components/admin-shell.tsx b/packages/admin-next/src/components/admin-shell.tsx index ea36ff5..b35710a 100644 --- a/packages/admin-next/src/components/admin-shell.tsx +++ b/packages/admin-next/src/components/admin-shell.tsx @@ -1,6 +1,7 @@ "use client"; import * as React from "react"; +import * as Dialog from "@radix-ui/react-dialog"; import * as Icons from "lucide-react"; import type { ActionType, @@ -57,64 +58,198 @@ export function AdminShell({ title, headerActions, }: AdminShellProps): React.JSX.Element { + const [sidebarCollapsed, setSidebarCollapsed] = React.useState(false); + const activeResource = resources.find((r) => r.id === activeResourceId); + const pageTitle = activeResource?.name ?? "Resources"; + return ( -
-
-
{title ?? "Admin"}
- {headerActions ? ( -
{headerActions}
- ) : null} -
+
+ -
- + + + +
+
+
{pageTitle}
+ {activeResource?.description ? ( +
+ {activeResource.description} +
+ ) : null} +
+ {headerActions ? ( +
+ {headerActions} +
+ ) : null} + -
- {error ? ( -
- {error} -
- ) : initialView ? ( - r.id === initialView.resourceId)} - resourceId={initialView.resourceId} - action={initialView.action} - dynamicPath={initialView.dynamicPath} - schema={initialView.schema} - initialData={initialView.initialData} - initialDetail={initialView.initialDetail} - actions={actions} - /> - ) : ( - - )} -
+
+
+ {error ? ( +
+ {error} +
+ ) : initialView ? ( + + ) : ( + + )} +
+
+
+
-
); } +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, 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/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%); + } +} From 30fd48848d09455a51c22a9a363891d3e760672a Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Tue, 7 Jul 2026 04:58:38 +0800 Subject: [PATCH 2/6] style: increase admin shell corner radius --- packages/admin-next/src/components/admin-shell.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/admin-next/src/components/admin-shell.tsx b/packages/admin-next/src/components/admin-shell.tsx index b35710a..81e4c18 100644 --- a/packages/admin-next/src/components/admin-shell.tsx +++ b/packages/admin-next/src/components/admin-shell.tsx @@ -92,7 +92,7 @@ export function AdminShell({
-
+
From e4f483e0c9384d866dd9623bd7cbf8513e50bfa2 Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Tue, 7 Jul 2026 05:04:57 +0800 Subject: [PATCH 4/6] fix: use next links for admin navigation --- packages/admin-next/src/components/admin-shell.tsx | 13 +++++++------ .../admin-next/src/components/resource-view.tsx | 5 +++-- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/admin-next/src/components/admin-shell.tsx b/packages/admin-next/src/components/admin-shell.tsx index b8151dd..93df1dc 100644 --- a/packages/admin-next/src/components/admin-shell.tsx +++ b/packages/admin-next/src/components/admin-shell.tsx @@ -3,6 +3,7 @@ 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, @@ -206,7 +207,7 @@ function SidebarContent({ }): React.JSX.Element { return (
- {title ?? "Admin"} - +
@@ -295,7 +296,7 @@ function Dashboard({

Resources

{resources.map((r) => ( -

{r.description}

-
+ ))}
diff --git a/packages/admin-next/src/components/resource-view.tsx b/packages/admin-next/src/components/resource-view.tsx index 3741f6e..fe9ff44 100644 --- a/packages/admin-next/src/components/resource-view.tsx +++ b/packages/admin-next/src/components/resource-view.tsx @@ -1,6 +1,7 @@ "use client"; import * as React from "react"; +import Link from "next/link"; import type { ActionButton, ActionType, @@ -185,12 +186,12 @@ function ResourceDetail({ return (
From 32d89a4e91ef79fb940cdb2f8bdee37091a9d769 Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Tue, 7 Jul 2026 05:08:15 +0800 Subject: [PATCH 5/6] fix: avoid sidebar collapse flicker --- .../src/components/admin-shell.test.tsx | 13 ++++++- .../admin-next/src/components/admin-shell.tsx | 34 +++++++++++++++---- packages/admin-next/src/server/admin-app.tsx | 6 ++++ 3 files changed, 45 insertions(+), 8 deletions(-) diff --git a/packages/admin-next/src/components/admin-shell.test.tsx b/packages/admin-next/src/components/admin-shell.test.tsx index 487098d..bee6149 100644 --- a/packages/admin-next/src/components/admin-shell.test.tsx +++ b/packages/admin-next/src/components/admin-shell.test.tsx @@ -25,13 +25,16 @@ const noopActions: AdminActions = { submitAction: vi.fn(), }; -function renderShell() { +function renderShell({ + initialSidebarCollapsed, +}: { initialSidebarCollapsed?: boolean } = {}) { return render( , ); } @@ -39,15 +42,23 @@ function renderShell() { 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("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(); diff --git a/packages/admin-next/src/components/admin-shell.tsx b/packages/admin-next/src/components/admin-shell.tsx index 93df1dc..203f566 100644 --- a/packages/admin-next/src/components/admin-shell.tsx +++ b/packages/admin-next/src/components/admin-shell.tsx @@ -34,16 +34,25 @@ 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 { - if (typeof window === "undefined") return false; +const useIsomorphicLayoutEffect = + typeof window === "undefined" ? React.useEffect : React.useLayoutEffect; + +function readPersistedSidebarCollapsed(): boolean | null { + if (typeof window === "undefined") return null; try { - return window.localStorage.getItem(SIDEBAR_COLLAPSED_STORAGE_KEY) === "true"; + const value = window.localStorage.getItem(SIDEBAR_COLLAPSED_STORAGE_KEY); + if (value === "true") return true; + if (value === "false") return false; + return null; } catch { - return false; + return null; } } @@ -56,6 +65,13 @@ function persistSidebarCollapsed(collapsed: boolean): void { } 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. */ @@ -80,8 +96,11 @@ export function AdminShell({ error, title, headerActions, + initialSidebarCollapsed = false, }: AdminShellProps): React.JSX.Element { - const [sidebarCollapsed, setSidebarCollapsed] = React.useState(false); + const [sidebarCollapsed, setSidebarCollapsed] = React.useState( + initialSidebarCollapsed, + ); const activeResource = resources.find((r) => r.id === activeResourceId); const pageTitle = activeResource?.name ?? "Resources"; const toggleSidebarCollapsed = React.useCallback(() => { @@ -92,8 +111,9 @@ export function AdminShell({ }); }, []); - React.useEffect(() => { - setSidebarCollapsed(readPersistedSidebarCollapsed()); + useIsomorphicLayoutEffect(() => { + const persisted = readPersistedSidebarCollapsed(); + if (persisted !== null) setSidebarCollapsed(persisted); }, []); return ( 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} /> ); } From ae0d9ae578bf579340ab45c761655da1af820945 Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Tue, 7 Jul 2026 05:11:22 +0800 Subject: [PATCH 6/6] fix: prevent sidebar flicker on navigation --- .../src/components/admin-shell.test.tsx | 8 ++++ .../admin-next/src/components/admin-shell.tsx | 12 +---- .../src/components/resource-view.test.tsx | 44 ++++++++++++++++++- .../src/components/resource-view.tsx | 3 ++ 4 files changed, 55 insertions(+), 12 deletions(-) diff --git a/packages/admin-next/src/components/admin-shell.test.tsx b/packages/admin-next/src/components/admin-shell.test.tsx index bee6149..eb58092 100644 --- a/packages/admin-next/src/components/admin-shell.test.tsx +++ b/packages/admin-next/src/components/admin-shell.test.tsx @@ -52,6 +52,14 @@ describe("AdminShell", () => { 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(); diff --git a/packages/admin-next/src/components/admin-shell.tsx b/packages/admin-next/src/components/admin-shell.tsx index 203f566..5c9f731 100644 --- a/packages/admin-next/src/components/admin-shell.tsx +++ b/packages/admin-next/src/components/admin-shell.tsx @@ -41,9 +41,6 @@ export interface AdminShellProps { const SIDEBAR_COLLAPSED_STORAGE_KEY = "ag:sidebar-collapsed"; const SIDEBAR_COLLAPSED_COOKIE_KEY = "ag_sidebar_collapsed"; -const useIsomorphicLayoutEffect = - typeof window === "undefined" ? React.useEffect : React.useLayoutEffect; - function readPersistedSidebarCollapsed(): boolean | null { if (typeof window === "undefined") return null; try { @@ -98,8 +95,8 @@ export function AdminShell({ headerActions, initialSidebarCollapsed = false, }: AdminShellProps): React.JSX.Element { - const [sidebarCollapsed, setSidebarCollapsed] = React.useState( - initialSidebarCollapsed, + const [sidebarCollapsed, setSidebarCollapsed] = React.useState(() => + readPersistedSidebarCollapsed() ?? initialSidebarCollapsed, ); const activeResource = resources.find((r) => r.id === activeResourceId); const pageTitle = activeResource?.name ?? "Resources"; @@ -111,11 +108,6 @@ export function AdminShell({ }); }, []); - useIsomorphicLayoutEffect(() => { - const persisted = readPersistedSidebarCollapsed(); - if (persisted !== null) setSidebarCollapsed(persisted); - }, []); - return (
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 fe9ff44..04cca10 100644 --- a/packages/admin-next/src/components/resource-view.tsx +++ b/packages/admin-next/src/components/resource-view.tsx @@ -2,6 +2,7 @@ import * as React from "react"; import Link from "next/link"; +import { useRouter } from "next/navigation"; import type { ActionButton, ActionType, @@ -38,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, ); @@ -134,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)} /> )}