create-vexcms 0.1.0-alpha.2 → 0.1.0-alpha.21

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 (115) hide show
  1. package/README.md +48 -14
  2. package/dist/index.js +1714 -1504
  3. package/package.json +2 -2
  4. package/templates/base-nextjs/README.md +18 -5
  5. package/templates/base-nextjs/convex/auth/index.ts +2 -2
  6. package/templates/base-nextjs/convex/auth/options.ts +27 -26
  7. package/templates/base-nextjs/convex/auth/schema.ts +51 -0
  8. package/templates/base-nextjs/convex/auth/sessions.ts +3 -3
  9. package/templates/base-nextjs/convex/tsconfig.json +2 -2
  10. package/templates/base-nextjs/convex/vex/firstUser.ts +2 -2
  11. package/templates/base-nextjs/convex/vex/globals.ts +3 -2
  12. package/templates/base-nextjs/convex/vex/media.ts +3 -2
  13. package/templates/base-nextjs/convex/vex.schema.ts +1 -0
  14. package/templates/base-nextjs/convex/vex.ts +17 -4
  15. package/templates/base-nextjs/next.config.ts +52 -6
  16. package/templates/base-nextjs/package.json +18 -7
  17. package/templates/base-nextjs/public/favicons/favicon.ico +0 -0
  18. package/templates/base-nextjs/src/app/(frontend)/@auth/(...)auth/[pathname]/view.tsx +27 -15
  19. package/templates/base-nextjs/src/app/(vexcms)/admin/[[...path]]/page.tsx +2 -2
  20. package/templates/base-nextjs/src/app/(vexcms)/admin/clientProviders.tsx +9 -14
  21. package/templates/base-nextjs/src/app/(vexcms)/admin/layout.tsx +8 -6
  22. package/templates/base-nextjs/src/app/api/vex/revalidate/route.ts +19 -0
  23. package/templates/base-nextjs/src/app/globals.css +10 -9
  24. package/templates/base-nextjs/src/app/layout.tsx +6 -0
  25. package/templates/base-nextjs/src/auth/access.ts +14 -2
  26. package/templates/base-nextjs/src/auth/serverUtils.ts +20 -6
  27. package/templates/base-nextjs/src/components/WelcomePage.tsx +54 -43
  28. package/templates/base-nextjs/src/components/component-example.tsx +1 -1
  29. package/templates/base-nextjs/src/components/providers/server.tsx +16 -8
  30. package/templates/base-nextjs/src/vex.config.server.ts +30 -0
  31. package/templates/base-nextjs/src/vex.config.ts +14 -11
  32. package/templates/base-nextjs/src/vex.types.ts +2 -1
  33. package/templates/base-nextjs/src/vexcms/collections/images.ts +1 -1
  34. package/templates/base-nextjs/src/vexcms/collections/users.ts +3 -1
  35. package/templates/marketing-site/convex/pages.ts +20 -1
  36. package/templates/marketing-site/convex/schema.ts +4 -4
  37. package/templates/marketing-site/convex/seed.ts +515 -70
  38. package/templates/marketing-site/convex/siteSettings.ts +1 -1
  39. package/templates/marketing-site/convex/theme.ts +3 -3
  40. package/templates/marketing-site/convex/vex.schema.ts +75 -38
  41. package/templates/marketing-site/public/favicon.svg +14 -0
  42. package/templates/marketing-site/public/favicons/apple-touch-icon.png +0 -0
  43. package/templates/marketing-site/public/favicons/favicon.ico +0 -0
  44. package/templates/marketing-site/src/app/(frontend)/(site)/PageContent.tsx +64 -0
  45. package/templates/marketing-site/src/app/(frontend)/(site)/SiteLivePreviewProvider.tsx +31 -0
  46. package/templates/marketing-site/src/app/(frontend)/(site)/[slug]/page.tsx +63 -0
  47. package/templates/marketing-site/src/app/(frontend)/(site)/layout.tsx +50 -0
  48. package/templates/marketing-site/src/app/(frontend)/(site)/page.tsx +33 -0
  49. package/templates/marketing-site/src/app/(frontend)/layout.tsx +10 -24
  50. package/templates/marketing-site/src/app/(vexcms)/admin/layout.tsx +32 -0
  51. package/templates/marketing-site/src/app/globals.css +413 -0
  52. package/templates/marketing-site/src/app/layout.tsx +85 -0
  53. package/templates/marketing-site/src/app/robots.ts +13 -0
  54. package/templates/marketing-site/src/app/sitemap.ts +18 -0
  55. package/templates/marketing-site/src/components/BrandMark.tsx +27 -0
  56. package/templates/marketing-site/src/components/CodeHighlightContext.tsx +76 -0
  57. package/templates/marketing-site/src/components/CodePane.tsx +98 -0
  58. package/templates/marketing-site/src/components/Container.tsx +25 -0
  59. package/templates/marketing-site/src/components/CopyButton.tsx +80 -0
  60. package/templates/marketing-site/src/components/FirstAdminBootstrap.tsx +54 -0
  61. package/templates/marketing-site/src/components/InstallCommand.tsx +29 -0
  62. package/templates/marketing-site/src/components/MediaImage.tsx +59 -0
  63. package/templates/marketing-site/src/components/SectionHeader.tsx +51 -0
  64. package/templates/marketing-site/src/components/SiteFooter.tsx +8 -6
  65. package/templates/marketing-site/src/components/SiteHeader.tsx +8 -6
  66. package/templates/marketing-site/src/components/ThemeLive.tsx +1 -2
  67. package/templates/marketing-site/src/components/ThemeStyle.tsx +3 -3
  68. package/templates/marketing-site/src/components/motion-primitives/animated-group.tsx +44 -120
  69. package/templates/marketing-site/src/components/motion-primitives/reveal.module.css +32 -0
  70. package/templates/marketing-site/src/components/motion-primitives/text-effect.tsx +47 -266
  71. package/templates/marketing-site/src/lib/codeHighlightKey.ts +27 -0
  72. package/templates/marketing-site/src/lib/highlight.ts +134 -0
  73. package/templates/marketing-site/src/lib/highlightClient.ts +37 -0
  74. package/templates/marketing-site/src/lib/metadata.ts +61 -33
  75. package/templates/marketing-site/src/lib/resolvePagePath.ts +8 -0
  76. package/templates/marketing-site/src/lib/vex.ts +18 -0
  77. package/templates/marketing-site/src/proxy.ts +179 -0
  78. package/templates/marketing-site/src/vex.config.server.ts +27 -0
  79. package/templates/marketing-site/src/vex.config.ts +27 -9
  80. package/templates/marketing-site/src/vex.types.ts +102 -76
  81. package/templates/marketing-site/src/vexcms/api.ts +2 -2
  82. package/templates/marketing-site/src/vexcms/blocks/CTA/config.ts +3 -2
  83. package/templates/marketing-site/src/vexcms/blocks/CTA/index.tsx +40 -24
  84. package/templates/marketing-site/src/vexcms/blocks/CodeShowcase/config.ts +62 -0
  85. package/templates/marketing-site/src/vexcms/blocks/CodeShowcase/index.tsx +106 -0
  86. package/templates/marketing-site/src/vexcms/blocks/FAQ/config.ts +4 -3
  87. package/templates/marketing-site/src/vexcms/blocks/FAQ/index.tsx +63 -50
  88. package/templates/marketing-site/src/vexcms/blocks/Features/config.ts +1 -0
  89. package/templates/marketing-site/src/vexcms/blocks/Features/index.tsx +43 -58
  90. package/templates/marketing-site/src/vexcms/blocks/Footer/config.ts +6 -3
  91. package/templates/marketing-site/src/vexcms/blocks/Footer/index.tsx +90 -53
  92. package/templates/marketing-site/src/vexcms/blocks/Header/config.ts +10 -3
  93. package/templates/marketing-site/src/vexcms/blocks/Header/index.tsx +205 -138
  94. package/templates/marketing-site/src/vexcms/blocks/Hero/config.ts +27 -12
  95. package/templates/marketing-site/src/vexcms/blocks/Hero/index.tsx +174 -119
  96. package/templates/marketing-site/src/vexcms/blocks/HowItWorks/config.ts +2 -1
  97. package/templates/marketing-site/src/vexcms/blocks/HowItWorks/index.tsx +59 -51
  98. package/templates/marketing-site/src/vexcms/blocks/Roadmap/config.ts +26 -5
  99. package/templates/marketing-site/src/vexcms/blocks/Roadmap/index.tsx +256 -73
  100. package/templates/marketing-site/src/vexcms/blocks/Split/config.ts +62 -0
  101. package/templates/marketing-site/src/vexcms/blocks/Split/index.tsx +186 -0
  102. package/templates/marketing-site/src/vexcms/blocks/Stats/config.ts +35 -0
  103. package/templates/marketing-site/src/vexcms/blocks/Stats/index.tsx +77 -0
  104. package/templates/marketing-site/src/vexcms/blocks/config.ts +27 -11
  105. package/templates/marketing-site/src/vexcms/blocks/constants.ts +3 -0
  106. package/templates/marketing-site/src/vexcms/blocks/index.ts +9 -0
  107. package/templates/marketing-site/src/vexcms/collections/footers.ts +1 -0
  108. package/templates/marketing-site/src/vexcms/collections/headers.ts +1 -0
  109. package/templates/marketing-site/src/vexcms/collections/pages.ts +23 -1
  110. package/templates/marketing-site/src/vexcms/collections/themeColors.ts +14 -14
  111. package/templates/marketing-site/src/vexcms/collections/users.ts +20 -0
  112. package/dist/index.mjs +0 -30976
  113. package/templates/marketing-site/src/app/(frontend)/PageContent.tsx +0 -53
  114. package/templates/marketing-site/src/app/(frontend)/[slug]/page.tsx +0 -25
  115. package/templates/marketing-site/src/app/(frontend)/page.tsx +0 -21
@@ -2,91 +2,274 @@
2
2
 
3
3
  import type { BlockComponentProps } from "@vexcms/react"
4
4
 
5
- import { Check, Clock, Compass } from "lucide-react"
6
- import { cn } from "@vexcms/react"
5
+ import { cn, ScrollArea, ScrollBar, Tabs, TabsList, TabsTrigger, Tooltip, TooltipContent, TooltipTrigger } from "@vexcms/react"
6
+ import { Check } from "lucide-react"
7
+ import { useState } from "react"
8
+
9
+ import type { RoadmapBlock } from "~/vex.types"
10
+
11
+ import { Container } from "~/components/Container"
12
+ import { SectionHeader } from "~/components/SectionHeader"
7
13
 
8
14
  export { roadmapBlock } from "./config"
9
15
 
10
- const statusConfig = {
11
- shipped: {
12
- label: "Shipped",
13
- icon: Check,
14
- badgeClass: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20",
15
- },
16
- "coming-soon": {
17
- label: "Coming Soon",
18
- icon: Clock,
19
- badgeClass: "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20",
20
- },
21
- planned: {
22
- label: "Planned",
23
- icon: Compass,
24
- badgeClass: "bg-zinc-500/10 text-zinc-600 dark:text-zinc-400 border-zinc-500/20",
25
- },
26
- } as const
27
-
28
- type RoadmapItem = { feature: string; description?: string; status: "shipped" | "coming-soon" | "planned" }
29
- type StatusKey = keyof typeof statusConfig
30
-
31
- const statusOrder: StatusKey[] = ["shipped", "coming-soon", "planned"]
32
16
 
17
+
18
+ /**
19
+ * Derived from the generated block type, never hand-written: `vex dev` emits
20
+ * the literal union straight from the `select` options, so adding a status to
21
+ * the config is a compile error here until this renderer handles it.
22
+ */
23
+ type RoadmapItemStatus = RoadmapBlock["items"][number]["status"][number]
24
+
25
+ /**
26
+ * Print order, and it is deliberately not the brief's Shipped-first order.
27
+ * In progress is the page's news; Shipped is 15 rows of reassurance people
28
+ * skim. Putting the reassurance first buries the news.
29
+ */
30
+ const GROUP_ORDER: RoadmapItemStatus[] = ["in-progress", "planned", "future", "exploring", "shipped"]
31
+
32
+ const GROUP_LABEL: Record<RoadmapItemStatus, string> = {
33
+ exploring: "Exploring",
34
+ future: "Future",
35
+ "in-progress": "In progress",
36
+ planned: "Planned",
37
+ shipped: "Shipped",
38
+ }
39
+
40
+ /** Below this many items the filter is noise — four short groups fit on one
41
+ * screen and the tab row costs more than it saves. */
42
+ const FILTER_MIN_ITEMS = 8
43
+
44
+ /**
45
+ * Roadmap — four buckets of wildly different size, solved by giving each a
46
+ * different density rather than a different colour.
47
+ *
48
+ * Every group is rendered server-side and toggled with `hidden`, so filtering
49
+ * costs no request and the page is complete with JavaScript disabled.
50
+ */
33
51
  export default function RoadmapBlock({ block }: BlockComponentProps) {
34
- const { heading, subheading, items } = block as unknown as {
35
- heading: string
36
- subheading?: string
37
- items?: RoadmapItem[]
38
- }
52
+ const { heading, items, subheading } = block as RoadmapBlock
53
+ const all = items ?? []
54
+ const [filter, setFilter] = useState<"all" | RoadmapItemStatus>("all")
55
+
56
+ if (all.length === 0) {return null}
39
57
 
40
- const grouped = statusOrder
41
- .map((status) => ({
42
- status,
43
- config: statusConfig[status],
44
- items: (items ?? []).filter((item) => item.status === status && item.status in statusConfig),
45
- }))
46
- .filter((group) => group.items.length > 0)
58
+ const grouped = GROUP_ORDER.map((status) => ({
59
+ entries: all.filter((item) => readStatus(item.status) === status),
60
+ status,
61
+ })).filter((group) => group.entries.length > 0)
62
+
63
+ const showFilter = all.length >= FILTER_MIN_ITEMS
47
64
 
48
65
  return (
49
- <section className={cn("py-16 md:py-32")}>
50
- <div className="mx-auto max-w-5xl px-6">
51
- <div className="text-center">
52
- <h2 className="text-4xl font-semibold text-balance lg:text-5xl">{heading}</h2>
53
- {subheading && <p className="text-muted-foreground mt-4 text-balance">{subheading}</p>}
54
- </div>
66
+ <section className="py-14 md:py-20 xl:py-24">
67
+ <Container>
68
+ <SectionHeader heading={heading} subheading={subheading} />
55
69
 
56
- {grouped.map((group, groupIndex) => {
57
- const StatusIcon = group.config.icon
58
- return (
59
- <div key={group.status} className={groupIndex === 0 ? "mt-16" : "mt-12"}>
60
- <div className="flex items-center gap-2">
61
- <StatusIcon className="size-5" />
62
- <h3 className="text-lg font-semibold">{group.config.label}</h3>
70
+ {showFilter ? (
71
+ <Tabs
72
+ className="mb-10"
73
+ onValueChange={(value) => setFilter(value as "all" | RoadmapItemStatus)}
74
+ value={filter}
75
+ >
76
+ <ScrollArea>
77
+ <TabsList className="h-10 w-full justify-start rounded-none border-b border-border bg-transparent p-0">
78
+ <TabsTrigger className="rounded-sm px-3 py-[5px] text-[13px] font-medium" value="all">
79
+ All {all.length}
80
+ </TabsTrigger>
81
+ {grouped.map((group) => (
82
+ <TabsTrigger
83
+ className="rounded-sm px-3 py-[5px] text-[13px] font-medium"
84
+ key={group.status}
85
+ value={group.status}
86
+ >
87
+ {GROUP_LABEL[group.status]} {group.entries.length}
88
+ </TabsTrigger>
89
+ ))}
90
+ </TabsList>
91
+ <ScrollBar orientation="horizontal" />
92
+ </ScrollArea>
93
+ </Tabs>
94
+ ) : null}
95
+
96
+ <div className="flex flex-col gap-11">
97
+ {grouped.map((group) => (
98
+ <div
99
+ className={cn(filter !== "all" && filter !== group.status && "hidden")}
100
+ key={group.status}
101
+ >
102
+ <div className="flex items-center gap-3">
103
+ <h3 className="sr-only">{GROUP_LABEL[group.status]}</h3>
104
+ <StatusBadge status={group.status} />
105
+ <span className="font-mono text-xs text-muted-foreground">
106
+ {group.entries.length}
107
+ </span>
108
+ <span aria-hidden className="h-px flex-1 bg-border" />
63
109
  </div>
64
- <div className="mt-6 grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
65
- {group.items.map((item, itemIndex) => {
66
- const BadgeIcon = group.config.icon
67
- return (
68
- <div key={itemIndex} className="bg-card rounded-xl border p-5">
69
- <div className="flex items-center justify-between gap-4">
70
- <span className="font-medium">{item.feature}</span>
71
- <span
72
- className={cn(
73
- "inline-flex shrink-0 items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium",
74
- group.config.badgeClass
75
- )}
76
- >
77
- <BadgeIcon className="size-3" />
78
- {group.config.label}
79
- </span>
80
- </div>
81
- {item.description && <p className="text-muted-foreground mt-2 text-sm">{item.description}</p>}
110
+
111
+ {group.status === "in-progress" ? (
112
+ <div
113
+ className={cn(
114
+ "mt-5 grid gap-4",
115
+ // A single card should not sit in a half-empty row.
116
+ group.entries.length >= 2 && "md:grid-cols-2"
117
+ )}
118
+ >
119
+ {group.entries.map((item, index) => (
120
+ <div
121
+ className="rounded-md border border-border bg-card p-5"
122
+ key={`${item.feature}-${index}`}
123
+ >
124
+ <p className="text-[17px] font-semibold text-foreground">{item.feature}</p>
125
+ {item.description ? (
126
+ <p className="mt-2 text-sm leading-[1.6] text-muted-foreground">
127
+ {item.description}
128
+ </p>
129
+ ) : null}
82
130
  </div>
83
- )
84
- })}
85
- </div>
131
+ ))}
132
+ </div>
133
+ ) : null}
134
+
135
+ {group.status === "planned" ? (
136
+ <ul className="mt-4 grid list-none md:grid-cols-2 md:gap-x-10">
137
+ {group.entries.map((item, index) => (
138
+ <li
139
+ className="flex gap-3 border-b border-border py-[11px]"
140
+ key={`${item.feature}-${index}`}
141
+ >
142
+ <span
143
+ aria-hidden
144
+ className="mt-[7px] size-[5px] shrink-0 rounded-4xl border border-muted-foreground"
145
+ />
146
+ <span>
147
+ <span className="text-[15px] text-foreground">{item.feature}</span>
148
+ {item.description ? (
149
+ <span className="block text-[13.5px] leading-[1.55] text-muted-foreground">
150
+ {item.description}
151
+ </span>
152
+ ) : null}
153
+ </span>
154
+ </li>
155
+ ))}
156
+ </ul>
157
+ ) : null}
158
+
159
+ {group.status === "exploring" || group.status === "future" ? (
160
+ <ul className="mt-4 flex list-none flex-wrap gap-2">
161
+ {group.entries.map((item, index) => {
162
+ const chip = (
163
+ <span
164
+ className={cn(
165
+ "inline-block rounded-sm border border-dashed border-border px-3 py-[7px] text-sm text-muted-foreground",
166
+ item.description && "cursor-help"
167
+ )}
168
+ >
169
+ {item.feature}
170
+ </span>
171
+ )
172
+ return (
173
+ <li key={`${item.feature}-${index}`}>
174
+ {item.description ? (
175
+ <Tooltip>
176
+ <TooltipTrigger render={chip} />
177
+ <TooltipContent>{item.description}</TooltipContent>
178
+ </Tooltip>
179
+ ) : (
180
+ chip
181
+ )}
182
+ </li>
183
+ )
184
+ })}
185
+ </ul>
186
+ ) : null}
187
+
188
+ {group.status === "shipped" ? (
189
+ // Quieter than every other bucket on purpose: 15 rows of
190
+ // reassurance that people skim rather than read.
191
+ <ul className="mt-4 grid list-none md:grid-cols-2 md:gap-x-10">
192
+ {group.entries.map((item, index) => (
193
+ <li
194
+ className="flex gap-3 border-b border-border/60 py-2"
195
+ key={`${item.feature}-${index}`}
196
+ >
197
+ <Check aria-hidden className="mt-[3px] size-3.5 shrink-0 text-primary" />
198
+ <span>
199
+ <span className="text-[14.5px] text-muted-foreground">{item.feature}</span>
200
+ {item.description ? (
201
+ <span className="block text-[13px] leading-[1.5] text-muted-foreground/80">
202
+ {item.description}
203
+ </span>
204
+ ) : null}
205
+ </span>
206
+ </li>
207
+ ))}
208
+ </ul>
209
+ ) : null}
86
210
  </div>
87
- )
88
- })}
89
- </div>
211
+ ))}
212
+ </div>
213
+ </Container>
90
214
  </section>
91
215
  )
92
216
  }
217
+
218
+ /**
219
+ * `select` stores an array; the first entry is the selection.
220
+ *
221
+ * No validation or casting: codegen emits the literal union straight from the
222
+ * config's options, so `status[0]` is already narrowed and an unhandled value
223
+ * is a compile error rather than a runtime fallback.
224
+ *
225
+ * @param status - The stored `status` value.
226
+ * @returns The selected status, defaulting to `planned` for an empty array.
227
+ */
228
+ function readStatus(status: RoadmapItemStatus[]): RoadmapItemStatus {
229
+ return status[0] ?? "planned"
230
+ }
231
+
232
+ /**
233
+ * Status badge. All four share one geometry and differ only in border, fill,
234
+ * and leading glyph — no second accent hue is introduced, and status is never
235
+ * carried by colour alone.
236
+ */
237
+ function StatusBadge({ status }: { status: RoadmapItemStatus }) {
238
+ const base =
239
+ "inline-flex items-center gap-1.5 rounded-4xl border px-2.5 py-[3px] text-xs font-medium tracking-[0.04em] uppercase"
240
+
241
+ if (status === "in-progress") {
242
+ return (
243
+ <span className={cn(base, "border-primary/40 bg-primary/12 text-primary")}>
244
+ {/* The one blessed decorative loop on the site. */}
245
+ <span aria-hidden className="live-dot size-[5px] rounded-4xl bg-primary" />
246
+ {GROUP_LABEL[status]}
247
+ </span>
248
+ )
249
+ }
250
+ if (status === "planned") {
251
+ return (
252
+ <span className={cn(base, "border-border text-muted-foreground")}>
253
+ <span
254
+ aria-hidden
255
+ className="size-[5px] rounded-4xl border border-current bg-transparent"
256
+ />
257
+ {GROUP_LABEL[status]}
258
+ </span>
259
+ )
260
+ }
261
+ if (status === "exploring") {
262
+ // Dashed is the "not committed" signal.
263
+ return (
264
+ <span className={cn(base, "border-dashed border-border text-muted-foreground")}>
265
+ {GROUP_LABEL[status]}
266
+ </span>
267
+ )
268
+ }
269
+ return (
270
+ <span className={cn(base, "border-border bg-card text-muted-foreground")}>
271
+ <Check aria-hidden className="size-3 text-primary" />
272
+ {GROUP_LABEL[status]}
273
+ </span>
274
+ )
275
+ }
@@ -0,0 +1,62 @@
1
+ import { array, defineBlock, group, select, text, upload } from "@vexcms/core"
2
+
3
+ import { TABLE_SLUG_IMAGES } from "~/db/constants"
4
+
5
+ import { BLOCK_SLUG_SPLIT } from "../constants"
6
+
7
+ export const splitBlock = defineBlock({
8
+ slug: BLOCK_SLUG_SPLIT,
9
+ label: "Split",
10
+ fields: {
11
+ eyebrow: text({ label: "Eyebrow" }),
12
+ heading: text({ label: "Heading", required: true }),
13
+ body: text({ label: "Body", required: true }),
14
+ bullets: array({
15
+ label: "Bullets",
16
+ items: group({
17
+ label: "Bullet",
18
+ fields: {
19
+ icon: text({ label: "Icon", description: "Lucide icon name" }),
20
+ text: text({ label: "Text", required: true }),
21
+ },
22
+ }),
23
+ }),
24
+ media: select({
25
+ label: "Media",
26
+ description:
27
+ 'Code is the variant the seed uses. Image falls back to None when no image is set, so a scaffold with an empty media library never shows a broken frame.',
28
+ options: [
29
+ { label: "Code", value: "code" },
30
+ { label: "Image", value: "image" },
31
+ { label: "None", value: "none" },
32
+ ],
33
+ defaultValue: ["code"],
34
+ }),
35
+ code: text({ label: "Code" }),
36
+ codeFilename: text({ label: "Code Filename" }),
37
+ codeLanguage: select({
38
+ label: "Code Language",
39
+ options: [
40
+ { label: "TypeScript", value: "ts" },
41
+ { label: "TSX", value: "tsx" },
42
+ { label: "Shell", value: "bash" },
43
+ { label: "JSON", value: "json" },
44
+ ],
45
+ defaultValue: ["ts"],
46
+ }),
47
+ image: upload({ label: "Image", to: TABLE_SLUG_IMAGES }),
48
+ mediaPosition: select({
49
+ label: "Media Position",
50
+ description:
51
+ "Consecutive Splits alternate by this field, not by an nth-child rule, so an editor can break the alternation.",
52
+ options: [
53
+ { label: "Right", value: "right" },
54
+ { label: "Left", value: "left" },
55
+ ],
56
+ defaultValue: ["right"],
57
+ }),
58
+ },
59
+ admin: {
60
+ icon: "Layers",
61
+ },
62
+ })
@@ -0,0 +1,186 @@
1
+ import type { BlockComponentProps } from "@vexcms/react"
2
+
3
+ import { cn } from "@vexcms/react"
4
+ import { icons } from "lucide-react"
5
+
6
+ import type { SplitBlock } from "~/vex.types"
7
+
8
+ import { CodePane } from "~/components/CodePane"
9
+ import { Container } from "~/components/Container"
10
+ import { MediaImage } from "~/components/MediaImage"
11
+
12
+ export { splitBlock } from "./config"
13
+
14
+
15
+ /** Above this many bullets the list splits into two columns inside the text
16
+ * column rather than running to an unreadable length. */
17
+ const BULLET_COLUMN_THRESHOLD = 8
18
+
19
+ /**
20
+ * Split — argument left, evidence right. Then swap.
21
+ *
22
+ * `mediaPosition: "left"` is expressed with order classes rather than a
23
+ * reversed source order, so the reading order stays text-first for assistive
24
+ * technology no matter which side the media sits on.
25
+ */
26
+ export default function SplitBlock({ block }: BlockComponentProps) {
27
+ const {
28
+ body,
29
+ bullets,
30
+ code,
31
+ codeFilename,
32
+ codeLanguage,
33
+ eyebrow,
34
+ heading,
35
+ image,
36
+ media,
37
+ mediaPosition,
38
+ } = block as SplitBlock
39
+
40
+ const requested = readOption(media, ["code", "image", "none"] as const, "code")
41
+ // A fresh scaffold has an empty media library. Rather than paint a
42
+ // placeholder box, an image variant with no image degrades to the prose
43
+ // layout, which is a legitimate quiet section.
44
+ const resolved =
45
+ requested === "code" && !code ? "none" : requested === "image" && !image ? "none" : requested
46
+ const position = readOption(mediaPosition, ["left", "right"] as const, "right")
47
+ const items = bullets ?? []
48
+
49
+ const bulletList =
50
+ items.length > 0 ? (
51
+ <ul
52
+ className={cn(
53
+ "flex list-none flex-col gap-3.5",
54
+ items.length >= BULLET_COLUMN_THRESHOLD && "md:grid md:grid-cols-2 md:gap-x-8"
55
+ )}
56
+ >
57
+ {items.map((bullet, index) => {
58
+ const Glyph = bullet.icon ? icons[bullet.icon as keyof typeof icons] : undefined
59
+ return (
60
+ <li className="flex items-start gap-3" key={`${bullet.text}-${index}`}>
61
+ <span className="mt-[3px] flex size-4 shrink-0 items-center justify-center">
62
+ {Glyph ? (
63
+ <Glyph className="size-4 text-primary" />
64
+ ) : (
65
+ // Keeps the 16px indent so mixed arrays stay aligned.
66
+ <span className="size-1 rounded-4xl bg-primary" />
67
+ )}
68
+ </span>
69
+ <span className="text-[15px] leading-[1.6] text-foreground">{bullet.text}</span>
70
+ </li>
71
+ )
72
+ })}
73
+ </ul>
74
+ ) : null
75
+
76
+ const textColumn = (
77
+ <div className={cn("min-w-0", position === "left" && "xl:order-2")}>
78
+ {eyebrow ? (
79
+ <p className="text-xs font-medium tracking-[0.08em] text-muted-foreground uppercase">
80
+ {eyebrow}
81
+ </p>
82
+ ) : null}
83
+ <h2
84
+ className={cn(
85
+ "text-[26px] leading-[1.18] font-bold tracking-[-0.025em] text-balance text-foreground md:text-3xl xl:max-w-[20ch] xl:text-4xl",
86
+ eyebrow && "mt-4"
87
+ )}
88
+ >
89
+ {heading}
90
+ </h2>
91
+ <p className="mt-4 max-w-[46rem] text-base leading-[1.65] text-pretty text-muted-foreground">
92
+ {body}
93
+ </p>
94
+ {resolved !== "none" && bulletList ? <div className="mt-7">{bulletList}</div> : null}
95
+ </div>
96
+ )
97
+
98
+ if (resolved === "none") {
99
+ // No media: bullets move into their own column as a divided list. With no
100
+ // bullets either, the block collapses to a single prose measure.
101
+ return (
102
+ <section className="py-14 md:py-20 xl:py-28">
103
+ <Container>
104
+ {bulletList ? (
105
+ <div className="grid gap-8 xl:grid-cols-[7fr_5fr] xl:gap-14">
106
+ {textColumn}
107
+ <ul className="flex list-none flex-col border-border xl:border-l xl:pl-7">
108
+ {items.map((bullet, index) => {
109
+ const Glyph = bullet.icon ? icons[bullet.icon as keyof typeof icons] : undefined
110
+ return (
111
+ <li
112
+ className="flex items-start gap-3 border-b border-border py-3 last:border-b-0"
113
+ key={`divided-${bullet.text}-${index}`}
114
+ >
115
+ <span className="mt-[3px] flex size-4 shrink-0 items-center justify-center">
116
+ {Glyph ? (
117
+ <Glyph className="size-4 text-primary" />
118
+ ) : (
119
+ <span className="size-1 rounded-4xl bg-primary" />
120
+ )}
121
+ </span>
122
+ <span className="text-[15px] leading-[1.6] text-foreground">
123
+ {bullet.text}
124
+ </span>
125
+ </li>
126
+ )
127
+ })}
128
+ </ul>
129
+ </div>
130
+ ) : (
131
+ <div className="max-w-[46rem]">{textColumn}</div>
132
+ )}
133
+ </Container>
134
+ </section>
135
+ )
136
+ }
137
+
138
+ return (
139
+ <section className="py-14 md:py-20 xl:py-28">
140
+ <Container>
141
+ <div
142
+ className={cn(
143
+ "grid items-start gap-8 md:grid-cols-2 md:gap-10 xl:items-center xl:gap-14",
144
+ position === "left" ? "xl:grid-cols-[6fr_5fr]" : "xl:grid-cols-[5fr_6fr]"
145
+ )}
146
+ >
147
+ {textColumn}
148
+
149
+ <div className={cn("min-w-0", position === "left" && "xl:order-1")}>
150
+ {resolved === "code" && code ? (
151
+ <div className="overflow-hidden rounded-md border border-border">
152
+ <CodePane
153
+ bodyClassName="max-h-[360px] md:max-h-[460px]"
154
+ code={code}
155
+ filename={codeFilename}
156
+ language={codeLanguage}
157
+ />
158
+ </div>
159
+ ) : null}
160
+
161
+ {resolved === "image" && image ? (
162
+ <div className="overflow-hidden rounded-md border border-border bg-card">
163
+ <MediaImage
164
+ alt=""
165
+ className="aspect-[4/3] w-full object-cover"
166
+ height={720}
167
+ value={image}
168
+ width={960}
169
+ />
170
+ </div>
171
+ ) : null}
172
+ </div>
173
+ </div>
174
+ </Container>
175
+ </section>
176
+ )
177
+ }
178
+
179
+ function readOption<T extends string>(
180
+ value: string | string[] | undefined,
181
+ allowed: readonly T[],
182
+ fallback: T
183
+ ): T {
184
+ const raw = Array.isArray(value) ? value[0] : value
185
+ return (allowed as readonly string[]).includes(raw ?? "") ? (raw as T) : fallback
186
+ }
@@ -0,0 +1,35 @@
1
+ import { array, defineBlock, group, text } from "@vexcms/core"
2
+
3
+ import { BLOCK_SLUG_STATS } from "../constants"
4
+
5
+ export const statsBlock = defineBlock({
6
+ slug: BLOCK_SLUG_STATS,
7
+ label: "Stats",
8
+ fields: {
9
+ heading: text({
10
+ label: "Heading",
11
+ description: "Optional. Leave blank to let the numbers be the whole section.",
12
+ }),
13
+ subheading: text({ label: "Subheading" }),
14
+ items: array({
15
+ label: "Stats",
16
+ required: true,
17
+ items: group({
18
+ label: "Stat",
19
+ fields: {
20
+ value: text({
21
+ label: "Value",
22
+ required: true,
23
+ description:
24
+ 'Free text, not a number — "0", "12" and "~30s" all render. Any unit belongs in the string.',
25
+ }),
26
+ label: text({ label: "Label", required: true }),
27
+ description: text({ label: "Description" }),
28
+ },
29
+ }),
30
+ }),
31
+ },
32
+ admin: {
33
+ icon: "Gauge",
34
+ },
35
+ })