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.
- 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/index.tsx +10 -6
- package/admin/src/embed/types.ts +12 -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 +18 -31
- package/admin/src/styles/tailwind-config.css +12 -0
- package/admin/src/styles/theme.css +299 -38
- package/admin-dist/nitro.json +1 -1
- package/admin-dist/public/assets/{CmsEmptyState-gxhf-b6F.js → CmsEmptyState-DTlpzjOI.js} +1 -1
- package/admin-dist/public/assets/{CmsPageHeader-equV7Sd9.js → CmsPageHeader-0REGRH4X.js} +1 -1
- package/admin-dist/public/assets/{CmsStatusBadge-DQAslyW4.js → CmsStatusBadge-D_n8u8xa.js} +1 -1
- package/admin-dist/public/assets/{CmsSurface-DdC_aGB5.js → CmsSurface-BHmvNai4.js} +1 -1
- package/admin-dist/public/assets/{CmsToolbar-Crleacii.js → CmsToolbar-CY6GV2L8.js} +1 -1
- package/admin-dist/public/assets/{ContentEntryEditor-RmtIo3lE.js → ContentEntryEditor-CRgcRkk5.js} +1 -1
- package/admin-dist/public/assets/{TaxonomyFilter-BsoK90hw.js → TaxonomyFilter-Ohv5Jg9c.js} +1 -1
- package/admin-dist/public/assets/{_contentTypeId-Bn2ItET5.js → _contentTypeId-C_vJq22X.js} +1 -1
- package/admin-dist/public/assets/{_entryId-CkZWLvOZ.js → _entryId-jPXz4z9T.js} +1 -1
- package/admin-dist/public/assets/{alert-C7q0k4u0.js → alert-CG97cMfC.js} +1 -1
- package/admin-dist/public/assets/{badge-DiaAY1It.js → badge-C6qt24oj.js} +1 -1
- package/admin-dist/public/assets/{circle-check-big-Bl0y10am.js → circle-check-big-PltpxuB1.js} +1 -1
- package/admin-dist/public/assets/{command-QyTDg7pa.js → command-CJ8i86fd.js} +1 -1
- package/admin-dist/public/assets/{content-D868GT7T.js → content-pKaIL2ru.js} +1 -1
- package/admin-dist/public/assets/{content-types-DD7fJA5i.js → content-types-Bl_8I1Re.js} +1 -1
- package/admin-dist/public/assets/{index-CMnzrG_D.js → index-CtHq_P5q.js} +1 -1
- package/admin-dist/public/assets/{main-DWSY6jZL.js → main-CA-4LyFT.js} +2 -2
- package/admin-dist/public/assets/{media-aqxopgtw.js → media-Bl1tBbJQ.js} +1 -1
- package/admin-dist/public/assets/{new._contentTypeId-9ji3Hibs.js → new._contentTypeId-qsvo01mH.js} +1 -1
- package/admin-dist/public/assets/{pencil-D8GqMaV3.js → pencil-gAL0R34f.js} +1 -1
- package/admin-dist/public/assets/{refresh-cw-JipRPLLT.js → refresh-cw-sdVUGJNs.js} +1 -1
- package/admin-dist/public/assets/{rotate-ccw-CK11hP79.js → rotate-ccw-6OcXCcxb.js} +1 -1
- package/admin-dist/public/assets/{scroll-area-CJS1P20j.js → scroll-area-CJBhf9pf.js} +1 -1
- package/admin-dist/public/assets/{search-BT8HTHxb.js → search-WXp6KxDJ.js} +1 -1
- package/admin-dist/public/assets/settings-D8crrFCn.js +1 -0
- package/admin-dist/public/assets/{switch-Cb-ecsrJ.js → switch-Ck9ecqEX.js} +1 -1
- package/admin-dist/public/assets/{tabs-CFEXN2p7.js → tabs-vQYu8rjC.js} +1 -1
- package/admin-dist/public/assets/{tanstack-adapter-CGxC-fmP.js → tanstack-adapter-BRt2CUCw.js} +1 -1
- package/admin-dist/public/assets/{taxonomies-C21Z8CBa.js → taxonomies-DvILUNvr.js} +1 -1
- package/admin-dist/public/assets/{trash-CMRJlzc0.js → trash-YyYaC3L9.js} +1 -1
- package/admin-dist/public/assets/{useBreadcrumbLabel-ZZFYdqzi.js → useBreadcrumbLabel-tlSh7dtO.js} +1 -1
- package/admin-dist/public/assets/{usePermissions-C2FRye75.js → usePermissions-BTGdTOJS.js} +1 -1
- package/admin-dist/server/_ssr/{CmsEmptyState-DWqt3y_O.mjs → CmsEmptyState-CB6e53i5.mjs} +1 -1
- package/admin-dist/server/_ssr/{CmsPageHeader-BuN0dOPA.mjs → CmsPageHeader-COUHuECp.mjs} +1 -1
- package/admin-dist/server/_ssr/{CmsStatusBadge-CV35-X_8.mjs → CmsStatusBadge-kMTL6koE.mjs} +2 -2
- package/admin-dist/server/_ssr/{CmsSurface-DEcWf_aJ.mjs → CmsSurface-D1HDYjRg.mjs} +1 -1
- package/admin-dist/server/_ssr/{CmsToolbar-BMBEZVgb.mjs → CmsToolbar-NB014hsd.mjs} +1 -1
- package/admin-dist/server/_ssr/{ContentEntryEditor-Db9Sy_0y.mjs → ContentEntryEditor-Bq8FR_uK.mjs} +8 -8
- package/admin-dist/server/_ssr/{TaxonomyFilter-D_xDfC8t.mjs → TaxonomyFilter-bm_p4ADg.mjs} +3 -3
- package/admin-dist/server/_ssr/{_contentTypeId-HZlfcQi-.mjs → _contentTypeId-B7obLmi_.mjs} +10 -10
- package/admin-dist/server/_ssr/{_entryId-Cc_Ry7AV.mjs → _entryId-B4zhQqFg.mjs} +11 -11
- package/admin-dist/server/_ssr/_tanstack-start-manifest_v-DndoqCo7.mjs +4 -0
- package/admin-dist/server/_ssr/{badge-CmG74mbX.mjs → badge-NOEC9bkk.mjs} +1 -1
- package/admin-dist/server/_ssr/{command-DWXiOsOb.mjs → command-h4-OYNBo.mjs} +1 -1
- package/admin-dist/server/_ssr/{content-CAgFQzx-.mjs → content-CShtLuhK.mjs} +8 -8
- package/admin-dist/server/_ssr/{content-types-CqKvAZ8P.mjs → content-types-PeyRyfbc.mjs} +6 -6
- package/admin-dist/server/_ssr/{index--qYdIqvh.mjs → index-CplFXpGg.mjs} +3 -3
- package/admin-dist/server/_ssr/index.mjs +2 -2
- package/admin-dist/server/_ssr/{media-AXePwPAK.mjs → media-QAkNdX54.mjs} +9 -9
- package/admin-dist/server/_ssr/{new._contentTypeId-DNWIl-Ha.mjs → new._contentTypeId-DEJyMphJ.mjs} +10 -10
- package/admin-dist/server/_ssr/{router-B_gIkxi2.mjs → router-CQXMuGMF.mjs} +10 -10
- package/admin-dist/server/_ssr/{scroll-area-Cz-9ry0J.mjs → scroll-area-B7zoNyWB.mjs} +1 -1
- package/admin-dist/server/_ssr/{settings-BjSxo5d6.mjs → settings-CNaqVa4D.mjs} +9 -9
- package/admin-dist/server/_ssr/{switch-IsC1gdb1.mjs → switch-BKZhvryc.mjs} +1 -1
- package/admin-dist/server/_ssr/{tabs-BdgLwrYe.mjs → tabs-DtIIQxiD.mjs} +1 -1
- package/admin-dist/server/_ssr/{tanstack-adapter-CFwjrqRl.mjs → tanstack-adapter-CLavdbUY.mjs} +1 -1
- package/admin-dist/server/_ssr/{taxonomies-D5Di9EgA.mjs → taxonomies-vIZYICzr.mjs} +7 -7
- package/admin-dist/server/_ssr/{trash-DokZl1yA.mjs → trash-7yGR4-dF.mjs} +7 -7
- package/admin-dist/server/_ssr/{useBreadcrumbLabel-C4TsA5z0.mjs → useBreadcrumbLabel-DR5FaAMf.mjs} +1 -1
- package/admin-dist/server/_ssr/{usePermissions-COsRlMp-.mjs → usePermissions-DKkpETj_.mjs} +1 -1
- package/admin-dist/server/index.mjs +155 -155
- package/package.json +1 -1
- package/admin-dist/public/assets/settings-DCY0s2hR.js +0 -1
- 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-
|
|
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"
|
|
@@ -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
|
|
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
|
-
<
|
|
177
|
-
<EmbedRouter />
|
|
178
|
-
</div>
|
|
182
|
+
<EmbedRouter />
|
|
179
183
|
</RouteGuard>
|
|
180
184
|
</EmbedNavigationProvider>
|
|
181
185
|
</AuthProvider>
|
package/admin/src/embed/types.ts
CHANGED
|
@@ -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
|
}
|