create-vexcms 0.0.20 → 0.1.0-alpha.2

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 (153) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +93 -83
  3. package/dist/index.js +246 -81
  4. package/dist/index.mjs +30976 -0
  5. package/package.json +35 -15
  6. package/templates/base-nextjs/README.md +53 -205
  7. package/templates/base-nextjs/_env.example +7 -9
  8. package/templates/base-nextjs/_gitignore +41 -1
  9. package/templates/base-nextjs/convex/_generated/api.d.ts +6 -62
  10. package/templates/base-nextjs/convex/_generated/server.d.ts +19 -0
  11. package/templates/base-nextjs/convex/_generated/server.js +8 -0
  12. package/templates/base-nextjs/convex/auth/api.ts +49 -3
  13. package/templates/base-nextjs/convex/auth/db.ts +42 -300
  14. package/templates/base-nextjs/convex/auth/index.ts +11 -7
  15. package/templates/base-nextjs/convex/auth/options.ts +5 -29
  16. package/templates/base-nextjs/convex/auth/plugins/index.ts +15 -5
  17. package/templates/base-nextjs/convex/auth/sessions.ts +9 -15
  18. package/templates/base-nextjs/convex/http.ts +18 -3
  19. package/templates/base-nextjs/convex/schema.ts +104 -2
  20. package/templates/base-nextjs/convex/vex/firstUser.ts +10 -84
  21. package/templates/base-nextjs/convex/vex/globals.ts +19 -82
  22. package/templates/base-nextjs/convex/vex/media.ts +19 -246
  23. package/templates/base-nextjs/convex/vex.schema.ts +138 -0
  24. package/templates/base-nextjs/convex/vex.ts +19 -0
  25. package/templates/base-nextjs/convex.json +6 -0
  26. package/templates/base-nextjs/eslint.config.mjs +1 -21
  27. package/templates/base-nextjs/next.config.ts +10 -0
  28. package/templates/base-nextjs/package.json +47 -48
  29. package/templates/base-nextjs/src/app/(frontend)/@auth/(...)auth/[pathname]/view.tsx +17 -5
  30. package/templates/base-nextjs/src/app/(frontend)/auth/[pathname]/view.tsx +2 -4
  31. package/templates/base-nextjs/src/app/(frontend)/layout.tsx +12 -2
  32. package/templates/base-nextjs/src/app/(frontend)/page.tsx +2 -113
  33. package/templates/base-nextjs/src/app/(vexcms)/admin/[[...path]]/page.tsx +40 -0
  34. package/templates/base-nextjs/src/app/(vexcms)/admin/clientProviders.tsx +22 -0
  35. package/templates/base-nextjs/src/app/(vexcms)/admin/layout.tsx +19 -0
  36. package/templates/base-nextjs/src/app/globals.css +240 -104
  37. package/templates/base-nextjs/src/app/layout.tsx +23 -13
  38. package/templates/base-nextjs/src/app/unauthorized/page.tsx +24 -0
  39. package/templates/base-nextjs/src/auth/access.ts +46 -0
  40. package/templates/base-nextjs/src/auth/client.tsx +17 -5
  41. package/templates/base-nextjs/src/auth/hasPermission.ts +39 -0
  42. package/templates/base-nextjs/src/auth/serverUtils.ts +15 -25
  43. package/templates/base-nextjs/src/components/WelcomePage.tsx +116 -0
  44. package/templates/base-nextjs/src/components/component-example.tsx +56 -464
  45. package/templates/base-nextjs/src/components/providers/auth.tsx +13 -0
  46. package/templates/base-nextjs/src/components/providers/client.tsx +6 -1
  47. package/templates/base-nextjs/src/components/providers/convex.tsx +53 -21
  48. package/templates/base-nextjs/src/components/providers/server.tsx +10 -7
  49. package/templates/{marketing-site → base-nextjs}/src/components/ui/accordion.tsx +30 -32
  50. package/templates/base-nextjs/src/components/ui/badge.tsx +10 -13
  51. package/templates/base-nextjs/src/components/ui/input.tsx +35 -5
  52. package/templates/base-nextjs/src/context/AuthContext.tsx +29 -0
  53. package/templates/base-nextjs/src/db/constants/auth.ts +0 -1
  54. package/templates/base-nextjs/src/db/constants/index.ts +22 -35
  55. package/templates/base-nextjs/src/lib/utils.ts +0 -12
  56. package/templates/base-nextjs/src/proxy.ts +97 -12
  57. package/templates/base-nextjs/src/vex.config.ts +35 -0
  58. package/templates/base-nextjs/src/vex.types.ts +293 -0
  59. package/templates/base-nextjs/src/vexcms/collections/images.ts +15 -0
  60. package/templates/base-nextjs/src/vexcms/collections/index.ts +2 -1
  61. package/templates/base-nextjs/src/vexcms/collections/users.ts +7 -35
  62. package/templates/base-nextjs/tsconfig.json +3 -0
  63. package/templates/marketing-site/convex/_generated/api.d.ts +81 -0
  64. package/templates/marketing-site/convex/_generated/api.js +23 -0
  65. package/templates/marketing-site/convex/_generated/dataModel.d.ts +60 -0
  66. package/templates/marketing-site/convex/_generated/server.d.ts +162 -0
  67. package/templates/marketing-site/convex/_generated/server.js +101 -0
  68. package/templates/marketing-site/convex/footers.ts +11 -7
  69. package/templates/marketing-site/convex/headers.ts +15 -5
  70. package/templates/marketing-site/convex/pages.ts +20 -35
  71. package/templates/marketing-site/convex/schema.ts +119 -0
  72. package/templates/marketing-site/convex/seed.ts +582 -35
  73. package/templates/marketing-site/convex/siteSettings.ts +19 -5
  74. package/templates/marketing-site/convex/theme.ts +55 -61
  75. package/templates/marketing-site/convex/vex.schema.ts +355 -0
  76. package/templates/marketing-site/src/app/(frontend)/PageContent.tsx +27 -160
  77. package/templates/marketing-site/src/app/(frontend)/[slug]/page.tsx +6 -9
  78. package/templates/marketing-site/src/app/(frontend)/layout.tsx +4 -2
  79. package/templates/marketing-site/src/app/(frontend)/page.tsx +2 -5
  80. package/templates/marketing-site/src/components/SiteFooter.tsx +6 -3
  81. package/templates/marketing-site/src/components/SiteHeader.tsx +6 -3
  82. package/templates/marketing-site/src/components/ThemeLive.tsx +59 -0
  83. package/templates/marketing-site/src/components/ThemeStyle.tsx +45 -89
  84. package/templates/marketing-site/src/lib/metadata.ts +51 -72
  85. package/templates/marketing-site/src/vex.config.ts +35 -0
  86. package/templates/marketing-site/src/vex.types.ts +634 -0
  87. package/templates/marketing-site/src/vexcms/api.ts +23 -0
  88. package/templates/marketing-site/src/vexcms/blocks/CTA/config.ts +3 -3
  89. package/templates/marketing-site/src/vexcms/blocks/CTA/index.tsx +2 -2
  90. package/templates/marketing-site/src/vexcms/blocks/FAQ/config.ts +4 -4
  91. package/templates/marketing-site/src/vexcms/blocks/FAQ/index.tsx +2 -2
  92. package/templates/marketing-site/src/vexcms/blocks/Features/config.ts +4 -8
  93. package/templates/marketing-site/src/vexcms/blocks/Features/index.tsx +2 -2
  94. package/templates/marketing-site/src/vexcms/blocks/Footer/config.ts +7 -11
  95. package/templates/marketing-site/src/vexcms/blocks/Footer/index.tsx +1 -1
  96. package/templates/marketing-site/src/vexcms/blocks/Header/config.ts +9 -9
  97. package/templates/marketing-site/src/vexcms/blocks/Header/index.tsx +4 -5
  98. package/templates/marketing-site/src/vexcms/blocks/Hero/config.ts +3 -4
  99. package/templates/marketing-site/src/vexcms/blocks/Hero/index.tsx +10 -19
  100. package/templates/marketing-site/src/vexcms/blocks/HowItWorks/config.ts +4 -9
  101. package/templates/marketing-site/src/vexcms/blocks/HowItWorks/index.tsx +3 -6
  102. package/templates/marketing-site/src/vexcms/blocks/Roadmap/config.ts +28 -68
  103. package/templates/marketing-site/src/vexcms/blocks/Roadmap/index.tsx +13 -43
  104. package/templates/marketing-site/src/vexcms/blocks/config.ts +7 -22
  105. package/templates/marketing-site/src/vexcms/blocks/constants.ts +2 -3
  106. package/templates/marketing-site/src/vexcms/blocks/index.ts +9 -8
  107. package/templates/marketing-site/src/vexcms/collections/footers.ts +1 -1
  108. package/templates/marketing-site/src/vexcms/collections/headers.ts +1 -1
  109. package/templates/marketing-site/src/vexcms/collections/index.ts +2 -1
  110. package/templates/marketing-site/src/vexcms/collections/pages.ts +12 -21
  111. package/templates/marketing-site/src/vexcms/collections/themeColors.ts +118 -0
  112. package/templates/marketing-site/src/vexcms/collections/themes.ts +29 -111
  113. package/templates/marketing-site/src/vexcms/globals/index.ts +1 -1
  114. package/templates/marketing-site/src/vexcms/globals/siteSettings.ts +43 -27
  115. package/templates/base-nextjs/convex/auth/adapter/index.ts +0 -230
  116. package/templates/base-nextjs/convex/auth/adapter/utils.ts +0 -547
  117. package/templates/base-nextjs/convex/auth/config.ts +0 -10
  118. package/templates/base-nextjs/convex/vex/auth.ts +0 -38
  119. package/templates/base-nextjs/convex/vex/collections.ts +0 -333
  120. package/templates/base-nextjs/convex/vex/helpers.ts +0 -33
  121. package/templates/base-nextjs/convex/vex/impersonation.ts +0 -51
  122. package/templates/base-nextjs/convex/vex/migrate.ts +0 -74
  123. package/templates/base-nextjs/convex/vex/model/collections.ts +0 -196
  124. package/templates/base-nextjs/convex/vex/model/media.ts +0 -87
  125. package/templates/base-nextjs/convex/vex/model/versions.ts +0 -254
  126. package/templates/base-nextjs/convex/vex/previewSnapshot.ts +0 -53
  127. package/templates/base-nextjs/convex/vex/versions.ts +0 -648
  128. package/templates/base-nextjs/src/app/admin/AdminLayoutWrapper.tsx +0 -33
  129. package/templates/base-nextjs/src/app/admin/AdminPageWrapper.tsx +0 -38
  130. package/templates/base-nextjs/src/app/admin/RichTextFieldWithMedia.tsx +0 -101
  131. package/templates/base-nextjs/src/app/admin/[[...path]]/page.tsx +0 -15
  132. package/templates/base-nextjs/src/app/admin/layout.tsx +0 -51
  133. package/templates/base-nextjs/src/app/favicon.ico +0 -0
  134. package/templates/base-nextjs/src/components/providers/theme.tsx +0 -11
  135. package/templates/base-nextjs/src/components/ui/theme-toggle.tsx +0 -67
  136. package/templates/base-nextjs/src/db/types.ts +0 -6
  137. package/templates/base-nextjs/src/vexcms/access.ts +0 -44
  138. package/templates/base-nextjs/src/vexcms/auth.ts +0 -4
  139. package/templates/base-nextjs/src/vexcms/collections/media.ts +0 -15
  140. package/templates/base-nextjs/vex.config.ts +0 -25
  141. package/templates/marketing-site/public/favicons/favicon.ico +0 -0
  142. package/templates/marketing-site/src/app/(frontend)/preview/[slug]/PreviewPageContent.tsx +0 -55
  143. package/templates/marketing-site/src/app/(frontend)/preview/[slug]/page.tsx +0 -27
  144. package/templates/marketing-site/src/app/(frontend)/preview/layout.tsx +0 -18
  145. package/templates/marketing-site/src/app/admin/AdminLayoutWrapper.tsx +0 -71
  146. package/templates/marketing-site/src/components/ThemeInjector.tsx +0 -128
  147. package/templates/marketing-site/src/components/admin/IconPickerField.tsx +0 -134
  148. package/templates/marketing-site/src/components/admin/ThemeImport.tsx +0 -174
  149. package/templates/marketing-site/src/components/admin/ThemeImportField.tsx +0 -33
  150. package/templates/marketing-site/src/db/constants/index.ts +0 -52
  151. package/templates/marketing-site/src/lib/colorConvert.ts +0 -171
  152. package/templates/marketing-site/src/vexcms/access.ts +0 -36
  153. package/templates/marketing-site/vex.config.ts +0 -32
@@ -1,186 +1,53 @@
1
1
  "use client"
2
2
 
3
- import { RenderBlocks } from "@vexcms/ui"
4
3
  import { convexQuery } from "@convex-dev/react-query"
5
4
  import { useQuery } from "@tanstack/react-query"
6
- import { useMutation, useQuery as useConvexQuery } from "convex/react"
7
- import Link from "next/link"
8
- import { usePathname, useRouter } from "next/navigation"
9
- import { useEffect, useState } from "react"
10
5
  import { anyApi } from "convex/server"
11
6
 
12
- import { api } from "@convex/_generated/api"
13
- import { useSession } from "~/auth/client"
14
- import { normalizeSlug } from "~/lib/utils"
7
+ import { RenderBlocks, type RenderableBlock } from "@vexcms/react"
8
+
9
+ import { WelcomePage } from "~/components/WelcomePage"
15
10
  import { blockComponents } from "~/vexcms/blocks"
16
11
 
12
+ type PageBlockLike = RenderableBlock
13
+
14
+ export interface PageContentProps {
15
+ /** URL slug to render. Omit (or empty) for the home page. */
16
+ slug?: string
17
+ /** Server-fetched `pages.getBySlug` result, hydrated as the query's initial data. */
18
+ initialData?: Record<string, unknown>[]
19
+ }
20
+
17
21
  /**
18
- * Client component that renders a page using TanStack Query + convexQuery.
19
- * When initialData is provided (from server fetchQuery), renders immediately.
20
- * The reactive subscription keeps data up-to-date after hydration.
22
+ * Renders one marketing page's blocks via `RenderBlocks` (Contract 1), or
23
+ * falls back to base's bootstrap `WelcomePage` when no `home` page document
24
+ * exists yet — a fresh scaffold before `pnpm seed` has run (Contract 3).
21
25
  *
22
- * On a fresh project with no "home" page, shows the bootstrap/auth flow
23
- * so the first user can sign up and become admin.
26
+ * `pages.getBySlug` always returns an array (empty when no match — the same
27
+ * shape every collection query returns), so this always reads `pages?.[0]`.
24
28
  */
25
- export function PageContent({
26
- slug,
27
- initialData,
28
- }: {
29
- slug?: string
30
- initialData?: Record<string, unknown> | null
31
- }) {
32
- const normalizedSlug = normalizeSlug(slug)
29
+ export function PageContent({ slug, initialData }: PageContentProps) {
30
+ const normalizedSlug = slug && slug.length > 0 ? slug : "home"
33
31
 
34
- const { data: page, isPending } = useQuery({
35
- ...convexQuery(anyApi.pages.getBySlug, {
36
- slug: normalizedSlug,
37
- _vexDrafts: false,
38
- }),
39
- initialData: initialData ?? undefined,
32
+ const { data: pages, isPending } = useQuery({
33
+ ...convexQuery(anyApi.pages.getBySlug, { slug: normalizedSlug }),
34
+ initialData,
40
35
  })
41
36
 
37
+ const page = pages?.[0]
38
+
42
39
  if (isPending && initialData === undefined) {
43
- return (
44
- <div className="flex min-h-screen items-center justify-center">
45
- <p className="text-muted-foreground">Loading...</p>
46
- </div>
47
- )
40
+ return null
48
41
  }
49
42
 
50
43
  if (!page) {
51
- if (normalizedSlug === "home") {
52
- return <WelcomePage />
53
- }
54
-
55
- return (
56
- <div className="mx-auto max-w-3xl px-4 py-12">
57
- <h1 className="text-2xl font-bold">Page not found</h1>
58
- <p className="mt-2 text-muted-foreground">
59
- The page &ldquo;{slug}&rdquo; doesn&apos;t exist or hasn&apos;t been
60
- published yet.
61
- </p>
62
- <Link
63
- className="mt-4 inline-block text-sm text-primary hover:underline"
64
- href="/"
65
- >
66
- &larr; Back to home
67
- </Link>
68
- </div>
69
- )
44
+ return <WelcomePage />
70
45
  }
71
46
 
72
47
  return (
73
48
  <RenderBlocks
74
- blocks={page.content as any}
49
+ blocks={page.blocks as PageBlockLike[] | null | undefined}
75
50
  components={blockComponents}
76
51
  />
77
52
  )
78
53
  }
79
-
80
- function LoadingSpinner() {
81
- return (
82
- <svg className="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
83
- <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
84
- <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
85
- </svg>
86
- )
87
- }
88
-
89
- /**
90
- * Welcome page shown on fresh projects with no "home" page.
91
- * Handles the first-user bootstrap flow: sign up → promote to admin → redirect to admin panel.
92
- */
93
- function WelcomePage() {
94
- const isBootstrapped = useConvexQuery(api.vex.firstUser.isBootstrapped)
95
- const promoteFirstAdmin = useMutation(api.vex.firstUser.promoteFirstAdmin)
96
- const { data: session } = useSession()
97
- const router = useRouter()
98
- const pathname = usePathname()
99
- const [promoting, setPromoting] = useState(false)
100
- const [navigating, setNavigating] = useState(false)
101
-
102
- useEffect(() => {
103
- if (pathname === "/") {
104
- setNavigating(false)
105
- }
106
- }, [pathname])
107
-
108
- useEffect(() => {
109
- if (session?.user && isBootstrapped === false && !promoting) {
110
- setPromoting(true)
111
- setNavigating(true)
112
- promoteFirstAdmin()
113
- .then((result) => {
114
- if (result.promoted) {
115
- router.push("/admin")
116
- } else {
117
- setNavigating(false)
118
- }
119
- })
120
- .catch(() => {
121
- setPromoting(false)
122
- setNavigating(false)
123
- })
124
- }
125
- }, [session, isBootstrapped, promoting, promoteFirstAdmin, router])
126
-
127
- const bootstrapped = isBootstrapped === true
128
-
129
- const buttonClass =
130
- "inline-flex items-center justify-center gap-2 rounded-md bg-primary px-6 py-3 text-sm font-medium text-primary-foreground shadow hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-opacity"
131
-
132
- const handleNavigate = (path: string) => {
133
- setNavigating(true)
134
- router.push(path)
135
- }
136
-
137
- return (
138
- <div className="flex min-h-screen flex-col items-center justify-center gap-8 p-8">
139
- <div className="text-center">
140
- <h1 className="text-4xl font-bold tracking-tight">VEX CMS</h1>
141
- <p className="mt-3 text-lg text-muted-foreground">
142
- {bootstrapped
143
- ? "Your content management system is ready."
144
- : "Welcome! Create your admin account to get started."}
145
- </p>
146
- </div>
147
-
148
- <div className="flex gap-4">
149
- {session?.user ? (
150
- <button
151
- onClick={() => handleNavigate("/admin")}
152
- disabled={navigating}
153
- className={buttonClass}
154
- >
155
- {navigating && <LoadingSpinner />}
156
- {navigating ? "Loading..." : "Go to Admin Panel"}
157
- </button>
158
- ) : bootstrapped ? (
159
- <button
160
- onClick={() => handleNavigate("/auth/sign-in")}
161
- disabled={navigating}
162
- className={buttonClass}
163
- >
164
- {navigating && <LoadingSpinner />}
165
- {navigating ? "Loading..." : "Sign In"}
166
- </button>
167
- ) : (
168
- <button
169
- onClick={() => handleNavigate("/auth/sign-up")}
170
- disabled={navigating}
171
- className={buttonClass}
172
- >
173
- {navigating && <LoadingSpinner />}
174
- {navigating ? "Loading..." : "Create Admin Account"}
175
- </button>
176
- )}
177
- </div>
178
-
179
- {promoting && (
180
- <p className="text-sm text-muted-foreground animate-pulse">
181
- Setting up your admin account...
182
- </p>
183
- )}
184
- </div>
185
- )
186
- }
@@ -1,25 +1,22 @@
1
- import { api } from "@convex/_generated/api"
2
1
  import { fetchQuery } from "convex/nextjs"
3
2
 
3
+ import { api } from "@convex/_generated/api"
4
+
4
5
  import { generatePageMetadata } from "~/lib/metadata"
5
- import { normalizeSlug } from "~/lib/utils"
6
6
  import { PageContent } from "../PageContent"
7
7
 
8
8
  export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
9
9
  const { slug } = await params
10
- return generatePageMetadata({ slug: normalizeSlug(slug) })
10
+ return generatePageMetadata({ slug: slug && slug.length > 0 ? slug : "home" })
11
11
  }
12
12
 
13
13
  export default async function PublicPage({ params }: { params: Promise<{ slug: string }> }) {
14
14
  const { slug } = await params
15
- const normalized = normalizeSlug(slug)
15
+ const normalized = slug && slug.length > 0 ? slug : "home"
16
16
 
17
- let initialData: null | Record<string, unknown> = null
17
+ let initialData: Record<string, unknown>[] | undefined
18
18
  try {
19
- initialData = await fetchQuery(api.pages.getBySlug, {
20
- slug: normalized,
21
- _vexDrafts: false,
22
- })
19
+ initialData = await fetchQuery(api.pages.getBySlug, { slug: normalized })
23
20
  } catch {
24
21
  // Fall back to client-only fetch
25
22
  }
@@ -1,3 +1,5 @@
1
+ import type { ReactNode } from "react"
2
+
1
3
  import { fetchQuery } from "convex/nextjs"
2
4
 
3
5
  import { api } from "@convex/_generated/api"
@@ -10,8 +12,8 @@ export default async function FrontendLayout({
10
12
  auth,
11
13
  children,
12
14
  }: Readonly<{
13
- auth: React.ReactNode
14
- children: React.ReactNode
15
+ auth: ReactNode
16
+ children: ReactNode
15
17
  }>) {
16
18
  let headerData: Record<string, unknown> | null = null
17
19
  let footerData: Record<string, unknown> | null = null
@@ -10,12 +10,9 @@ export async function generateMetadata() {
10
10
  }
11
11
 
12
12
  export default async function HomePage() {
13
- let initialData: Record<string, unknown> | null = null
13
+ let initialData: Record<string, unknown>[] | undefined
14
14
  try {
15
- initialData = await fetchQuery(api.pages.getBySlug, {
16
- slug: "home",
17
- _vexDrafts: false,
18
- })
15
+ initialData = await fetchQuery(api.pages.getBySlug, { slug: "home" })
19
16
  } catch {
20
17
  // Fall back to client-only fetch
21
18
  }
@@ -1,12 +1,14 @@
1
1
  "use client"
2
2
 
3
- import { RenderBlocks } from "@vexcms/ui"
3
+ import { RenderBlocks, type RenderableBlock } from "@vexcms/react"
4
4
  import { convexQuery } from "@convex-dev/react-query"
5
5
  import { useQuery } from "@tanstack/react-query"
6
6
  import { anyApi } from "convex/server"
7
7
 
8
8
  import { blockComponents } from "~/vexcms/blocks"
9
9
 
10
+ type PageBlockLike = RenderableBlock
11
+
10
12
  export function SiteFooter({
11
13
  initialData,
12
14
  }: {
@@ -17,11 +19,12 @@ export function SiteFooter({
17
19
  initialData: initialData ?? undefined,
18
20
  })
19
21
 
20
- if (!footer?.content) return null
22
+ const content = footer?.content as PageBlockLike[] | null | undefined
23
+ if (!content) return null
21
24
 
22
25
  return (
23
26
  <RenderBlocks
24
- blocks={footer.content as any}
27
+ blocks={content}
25
28
  components={blockComponents}
26
29
  />
27
30
  )
@@ -1,12 +1,14 @@
1
1
  "use client"
2
2
 
3
- import { RenderBlocks } from "@vexcms/ui"
3
+ import { RenderBlocks, type RenderableBlock } from "@vexcms/react"
4
4
  import { convexQuery } from "@convex-dev/react-query"
5
5
  import { useQuery } from "@tanstack/react-query"
6
6
  import { anyApi } from "convex/server"
7
7
 
8
8
  import { blockComponents } from "~/vexcms/blocks"
9
9
 
10
+ type PageBlockLike = RenderableBlock
11
+
10
12
  export function SiteHeader({
11
13
  initialData,
12
14
  }: {
@@ -17,11 +19,12 @@ export function SiteHeader({
17
19
  initialData: initialData ?? undefined,
18
20
  })
19
21
 
20
- if (!header?.content) return null
22
+ const content = header?.content as PageBlockLike[] | null | undefined
23
+ if (!content) return null
21
24
 
22
25
  return (
23
26
  <RenderBlocks
24
- blocks={header.content as any}
27
+ blocks={content}
25
28
  components={blockComponents}
26
29
  />
27
30
  )
@@ -0,0 +1,59 @@
1
+ "use client"
2
+
3
+ import { api } from "@convex/_generated/api"
4
+ import { useQuery } from "convex/react"
5
+ import { useEffect } from "react"
6
+
7
+ import { buildThemeCss, type ThemeScope } from "@vexcms/core"
8
+
9
+ /**
10
+ * Client companion to the server `<ThemeStyle />`: keeps the applied theme
11
+ * live without a page reload.
12
+ *
13
+ * `useQuery` is a Convex subscription — the deployment pushes every change to
14
+ * the active theme document (or to which theme is active), so saving in the
15
+ * admin re-skins every open tab immediately. No polling, no preview channel,
16
+ * no drafts machinery.
17
+ *
18
+ * The CSS goes into an effect-managed `<style>` tag appended to `<head>`
19
+ * rather than a React-hoisted one: hoisted styles are deduplicated by `href`
20
+ * and are not guaranteed to update in place when their text changes, and an
21
+ * appended tag deterministically follows the server-rendered block — equal
22
+ * specificity, later in the document, so it wins the moment it exists. The
23
+ * first push matches the server CSS byte-for-byte, so nothing visibly changes
24
+ * until a real edit lands.
25
+ *
26
+ * Distinguishes loading from empty: while the query is unresolved
27
+ * (`undefined`) the server CSS stands; once it resolves to `null` (no active
28
+ * theme) the override is cleared and `globals.css` shows through.
29
+ *
30
+ * @param props - Input props.
31
+ * @param props.scope - Same contract as `<ThemeStyle />`: `"site"` emits
32
+ * `:root`, `"admin"` emits `:root:root` with the `adminTheme` fallback.
33
+ * @returns Nothing — the style element is managed imperatively.
34
+ */
35
+ export function ThemeLive(props: { scope?: ThemeScope }) {
36
+ const scope = props.scope ?? "site"
37
+ const theme = useQuery(scope === "admin" ? api.theme.getAdmin : api.theme.getActive)
38
+
39
+ useEffect(() => {
40
+ if (theme === undefined) {return} // still loading — leave the server CSS alone
41
+
42
+ const id = `vex-theme-live-${scope}`
43
+ let el = document.getElementById(id) as HTMLStyleElement | null
44
+ if (!el) {
45
+ el = document.createElement("style")
46
+ el.id = id
47
+ document.head.appendChild(el)
48
+ }
49
+ el.textContent = theme ? buildThemeCss({ theme, scope }) : ""
50
+
51
+ return () => {
52
+ // Admin routes unmount their scope on navigation back to the site; the
53
+ // override must not outlive the layout that owns it.
54
+ el?.remove()
55
+ }
56
+ }, [theme, scope])
57
+
58
+ return null
59
+ }
@@ -1,104 +1,60 @@
1
- import { fetchQuery } from "convex/nextjs"
2
-
3
1
  import { api } from "@convex/_generated/api"
2
+ import { fetchQuery } from "convex/nextjs"
4
3
 
5
- /**
6
- * CSS variable name mapping from camelCase field names to CSS custom property names.
7
- */
8
- const FIELD_TO_CSS_VAR: Record<string, string> = {
9
- background: "background",
10
- foreground: "foreground",
11
- card: "card",
12
- cardForeground: "card-foreground",
13
- popover: "popover",
14
- popoverForeground: "popover-foreground",
15
- primary: "primary",
16
- primaryForeground: "primary-foreground",
17
- secondary: "secondary",
18
- secondaryForeground: "secondary-foreground",
19
- muted: "muted",
20
- mutedForeground: "muted-foreground",
21
- accent: "accent",
22
- accentForeground: "accent-foreground",
23
- destructive: "destructive",
24
- destructiveForeground: "destructive-foreground",
25
- border: "border",
26
- input: "input",
27
- ring: "ring",
28
- chart1: "chart-1",
29
- chart2: "chart-2",
30
- chart3: "chart-3",
31
- chart4: "chart-4",
32
- chart5: "chart-5",
33
- sidebar: "sidebar",
34
- sidebarForeground: "sidebar-foreground",
35
- sidebarPrimary: "sidebar-primary",
36
- sidebarPrimaryForeground: "sidebar-primary-foreground",
37
- sidebarAccent: "sidebar-accent",
38
- sidebarAccentForeground: "sidebar-accent-foreground",
39
- sidebarBorder: "sidebar-border",
40
- sidebarRing: "sidebar-ring",
41
- }
42
-
43
- function buildCSSVars(colors: Record<string, unknown> | null | undefined): string {
44
- if (!colors) return ""
45
-
46
- const vars: string[] = []
47
- for (const [fieldName, cssVar] of Object.entries(FIELD_TO_CSS_VAR)) {
48
- const value = colors[fieldName]
49
- if (typeof value === "string" && value) {
50
- vars.push(` --${cssVar}: ${value};`)
51
- }
52
- }
53
- return vars.join("\n")
54
- }
4
+ import { buildThemeCss, type ThemeScope } from "@vexcms/core"
55
5
 
56
6
  /**
57
- * Server component that fetches the active theme and inlines CSS variables
58
- * into the initial HTML. This eliminates the flash of unstyled content
59
- * that occurs when theme variables are injected client-side.
7
+ * Server component that inlines a theme's CSS custom properties.
8
+ *
9
+ * Rendered twice per admin request and once per public request:
10
+ *
11
+ * - the root layout emits the **site** theme at `:root`;
12
+ * - the admin layout emits the **admin** theme at `:root:root`.
60
13
  *
61
- * @param props.drafts - When true, fetches the draft/preview theme instead of published.
62
- * Use this in the preview layout so the server-rendered CSS
63
- * matches the draft state the admin is editing.
14
+ * `:root:root` is specificity (0,2,0) against `:root`'s (0,1,0), so the admin
15
+ * block wins wherever both are present without depending on style-injection
16
+ * order. On public routes the admin layout never renders, so there is exactly
17
+ * one block. Leave `siteSettings.adminTheme` empty and `getAdmin` falls back to
18
+ * the site theme, which is the default: **the admin adopts the site's palette.**
19
+ *
20
+ * Values are written through verbatim. A `color()` field storing
21
+ * `oklch(60.5% 0.175 42)` needs no conversion, because that is already the
22
+ * notation `globals.css` declares its tokens in.
23
+ *
24
+ * This component covers the **first paint** only — no flash of unthemed
25
+ * content. Live updates after a save are `<ThemeLive />`'s job, which
26
+ * subscribes to the same query client-side and overrides this block.
27
+ *
28
+ * Renders nothing when Convex is unreachable (e.g. a build with no deployment)
29
+ * or no theme is active — the app then uses `globals.css` unchanged.
30
+ *
31
+ * @param props - Input props.
32
+ * @param props.scope - `"site"` emits `:root`; `"admin"` emits `:root:root` and
33
+ * reads `adminTheme` with a fallback to the site theme.
34
+ * @returns A `<style>` element, or `null`.
64
35
  */
65
- export async function ThemeStyle({ drafts }: { drafts?: boolean } = {}) {
66
- let theme: Record<string, unknown> | null = null
36
+ export async function ThemeStyle(props: { scope?: ThemeScope }) {
37
+ const scope = props.scope ?? "site"
38
+
39
+ let theme: null | Record<string, unknown> = null
67
40
  try {
68
- theme = drafts
69
- ? await fetchQuery(api.theme.getActivePreview) as Record<string, unknown> | null
70
- : await fetchQuery(api.theme.getActive) as Record<string, unknown> | null
41
+ theme = await fetchQuery(scope === "admin" ? api.theme.getAdmin : api.theme.getActive)
71
42
  } catch {
72
- // Convex not available during build — use default styles
43
+ // No deployment reachable at build time — fall back to globals.css.
73
44
  return null
74
45
  }
46
+ if (!theme) {return null}
75
47
 
76
- if (!theme) return null
77
-
78
- const lightColors = theme.light as Record<string, unknown> | undefined
79
- const darkColors = theme.dark as Record<string, unknown> | undefined
80
-
81
- const lightVars = buildCSSVars(lightColors)
82
- const darkVars = buildCSSVars(darkColors)
83
-
84
- if (!lightVars && !darkVars) return null
85
-
86
- const css = [
87
- lightVars ? `:root {\n${lightVars}\n}` : "",
88
- darkVars ? `.dark {\n${darkVars}\n}` : "",
89
- ]
90
- .filter(Boolean)
91
- .join("\n\n")
92
-
93
- const radius = theme.radius as string | undefined
94
- const fontFamily = theme.fontFamily as string | undefined
95
- const extraVars: string[] = []
96
- if (radius) extraVars.push(` --radius: ${radius};`)
97
- if (fontFamily) extraVars.push(` --font-sans: ${fontFamily};`)
98
-
99
- const extraCSS = extraVars.length > 0 ? `\n:root {\n${extraVars.join("\n")}\n}` : ""
48
+ const css = buildThemeCss({ theme, scope })
49
+ if (!css) {return null}
100
50
 
51
+ // `precedence` opts into React 19 style hoisting, so this lands in <head>
52
+ // before first paint instead of mid-body.
101
53
  return (
102
- <style dangerouslySetInnerHTML={{ __html: css + extraCSS }} />
54
+ <style
55
+ dangerouslySetInnerHTML={{ __html: css }}
56
+ href={`vex-theme-${scope}`}
57
+ precedence="high"
58
+ />
103
59
  )
104
60
  }