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
|
@@ -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 {
|
|
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?:
|
|
17
|
+
initialData?: FootersDocument | null
|
|
16
18
|
}) {
|
|
17
19
|
const { data: footer } = useQuery({
|
|
18
|
-
...convexQuery(
|
|
20
|
+
...convexQuery(api.footers.getFirst, {}),
|
|
19
21
|
initialData: initialData ?? undefined,
|
|
20
22
|
})
|
|
21
23
|
|
|
22
|
-
const content = footer?.content as PageBlockLike[] |
|
|
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 {
|
|
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?:
|
|
17
|
+
initialData?: HeadersDocument | null
|
|
16
18
|
}) {
|
|
17
19
|
const { data: header } = useQuery({
|
|
18
|
-
...convexQuery(
|
|
20
|
+
...convexQuery(api.headers.getFirst, {}),
|
|
19
21
|
initialData: initialData ?? undefined,
|
|
20
22
|
})
|
|
21
23
|
|
|
22
|
-
const content = header?.content as PageBlockLike[] |
|
|
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
|
|
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
|
-
|
|
1
|
+
import React, { type ComponentPropsWithRef, type CSSProperties } from "react"
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
import React, { type ComponentPropsWithRef } from "react"
|
|
3
|
+
import { cn } from "~/lib/utils"
|
|
5
4
|
|
|
6
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
108
|
-
|
|
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
|
-
<
|
|
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
|
-
<
|
|
32
|
+
<div
|
|
33
|
+
className={styles.item}
|
|
34
|
+
style={{ "--reveal-delay": `${delay + index * stagger}s` } as CSSProperties}
|
|
35
|
+
>
|
|
131
36
|
{child}
|
|
132
|
-
</
|
|
37
|
+
</div>
|
|
133
38
|
))}
|
|
134
|
-
</
|
|
39
|
+
</Tag>
|
|
135
40
|
)
|
|
136
41
|
}
|
|
137
42
|
|
|
138
|
-
|
|
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
|
+
}
|