create-vexcms 0.0.7 → 0.0.9
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/package.json +1 -1
- package/templates/base-nextjs/convex/vex/globals.ts +82 -0
- package/templates/base-nextjs/convex/vex/versions.ts +66 -6
- package/templates/base-nextjs/package.json +8 -8
- package/templates/base-nextjs/src/app/admin/layout.tsx +29 -11
- package/templates/base-nextjs/src/db/constants/auth.ts +1 -3
- package/templates/base-nextjs/src/vexcms/access.ts +29 -13
- package/templates/base-nextjs/src/vexcms/collections/users.ts +2 -2
- package/templates/base-nextjs/vex.config.ts +2 -0
- package/templates/marketing-site/convex/footers.ts +14 -0
- package/templates/marketing-site/convex/headers.ts +14 -0
- package/templates/marketing-site/convex/siteSettings.ts +14 -0
- package/templates/marketing-site/convex/theme.ts +75 -0
- package/templates/marketing-site/public/favicons/favicon.ico +0 -0
- package/templates/marketing-site/src/app/(frontend)/PageContent.tsx +74 -0
- package/templates/marketing-site/src/app/(frontend)/[slug]/page.tsx +19 -51
- package/templates/marketing-site/src/app/(frontend)/layout.tsx +37 -0
- package/templates/marketing-site/src/app/(frontend)/page.tsx +24 -0
- package/templates/marketing-site/src/app/(frontend)/preview/[slug]/PreviewPageContent.tsx +55 -0
- package/templates/marketing-site/src/app/(frontend)/preview/[slug]/page.tsx +19 -63
- package/templates/marketing-site/src/app/(frontend)/preview/layout.tsx +18 -0
- package/templates/marketing-site/src/components/SiteFooter.tsx +28 -0
- package/templates/marketing-site/src/components/SiteHeader.tsx +28 -0
- package/templates/marketing-site/src/components/ThemeInjector.tsx +128 -0
- package/templates/marketing-site/src/components/ThemeStyle.tsx +104 -0
- package/templates/marketing-site/src/components/admin/IconPickerField.tsx +134 -0
- package/templates/marketing-site/src/components/motion-primitives/animated-group.tsx +138 -0
- package/templates/marketing-site/src/components/motion-primitives/text-effect.tsx +292 -0
- package/templates/marketing-site/src/lib/metadata.ts +104 -0
- package/templates/marketing-site/src/vexcms/access.ts +28 -0
- package/templates/marketing-site/src/vexcms/blocks/CTA/config.ts +36 -0
- package/templates/marketing-site/src/vexcms/blocks/CTA/index.tsx +46 -0
- package/templates/marketing-site/src/vexcms/blocks/FAQ/config.ts +70 -0
- package/templates/marketing-site/src/vexcms/blocks/FAQ/index.tsx +71 -0
- package/templates/marketing-site/src/vexcms/blocks/Features/config.ts +61 -0
- package/templates/marketing-site/src/vexcms/blocks/Features/index.tsx +73 -0
- package/templates/marketing-site/src/vexcms/blocks/Footer/config.ts +65 -0
- package/templates/marketing-site/src/vexcms/blocks/Footer/index.tsx +71 -0
- package/templates/marketing-site/src/vexcms/blocks/Header/config.ts +65 -0
- package/templates/marketing-site/src/vexcms/blocks/Header/index.tsx +174 -0
- package/templates/marketing-site/src/vexcms/blocks/Hero/config.ts +52 -0
- package/templates/marketing-site/src/vexcms/blocks/Hero/index.tsx +152 -0
- package/templates/marketing-site/src/vexcms/blocks/HowItWorks/config.ts +68 -0
- package/templates/marketing-site/src/vexcms/blocks/HowItWorks/index.tsx +74 -0
- package/templates/marketing-site/src/vexcms/blocks/Roadmap/config.ts +154 -0
- package/templates/marketing-site/src/vexcms/blocks/Roadmap/index.tsx +122 -0
- package/templates/marketing-site/src/vexcms/blocks/config.ts +40 -0
- package/templates/marketing-site/src/vexcms/blocks/constants.ts +9 -0
- package/templates/marketing-site/src/vexcms/blocks/index.ts +39 -0
- package/templates/marketing-site/src/vexcms/collections/footers.ts +11 -8
- package/templates/marketing-site/src/vexcms/collections/headers.ts +10 -14
- package/templates/marketing-site/src/vexcms/collections/index.ts +0 -1
- package/templates/marketing-site/src/vexcms/collections/pages.ts +30 -4
- package/templates/marketing-site/src/vexcms/collections/themes.ts +107 -14
- package/templates/marketing-site/src/vexcms/globals/index.ts +1 -0
- package/templates/marketing-site/src/vexcms/globals/siteSettings.ts +57 -0
- package/templates/marketing-site/vex.config.ts +5 -5
- package/templates/base-nextjs/src/auth/permissions.ts +0 -102
- package/templates/marketing-site/src/vexcms/collections/site_settings.ts +0 -31
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { fetchQuery } from "convex/nextjs"
|
|
2
|
+
|
|
3
|
+
import { api } from "@convex/_generated/api"
|
|
4
|
+
|
|
5
|
+
import { SiteFooter } from "~/components/SiteFooter"
|
|
6
|
+
import { SiteHeader } from "~/components/SiteHeader"
|
|
7
|
+
import { ThemeStyle } from "~/components/ThemeStyle"
|
|
8
|
+
|
|
9
|
+
export default async function FrontendLayout({
|
|
10
|
+
auth,
|
|
11
|
+
children,
|
|
12
|
+
}: Readonly<{
|
|
13
|
+
auth: React.ReactNode
|
|
14
|
+
children: React.ReactNode
|
|
15
|
+
}>) {
|
|
16
|
+
let headerData: Record<string, unknown> | null = null
|
|
17
|
+
let footerData: Record<string, unknown> | null = null
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
;[headerData, footerData] = await Promise.all([
|
|
21
|
+
fetchQuery(api.headers.getFirst),
|
|
22
|
+
fetchQuery(api.footers.getFirst),
|
|
23
|
+
])
|
|
24
|
+
} catch {
|
|
25
|
+
// Convex not available — fall back to client-only fetch
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<>
|
|
30
|
+
<ThemeStyle />
|
|
31
|
+
<SiteHeader initialData={headerData} />
|
|
32
|
+
<main>{children}</main>
|
|
33
|
+
<SiteFooter initialData={footerData} />
|
|
34
|
+
{auth}
|
|
35
|
+
</>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { fetchQuery } from "convex/nextjs"
|
|
2
|
+
|
|
3
|
+
import { api } from "@convex/_generated/api"
|
|
4
|
+
|
|
5
|
+
import { generatePageMetadata } from "~/lib/metadata"
|
|
6
|
+
import { PageContent } from "./PageContent"
|
|
7
|
+
|
|
8
|
+
export async function generateMetadata() {
|
|
9
|
+
return generatePageMetadata({ slug: "home" })
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export default async function HomePage() {
|
|
13
|
+
let initialData: Record<string, unknown> | null = null
|
|
14
|
+
try {
|
|
15
|
+
initialData = await fetchQuery(api.pages.getBySlug, {
|
|
16
|
+
slug: "home",
|
|
17
|
+
_vexDrafts: false,
|
|
18
|
+
})
|
|
19
|
+
} catch {
|
|
20
|
+
// Fall back to client-only fetch
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return <PageContent initialData={initialData} />
|
|
24
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { RenderBlocks, useVexPreview } from "@vexcms/ui"
|
|
4
|
+
import { convexQuery } from "@convex-dev/react-query"
|
|
5
|
+
import { useQuery } from "@tanstack/react-query"
|
|
6
|
+
import { anyApi } from "convex/server"
|
|
7
|
+
|
|
8
|
+
import { blockComponents } from "~/vexcms/blocks"
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Client component for live preview using TanStack Query.
|
|
12
|
+
* initialData from server fetch eliminates loading flash.
|
|
13
|
+
* The reactive subscription picks up draft snapshot changes in real-time.
|
|
14
|
+
*/
|
|
15
|
+
export function PreviewPageContent({
|
|
16
|
+
slug,
|
|
17
|
+
initialData,
|
|
18
|
+
}: {
|
|
19
|
+
slug: string
|
|
20
|
+
initialData?: Record<string, unknown> | null
|
|
21
|
+
}) {
|
|
22
|
+
const { data: page } = useQuery({
|
|
23
|
+
...convexQuery(anyApi.pages.getBySlug, {
|
|
24
|
+
slug,
|
|
25
|
+
_vexDrafts: "snapshot",
|
|
26
|
+
}),
|
|
27
|
+
initialData: initialData ?? undefined,
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
// Notify admin panel's live preview when data changes
|
|
31
|
+
useVexPreview({ data: page })
|
|
32
|
+
|
|
33
|
+
if (page === null || page === undefined) {
|
|
34
|
+
return (
|
|
35
|
+
<div className="mx-auto max-w-3xl px-4 py-12">
|
|
36
|
+
<h1 className="text-2xl font-bold">Preview not found</h1>
|
|
37
|
+
<p className="mt-2 text-muted-foreground">
|
|
38
|
+
No page with slug “{slug}” exists.
|
|
39
|
+
</p>
|
|
40
|
+
</div>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<>
|
|
46
|
+
{/* Preview banner */}
|
|
47
|
+
<div className="fixed top-0 left-0 right-0 z-50 border-b border-yellow-200 bg-yellow-50 px-4 py-2 text-center text-sm text-yellow-800">
|
|
48
|
+
Preview Mode — This page may not be published yet.
|
|
49
|
+
</div>
|
|
50
|
+
<div className="pt-10">
|
|
51
|
+
<RenderBlocks blocks={page.content} components={blockComponents} />
|
|
52
|
+
</div>
|
|
53
|
+
</>
|
|
54
|
+
)
|
|
55
|
+
}
|
|
@@ -1,69 +1,25 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
import type { RichTextDocument } from "@vexcms/core"
|
|
1
|
+
import { fetchQuery } from "convex/nextjs"
|
|
4
2
|
|
|
5
3
|
import { api } from "@convex/_generated/api"
|
|
6
|
-
import { RichText } from "@vexcms/richtext/render"
|
|
7
|
-
import { useVexPreview } from "@vexcms/ui"
|
|
8
|
-
import { useQuery } from "convex/react"
|
|
9
|
-
import { useParams } from "next/navigation"
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Preview page route — renders draft pages for live preview.
|
|
13
|
-
* Used by the admin panel's live preview iframe.
|
|
14
|
-
*
|
|
15
|
-
* Uses the same getBySlug query but with _vexDrafts: "snapshot"
|
|
16
|
-
* so the draft snapshot is automatically merged onto the document.
|
|
17
|
-
*/
|
|
18
|
-
export default function PreviewPage() {
|
|
19
|
-
const { slug } = useParams<{ slug: string }>()
|
|
20
|
-
|
|
21
|
-
const page = useQuery(api.pages.getBySlug, {
|
|
22
|
-
slug,
|
|
23
|
-
_vexDrafts: "snapshot",
|
|
24
|
-
})
|
|
25
|
-
|
|
26
|
-
// Notify admin panel's live preview when data changes
|
|
27
|
-
useVexPreview({ data: page })
|
|
28
4
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
5
|
+
import { PreviewPageContent } from "./PreviewPageContent"
|
|
6
|
+
|
|
7
|
+
export default async function PreviewPage({
|
|
8
|
+
params,
|
|
9
|
+
}: {
|
|
10
|
+
params: Promise<{ slug: string }>
|
|
11
|
+
}) {
|
|
12
|
+
const { slug } = await params
|
|
13
|
+
|
|
14
|
+
let initialData: Record<string, unknown> | null = null
|
|
15
|
+
try {
|
|
16
|
+
initialData = await fetchQuery(api.pages.getBySlug, {
|
|
17
|
+
slug,
|
|
18
|
+
_vexDrafts: "snapshot" as any,
|
|
19
|
+
})
|
|
20
|
+
} catch {
|
|
21
|
+
// Fall back to client-only fetch
|
|
38
22
|
}
|
|
39
23
|
|
|
40
|
-
|
|
41
|
-
return (
|
|
42
|
-
<div className="mx-auto max-w-3xl px-4 py-12">
|
|
43
|
-
<h1 className="text-2xl font-bold">Preview not found</h1>
|
|
44
|
-
<p className="mt-2 text-muted-foreground">
|
|
45
|
-
No page with slug “{slug}” exists.
|
|
46
|
-
</p>
|
|
47
|
-
</div>
|
|
48
|
-
)
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
return (
|
|
52
|
-
<div className="mx-auto max-w-3xl px-4 py-12">
|
|
53
|
-
{/* Preview banner */}
|
|
54
|
-
<div className="mb-6 rounded-md border border-yellow-200 bg-yellow-50 px-4 py-2 text-sm text-yellow-800">
|
|
55
|
-
Preview Mode — This page may not be published yet.
|
|
56
|
-
</div>
|
|
57
|
-
|
|
58
|
-
<h1 className="text-3xl font-bold mb-6">{page.title ?? "Untitled"}</h1>
|
|
59
|
-
|
|
60
|
-
{page.content ? (
|
|
61
|
-
<div className="prose prose-lg max-w-none">
|
|
62
|
-
<RichText content={page.content as RichTextDocument} />
|
|
63
|
-
</div>
|
|
64
|
-
) : (
|
|
65
|
-
<p className="text-muted-foreground">This page has no content yet.</p>
|
|
66
|
-
)}
|
|
67
|
-
</div>
|
|
68
|
-
)
|
|
24
|
+
return <PreviewPageContent slug={slug} initialData={initialData} />
|
|
69
25
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { ThemeInjector } from "~/components/ThemeInjector"
|
|
2
|
+
import { ThemeStyle } from "~/components/ThemeStyle"
|
|
3
|
+
|
|
4
|
+
export default function PreviewLayout({
|
|
5
|
+
children,
|
|
6
|
+
}: Readonly<{
|
|
7
|
+
children: React.ReactNode
|
|
8
|
+
}>) {
|
|
9
|
+
return (
|
|
10
|
+
<>
|
|
11
|
+
{/* Server-rendered draft theme CSS — eliminates flash on preview load */}
|
|
12
|
+
<ThemeStyle drafts />
|
|
13
|
+
{/* Client-side theme injector — picks up real-time draft changes via snapshot */}
|
|
14
|
+
<ThemeInjector siteSettingsSlug="site_settings" drafts="snapshot" />
|
|
15
|
+
{children}
|
|
16
|
+
</>
|
|
17
|
+
)
|
|
18
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { RenderBlocks } from "@vexcms/ui"
|
|
4
|
+
import { convexQuery } from "@convex-dev/react-query"
|
|
5
|
+
import { useQuery } from "@tanstack/react-query"
|
|
6
|
+
import { anyApi } from "convex/server"
|
|
7
|
+
|
|
8
|
+
import { blockComponents } from "~/vexcms/blocks"
|
|
9
|
+
|
|
10
|
+
export function SiteFooter({
|
|
11
|
+
initialData,
|
|
12
|
+
}: {
|
|
13
|
+
initialData?: Record<string, unknown> | null
|
|
14
|
+
}) {
|
|
15
|
+
const { data: footer } = useQuery({
|
|
16
|
+
...convexQuery(anyApi.footers.getFirst, {}),
|
|
17
|
+
initialData: initialData ?? undefined,
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
if (!footer?.content) return null
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<RenderBlocks
|
|
24
|
+
blocks={footer.content as any}
|
|
25
|
+
components={blockComponents}
|
|
26
|
+
/>
|
|
27
|
+
)
|
|
28
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { RenderBlocks } from "@vexcms/ui"
|
|
4
|
+
import { convexQuery } from "@convex-dev/react-query"
|
|
5
|
+
import { useQuery } from "@tanstack/react-query"
|
|
6
|
+
import { anyApi } from "convex/server"
|
|
7
|
+
|
|
8
|
+
import { blockComponents } from "~/vexcms/blocks"
|
|
9
|
+
|
|
10
|
+
export function SiteHeader({
|
|
11
|
+
initialData,
|
|
12
|
+
}: {
|
|
13
|
+
initialData?: Record<string, unknown> | null
|
|
14
|
+
}) {
|
|
15
|
+
const { data: header } = useQuery({
|
|
16
|
+
...convexQuery(anyApi.headers.getFirst, {}),
|
|
17
|
+
initialData: initialData ?? undefined,
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
if (!header?.content) return null
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<RenderBlocks
|
|
24
|
+
blocks={header.content as any}
|
|
25
|
+
components={blockComponents}
|
|
26
|
+
/>
|
|
27
|
+
)
|
|
28
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useQuery } from "convex/react"
|
|
4
|
+
import { useVexPreview } from "@vexcms/ui"
|
|
5
|
+
import { anyApi } from "convex/server"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* CSS variable name mapping from camelCase field names to CSS custom property names.
|
|
9
|
+
*/
|
|
10
|
+
const FIELD_TO_CSS_VAR: Record<string, string> = {
|
|
11
|
+
background: "background",
|
|
12
|
+
foreground: "foreground",
|
|
13
|
+
card: "card",
|
|
14
|
+
cardForeground: "card-foreground",
|
|
15
|
+
popover: "popover",
|
|
16
|
+
popoverForeground: "popover-foreground",
|
|
17
|
+
primary: "primary",
|
|
18
|
+
primaryForeground: "primary-foreground",
|
|
19
|
+
secondary: "secondary",
|
|
20
|
+
secondaryForeground: "secondary-foreground",
|
|
21
|
+
muted: "muted",
|
|
22
|
+
mutedForeground: "muted-foreground",
|
|
23
|
+
accent: "accent",
|
|
24
|
+
accentForeground: "accent-foreground",
|
|
25
|
+
destructive: "destructive",
|
|
26
|
+
destructiveForeground: "destructive-foreground",
|
|
27
|
+
border: "border",
|
|
28
|
+
input: "input",
|
|
29
|
+
ring: "ring",
|
|
30
|
+
chart1: "chart-1",
|
|
31
|
+
chart2: "chart-2",
|
|
32
|
+
chart3: "chart-3",
|
|
33
|
+
chart4: "chart-4",
|
|
34
|
+
chart5: "chart-5",
|
|
35
|
+
sidebar: "sidebar",
|
|
36
|
+
sidebarForeground: "sidebar-foreground",
|
|
37
|
+
sidebarPrimary: "sidebar-primary",
|
|
38
|
+
sidebarPrimaryForeground: "sidebar-primary-foreground",
|
|
39
|
+
sidebarAccent: "sidebar-accent",
|
|
40
|
+
sidebarAccentForeground: "sidebar-accent-foreground",
|
|
41
|
+
sidebarBorder: "sidebar-border",
|
|
42
|
+
sidebarRing: "sidebar-ring",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function buildCSSVars(colors: Record<string, unknown> | null | undefined): string {
|
|
46
|
+
if (!colors) return ""
|
|
47
|
+
|
|
48
|
+
const vars: string[] = []
|
|
49
|
+
for (const [fieldName, cssVar] of Object.entries(FIELD_TO_CSS_VAR)) {
|
|
50
|
+
const value = colors[fieldName]
|
|
51
|
+
if (typeof value === "string" && value) {
|
|
52
|
+
vars.push(` --${cssVar}: ${value};`)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return vars.join("\n")
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Client component that reads the active theme from the site_settings global
|
|
60
|
+
* and injects CSS variables for light and dark modes.
|
|
61
|
+
*
|
|
62
|
+
* @param props.siteSettingsSlug - The global slug for site settings
|
|
63
|
+
* @param props.drafts - When "snapshot", reads draft/preview data instead of published
|
|
64
|
+
*/
|
|
65
|
+
export function ThemeInjector({
|
|
66
|
+
siteSettingsSlug,
|
|
67
|
+
drafts,
|
|
68
|
+
}: {
|
|
69
|
+
siteSettingsSlug: string
|
|
70
|
+
drafts?: "snapshot" | false
|
|
71
|
+
}) {
|
|
72
|
+
// Use globals.get which supports draft awareness
|
|
73
|
+
const settingsDoc = useQuery(
|
|
74
|
+
anyApi.vex.globals?.get,
|
|
75
|
+
{
|
|
76
|
+
globalSlug: siteSettingsSlug,
|
|
77
|
+
...(drafts ? { _vexDrafts: drafts } : {}),
|
|
78
|
+
},
|
|
79
|
+
) as Record<string, unknown> | null | undefined
|
|
80
|
+
|
|
81
|
+
const activeThemeId = settingsDoc?.activeTheme as string | undefined
|
|
82
|
+
|
|
83
|
+
// Fetch the active theme document
|
|
84
|
+
// In draft mode, use versioned query to pick up unpublished theme changes
|
|
85
|
+
const theme = useQuery(
|
|
86
|
+
drafts
|
|
87
|
+
? anyApi.vex.versions?.getDocumentForEdit
|
|
88
|
+
: anyApi.vex.api.themes?.get,
|
|
89
|
+
activeThemeId
|
|
90
|
+
? drafts
|
|
91
|
+
? { collectionSlug: "themes", documentId: activeThemeId }
|
|
92
|
+
: { id: activeThemeId }
|
|
93
|
+
: "skip",
|
|
94
|
+
) as Record<string, unknown> | null | undefined
|
|
95
|
+
|
|
96
|
+
// In draft mode, notify the admin panel when settings or theme data changes
|
|
97
|
+
// so the live preview spinner stops
|
|
98
|
+
useVexPreview({ data: drafts ? { settings: settingsDoc, theme } : undefined })
|
|
99
|
+
|
|
100
|
+
if (!theme) return null
|
|
101
|
+
|
|
102
|
+
const lightColors = theme.light as Record<string, unknown> | undefined
|
|
103
|
+
const darkColors = theme.dark as Record<string, unknown> | undefined
|
|
104
|
+
|
|
105
|
+
const lightVars = buildCSSVars(lightColors)
|
|
106
|
+
const darkVars = buildCSSVars(darkColors)
|
|
107
|
+
|
|
108
|
+
if (!lightVars && !darkVars) return null
|
|
109
|
+
|
|
110
|
+
const css = [
|
|
111
|
+
lightVars ? `:root {\n${lightVars}\n}` : "",
|
|
112
|
+
darkVars ? `.dark {\n${darkVars}\n}` : "",
|
|
113
|
+
]
|
|
114
|
+
.filter(Boolean)
|
|
115
|
+
.join("\n\n")
|
|
116
|
+
|
|
117
|
+
const radius = theme.radius as string | undefined
|
|
118
|
+
const fontFamily = theme.fontFamily as string | undefined
|
|
119
|
+
const extraVars: string[] = []
|
|
120
|
+
if (radius) extraVars.push(` --radius: ${radius};`)
|
|
121
|
+
if (fontFamily) extraVars.push(` --font-sans: ${fontFamily};`)
|
|
122
|
+
|
|
123
|
+
const extraCSS = extraVars.length > 0 ? `\n:root {\n${extraVars.join("\n")}\n}` : ""
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<style dangerouslySetInnerHTML={{ __html: css + extraCSS }} />
|
|
127
|
+
)
|
|
128
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { fetchQuery } from "convex/nextjs"
|
|
2
|
+
|
|
3
|
+
import { api } from "@convex/_generated/api"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* CSS variable name mapping from camelCase field names to CSS custom property names.
|
|
7
|
+
*/
|
|
8
|
+
const FIELD_TO_CSS_VAR: Record<string, string> = {
|
|
9
|
+
background: "background",
|
|
10
|
+
foreground: "foreground",
|
|
11
|
+
card: "card",
|
|
12
|
+
cardForeground: "card-foreground",
|
|
13
|
+
popover: "popover",
|
|
14
|
+
popoverForeground: "popover-foreground",
|
|
15
|
+
primary: "primary",
|
|
16
|
+
primaryForeground: "primary-foreground",
|
|
17
|
+
secondary: "secondary",
|
|
18
|
+
secondaryForeground: "secondary-foreground",
|
|
19
|
+
muted: "muted",
|
|
20
|
+
mutedForeground: "muted-foreground",
|
|
21
|
+
accent: "accent",
|
|
22
|
+
accentForeground: "accent-foreground",
|
|
23
|
+
destructive: "destructive",
|
|
24
|
+
destructiveForeground: "destructive-foreground",
|
|
25
|
+
border: "border",
|
|
26
|
+
input: "input",
|
|
27
|
+
ring: "ring",
|
|
28
|
+
chart1: "chart-1",
|
|
29
|
+
chart2: "chart-2",
|
|
30
|
+
chart3: "chart-3",
|
|
31
|
+
chart4: "chart-4",
|
|
32
|
+
chart5: "chart-5",
|
|
33
|
+
sidebar: "sidebar",
|
|
34
|
+
sidebarForeground: "sidebar-foreground",
|
|
35
|
+
sidebarPrimary: "sidebar-primary",
|
|
36
|
+
sidebarPrimaryForeground: "sidebar-primary-foreground",
|
|
37
|
+
sidebarAccent: "sidebar-accent",
|
|
38
|
+
sidebarAccentForeground: "sidebar-accent-foreground",
|
|
39
|
+
sidebarBorder: "sidebar-border",
|
|
40
|
+
sidebarRing: "sidebar-ring",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function buildCSSVars(colors: Record<string, unknown> | null | undefined): string {
|
|
44
|
+
if (!colors) return ""
|
|
45
|
+
|
|
46
|
+
const vars: string[] = []
|
|
47
|
+
for (const [fieldName, cssVar] of Object.entries(FIELD_TO_CSS_VAR)) {
|
|
48
|
+
const value = colors[fieldName]
|
|
49
|
+
if (typeof value === "string" && value) {
|
|
50
|
+
vars.push(` --${cssVar}: ${value};`)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return vars.join("\n")
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Server component that fetches the active theme and inlines CSS variables
|
|
58
|
+
* into the initial HTML. This eliminates the flash of unstyled content
|
|
59
|
+
* that occurs when theme variables are injected client-side.
|
|
60
|
+
*
|
|
61
|
+
* @param props.drafts - When true, fetches the draft/preview theme instead of published.
|
|
62
|
+
* Use this in the preview layout so the server-rendered CSS
|
|
63
|
+
* matches the draft state the admin is editing.
|
|
64
|
+
*/
|
|
65
|
+
export async function ThemeStyle({ drafts }: { drafts?: boolean } = {}) {
|
|
66
|
+
let theme: Record<string, unknown> | null = null
|
|
67
|
+
try {
|
|
68
|
+
theme = drafts
|
|
69
|
+
? await fetchQuery(api.theme.getActivePreview) as Record<string, unknown> | null
|
|
70
|
+
: await fetchQuery(api.theme.getActive) as Record<string, unknown> | null
|
|
71
|
+
} catch {
|
|
72
|
+
// Convex not available during build — use default styles
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (!theme) return null
|
|
77
|
+
|
|
78
|
+
const lightColors = theme.light as Record<string, unknown> | undefined
|
|
79
|
+
const darkColors = theme.dark as Record<string, unknown> | undefined
|
|
80
|
+
|
|
81
|
+
const lightVars = buildCSSVars(lightColors)
|
|
82
|
+
const darkVars = buildCSSVars(darkColors)
|
|
83
|
+
|
|
84
|
+
if (!lightVars && !darkVars) return null
|
|
85
|
+
|
|
86
|
+
const css = [
|
|
87
|
+
lightVars ? `:root {\n${lightVars}\n}` : "",
|
|
88
|
+
darkVars ? `.dark {\n${darkVars}\n}` : "",
|
|
89
|
+
]
|
|
90
|
+
.filter(Boolean)
|
|
91
|
+
.join("\n\n")
|
|
92
|
+
|
|
93
|
+
const radius = theme.radius as string | undefined
|
|
94
|
+
const fontFamily = theme.fontFamily as string | undefined
|
|
95
|
+
const extraVars: string[] = []
|
|
96
|
+
if (radius) extraVars.push(` --radius: ${radius};`)
|
|
97
|
+
if (fontFamily) extraVars.push(` --font-sans: ${fontFamily};`)
|
|
98
|
+
|
|
99
|
+
const extraCSS = extraVars.length > 0 ? `\n:root {\n${extraVars.join("\n")}\n}` : ""
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<style dangerouslySetInnerHTML={{ __html: css + extraCSS }} />
|
|
103
|
+
)
|
|
104
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useVexField } from "@vexcms/ui"
|
|
4
|
+
import { icons } from "lucide-react"
|
|
5
|
+
import { useMemo } from "react"
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
Combobox,
|
|
9
|
+
ComboboxContent,
|
|
10
|
+
ComboboxEmpty,
|
|
11
|
+
ComboboxInput,
|
|
12
|
+
ComboboxItem,
|
|
13
|
+
ComboboxList,
|
|
14
|
+
} from "~/components/ui/combobox"
|
|
15
|
+
import { Label } from "~/components/ui/label"
|
|
16
|
+
import { cn } from "~/lib/utils"
|
|
17
|
+
|
|
18
|
+
type IconName = keyof typeof icons
|
|
19
|
+
|
|
20
|
+
const iconNames = Object.keys(icons) as IconName[]
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Custom admin field component that renders a searchable icon picker.
|
|
24
|
+
* Stores the selected icon name as a string (e.g. "Zap", "Shield", "Code").
|
|
25
|
+
*
|
|
26
|
+
* When used inside an array field, receives a `field` prop with synthetic
|
|
27
|
+
* state/handleChange. When used at the top level, uses useVexField.
|
|
28
|
+
*/
|
|
29
|
+
export default function IconPickerField({
|
|
30
|
+
name,
|
|
31
|
+
field: syntheticField,
|
|
32
|
+
}: {
|
|
33
|
+
name: string
|
|
34
|
+
fieldDef?: any
|
|
35
|
+
readOnly?: boolean
|
|
36
|
+
field?: {
|
|
37
|
+
state: { value: unknown }
|
|
38
|
+
handleChange: (value: unknown) => void
|
|
39
|
+
}
|
|
40
|
+
}) {
|
|
41
|
+
// Use synthetic field from array context, or useVexField for top-level
|
|
42
|
+
const vexField = useVexField<string>({ name })
|
|
43
|
+
|
|
44
|
+
const value = syntheticField
|
|
45
|
+
? (syntheticField.state.value as string) ?? ""
|
|
46
|
+
: vexField.value ?? ""
|
|
47
|
+
|
|
48
|
+
const setValueFn = syntheticField
|
|
49
|
+
? (v: string) => syntheticField.handleChange(v)
|
|
50
|
+
: (v: string) => vexField.setValue(v)
|
|
51
|
+
|
|
52
|
+
const fieldDef = vexField.fieldDef
|
|
53
|
+
const readOnly = vexField.readOnly
|
|
54
|
+
|
|
55
|
+
const selectedIcon = value && value in icons ? value : null
|
|
56
|
+
const SelectedIconComponent = selectedIcon
|
|
57
|
+
? icons[selectedIcon as IconName]
|
|
58
|
+
: null
|
|
59
|
+
|
|
60
|
+
// Sort items so the selected icon appears first
|
|
61
|
+
const sortedItems = useMemo(() => {
|
|
62
|
+
if (!selectedIcon) return iconNames
|
|
63
|
+
return [
|
|
64
|
+
selectedIcon as IconName,
|
|
65
|
+
...iconNames.filter((n) => n !== selectedIcon),
|
|
66
|
+
]
|
|
67
|
+
}, [selectedIcon])
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<div className="space-y-2">
|
|
71
|
+
<Label>{(fieldDef as any)?.label ?? "Icon"}</Label>
|
|
72
|
+
<div className="flex items-start gap-2">
|
|
73
|
+
{/* Icon preview square */}
|
|
74
|
+
<div
|
|
75
|
+
className={cn(
|
|
76
|
+
"flex size-9 shrink-0 items-center justify-center rounded-md border bg-background",
|
|
77
|
+
selectedIcon
|
|
78
|
+
? "text-foreground"
|
|
79
|
+
: "text-muted-foreground/40"
|
|
80
|
+
)}
|
|
81
|
+
>
|
|
82
|
+
{SelectedIconComponent ? (
|
|
83
|
+
<SelectedIconComponent className="size-4" />
|
|
84
|
+
) : null}
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
{/* Combobox */}
|
|
88
|
+
<div className="flex-1">
|
|
89
|
+
<Combobox
|
|
90
|
+
value={selectedIcon ?? ""}
|
|
91
|
+
onValueChange={(val) => {
|
|
92
|
+
// Toggle: clicking the already-selected icon deselects it
|
|
93
|
+
if (val === selectedIcon) {
|
|
94
|
+
setValueFn("")
|
|
95
|
+
} else {
|
|
96
|
+
setValueFn((val as string) ?? "")
|
|
97
|
+
}
|
|
98
|
+
}}
|
|
99
|
+
items={sortedItems}
|
|
100
|
+
>
|
|
101
|
+
<ComboboxInput
|
|
102
|
+
placeholder="Search icons..."
|
|
103
|
+
disabled={readOnly}
|
|
104
|
+
/>
|
|
105
|
+
<ComboboxContent>
|
|
106
|
+
<ComboboxEmpty>No icons found</ComboboxEmpty>
|
|
107
|
+
<ComboboxList>
|
|
108
|
+
{(iconName) => {
|
|
109
|
+
const Icon = icons[iconName as IconName]
|
|
110
|
+
const isSelected = iconName === selectedIcon
|
|
111
|
+
return (
|
|
112
|
+
<ComboboxItem
|
|
113
|
+
key={iconName}
|
|
114
|
+
value={iconName}
|
|
115
|
+
className={cn(isSelected && "bg-accent")}
|
|
116
|
+
>
|
|
117
|
+
<Icon className="size-4 shrink-0" />
|
|
118
|
+
<span>{iconName}</span>
|
|
119
|
+
</ComboboxItem>
|
|
120
|
+
)
|
|
121
|
+
}}
|
|
122
|
+
</ComboboxList>
|
|
123
|
+
</ComboboxContent>
|
|
124
|
+
</Combobox>
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
{(fieldDef as any)?.admin?.description && (
|
|
128
|
+
<p className="text-muted-foreground text-xs">
|
|
129
|
+
{(fieldDef as any).admin.description}
|
|
130
|
+
</p>
|
|
131
|
+
)}
|
|
132
|
+
</div>
|
|
133
|
+
)
|
|
134
|
+
}
|