create-vexcms 0.1.0-alpha.21 → 0.1.0-alpha.23

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-vexcms",
3
- "version": "0.1.0-alpha.21",
3
+ "version": "0.1.0-alpha.23",
4
4
  "description": "Scaffold a new VexCMS project",
5
5
  "keywords": [
6
6
  "cms",
@@ -19,7 +19,7 @@ export const { mutation: vexMutation, internalMutation: vexInternalMutation } =
19
19
  internalMutation,
20
20
  });
21
21
 
22
- export const { find, get, search, create, update, remove } = collectionsApi({
22
+ export const { find, get, search, create, update, remove, livePreviewUrl } = collectionsApi({
23
23
  config,
24
24
  query,
25
25
  mutation: vexMutation,
@@ -24,11 +24,11 @@
24
24
  "@t3-oss/env-nextjs": "0.13.11",
25
25
  "@tanstack/react-form": "1.33.1",
26
26
  "@tanstack/react-query": "5.101.2",
27
- "@vexcms/better-auth": "~0.1.0-alpha.21",
28
- "@vexcms/core": "~0.1.0-alpha.21",
29
- "@vexcms/file-storage-convex": "~0.1.0-alpha.21",
30
- "@vexcms/next": "~0.1.0-alpha.21",
31
- "@vexcms/react": "~0.1.0-alpha.21",
27
+ "@vexcms/better-auth": "~0.1.0-alpha.23",
28
+ "@vexcms/core": "~0.1.0-alpha.23",
29
+ "@vexcms/file-storage-convex": "~0.1.0-alpha.23",
30
+ "@vexcms/next": "~0.1.0-alpha.23",
31
+ "@vexcms/react": "~0.1.0-alpha.23",
32
32
  "better-auth": "1.6.23",
33
33
  "class-variance-authority": "0.7.1",
34
34
  "clsx": "2.1.1",
@@ -52,7 +52,7 @@
52
52
  "@types/node": "20.19.43",
53
53
  "@types/react": "19.2.17",
54
54
  "@types/react-dom": "19.2.3",
55
- "@vexcms/cli": "~0.1.0-alpha.21",
55
+ "@vexcms/cli": "~0.1.0-alpha.23",
56
56
  "babel-plugin-react-compiler": "1.0.0",
57
57
  "eslint": "9.39.5",
58
58
  "eslint-config-next": "15.5.9",
@@ -5,17 +5,13 @@ import { NextAdminLayout } from "@vexcms/next/client"
5
5
  import { AuthServerProvider } from "~/components/providers/auth"
6
6
  import { getCurrentUser } from "~/auth/serverUtils"
7
7
 
8
- import { ClientProviders } from "./clientProviders"
9
-
10
8
  export default async function AdminLayout({ children }: { children: ReactNode }) {
11
9
  const user = await getCurrentUser()
12
10
  return (
11
+ // `VexConfigProvider` is mounted once by the root layout's
12
+ // `ClientProviders`, for the admin and the public site alike.
13
13
  <AuthServerProvider>
14
- <ClientProviders>
15
- <NextAdminLayout user={user ?? undefined}>
16
- {children}
17
- </NextAdminLayout>
18
- </ClientProviders>
14
+ <NextAdminLayout user={user ?? undefined}>{children}</NextAdminLayout>
19
15
  </AuthServerProvider>
20
16
  )
21
17
  }
@@ -2,13 +2,31 @@
2
2
 
3
3
  import { type PropsWithChildren } from "react"
4
4
 
5
+ import { VexConfigProvider } from "@vexcms/react"
6
+
5
7
  import BetterAuthClientProvider from "~/auth/client"
6
8
  import ConvexClientProvider from "~/components/providers/convex"
9
+ import config from "~/vex.config"
7
10
 
11
+ /**
12
+ * Mounts the app-wide client providers, including the single
13
+ * `VexConfigProvider` for the WHOLE app — admin and public site alike.
14
+ *
15
+ * One mount, at the root, because both trees need the config now: the admin
16
+ * views always did, and `LivePreviewProvider` reads its collections, globals,
17
+ * and allowed origins from the same context rather than from props. Two mounts
18
+ * would be two provenances for one config.
19
+ *
20
+ * `"use client"` so `vex.config` is imported and owned on the client — it never
21
+ * crosses the RSC boundary as a prop, which React would refuse: the config
22
+ * carries functions (field validators, `admin.livePreview.url` resolvers).
23
+ */
8
24
  export default function ClientProviders({ children }: PropsWithChildren) {
9
25
  return (
10
26
  <ConvexClientProvider>
11
- <BetterAuthClientProvider>{children}</BetterAuthClientProvider>
27
+ <BetterAuthClientProvider>
28
+ <VexConfigProvider config={config}>{children}</VexConfigProvider>
29
+ </BetterAuthClientProvider>
12
30
  </ConvexClientProvider>
13
31
  )
14
32
  }
@@ -5,7 +5,7 @@
5
5
  /* eslint-disable perfectionist/sort-interfaces */
6
6
  /* eslint-disable perfectionist/sort-modules */
7
7
 
8
- import type { Id } from "@convex/_generated/dataModel"
8
+ import type { DataModel, Id } from "@convex/_generated/dataModel"
9
9
  import type { VexDocument, VexDocumentGlobal } from "@vexcms/core"
10
10
 
11
11
  export interface UserDocument extends VexDocument {
@@ -290,5 +290,6 @@ declare module "@vexcms/core" {
290
290
  user: "user"
291
291
  organization: "organization"
292
292
  }
293
+ DataModel: DataModel
293
294
  }
294
295
  }
@@ -6,6 +6,8 @@ import config from "~/vex.config.server"
6
6
  import type { Doc, Id } from "./_generated/dataModel"
7
7
  import type { QueryCtx } from "./_generated/server"
8
8
 
9
+ import { v } from "convex/values"
10
+
9
11
  import { query } from "./_generated/server"
10
12
 
11
13
  /** A theme document, or `null` when none is selected. */
@@ -67,3 +69,19 @@ export const getAdmin = query({
67
69
  (await resolveTheme({ ctx, field: "adminTheme" })) ??
68
70
  (await resolveTheme({ ctx, field: "activeTheme" })),
69
71
  })
72
+
73
+ /**
74
+ * One theme document by id, for the client to resolve a theme reference itself.
75
+ *
76
+ * Exists for live preview: `getActive`/`getAdmin` resolve
77
+ * `siteSettings -> theme` on the SERVER, so they answer from the saved global
78
+ * and cannot see an editor's unsaved `activeTheme`. `<ThemeLive />` therefore
79
+ * does the join on the client — overlaid settings, then this lookup.
80
+ *
81
+ * Access is bypassed for the same reason as `resolveTheme`: a palette is public.
82
+ */
83
+ export const byId = query({
84
+ args: { id: v.string() },
85
+ handler: async (ctx, args): Promise<ActiveTheme> =>
86
+ await ctx.db.get(args.id as Id<typeof TABLE_SLUG_THEMES>),
87
+ })
@@ -8,7 +8,6 @@ import { SiteFooter } from "~/components/SiteFooter"
8
8
  import { SiteHeader } from "~/components/SiteHeader"
9
9
  import { ThemeStyle } from "~/components/ThemeStyle"
10
10
  import { vex } from "~/lib/vex"
11
- import { SiteLivePreviewProvider } from "./SiteLivePreviewProvider"
12
11
 
13
12
  /**
14
13
  * Marketing chrome: header + footer around every site page. Auth routes live
@@ -32,7 +31,7 @@ export default async function SiteLayout({
32
31
  }
33
32
 
34
33
  return (
35
- <SiteLivePreviewProvider>
34
+ <>
36
35
  <ThemeStyle />
37
36
  {/* Sits above the sticky header so it is the first tab stop on every
38
37
  page. Visually hidden until focused. */}
@@ -45,6 +44,6 @@ export default async function SiteLayout({
45
44
  <SiteHeader initialData={headerData} />
46
45
  <main id="main">{children}</main>
47
46
  <SiteFooter initialData={footerData} />
48
- </SiteLivePreviewProvider>
47
+ </>
49
48
  )
50
49
  }
@@ -7,8 +7,6 @@ import { getCurrentUser } from "~/auth/serverUtils";
7
7
  import { ThemeLive } from "~/components/ThemeLive";
8
8
  import { ThemeStyle } from "~/components/ThemeStyle";
9
9
 
10
- import { ClientProviders } from "./clientProviders";
11
-
12
10
  /**
13
11
  * Overlays `base-nextjs`'s admin layout to wire the theme system in: base
14
12
  * ships no theme, so its admin layout has nothing to render here. `<ThemeStyle
@@ -21,12 +19,12 @@ import { ClientProviders } from "./clientProviders";
21
19
  export default async function AdminLayout({ children }: { children: ReactNode }) {
22
20
  const user = await getCurrentUser();
23
21
  return (
22
+ // `VexConfigProvider` is no longer mounted here — the root layout's
23
+ // `ClientProviders` mounts it once for the whole app.
24
24
  <AuthServerProvider>
25
- <ClientProviders>
26
- <ThemeStyle scope="admin" />
27
- <ThemeLive scope="admin" />
28
- <NextAdminLayout user={user ?? undefined}>{children}</NextAdminLayout>
29
- </ClientProviders>
25
+ <ThemeStyle scope="admin" />
26
+ <ThemeLive scope="admin" />
27
+ <NextAdminLayout user={user ?? undefined}>{children}</NextAdminLayout>
30
28
  </AuthServerProvider>
31
29
  );
32
30
  }
@@ -2,7 +2,7 @@ import type { Metadata } from "next"
2
2
 
3
3
  import "./globals.css"
4
4
 
5
- import { ThemeScript } from "@vexcms/react"
5
+ import { LivePreviewProvider, ThemeScript } from "@vexcms/react"
6
6
  import { Geist, Geist_Mono } from "next/font/google"
7
7
 
8
8
  import ClientProviders from "~/components/providers/client"
@@ -75,8 +75,17 @@ export default function RootLayout({
75
75
  <body>
76
76
  <ServerProviders>
77
77
  <ClientProviders>
78
- <ThemeLive />
79
- {children}
78
+ {/* Wraps the whole app, not just `(site)`: `<ThemeLive />` lives
79
+ here (it skins every route, auth pages included) and the overlay
80
+ only reaches consumers inside the provider. Rendered directly —
81
+ it takes nothing but `children`, reading collections, globals,
82
+ and allowed origins from `VexConfigContext` — and gates itself
83
+ on `?vexLivePreview=1` plus the session-verified cookie, so
84
+ every public route stays prerenderable. */}
85
+ <LivePreviewProvider>
86
+ <ThemeLive />
87
+ {children}
88
+ </LivePreviewProvider>
80
89
  </ClientProviders>
81
90
  </ServerProviders>
82
91
  </body>
@@ -2,8 +2,7 @@
2
2
 
3
3
  import { convexQuery } from "@convex-dev/react-query"
4
4
  import { api } from "@convex/_generated/api"
5
- import { useQuery } from "@tanstack/react-query"
6
- import { type RenderableBlock, RenderBlocks } from "@vexcms/react"
5
+ import { type RenderableBlock, RenderBlocks, useLivePreviewDocumentQuery } from "@vexcms/react"
7
6
 
8
7
  import type { FootersDocument } from "~/vex.types"
9
8
 
@@ -16,10 +15,15 @@ export function SiteFooter({
16
15
  }: {
17
16
  initialData?: FootersDocument | null
18
17
  }) {
19
- const { data: footer } = useQuery({
20
- ...convexQuery(api.footers.getFirst, {}),
21
- initialData: initialData ?? undefined,
22
- })
18
+ // See `SiteHeader`: the overlay only reaches documents a consumer hands it,
19
+ // so editing the footer while previewing a page needs this wrapper.
20
+ const { data: footer } = useLivePreviewDocumentQuery(
21
+ {
22
+ ...convexQuery(api.footers.getFirst, {}),
23
+ initialData: initialData ?? undefined,
24
+ },
25
+ "footers",
26
+ )
23
27
 
24
28
  const content = footer?.content as null | PageBlockLike[] | undefined
25
29
  if (!content) {return null}
@@ -2,8 +2,7 @@
2
2
 
3
3
  import { convexQuery } from "@convex-dev/react-query"
4
4
  import { api } from "@convex/_generated/api"
5
- import { useQuery } from "@tanstack/react-query"
6
- import { type RenderableBlock, RenderBlocks } from "@vexcms/react"
5
+ import { type RenderableBlock, RenderBlocks, useLivePreviewDocumentQuery } from "@vexcms/react"
7
6
 
8
7
  import type { HeadersDocument } from "~/vex.types"
9
8
 
@@ -16,10 +15,16 @@ export function SiteHeader({
16
15
  }: {
17
16
  initialData?: HeadersDocument | null
18
17
  }) {
19
- const { data: header } = useQuery({
20
- ...convexQuery(api.headers.getFirst, {}),
21
- initialData: initialData ?? undefined,
22
- })
18
+ // `useLivePreviewDocumentQuery`, not a bare `useQuery`: the live-preview
19
+ // overlay only reaches documents a consumer hands it, so a plain query here
20
+ // means editing the header while previewing a page changes nothing.
21
+ const { data: header } = useLivePreviewDocumentQuery(
22
+ {
23
+ ...convexQuery(api.headers.getFirst, {}),
24
+ initialData: initialData ?? undefined,
25
+ },
26
+ "headers",
27
+ )
23
28
 
24
29
  const content = header?.content as null | PageBlockLike[] | undefined
25
30
  if (!content) {return null}
@@ -1,30 +1,41 @@
1
1
  "use client"
2
2
 
3
+ import { convexQuery } from "@convex-dev/react-query"
3
4
  import { api } from "@convex/_generated/api"
5
+ import { useQuery } from "@tanstack/react-query"
4
6
  import { buildThemeCss, type ThemeScope } from "@vexcms/core"
5
- import { useQuery } from "convex/react"
6
- import { useEffect } from "react"
7
+ import { useLivePreviewDocumentQuery } from "@vexcms/react"
8
+ import { useEffect, useRef } from "react"
9
+
10
+ import type { SiteSettingsGlobal, Theme } from "~/vex.types"
11
+
12
+ import { GLOBAL_SLUG_SITE_SETTINGS } from "~/db/constants"
7
13
 
8
14
  /**
9
15
  * Client companion to the server `<ThemeStyle />`: keeps the applied theme
10
16
  * live without a page reload.
11
17
  *
12
- * `useQuery` is a Convex subscription — the deployment pushes every change to
13
- * the active theme document (or to which theme is active), so saving in the
14
- * admin re-skins every open tab immediately. No polling, no preview channel,
15
- * no drafts machinery.
18
+ * Resolves `siteSettings -> theme` on the CLIENT, in two steps, rather than
19
+ * subscribing to `api.theme.getActive`. That query does the join on the server,
20
+ * so it answers from the saved global and is structurally blind to an editor's
21
+ * unsaved `activeTheme` — switching themes in live preview changed nothing
22
+ * until save. Reading the global through `useLivePreviewDocumentQuery` lets the
23
+ * overlay reach it, then `api.theme.byId` resolves whichever theme it now
24
+ * names. Outside preview the overlay is absent and this behaves exactly as the
25
+ * server join did, still as a live Convex subscription.
16
26
  *
17
- * The CSS goes into an effect-managed `<style>` tag appended to `<head>`
18
- * rather than a React-hoisted one: hoisted styles are deduplicated by `href`
19
- * and are not guaranteed to update in place when their text changes, and an
20
- * appended tag deterministically follows the server-rendered block — equal
21
- * specificity, later in the document, so it wins the moment it exists. The
22
- * first push matches the server CSS byte-for-byte, so nothing visibly changes
23
- * until a real edit lands.
27
+ * The CSS goes into an effect-managed `<style>` appended to the end of
28
+ * `<body>` rather than a React-hoisted one: hoisted styles are deduplicated by
29
+ * `href` and are not guaranteed to update in place when their text changes.
30
+ * The end of `<body>` is the one position that follows *both* server blocks —
31
+ * the hoisted site block in `<head>` and the admin layout's in-tree block — so
32
+ * at equal specificity this one wins the moment it exists. The first push
33
+ * matches the server CSS byte-for-byte, so nothing visibly changes until a
34
+ * real edit lands.
24
35
  *
25
- * Distinguishes loading from empty: while the query is unresolved
26
- * (`undefined`) the server CSS stands; once it resolves to `null` (no active
27
- * theme) the override is cleared and `globals.css` shows through.
36
+ * Distinguishes loading from empty: while either query is unresolved the server
37
+ * CSS stands; once the reference resolves to nothing the override is cleared
38
+ * and `globals.css` shows through.
28
39
  *
29
40
  * @param props - Input props.
30
41
  * @param props.scope - Same contract as `<ThemeStyle />`: `"site"` emits
@@ -33,26 +44,58 @@ import { useEffect } from "react"
33
44
  */
34
45
  export function ThemeLive(props: { scope?: ThemeScope }) {
35
46
  const scope = props.scope ?? "site"
36
- const theme = useQuery(scope === "admin" ? api.theme.getAdmin : api.theme.getActive)
37
47
 
48
+ const { data: settings } = useLivePreviewDocumentQuery<typeof GLOBAL_SLUG_SITE_SETTINGS>(
49
+ convexQuery(api.siteSettings.get, {}) as never,
50
+ GLOBAL_SLUG_SITE_SETTINGS,
51
+ )
52
+
53
+ // `adminTheme ?? activeTheme` mirrors `theme.getAdmin`'s own fallback: the
54
+ // admin adopts the site's palette unless it opts out.
55
+ const reference = settings as SiteSettingsGlobal | undefined
56
+ const themeIds = (
57
+ scope === "admin" ? (reference?.adminTheme ?? reference?.activeTheme) : reference?.activeTheme
58
+ ) as string[] | undefined
59
+ const themeId = themeIds?.[0]
60
+
61
+ const { data: theme } = useQuery({
62
+ ...convexQuery(api.theme.byId, themeId ? { id: themeId } : "skip"),
63
+ enabled: Boolean(themeId),
64
+ })
65
+
66
+ // Owning the element in its own mount-scoped effect is load-bearing. Creating
67
+ // it inside the effect that writes the CSS meant every dependency change ran
68
+ // that effect's cleanup first — removing the element and exposing the server
69
+ // CSS underneath. Selecting a theme the page had not fetched yet left it
70
+ // removed for the length of the round trip, which is the flash back to the
71
+ // saved theme between edits; an already-cached theme resolved in the same
72
+ // tick, which is why it only happened the first time.
73
+ const styleElementRef = useRef<HTMLStyleElement | null>(null)
38
74
  useEffect(() => {
39
- if (theme === undefined) {return} // still loading — leave the server CSS alone
40
-
41
- const id = `vex-theme-live-${scope}`
42
- let el = document.getElementById(id) as HTMLStyleElement | null
43
- if (!el) {
44
- el = document.createElement("style")
45
- el.id = id
46
- document.head.appendChild(el)
47
- }
48
- el.textContent = theme ? buildThemeCss({ theme, scope }) : ""
75
+ const element = document.createElement("style")
76
+ element.id = `vex-theme-live-${scope}`
77
+ document.body.appendChild(element)
78
+ styleElementRef.current = element
49
79
 
50
80
  return () => {
51
81
  // Admin routes unmount their scope on navigation back to the site; the
52
82
  // override must not outlive the layout that owns it.
53
- el?.remove()
83
+ element.remove()
84
+ styleElementRef.current = null
54
85
  }
55
- }, [theme, scope])
86
+ }, [scope])
87
+
88
+ useEffect(() => {
89
+ const element = styleElementRef.current
90
+ if (!element) {return}
91
+
92
+ // Still resolving — hold whatever is already applied. Before first paint
93
+ // that is the server CSS; mid-preview it is the last previewed theme.
94
+ if (settings === undefined) {return}
95
+ if (themeId && theme === undefined) {return}
96
+
97
+ element.textContent = theme ? buildThemeCss({ theme: theme as Theme, scope }) : ""
98
+ }, [settings, theme, themeId, scope])
56
99
 
57
100
  return null
58
101
  }
@@ -14,8 +14,10 @@ import { vex } from "~/lib/vex"
14
14
  * `:root:root` is specificity (0,2,0) against `:root`'s (0,1,0), so the admin
15
15
  * block wins wherever both are present without depending on style-injection
16
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.**
17
+ * one block — and the admin block is deliberately *not* hoisted, so React tears
18
+ * it down with the admin layout instead of leaving it in `<head>` (see below).
19
+ * Leave `siteSettings.adminTheme` empty and `getAdmin` falls back to the site
20
+ * theme, which is the default: **the admin adopts the site's palette.**
19
21
  *
20
22
  * Values are written through verbatim. A `color()` field storing
21
23
  * `oklch(60.5% 0.175 42)` needs no conversion, because that is already the
@@ -48,12 +50,25 @@ export async function ThemeStyle(props: { scope?: ThemeScope }) {
48
50
  const css = buildThemeCss({ theme, scope })
49
51
  if (!css) {return null}
50
52
 
51
- // `precedence` opts into React 19 style hoisting, so this lands in <head>
52
- // before first paint instead of mid-body.
53
+ // Only the **site** block opts into React 19 style hoisting. A hoisted
54
+ // `<style>` (`href` + `precedence`) is a stylesheet *resource*: react-dom
55
+ // keeps it in `<head>` for the life of the document and merely decrements a
56
+ // refcount when the component that rendered it unmounts
57
+ // (`commitDeletionEffectsOnFiber`, fiber tag 26). That is correct for the
58
+ // site theme, which is document-wide — and wrong for the admin theme: the
59
+ // hoisted `:root:root` block outlived a client-side nav out of `/admin` and
60
+ // re-skinned the public site until a full reload. Rendering the admin block
61
+ // in place ties its lifetime to the admin layout, at the cost of nothing:
62
+ // it still streams ahead of any admin markup, and `:root:root` outranks the
63
+ // site block wherever both apply, whatever the document order.
64
+ if (scope === "admin") {
65
+ return <style dangerouslySetInnerHTML={{ __html: css }} data-vex-theme="admin" />
66
+ }
67
+
53
68
  return (
54
69
  <style
55
70
  dangerouslySetInnerHTML={{ __html: css }}
56
- href={`vex-theme-${scope}`}
71
+ href="vex-theme-site"
57
72
  precedence="high"
58
73
  />
59
74
  )
@@ -26,6 +26,12 @@ const vexConfig = defineConfig({
26
26
  sidebar: {
27
27
  side: "right",
28
28
  },
29
+ livePreview: {
30
+ allowedOrigins: [
31
+ "http://localhost:3000",
32
+ ...(process.env.NEXT_PUBLIC_SITE_URL ? [process.env.NEXT_PUBLIC_SITE_URL] : []),
33
+ ],
34
+ },
29
35
  },
30
36
  storage: {
31
37
  clientUploads: { convex: uploadFile },
@@ -42,12 +48,6 @@ const vexConfig = defineConfig({
42
48
  return path === undefined ? [] : [path]
43
49
  },
44
50
  },
45
- livePreview: {
46
- allowedOrigins: [
47
- "http://localhost:3000",
48
- ...(process.env.NEXT_PUBLIC_SITE_URL ? [process.env.NEXT_PUBLIC_SITE_URL] : []),
49
- ],
50
- },
51
51
  })
52
52
 
53
53
  export default vexConfig
@@ -7,7 +7,7 @@
7
7
  /* eslint-disable perfectionist/sort-object-types */
8
8
  /* eslint-disable @typescript-eslint/no-empty-object-type */
9
9
 
10
- import type { Id } from "@convex/_generated/dataModel"
10
+ import type { DataModel, Id } from "@convex/_generated/dataModel"
11
11
  import type { VexDocument, VexDocumentGlobal } from "@vexcms/core"
12
12
 
13
13
  export interface UserDocument extends VexDocument {
@@ -656,5 +656,6 @@ declare module "@vexcms/core" {
656
656
  user: "user"
657
657
  organization: never
658
658
  }
659
+ DataModel: DataModel
659
660
  }
660
661
  }
@@ -90,25 +90,25 @@ export const roadmapBlock = defineBlock({
90
90
  {
91
91
  feature: "Versioning & Drafts",
92
92
  description: "Draft/publish workflow with autosave and version history — in active development.",
93
- status: "coming-soon",
93
+ status: "planned",
94
94
  },
95
95
  {
96
96
  feature: "Live Preview",
97
97
  description:
98
98
  "Side-by-side preview of draft content against the real frontend before publishing — builds on the drafts infrastructure.",
99
- status: "coming-soon",
99
+ status: "planned",
100
100
  },
101
101
  {
102
102
  feature: "Form Builder & Lifecycle Hooks",
103
103
  description:
104
104
  "Composable form fields beyond content editing, plus beforeChange/afterChange hooks for custom side effects.",
105
- status: "coming-soon",
105
+ status: "planned",
106
106
  },
107
107
  {
108
108
  feature: "Field Input Consistency Pass",
109
109
  description:
110
110
  "Touch-ups across field inputs — starting with the relationship field — for consistent interaction patterns in the admin panel.",
111
- status: "coming-soon",
111
+ status: "planned",
112
112
  },
113
113
  {
114
114
  feature: "Richtext, JSON, Email & Textarea Fields",
@@ -8,6 +8,9 @@ export const headers = defineCollection({
8
8
  admin: {
9
9
  useAsTitle: "name",
10
10
  icon: "PanelTop",
11
+ livePreview: {
12
+ url: "/",
13
+ },
11
14
  },
12
15
  fields: {
13
16
  name: text({
@@ -1,6 +1,7 @@
1
- import type { DataModel } from "@convex/_generated/dataModel"
2
1
 
3
- import { blocks, defineCollection, text, textValidator, upload } from "@vexcms/core"
2
+ import { ConvexError } from "convex/values"
3
+
4
+ import { blocks, defineCollection, text, upload } from "@vexcms/core"
4
5
 
5
6
  import { resolvePagePath } from "~/lib/resolvePagePath"
6
7
  import { TABLE_SLUG_IMAGES, TABLE_SLUG_PAGES } from "~/db/constants"
@@ -21,23 +22,28 @@ export const pages = defineCollection({
21
22
  label: "Title",
22
23
  required: true,
23
24
  }),
24
- slug: text({
25
+ // One type argument — the collection's own slug — types `doc`, `value` and
26
+ // `ctx` (the project's `DataModel` arrives through the generated
27
+ // `@vexcms/core` augmentation).
28
+ slug: text<typeof TABLE_SLUG_PAGES>({
25
29
  description: "URL-friendly page path",
26
30
  index: "by_slug",
27
31
  label: "Slug",
28
32
  required: true,
29
- validate: textValidator<typeof TABLE_SLUG_PAGES, DataModel>(
30
- TABLE_SLUG_PAGES,
31
- async ({ value, doc, ctx }) => {
32
- const existing = await ctx.db
33
- .query("pages")
34
- .withIndex("by_slug", (q) => q.eq("slug", value))
35
- .first()
36
- if (existing && existing._id !== doc._id) {
37
- return `This slug is already in use by the page '${existing.title}'.`
38
- }
39
- },
40
- ),
33
+ validate: async ({ value, doc, ctx }) => {
34
+ const existing = await ctx.db
35
+ .query("pages")
36
+ .withIndex("by_slug", (q) => q.eq("slug", value))
37
+ .first()
38
+ if (existing && existing._id !== doc._id) {
39
+ // Rejection is a throw, so the payload can carry more than a message.
40
+ throw new ConvexError({
41
+ code: "SLUG_CONFLICT",
42
+ conflictingPageId: existing._id,
43
+ message: `This slug is already in use by the page '${existing.title}'.`,
44
+ })
45
+ }
46
+ },
41
47
  }),
42
48
  blocks: blocks({
43
49
  admin: {
@@ -1,17 +0,0 @@
1
- "use client"
2
-
3
- import { VexConfigProvider } from "@vexcms/react"
4
-
5
- import config from "~/vex.config"
6
-
7
- /**
8
- * Mounts the single `VexConfigProvider` for the admin panel's client tree.
9
- *
10
- * This is a "use client" component so `vex.config` is imported and owned on
11
- * the client — it never crosses the RSC boundary as a prop. `access` and
12
- * `storage.clientUploads` (the two fields that used to need their own
13
- * providers) are already part of the config this imports.
14
- */
15
- export function ClientProviders({ children }: { children: React.ReactNode }) {
16
- return <VexConfigProvider config={config}>{children}</VexConfigProvider>
17
- }
@@ -1,31 +0,0 @@
1
- "use client"
2
-
3
- import type { ReactNode } from "react"
4
-
5
- import { LivePreviewProvider } from "@vexcms/react"
6
-
7
- import config from "~/vex.config"
8
-
9
- /**
10
- * Mounts the live-preview listener for the public site's client tree.
11
- *
12
- * A `"use client"` component so `vex.config` is imported and owned on the
13
- * client — it never crosses the RSC boundary as a prop (ADR-013; the config's
14
- * `admin.livePreview.url` resolvers and field validators are functions, which
15
- * React refuses to serialize). Mirrors `admin/clientProviders.tsx`.
16
- *
17
- * `LivePreviewProvider` gates itself on `?vexLivePreview=1` plus the
18
- * session-verified `vex-live-preview` cookie `proxy.ts` sets, so wrapping the
19
- * layout unconditionally costs a disabled provider and nothing else — and
20
- * keeps every public route statically prerenderable.
21
- */
22
- export function SiteLivePreviewProvider({ children }: { children: ReactNode }) {
23
- return (
24
- <LivePreviewProvider
25
- allowedOrigins={config.livePreview.allowedOrigins}
26
- collections={config.collections}
27
- >
28
- {children}
29
- </LivePreviewProvider>
30
- )
31
- }