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.
Files changed (59) hide show
  1. package/package.json +1 -1
  2. package/templates/base-nextjs/convex/vex/globals.ts +82 -0
  3. package/templates/base-nextjs/convex/vex/versions.ts +66 -6
  4. package/templates/base-nextjs/package.json +8 -8
  5. package/templates/base-nextjs/src/app/admin/layout.tsx +29 -11
  6. package/templates/base-nextjs/src/db/constants/auth.ts +1 -3
  7. package/templates/base-nextjs/src/vexcms/access.ts +29 -13
  8. package/templates/base-nextjs/src/vexcms/collections/users.ts +2 -2
  9. package/templates/base-nextjs/vex.config.ts +2 -0
  10. package/templates/marketing-site/convex/footers.ts +14 -0
  11. package/templates/marketing-site/convex/headers.ts +14 -0
  12. package/templates/marketing-site/convex/siteSettings.ts +14 -0
  13. package/templates/marketing-site/convex/theme.ts +75 -0
  14. package/templates/marketing-site/public/favicons/favicon.ico +0 -0
  15. package/templates/marketing-site/src/app/(frontend)/PageContent.tsx +74 -0
  16. package/templates/marketing-site/src/app/(frontend)/[slug]/page.tsx +19 -51
  17. package/templates/marketing-site/src/app/(frontend)/layout.tsx +37 -0
  18. package/templates/marketing-site/src/app/(frontend)/page.tsx +24 -0
  19. package/templates/marketing-site/src/app/(frontend)/preview/[slug]/PreviewPageContent.tsx +55 -0
  20. package/templates/marketing-site/src/app/(frontend)/preview/[slug]/page.tsx +19 -63
  21. package/templates/marketing-site/src/app/(frontend)/preview/layout.tsx +18 -0
  22. package/templates/marketing-site/src/components/SiteFooter.tsx +28 -0
  23. package/templates/marketing-site/src/components/SiteHeader.tsx +28 -0
  24. package/templates/marketing-site/src/components/ThemeInjector.tsx +128 -0
  25. package/templates/marketing-site/src/components/ThemeStyle.tsx +104 -0
  26. package/templates/marketing-site/src/components/admin/IconPickerField.tsx +134 -0
  27. package/templates/marketing-site/src/components/motion-primitives/animated-group.tsx +138 -0
  28. package/templates/marketing-site/src/components/motion-primitives/text-effect.tsx +292 -0
  29. package/templates/marketing-site/src/lib/metadata.ts +104 -0
  30. package/templates/marketing-site/src/vexcms/access.ts +28 -0
  31. package/templates/marketing-site/src/vexcms/blocks/CTA/config.ts +36 -0
  32. package/templates/marketing-site/src/vexcms/blocks/CTA/index.tsx +46 -0
  33. package/templates/marketing-site/src/vexcms/blocks/FAQ/config.ts +70 -0
  34. package/templates/marketing-site/src/vexcms/blocks/FAQ/index.tsx +71 -0
  35. package/templates/marketing-site/src/vexcms/blocks/Features/config.ts +61 -0
  36. package/templates/marketing-site/src/vexcms/blocks/Features/index.tsx +73 -0
  37. package/templates/marketing-site/src/vexcms/blocks/Footer/config.ts +65 -0
  38. package/templates/marketing-site/src/vexcms/blocks/Footer/index.tsx +71 -0
  39. package/templates/marketing-site/src/vexcms/blocks/Header/config.ts +65 -0
  40. package/templates/marketing-site/src/vexcms/blocks/Header/index.tsx +174 -0
  41. package/templates/marketing-site/src/vexcms/blocks/Hero/config.ts +52 -0
  42. package/templates/marketing-site/src/vexcms/blocks/Hero/index.tsx +152 -0
  43. package/templates/marketing-site/src/vexcms/blocks/HowItWorks/config.ts +68 -0
  44. package/templates/marketing-site/src/vexcms/blocks/HowItWorks/index.tsx +74 -0
  45. package/templates/marketing-site/src/vexcms/blocks/Roadmap/config.ts +154 -0
  46. package/templates/marketing-site/src/vexcms/blocks/Roadmap/index.tsx +122 -0
  47. package/templates/marketing-site/src/vexcms/blocks/config.ts +40 -0
  48. package/templates/marketing-site/src/vexcms/blocks/constants.ts +9 -0
  49. package/templates/marketing-site/src/vexcms/blocks/index.ts +39 -0
  50. package/templates/marketing-site/src/vexcms/collections/footers.ts +11 -8
  51. package/templates/marketing-site/src/vexcms/collections/headers.ts +10 -14
  52. package/templates/marketing-site/src/vexcms/collections/index.ts +0 -1
  53. package/templates/marketing-site/src/vexcms/collections/pages.ts +30 -4
  54. package/templates/marketing-site/src/vexcms/collections/themes.ts +107 -14
  55. package/templates/marketing-site/src/vexcms/globals/index.ts +1 -0
  56. package/templates/marketing-site/src/vexcms/globals/siteSettings.ts +57 -0
  57. package/templates/marketing-site/vex.config.ts +5 -5
  58. package/templates/base-nextjs/src/auth/permissions.ts +0 -102
  59. 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 &ldquo;{slug}&rdquo; 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
- "use client"
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
- if (page === undefined) {
30
- return (
31
- <div className="mx-auto max-w-3xl px-4 py-12">
32
- <div className="flex items-center gap-2">
33
- <div className="h-2 w-2 rounded-full bg-yellow-500 animate-pulse" />
34
- <p className="text-muted-foreground">Loading preview…</p>
35
- </div>
36
- </div>
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
- if (page === null) {
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 &ldquo;{slug}&rdquo; 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
+ }