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
@@ -0,0 +1,98 @@
1
+ "use client"
2
+
3
+ import { cn } from "@vexcms/react"
4
+
5
+ import { useCodeHighlight } from "~/components/CodeHighlightContext"
6
+ import { CopyButton } from "~/components/CopyButton"
7
+ import { toCodeLanguage } from "~/lib/highlight"
8
+
9
+ export type CodePaneProps = {
10
+ /** Extra classes for the scroll body. */
11
+ bodyClassName?: string
12
+ /** Source text exactly as authored in the CMS. */
13
+ code: string
14
+ /** Optional filename shown in the chrome bar. */
15
+ filename?: string
16
+ /** Uppercase pane label. `text-primary` marks the hand-written pane. */
17
+ label?: string
18
+ /** Raw `language` field value — a `select`, so usually an array. */
19
+ language?: string | string[]
20
+ /** Marks this pane as the authored one, which is the only colour signal
21
+ * distinguishing human input from generated output. */
22
+ tone?: "authored" | "generated"
23
+ }
24
+
25
+ /**
26
+ * One code pane: a chrome bar and a scrolling body on the fixed dark surface.
27
+ *
28
+ * Shared by CodeShowcase and Split so the two never drift.
29
+ *
30
+ * Synchronous, and a client component, because it renders inside
31
+ * `PageContent` — which is client-side so live preview can overlay its query
32
+ * result. Highlighting therefore happens outside it: the server pre-highlights
33
+ * the fetched document's panes (`highlightPageBlocks`) and this reads the
34
+ * result through `useCodeHighlight`. An async component here is the error
35
+ * "<CodePane> is an async Client Component".
36
+ */
37
+ export function CodePane({
38
+ bodyClassName,
39
+ code,
40
+ filename,
41
+ language,
42
+ label,
43
+ tone = "generated",
44
+ }: CodePaneProps) {
45
+ const resolvedLanguage = toCodeLanguage(language)
46
+ const html = useCodeHighlight({ code, language: resolvedLanguage })
47
+
48
+ return (
49
+ // `min-w-0` is not optional. In a `1fr` grid track the longest line would
50
+ // otherwise set the min-content width, the column would outgrow the frame,
51
+ // and `overflow-x-auto` would never engage — the code gets clipped instead
52
+ // of scrolling.
53
+ <div className="flex min-w-0 flex-col">
54
+ <div className="flex h-10 shrink-0 items-center justify-between gap-3 border-b border-border bg-card px-3.5">
55
+ <div className="flex min-w-0 items-center gap-3">
56
+ {label ? (
57
+ <span
58
+ className={cn(
59
+ "shrink-0 text-[11px] font-medium tracking-[0.08em] uppercase",
60
+ tone === "authored" ? "text-primary" : "text-muted-foreground"
61
+ )}
62
+ >
63
+ {label}
64
+ </span>
65
+ ) : null}
66
+ {filename ? (
67
+ <span className="hidden truncate font-mono text-xs text-muted-foreground sm:block">
68
+ {filename}
69
+ </span>
70
+ ) : null}
71
+ </div>
72
+
73
+ <div className="flex shrink-0 items-center gap-1.5">
74
+ <span className="rounded-sm border border-border px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground">
75
+ {resolvedLanguage}
76
+ </span>
77
+ <CopyButton label={`Copy ${filename ?? "code"}`} value={code} />
78
+ </div>
79
+ </div>
80
+
81
+ <div
82
+ className={cn(
83
+ "min-w-0 flex-1 overflow-auto overscroll-x-contain bg-[--color-code-bg] p-4 xl:px-4 xl:py-[18px]",
84
+ "max-h-[420px] md:max-h-[560px]",
85
+ bodyClassName
86
+ )}
87
+ >
88
+ <pre className="font-mono text-xs leading-[1.7] whitespace-pre text-[--color-code-fg] xl:text-[12.5px]">
89
+ {/* Pre-highlighted on the server for anything the page fetched. A
90
+ pane the server never saw (code being typed in live preview)
91
+ renders as plain source until the lazy browser highlighter
92
+ resolves — never blank, never a spinner over a code block. */}
93
+ {html === undefined ? <code>{code}</code> : <code dangerouslySetInnerHTML={{ __html: html }} />}
94
+ </pre>
95
+ </div>
96
+ </div>
97
+ )
98
+ }
@@ -0,0 +1,25 @@
1
+ import type { ReactNode } from "react"
2
+
3
+ import { cn } from "@vexcms/react"
4
+
5
+ /**
6
+ * The site's single container. Every block uses it, so the left edge of a
7
+ * heading in one block lines up with the left edge of a card in the next.
8
+ *
9
+ * `max-w-[1280px]` with 20 / 32 / 40px gutters. Nothing changes above `xl`
10
+ * except centring. Prose-only sections nest a second `max-w-[46rem]` measure
11
+ * inside this one rather than narrowing it.
12
+ */
13
+ export function Container({
14
+ children,
15
+ className,
16
+ }: {
17
+ children: ReactNode
18
+ className?: string
19
+ }) {
20
+ return (
21
+ <div className={cn("mx-auto w-full max-w-[1280px] px-5 md:px-8 xl:px-10", className)}>
22
+ {children}
23
+ </div>
24
+ )
25
+ }
@@ -0,0 +1,80 @@
1
+ "use client"
2
+
3
+ import { Button, cn, Tooltip, TooltipContent, TooltipTrigger } from "@vexcms/react"
4
+ import { Check, Copy } from "lucide-react"
5
+ import { useEffect, useRef, useState } from "react"
6
+
7
+ /** How long the confirmed state holds before reverting to the copy glyph. */
8
+ const CONFIRM_MS = 1600
9
+
10
+ /**
11
+ * Copy-to-clipboard control shared by the hero install row and every code
12
+ * pane, so the confirmation timing and the screen-reader announcement are
13
+ * identical everywhere they appear.
14
+ *
15
+ * @param props - Component props.
16
+ * @param props.value - Exact string written to the clipboard. Callers strip
17
+ * decoration (the `$` prompt, chrome) before passing it.
18
+ * @param props.label - Accessible name for the control.
19
+ * @param props.className - Extra classes for the trigger.
20
+ */
21
+ export function CopyButton({
22
+ className,
23
+ label = "Copy",
24
+ value,
25
+ }: {
26
+ className?: string
27
+ label?: string
28
+ value: string
29
+ }) {
30
+ const [isCopied, setIsCopied] = useState(false)
31
+ const timeoutRef = useRef<null | number>(null)
32
+
33
+ useEffect(() => {
34
+ return () => {
35
+ window.clearTimeout(timeoutRef.current ?? undefined)
36
+ }
37
+ }, [])
38
+
39
+ const handleCopy = () => {
40
+ void navigator.clipboard.writeText(value).then(() => {
41
+ setIsCopied(true)
42
+ window.clearTimeout(timeoutRef.current ?? undefined)
43
+ timeoutRef.current = window.setTimeout(() => setIsCopied(false), CONFIRM_MS)
44
+ })
45
+ }
46
+
47
+ const trigger = (
48
+ <Button
49
+ aria-label={label}
50
+ className={cn(
51
+ "shrink-0 transition-colors duration-[180ms] ease-[var(--ease-emphasized)] active:translate-y-px",
52
+ isCopied && "text-primary",
53
+ className
54
+ )}
55
+ onClick={handleCopy}
56
+ size="icon-xs"
57
+ type="button"
58
+ variant="ghost"
59
+ >
60
+ {isCopied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
61
+ </Button>
62
+ )
63
+
64
+ return (
65
+ <>
66
+ {/* Tooltips are pointer affordances; the live region is what carries the
67
+ confirmation to assistive tech. */}
68
+ <span aria-live="polite" className="sr-only">
69
+ {isCopied ? "Copied" : ""}
70
+ </span>
71
+ <span className="hidden md:contents">
72
+ <Tooltip>
73
+ <TooltipTrigger render={trigger} />
74
+ <TooltipContent>{isCopied ? "Copied" : label}</TooltipContent>
75
+ </Tooltip>
76
+ </span>
77
+ <span className="contents md:hidden">{trigger}</span>
78
+ </>
79
+ )
80
+ }
@@ -0,0 +1,54 @@
1
+ "use client"
2
+
3
+ import { api } from "@convex/_generated/api"
4
+ import { useMutation, useQuery } from "convex/react"
5
+ import { useRouter } from "next/navigation"
6
+ import { useEffect, useRef } from "react"
7
+
8
+ import { useSession } from "~/auth/client"
9
+
10
+ /**
11
+ * Promotes the first signed-in user to admin when no admin exists yet, then
12
+ * redirects to `/admin`. Headless — renders nothing.
13
+ *
14
+ * `WelcomePage`'s own trigger (`convex/vex/firstUser.ts`'s
15
+ * `promoteFirstAdmin`) only runs while `PageContent` is showing its
16
+ * no-page-found fallback — which stops rendering the instant a `home` page
17
+ * document exists. A fresh marketing scaffold gets a `home` document from
18
+ * `pnpm seed` on day one, so that fallback may never mount again and the
19
+ * bootstrap trigger would never fire. This mounts unconditionally in
20
+ * `(frontend)/layout.tsx` instead, so the trigger survives seeding.
21
+ *
22
+ * `promoteFirstAdmin` is a Convex mutation, so concurrent signups cannot
23
+ * both win — Convex serializes mutations, and the loser observes
24
+ * `isBootstrapped` already true.
25
+ */
26
+ export function FirstAdminBootstrap() {
27
+ const isBootstrapped = useQuery(api.vex.firstUser.isBootstrapped)
28
+ const promoteFirstAdmin = useMutation(api.vex.firstUser.promoteFirstAdmin)
29
+ const { data: session } = useSession()
30
+ const router = useRouter()
31
+ // A ref, not state: this is a run-once latch, and nothing renders from it.
32
+ // Setting state inside the effect re-runs the effect to reach the same
33
+ // conclusion, which is what `react-hooks/set-state-in-effect` objects to.
34
+ const promotingRef = useRef(false)
35
+
36
+ useEffect(() => {
37
+ if (!session?.user || isBootstrapped !== false || promotingRef.current) {return}
38
+
39
+ promotingRef.current = true
40
+ promoteFirstAdmin()
41
+ .then((result) => {
42
+ if (result.promoted) {
43
+ router.push("/admin")
44
+ } else {
45
+ promotingRef.current = false
46
+ }
47
+ })
48
+ .catch(() => {
49
+ promotingRef.current = false
50
+ })
51
+ }, [session, isBootstrapped, promoteFirstAdmin, router])
52
+
53
+ return null
54
+ }
@@ -0,0 +1,29 @@
1
+ "use client"
2
+
3
+ import { CopyButton } from "~/components/CopyButton"
4
+
5
+ /**
6
+ * The hero's install row — a mono command on the code-pane surface with a copy
7
+ * control. The only client component in the hero.
8
+ *
9
+ * The `$` prompt is decoration: it is `aria-hidden` and never part of the
10
+ * copied string, so pasting the result runs.
11
+ *
12
+ * @param props - Component props.
13
+ * @param props.command - The command to display and copy.
14
+ */
15
+ export function InstallCommand({ command }: { command: string }) {
16
+ return (
17
+ <div className="flex w-full max-w-full items-center gap-2 rounded-sm border border-border bg-[--color-code-bg] py-1.5 pr-1.5 pl-3 sm:w-auto">
18
+ <span aria-hidden className="shrink-0 font-mono text-[13px] text-muted-foreground">
19
+ $
20
+ </span>
21
+ {/* Below 640 the command scrolls rather than wrapping to two lines — a
22
+ wrapped shell command reads as two commands. */}
23
+ <code className="overflow-x-auto overscroll-x-contain font-mono text-[13px] whitespace-pre text-[--color-code-fg]">
24
+ {command}
25
+ </code>
26
+ <CopyButton label="Copy install command" value={command} />
27
+ </div>
28
+ )
29
+ }
@@ -0,0 +1,59 @@
1
+ "use client"
2
+
3
+ import { convexQuery } from "@convex-dev/react-query"
4
+ import { api } from "@convex/_generated/api"
5
+ import { useQuery } from "@tanstack/react-query"
6
+ import Image from "next/image"
7
+
8
+ /**
9
+ * Renders an `upload()` field value as a `next/image`.
10
+ *
11
+ * An `upload()` field stores an **array of media ids**, not a URL, so the id
12
+ * has to be exchanged for a public URL through the storage adapter before
13
+ * anything can be rendered. Passing the field value straight to `src` sets it
14
+ * to a Convex document id and renders a broken image.
15
+ *
16
+ * Client-side by design: it is used from both the client Header and the
17
+ * server Footer, and one component with one code path beats a server and a
18
+ * client variant that can drift.
19
+ *
20
+ * Renders nothing while resolving, and nothing if the media document is gone —
21
+ * callers pair it with a text fallback rather than reserving empty space.
22
+ *
23
+ * @param props - Component props.
24
+ * @param props.value - Raw `upload()` field value.
25
+ * @param props.alt - Accessible name. Empty string for decorative marks.
26
+ * @param props.width - Intrinsic width hint for `next/image`.
27
+ * @param props.height - Intrinsic height hint for `next/image`.
28
+ * @param props.className - Classes applied to the rendered image.
29
+ */
30
+ export function MediaImage({
31
+ alt,
32
+ className,
33
+ height,
34
+ value,
35
+ width,
36
+ }: {
37
+ alt: string
38
+ className?: string
39
+ height: number
40
+ value: string | string[] | undefined
41
+ width: number
42
+ }) {
43
+ const mediaId = Array.isArray(value) ? value[0] : value
44
+
45
+ const { data } = useQuery({
46
+ ...convexQuery(
47
+ api.vex.media.getUrl,
48
+ mediaId ? { adapter: "convex", mediaId } : "skip"
49
+ ),
50
+ enabled: Boolean(mediaId),
51
+ })
52
+
53
+ const url = (data as undefined | { url?: string })?.url
54
+ if (!url) {return null}
55
+
56
+ return (
57
+ <Image alt={alt} className={className} height={height} src={url} width={width} />
58
+ )
59
+ }
@@ -0,0 +1,51 @@
1
+ import { cn } from "@vexcms/react"
2
+
3
+ /**
4
+ * The one section-header pattern: left-aligned, capped at a 36rem measure,
5
+ * h2 then subheading, 40px above the block's content.
6
+ *
7
+ * When `subheading` is blank the gap drops to 32px rather than the h2 growing
8
+ * to compensate — every block that has a heading uses this, so the rhythm has
9
+ * to be decided once.
10
+ *
11
+ * @param props - Component props.
12
+ * @param props.heading - Section h2.
13
+ * @param props.subheading - Optional supporting line.
14
+ * @param props.align - `center` is used only by FAQ and CTA.
15
+ * @param props.className - Extra classes on the wrapper.
16
+ */
17
+ export function SectionHeader({
18
+ align = "left",
19
+ className,
20
+ heading,
21
+ subheading,
22
+ }: {
23
+ align?: "center" | "left"
24
+ className?: string
25
+ heading?: string
26
+ subheading?: string
27
+ }) {
28
+ if (!heading && !subheading) {return null}
29
+
30
+ return (
31
+ <div
32
+ className={cn(
33
+ "max-w-[36rem]",
34
+ align === "center" && "mx-auto text-center",
35
+ subheading ? "mb-8 xl:mb-10" : "mb-7 xl:mb-8",
36
+ className
37
+ )}
38
+ >
39
+ {heading ? (
40
+ <h2 className="text-[26px] leading-[1.18] font-bold tracking-[-0.025em] text-balance text-foreground md:text-3xl xl:text-4xl">
41
+ {heading}
42
+ </h2>
43
+ ) : null}
44
+ {subheading ? (
45
+ <p className="mt-4 text-[17px] leading-[1.6] text-pretty text-muted-foreground md:text-lg">
46
+ {subheading}
47
+ </p>
48
+ ) : null}
49
+ </div>
50
+ )
51
+ }
@@ -1,9 +1,11 @@
1
1
  "use client"
2
2
 
3
- import { RenderBlocks, type RenderableBlock } from "@vexcms/react"
4
3
  import { convexQuery } from "@convex-dev/react-query"
4
+ import { api } from "@convex/_generated/api"
5
5
  import { useQuery } from "@tanstack/react-query"
6
- import { anyApi } from "convex/server"
6
+ import { type RenderableBlock, RenderBlocks } from "@vexcms/react"
7
+
8
+ import type { FootersDocument } from "~/vex.types"
7
9
 
8
10
  import { blockComponents } from "~/vexcms/blocks"
9
11
 
@@ -12,15 +14,15 @@ type PageBlockLike = RenderableBlock
12
14
  export function SiteFooter({
13
15
  initialData,
14
16
  }: {
15
- initialData?: Record<string, unknown> | null
17
+ initialData?: FootersDocument | null
16
18
  }) {
17
19
  const { data: footer } = useQuery({
18
- ...convexQuery(anyApi.footers.getFirst, {}),
20
+ ...convexQuery(api.footers.getFirst, {}),
19
21
  initialData: initialData ?? undefined,
20
22
  })
21
23
 
22
- const content = footer?.content as PageBlockLike[] | null | undefined
23
- if (!content) return null
24
+ const content = footer?.content as null | PageBlockLike[] | undefined
25
+ if (!content) {return null}
24
26
 
25
27
  return (
26
28
  <RenderBlocks
@@ -1,9 +1,11 @@
1
1
  "use client"
2
2
 
3
- import { RenderBlocks, type RenderableBlock } from "@vexcms/react"
4
3
  import { convexQuery } from "@convex-dev/react-query"
4
+ import { api } from "@convex/_generated/api"
5
5
  import { useQuery } from "@tanstack/react-query"
6
- import { anyApi } from "convex/server"
6
+ import { type RenderableBlock, RenderBlocks } from "@vexcms/react"
7
+
8
+ import type { HeadersDocument } from "~/vex.types"
7
9
 
8
10
  import { blockComponents } from "~/vexcms/blocks"
9
11
 
@@ -12,15 +14,15 @@ type PageBlockLike = RenderableBlock
12
14
  export function SiteHeader({
13
15
  initialData,
14
16
  }: {
15
- initialData?: Record<string, unknown> | null
17
+ initialData?: HeadersDocument | null
16
18
  }) {
17
19
  const { data: header } = useQuery({
18
- ...convexQuery(anyApi.headers.getFirst, {}),
20
+ ...convexQuery(api.headers.getFirst, {}),
19
21
  initialData: initialData ?? undefined,
20
22
  })
21
23
 
22
- const content = header?.content as PageBlockLike[] | null | undefined
23
- if (!content) return null
24
+ const content = header?.content as null | PageBlockLike[] | undefined
25
+ if (!content) {return null}
24
26
 
25
27
  return (
26
28
  <RenderBlocks
@@ -1,11 +1,10 @@
1
1
  "use client"
2
2
 
3
3
  import { api } from "@convex/_generated/api"
4
+ import { buildThemeCss, type ThemeScope } from "@vexcms/core"
4
5
  import { useQuery } from "convex/react"
5
6
  import { useEffect } from "react"
6
7
 
7
- import { buildThemeCss, type ThemeScope } from "@vexcms/core"
8
-
9
8
  /**
10
9
  * Client companion to the server `<ThemeStyle />`: keeps the applied theme
11
10
  * live without a page reload.
@@ -1,8 +1,8 @@
1
1
  import { api } from "@convex/_generated/api"
2
- import { fetchQuery } from "convex/nextjs"
3
-
4
2
  import { buildThemeCss, type ThemeScope } from "@vexcms/core"
5
3
 
4
+ import { vex } from "~/lib/vex"
5
+
6
6
  /**
7
7
  * Server component that inlines a theme's CSS custom properties.
8
8
  *
@@ -38,7 +38,7 @@ export async function ThemeStyle(props: { scope?: ThemeScope }) {
38
38
 
39
39
  let theme: null | Record<string, unknown> = null
40
40
  try {
41
- theme = await fetchQuery(scope === "admin" ? api.theme.getAdmin : api.theme.getActive)
41
+ theme = await vex.query(scope === "admin" ? api.theme.getAdmin : api.theme.getActive)
42
42
  } catch {
43
43
  // No deployment reachable at build time — fall back to globals.css.
44
44
  return null
@@ -1,138 +1,62 @@
1
- "use client"
1
+ import React, { type ComponentPropsWithRef, type CSSProperties } from "react"
2
2
 
3
- import { motion, type Variants } from "motion/react"
4
- import React, { type ComponentPropsWithRef } from "react"
3
+ import { cn } from "~/lib/utils"
5
4
 
6
- export type PresetType =
7
- | "blur"
8
- | "blur-slide"
9
- | "bounce"
10
- | "fade"
11
- | "flip"
12
- | "rotate"
13
- | "scale"
14
- | "slide"
15
- | "swing"
16
- | "zoom"
5
+ import styles from "./reveal.module.css"
17
6
 
18
7
  export type AnimatedGroupProps = ComponentPropsWithRef<"div"> & {
19
8
  as?: React.ElementType
20
- asChild?: React.ElementType
21
- className?: string
22
- preset?: PresetType
23
- variants?: {
24
- container?: Variants
25
- item?: Variants
26
- }
9
+ /** Seconds before the first child starts animating. */
10
+ delay?: number
11
+ /** Seconds between each child's start. */
12
+ stagger?: number
27
13
  }
28
14
 
29
- const defaultContainerVariants: Variants = {
30
- visible: {
31
- transition: {
32
- staggerChildren: 0.1,
33
- },
34
- },
35
- }
36
-
37
- const defaultItemVariants: Variants = {
38
- hidden: { opacity: 0 },
39
- visible: { opacity: 1 },
40
- }
41
-
42
- const presetVariants: Record<PresetType, Variants> = {
43
- "blur-slide": {
44
- hidden: { filter: "blur(4px)", y: 20 },
45
- visible: { filter: "blur(0px)", y: 0 },
46
- },
47
- slide: {
48
- hidden: { y: 20 },
49
- visible: { y: 0 },
50
- },
51
- blur: {
52
- hidden: { filter: "blur(4px)" },
53
- visible: { filter: "blur(0px)" },
54
- },
55
- bounce: {
56
- hidden: { y: -50 },
57
- visible: {
58
- transition: { type: "spring", damping: 10, stiffness: 400 },
59
- y: 0,
60
- },
61
- },
62
- fade: {},
63
- flip: {
64
- hidden: { rotateX: -90 },
65
- visible: {
66
- rotateX: 0,
67
- transition: { type: "spring", damping: 20, stiffness: 300 },
68
- },
69
- },
70
- rotate: {
71
- hidden: { rotate: -180 },
72
- visible: {
73
- rotate: 0,
74
- transition: { type: "spring", damping: 15, stiffness: 200 },
75
- },
76
- },
77
- scale: {
78
- hidden: { scale: 0.8 },
79
- visible: { scale: 1 },
80
- },
81
- swing: {
82
- hidden: { rotate: -10 },
83
- visible: {
84
- rotate: 0,
85
- transition: { type: "spring", damping: 8, stiffness: 300 },
86
- },
87
- },
88
- zoom: {
89
- hidden: { scale: 0.5 },
90
- visible: {
91
- scale: 1,
92
- transition: { type: "spring", damping: 20, stiffness: 300 },
93
- },
94
- },
95
- }
96
-
97
- const addDefaultVariants = (variants: Variants) => ({
98
- hidden: { ...defaultItemVariants.hidden, ...variants.hidden },
99
- visible: { ...defaultItemVariants.visible, ...variants.visible },
100
- })
101
-
102
- function AnimatedGroup({
103
- as = "div",
104
- asChild = "div",
15
+ /**
16
+ * Staggers a blur + fade + rise entrance across its direct children using a
17
+ * pure-CSS keyframe (see `reveal.module.css`) — no animation-library
18
+ * dependency. Each child is wrapped in a div carrying the animation with an
19
+ * incremented `animation-delay`.
20
+ */
21
+ export function AnimatedGroup({
22
+ as: Tag = "div",
105
23
  children,
106
24
  className,
107
- preset,
108
- variants,
25
+ delay = 0,
26
+ stagger = 0.05,
109
27
  ...divProps
110
28
  }: AnimatedGroupProps) {
111
- const selectedVariants = {
112
- container: addDefaultVariants(defaultContainerVariants),
113
- item: addDefaultVariants(preset ? presetVariants[preset] : {}),
114
- }
115
- const containerVariants = variants?.container ?? selectedVariants.container
116
- const itemVariants = variants?.item ?? selectedVariants.item
117
-
118
- const MotionComponent = motion.create(as)
119
- const MotionChild = motion.create(asChild)
120
-
121
29
  return (
122
- <MotionComponent
123
- animate="visible"
124
- className={className}
125
- initial="hidden"
126
- variants={containerVariants}
127
- {...divProps}
128
- >
30
+ <Tag className={className} {...divProps}>
129
31
  {React.Children.map(children, (child, index) => (
130
- <MotionChild key={index} variants={itemVariants}>
32
+ <div
33
+ className={styles.item}
34
+ style={{ "--reveal-delay": `${delay + index * stagger}s` } as CSSProperties}
35
+ >
131
36
  {child}
132
- </MotionChild>
37
+ </div>
133
38
  ))}
134
- </MotionComponent>
39
+ </Tag>
135
40
  )
136
41
  }
137
42
 
138
- export { AnimatedGroup }
43
+ /**
44
+ * Applies the same reveal animation to a single element without adding a
45
+ * wrapper div — for callers that only need one animated child.
46
+ */
47
+ export function AnimatedItem({
48
+ children,
49
+ className,
50
+ delay = 0,
51
+ ...divProps
52
+ }: ComponentPropsWithRef<"div"> & { delay?: number }) {
53
+ return (
54
+ <div
55
+ className={cn(styles.item, className)}
56
+ style={{ "--reveal-delay": `${delay}s` } as CSSProperties}
57
+ {...divProps}
58
+ >
59
+ {children}
60
+ </div>
61
+ )
62
+ }