convex-cms 0.0.9-alpha.8 → 0.0.10
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/Header.tsx +1 -1
- package/admin/src/components/RouteGuard.tsx +1 -1
- package/admin/src/components/UploadDropzone.tsx +1 -1
- 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/components/ui/sidebar.tsx +1 -1
- package/admin/src/contexts/AuthContext.tsx +1 -1
- 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 +16 -13
- package/admin/src/embed/contexts/ApiContext.tsx +1 -1
- package/admin/src/embed/index.tsx +3 -2
- package/admin/src/embed/types.ts +6 -0
- package/admin/src/hooks/usePermissions.ts +1 -1
- package/admin/src/index.css +432 -0
- package/admin/src/lib/cmsExports.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 +15 -55
- 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/routes/__root.tsx +1 -1
- package/admin-dist/nitro.json +1 -1
- package/admin-dist/public/assets/{CmsEmptyState-DTlpzjOI.js → CmsEmptyState-BKeL4DBB.js} +1 -1
- package/admin-dist/public/assets/CmsFilterBar-CEpMHd_c.js +1 -0
- package/admin-dist/public/assets/{CmsPageHeader-0REGRH4X.js → CmsPageHeader-CIEkTbyH.js} +1 -1
- package/admin-dist/public/assets/{CmsStatusBadge-D_n8u8xa.js → CmsStatusBadge-BFMOsfMW.js} +1 -1
- package/admin-dist/public/assets/{CmsSurface-BHmvNai4.js → CmsSurface-kqqaFKUI.js} +1 -1
- package/admin-dist/public/assets/CmsTable-Db53Exq0.js +1 -0
- package/admin-dist/public/assets/ContentEntryEditor-Ct7cHayy.js +4 -0
- package/admin-dist/public/assets/TaxonomyFilter-Bm1DI1A7.js +1 -0
- package/admin-dist/public/assets/_contentTypeId-BekeCblX.js +1 -0
- package/admin-dist/public/assets/{_entryId-jPXz4z9T.js → _entryId-CoZDE0l0.js} +1 -1
- package/admin-dist/public/assets/{alert-CG97cMfC.js → alert-CpLdsTGU.js} +1 -1
- package/admin-dist/public/assets/{badge-C6qt24oj.js → badge-BQAotc5B.js} +1 -1
- package/admin-dist/public/assets/{circle-check-big-PltpxuB1.js → circle-check-big-BF3Y5nES.js} +1 -1
- package/admin-dist/public/assets/{command-CJ8i86fd.js → command-lEq6f_Ee.js} +1 -1
- package/admin-dist/public/assets/content-DH6k0dN6.js +1 -0
- package/admin-dist/public/assets/content-types-DHr9tc2V.js +1 -0
- package/admin-dist/public/assets/index-Cf0lbl0G.js +1 -0
- package/admin-dist/public/assets/index-D-4wFfgU.css +1 -0
- package/admin-dist/public/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
- package/admin-dist/public/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- package/admin-dist/public/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
- package/admin-dist/public/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
- package/admin-dist/public/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- package/admin-dist/public/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- package/admin-dist/public/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
- package/admin-dist/public/assets/jetbrains-mono-cyrillic-400-normal-BEIGL1Tu.woff2 +0 -0
- package/admin-dist/public/assets/jetbrains-mono-cyrillic-400-normal-ugxPyKxw.woff +0 -0
- package/admin-dist/public/assets/jetbrains-mono-greek-400-normal-B9oWc5Lo.woff +0 -0
- package/admin-dist/public/assets/jetbrains-mono-greek-400-normal-C190GLew.woff2 +0 -0
- package/admin-dist/public/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
- package/admin-dist/public/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
- package/admin-dist/public/assets/jetbrains-mono-latin-ext-400-normal-Bc8Ftmh3.woff2 +0 -0
- package/admin-dist/public/assets/jetbrains-mono-latin-ext-400-normal-fXTG6kC5.woff +0 -0
- package/admin-dist/public/assets/jetbrains-mono-vietnamese-400-normal-CqNFfHCs.woff +0 -0
- package/admin-dist/public/assets/main-B-6700eG.js +137 -0
- package/admin-dist/public/assets/media-DY5zD52L.js +1 -0
- package/admin-dist/public/assets/{new._contentTypeId-qsvo01mH.js → new._contentTypeId-Dq_NqTQV.js} +1 -1
- package/admin-dist/public/assets/{pencil-gAL0R34f.js → pencil-CI_KfxSx.js} +1 -1
- package/admin-dist/public/assets/refresh-cw-BrXg9a2r.js +1 -0
- package/admin-dist/public/assets/rotate-ccw-PwzxdPxd.js +1 -0
- package/admin-dist/public/assets/{scroll-area-CJBhf9pf.js → scroll-area-DX_nZYp8.js} +1 -1
- package/admin-dist/public/assets/{search-WXp6KxDJ.js → search-DlwBH4C5.js} +1 -1
- package/admin-dist/public/assets/settings-2mx3_ORG.js +1 -0
- package/admin-dist/public/assets/{switch-Ck9ecqEX.js → switch-CjPi4DKH.js} +1 -1
- package/admin-dist/public/assets/{tabs-vQYu8rjC.js → tabs-B5X37GEM.js} +1 -1
- package/admin-dist/public/assets/tanstack-adapter-KSm-nO5L.js +1 -0
- package/admin-dist/public/assets/{taxonomies-DvILUNvr.js → taxonomies-CHjJKNlR.js} +1 -1
- package/admin-dist/public/assets/trash-Cle-tcqq.js +1 -0
- package/admin-dist/public/assets/{useBreadcrumbLabel-tlSh7dtO.js → useBreadcrumbLabel-yZQG_N_3.js} +1 -1
- package/admin-dist/public/assets/{usePermissions-BTGdTOJS.js → usePermissions-D6vsoaJf.js} +1 -1
- package/admin-dist/server/_libs/convex-helpers.mjs +1077 -2
- package/admin-dist/server/_libs/convex.mjs +222 -13
- package/admin-dist/server/_libs/lucide-react.mjs +57 -51
- package/admin-dist/server/_ssr/{CmsEmptyState-CB6e53i5.mjs → CmsEmptyState-DzzuQG0S.mjs} +1 -1
- package/admin-dist/server/_ssr/CmsFilterBar-C5XADS12.mjs +81 -0
- package/admin-dist/server/_ssr/{CmsPageHeader-COUHuECp.mjs → CmsPageHeader-DZ6h7smh.mjs} +1 -1
- package/admin-dist/server/_ssr/{CmsStatusBadge-kMTL6koE.mjs → CmsStatusBadge-D-YFSAa1.mjs} +3 -3
- package/admin-dist/server/_ssr/{CmsSurface-D1HDYjRg.mjs → CmsSurface-Cv51NBLZ.mjs} +1 -1
- package/admin-dist/server/_ssr/CmsTable-DG88C5nO.mjs +189 -0
- package/admin-dist/server/_ssr/{ContentEntryEditor-Bq8FR_uK.mjs → ContentEntryEditor-CRjwXB17.mjs} +10 -10
- package/admin-dist/server/_ssr/{TaxonomyFilter-bm_p4ADg.mjs → TaxonomyFilter-xGwcgtjr.mjs} +3 -3
- package/admin-dist/server/_ssr/{_contentTypeId-B7obLmi_.mjs → _contentTypeId-DRCfeKkm.mjs} +53 -12
- package/admin-dist/server/_ssr/{_entryId-B4zhQqFg.mjs → _entryId-DULm2TDy.mjs} +11 -11
- package/admin-dist/server/_ssr/_tanstack-start-manifest_v-iX3K33p1.mjs +4 -0
- package/admin-dist/server/_ssr/{badge-NOEC9bkk.mjs → badge-CbjIvhb6.mjs} +1 -1
- package/admin-dist/server/_ssr/{command-h4-OYNBo.mjs → command-xB2uiYps.mjs} +2 -2
- package/admin-dist/server/_ssr/{content-CShtLuhK.mjs → content-BfLBaJCZ.mjs} +108 -138
- package/admin-dist/server/_ssr/{content-types-PeyRyfbc.mjs → content-types-DZbF6O2q.mjs} +130 -119
- package/admin-dist/server/_ssr/{index-CplFXpGg.mjs → index-Cfe8sZv5.mjs} +65 -39
- package/admin-dist/server/_ssr/index.mjs +2 -2
- package/admin-dist/server/_ssr/{media-QAkNdX54.mjs → media-Bds2AnPC.mjs} +36 -56
- package/admin-dist/server/_ssr/{new._contentTypeId-DEJyMphJ.mjs → new._contentTypeId-DGvz_tlW.mjs} +10 -10
- package/admin-dist/server/_ssr/{router-CQXMuGMF.mjs → router-DxF7GBcO.mjs} +8804 -4995
- package/admin-dist/server/_ssr/{scroll-area-B7zoNyWB.mjs → scroll-area-DLDlXI07.mjs} +1 -1
- package/admin-dist/server/_ssr/{settings-CNaqVa4D.mjs → settings-BbaiS6z9.mjs} +13 -10
- package/admin-dist/server/_ssr/{switch-BKZhvryc.mjs → switch-Bl89Pfxu.mjs} +1 -1
- package/admin-dist/server/_ssr/{tabs-DtIIQxiD.mjs → tabs-QkbR0iir.mjs} +3 -3
- package/admin-dist/server/_ssr/{tanstack-adapter-CLavdbUY.mjs → tanstack-adapter-CKknPtcU.mjs} +19 -1
- package/admin-dist/server/_ssr/{taxonomies-vIZYICzr.mjs → taxonomies-S_Ontd0z.mjs} +9 -9
- package/admin-dist/server/_ssr/{trash-7yGR4-dF.mjs → trash-BzAIsbbN.mjs} +109 -132
- package/admin-dist/server/_ssr/{useBreadcrumbLabel-DR5FaAMf.mjs → useBreadcrumbLabel-BjiR1fM_.mjs} +1 -1
- package/admin-dist/server/_ssr/{usePermissions-DKkpETj_.mjs → usePermissions-CDHN95Nz.mjs} +1 -1
- package/admin-dist/server/index.mjs +284 -165
- package/package.json +3 -2
- package/admin/src/styles/globals.css +0 -104
- package/admin/src/styles/tailwind-config.css +0 -99
- package/admin/src/styles/theme.css +0 -261
- package/admin-dist/public/assets/CmsToolbar-CY6GV2L8.js +0 -1
- package/admin-dist/public/assets/ContentEntryEditor-CRgcRkk5.js +0 -4
- package/admin-dist/public/assets/TaxonomyFilter-Ohv5Jg9c.js +0 -1
- package/admin-dist/public/assets/_contentTypeId-C_vJq22X.js +0 -1
- package/admin-dist/public/assets/content-pKaIL2ru.js +0 -1
- package/admin-dist/public/assets/content-types-Bl_8I1Re.js +0 -1
- package/admin-dist/public/assets/globals-CoCRjt0K.css +0 -1
- package/admin-dist/public/assets/index-CtHq_P5q.js +0 -1
- package/admin-dist/public/assets/main-CA-4LyFT.js +0 -107
- package/admin-dist/public/assets/media-Bl1tBbJQ.js +0 -1
- package/admin-dist/public/assets/refresh-cw-sdVUGJNs.js +0 -1
- package/admin-dist/public/assets/rotate-ccw-6OcXCcxb.js +0 -1
- package/admin-dist/public/assets/settings-D8crrFCn.js +0 -1
- package/admin-dist/public/assets/tanstack-adapter-BRt2CUCw.js +0 -1
- package/admin-dist/public/assets/trash-YyYaC3L9.js +0 -1
- package/admin-dist/server/_ssr/CmsToolbar-NB014hsd.mjs +0 -48
- package/admin-dist/server/_ssr/_tanstack-start-manifest_v-DndoqCo7.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 |
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useRouterState, useNavigate, Link } from '@tanstack/react-router'
|
|
2
2
|
import { useAuth, useAdminConfig, useBreadcrumbContext } from '~/contexts'
|
|
3
|
-
import { getRole } from '
|
|
3
|
+
import { getRole } from '~/lib/cmsExports'
|
|
4
4
|
import {
|
|
5
5
|
Breadcrumb,
|
|
6
6
|
BreadcrumbItem,
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
|
|
28
28
|
import type { ReactNode } from 'react';
|
|
29
29
|
import { useAuth, type PermissionCheck } from '../contexts/AuthContext';
|
|
30
|
-
import type { RoleName } from '
|
|
30
|
+
import type { RoleName } from '~/lib/cmsExports';
|
|
31
31
|
import { Loader2 } from 'lucide-react';
|
|
32
32
|
|
|
33
33
|
// =============================================================================
|
|
@@ -3,7 +3,7 @@ import {
|
|
|
3
3
|
useMediaUploadQueue,
|
|
4
4
|
type UploadQueueFile,
|
|
5
5
|
type UploadQueueFileStatus,
|
|
6
|
-
} from '
|
|
6
|
+
} from '~/lib/cmsExports'
|
|
7
7
|
import type { FunctionReference } from 'convex/server'
|
|
8
8
|
import { CmsButton } from '~/components/cmsds/CmsButton'
|
|
9
9
|
import { cn } from '~/lib/cn'
|
|
@@ -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'
|
|
@@ -478,7 +478,7 @@ const sidebarMenuButtonVariants = cva(
|
|
|
478
478
|
variant: {
|
|
479
479
|
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
|
480
480
|
outline:
|
|
481
|
-
"bg-background shadow-[
|
|
481
|
+
"bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--sidebar-accent)]",
|
|
482
482
|
},
|
|
483
483
|
size: {
|
|
484
484
|
default: "h-8 text-sm",
|
|
@@ -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>
|