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
@@ -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
+ }