@liam-public/browser-react-cms 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -0
- package/dist/index.d.ts +111 -0
- package/dist/index.js +180 -0
- package/dist/index.js.map +1 -0
- package/package.json +57 -0
- package/theme.css +32 -0
package/README.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# @liam-public/browser-react-cms
|
|
2
|
+
|
|
3
|
+
Reusable CMS/admin **chrome** on top of `@liam-public/browser-react-ui` — the parts every
|
|
4
|
+
admin app reinvents: a responsive app-shell, auth route-guards, and page primitives.
|
|
5
|
+
|
|
6
|
+
## Exports
|
|
7
|
+
|
|
8
|
+
- **`CmsLayout`** — responsive shell (desktop sidebar + mobile hamburger sheet) driven by a
|
|
9
|
+
`nav: NavItem[]` prop (`{ to, label, icon, end?, badge? }`), with `brand` and `headerRight`.
|
|
10
|
+
- **`RequireAuth`** / **`RequireRole`** — route guards over `useAuth()` from
|
|
11
|
+
`@liam-public/browser-react-auth` (the guard `browser-react-auth` doesn't ship). `RequireAuth`
|
|
12
|
+
takes `onUnauthenticated` (e.g. trigger the OIDC redirect) + a `fallback`; `RequireRole` takes
|
|
13
|
+
`anyOf` roles + a `getRoles(user)` extractor.
|
|
14
|
+
- **`PageHeader`**, **`PageStack`** — page title row + vertical section spacing.
|
|
15
|
+
- **`DesktopOnly`/`MobileCards`/`MobileCard`/`MobileField`** — responsive table/card primitives.
|
|
16
|
+
- **`LoadingSpinner`**, **`ConfirmDialog`** — common feedback components.
|
|
17
|
+
|
|
18
|
+
## Setup
|
|
19
|
+
|
|
20
|
+
Peer deps: `react`, `react-dom`, `react-router-dom`. Run Tailwind v4 in your app and import the
|
|
21
|
+
theme once:
|
|
22
|
+
|
|
23
|
+
```css
|
|
24
|
+
/* app.css */
|
|
25
|
+
@import '@liam-public/browser-react-cms/theme.css';
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
```tsx
|
|
29
|
+
import { CmsLayout, RequireAuth } from '@liam-public/browser-react-cms'
|
|
30
|
+
import { LayoutDashboard } from 'lucide-react'
|
|
31
|
+
|
|
32
|
+
<RequireAuth onUnauthenticated={() => authClient.signInRedirect()}>
|
|
33
|
+
<CmsLayout brand="My App" nav={[{ to: '/', label: 'Dashboard', icon: LayoutDashboard, end: true }]}>
|
|
34
|
+
<Routes>…</Routes>
|
|
35
|
+
</CmsLayout>
|
|
36
|
+
</RequireAuth>
|
|
37
|
+
```
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
import { ReactNode, ComponentType } from 'react';
|
|
3
|
+
|
|
4
|
+
/** A single sidebar navigation entry. `icon` is any lucide-react (or compatible) icon. */
|
|
5
|
+
interface NavItem {
|
|
6
|
+
readonly to: string;
|
|
7
|
+
readonly label: string;
|
|
8
|
+
readonly icon: ComponentType<{
|
|
9
|
+
className?: string;
|
|
10
|
+
}>;
|
|
11
|
+
/** Exact-match the route (e.g. for the index `/`). */
|
|
12
|
+
readonly end?: boolean;
|
|
13
|
+
/** Optional trailing content, e.g. an alert count `<Badge>`. */
|
|
14
|
+
readonly badge?: ReactNode;
|
|
15
|
+
}
|
|
16
|
+
interface CmsLayoutProps {
|
|
17
|
+
readonly children: ReactNode;
|
|
18
|
+
/** Sidebar navigation entries. */
|
|
19
|
+
readonly nav: readonly NavItem[];
|
|
20
|
+
/** Brand shown at the top of the sidebar (defaults to "CMS"). */
|
|
21
|
+
readonly brand?: ReactNode;
|
|
22
|
+
/** Optional content for the top-right of the desktop header (e.g. a user menu). */
|
|
23
|
+
readonly headerRight?: ReactNode;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Responsive CMS app shell: a desktop sidebar (md+) and a mobile hamburger sheet,
|
|
27
|
+
* with an active-link-aware nav driven entirely by the `nav` prop. Bring your own
|
|
28
|
+
* router (`react-router-dom`) and Tailwind theme.
|
|
29
|
+
*/
|
|
30
|
+
declare function CmsLayout({ children, nav, brand, headerRight }: CmsLayoutProps): react.JSX.Element;
|
|
31
|
+
|
|
32
|
+
interface RequireAuthProps {
|
|
33
|
+
readonly children: ReactNode;
|
|
34
|
+
/** Rendered while loading or when unauthenticated (defaults to a "Redirecting…" line). */
|
|
35
|
+
readonly fallback?: ReactNode;
|
|
36
|
+
/**
|
|
37
|
+
* Called once when the user is confirmed unauthenticated — e.g. trigger the OIDC
|
|
38
|
+
* redirect from `@liam-workspace/auth-client`, or navigate to `/login`.
|
|
39
|
+
*/
|
|
40
|
+
readonly onUnauthenticated?: () => void;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Gate that renders `children` only for an authenticated user. Wraps `useAuth()` from
|
|
44
|
+
* `@liam-public/browser-react-auth`, filling the route-guard gap that package leaves open.
|
|
45
|
+
*/
|
|
46
|
+
declare function RequireAuth({ children, fallback, onUnauthenticated }: RequireAuthProps): react.JSX.Element;
|
|
47
|
+
interface RequireRoleProps {
|
|
48
|
+
readonly children: ReactNode;
|
|
49
|
+
/** The user is allowed if they hold ANY of these roles. */
|
|
50
|
+
readonly anyOf: readonly string[];
|
|
51
|
+
/** Extract the user's roles from the auth `user` object (shape is app-specific). */
|
|
52
|
+
readonly getRoles: (user: unknown) => readonly string[];
|
|
53
|
+
/** Rendered when the user lacks the required role(s). */
|
|
54
|
+
readonly fallback?: ReactNode;
|
|
55
|
+
}
|
|
56
|
+
/** Gate that additionally requires the authenticated user to hold one of `anyOf` roles. */
|
|
57
|
+
declare function RequireRole({ children, anyOf, getRoles, fallback }: RequireRoleProps): react.JSX.Element;
|
|
58
|
+
|
|
59
|
+
interface PageHeaderProps {
|
|
60
|
+
readonly title: string;
|
|
61
|
+
readonly action?: ReactNode;
|
|
62
|
+
}
|
|
63
|
+
/** A page title row with an optional right-aligned action (e.g. a "New" button). */
|
|
64
|
+
declare function PageHeader({ title, action }: PageHeaderProps): react.JSX.Element;
|
|
65
|
+
interface PageStackProps {
|
|
66
|
+
readonly children: ReactNode;
|
|
67
|
+
readonly spacing?: 4 | 6;
|
|
68
|
+
}
|
|
69
|
+
/** Vertical spacing container for page sections. */
|
|
70
|
+
declare function PageStack({ children, spacing }: PageStackProps): react.JSX.Element;
|
|
71
|
+
|
|
72
|
+
/** Renders children only at md and up (desktop table view). */
|
|
73
|
+
declare function DesktopOnly({ children }: {
|
|
74
|
+
children: ReactNode;
|
|
75
|
+
}): react.JSX.Element;
|
|
76
|
+
/** Renders children only below md (stacked-card view). */
|
|
77
|
+
declare function MobileCards({ children }: {
|
|
78
|
+
children: ReactNode;
|
|
79
|
+
}): react.JSX.Element;
|
|
80
|
+
/** A single stacked card used inside MobileCards. */
|
|
81
|
+
declare function MobileCard({ children }: {
|
|
82
|
+
children: ReactNode;
|
|
83
|
+
}): react.JSX.Element;
|
|
84
|
+
/** A label/value line inside a MobileCard. */
|
|
85
|
+
declare function MobileField({ label, children }: {
|
|
86
|
+
label: string;
|
|
87
|
+
children: ReactNode;
|
|
88
|
+
}): react.JSX.Element;
|
|
89
|
+
|
|
90
|
+
interface LoadingSpinnerProps {
|
|
91
|
+
readonly size?: 'md' | 'lg';
|
|
92
|
+
}
|
|
93
|
+
/** Centered spinner for loading states. */
|
|
94
|
+
declare function LoadingSpinner({ size }: LoadingSpinnerProps): react.JSX.Element;
|
|
95
|
+
interface ConfirmDialogProps {
|
|
96
|
+
readonly open: boolean;
|
|
97
|
+
readonly onOpenChange: (open: boolean) => void;
|
|
98
|
+
readonly title: string;
|
|
99
|
+
readonly description: string;
|
|
100
|
+
readonly onConfirm: () => void;
|
|
101
|
+
readonly loading?: boolean;
|
|
102
|
+
readonly error?: string | null;
|
|
103
|
+
/** Confirm-button label (defaults to "Confirm"). */
|
|
104
|
+
readonly confirmLabel?: string;
|
|
105
|
+
/** Set true for destructive actions (red confirm button). */
|
|
106
|
+
readonly destructive?: boolean;
|
|
107
|
+
}
|
|
108
|
+
/** A modal confirm dialog with loading + error states. */
|
|
109
|
+
declare function ConfirmDialog({ open, onOpenChange, title, description, onConfirm, loading, error, confirmLabel, destructive, }: ConfirmDialogProps): react.JSX.Element;
|
|
110
|
+
|
|
111
|
+
export { CmsLayout, type CmsLayoutProps, ConfirmDialog, type ConfirmDialogProps, DesktopOnly, LoadingSpinner, type LoadingSpinnerProps, MobileCard, MobileCards, MobileField, type NavItem, PageHeader, type PageHeaderProps, PageStack, type PageStackProps, RequireAuth, type RequireAuthProps, RequireRole, type RequireRoleProps };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// src/cms-layout.tsx
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { NavLink } from "react-router-dom";
|
|
4
|
+
import { Menu } from "lucide-react";
|
|
5
|
+
import {
|
|
6
|
+
Button,
|
|
7
|
+
Separator,
|
|
8
|
+
Sheet,
|
|
9
|
+
SheetContent,
|
|
10
|
+
SheetTrigger,
|
|
11
|
+
cn
|
|
12
|
+
} from "@liam-public/browser-react-ui";
|
|
13
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
14
|
+
function NavList({ nav, onNavigate }) {
|
|
15
|
+
return /* @__PURE__ */ jsx(Fragment, { children: nav.map((item) => /* @__PURE__ */ jsxs(
|
|
16
|
+
NavLink,
|
|
17
|
+
{
|
|
18
|
+
to: item.to,
|
|
19
|
+
end: item.end,
|
|
20
|
+
onClick: onNavigate,
|
|
21
|
+
className: ({ isActive }) => cn(
|
|
22
|
+
"flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent",
|
|
23
|
+
isActive && "bg-accent text-accent-foreground"
|
|
24
|
+
),
|
|
25
|
+
children: [
|
|
26
|
+
/* @__PURE__ */ jsx(item.icon, { className: "h-4 w-4" }),
|
|
27
|
+
item.label,
|
|
28
|
+
item.badge != null && /* @__PURE__ */ jsx("span", { className: "ml-auto", children: item.badge })
|
|
29
|
+
]
|
|
30
|
+
},
|
|
31
|
+
item.to
|
|
32
|
+
)) });
|
|
33
|
+
}
|
|
34
|
+
function CmsLayout({ children, nav, brand = "CMS", headerRight }) {
|
|
35
|
+
const [open, setOpen] = useState(false);
|
|
36
|
+
return /* @__PURE__ */ jsxs("div", { className: "flex h-screen flex-col md:flex-row", children: [
|
|
37
|
+
/* @__PURE__ */ jsxs("header", { className: "flex items-center gap-2 border-b p-3 md:hidden", children: [
|
|
38
|
+
/* @__PURE__ */ jsxs(Sheet, { open, onOpenChange: setOpen, children: [
|
|
39
|
+
/* @__PURE__ */ jsx(SheetTrigger, { asChild: true, children: /* @__PURE__ */ jsx(Button, { variant: "ghost", size: "icon", "aria-label": "Open menu", className: "h-11 w-11", children: /* @__PURE__ */ jsx(Menu, { className: "h-5 w-5" }) }) }),
|
|
40
|
+
/* @__PURE__ */ jsxs(SheetContent, { side: "left", className: "p-0", children: [
|
|
41
|
+
/* @__PURE__ */ jsx("div", { className: "p-4 font-semibold text-lg", children: brand }),
|
|
42
|
+
/* @__PURE__ */ jsx(Separator, {}),
|
|
43
|
+
/* @__PURE__ */ jsx("nav", { className: "flex flex-col gap-1 p-2", children: /* @__PURE__ */ jsx(NavList, { nav, onNavigate: () => setOpen(false) }) })
|
|
44
|
+
] })
|
|
45
|
+
] }),
|
|
46
|
+
/* @__PURE__ */ jsx("span", { className: "font-semibold", children: brand })
|
|
47
|
+
] }),
|
|
48
|
+
/* @__PURE__ */ jsxs("aside", { className: "hidden md:flex w-56 border-r bg-muted/30 flex-col", children: [
|
|
49
|
+
/* @__PURE__ */ jsx("div", { className: "p-4 font-semibold text-lg", children: brand }),
|
|
50
|
+
/* @__PURE__ */ jsx(Separator, {}),
|
|
51
|
+
/* @__PURE__ */ jsx("nav", { className: "flex flex-1 flex-col gap-1 p-2", children: /* @__PURE__ */ jsx(NavList, { nav }) })
|
|
52
|
+
] }),
|
|
53
|
+
/* @__PURE__ */ jsxs("main", { className: "flex-1 overflow-auto", children: [
|
|
54
|
+
headerRight && /* @__PURE__ */ jsx("div", { className: "hidden md:flex items-center justify-end border-b p-3", children: headerRight }),
|
|
55
|
+
/* @__PURE__ */ jsx("div", { className: "p-4 md:p-6", children })
|
|
56
|
+
] })
|
|
57
|
+
] });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// src/guards.tsx
|
|
61
|
+
import { useEffect } from "react";
|
|
62
|
+
import { useAuth } from "@liam-public/browser-react-auth";
|
|
63
|
+
import { Fragment as Fragment2, jsx as jsx2 } from "react/jsx-runtime";
|
|
64
|
+
function DefaultFallback({ message }) {
|
|
65
|
+
return /* @__PURE__ */ jsx2("div", { className: "flex h-screen items-center justify-center", children: /* @__PURE__ */ jsx2("p", { className: "text-sm text-muted-foreground", children: message }) });
|
|
66
|
+
}
|
|
67
|
+
function RequireAuth({ children, fallback, onUnauthenticated }) {
|
|
68
|
+
const { isAuthenticated, isLoading } = useAuth();
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (!isLoading && !isAuthenticated) onUnauthenticated?.();
|
|
71
|
+
}, [isLoading, isAuthenticated, onUnauthenticated]);
|
|
72
|
+
if (isLoading) return /* @__PURE__ */ jsx2(Fragment2, { children: fallback ?? /* @__PURE__ */ jsx2(DefaultFallback, { message: "Loading\u2026" }) });
|
|
73
|
+
if (!isAuthenticated) return /* @__PURE__ */ jsx2(Fragment2, { children: fallback ?? /* @__PURE__ */ jsx2(DefaultFallback, { message: "Redirecting to sign in\u2026" }) });
|
|
74
|
+
return /* @__PURE__ */ jsx2(Fragment2, { children });
|
|
75
|
+
}
|
|
76
|
+
function RequireRole({ children, anyOf, getRoles, fallback }) {
|
|
77
|
+
const { user, isLoading, isAuthenticated } = useAuth();
|
|
78
|
+
if (isLoading) return /* @__PURE__ */ jsx2(Fragment2, { children: fallback ?? /* @__PURE__ */ jsx2(DefaultFallback, { message: "Loading\u2026" }) });
|
|
79
|
+
if (!isAuthenticated) return /* @__PURE__ */ jsx2(Fragment2, { children: fallback ?? /* @__PURE__ */ jsx2(DefaultFallback, { message: "Not signed in." }) });
|
|
80
|
+
const roles = getRoles(user);
|
|
81
|
+
const allowed = anyOf.some((r) => roles.includes(r));
|
|
82
|
+
if (!allowed) return /* @__PURE__ */ jsx2(Fragment2, { children: fallback ?? /* @__PURE__ */ jsx2(DefaultFallback, { message: "You don't have access to this page." }) });
|
|
83
|
+
return /* @__PURE__ */ jsx2(Fragment2, { children });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// src/page.tsx
|
|
87
|
+
import { jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
88
|
+
function PageHeader({ title, action }) {
|
|
89
|
+
return /* @__PURE__ */ jsxs2("div", { className: "flex items-center justify-between", children: [
|
|
90
|
+
/* @__PURE__ */ jsx3("h1", { className: "text-2xl font-bold", children: title }),
|
|
91
|
+
action
|
|
92
|
+
] });
|
|
93
|
+
}
|
|
94
|
+
function PageStack({ children, spacing = 6 }) {
|
|
95
|
+
return /* @__PURE__ */ jsx3("div", { className: spacing === 4 ? "space-y-4" : "space-y-6", children });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// src/responsive-table.tsx
|
|
99
|
+
import { jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
|
|
100
|
+
function DesktopOnly({ children }) {
|
|
101
|
+
return /* @__PURE__ */ jsx4("div", { className: "hidden md:block", children });
|
|
102
|
+
}
|
|
103
|
+
function MobileCards({ children }) {
|
|
104
|
+
return /* @__PURE__ */ jsx4("div", { className: "flex flex-col gap-3 md:hidden", children });
|
|
105
|
+
}
|
|
106
|
+
function MobileCard({ children }) {
|
|
107
|
+
return /* @__PURE__ */ jsx4("div", { className: "rounded-lg border bg-card p-3 text-sm", children });
|
|
108
|
+
}
|
|
109
|
+
function MobileField({ label, children }) {
|
|
110
|
+
return /* @__PURE__ */ jsxs3("div", { className: "flex items-start justify-between gap-3 py-0.5", children: [
|
|
111
|
+
/* @__PURE__ */ jsx4("span", { className: "text-muted-foreground", children: label }),
|
|
112
|
+
/* @__PURE__ */ jsx4("span", { className: "text-right font-medium break-words", children })
|
|
113
|
+
] });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// src/feedback.tsx
|
|
117
|
+
import { Loader2 } from "lucide-react";
|
|
118
|
+
import {
|
|
119
|
+
Button as Button2,
|
|
120
|
+
Dialog,
|
|
121
|
+
DialogContent,
|
|
122
|
+
DialogDescription,
|
|
123
|
+
DialogFooter,
|
|
124
|
+
DialogHeader,
|
|
125
|
+
DialogTitle
|
|
126
|
+
} from "@liam-public/browser-react-ui";
|
|
127
|
+
import { jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
|
|
128
|
+
function LoadingSpinner({ size = "md" }) {
|
|
129
|
+
const padding = size === "lg" ? "py-16" : "py-8";
|
|
130
|
+
const icon = size === "lg" ? "h-8 w-8" : "h-6 w-6";
|
|
131
|
+
return /* @__PURE__ */ jsx5("div", { className: `flex items-center justify-center ${padding}`, children: /* @__PURE__ */ jsx5(Loader2, { className: `${icon} animate-spin text-muted-foreground` }) });
|
|
132
|
+
}
|
|
133
|
+
function ConfirmDialog({
|
|
134
|
+
open,
|
|
135
|
+
onOpenChange,
|
|
136
|
+
title,
|
|
137
|
+
description,
|
|
138
|
+
onConfirm,
|
|
139
|
+
loading,
|
|
140
|
+
error,
|
|
141
|
+
confirmLabel = "Confirm",
|
|
142
|
+
destructive
|
|
143
|
+
}) {
|
|
144
|
+
return /* @__PURE__ */ jsx5(Dialog, { open, onOpenChange, children: /* @__PURE__ */ jsxs4(DialogContent, { children: [
|
|
145
|
+
/* @__PURE__ */ jsxs4(DialogHeader, { children: [
|
|
146
|
+
/* @__PURE__ */ jsx5(DialogTitle, { children: title }),
|
|
147
|
+
/* @__PURE__ */ jsx5(DialogDescription, { children: description })
|
|
148
|
+
] }),
|
|
149
|
+
error && /* @__PURE__ */ jsx5("p", { className: "text-sm text-destructive", children: error }),
|
|
150
|
+
/* @__PURE__ */ jsxs4(DialogFooter, { children: [
|
|
151
|
+
/* @__PURE__ */ jsx5(Button2, { variant: "outline", onClick: () => onOpenChange(false), disabled: loading, children: "Cancel" }),
|
|
152
|
+
/* @__PURE__ */ jsxs4(
|
|
153
|
+
Button2,
|
|
154
|
+
{
|
|
155
|
+
variant: destructive ? "destructive" : "default",
|
|
156
|
+
onClick: onConfirm,
|
|
157
|
+
disabled: loading,
|
|
158
|
+
children: [
|
|
159
|
+
loading && /* @__PURE__ */ jsx5(Loader2, { className: "h-4 w-4 animate-spin" }),
|
|
160
|
+
confirmLabel
|
|
161
|
+
]
|
|
162
|
+
}
|
|
163
|
+
)
|
|
164
|
+
] })
|
|
165
|
+
] }) });
|
|
166
|
+
}
|
|
167
|
+
export {
|
|
168
|
+
CmsLayout,
|
|
169
|
+
ConfirmDialog,
|
|
170
|
+
DesktopOnly,
|
|
171
|
+
LoadingSpinner,
|
|
172
|
+
MobileCard,
|
|
173
|
+
MobileCards,
|
|
174
|
+
MobileField,
|
|
175
|
+
PageHeader,
|
|
176
|
+
PageStack,
|
|
177
|
+
RequireAuth,
|
|
178
|
+
RequireRole
|
|
179
|
+
};
|
|
180
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/cms-layout.tsx","../src/guards.tsx","../src/page.tsx","../src/responsive-table.tsx","../src/feedback.tsx"],"sourcesContent":["import { useState } from 'react'\nimport type { ComponentType, ReactNode } from 'react'\nimport { NavLink } from 'react-router-dom'\nimport { Menu } from 'lucide-react'\nimport {\n Button,\n Separator,\n Sheet,\n SheetContent,\n SheetTrigger,\n cn,\n} from '@liam-public/browser-react-ui'\n\n/** A single sidebar navigation entry. `icon` is any lucide-react (or compatible) icon. */\nexport interface NavItem {\n readonly to: string\n readonly label: string\n readonly icon: ComponentType<{ className?: string }>\n /** Exact-match the route (e.g. for the index `/`). */\n readonly end?: boolean\n /** Optional trailing content, e.g. an alert count `<Badge>`. */\n readonly badge?: ReactNode\n}\n\nfunction NavList({ nav, onNavigate }: { nav: readonly NavItem[]; onNavigate?: () => void }) {\n return (\n <>\n {nav.map((item) => (\n <NavLink\n key={item.to}\n to={item.to}\n end={item.end}\n onClick={onNavigate}\n className={({ isActive }) =>\n cn(\n 'flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent',\n isActive && 'bg-accent text-accent-foreground',\n )\n }\n >\n <item.icon className=\"h-4 w-4\" />\n {item.label}\n {item.badge != null && <span className=\"ml-auto\">{item.badge}</span>}\n </NavLink>\n ))}\n </>\n )\n}\n\nexport interface CmsLayoutProps {\n readonly children: ReactNode\n /** Sidebar navigation entries. */\n readonly nav: readonly NavItem[]\n /** Brand shown at the top of the sidebar (defaults to \"CMS\"). */\n readonly brand?: ReactNode\n /** Optional content for the top-right of the desktop header (e.g. a user menu). */\n readonly headerRight?: ReactNode\n}\n\n/**\n * Responsive CMS app shell: a desktop sidebar (md+) and a mobile hamburger sheet,\n * with an active-link-aware nav driven entirely by the `nav` prop. Bring your own\n * router (`react-router-dom`) and Tailwind theme.\n */\nexport function CmsLayout({ children, nav, brand = 'CMS', headerRight }: CmsLayoutProps) {\n const [open, setOpen] = useState(false)\n return (\n <div className=\"flex h-screen flex-col md:flex-row\">\n {/* Mobile top bar (hidden at md+) */}\n <header className=\"flex items-center gap-2 border-b p-3 md:hidden\">\n <Sheet open={open} onOpenChange={setOpen}>\n <SheetTrigger asChild>\n <Button variant=\"ghost\" size=\"icon\" aria-label=\"Open menu\" className=\"h-11 w-11\">\n <Menu className=\"h-5 w-5\" />\n </Button>\n </SheetTrigger>\n <SheetContent side=\"left\" className=\"p-0\">\n <div className=\"p-4 font-semibold text-lg\">{brand}</div>\n <Separator />\n <nav className=\"flex flex-col gap-1 p-2\">\n <NavList nav={nav} onNavigate={() => setOpen(false)} />\n </nav>\n </SheetContent>\n </Sheet>\n <span className=\"font-semibold\">{brand}</span>\n </header>\n\n {/* Desktop sidebar (hidden below md) */}\n <aside className=\"hidden md:flex w-56 border-r bg-muted/30 flex-col\">\n <div className=\"p-4 font-semibold text-lg\">{brand}</div>\n <Separator />\n <nav className=\"flex flex-1 flex-col gap-1 p-2\">\n <NavList nav={nav} />\n </nav>\n </aside>\n\n <main className=\"flex-1 overflow-auto\">\n {headerRight && (\n <div className=\"hidden md:flex items-center justify-end border-b p-3\">{headerRight}</div>\n )}\n <div className=\"p-4 md:p-6\">{children}</div>\n </main>\n </div>\n )\n}\n","import { useEffect } from 'react'\nimport type { ReactNode } from 'react'\nimport { useAuth } from '@liam-public/browser-react-auth'\n\nfunction DefaultFallback({ message }: { message: string }) {\n return (\n <div className=\"flex h-screen items-center justify-center\">\n <p className=\"text-sm text-muted-foreground\">{message}</p>\n </div>\n )\n}\n\nexport interface RequireAuthProps {\n readonly children: ReactNode\n /** Rendered while loading or when unauthenticated (defaults to a \"Redirecting…\" line). */\n readonly fallback?: ReactNode\n /**\n * Called once when the user is confirmed unauthenticated — e.g. trigger the OIDC\n * redirect from `@liam-workspace/auth-client`, or navigate to `/login`.\n */\n readonly onUnauthenticated?: () => void\n}\n\n/**\n * Gate that renders `children` only for an authenticated user. Wraps `useAuth()` from\n * `@liam-public/browser-react-auth`, filling the route-guard gap that package leaves open.\n */\nexport function RequireAuth({ children, fallback, onUnauthenticated }: RequireAuthProps) {\n const { isAuthenticated, isLoading } = useAuth()\n\n useEffect(() => {\n if (!isLoading && !isAuthenticated) onUnauthenticated?.()\n }, [isLoading, isAuthenticated, onUnauthenticated])\n\n if (isLoading) return <>{fallback ?? <DefaultFallback message=\"Loading…\" />}</>\n if (!isAuthenticated) return <>{fallback ?? <DefaultFallback message=\"Redirecting to sign in…\" />}</>\n return <>{children}</>\n}\n\nexport interface RequireRoleProps {\n readonly children: ReactNode\n /** The user is allowed if they hold ANY of these roles. */\n readonly anyOf: readonly string[]\n /** Extract the user's roles from the auth `user` object (shape is app-specific). */\n readonly getRoles: (user: unknown) => readonly string[]\n /** Rendered when the user lacks the required role(s). */\n readonly fallback?: ReactNode\n}\n\n/** Gate that additionally requires the authenticated user to hold one of `anyOf` roles. */\nexport function RequireRole({ children, anyOf, getRoles, fallback }: RequireRoleProps) {\n const { user, isLoading, isAuthenticated } = useAuth()\n if (isLoading) return <>{fallback ?? <DefaultFallback message=\"Loading…\" />}</>\n if (!isAuthenticated) return <>{fallback ?? <DefaultFallback message=\"Not signed in.\" />}</>\n const roles = getRoles(user)\n const allowed = anyOf.some((r) => roles.includes(r))\n if (!allowed) return <>{fallback ?? <DefaultFallback message=\"You don't have access to this page.\" />}</>\n return <>{children}</>\n}\n","import type { ReactNode } from 'react'\n\nexport interface PageHeaderProps {\n readonly title: string\n readonly action?: ReactNode\n}\n\n/** A page title row with an optional right-aligned action (e.g. a \"New\" button). */\nexport function PageHeader({ title, action }: PageHeaderProps) {\n return (\n <div className=\"flex items-center justify-between\">\n <h1 className=\"text-2xl font-bold\">{title}</h1>\n {action}\n </div>\n )\n}\n\nexport interface PageStackProps {\n readonly children: ReactNode\n readonly spacing?: 4 | 6\n}\n\n/** Vertical spacing container for page sections. */\nexport function PageStack({ children, spacing = 6 }: PageStackProps) {\n return <div className={spacing === 4 ? 'space-y-4' : 'space-y-6'}>{children}</div>\n}\n","import type { ReactNode } from 'react'\n\n/** Renders children only at md and up (desktop table view). */\nexport function DesktopOnly({ children }: { children: ReactNode }) {\n return <div className=\"hidden md:block\">{children}</div>\n}\n\n/** Renders children only below md (stacked-card view). */\nexport function MobileCards({ children }: { children: ReactNode }) {\n return <div className=\"flex flex-col gap-3 md:hidden\">{children}</div>\n}\n\n/** A single stacked card used inside MobileCards. */\nexport function MobileCard({ children }: { children: ReactNode }) {\n return <div className=\"rounded-lg border bg-card p-3 text-sm\">{children}</div>\n}\n\n/** A label/value line inside a MobileCard. */\nexport function MobileField({ label, children }: { label: string; children: ReactNode }) {\n return (\n <div className=\"flex items-start justify-between gap-3 py-0.5\">\n <span className=\"text-muted-foreground\">{label}</span>\n <span className=\"text-right font-medium break-words\">{children}</span>\n </div>\n )\n}\n","import { Loader2 } from 'lucide-react'\nimport {\n Button,\n Dialog,\n DialogContent,\n DialogDescription,\n DialogFooter,\n DialogHeader,\n DialogTitle,\n} from '@liam-public/browser-react-ui'\n\nexport interface LoadingSpinnerProps {\n readonly size?: 'md' | 'lg'\n}\n\n/** Centered spinner for loading states. */\nexport function LoadingSpinner({ size = 'md' }: LoadingSpinnerProps) {\n const padding = size === 'lg' ? 'py-16' : 'py-8'\n const icon = size === 'lg' ? 'h-8 w-8' : 'h-6 w-6'\n return (\n <div className={`flex items-center justify-center ${padding}`}>\n <Loader2 className={`${icon} animate-spin text-muted-foreground`} />\n </div>\n )\n}\n\nexport interface ConfirmDialogProps {\n readonly open: boolean\n readonly onOpenChange: (open: boolean) => void\n readonly title: string\n readonly description: string\n readonly onConfirm: () => void\n readonly loading?: boolean\n readonly error?: string | null\n /** Confirm-button label (defaults to \"Confirm\"). */\n readonly confirmLabel?: string\n /** Set true for destructive actions (red confirm button). */\n readonly destructive?: boolean\n}\n\n/** A modal confirm dialog with loading + error states. */\nexport function ConfirmDialog({\n open,\n onOpenChange,\n title,\n description,\n onConfirm,\n loading,\n error,\n confirmLabel = 'Confirm',\n destructive,\n}: ConfirmDialogProps) {\n return (\n <Dialog open={open} onOpenChange={onOpenChange}>\n <DialogContent>\n <DialogHeader>\n <DialogTitle>{title}</DialogTitle>\n <DialogDescription>{description}</DialogDescription>\n </DialogHeader>\n {error && <p className=\"text-sm text-destructive\">{error}</p>}\n <DialogFooter>\n <Button variant=\"outline\" onClick={() => onOpenChange(false)} disabled={loading}>\n Cancel\n </Button>\n <Button\n variant={destructive ? 'destructive' : 'default'}\n onClick={onConfirm}\n disabled={loading}\n >\n {loading && <Loader2 className=\"h-4 w-4 animate-spin\" />}\n {confirmLabel}\n </Button>\n </DialogFooter>\n </DialogContent>\n </Dialog>\n )\n}\n"],"mappings":"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","names":["Fragment","jsx","jsx","jsxs","jsx","jsxs","Button","jsx","jsxs"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@liam-public/browser-react-cms",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Reusable CMS/admin chrome: responsive app-shell, auth route-guards, and page primitives, on Tailwind v4 + Radix.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"liamCompatibility": {
|
|
7
|
+
"runtime": [
|
|
8
|
+
"browser"
|
|
9
|
+
],
|
|
10
|
+
"framework": [
|
|
11
|
+
"react"
|
|
12
|
+
]
|
|
13
|
+
},
|
|
14
|
+
"main": "./dist/index.js",
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"import": "./dist/index.js"
|
|
20
|
+
},
|
|
21
|
+
"./theme.css": "./theme.css"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist",
|
|
25
|
+
"theme.css",
|
|
26
|
+
"README.md"
|
|
27
|
+
],
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public",
|
|
30
|
+
"registry": "https://registry.npmjs.org/"
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"react": "^19.0.0",
|
|
34
|
+
"react-dom": "^19.0.0",
|
|
35
|
+
"react-router-dom": "^7.0.0"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"lucide-react": "^0.563.0",
|
|
39
|
+
"@liam-public/browser-react-auth": "0.1.0",
|
|
40
|
+
"@liam-public/browser-react-ui": "0.1.0"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@testing-library/react": "^16.3.0",
|
|
44
|
+
"@types/react": "^19.2.2",
|
|
45
|
+
"@types/react-dom": "^19.2.2",
|
|
46
|
+
"jsdom": "^27.0.1",
|
|
47
|
+
"react": "^19.2.0",
|
|
48
|
+
"react-dom": "^19.2.0",
|
|
49
|
+
"react-router-dom": "^7.0.0"
|
|
50
|
+
},
|
|
51
|
+
"scripts": {
|
|
52
|
+
"build": "tsup src/index.ts --format esm --dts --sourcemap",
|
|
53
|
+
"clean": "rm -rf dist",
|
|
54
|
+
"test": "vitest run --environment jsdom",
|
|
55
|
+
"typecheck": "tsc --noEmit"
|
|
56
|
+
}
|
|
57
|
+
}
|
package/theme.css
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
@import 'tailwindcss';
|
|
2
|
+
|
|
3
|
+
@theme inline {
|
|
4
|
+
--color-background: oklch(1 0 0);
|
|
5
|
+
--color-foreground: oklch(0.145 0 0);
|
|
6
|
+
--color-card: oklch(1 0 0);
|
|
7
|
+
--color-card-foreground: oklch(0.145 0 0);
|
|
8
|
+
--color-popover: oklch(1 0 0);
|
|
9
|
+
--color-popover-foreground: oklch(0.145 0 0);
|
|
10
|
+
--color-primary: oklch(0.205 0 0);
|
|
11
|
+
--color-primary-foreground: oklch(0.985 0 0);
|
|
12
|
+
--color-secondary: oklch(0.97 0 0);
|
|
13
|
+
--color-secondary-foreground: oklch(0.205 0 0);
|
|
14
|
+
--color-muted: oklch(0.97 0 0);
|
|
15
|
+
--color-muted-foreground: oklch(0.556 0 0);
|
|
16
|
+
--color-accent: oklch(0.97 0 0);
|
|
17
|
+
--color-accent-foreground: oklch(0.205 0 0);
|
|
18
|
+
--color-destructive: oklch(0.577 0.245 27.325);
|
|
19
|
+
--color-destructive-foreground: oklch(0.577 0.245 27.325);
|
|
20
|
+
--color-border: oklch(0.922 0 0);
|
|
21
|
+
--color-input: oklch(0.922 0 0);
|
|
22
|
+
--color-ring: oklch(0.708 0 0);
|
|
23
|
+
--radius: 0.625rem;
|
|
24
|
+
--color-sidebar: oklch(0.985 0 0);
|
|
25
|
+
--color-sidebar-foreground: oklch(0.145 0 0);
|
|
26
|
+
--color-sidebar-primary: oklch(0.205 0 0);
|
|
27
|
+
--color-sidebar-primary-foreground: oklch(0.985 0 0);
|
|
28
|
+
--color-sidebar-accent: oklch(0.97 0 0);
|
|
29
|
+
--color-sidebar-accent-foreground: oklch(0.205 0 0);
|
|
30
|
+
--color-sidebar-border: oklch(0.922 0 0);
|
|
31
|
+
--color-sidebar-ring: oklch(0.708 0 0);
|
|
32
|
+
}
|