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.
- 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/admin/ThemeImport.tsx +174 -0
- package/templates/marketing-site/src/components/admin/ThemeImportField.tsx +33 -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/components/ui/accordion.tsx +74 -0
- package/templates/marketing-site/src/lib/colorConvert.ts +171 -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
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// @ts-expect-error culori/fn lacks type declarations in v4
|
|
2
|
+
import { parse, formatHex, formatHsl, converter } from "culori/fn"
|
|
3
|
+
|
|
4
|
+
const toOklch = converter("oklch")
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Convert a color string to the specified format.
|
|
8
|
+
*
|
|
9
|
+
* @param props.color - Input color in any CSS-parseable format
|
|
10
|
+
* @param props.format - Target format: "hex", "hsl", or "oklch"
|
|
11
|
+
* @returns Formatted color string, or the original if parsing fails
|
|
12
|
+
*/
|
|
13
|
+
export function convertColor(props: {
|
|
14
|
+
color: string
|
|
15
|
+
format: "hex" | "hsl" | "oklch"
|
|
16
|
+
}): string {
|
|
17
|
+
if (!props.color || props.color.startsWith("var(")) return props.color
|
|
18
|
+
|
|
19
|
+
const parsed = parse(props.color)
|
|
20
|
+
if (!parsed) return props.color
|
|
21
|
+
|
|
22
|
+
switch (props.format) {
|
|
23
|
+
case "hex":
|
|
24
|
+
return formatHex(parsed) ?? props.color
|
|
25
|
+
case "hsl":
|
|
26
|
+
return formatHsl(parsed) ?? props.color
|
|
27
|
+
case "oklch": {
|
|
28
|
+
const oklch = toOklch(parsed)
|
|
29
|
+
if (!oklch) return props.color
|
|
30
|
+
const l = (oklch.l ?? 0).toFixed(3)
|
|
31
|
+
const c = (oklch.c ?? 0).toFixed(3)
|
|
32
|
+
const h = (oklch.h ?? 0).toFixed(1)
|
|
33
|
+
return `oklch(${l} ${c} ${h})`
|
|
34
|
+
}
|
|
35
|
+
default:
|
|
36
|
+
return props.color
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Known CSS custom property names that represent colors.
|
|
42
|
+
* These are the standard shadcn/Tailwind CSS variables.
|
|
43
|
+
*/
|
|
44
|
+
const COLOR_VAR_NAMES = [
|
|
45
|
+
"background",
|
|
46
|
+
"foreground",
|
|
47
|
+
"card",
|
|
48
|
+
"card-foreground",
|
|
49
|
+
"popover",
|
|
50
|
+
"popover-foreground",
|
|
51
|
+
"primary",
|
|
52
|
+
"primary-foreground",
|
|
53
|
+
"secondary",
|
|
54
|
+
"secondary-foreground",
|
|
55
|
+
"muted",
|
|
56
|
+
"muted-foreground",
|
|
57
|
+
"accent",
|
|
58
|
+
"accent-foreground",
|
|
59
|
+
"destructive",
|
|
60
|
+
"destructive-foreground",
|
|
61
|
+
"border",
|
|
62
|
+
"input",
|
|
63
|
+
"ring",
|
|
64
|
+
"chart-1",
|
|
65
|
+
"chart-2",
|
|
66
|
+
"chart-3",
|
|
67
|
+
"chart-4",
|
|
68
|
+
"chart-5",
|
|
69
|
+
"sidebar",
|
|
70
|
+
"sidebar-foreground",
|
|
71
|
+
"sidebar-primary",
|
|
72
|
+
"sidebar-primary-foreground",
|
|
73
|
+
"sidebar-accent",
|
|
74
|
+
"sidebar-accent-foreground",
|
|
75
|
+
"sidebar-border",
|
|
76
|
+
"sidebar-ring",
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Extract color CSS custom properties from the document's stylesheets.
|
|
81
|
+
* Returns light and dark values for each known color variable.
|
|
82
|
+
*/
|
|
83
|
+
export function getThemeColorsFromDOM(): {
|
|
84
|
+
name: string
|
|
85
|
+
lightValue: string
|
|
86
|
+
darkValue: string | null
|
|
87
|
+
}[] {
|
|
88
|
+
if (typeof document === "undefined") return []
|
|
89
|
+
|
|
90
|
+
const results: { name: string; lightValue: string; darkValue: string | null }[] = []
|
|
91
|
+
|
|
92
|
+
// Get computed styles for :root (light mode)
|
|
93
|
+
const rootStyles = getComputedStyle(document.documentElement)
|
|
94
|
+
|
|
95
|
+
// Try to get dark mode values by reading stylesheets
|
|
96
|
+
const darkValues = new Map<string, string>()
|
|
97
|
+
try {
|
|
98
|
+
for (const sheet of document.styleSheets) {
|
|
99
|
+
try {
|
|
100
|
+
for (const rule of sheet.cssRules) {
|
|
101
|
+
if (rule instanceof CSSStyleRule && rule.selectorText?.includes(".dark")) {
|
|
102
|
+
for (const varName of COLOR_VAR_NAMES) {
|
|
103
|
+
const value = rule.style.getPropertyValue(`--${varName}`).trim()
|
|
104
|
+
if (value) darkValues.set(varName, value)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
} catch {
|
|
109
|
+
// Cross-origin stylesheet — skip
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
} catch {
|
|
113
|
+
// No stylesheet access
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
for (const varName of COLOR_VAR_NAMES) {
|
|
117
|
+
const lightValue = rootStyles.getPropertyValue(`--${varName}`).trim()
|
|
118
|
+
if (!lightValue) continue
|
|
119
|
+
|
|
120
|
+
results.push({
|
|
121
|
+
name: varName,
|
|
122
|
+
lightValue,
|
|
123
|
+
darkValue: darkValues.get(varName) ?? null,
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return results
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Parse CSS text containing :root and .dark variable declarations.
|
|
132
|
+
* Used by the theme import feature to extract color values from pasted CSS.
|
|
133
|
+
*
|
|
134
|
+
* @param props.css - Raw CSS text containing :root { } and optionally .dark { }
|
|
135
|
+
* @returns Parsed light and dark color maps
|
|
136
|
+
*/
|
|
137
|
+
export function parseThemeCSS(props: { css: string }): {
|
|
138
|
+
light: Record<string, string>
|
|
139
|
+
dark: Record<string, string>
|
|
140
|
+
} {
|
|
141
|
+
const light: Record<string, string> = {}
|
|
142
|
+
const dark: Record<string, string> = {}
|
|
143
|
+
|
|
144
|
+
// Match :root { ... } block
|
|
145
|
+
const rootMatch = props.css.match(/:root\s*\{([\s\S]+?)\}/)
|
|
146
|
+
if (rootMatch) {
|
|
147
|
+
const vars = rootMatch[1].matchAll(/--([a-z-]+)\s*:\s*([^;]+)/g)
|
|
148
|
+
for (const match of vars) {
|
|
149
|
+
const name = match[1].trim()
|
|
150
|
+
const value = match[2].trim()
|
|
151
|
+
if (COLOR_VAR_NAMES.includes(name)) {
|
|
152
|
+
light[name] = value
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Match .dark { ... } block
|
|
158
|
+
const darkMatch = props.css.match(/\.dark\s*\{([\s\S]+?)\}/)
|
|
159
|
+
if (darkMatch) {
|
|
160
|
+
const vars = darkMatch[1].matchAll(/--([a-z-]+)\s*:\s*([^;]+)/g)
|
|
161
|
+
for (const match of vars) {
|
|
162
|
+
const name = match[1].trim()
|
|
163
|
+
const value = match[2].trim()
|
|
164
|
+
if (COLOR_VAR_NAMES.includes(name)) {
|
|
165
|
+
dark[name] = value
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return { light, dark }
|
|
171
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { Metadata } from "next"
|
|
2
|
+
|
|
3
|
+
import { buildSiteMetadata } from "@vexcms/core"
|
|
4
|
+
import { fetchQuery } from "convex/nextjs"
|
|
5
|
+
|
|
6
|
+
import { api } from "@convex/_generated/api"
|
|
7
|
+
import { anyApi } from "convex/server"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Generate Next.js Metadata for a page.
|
|
11
|
+
* Fetches site settings and optionally a page document,
|
|
12
|
+
* merges with buildSiteMetadata, and returns Next.js Metadata format.
|
|
13
|
+
*
|
|
14
|
+
* @param props.slug - Optional page slug to fetch per-page SEO overrides
|
|
15
|
+
*/
|
|
16
|
+
export async function generatePageMetadata(props: {
|
|
17
|
+
slug?: string
|
|
18
|
+
}): Promise<Metadata> {
|
|
19
|
+
try {
|
|
20
|
+
// Fetch site settings
|
|
21
|
+
const settings = (await fetchQuery(api.siteSettings.get)) as Record<
|
|
22
|
+
string,
|
|
23
|
+
unknown
|
|
24
|
+
> | null
|
|
25
|
+
|
|
26
|
+
if (!settings) {
|
|
27
|
+
return { title: "Untitled" }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Resolve the ogImage URL from site settings (upload ID → URL)
|
|
31
|
+
let siteOgImageUrl: string | undefined
|
|
32
|
+
const ogImageId = settings.ogImage as string | undefined
|
|
33
|
+
if (ogImageId) {
|
|
34
|
+
try {
|
|
35
|
+
const mediaDoc = (await fetchQuery(
|
|
36
|
+
anyApi.vex.collections.getDocument,
|
|
37
|
+
{
|
|
38
|
+
collectionSlug: "media",
|
|
39
|
+
documentId: ogImageId,
|
|
40
|
+
}
|
|
41
|
+
)) as Record<string, unknown> | null
|
|
42
|
+
siteOgImageUrl = (mediaDoc?.url as string) || undefined
|
|
43
|
+
} catch {
|
|
44
|
+
// Media document not found — skip ogImage
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Fetch page data for per-page overrides
|
|
49
|
+
let pageData: Record<string, unknown> | null = null
|
|
50
|
+
if (props.slug) {
|
|
51
|
+
pageData = await fetchQuery(api.pages.getBySlug, {
|
|
52
|
+
slug: props.slug,
|
|
53
|
+
_vexDrafts: false,
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Build framework-agnostic metadata
|
|
58
|
+
const meta = buildSiteMetadata({
|
|
59
|
+
site: {
|
|
60
|
+
name: settings.name as string | undefined,
|
|
61
|
+
metaTitle: settings.metaTitle as string | undefined,
|
|
62
|
+
metaDescription: settings.metaDescription as string | undefined,
|
|
63
|
+
description: settings.description as string | undefined,
|
|
64
|
+
ogImage: siteOgImageUrl,
|
|
65
|
+
twitterHandle: settings.twitterHandle as string | undefined,
|
|
66
|
+
},
|
|
67
|
+
page: pageData
|
|
68
|
+
? {
|
|
69
|
+
title: pageData.title as string | undefined,
|
|
70
|
+
metaTitle: pageData.metaTitle as string | undefined,
|
|
71
|
+
metaDescription: pageData.metaDescription as string | undefined,
|
|
72
|
+
ogImage: pageData.ogImage as string | undefined,
|
|
73
|
+
}
|
|
74
|
+
: undefined,
|
|
75
|
+
titleSuffix: " | Vex CMS",
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
// Map to Next.js Metadata
|
|
79
|
+
const metadata: Metadata = {
|
|
80
|
+
title: meta.title,
|
|
81
|
+
description: meta.description,
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (meta.ogImage) {
|
|
85
|
+
metadata.openGraph = {
|
|
86
|
+
title: meta.title,
|
|
87
|
+
description: meta.description,
|
|
88
|
+
images: [{ url: meta.ogImage }],
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (meta.twitterHandle) {
|
|
93
|
+
metadata.twitter = {
|
|
94
|
+
card: "summary_large_image",
|
|
95
|
+
site: meta.twitterHandle,
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return metadata
|
|
100
|
+
} catch {
|
|
101
|
+
// Convex not available — return minimal metadata
|
|
102
|
+
return { title: "Vex CMS" }
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { defineAccess } from "@vexcms/core"
|
|
2
|
+
|
|
3
|
+
import { TABLE_SLUG_USERS, USER_ROLES } from "~/db/constants"
|
|
4
|
+
import { users } from "~/vexcms/collections"
|
|
5
|
+
|
|
6
|
+
export const access = defineAccess({
|
|
7
|
+
permissions: {
|
|
8
|
+
admin: {
|
|
9
|
+
admin: true,
|
|
10
|
+
user: true,
|
|
11
|
+
},
|
|
12
|
+
user: {
|
|
13
|
+
[TABLE_SLUG_USERS]: {
|
|
14
|
+
create: false,
|
|
15
|
+
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
|
+
},
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
resources: [users],
|
|
26
|
+
roles: [USER_ROLES.user, USER_ROLES.admin],
|
|
27
|
+
userCollection: users,
|
|
28
|
+
})
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { array, defineBlock, object, text } from "@vexcms/core"
|
|
2
|
+
|
|
3
|
+
import { BLOCK_SLUG_CTA } from "../constants"
|
|
4
|
+
|
|
5
|
+
export const ctaBlock = defineBlock({
|
|
6
|
+
slug: BLOCK_SLUG_CTA,
|
|
7
|
+
label: "Call to Action",
|
|
8
|
+
fields: {
|
|
9
|
+
heading: text({
|
|
10
|
+
label: "Heading",
|
|
11
|
+
required: true,
|
|
12
|
+
defaultValue: "Ready to build with Vex CMS?",
|
|
13
|
+
}),
|
|
14
|
+
subheading: text({
|
|
15
|
+
label: "Subheading",
|
|
16
|
+
defaultValue:
|
|
17
|
+
"Get started in minutes with create-vexcms. Real-time content management powered by Convex.",
|
|
18
|
+
}),
|
|
19
|
+
actions: array({
|
|
20
|
+
label: "Actions",
|
|
21
|
+
items: object({
|
|
22
|
+
fields: {
|
|
23
|
+
label: text({ label: "Button Label", required: true }),
|
|
24
|
+
href: text({ label: "Button Link", required: true }),
|
|
25
|
+
},
|
|
26
|
+
}),
|
|
27
|
+
defaultValue: [
|
|
28
|
+
{ label: "Get Started", href: "/docs" },
|
|
29
|
+
{ label: "View on GitHub", href: "https://github.com/vexcms/vex" },
|
|
30
|
+
],
|
|
31
|
+
}),
|
|
32
|
+
},
|
|
33
|
+
admin: {
|
|
34
|
+
icon: "megaphone",
|
|
35
|
+
},
|
|
36
|
+
})
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { BlockComponentProps } from "@vexcms/ui"
|
|
2
|
+
|
|
3
|
+
import Link from "next/link"
|
|
4
|
+
|
|
5
|
+
import { cn } from "~/lib/utils"
|
|
6
|
+
import { buttonVariants } from "~/components/ui/button"
|
|
7
|
+
|
|
8
|
+
export { ctaBlock } from "./config"
|
|
9
|
+
|
|
10
|
+
export default function CTABlock({ block }: BlockComponentProps) {
|
|
11
|
+
const { heading, subheading, actions } = block as unknown as {
|
|
12
|
+
heading: string
|
|
13
|
+
subheading?: string
|
|
14
|
+
actions?: Array<{ label: string; href: string }>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<section className="py-16 md:py-32">
|
|
19
|
+
<div className="mx-auto max-w-5xl px-6">
|
|
20
|
+
<div className="text-center">
|
|
21
|
+
<h2 className="text-4xl font-semibold text-balance lg:text-5xl">
|
|
22
|
+
{heading}
|
|
23
|
+
</h2>
|
|
24
|
+
{subheading && <p className="mt-4">{subheading}</p>}
|
|
25
|
+
|
|
26
|
+
<div className="mt-12 flex flex-wrap justify-center gap-4">
|
|
27
|
+
{(actions ?? []).map((action, index) => (
|
|
28
|
+
<Link
|
|
29
|
+
key={index}
|
|
30
|
+
href={action.href ?? "/"}
|
|
31
|
+
className={cn(
|
|
32
|
+
buttonVariants({
|
|
33
|
+
size: "lg",
|
|
34
|
+
variant: index % 2 === 1 ? "outline" : "default",
|
|
35
|
+
})
|
|
36
|
+
)}
|
|
37
|
+
>
|
|
38
|
+
<span>{action.label}</span>
|
|
39
|
+
</Link>
|
|
40
|
+
))}
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
</section>
|
|
45
|
+
)
|
|
46
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { array, defineBlock, object, text } from "@vexcms/core"
|
|
2
|
+
|
|
3
|
+
import { BLOCK_SLUG_FAQ } from "../constants"
|
|
4
|
+
|
|
5
|
+
export const faqBlock = defineBlock({
|
|
6
|
+
slug: BLOCK_SLUG_FAQ,
|
|
7
|
+
label: "FAQ",
|
|
8
|
+
fields: {
|
|
9
|
+
heading: text({
|
|
10
|
+
label: "Heading",
|
|
11
|
+
required: true,
|
|
12
|
+
defaultValue: "Frequently Asked Questions",
|
|
13
|
+
}),
|
|
14
|
+
subheading: text({
|
|
15
|
+
label: "Subheading",
|
|
16
|
+
defaultValue:
|
|
17
|
+
"Everything you need to know about Vex CMS and building with Convex.",
|
|
18
|
+
}),
|
|
19
|
+
supportLink: text({
|
|
20
|
+
label: "Support Link",
|
|
21
|
+
defaultValue: "https://github.com/vexcms/vex/issues",
|
|
22
|
+
admin: { description: "URL to contact support page" },
|
|
23
|
+
}),
|
|
24
|
+
items: array({
|
|
25
|
+
label: "FAQ Items",
|
|
26
|
+
required: true,
|
|
27
|
+
items: object({
|
|
28
|
+
fields: {
|
|
29
|
+
question: text({ label: "Question", required: true }),
|
|
30
|
+
answer: text({ label: "Answer", required: true }),
|
|
31
|
+
},
|
|
32
|
+
}),
|
|
33
|
+
defaultValue: [
|
|
34
|
+
{
|
|
35
|
+
question: "What is Vex CMS?",
|
|
36
|
+
answer:
|
|
37
|
+
"Vex CMS is a headless content management system built on Convex. It provides real-time data, type-safe schemas, draft/publish workflows, live preview, and a beautiful admin panel — all configured with TypeScript.",
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
question: "How is Vex different from other headless CMS platforms?",
|
|
41
|
+
answer:
|
|
42
|
+
"Unlike traditional headless CMS platforms that use REST or GraphQL APIs, Vex is powered by Convex's real-time database. This means content updates are instant across all clients, with no polling or webhooks needed. Your schema is defined in code, giving you full type safety from database to frontend.",
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
question: "Do I need to know Convex to use Vex CMS?",
|
|
46
|
+
answer:
|
|
47
|
+
"Basic familiarity with Convex helps, but Vex handles most of the complexity for you. The CLI generates your Convex schema, queries, and types automatically from your collection definitions. You just define your fields and Vex does the rest.",
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
question: "Can I use Vex with any frontend framework?",
|
|
51
|
+
answer:
|
|
52
|
+
"Vex CMS is framework-agnostic at the data layer — any app that can use Convex can use Vex. The admin panel and UI components are built for Next.js, but the core package and generated queries work with any Convex-compatible frontend.",
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
question: "Is Vex CMS free?",
|
|
56
|
+
answer:
|
|
57
|
+
"Yes, Vex CMS is open source and free to use. You only pay for your Convex usage, which has a generous free tier for most projects.",
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
question: "How do I get started?",
|
|
61
|
+
answer:
|
|
62
|
+
"Run `npx create-vexcms@latest` to scaffold a new project with Vex CMS pre-configured. The CLI sets up your Next.js app, Convex backend, authentication, and admin panel in under a minute.",
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
}),
|
|
66
|
+
},
|
|
67
|
+
admin: {
|
|
68
|
+
icon: "help-circle",
|
|
69
|
+
},
|
|
70
|
+
})
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { BlockComponentProps } from "@vexcms/ui"
|
|
2
|
+
|
|
3
|
+
import Link from "next/link"
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
Accordion,
|
|
7
|
+
AccordionContent,
|
|
8
|
+
AccordionItem,
|
|
9
|
+
AccordionTrigger,
|
|
10
|
+
} from "~/components/ui/accordion"
|
|
11
|
+
|
|
12
|
+
export { faqBlock } from "./config"
|
|
13
|
+
|
|
14
|
+
export default function FAQBlock({ block }: BlockComponentProps) {
|
|
15
|
+
const { heading, subheading, supportLink, items } = block as unknown as {
|
|
16
|
+
heading: string
|
|
17
|
+
subheading?: string
|
|
18
|
+
supportLink?: string
|
|
19
|
+
items?: Array<{ question: string; answer: string }>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<section className="py-16 md:py-24">
|
|
24
|
+
<div className="mx-auto max-w-5xl px-4 md:px-6">
|
|
25
|
+
<div className="mx-auto max-w-xl text-center">
|
|
26
|
+
<h2 className="text-3xl font-bold text-balance md:text-4xl lg:text-5xl">
|
|
27
|
+
{heading}
|
|
28
|
+
</h2>
|
|
29
|
+
{subheading && (
|
|
30
|
+
<p className="text-muted-foreground mt-4 text-balance">
|
|
31
|
+
{subheading}
|
|
32
|
+
</p>
|
|
33
|
+
)}
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
<div className="mx-auto mt-12 max-w-xl">
|
|
37
|
+
<Accordion className="bg-muted dark:bg-muted/50 w-full rounded-2xl p-1">
|
|
38
|
+
{(items ?? []).map((faq, index) => (
|
|
39
|
+
<div className="group" key={index}>
|
|
40
|
+
<AccordionItem
|
|
41
|
+
className="data-open:bg-card dark:data-open:bg-muted rounded-xl border-none px-7 py-1 data-open:border-none data-open:shadow-sm"
|
|
42
|
+
value={`${index}`}
|
|
43
|
+
>
|
|
44
|
+
<AccordionTrigger className="cursor-pointer text-base hover:no-underline">
|
|
45
|
+
{faq.question}
|
|
46
|
+
</AccordionTrigger>
|
|
47
|
+
<AccordionContent>
|
|
48
|
+
<p className="text-base">{faq.answer}</p>
|
|
49
|
+
</AccordionContent>
|
|
50
|
+
</AccordionItem>
|
|
51
|
+
<hr className="mx-7 border-dashed group-last:hidden" />
|
|
52
|
+
</div>
|
|
53
|
+
))}
|
|
54
|
+
</Accordion>
|
|
55
|
+
|
|
56
|
+
{supportLink && (
|
|
57
|
+
<p className="text-muted-foreground mt-6 px-8">
|
|
58
|
+
{"Can't find what you're looking for? Contact our "}
|
|
59
|
+
<Link
|
|
60
|
+
className="text-primary font-medium hover:underline"
|
|
61
|
+
href={supportLink}
|
|
62
|
+
>
|
|
63
|
+
support team
|
|
64
|
+
</Link>
|
|
65
|
+
</p>
|
|
66
|
+
)}
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
</section>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { array, defineBlock, object, text } from "@vexcms/core"
|
|
2
|
+
|
|
3
|
+
import IconPickerField from "~/components/admin/IconPickerField"
|
|
4
|
+
import { BLOCK_SLUG_FEATURES } from "../constants"
|
|
5
|
+
|
|
6
|
+
export const featuresBlock = defineBlock({
|
|
7
|
+
slug: BLOCK_SLUG_FEATURES,
|
|
8
|
+
label: "Features",
|
|
9
|
+
fields: {
|
|
10
|
+
heading: text({
|
|
11
|
+
label: "Heading",
|
|
12
|
+
required: true,
|
|
13
|
+
defaultValue: "Everything you need to manage content",
|
|
14
|
+
}),
|
|
15
|
+
subheading: text({
|
|
16
|
+
label: "Subheading",
|
|
17
|
+
defaultValue:
|
|
18
|
+
"Built on Convex's real-time infrastructure with a developer experience that doesn't compromise on power.",
|
|
19
|
+
}),
|
|
20
|
+
features: array({
|
|
21
|
+
label: "Features",
|
|
22
|
+
required: true,
|
|
23
|
+
items: object({
|
|
24
|
+
fields: {
|
|
25
|
+
title: text({ label: "Title", required: true }),
|
|
26
|
+
description: text({ label: "Description", required: true }),
|
|
27
|
+
icon: text({
|
|
28
|
+
label: "Icon",
|
|
29
|
+
admin: {
|
|
30
|
+
description: "Lucide icon name",
|
|
31
|
+
components: { Field: IconPickerField },
|
|
32
|
+
},
|
|
33
|
+
}),
|
|
34
|
+
},
|
|
35
|
+
}),
|
|
36
|
+
defaultValue: [
|
|
37
|
+
{
|
|
38
|
+
title: "Real-Time by Default",
|
|
39
|
+
description:
|
|
40
|
+
"Every query is live. Content updates appear instantly across all connected clients — no polling, no webhooks.",
|
|
41
|
+
icon: "Zap",
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
title: "Type-Safe Schemas",
|
|
45
|
+
description:
|
|
46
|
+
"Define your collections with TypeScript. Vex generates Convex schemas, Zod validators, and typed queries automatically.",
|
|
47
|
+
icon: "Shield",
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
title: "Developer First",
|
|
51
|
+
description:
|
|
52
|
+
"Code-first configuration, CLI tooling, and a clean API. Build with the tools you already know and love.",
|
|
53
|
+
icon: "Code",
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
}),
|
|
57
|
+
},
|
|
58
|
+
admin: {
|
|
59
|
+
icon: "layout-grid",
|
|
60
|
+
},
|
|
61
|
+
})
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { BlockComponentProps } from "@vexcms/ui"
|
|
2
|
+
import type { ComponentPropsWithRef } from "react"
|
|
3
|
+
|
|
4
|
+
import { icons } from "lucide-react"
|
|
5
|
+
|
|
6
|
+
import { Card, CardContent, CardHeader } from "~/components/ui/card"
|
|
7
|
+
|
|
8
|
+
export { featuresBlock } from "./config"
|
|
9
|
+
|
|
10
|
+
function LucideIcon({ name }: { name: string }) {
|
|
11
|
+
const Icon = icons[name as keyof typeof icons]
|
|
12
|
+
if (!Icon) return null
|
|
13
|
+
return <Icon className="size-4" />
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function CardDecorator({ children, ...divProps }: ComponentPropsWithRef<"div">) {
|
|
17
|
+
return (
|
|
18
|
+
<div
|
|
19
|
+
className="relative mx-auto size-36 duration-200 [--color-border:color-mix(in_oklab,var(--color-zinc-950)10%,transparent)] group-hover:[--color-border:color-mix(in_oklab,var(--color-zinc-950)20%,transparent)] dark:[--color-border:color-mix(in_oklab,var(--color-white)15%,transparent)] dark:group-hover:bg-white/5 dark:group-hover:[--color-border:color-mix(in_oklab,var(--color-white)20%,transparent)]"
|
|
20
|
+
{...divProps}
|
|
21
|
+
>
|
|
22
|
+
<div
|
|
23
|
+
aria-hidden
|
|
24
|
+
className="absolute inset-0 bg-[linear-gradient(to_right,var(--color-border)_1px,transparent_1px),linear-gradient(to_bottom,var(--color-border)_1px,transparent_1px)] bg-[size:24px_24px]"
|
|
25
|
+
/>
|
|
26
|
+
<div
|
|
27
|
+
aria-hidden
|
|
28
|
+
className="to-background absolute inset-0 bg-radial from-transparent to-75%"
|
|
29
|
+
/>
|
|
30
|
+
<div className="bg-background absolute inset-0 m-auto flex size-12 items-center justify-center border-t border-l">
|
|
31
|
+
{children}
|
|
32
|
+
</div>
|
|
33
|
+
</div>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export default function FeaturesBlock({ block }: BlockComponentProps) {
|
|
38
|
+
const { heading, subheading, features } = block as unknown as {
|
|
39
|
+
heading: string
|
|
40
|
+
subheading?: string
|
|
41
|
+
features?: Array<{ title: string; description: string; icon?: string }>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<section className="bg-zinc-50 py-16 md:py-32 dark:bg-transparent">
|
|
46
|
+
<div className="@container mx-auto max-w-5xl px-6">
|
|
47
|
+
<div className="text-center">
|
|
48
|
+
<h2 className="text-4xl font-semibold text-balance lg:text-5xl">
|
|
49
|
+
{heading}
|
|
50
|
+
</h2>
|
|
51
|
+
{subheading && <p className="mt-4">{subheading}</p>}
|
|
52
|
+
</div>
|
|
53
|
+
<Card className="mx-auto mt-8 grid max-w-sm divide-y overflow-hidden shadow-zinc-950/5 *:text-center md:mt-16 @min-4xl:max-w-full @min-4xl:grid-cols-3 @min-4xl:divide-x @min-4xl:divide-y-0">
|
|
54
|
+
{(features ?? []).map((feature, index) => (
|
|
55
|
+
<div className="group shadow-zinc-950/5" key={index}>
|
|
56
|
+
<CardHeader className="pb-3">
|
|
57
|
+
{feature.icon && (
|
|
58
|
+
<CardDecorator>
|
|
59
|
+
<LucideIcon name={feature.icon} />
|
|
60
|
+
</CardDecorator>
|
|
61
|
+
)}
|
|
62
|
+
<h3 className="mt-6 font-medium">{feature.title}</h3>
|
|
63
|
+
</CardHeader>
|
|
64
|
+
<CardContent>
|
|
65
|
+
<p className="text-sm">{feature.description}</p>
|
|
66
|
+
</CardContent>
|
|
67
|
+
</div>
|
|
68
|
+
))}
|
|
69
|
+
</Card>
|
|
70
|
+
</div>
|
|
71
|
+
</section>
|
|
72
|
+
)
|
|
73
|
+
}
|