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 +1 -1
- package/templates/base-nextjs/convex/vex.ts +1 -1
- package/templates/base-nextjs/package.json +6 -6
- package/templates/base-nextjs/src/app/(vexcms)/admin/layout.tsx +3 -7
- package/templates/base-nextjs/src/components/providers/client.tsx +19 -1
- package/templates/base-nextjs/src/vex.types.ts +2 -1
- package/templates/marketing-site/convex/theme.ts +18 -0
- package/templates/marketing-site/src/app/(frontend)/(site)/layout.tsx +2 -3
- package/templates/marketing-site/src/app/(vexcms)/admin/layout.tsx +5 -7
- package/templates/marketing-site/src/app/layout.tsx +12 -3
- package/templates/marketing-site/src/components/SiteFooter.tsx +10 -6
- package/templates/marketing-site/src/components/SiteHeader.tsx +11 -6
- package/templates/marketing-site/src/components/ThemeLive.tsx +72 -29
- package/templates/marketing-site/src/components/ThemeStyle.tsx +20 -5
- package/templates/marketing-site/src/vex.config.ts +6 -6
- package/templates/marketing-site/src/vex.types.ts +2 -1
- package/templates/marketing-site/src/vexcms/blocks/Roadmap/config.ts +4 -4
- package/templates/marketing-site/src/vexcms/collections/headers.ts +3 -0
- package/templates/marketing-site/src/vexcms/collections/pages.ts +21 -15
- package/templates/base-nextjs/src/app/(vexcms)/admin/clientProviders.tsx +0 -17
- package/templates/marketing-site/src/app/(frontend)/(site)/SiteLivePreviewProvider.tsx +0 -31
package/package.json
CHANGED
|
@@ -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.
|
|
28
|
-
"@vexcms/core": "~0.1.0-alpha.
|
|
29
|
-
"@vexcms/file-storage-convex": "~0.1.0-alpha.
|
|
30
|
-
"@vexcms/next": "~0.1.0-alpha.
|
|
31
|
-
"@vexcms/react": "~0.1.0-alpha.
|
|
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.
|
|
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
|
-
<
|
|
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>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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 {
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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 {
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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 {
|
|
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
|
-
* `
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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>`
|
|
18
|
-
* rather than a React-hoisted one: hoisted styles are deduplicated by
|
|
19
|
-
* and are not guaranteed to update in place when their text changes
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
83
|
+
element.remove()
|
|
84
|
+
styleElementRef.current = null
|
|
54
85
|
}
|
|
55
|
-
}, [
|
|
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
|
|
18
|
-
*
|
|
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
|
-
//
|
|
52
|
-
//
|
|
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=
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
111
|
+
status: "planned",
|
|
112
112
|
},
|
|
113
113
|
{
|
|
114
114
|
feature: "Richtext, JSON, Email & Textarea Fields",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import type { DataModel } from "@convex/_generated/dataModel"
|
|
2
1
|
|
|
3
|
-
import {
|
|
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
|
|
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:
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
}
|