create-vexcms 0.0.8 → 0.0.9
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/globals.ts +82 -0
- package/templates/base-nextjs/convex/vex/versions.ts +66 -6
- package/templates/base-nextjs/package.json +7 -7
- package/templates/base-nextjs/src/app/admin/layout.tsx +29 -11
- package/templates/base-nextjs/src/db/constants/auth.ts +1 -3
- package/templates/base-nextjs/src/vexcms/access.ts +29 -13
- package/templates/base-nextjs/src/vexcms/collections/users.ts +2 -2
- package/templates/base-nextjs/vex.config.ts +2 -0
- package/templates/marketing-site/convex/footers.ts +14 -0
- package/templates/marketing-site/convex/headers.ts +14 -0
- package/templates/marketing-site/convex/siteSettings.ts +14 -0
- package/templates/marketing-site/convex/theme.ts +75 -0
- package/templates/marketing-site/public/favicons/favicon.ico +0 -0
- package/templates/marketing-site/src/app/(frontend)/PageContent.tsx +74 -0
- package/templates/marketing-site/src/app/(frontend)/[slug]/page.tsx +19 -51
- package/templates/marketing-site/src/app/(frontend)/layout.tsx +37 -0
- package/templates/marketing-site/src/app/(frontend)/page.tsx +24 -0
- package/templates/marketing-site/src/app/(frontend)/preview/[slug]/PreviewPageContent.tsx +55 -0
- package/templates/marketing-site/src/app/(frontend)/preview/[slug]/page.tsx +19 -63
- package/templates/marketing-site/src/app/(frontend)/preview/layout.tsx +18 -0
- package/templates/marketing-site/src/components/SiteFooter.tsx +28 -0
- package/templates/marketing-site/src/components/SiteHeader.tsx +28 -0
- package/templates/marketing-site/src/components/ThemeInjector.tsx +128 -0
- package/templates/marketing-site/src/components/ThemeStyle.tsx +104 -0
- package/templates/marketing-site/src/components/admin/IconPickerField.tsx +134 -0
- package/templates/marketing-site/src/components/motion-primitives/animated-group.tsx +138 -0
- package/templates/marketing-site/src/components/motion-primitives/text-effect.tsx +292 -0
- package/templates/marketing-site/src/lib/metadata.ts +104 -0
- package/templates/marketing-site/src/vexcms/access.ts +28 -0
- package/templates/marketing-site/src/vexcms/blocks/CTA/config.ts +36 -0
- package/templates/marketing-site/src/vexcms/blocks/CTA/index.tsx +46 -0
- package/templates/marketing-site/src/vexcms/blocks/FAQ/config.ts +70 -0
- package/templates/marketing-site/src/vexcms/blocks/FAQ/index.tsx +71 -0
- package/templates/marketing-site/src/vexcms/blocks/Features/config.ts +61 -0
- package/templates/marketing-site/src/vexcms/blocks/Features/index.tsx +73 -0
- package/templates/marketing-site/src/vexcms/blocks/Footer/config.ts +65 -0
- package/templates/marketing-site/src/vexcms/blocks/Footer/index.tsx +71 -0
- package/templates/marketing-site/src/vexcms/blocks/Header/config.ts +65 -0
- package/templates/marketing-site/src/vexcms/blocks/Header/index.tsx +174 -0
- package/templates/marketing-site/src/vexcms/blocks/Hero/config.ts +52 -0
- package/templates/marketing-site/src/vexcms/blocks/Hero/index.tsx +152 -0
- package/templates/marketing-site/src/vexcms/blocks/HowItWorks/config.ts +68 -0
- package/templates/marketing-site/src/vexcms/blocks/HowItWorks/index.tsx +74 -0
- package/templates/marketing-site/src/vexcms/blocks/Roadmap/config.ts +154 -0
- package/templates/marketing-site/src/vexcms/blocks/Roadmap/index.tsx +122 -0
- package/templates/marketing-site/src/vexcms/blocks/config.ts +40 -0
- package/templates/marketing-site/src/vexcms/blocks/constants.ts +9 -0
- package/templates/marketing-site/src/vexcms/blocks/index.ts +39 -0
- package/templates/marketing-site/src/vexcms/collections/footers.ts +11 -8
- package/templates/marketing-site/src/vexcms/collections/headers.ts +10 -14
- package/templates/marketing-site/src/vexcms/collections/index.ts +0 -1
- package/templates/marketing-site/src/vexcms/collections/pages.ts +30 -4
- package/templates/marketing-site/src/vexcms/collections/themes.ts +107 -14
- package/templates/marketing-site/src/vexcms/globals/index.ts +1 -0
- package/templates/marketing-site/src/vexcms/globals/siteSettings.ts +57 -0
- package/templates/marketing-site/vex.config.ts +5 -5
- package/templates/base-nextjs/src/auth/permissions.ts +0 -102
- package/templates/marketing-site/src/vexcms/collections/site_settings.ts +0 -31
package/package.json
CHANGED
|
@@ -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.
|
|
26
|
-
"@vexcms/better-auth": "~0.0.
|
|
27
|
-
"@vexcms/core": "~0.0.
|
|
28
|
-
"@vexcms/richtext": "~0.0.
|
|
29
|
-
"@vexcms/ui": "~0.0.
|
|
30
|
-
"@vexcms/file-storage-convex": "~0.0.
|
|
25
|
+
"@vexcms/admin-next": "~0.0.9",
|
|
26
|
+
"@vexcms/better-auth": "~0.0.9",
|
|
27
|
+
"@vexcms/core": "~0.0.9",
|
|
28
|
+
"@vexcms/richtext": "~0.0.9",
|
|
29
|
+
"@vexcms/ui": "~0.0.9",
|
|
30
|
+
"@vexcms/file-storage-convex": "~0.0.9",
|
|
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.
|
|
55
|
+
"@vexcms/cli": "~0.0.9",
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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={
|
|
44
|
+
user={{ name: user.name, avatar: user.image, email: user.email }}
|
|
27
45
|
permissionUser={permissionUser}
|
|
28
46
|
>
|
|
29
47
|
<SidebarTrigger />
|
|
@@ -1,28 +1,44 @@
|
|
|
1
1
|
import { defineAccess } from "@vexcms/core"
|
|
2
2
|
|
|
3
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
35
|
+
admin: false,
|
|
36
|
+
user: {
|
|
14
37
|
create: false,
|
|
15
38
|
delete: false,
|
|
16
|
-
read: ({ data
|
|
17
|
-
|
|
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
|
-
{
|
|
30
|
+
{ label: "Admin", value: "admin" },
|
|
31
31
|
{ label: "Editor", value: "editor" },
|
|
32
|
-
{
|
|
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
|
+
})
|
|
Binary file
|
|
@@ -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 “home” 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 “{slug}” doesn't exist or hasn'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
|
+
← 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 {
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
_vexDrafts: false,
|
|
21
|
-
})
|
|
4
|
+
import { generatePageMetadata } from "~/lib/metadata"
|
|
5
|
+
import { PageContent } from "../PageContent"
|
|
22
6
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
}
|