convex-cms 0.0.9-alpha.8 → 0.0.9-alpha.9
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 +27 -0
- package/admin/src/components/cmsds/CmsFilterBar.tsx +74 -0
- package/admin/src/components/cmsds/CmsInput.tsx +24 -0
- package/admin/src/components/cmsds/CmsPagination.tsx +79 -0
- package/admin/src/components/cmsds/CmsSelect.tsx +59 -0
- package/admin/src/components/cmsds/CmsStatCard.tsx +79 -0
- package/admin/src/components/cmsds/CmsStatusBadge.tsx +1 -1
- package/admin/src/components/cmsds/index.ts +5 -0
- package/admin/src/contexts/ThemeContext.tsx +85 -17
- package/admin/src/embed/components/EmbedHeader.tsx +11 -9
- package/admin/src/embed/components/EmbedLayout.tsx +2 -6
- package/admin/src/embed/components/EmbedSidebar.tsx +7 -4
- package/admin/src/embed/index.tsx +3 -2
- package/admin/src/embed/types.ts +6 -0
- package/admin/src/pages/ContentPage.tsx +116 -172
- package/admin/src/pages/ContentTypeEntriesPage.tsx +120 -194
- package/admin/src/pages/ContentTypesPage.tsx +136 -139
- package/admin/src/pages/DashboardPage.tsx +13 -52
- package/admin/src/pages/MediaPage.tsx +31 -57
- package/admin/src/pages/SettingsPage.tsx +5 -1
- package/admin/src/pages/TrashPage.tsx +115 -170
- package/admin/src/styles/globals.css +10 -32
- package/admin/src/styles/tailwind-config.css +12 -0
- package/admin/src/styles/theme.css +229 -106
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -120,6 +120,33 @@ Leverage included features or extend and customize within your own convex functi
|
|
|
120
120
|
|
|
121
121
|
Both modes call the same functions from your `convex/admin.ts`.
|
|
122
122
|
|
|
123
|
+
### Embedding with Theme Modes
|
|
124
|
+
|
|
125
|
+
When embedding CmsAdmin in your React app, you can control how it handles CSS variables:
|
|
126
|
+
|
|
127
|
+
```tsx
|
|
128
|
+
// Isolated mode (default) - admin uses its own theme
|
|
129
|
+
<CmsAdmin api={api.admin} auth={authConfig} themeMode="isolated" />
|
|
130
|
+
|
|
131
|
+
// Inherit mode - admin inherits your app's CSS variables (for shadcn apps)
|
|
132
|
+
<CmsAdmin api={api.admin} auth={authConfig} themeMode="inherit" />
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
| Mode | Behavior |
|
|
136
|
+
|------|----------|
|
|
137
|
+
| `isolated` | Admin defines all CSS variables, ignoring parent app styles |
|
|
138
|
+
| `inherit` | Admin inherits parent's shadcn variables, only defines sidebar fallbacks |
|
|
139
|
+
|
|
140
|
+
**Critical for Tailwind 4 apps:** If Tailwind utility classes aren't applying to the embedded admin, add a `@source` directive to your app's CSS:
|
|
141
|
+
|
|
142
|
+
```css
|
|
143
|
+
/* your-app/src/index.css */
|
|
144
|
+
@import "tailwindcss";
|
|
145
|
+
@source "../node_modules/convex-cms/admin/dist/**/*.js";
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
This tells Tailwind to scan the admin's compiled JavaScript for utility classes.
|
|
149
|
+
|
|
123
150
|
## Documentation
|
|
124
151
|
|
|
125
152
|
| Guide | Description |
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { Search, X } from 'lucide-react'
|
|
3
|
+
import { CmsInput } from './CmsInput'
|
|
4
|
+
import { CmsSelect, type CmsSelectOption } from './CmsSelect'
|
|
5
|
+
import { CmsButton } from './CmsButton'
|
|
6
|
+
import { cn } from '~/lib/cn'
|
|
7
|
+
|
|
8
|
+
export interface CmsFilterBarFilter {
|
|
9
|
+
key: string
|
|
10
|
+
value: string
|
|
11
|
+
onChange: (value: string) => void
|
|
12
|
+
options: CmsSelectOption[]
|
|
13
|
+
placeholder?: string
|
|
14
|
+
className?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface CmsFilterBarProps {
|
|
18
|
+
search?: {
|
|
19
|
+
value: string
|
|
20
|
+
onChange: (value: string) => void
|
|
21
|
+
placeholder?: string
|
|
22
|
+
className?: string
|
|
23
|
+
}
|
|
24
|
+
filters?: CmsFilterBarFilter[]
|
|
25
|
+
actions?: React.ReactNode
|
|
26
|
+
onClearFilters?: () => void
|
|
27
|
+
hasActiveFilters?: boolean
|
|
28
|
+
className?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function CmsFilterBar({
|
|
32
|
+
search,
|
|
33
|
+
filters,
|
|
34
|
+
actions,
|
|
35
|
+
onClearFilters,
|
|
36
|
+
hasActiveFilters,
|
|
37
|
+
className,
|
|
38
|
+
}: CmsFilterBarProps) {
|
|
39
|
+
return (
|
|
40
|
+
<div className={cn("flex flex-wrap items-center gap-3 pb-4", className)}>
|
|
41
|
+
<div className="flex flex-1 flex-wrap items-center gap-2">
|
|
42
|
+
{search && (
|
|
43
|
+
<div className="relative w-full max-w-xs">
|
|
44
|
+
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
|
45
|
+
<CmsInput
|
|
46
|
+
type="search"
|
|
47
|
+
placeholder={search.placeholder ?? "Search..."}
|
|
48
|
+
value={search.value}
|
|
49
|
+
onChange={(e) => search.onChange(e.target.value)}
|
|
50
|
+
className={cn("pl-9", search.className)}
|
|
51
|
+
/>
|
|
52
|
+
</div>
|
|
53
|
+
)}
|
|
54
|
+
{filters?.map((filter) => (
|
|
55
|
+
<CmsSelect
|
|
56
|
+
key={filter.key}
|
|
57
|
+
value={filter.value}
|
|
58
|
+
onValueChange={filter.onChange}
|
|
59
|
+
options={filter.options}
|
|
60
|
+
placeholder={filter.placeholder}
|
|
61
|
+
className={cn("w-[150px]", filter.className)}
|
|
62
|
+
/>
|
|
63
|
+
))}
|
|
64
|
+
{hasActiveFilters && onClearFilters && (
|
|
65
|
+
<CmsButton variant="ghost" size="sm" onClick={onClearFilters}>
|
|
66
|
+
<X className="mr-1 size-4" />
|
|
67
|
+
Clear
|
|
68
|
+
</CmsButton>
|
|
69
|
+
)}
|
|
70
|
+
</div>
|
|
71
|
+
{actions && <div className="flex items-center gap-2">{actions}</div>}
|
|
72
|
+
</div>
|
|
73
|
+
)
|
|
74
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { Input } from '~/components/ui/input'
|
|
3
|
+
import { cn } from '~/lib/cn'
|
|
4
|
+
|
|
5
|
+
export interface CmsInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
|
6
|
+
error?: boolean
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const CmsInput = React.forwardRef<HTMLInputElement, CmsInputProps>(
|
|
10
|
+
({ className, error, ...props }, ref) => {
|
|
11
|
+
return (
|
|
12
|
+
<Input
|
|
13
|
+
ref={ref}
|
|
14
|
+
className={cn(
|
|
15
|
+
error && "border-destructive focus-visible:ring-destructive",
|
|
16
|
+
className
|
|
17
|
+
)}
|
|
18
|
+
aria-invalid={error}
|
|
19
|
+
{...props}
|
|
20
|
+
/>
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
)
|
|
24
|
+
CmsInput.displayName = 'CmsInput'
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ChevronLeft,
|
|
3
|
+
ChevronRight,
|
|
4
|
+
ChevronsLeft,
|
|
5
|
+
ChevronsRight,
|
|
6
|
+
} from "lucide-react";
|
|
7
|
+
import { CmsButton } from "./CmsButton";
|
|
8
|
+
import { cn } from "~/lib/cn";
|
|
9
|
+
|
|
10
|
+
export interface CmsPaginationProps {
|
|
11
|
+
currentPage: number;
|
|
12
|
+
totalPages: number;
|
|
13
|
+
onPageChange: (page: number) => void;
|
|
14
|
+
showFirstLast?: boolean;
|
|
15
|
+
className?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function CmsPagination({
|
|
19
|
+
currentPage,
|
|
20
|
+
totalPages,
|
|
21
|
+
onPageChange,
|
|
22
|
+
showFirstLast = true,
|
|
23
|
+
className,
|
|
24
|
+
}: CmsPaginationProps) {
|
|
25
|
+
const canGoPrev = currentPage > 1;
|
|
26
|
+
const canGoNext = currentPage < totalPages;
|
|
27
|
+
|
|
28
|
+
if (totalPages <= 1) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<div className={cn("flex items-center justify-center gap-1", className)}>
|
|
34
|
+
{showFirstLast && (
|
|
35
|
+
<CmsButton
|
|
36
|
+
variant="ghost"
|
|
37
|
+
size="sm"
|
|
38
|
+
onClick={() => onPageChange(1)}
|
|
39
|
+
disabled={!canGoPrev}
|
|
40
|
+
aria-label="First page"
|
|
41
|
+
>
|
|
42
|
+
<ChevronsLeft className="size-4" />
|
|
43
|
+
</CmsButton>
|
|
44
|
+
)}
|
|
45
|
+
<CmsButton
|
|
46
|
+
variant="ghost"
|
|
47
|
+
size="sm"
|
|
48
|
+
onClick={() => onPageChange(currentPage - 1)}
|
|
49
|
+
disabled={!canGoPrev}
|
|
50
|
+
aria-label="Previous page"
|
|
51
|
+
>
|
|
52
|
+
<ChevronLeft className="size-4" />
|
|
53
|
+
</CmsButton>
|
|
54
|
+
<span className="px-3 text-sm text-muted-foreground">
|
|
55
|
+
Page {currentPage} of {totalPages}
|
|
56
|
+
</span>
|
|
57
|
+
<CmsButton
|
|
58
|
+
variant="ghost"
|
|
59
|
+
size="sm"
|
|
60
|
+
onClick={() => onPageChange(currentPage + 1)}
|
|
61
|
+
disabled={!canGoNext}
|
|
62
|
+
aria-label="Next page"
|
|
63
|
+
>
|
|
64
|
+
<ChevronRight className="size-4" />
|
|
65
|
+
</CmsButton>
|
|
66
|
+
{showFirstLast && (
|
|
67
|
+
<CmsButton
|
|
68
|
+
variant="ghost"
|
|
69
|
+
size="sm"
|
|
70
|
+
onClick={() => onPageChange(totalPages)}
|
|
71
|
+
disabled={!canGoNext}
|
|
72
|
+
aria-label="Last page"
|
|
73
|
+
>
|
|
74
|
+
<ChevronsRight className="size-4" />
|
|
75
|
+
</CmsButton>
|
|
76
|
+
)}
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Select,
|
|
3
|
+
SelectContent,
|
|
4
|
+
SelectItem,
|
|
5
|
+
SelectTrigger,
|
|
6
|
+
SelectValue,
|
|
7
|
+
} from "~/components/ui/select";
|
|
8
|
+
import { cn } from "~/lib/cn";
|
|
9
|
+
|
|
10
|
+
export interface CmsSelectOption {
|
|
11
|
+
value: string;
|
|
12
|
+
label: string;
|
|
13
|
+
disabled?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface CmsSelectProps {
|
|
17
|
+
value?: string;
|
|
18
|
+
onValueChange?: (value: string) => void;
|
|
19
|
+
options: CmsSelectOption[];
|
|
20
|
+
placeholder?: string;
|
|
21
|
+
disabled?: boolean;
|
|
22
|
+
error?: boolean;
|
|
23
|
+
className?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function CmsSelect({
|
|
27
|
+
value,
|
|
28
|
+
onValueChange,
|
|
29
|
+
options,
|
|
30
|
+
placeholder = "Select...",
|
|
31
|
+
disabled,
|
|
32
|
+
error,
|
|
33
|
+
className,
|
|
34
|
+
}: CmsSelectProps) {
|
|
35
|
+
return (
|
|
36
|
+
<Select value={value} onValueChange={onValueChange} disabled={disabled}>
|
|
37
|
+
<SelectTrigger
|
|
38
|
+
className={cn(
|
|
39
|
+
error && "border-destructive focus:ring-destructive",
|
|
40
|
+
className,
|
|
41
|
+
)}
|
|
42
|
+
aria-invalid={error}
|
|
43
|
+
>
|
|
44
|
+
<SelectValue placeholder={placeholder} />
|
|
45
|
+
</SelectTrigger>
|
|
46
|
+
<SelectContent>
|
|
47
|
+
{options.map((option) => (
|
|
48
|
+
<SelectItem
|
|
49
|
+
key={option.value}
|
|
50
|
+
value={option.value}
|
|
51
|
+
disabled={option.disabled}
|
|
52
|
+
>
|
|
53
|
+
{option.label}
|
|
54
|
+
</SelectItem>
|
|
55
|
+
))}
|
|
56
|
+
</SelectContent>
|
|
57
|
+
</Select>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { CmsSurface } from './CmsSurface'
|
|
3
|
+
import { cn } from '~/lib/cn'
|
|
4
|
+
|
|
5
|
+
export interface CmsStatCardProps {
|
|
6
|
+
title: string
|
|
7
|
+
value: string | number
|
|
8
|
+
description?: string
|
|
9
|
+
icon?: React.ReactNode
|
|
10
|
+
trend?: { value: number; label: string }
|
|
11
|
+
onClick?: () => void
|
|
12
|
+
isLoading?: boolean
|
|
13
|
+
className?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function CmsStatCard({
|
|
17
|
+
title,
|
|
18
|
+
value,
|
|
19
|
+
description,
|
|
20
|
+
icon,
|
|
21
|
+
trend,
|
|
22
|
+
onClick,
|
|
23
|
+
isLoading,
|
|
24
|
+
className,
|
|
25
|
+
}: CmsStatCardProps) {
|
|
26
|
+
const content = (
|
|
27
|
+
<>
|
|
28
|
+
<div className="flex items-start justify-between">
|
|
29
|
+
<div className="space-y-1">
|
|
30
|
+
<p className="text-sm font-medium text-muted-foreground">{title}</p>
|
|
31
|
+
{isLoading ? (
|
|
32
|
+
<div className="h-8 w-16 animate-pulse rounded bg-muted" />
|
|
33
|
+
) : (
|
|
34
|
+
<p className="text-2xl font-semibold text-foreground">{value}</p>
|
|
35
|
+
)}
|
|
36
|
+
{description && (
|
|
37
|
+
<p className="text-xs text-muted-foreground">{description}</p>
|
|
38
|
+
)}
|
|
39
|
+
</div>
|
|
40
|
+
{icon && (
|
|
41
|
+
<div className="flex size-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
|
42
|
+
{icon}
|
|
43
|
+
</div>
|
|
44
|
+
)}
|
|
45
|
+
</div>
|
|
46
|
+
{trend && !isLoading && (
|
|
47
|
+
<div className={cn(
|
|
48
|
+
"mt-2 text-xs font-medium",
|
|
49
|
+
trend.value >= 0 ? "text-diff-added-foreground" : "text-diff-removed-foreground"
|
|
50
|
+
)}>
|
|
51
|
+
{trend.value >= 0 ? "+" : ""}{trend.value}% {trend.label}
|
|
52
|
+
</div>
|
|
53
|
+
)}
|
|
54
|
+
</>
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
if (onClick) {
|
|
58
|
+
return (
|
|
59
|
+
<button
|
|
60
|
+
type="button"
|
|
61
|
+
onClick={onClick}
|
|
62
|
+
className={cn(
|
|
63
|
+
"text-left w-full cursor-pointer transition-colors hover:bg-accent/50",
|
|
64
|
+
className
|
|
65
|
+
)}
|
|
66
|
+
>
|
|
67
|
+
<CmsSurface elevation="base" padding="md" className="h-full">
|
|
68
|
+
{content}
|
|
69
|
+
</CmsSurface>
|
|
70
|
+
</button>
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<CmsSurface elevation="base" padding="md" className={cn("h-full", className)}>
|
|
76
|
+
{content}
|
|
77
|
+
</CmsSurface>
|
|
78
|
+
)
|
|
79
|
+
}
|
|
@@ -96,7 +96,7 @@ const colorToClassName: Record<WorkflowStateColor, string> = {
|
|
|
96
96
|
blue: "bg-info-bg text-info-foreground",
|
|
97
97
|
green: "bg-diff-added-bg text-diff-added-foreground",
|
|
98
98
|
red: "bg-diff-removed-bg text-diff-removed-foreground",
|
|
99
|
-
purple: "bg-purple-
|
|
99
|
+
purple: "bg-purple-bg text-purple-foreground",
|
|
100
100
|
orange: "bg-diff-modified-bg text-diff-modified-foreground",
|
|
101
101
|
};
|
|
102
102
|
|
|
@@ -8,3 +8,8 @@ export { CmsToolbar, type CmsToolbarProps } from './CmsToolbar'
|
|
|
8
8
|
export { CmsDropdown, type CmsDropdownProps, type CmsDropdownAction } from './CmsDropdown'
|
|
9
9
|
export { CmsTable, type CmsTableProps, type CmsTableColumn } from './CmsTable'
|
|
10
10
|
export { CmsDialog, CmsConfirmDialog, type CmsDialogProps, type CmsConfirmDialogProps } from './CmsDialog'
|
|
11
|
+
export { CmsInput, type CmsInputProps } from './CmsInput'
|
|
12
|
+
export { CmsSelect, type CmsSelectProps, type CmsSelectOption } from './CmsSelect'
|
|
13
|
+
export { CmsFilterBar, type CmsFilterBarProps, type CmsFilterBarFilter } from './CmsFilterBar'
|
|
14
|
+
export { CmsStatCard, type CmsStatCardProps } from './CmsStatCard'
|
|
15
|
+
export { CmsPagination, type CmsPaginationProps } from './CmsPagination'
|
|
@@ -13,6 +13,7 @@ interface ThemeContextValue {
|
|
|
13
13
|
theme: Theme
|
|
14
14
|
resolvedTheme: 'light' | 'dark'
|
|
15
15
|
setTheme: (theme: Theme) => void
|
|
16
|
+
canToggleDarkMode: boolean
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
const ThemeContext = createContext<ThemeContextValue | null>(null)
|
|
@@ -33,9 +34,36 @@ function getStoredTheme(): Theme {
|
|
|
33
34
|
return 'system'
|
|
34
35
|
}
|
|
35
36
|
|
|
36
|
-
|
|
37
|
-
|
|
37
|
+
function getParentDarkMode(): boolean {
|
|
38
|
+
if (typeof window === 'undefined') return false
|
|
39
|
+
return document.documentElement.classList.contains('dark')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ThemeProviderProps {
|
|
43
|
+
children: ReactNode
|
|
44
|
+
themeMode?: 'isolated' | 'inherit'
|
|
45
|
+
darkModeControl?: 'independent' | 'follow-parent'
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function ThemeProvider({
|
|
49
|
+
children,
|
|
50
|
+
themeMode = 'isolated',
|
|
51
|
+
darkModeControl = 'independent',
|
|
52
|
+
}: ThemeProviderProps) {
|
|
53
|
+
const canToggleDarkMode = themeMode === 'isolated' || darkModeControl === 'independent'
|
|
54
|
+
const shouldFollowParent = themeMode === 'inherit' && darkModeControl === 'follow-parent'
|
|
55
|
+
|
|
56
|
+
const [theme, setThemeState] = useState<Theme>(() => {
|
|
57
|
+
if (shouldFollowParent) {
|
|
58
|
+
return getParentDarkMode() ? 'dark' : 'light'
|
|
59
|
+
}
|
|
60
|
+
return getStoredTheme()
|
|
61
|
+
})
|
|
62
|
+
|
|
38
63
|
const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>(() => {
|
|
64
|
+
if (shouldFollowParent) {
|
|
65
|
+
return getParentDarkMode() ? 'dark' : 'light'
|
|
66
|
+
}
|
|
39
67
|
const stored = getStoredTheme()
|
|
40
68
|
return stored === 'system' ? getSystemTheme() : stored
|
|
41
69
|
})
|
|
@@ -44,39 +72,79 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
|
|
44
72
|
const resolved = newTheme === 'system' ? getSystemTheme() : newTheme
|
|
45
73
|
setResolvedTheme(resolved)
|
|
46
74
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
75
|
+
if (canToggleDarkMode) {
|
|
76
|
+
const root = document.documentElement
|
|
77
|
+
root.classList.remove('light', 'dark')
|
|
78
|
+
root.classList.add(resolved)
|
|
79
|
+
}
|
|
80
|
+
}, [canToggleDarkMode])
|
|
51
81
|
|
|
52
82
|
const setTheme = useCallback(
|
|
53
83
|
(newTheme: Theme) => {
|
|
84
|
+
if (!canToggleDarkMode) return
|
|
85
|
+
|
|
54
86
|
setThemeState(newTheme)
|
|
55
87
|
localStorage.setItem(STORAGE_KEY, newTheme)
|
|
56
88
|
applyTheme(newTheme)
|
|
57
89
|
},
|
|
58
|
-
[applyTheme]
|
|
90
|
+
[applyTheme, canToggleDarkMode]
|
|
59
91
|
)
|
|
60
92
|
|
|
61
93
|
useEffect(() => {
|
|
62
|
-
|
|
63
|
-
|
|
94
|
+
if (!shouldFollowParent) {
|
|
95
|
+
applyTheme(theme)
|
|
96
|
+
}
|
|
97
|
+
}, [theme, applyTheme, shouldFollowParent])
|
|
64
98
|
|
|
65
99
|
useEffect(() => {
|
|
66
|
-
|
|
100
|
+
if (!shouldFollowParent) {
|
|
101
|
+
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
|
67
102
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
103
|
+
const handleChange = () => {
|
|
104
|
+
if (theme === 'system') {
|
|
105
|
+
applyTheme('system')
|
|
106
|
+
}
|
|
71
107
|
}
|
|
108
|
+
|
|
109
|
+
mediaQuery.addEventListener('change', handleChange)
|
|
110
|
+
return () => mediaQuery.removeEventListener('change', handleChange)
|
|
111
|
+
}
|
|
112
|
+
}, [theme, applyTheme, shouldFollowParent])
|
|
113
|
+
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
if (!shouldFollowParent) return
|
|
116
|
+
|
|
117
|
+
const handleParentThemeChange = () => {
|
|
118
|
+
const isDark = getParentDarkMode()
|
|
119
|
+
const newTheme = isDark ? 'dark' : 'light'
|
|
120
|
+
setThemeState(newTheme)
|
|
121
|
+
setResolvedTheme(newTheme)
|
|
72
122
|
}
|
|
73
123
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
124
|
+
handleParentThemeChange()
|
|
125
|
+
|
|
126
|
+
const observer = new MutationObserver((mutations) => {
|
|
127
|
+
for (const mutation of mutations) {
|
|
128
|
+
if (
|
|
129
|
+
mutation.type === 'attributes' &&
|
|
130
|
+
mutation.attributeName === 'class'
|
|
131
|
+
) {
|
|
132
|
+
handleParentThemeChange()
|
|
133
|
+
break
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
observer.observe(document.documentElement, {
|
|
139
|
+
attributes: true,
|
|
140
|
+
attributeFilter: ['class'],
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
return () => observer.disconnect()
|
|
144
|
+
}, [shouldFollowParent])
|
|
77
145
|
|
|
78
146
|
return (
|
|
79
|
-
<ThemeContext.Provider value={{ theme, resolvedTheme, setTheme }}>
|
|
147
|
+
<ThemeContext.Provider value={{ theme, resolvedTheme, setTheme, canToggleDarkMode }}>
|
|
80
148
|
{children}
|
|
81
149
|
</ThemeContext.Provider>
|
|
82
150
|
)
|
|
@@ -11,7 +11,7 @@ import { useEmbedNavigation } from "../navigation";
|
|
|
11
11
|
export function EmbedHeader() {
|
|
12
12
|
const { goBack, canGoBack, currentRoute } = useEmbedNavigation();
|
|
13
13
|
const { branding: _branding } = useAdminConfig();
|
|
14
|
-
const { theme, setTheme } = useTheme();
|
|
14
|
+
const { theme, setTheme, canToggleDarkMode } = useTheme();
|
|
15
15
|
const { user, logout, isAuthenticated } = useAuth();
|
|
16
16
|
|
|
17
17
|
const routeTitles: Record<string, string> = {
|
|
@@ -44,14 +44,16 @@ export function EmbedHeader() {
|
|
|
44
44
|
</div>
|
|
45
45
|
|
|
46
46
|
<div className="flex items-center gap-2">
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
47
|
+
{canToggleDarkMode && (
|
|
48
|
+
<button
|
|
49
|
+
type="button"
|
|
50
|
+
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
|
51
|
+
className="flex size-9 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
52
|
+
title={`Switch to ${theme === "dark" ? "light" : "dark"} mode`}
|
|
53
|
+
>
|
|
54
|
+
{theme === "dark" ? <Sun className="size-5" /> : <Moon className="size-5" />}
|
|
55
|
+
</button>
|
|
56
|
+
)}
|
|
55
57
|
|
|
56
58
|
<button
|
|
57
59
|
type="button"
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
*
|
|
4
4
|
* A router-agnostic layout for the embedded admin that uses
|
|
5
5
|
* EmbedSidebar instead of the router-dependent Sidebar.
|
|
6
|
-
* Uses CSS Grid for proper container-relative positioning.
|
|
7
6
|
*/
|
|
8
7
|
|
|
9
8
|
import type { ReactNode } from "react";
|
|
@@ -19,12 +18,9 @@ export function EmbedLayout({ children }: EmbedLayoutProps) {
|
|
|
19
18
|
const { layout } = useAdminConfig();
|
|
20
19
|
|
|
21
20
|
return (
|
|
22
|
-
<div
|
|
23
|
-
className="grid h-full bg-background"
|
|
24
|
-
style={{ gridTemplateColumns: `${layout.sidebarWidth}px 1fr` }}
|
|
25
|
-
>
|
|
21
|
+
<div className="flex min-h-screen bg-background">
|
|
26
22
|
<EmbedSidebar />
|
|
27
|
-
<div className="flex flex-
|
|
23
|
+
<div className="flex flex-1 flex-col" style={{ marginLeft: layout.sidebarWidth }}>
|
|
28
24
|
<EmbedHeader />
|
|
29
25
|
<main className="flex-1 overflow-auto p-6">{children}</main>
|
|
30
26
|
</div>
|
|
@@ -36,9 +36,7 @@ function pathToRoute(path: string): EmbedRoute {
|
|
|
36
36
|
export function EmbedSidebar() {
|
|
37
37
|
const { currentPath, navigate, navigateToContentType } = useEmbedNavigation();
|
|
38
38
|
const config = useAdminConfig();
|
|
39
|
-
const { navItems, branding,
|
|
40
|
-
// layout
|
|
41
|
-
} = config;
|
|
39
|
+
const { navItems, branding, layout } = config;
|
|
42
40
|
const api = useApi();
|
|
43
41
|
|
|
44
42
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
|
@@ -154,9 +152,14 @@ export function EmbedSidebar() {
|
|
|
154
152
|
</Collapsible>
|
|
155
153
|
);
|
|
156
154
|
|
|
155
|
+
const sidebarWidth = layout.sidebarWidth;
|
|
156
|
+
|
|
157
157
|
return (
|
|
158
158
|
<>
|
|
159
|
-
<aside
|
|
159
|
+
<aside
|
|
160
|
+
className="fixed inset-y-0 left-0 z-50 flex flex-col border-r border-sidebar-border bg-sidebar"
|
|
161
|
+
style={{ width: sidebarWidth }}
|
|
162
|
+
>
|
|
160
163
|
<div className="flex h-14 items-center gap-2 border-b border-sidebar-border px-4">
|
|
161
164
|
<button
|
|
162
165
|
type="button"
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
|
|
37
37
|
import { useConvex } from "convex/react";
|
|
38
38
|
import { useMemo } from "react";
|
|
39
|
+
import { cn } from "../lib/cn";
|
|
39
40
|
import { SettingsConfigProvider } from "../contexts/SettingsConfigContext";
|
|
40
41
|
import {
|
|
41
42
|
AuthProvider,
|
|
@@ -46,7 +47,6 @@ import {
|
|
|
46
47
|
import { ThemeProvider } from "../contexts/ThemeContext";
|
|
47
48
|
import { RouteGuard } from "../components/RouteGuard";
|
|
48
49
|
import { resolveAdminConfig } from "../lib/admin-config";
|
|
49
|
-
import { cn } from "../lib/cn";
|
|
50
50
|
import type { CmsAdminProps, CmsAdminAuthConfig } from "./types";
|
|
51
51
|
import { ApiProvider } from "./contexts/ApiContext";
|
|
52
52
|
import {
|
|
@@ -129,6 +129,7 @@ export function CmsAdmin({
|
|
|
129
129
|
basePath = "/admin",
|
|
130
130
|
className,
|
|
131
131
|
themeMode = "isolated",
|
|
132
|
+
darkModeControl = "independent",
|
|
132
133
|
initialRoute = "dashboard",
|
|
133
134
|
onNavigate,
|
|
134
135
|
}: CmsAdminProps & {
|
|
@@ -165,7 +166,7 @@ export function CmsAdmin({
|
|
|
165
166
|
return (
|
|
166
167
|
<div className={cn("h-full", className)} data-cms-admin={themeMode}>
|
|
167
168
|
<ApiProvider api={api}>
|
|
168
|
-
<ThemeProvider>
|
|
169
|
+
<ThemeProvider themeMode={themeMode} darkModeControl={darkModeControl}>
|
|
169
170
|
<SettingsConfigProvider baseConfig={adminConfig} api={settingsApi}>
|
|
170
171
|
<AuthProvider
|
|
171
172
|
getUser={authConfig.getUser}
|
package/admin/src/embed/types.ts
CHANGED
|
@@ -26,4 +26,10 @@ export interface CmsAdminProps {
|
|
|
26
26
|
* - 'inherit': Admin inherits parent app's CSS variables (for shadcn apps)
|
|
27
27
|
*/
|
|
28
28
|
themeMode?: "isolated" | "inherit";
|
|
29
|
+
/**
|
|
30
|
+
* Dark mode control behavior:
|
|
31
|
+
* - 'independent' (default): Admin has its own dark mode toggle
|
|
32
|
+
* - 'follow-parent': Admin follows parent app's dark mode (hides toggle in inherit mode)
|
|
33
|
+
*/
|
|
34
|
+
darkModeControl?: "independent" | "follow-parent";
|
|
29
35
|
}
|