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} -
+
+ -
- + + + +
+
+
{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, @@ -127,7 +308,7 @@ function Dashboard({

Resources

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

{r.description}

-
+ ))}
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 (
- Back to {resource?.name ?? resourceId} - +
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%); + } +}