create-vexcms 0.0.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.
Files changed (63) hide show
  1. package/package.json +1 -1
  2. package/templates/base-nextjs/convex/vex/globals.ts +82 -0
  3. package/templates/base-nextjs/convex/vex/versions.ts +66 -6
  4. package/templates/base-nextjs/package.json +7 -7
  5. package/templates/base-nextjs/src/app/admin/layout.tsx +29 -11
  6. package/templates/base-nextjs/src/db/constants/auth.ts +1 -3
  7. package/templates/base-nextjs/src/vexcms/access.ts +29 -13
  8. package/templates/base-nextjs/src/vexcms/collections/users.ts +2 -2
  9. package/templates/base-nextjs/vex.config.ts +2 -0
  10. package/templates/marketing-site/convex/footers.ts +14 -0
  11. package/templates/marketing-site/convex/headers.ts +14 -0
  12. package/templates/marketing-site/convex/siteSettings.ts +14 -0
  13. package/templates/marketing-site/convex/theme.ts +75 -0
  14. package/templates/marketing-site/public/favicons/favicon.ico +0 -0
  15. package/templates/marketing-site/src/app/(frontend)/PageContent.tsx +74 -0
  16. package/templates/marketing-site/src/app/(frontend)/[slug]/page.tsx +19 -51
  17. package/templates/marketing-site/src/app/(frontend)/layout.tsx +37 -0
  18. package/templates/marketing-site/src/app/(frontend)/page.tsx +24 -0
  19. package/templates/marketing-site/src/app/(frontend)/preview/[slug]/PreviewPageContent.tsx +55 -0
  20. package/templates/marketing-site/src/app/(frontend)/preview/[slug]/page.tsx +19 -63
  21. package/templates/marketing-site/src/app/(frontend)/preview/layout.tsx +18 -0
  22. package/templates/marketing-site/src/components/SiteFooter.tsx +28 -0
  23. package/templates/marketing-site/src/components/SiteHeader.tsx +28 -0
  24. package/templates/marketing-site/src/components/ThemeInjector.tsx +128 -0
  25. package/templates/marketing-site/src/components/ThemeStyle.tsx +104 -0
  26. package/templates/marketing-site/src/components/admin/IconPickerField.tsx +134 -0
  27. package/templates/marketing-site/src/components/admin/ThemeImport.tsx +174 -0
  28. package/templates/marketing-site/src/components/admin/ThemeImportField.tsx +33 -0
  29. package/templates/marketing-site/src/components/motion-primitives/animated-group.tsx +138 -0
  30. package/templates/marketing-site/src/components/motion-primitives/text-effect.tsx +292 -0
  31. package/templates/marketing-site/src/components/ui/accordion.tsx +74 -0
  32. package/templates/marketing-site/src/lib/colorConvert.ts +171 -0
  33. package/templates/marketing-site/src/lib/metadata.ts +104 -0
  34. package/templates/marketing-site/src/vexcms/access.ts +28 -0
  35. package/templates/marketing-site/src/vexcms/blocks/CTA/config.ts +36 -0
  36. package/templates/marketing-site/src/vexcms/blocks/CTA/index.tsx +46 -0
  37. package/templates/marketing-site/src/vexcms/blocks/FAQ/config.ts +70 -0
  38. package/templates/marketing-site/src/vexcms/blocks/FAQ/index.tsx +71 -0
  39. package/templates/marketing-site/src/vexcms/blocks/Features/config.ts +61 -0
  40. package/templates/marketing-site/src/vexcms/blocks/Features/index.tsx +73 -0
  41. package/templates/marketing-site/src/vexcms/blocks/Footer/config.ts +65 -0
  42. package/templates/marketing-site/src/vexcms/blocks/Footer/index.tsx +71 -0
  43. package/templates/marketing-site/src/vexcms/blocks/Header/config.ts +65 -0
  44. package/templates/marketing-site/src/vexcms/blocks/Header/index.tsx +174 -0
  45. package/templates/marketing-site/src/vexcms/blocks/Hero/config.ts +52 -0
  46. package/templates/marketing-site/src/vexcms/blocks/Hero/index.tsx +152 -0
  47. package/templates/marketing-site/src/vexcms/blocks/HowItWorks/config.ts +68 -0
  48. package/templates/marketing-site/src/vexcms/blocks/HowItWorks/index.tsx +74 -0
  49. package/templates/marketing-site/src/vexcms/blocks/Roadmap/config.ts +154 -0
  50. package/templates/marketing-site/src/vexcms/blocks/Roadmap/index.tsx +122 -0
  51. package/templates/marketing-site/src/vexcms/blocks/config.ts +40 -0
  52. package/templates/marketing-site/src/vexcms/blocks/constants.ts +9 -0
  53. package/templates/marketing-site/src/vexcms/blocks/index.ts +39 -0
  54. package/templates/marketing-site/src/vexcms/collections/footers.ts +11 -8
  55. package/templates/marketing-site/src/vexcms/collections/headers.ts +10 -14
  56. package/templates/marketing-site/src/vexcms/collections/index.ts +0 -1
  57. package/templates/marketing-site/src/vexcms/collections/pages.ts +30 -4
  58. package/templates/marketing-site/src/vexcms/collections/themes.ts +107 -14
  59. package/templates/marketing-site/src/vexcms/globals/index.ts +1 -0
  60. package/templates/marketing-site/src/vexcms/globals/siteSettings.ts +57 -0
  61. package/templates/marketing-site/vex.config.ts +5 -5
  62. package/templates/base-nextjs/src/auth/permissions.ts +0 -102
  63. package/templates/marketing-site/src/vexcms/collections/site_settings.ts +0 -31
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-vexcms",
3
- "version": "0.0.8",
3
+ "version": "0.0.10",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "create-vexcms": "./dist/index.js"
@@ -0,0 +1,82 @@
1
+ import type { DataModel } from "@convex/_generated/dataModel"
2
+ import type { TableNamesInDataModel } from "convex/server"
3
+
4
+ import { query } from "@convex/_generated/server"
5
+ import { getPreviewSnapshot } from "@vexcms/core"
6
+ import { v } from "convex/values"
7
+
8
+ import config from "../../vex.config"
9
+ import * as Versions from "./model/versions"
10
+
11
+ /**
12
+ * Get the single document for a global, with draft awareness.
13
+ *
14
+ * - No _vexDrafts or _vexDrafts: false → returns the published document
15
+ * - _vexDrafts: true → returns the latest version (draft or published)
16
+ * - _vexDrafts: "snapshot" → overlays the preview snapshot on the document
17
+ */
18
+ export const get = query({
19
+ args: {
20
+ globalSlug: v.string(),
21
+ _vexDrafts: v.optional(v.union(v.literal("snapshot"), v.boolean())),
22
+ },
23
+ handler: async (ctx, { globalSlug, _vexDrafts }) => {
24
+ const doc = await ctx.db
25
+ .query(globalSlug as TableNamesInDataModel<DataModel>)
26
+ .first()
27
+
28
+ if (!doc) return null
29
+
30
+ const documentId = doc._id as string
31
+
32
+ // Check if this global has versioning enabled
33
+ const globalDef = config.globals?.find((g) => g.slug === globalSlug)
34
+ const isVersioned = !!globalDef?.versions?.drafts
35
+
36
+ // If draft mode is "snapshot", overlay the preview snapshot
37
+ if (_vexDrafts === "snapshot") {
38
+ const snapshot = await getPreviewSnapshot<DataModel>({
39
+ ctx,
40
+ collection: globalSlug,
41
+ documentId,
42
+ })
43
+
44
+ if (snapshot) {
45
+ return {
46
+ ...doc,
47
+ ...(snapshot as Record<string, unknown>),
48
+ _id: doc._id,
49
+ _creationTime: doc._creationTime,
50
+ }
51
+ }
52
+ }
53
+
54
+ // If versioned and draft-aware, return the latest version's snapshot
55
+ if (isVersioned && _vexDrafts !== false) {
56
+ const latestVersion = await Versions.getLatestVersion<DataModel>({
57
+ ctx,
58
+ collection: globalSlug,
59
+ documentId,
60
+ })
61
+
62
+ if (latestVersion) {
63
+ return {
64
+ _id: doc._id,
65
+ ...(latestVersion.snapshot as Record<string, unknown>),
66
+ vex_status: (latestVersion as any).status ?? (doc as any).vex_status ?? "draft",
67
+ vex_version: latestVersion.version,
68
+ vex_publishedAt: (doc as any).vex_publishedAt,
69
+ _creationTime: doc._creationTime,
70
+ }
71
+ }
72
+
73
+ // No versions yet — treat as published
74
+ const raw = doc as Record<string, unknown>
75
+ if (!raw.vex_status) {
76
+ raw.vex_status = "published"
77
+ }
78
+ }
79
+
80
+ return doc
81
+ },
82
+ })
@@ -16,8 +16,8 @@ import { access } from "../../src/vexcms/access"
16
16
  import * as Versions from "./model/versions"
17
17
 
18
18
  function requireVersionedCollection(slug: string) {
19
- const collections = [...config.collections, ...(config.media?.collections ?? [])]
20
- const match = collections.find((c) => c.slug === slug)
19
+ const collections = [...config.collections, ...(config.media?.collections ?? []), ...(config.globals ?? [])]
20
+ const match = collections.find((c: any) => c.slug === slug)
21
21
  if (!match) {
22
22
  throw new ConvexError(`Collection not found: ${slug}`)
23
23
  }
@@ -503,11 +503,19 @@ export const getDocumentForEdit = query({
503
503
  documentId: v.string(),
504
504
  },
505
505
  handler: async (ctx, { collectionSlug, documentId }) => {
506
- requireVersionedCollection(collectionSlug)
507
-
508
506
  const mainDoc = await ctx.db.get(documentId as any)
509
507
  if (!mainDoc) return null
510
508
 
509
+ // Check if this collection/global actually has versioning enabled in config.
510
+ // If not (e.g. config not yet redeployed), just return the raw document.
511
+ const collections = [...config.collections, ...(config.media?.collections ?? []), ...(config.globals ?? [])]
512
+ const match = collections.find((c: any) => c.slug === collectionSlug)
513
+ if (!match?.versions?.drafts) {
514
+ const doc = mainDoc as Record<string, unknown>
515
+ if (!doc.vex_status) doc.vex_status = "published"
516
+ return doc
517
+ }
518
+
511
519
  const latestVersion = await Versions.getLatestVersion<DataModel>({
512
520
  ctx,
513
521
  collection: collectionSlug,
@@ -518,8 +526,6 @@ export const getDocumentForEdit = query({
518
526
  return {
519
527
  _id: mainDoc._id,
520
528
  ...(latestVersion.snapshot as Record<string, unknown>),
521
- // Use the latest version's status, not the main doc's — the main doc
522
- // only updates on publish, but the latest version may be a draft
523
529
  vex_status: (latestVersion as any).status ?? (mainDoc as any).vex_status ?? "draft",
524
530
  vex_version: latestVersion.version,
525
531
  vex_publishedAt: (mainDoc as any).vex_publishedAt,
@@ -537,6 +543,60 @@ export const getDocumentForEdit = query({
537
543
  },
538
544
  })
539
545
 
546
+ /**
547
+ * Creates an initial version entry for a document that has none.
548
+ * Called by the admin edit view when it detects a versioned document
549
+ * without any version records (e.g., versioning was just enabled on
550
+ * an existing collection).
551
+ *
552
+ * Preserves the document's current status — if vex_status is "published",
553
+ * the version is created as published. Otherwise defaults to "draft".
554
+ *
555
+ * No-ops if the document already has version entries.
556
+ */
557
+ export const initializeVersion = mutation({
558
+ args: {
559
+ collectionSlug: v.string(),
560
+ documentId: v.string(),
561
+ },
562
+ handler: async (ctx, { collectionSlug, documentId }) => {
563
+ const mainDoc = await ctx.db.get(documentId as any)
564
+ if (!mainDoc) return null
565
+
566
+ // Check if versions already exist
567
+ const existing = await Versions.getLatestVersion<DataModel>({
568
+ ctx,
569
+ collection: collectionSlug,
570
+ documentId,
571
+ })
572
+ if (existing) return existing
573
+
574
+ // Build snapshot from the main document's current fields
575
+ const { _id, _creationTime, ...snapshot } = mainDoc as Record<string, unknown>
576
+
577
+ // Determine status from the document's current state
578
+ const status = (snapshot.vex_status as string) === "published" ? "published" : "draft"
579
+
580
+ // Ensure the main doc has vex_status set
581
+ if (!snapshot.vex_status) {
582
+ await ctx.db.patch(documentId as any, { vex_status: "published" } as any)
583
+ snapshot.vex_status = "published"
584
+ }
585
+
586
+ // Create the initial version
587
+ const versionId = await Versions.createVersion<DataModel>({
588
+ ctx,
589
+ collection: collectionSlug,
590
+ documentId,
591
+ snapshot,
592
+ status: status as "draft" | "published",
593
+ createdBy: null,
594
+ })
595
+
596
+ return versionId
597
+ },
598
+ })
599
+
540
600
  /**
541
601
  * Backfills vex_status on existing documents in a versioned collection
542
602
  * that don't have the field yet (e.g. versioning was just enabled on
@@ -22,12 +22,12 @@
22
22
  "@t3-oss/env-nextjs": "^0.13.10",
23
23
  "@tanstack/react-form": "^1.27.7",
24
24
  "@tanstack/react-query": "^5.90.17",
25
- "@vexcms/admin-next": "~0.0.8",
26
- "@vexcms/better-auth": "~0.0.8",
27
- "@vexcms/core": "~0.0.8",
28
- "@vexcms/richtext": "~0.0.8",
29
- "@vexcms/ui": "~0.0.8",
30
- "@vexcms/file-storage-convex": "~0.0.8",
25
+ "@vexcms/admin-next": "~0.0.10",
26
+ "@vexcms/better-auth": "~0.0.10",
27
+ "@vexcms/core": "~0.0.10",
28
+ "@vexcms/richtext": "~0.0.10",
29
+ "@vexcms/ui": "~0.0.10",
30
+ "@vexcms/file-storage-convex": "~0.0.10",
31
31
  "better-auth": ">=1.4.9 <1.5.0",
32
32
  "class-variance-authority": "^0.7.1",
33
33
  "clsx": "^2.1.1",
@@ -52,7 +52,7 @@
52
52
  "@types/node": "^20",
53
53
  "@types/react": "^19",
54
54
  "@types/react-dom": "^19",
55
- "@vexcms/cli": "~0.0.8",
55
+ "@vexcms/cli": "~0.0.10",
56
56
  "babel-plugin-react-compiler": "^1.0.0",
57
57
  "eslint": "^9",
58
58
  "eslint-config-next": "15.5.9",
@@ -1,8 +1,10 @@
1
1
  import { SidebarTrigger } from "@vexcms/ui"
2
- import { sanitizeConfigForClient } from "@vexcms/core"
2
+ import { checkAdminAccess, sanitizeConfigForClient } from "@vexcms/core"
3
+ import { redirect } from "next/navigation"
3
4
 
4
5
  import config from "~/../vex.config"
5
6
  import { getCurrentUser } from "~/auth/serverUtils"
7
+ import { access } from "~/vexcms/access"
6
8
  import { AdminLayoutWrapper } from "./AdminLayoutWrapper"
7
9
 
8
10
  const clientConfig = sanitizeConfigForClient(config)
@@ -10,20 +12,36 @@ const clientConfig = sanitizeConfigForClient(config)
10
12
  export default async function AdminLayout({ children }: { children: React.ReactNode }) {
11
13
  const user = await getCurrentUser()
12
14
 
13
- const permissionUser = user
14
- ? {
15
- id: user.id,
16
- roles: Array.isArray(user.role) ? user.role : [user.role].filter(Boolean),
17
- name: user.name,
18
- email: user.email,
19
- avatar: user.image,
20
- }
21
- : undefined
15
+ // No user — redirect to sign in
16
+ if (!user) {
17
+ redirect("/auth/sign-in")
18
+ }
19
+
20
+ const userRoles = Array.isArray(user.role) ? user.role : [user.role].filter(Boolean)
21
+
22
+ // Check admin panel access
23
+ const hasAccess = checkAdminAccess({
24
+ access,
25
+ user: user as Record<string, any>,
26
+ userRoles,
27
+ })
28
+
29
+ if (!hasAccess) {
30
+ redirect("/?error=unauthorized")
31
+ }
32
+
33
+ const permissionUser = {
34
+ id: user.id,
35
+ roles: userRoles,
36
+ name: user.name,
37
+ email: user.email,
38
+ avatar: user.image,
39
+ }
22
40
 
23
41
  return (
24
42
  <AdminLayoutWrapper
25
43
  config={clientConfig}
26
- user={user ? { name: user.name, avatar: user.image, email: user.email } : undefined}
44
+ user={{ name: user.name, avatar: user.image, email: user.email }}
27
45
  permissionUser={permissionUser}
28
46
  >
29
47
  <SidebarTrigger />
@@ -1,8 +1,6 @@
1
1
  export const USER_ROLES = {
2
2
  admin: "admin",
3
- user: "user",
4
3
  editor: "editor",
5
- member: "member",
6
- author: "author",
4
+ user: "user",
7
5
  } as const
8
6
  export type UserRole = (typeof USER_ROLES)[keyof typeof USER_ROLES]
@@ -1,28 +1,44 @@
1
1
  import { defineAccess } from "@vexcms/core"
2
2
 
3
- import { TABLE_SLUG_USERS, USER_ROLES } from "~/db/constants"
4
- import { users } from "~/vexcms/collections"
3
+ import { USER_ROLES } from "~/db/constants"
4
+ import { users } from "~/vexcms/collections/users"
5
5
 
6
+ /**
7
+ * Access control configuration for the admin panel.
8
+ *
9
+ * - `admin` on each role controls whether that role can access the admin panel.
10
+ * - Resource permissions control CRUD access per collection.
11
+ * - Add your own collections to `resources` for autocomplete in `permissions`.
12
+ *
13
+ * @see https://vexcms.dev/docs/access-control
14
+ */
6
15
  export const access = defineAccess({
16
+ roles: [USER_ROLES.admin, USER_ROLES.editor, USER_ROLES.user],
17
+ adminRoles: [USER_ROLES.admin],
18
+ resources: [users],
19
+ userCollection: users,
7
20
  permissions: {
8
21
  admin: {
9
- // Admins have full access to all resources
22
+ admin: true,
10
23
  user: true,
11
24
  },
25
+ editor: {
26
+ admin: true,
27
+ user: {
28
+ create: false,
29
+ delete: false,
30
+ read: true,
31
+ update: false,
32
+ },
33
+ },
12
34
  user: {
13
- [TABLE_SLUG_USERS]: {
35
+ admin: false,
36
+ user: {
14
37
  create: false,
15
38
  delete: false,
16
- read: ({ data: targetUser, user }) => {
17
- return targetUser._id === user._id
18
- },
19
- update: ({ data: targetUser, user }) => {
20
- return targetUser._id === user._id
21
- },
39
+ read: ({ data, user }) => data._id === user._id,
40
+ update: ({ data, user }) => data._id === user._id,
22
41
  },
23
42
  },
24
43
  },
25
- resources: [users],
26
- roles: [USER_ROLES.user, USER_ROLES.admin],
27
- userCollection: users,
28
44
  })
@@ -27,9 +27,9 @@ export const users = defineCollection({
27
27
  singular: "Role",
28
28
  },
29
29
  options: [
30
- { badgeColor: "#ff0000", label: "Admin", value: "admin" },
30
+ { label: "Admin", value: "admin" },
31
31
  { label: "Editor", value: "editor" },
32
- { badgeColor: "#333333", label: "User", value: "user" },
32
+ { label: "User", value: "user" },
33
33
  ],
34
34
  required: true,
35
35
  }),
@@ -2,8 +2,10 @@ import { defineConfig } from "@vexcms/core"
2
2
  import { convexFileStorage } from "@vexcms/file-storage-convex"
3
3
 
4
4
  import { auth } from "~/vexcms/auth"
5
+ import { access } from "~/vexcms/access"
5
6
 
6
7
  export default defineConfig({
8
+ access,
7
9
  admin: {
8
10
  meta: {
9
11
  titleSuffix: " | Admin",
@@ -0,0 +1,14 @@
1
+ import { query } from "./_generated/server"
2
+
3
+ /**
4
+ * Get the first published footer.
5
+ * Used by the public site layout for server-side preloading.
6
+ */
7
+ export const getFirst = query({
8
+ args: {},
9
+ handler: async (ctx) => {
10
+ const footer = await ctx.db.query("footers").first()
11
+ if (!footer || footer.vex_status === "draft") return null
12
+ return footer
13
+ },
14
+ })
@@ -0,0 +1,14 @@
1
+ import { query } from "./_generated/server"
2
+
3
+ /**
4
+ * Get the first published header.
5
+ * Used by the public site layout for server-side preloading.
6
+ */
7
+ export const getFirst = query({
8
+ args: {},
9
+ handler: async (ctx) => {
10
+ const header = await ctx.db.query("headers").first()
11
+ if (!header || header.vex_status === "draft") return null
12
+ return header
13
+ },
14
+ })
@@ -0,0 +1,14 @@
1
+ import { query } from "./_generated/server"
2
+
3
+ /**
4
+ * Get the published site settings.
5
+ * Used by server components for SEO metadata generation.
6
+ */
7
+ export const get = query({
8
+ args: {},
9
+ handler: async (ctx) => {
10
+ const settings = await ctx.db.query("site_settings").first()
11
+ if (!settings) return null
12
+ return settings
13
+ },
14
+ })
@@ -0,0 +1,75 @@
1
+ import { getPreviewSnapshot } from "@vexcms/core"
2
+
3
+ import { query } from "./_generated/server"
4
+ import * as Versions from "./vex/model/versions"
5
+
6
+ /**
7
+ * Get the active theme from site settings (published).
8
+ * Returns the full theme document or null if no active theme is set.
9
+ * Used for server-side CSS variable injection on public pages.
10
+ */
11
+ export const getActive = query({
12
+ args: {},
13
+ handler: async (ctx) => {
14
+ const settings = await ctx.db.query("site_settings").first()
15
+ if (!settings?.activeTheme) return null
16
+
17
+ const theme = await ctx.db.get(settings.activeTheme)
18
+ if (!theme) return null
19
+
20
+ // Only return published themes
21
+ if (theme.vex_status === "draft") return null
22
+
23
+ return theme
24
+ },
25
+ })
26
+
27
+ /**
28
+ * Get the active theme for preview/draft mode.
29
+ * Reads site settings with preview snapshot overlay to get the draft activeTheme,
30
+ * then fetches the latest version of that theme (draft or published).
31
+ * Used for server-side CSS variable injection on preview pages.
32
+ */
33
+ export const getActivePreview = query({
34
+ args: {},
35
+ handler: async (ctx) => {
36
+ // Get site settings, overlaying any preview snapshot
37
+ const settings = await ctx.db.query("site_settings").first()
38
+ if (!settings) return null
39
+
40
+ const settingsId = settings._id as string
41
+
42
+ // Check for a preview snapshot on site_settings
43
+ const snapshot = await getPreviewSnapshot({
44
+ ctx,
45
+ collection: "site_settings",
46
+ documentId: settingsId,
47
+ })
48
+
49
+ const merged = snapshot
50
+ ? { ...settings, ...(snapshot as Record<string, unknown>) }
51
+ : settings
52
+
53
+ const activeThemeId = (merged as any).activeTheme as string | undefined
54
+ if (!activeThemeId) return null
55
+
56
+ // Get the theme — prefer the latest draft version
57
+ const theme = await ctx.db.get(activeThemeId as any)
58
+ if (!theme) return null
59
+
60
+ const latestVersion = await Versions.getLatestVersion({
61
+ ctx,
62
+ collection: "themes",
63
+ documentId: activeThemeId,
64
+ })
65
+
66
+ if (latestVersion?.snapshot) {
67
+ return {
68
+ ...theme,
69
+ ...(latestVersion.snapshot as Record<string, unknown>),
70
+ }
71
+ }
72
+
73
+ return theme
74
+ },
75
+ })
@@ -0,0 +1,74 @@
1
+ "use client"
2
+
3
+ import { RenderBlocks } from "@vexcms/ui"
4
+ import { convexQuery } from "@convex-dev/react-query"
5
+ import { useQuery } from "@tanstack/react-query"
6
+ import Link from "next/link"
7
+ import { anyApi } from "convex/server"
8
+
9
+ import { blockComponents } from "~/vexcms/blocks"
10
+
11
+ /**
12
+ * Client component that renders a page using TanStack Query + convexQuery.
13
+ * When initialData is provided (from server fetchQuery), renders immediately.
14
+ * The reactive subscription keeps data up-to-date after hydration.
15
+ */
16
+ export function PageContent({
17
+ slug,
18
+ initialData,
19
+ }: {
20
+ slug?: string
21
+ initialData?: Record<string, unknown> | null
22
+ }) {
23
+ const { data: page, isPending } = useQuery({
24
+ ...convexQuery(anyApi.pages.getBySlug, {
25
+ slug: slug ?? "home",
26
+ _vexDrafts: false,
27
+ }),
28
+ initialData: initialData ?? undefined,
29
+ })
30
+
31
+ if (isPending && initialData === undefined) {
32
+ return (
33
+ <div className="flex min-h-screen items-center justify-center">
34
+ <p className="text-muted-foreground">Loading...</p>
35
+ </div>
36
+ )
37
+ }
38
+
39
+ if (!page) {
40
+ if (!slug || slug === "home") {
41
+ return (
42
+ <div className="flex min-h-screen flex-col items-center justify-center gap-4 p-8">
43
+ <h1 className="text-4xl font-bold tracking-tight">Vex CMS</h1>
44
+ <p className="text-lg text-muted-foreground">
45
+ Create a page with slug &ldquo;home&rdquo; to get started.
46
+ </p>
47
+ </div>
48
+ )
49
+ }
50
+
51
+ return (
52
+ <div className="mx-auto max-w-3xl px-4 py-12">
53
+ <h1 className="text-2xl font-bold">Page not found</h1>
54
+ <p className="mt-2 text-muted-foreground">
55
+ The page &ldquo;{slug}&rdquo; doesn&apos;t exist or hasn&apos;t been
56
+ published yet.
57
+ </p>
58
+ <Link
59
+ className="mt-4 inline-block text-sm text-primary hover:underline"
60
+ href="/"
61
+ >
62
+ &larr; Back to home
63
+ </Link>
64
+ </div>
65
+ )
66
+ }
67
+
68
+ return (
69
+ <RenderBlocks
70
+ blocks={page.content as any}
71
+ components={blockComponents}
72
+ />
73
+ )
74
+ }
@@ -1,58 +1,26 @@
1
- "use client"
2
-
3
- import type { RichTextDocument } from "@vexcms/core"
4
-
5
1
  import { api } from "@convex/_generated/api"
6
- import { RichText } from "@vexcms/richtext/render"
7
- import { useQuery } from "convex/react"
8
- import Link from "next/link"
9
- import { useParams } from "next/navigation"
10
-
11
- /**
12
- * Public page route — renders published pages by slug.
13
- * Passes _vexDrafts: false to only get published content.
14
- */
15
- export default function PublicPage() {
16
- const { slug } = useParams<{ slug: string }>()
2
+ import { fetchQuery } from "convex/nextjs"
17
3
 
18
- const page = useQuery(api.pages.getBySlug, {
19
- slug,
20
- _vexDrafts: false,
21
- })
4
+ import { generatePageMetadata } from "~/lib/metadata"
5
+ import { PageContent } from "../PageContent"
22
6
 
23
- if (page === undefined) {
24
- return (
25
- <div className="mx-auto max-w-3xl px-4 py-12">
26
- <p className="text-muted-foreground">Loading…</p>
27
- </div>
28
- )
29
- }
7
+ export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
8
+ const { slug } = await params
9
+ return generatePageMetadata({ slug })
10
+ }
30
11
 
31
- if (page === null) {
32
- return (
33
- <div className="mx-auto max-w-3xl px-4 py-12">
34
- <h1 className="text-2xl font-bold">Page not found</h1>
35
- <p className="mt-2 text-muted-foreground">
36
- The page &ldquo;{slug}&rdquo; doesn&apos;t exist or hasn&apos;t been published yet.
37
- </p>
38
- <Link className="mt-4 inline-block text-sm text-primary hover:underline" href="/">
39
- ← Back to home
40
- </Link>
41
- </div>
42
- )
12
+ export default async function PublicPage({ params }: { params: Promise<{ slug: string }> }) {
13
+ const { slug } = await params
14
+
15
+ let initialData: null | Record<string, unknown> = null
16
+ try {
17
+ initialData = await fetchQuery(api.pages.getBySlug, {
18
+ slug,
19
+ _vexDrafts: false,
20
+ })
21
+ } catch {
22
+ // Fall back to client-only fetch
43
23
  }
44
24
 
45
- return (
46
- <div className="mx-auto max-w-3xl px-4 py-12">
47
- <h1 className="text-3xl font-bold mb-6">{page.title ?? "Untitled"}</h1>
48
-
49
- {page.content ? (
50
- <div className="prose prose-lg max-w-none">
51
- <RichText content={page.content as RichTextDocument} />
52
- </div>
53
- ) : (
54
- <p className="text-muted-foreground">This page has no content yet.</p>
55
- )}
56
- </div>
57
- )
25
+ return <PageContent initialData={initialData} slug={slug} />
58
26
  }