convex-cms 0.0.9-alpha.7 → 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.
Files changed (87) hide show
  1. package/README.md +27 -0
  2. package/admin/src/components/cmsds/CmsFilterBar.tsx +74 -0
  3. package/admin/src/components/cmsds/CmsInput.tsx +24 -0
  4. package/admin/src/components/cmsds/CmsPagination.tsx +79 -0
  5. package/admin/src/components/cmsds/CmsSelect.tsx +59 -0
  6. package/admin/src/components/cmsds/CmsStatCard.tsx +79 -0
  7. package/admin/src/components/cmsds/CmsStatusBadge.tsx +1 -1
  8. package/admin/src/components/cmsds/index.ts +5 -0
  9. package/admin/src/contexts/ThemeContext.tsx +85 -17
  10. package/admin/src/embed/components/EmbedHeader.tsx +11 -9
  11. package/admin/src/embed/index.tsx +10 -6
  12. package/admin/src/embed/types.ts +12 -0
  13. package/admin/src/pages/ContentPage.tsx +116 -172
  14. package/admin/src/pages/ContentTypeEntriesPage.tsx +120 -194
  15. package/admin/src/pages/ContentTypesPage.tsx +136 -139
  16. package/admin/src/pages/DashboardPage.tsx +13 -52
  17. package/admin/src/pages/MediaPage.tsx +31 -57
  18. package/admin/src/pages/SettingsPage.tsx +5 -1
  19. package/admin/src/pages/TrashPage.tsx +115 -170
  20. package/admin/src/styles/globals.css +18 -31
  21. package/admin/src/styles/tailwind-config.css +12 -0
  22. package/admin/src/styles/theme.css +299 -38
  23. package/admin-dist/nitro.json +1 -1
  24. package/admin-dist/public/assets/{CmsEmptyState-gxhf-b6F.js → CmsEmptyState-DTlpzjOI.js} +1 -1
  25. package/admin-dist/public/assets/{CmsPageHeader-equV7Sd9.js → CmsPageHeader-0REGRH4X.js} +1 -1
  26. package/admin-dist/public/assets/{CmsStatusBadge-DQAslyW4.js → CmsStatusBadge-D_n8u8xa.js} +1 -1
  27. package/admin-dist/public/assets/{CmsSurface-DdC_aGB5.js → CmsSurface-BHmvNai4.js} +1 -1
  28. package/admin-dist/public/assets/{CmsToolbar-Crleacii.js → CmsToolbar-CY6GV2L8.js} +1 -1
  29. package/admin-dist/public/assets/{ContentEntryEditor-RmtIo3lE.js → ContentEntryEditor-CRgcRkk5.js} +1 -1
  30. package/admin-dist/public/assets/{TaxonomyFilter-BsoK90hw.js → TaxonomyFilter-Ohv5Jg9c.js} +1 -1
  31. package/admin-dist/public/assets/{_contentTypeId-Bn2ItET5.js → _contentTypeId-C_vJq22X.js} +1 -1
  32. package/admin-dist/public/assets/{_entryId-CkZWLvOZ.js → _entryId-jPXz4z9T.js} +1 -1
  33. package/admin-dist/public/assets/{alert-C7q0k4u0.js → alert-CG97cMfC.js} +1 -1
  34. package/admin-dist/public/assets/{badge-DiaAY1It.js → badge-C6qt24oj.js} +1 -1
  35. package/admin-dist/public/assets/{circle-check-big-Bl0y10am.js → circle-check-big-PltpxuB1.js} +1 -1
  36. package/admin-dist/public/assets/{command-QyTDg7pa.js → command-CJ8i86fd.js} +1 -1
  37. package/admin-dist/public/assets/{content-D868GT7T.js → content-pKaIL2ru.js} +1 -1
  38. package/admin-dist/public/assets/{content-types-DD7fJA5i.js → content-types-Bl_8I1Re.js} +1 -1
  39. package/admin-dist/public/assets/{index-CMnzrG_D.js → index-CtHq_P5q.js} +1 -1
  40. package/admin-dist/public/assets/{main-DWSY6jZL.js → main-CA-4LyFT.js} +2 -2
  41. package/admin-dist/public/assets/{media-aqxopgtw.js → media-Bl1tBbJQ.js} +1 -1
  42. package/admin-dist/public/assets/{new._contentTypeId-9ji3Hibs.js → new._contentTypeId-qsvo01mH.js} +1 -1
  43. package/admin-dist/public/assets/{pencil-D8GqMaV3.js → pencil-gAL0R34f.js} +1 -1
  44. package/admin-dist/public/assets/{refresh-cw-JipRPLLT.js → refresh-cw-sdVUGJNs.js} +1 -1
  45. package/admin-dist/public/assets/{rotate-ccw-CK11hP79.js → rotate-ccw-6OcXCcxb.js} +1 -1
  46. package/admin-dist/public/assets/{scroll-area-CJS1P20j.js → scroll-area-CJBhf9pf.js} +1 -1
  47. package/admin-dist/public/assets/{search-BT8HTHxb.js → search-WXp6KxDJ.js} +1 -1
  48. package/admin-dist/public/assets/settings-D8crrFCn.js +1 -0
  49. package/admin-dist/public/assets/{switch-Cb-ecsrJ.js → switch-Ck9ecqEX.js} +1 -1
  50. package/admin-dist/public/assets/{tabs-CFEXN2p7.js → tabs-vQYu8rjC.js} +1 -1
  51. package/admin-dist/public/assets/{tanstack-adapter-CGxC-fmP.js → tanstack-adapter-BRt2CUCw.js} +1 -1
  52. package/admin-dist/public/assets/{taxonomies-C21Z8CBa.js → taxonomies-DvILUNvr.js} +1 -1
  53. package/admin-dist/public/assets/{trash-CMRJlzc0.js → trash-YyYaC3L9.js} +1 -1
  54. package/admin-dist/public/assets/{useBreadcrumbLabel-ZZFYdqzi.js → useBreadcrumbLabel-tlSh7dtO.js} +1 -1
  55. package/admin-dist/public/assets/{usePermissions-C2FRye75.js → usePermissions-BTGdTOJS.js} +1 -1
  56. package/admin-dist/server/_ssr/{CmsEmptyState-DWqt3y_O.mjs → CmsEmptyState-CB6e53i5.mjs} +1 -1
  57. package/admin-dist/server/_ssr/{CmsPageHeader-BuN0dOPA.mjs → CmsPageHeader-COUHuECp.mjs} +1 -1
  58. package/admin-dist/server/_ssr/{CmsStatusBadge-CV35-X_8.mjs → CmsStatusBadge-kMTL6koE.mjs} +2 -2
  59. package/admin-dist/server/_ssr/{CmsSurface-DEcWf_aJ.mjs → CmsSurface-D1HDYjRg.mjs} +1 -1
  60. package/admin-dist/server/_ssr/{CmsToolbar-BMBEZVgb.mjs → CmsToolbar-NB014hsd.mjs} +1 -1
  61. package/admin-dist/server/_ssr/{ContentEntryEditor-Db9Sy_0y.mjs → ContentEntryEditor-Bq8FR_uK.mjs} +8 -8
  62. package/admin-dist/server/_ssr/{TaxonomyFilter-D_xDfC8t.mjs → TaxonomyFilter-bm_p4ADg.mjs} +3 -3
  63. package/admin-dist/server/_ssr/{_contentTypeId-HZlfcQi-.mjs → _contentTypeId-B7obLmi_.mjs} +10 -10
  64. package/admin-dist/server/_ssr/{_entryId-Cc_Ry7AV.mjs → _entryId-B4zhQqFg.mjs} +11 -11
  65. package/admin-dist/server/_ssr/_tanstack-start-manifest_v-DndoqCo7.mjs +4 -0
  66. package/admin-dist/server/_ssr/{badge-CmG74mbX.mjs → badge-NOEC9bkk.mjs} +1 -1
  67. package/admin-dist/server/_ssr/{command-DWXiOsOb.mjs → command-h4-OYNBo.mjs} +1 -1
  68. package/admin-dist/server/_ssr/{content-CAgFQzx-.mjs → content-CShtLuhK.mjs} +8 -8
  69. package/admin-dist/server/_ssr/{content-types-CqKvAZ8P.mjs → content-types-PeyRyfbc.mjs} +6 -6
  70. package/admin-dist/server/_ssr/{index--qYdIqvh.mjs → index-CplFXpGg.mjs} +3 -3
  71. package/admin-dist/server/_ssr/index.mjs +2 -2
  72. package/admin-dist/server/_ssr/{media-AXePwPAK.mjs → media-QAkNdX54.mjs} +9 -9
  73. package/admin-dist/server/_ssr/{new._contentTypeId-DNWIl-Ha.mjs → new._contentTypeId-DEJyMphJ.mjs} +10 -10
  74. package/admin-dist/server/_ssr/{router-B_gIkxi2.mjs → router-CQXMuGMF.mjs} +10 -10
  75. package/admin-dist/server/_ssr/{scroll-area-Cz-9ry0J.mjs → scroll-area-B7zoNyWB.mjs} +1 -1
  76. package/admin-dist/server/_ssr/{settings-BjSxo5d6.mjs → settings-CNaqVa4D.mjs} +9 -9
  77. package/admin-dist/server/_ssr/{switch-IsC1gdb1.mjs → switch-BKZhvryc.mjs} +1 -1
  78. package/admin-dist/server/_ssr/{tabs-BdgLwrYe.mjs → tabs-DtIIQxiD.mjs} +1 -1
  79. package/admin-dist/server/_ssr/{tanstack-adapter-CFwjrqRl.mjs → tanstack-adapter-CLavdbUY.mjs} +1 -1
  80. package/admin-dist/server/_ssr/{taxonomies-D5Di9EgA.mjs → taxonomies-vIZYICzr.mjs} +7 -7
  81. package/admin-dist/server/_ssr/{trash-DokZl1yA.mjs → trash-7yGR4-dF.mjs} +7 -7
  82. package/admin-dist/server/_ssr/{useBreadcrumbLabel-C4TsA5z0.mjs → useBreadcrumbLabel-DR5FaAMf.mjs} +1 -1
  83. package/admin-dist/server/_ssr/{usePermissions-COsRlMp-.mjs → usePermissions-DKkpETj_.mjs} +1 -1
  84. package/admin-dist/server/index.mjs +155 -155
  85. package/package.json +1 -1
  86. package/admin-dist/public/assets/settings-DCY0s2hR.js +0 -1
  87. package/admin-dist/server/_ssr/_tanstack-start-manifest_v-DhspKP9e.mjs +0 -4
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-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400",
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
- export function ThemeProvider({ children }: { children: ReactNode }) {
37
- const [theme, setThemeState] = useState<Theme>(() => getStoredTheme())
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
- const root = document.documentElement
48
- root.classList.remove('light', 'dark')
49
- root.classList.add(resolved)
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
- applyTheme(theme)
63
- }, [theme, applyTheme])
94
+ if (!shouldFollowParent) {
95
+ applyTheme(theme)
96
+ }
97
+ }, [theme, applyTheme, shouldFollowParent])
64
98
 
65
99
  useEffect(() => {
66
- const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
100
+ if (!shouldFollowParent) {
101
+ const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
67
102
 
68
- const handleChange = () => {
69
- if (theme === 'system') {
70
- applyTheme('system')
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
- mediaQuery.addEventListener('change', handleChange)
75
- return () => mediaQuery.removeEventListener('change', handleChange)
76
- }, [theme, applyTheme])
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
- <button
48
- type="button"
49
- onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
50
- className="flex size-9 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
51
- title={`Switch to ${theme === "dark" ? "light" : "dark"} mode`}
52
- >
53
- {theme === "dark" ? <Sun className="size-5" /> : <Moon className="size-5" />}
54
- </button>
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"
@@ -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,
@@ -127,6 +128,8 @@ export function CmsAdmin({
127
128
  auth,
128
129
  basePath = "/admin",
129
130
  className,
131
+ themeMode = "isolated",
132
+ darkModeControl = "independent",
130
133
  initialRoute = "dashboard",
131
134
  onNavigate,
132
135
  }: CmsAdminProps & {
@@ -143,7 +146,10 @@ export function CmsAdmin({
143
146
 
144
147
  if (!convex) {
145
148
  return (
146
- <div className="flex min-h-full items-center justify-center bg-background p-6">
149
+ <div
150
+ className={cn("flex h-full items-center justify-center bg-background p-6", className)}
151
+ data-cms-admin={themeMode}
152
+ >
147
153
  <div className="diff-modified max-w-lg space-y-4 rounded-lg border p-6 text-center">
148
154
  <h2 className="text-xl font-semibold text-diff-modified">
149
155
  ConvexProvider Required
@@ -158,9 +164,9 @@ export function CmsAdmin({
158
164
  }
159
165
 
160
166
  return (
161
- <div className={className}>
167
+ <div className={cn("h-full", className)} data-cms-admin={themeMode}>
162
168
  <ApiProvider api={api}>
163
- <ThemeProvider>
169
+ <ThemeProvider themeMode={themeMode} darkModeControl={darkModeControl}>
164
170
  <SettingsConfigProvider baseConfig={adminConfig} api={settingsApi}>
165
171
  <AuthProvider
166
172
  getUser={authConfig.getUser}
@@ -173,9 +179,7 @@ export function CmsAdmin({
173
179
  onNavigate={onNavigate}
174
180
  >
175
181
  <RouteGuard>
176
- <div className="min-h-screen">
177
- <EmbedRouter />
178
- </div>
182
+ <EmbedRouter />
179
183
  </RouteGuard>
180
184
  </EmbedNavigationProvider>
181
185
  </AuthProvider>
@@ -20,4 +20,16 @@ export interface CmsAdminProps {
20
20
  auth: CmsAdminAuthConfig;
21
21
  basePath?: string;
22
22
  className?: string;
23
+ /**
24
+ * Theme mode for CSS variable scoping:
25
+ * - 'isolated' (default): Admin uses its own theme, ignores parent app styles
26
+ * - 'inherit': Admin inherits parent app's CSS variables (for shadcn apps)
27
+ */
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";
23
35
  }