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.
- package/README.md +48 -14
- package/dist/index.js +1714 -1504
- package/package.json +2 -2
- package/templates/base-nextjs/README.md +18 -5
- package/templates/base-nextjs/convex/auth/index.ts +2 -2
- package/templates/base-nextjs/convex/auth/options.ts +27 -26
- package/templates/base-nextjs/convex/auth/schema.ts +51 -0
- package/templates/base-nextjs/convex/auth/sessions.ts +3 -3
- package/templates/base-nextjs/convex/tsconfig.json +2 -2
- package/templates/base-nextjs/convex/vex/firstUser.ts +2 -2
- package/templates/base-nextjs/convex/vex/globals.ts +3 -2
- package/templates/base-nextjs/convex/vex/media.ts +3 -2
- package/templates/base-nextjs/convex/vex.schema.ts +1 -0
- package/templates/base-nextjs/convex/vex.ts +17 -4
- package/templates/base-nextjs/next.config.ts +52 -6
- package/templates/base-nextjs/package.json +18 -7
- package/templates/base-nextjs/public/favicons/favicon.ico +0 -0
- package/templates/base-nextjs/src/app/(frontend)/@auth/(...)auth/[pathname]/view.tsx +27 -15
- package/templates/base-nextjs/src/app/(vexcms)/admin/[[...path]]/page.tsx +2 -2
- package/templates/base-nextjs/src/app/(vexcms)/admin/clientProviders.tsx +9 -14
- package/templates/base-nextjs/src/app/(vexcms)/admin/layout.tsx +8 -6
- package/templates/base-nextjs/src/app/api/vex/revalidate/route.ts +19 -0
- package/templates/base-nextjs/src/app/globals.css +10 -9
- package/templates/base-nextjs/src/app/layout.tsx +6 -0
- package/templates/base-nextjs/src/auth/access.ts +14 -2
- package/templates/base-nextjs/src/auth/serverUtils.ts +20 -6
- package/templates/base-nextjs/src/components/WelcomePage.tsx +54 -43
- package/templates/base-nextjs/src/components/component-example.tsx +1 -1
- package/templates/base-nextjs/src/components/providers/server.tsx +16 -8
- package/templates/base-nextjs/src/vex.config.server.ts +30 -0
- package/templates/base-nextjs/src/vex.config.ts +14 -11
- package/templates/base-nextjs/src/vex.types.ts +2 -1
- package/templates/base-nextjs/src/vexcms/collections/images.ts +1 -1
- package/templates/base-nextjs/src/vexcms/collections/users.ts +3 -1
- package/templates/marketing-site/convex/pages.ts +20 -1
- package/templates/marketing-site/convex/schema.ts +4 -4
- package/templates/marketing-site/convex/seed.ts +515 -70
- package/templates/marketing-site/convex/siteSettings.ts +1 -1
- package/templates/marketing-site/convex/theme.ts +3 -3
- package/templates/marketing-site/convex/vex.schema.ts +75 -38
- package/templates/marketing-site/public/favicon.svg +14 -0
- package/templates/marketing-site/public/favicons/apple-touch-icon.png +0 -0
- package/templates/marketing-site/public/favicons/favicon.ico +0 -0
- package/templates/marketing-site/src/app/(frontend)/(site)/PageContent.tsx +64 -0
- package/templates/marketing-site/src/app/(frontend)/(site)/SiteLivePreviewProvider.tsx +31 -0
- package/templates/marketing-site/src/app/(frontend)/(site)/[slug]/page.tsx +63 -0
- package/templates/marketing-site/src/app/(frontend)/(site)/layout.tsx +50 -0
- package/templates/marketing-site/src/app/(frontend)/(site)/page.tsx +33 -0
- package/templates/marketing-site/src/app/(frontend)/layout.tsx +10 -24
- package/templates/marketing-site/src/app/(vexcms)/admin/layout.tsx +32 -0
- package/templates/marketing-site/src/app/globals.css +413 -0
- package/templates/marketing-site/src/app/layout.tsx +85 -0
- package/templates/marketing-site/src/app/robots.ts +13 -0
- package/templates/marketing-site/src/app/sitemap.ts +18 -0
- package/templates/marketing-site/src/components/BrandMark.tsx +27 -0
- package/templates/marketing-site/src/components/CodeHighlightContext.tsx +76 -0
- package/templates/marketing-site/src/components/CodePane.tsx +98 -0
- package/templates/marketing-site/src/components/Container.tsx +25 -0
- package/templates/marketing-site/src/components/CopyButton.tsx +80 -0
- package/templates/marketing-site/src/components/FirstAdminBootstrap.tsx +54 -0
- package/templates/marketing-site/src/components/InstallCommand.tsx +29 -0
- package/templates/marketing-site/src/components/MediaImage.tsx +59 -0
- package/templates/marketing-site/src/components/SectionHeader.tsx +51 -0
- package/templates/marketing-site/src/components/SiteFooter.tsx +8 -6
- package/templates/marketing-site/src/components/SiteHeader.tsx +8 -6
- package/templates/marketing-site/src/components/ThemeLive.tsx +1 -2
- package/templates/marketing-site/src/components/ThemeStyle.tsx +3 -3
- package/templates/marketing-site/src/components/motion-primitives/animated-group.tsx +44 -120
- package/templates/marketing-site/src/components/motion-primitives/reveal.module.css +32 -0
- package/templates/marketing-site/src/components/motion-primitives/text-effect.tsx +47 -266
- package/templates/marketing-site/src/lib/codeHighlightKey.ts +27 -0
- package/templates/marketing-site/src/lib/highlight.ts +134 -0
- package/templates/marketing-site/src/lib/highlightClient.ts +37 -0
- package/templates/marketing-site/src/lib/metadata.ts +61 -33
- package/templates/marketing-site/src/lib/resolvePagePath.ts +8 -0
- package/templates/marketing-site/src/lib/vex.ts +18 -0
- package/templates/marketing-site/src/proxy.ts +179 -0
- package/templates/marketing-site/src/vex.config.server.ts +27 -0
- package/templates/marketing-site/src/vex.config.ts +27 -9
- package/templates/marketing-site/src/vex.types.ts +102 -76
- package/templates/marketing-site/src/vexcms/api.ts +2 -2
- package/templates/marketing-site/src/vexcms/blocks/CTA/config.ts +3 -2
- package/templates/marketing-site/src/vexcms/blocks/CTA/index.tsx +40 -24
- package/templates/marketing-site/src/vexcms/blocks/CodeShowcase/config.ts +62 -0
- package/templates/marketing-site/src/vexcms/blocks/CodeShowcase/index.tsx +106 -0
- package/templates/marketing-site/src/vexcms/blocks/FAQ/config.ts +4 -3
- package/templates/marketing-site/src/vexcms/blocks/FAQ/index.tsx +63 -50
- package/templates/marketing-site/src/vexcms/blocks/Features/config.ts +1 -0
- package/templates/marketing-site/src/vexcms/blocks/Features/index.tsx +43 -58
- package/templates/marketing-site/src/vexcms/blocks/Footer/config.ts +6 -3
- package/templates/marketing-site/src/vexcms/blocks/Footer/index.tsx +90 -53
- package/templates/marketing-site/src/vexcms/blocks/Header/config.ts +10 -3
- package/templates/marketing-site/src/vexcms/blocks/Header/index.tsx +205 -138
- package/templates/marketing-site/src/vexcms/blocks/Hero/config.ts +27 -12
- package/templates/marketing-site/src/vexcms/blocks/Hero/index.tsx +174 -119
- package/templates/marketing-site/src/vexcms/blocks/HowItWorks/config.ts +2 -1
- package/templates/marketing-site/src/vexcms/blocks/HowItWorks/index.tsx +59 -51
- package/templates/marketing-site/src/vexcms/blocks/Roadmap/config.ts +26 -5
- package/templates/marketing-site/src/vexcms/blocks/Roadmap/index.tsx +256 -73
- package/templates/marketing-site/src/vexcms/blocks/Split/config.ts +62 -0
- package/templates/marketing-site/src/vexcms/blocks/Split/index.tsx +186 -0
- package/templates/marketing-site/src/vexcms/blocks/Stats/config.ts +35 -0
- package/templates/marketing-site/src/vexcms/blocks/Stats/index.tsx +77 -0
- package/templates/marketing-site/src/vexcms/blocks/config.ts +27 -11
- package/templates/marketing-site/src/vexcms/blocks/constants.ts +3 -0
- package/templates/marketing-site/src/vexcms/blocks/index.ts +9 -0
- package/templates/marketing-site/src/vexcms/collections/footers.ts +1 -0
- package/templates/marketing-site/src/vexcms/collections/headers.ts +1 -0
- package/templates/marketing-site/src/vexcms/collections/pages.ts +23 -1
- package/templates/marketing-site/src/vexcms/collections/themeColors.ts +14 -14
- package/templates/marketing-site/src/vexcms/collections/users.ts +20 -0
- package/dist/index.mjs +0 -30976
- package/templates/marketing-site/src/app/(frontend)/PageContent.tsx +0 -53
- package/templates/marketing-site/src/app/(frontend)/[slug]/page.tsx +0 -25
- 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 {
|
|
6
|
-
import {
|
|
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,
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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 =
|
|
41
|
-
.
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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=
|
|
50
|
-
<
|
|
51
|
-
<
|
|
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
|
-
{
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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
|
-
</
|
|
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
|
+
})
|