create-vexcms 0.0.12 → 0.0.14

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-vexcms",
3
- "version": "0.0.12",
3
+ "version": "0.0.14",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "create-vexcms": "./dist/index.js"
@@ -7,7 +7,7 @@
7
7
  "skipLibCheck": true,
8
8
  "allowSyntheticDefaultImports": true,
9
9
  "target": "ESNext",
10
- "lib": ["ES2023", "dom"],
10
+ "lib": ["ES2023", "dom", "dom.iterable"],
11
11
  "forceConsistentCasingInFileNames": true,
12
12
  "module": "ESNext",
13
13
  "isolatedModules": true,
@@ -22,12 +22,12 @@
22
22
  "@t3-oss/env-nextjs": "^0.13.10",
23
23
  "@tanstack/react-form": "^1.27.7",
24
24
  "@tanstack/react-query": "^5.90.17",
25
- "@vexcms/admin-next": "~0.0.12",
26
- "@vexcms/better-auth": "~0.0.12",
27
- "@vexcms/core": "~0.0.12",
28
- "@vexcms/richtext": "~0.0.12",
29
- "@vexcms/ui": "~0.0.12",
30
- "@vexcms/file-storage-convex": "~0.0.12",
25
+ "@vexcms/admin-next": "~0.0.14",
26
+ "@vexcms/better-auth": "~0.0.14",
27
+ "@vexcms/core": "~0.0.14",
28
+ "@vexcms/richtext": "~0.0.14",
29
+ "@vexcms/ui": "~0.0.14",
30
+ "@vexcms/file-storage-convex": "~0.0.14",
31
31
  "better-auth": ">=1.4.9 <1.5.0",
32
32
  "class-variance-authority": "^0.7.1",
33
33
  "clsx": "^2.1.1",
@@ -54,7 +54,7 @@
54
54
  "@types/node": "^20",
55
55
  "@types/react": "^19",
56
56
  "@types/react-dom": "^19",
57
- "@vexcms/cli": "~0.0.12",
57
+ "@vexcms/cli": "~0.0.14",
58
58
  "babel-plugin-react-compiler": "^1.0.0",
59
59
  "eslint": "^9",
60
60
  "eslint-config-next": "15.5.9",
@@ -4,3 +4,15 @@ import { twMerge } from "tailwind-merge"
4
4
  export function cn(...inputs: ClassValue[]) {
5
5
  return twMerge(clsx(inputs))
6
6
  }
7
+
8
+ /**
9
+ * Normalize a page slug for consistent lookup.
10
+ * - Strips leading/trailing slashes
11
+ * - Treats "/" and "/home" and "home" all as "home"
12
+ * - "/features" becomes "features"
13
+ */
14
+ export function normalizeSlug(slug?: string): string {
15
+ if (!slug) return "home"
16
+ const cleaned = slug.replace(/^\/+|\/+$/g, "")
17
+ return cleaned === "" ? "home" : cleaned
18
+ }
@@ -3,15 +3,24 @@
3
3
  import { RenderBlocks } from "@vexcms/ui"
4
4
  import { convexQuery } from "@convex-dev/react-query"
5
5
  import { useQuery } from "@tanstack/react-query"
6
+ import { useMutation, useQuery as useConvexQuery } from "convex/react"
6
7
  import Link from "next/link"
8
+ import { usePathname, useRouter } from "next/navigation"
9
+ import { useEffect, useState } from "react"
7
10
  import { anyApi } from "convex/server"
8
11
 
12
+ import { api } from "@convex/_generated/api"
13
+ import { useSession } from "~/auth/client"
14
+ import { normalizeSlug } from "~/lib/utils"
9
15
  import { blockComponents } from "~/vexcms/blocks"
10
16
 
11
17
  /**
12
18
  * Client component that renders a page using TanStack Query + convexQuery.
13
19
  * When initialData is provided (from server fetchQuery), renders immediately.
14
20
  * The reactive subscription keeps data up-to-date after hydration.
21
+ *
22
+ * On a fresh project with no "home" page, shows the bootstrap/auth flow
23
+ * so the first user can sign up and become admin.
15
24
  */
16
25
  export function PageContent({
17
26
  slug,
@@ -20,9 +29,11 @@ export function PageContent({
20
29
  slug?: string
21
30
  initialData?: Record<string, unknown> | null
22
31
  }) {
32
+ const normalizedSlug = normalizeSlug(slug)
33
+
23
34
  const { data: page, isPending } = useQuery({
24
35
  ...convexQuery(anyApi.pages.getBySlug, {
25
- slug: slug ?? "home",
36
+ slug: normalizedSlug,
26
37
  _vexDrafts: false,
27
38
  }),
28
39
  initialData: initialData ?? undefined,
@@ -37,15 +48,8 @@ export function PageContent({
37
48
  }
38
49
 
39
50
  if (!page) {
40
- if (!slug || slug === "home") {
41
- return (
42
- <div className="flex min-h-screen flex-col items-center justify-center gap-4 p-8">
43
- <h1 className="text-4xl font-bold tracking-tight">Vex CMS</h1>
44
- <p className="text-lg text-muted-foreground">
45
- Create a page with slug &ldquo;home&rdquo; to get started.
46
- </p>
47
- </div>
48
- )
51
+ if (normalizedSlug === "home") {
52
+ return <WelcomePage />
49
53
  }
50
54
 
51
55
  return (
@@ -72,3 +76,111 @@ export function PageContent({
72
76
  />
73
77
  )
74
78
  }
79
+
80
+ function LoadingSpinner() {
81
+ return (
82
+ <svg className="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
83
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
84
+ <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
85
+ </svg>
86
+ )
87
+ }
88
+
89
+ /**
90
+ * Welcome page shown on fresh projects with no "home" page.
91
+ * Handles the first-user bootstrap flow: sign up → promote to admin → redirect to admin panel.
92
+ */
93
+ function WelcomePage() {
94
+ const isBootstrapped = useConvexQuery(api.vex.firstUser.isBootstrapped)
95
+ const promoteFirstAdmin = useMutation(api.vex.firstUser.promoteFirstAdmin)
96
+ const { data: session } = useSession()
97
+ const router = useRouter()
98
+ const pathname = usePathname()
99
+ const [promoting, setPromoting] = useState(false)
100
+ const [navigating, setNavigating] = useState(false)
101
+
102
+ useEffect(() => {
103
+ if (pathname === "/") {
104
+ setNavigating(false)
105
+ }
106
+ }, [pathname])
107
+
108
+ useEffect(() => {
109
+ if (session?.user && isBootstrapped === false && !promoting) {
110
+ setPromoting(true)
111
+ setNavigating(true)
112
+ promoteFirstAdmin()
113
+ .then((result) => {
114
+ if (result.promoted) {
115
+ router.push("/admin")
116
+ } else {
117
+ setNavigating(false)
118
+ }
119
+ })
120
+ .catch(() => {
121
+ setPromoting(false)
122
+ setNavigating(false)
123
+ })
124
+ }
125
+ }, [session, isBootstrapped, promoting, promoteFirstAdmin, router])
126
+
127
+ const bootstrapped = isBootstrapped === true
128
+
129
+ const buttonClass =
130
+ "inline-flex items-center justify-center gap-2 rounded-md bg-primary px-6 py-3 text-sm font-medium text-primary-foreground shadow hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-opacity"
131
+
132
+ const handleNavigate = (path: string) => {
133
+ setNavigating(true)
134
+ router.push(path)
135
+ }
136
+
137
+ return (
138
+ <div className="flex min-h-screen flex-col items-center justify-center gap-8 p-8">
139
+ <div className="text-center">
140
+ <h1 className="text-4xl font-bold tracking-tight">VEX CMS</h1>
141
+ <p className="mt-3 text-lg text-muted-foreground">
142
+ {bootstrapped
143
+ ? "Your content management system is ready."
144
+ : "Welcome! Create your admin account to get started."}
145
+ </p>
146
+ </div>
147
+
148
+ <div className="flex gap-4">
149
+ {session?.user ? (
150
+ <button
151
+ onClick={() => handleNavigate("/admin")}
152
+ disabled={navigating}
153
+ className={buttonClass}
154
+ >
155
+ {navigating && <LoadingSpinner />}
156
+ {navigating ? "Loading..." : "Go to Admin Panel"}
157
+ </button>
158
+ ) : bootstrapped ? (
159
+ <button
160
+ onClick={() => handleNavigate("/auth/sign-in")}
161
+ disabled={navigating}
162
+ className={buttonClass}
163
+ >
164
+ {navigating && <LoadingSpinner />}
165
+ {navigating ? "Loading..." : "Sign In"}
166
+ </button>
167
+ ) : (
168
+ <button
169
+ onClick={() => handleNavigate("/auth/sign-up")}
170
+ disabled={navigating}
171
+ className={buttonClass}
172
+ >
173
+ {navigating && <LoadingSpinner />}
174
+ {navigating ? "Loading..." : "Create Admin Account"}
175
+ </button>
176
+ )}
177
+ </div>
178
+
179
+ {promoting && (
180
+ <p className="text-sm text-muted-foreground animate-pulse">
181
+ Setting up your admin account...
182
+ </p>
183
+ )}
184
+ </div>
185
+ )
186
+ }
@@ -2,25 +2,27 @@ import { api } from "@convex/_generated/api"
2
2
  import { fetchQuery } from "convex/nextjs"
3
3
 
4
4
  import { generatePageMetadata } from "~/lib/metadata"
5
+ import { normalizeSlug } from "~/lib/utils"
5
6
  import { PageContent } from "../PageContent"
6
7
 
7
8
  export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
8
9
  const { slug } = await params
9
- return generatePageMetadata({ slug })
10
+ return generatePageMetadata({ slug: normalizeSlug(slug) })
10
11
  }
11
12
 
12
13
  export default async function PublicPage({ params }: { params: Promise<{ slug: string }> }) {
13
14
  const { slug } = await params
15
+ const normalized = normalizeSlug(slug)
14
16
 
15
17
  let initialData: null | Record<string, unknown> = null
16
18
  try {
17
19
  initialData = await fetchQuery(api.pages.getBySlug, {
18
- slug,
20
+ slug: normalized,
19
21
  _vexDrafts: false,
20
22
  })
21
23
  } catch {
22
24
  // Fall back to client-only fetch
23
25
  }
24
26
 
25
- return <PageContent initialData={initialData} slug={slug} />
27
+ return <PageContent initialData={initialData} slug={normalized} />
26
28
  }
@@ -2,6 +2,7 @@ import { fetchQuery } from "convex/nextjs"
2
2
 
3
3
  import { api } from "@convex/_generated/api"
4
4
 
5
+ import { normalizeSlug } from "~/lib/utils"
5
6
  import { PreviewPageContent } from "./PreviewPageContent"
6
7
 
7
8
  export default async function PreviewPage({
@@ -10,16 +11,17 @@ export default async function PreviewPage({
10
11
  params: Promise<{ slug: string }>
11
12
  }) {
12
13
  const { slug } = await params
14
+ const normalized = normalizeSlug(slug)
13
15
 
14
16
  let initialData: Record<string, unknown> | null = null
15
17
  try {
16
18
  initialData = await fetchQuery(api.pages.getBySlug, {
17
- slug,
19
+ slug: normalized,
18
20
  _vexDrafts: "snapshot" as any,
19
21
  })
20
22
  } catch {
21
23
  // Fall back to client-only fetch
22
24
  }
23
25
 
24
- return <PreviewPageContent slug={slug} initialData={initialData} />
26
+ return <PreviewPageContent slug={normalized} initialData={initialData} />
25
27
  }