@tanstack/cta-ui 0.10.0-alpha.22 → 0.10.0-alpha.24

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
@@ -33,6 +33,7 @@
33
33
  "@uiw/react-codemirror": "^4.23.10",
34
34
  "class-variance-authority": "^0.7.1",
35
35
  "clsx": "^2.1.1",
36
+ "embla-carousel-react": "^8.6.0",
36
37
  "execa": "^9.5.2",
37
38
  "jotai-tanstack-query": "^0.9.0",
38
39
  "lucide-react": "^0.476.0",
@@ -47,8 +48,8 @@
47
48
  "vinxi": "^0.5.3",
48
49
  "vite-tsconfig-paths": "^5.1.4",
49
50
  "zustand": "^5.0.3",
50
- "@tanstack/cta-framework-react-cra": "0.10.0-alpha.21",
51
51
  "@tanstack/cta-engine": "0.10.0-alpha.21",
52
+ "@tanstack/cta-framework-react-cra": "0.10.0-alpha.21",
52
53
  "@tanstack/cta-framework-solid": "0.10.0-alpha.21"
53
54
  },
54
55
  "devDependencies": {
@@ -65,6 +66,6 @@
65
66
  "vitest": "^3.0.5",
66
67
  "web-vitals": "^4.2.4"
67
68
  },
68
- "version": "0.10.0-alpha.22",
69
+ "version": "0.10.0-alpha.24",
69
70
  "scripts": {}
70
71
  }
@@ -12,13 +12,14 @@ import {
12
12
  DialogHeader,
13
13
  DialogTitle,
14
14
  } from '@/components/ui/dialog'
15
-
16
15
  import {
17
16
  setProjectStarter,
18
17
  useApplicationMode,
19
18
  useProjectStarter,
19
+ useRegistry,
20
20
  } from '@/store/project'
21
21
  import { loadRemoteStarter } from '@/lib/api'
22
+ import { StartersCarousel } from '@/components/starters-carousel'
22
23
 
23
24
  export default function Starter() {
24
25
  const [url, setUrl] = useState('')
@@ -32,8 +33,8 @@ export default function Starter() {
32
33
  return null
33
34
  }
34
35
 
35
- async function onImport() {
36
- const data = await loadRemoteStarter(url)
36
+ async function onImport(registryUrl?: string) {
37
+ const data = await loadRemoteStarter(registryUrl || url)
37
38
 
38
39
  if ('error' in data) {
39
40
  toast.error('Failed to load starter', {
@@ -45,6 +46,8 @@ export default function Starter() {
45
46
  }
46
47
  }
47
48
 
49
+ const registry = useRegistry()
50
+
48
51
  return (
49
52
  <>
50
53
  {projectStarter?.banner && (
@@ -86,11 +89,16 @@ export default function Starter() {
86
89
  <FileBoxIcon className="w-4 h-4" />
87
90
  Set Project Starter
88
91
  </Button>
89
- <Dialog modal open={open}>
92
+ <Dialog modal open={open} onOpenChange={setOpen}>
90
93
  <DialogContent className="sm:min-w-[425px] sm:max-w-fit">
91
94
  <DialogHeader>
92
95
  <DialogTitle>Project Starter URL</DialogTitle>
93
96
  </DialogHeader>
97
+ {registry?.starters && (
98
+ <div>
99
+ <StartersCarousel onImport={onImport} />
100
+ </div>
101
+ )}
94
102
  <div>
95
103
  <Input
96
104
  value={url}
@@ -0,0 +1,45 @@
1
+ import {
2
+ Carousel,
3
+ CarouselContent,
4
+ CarouselItem,
5
+ } from '@/components/ui/carousel'
6
+
7
+ import { useRegistry } from '@/store/project'
8
+
9
+ export function StartersCarousel({
10
+ onImport,
11
+ }: {
12
+ onImport: (url: string) => void
13
+ }) {
14
+ const registry = useRegistry()
15
+
16
+ if (!registry) {
17
+ return null
18
+ }
19
+
20
+ return (
21
+ <div>
22
+ <Carousel>
23
+ <CarouselContent>
24
+ {registry.starters.map((starter) => (
25
+ <CarouselItem className="basis-1/3" key={starter.url}>
26
+ <div
27
+ className="p-2 flex flex-col items-center hover:cursor-pointer hover:bg-gray-700/50 hover:text-white rounded-lg"
28
+ onClick={() => {
29
+ onImport(starter.url)
30
+ }}
31
+ >
32
+ <img
33
+ src={starter.banner}
34
+ alt={starter.name}
35
+ className="w-100 max-w-full"
36
+ />
37
+ <div className="text-md font-bold">{starter.name}</div>
38
+ </div>
39
+ </CarouselItem>
40
+ ))}
41
+ </CarouselContent>
42
+ </Carousel>
43
+ </div>
44
+ )
45
+ }
@@ -0,0 +1,73 @@
1
+ import { toast } from 'sonner'
2
+
3
+ import {
4
+ Dialog,
5
+ DialogContent,
6
+ DialogFooter,
7
+ DialogHeader,
8
+ DialogTitle,
9
+ } from '@/components/ui/dialog'
10
+ import { StartersCarousel } from '@/components/starters-carousel'
11
+ import { Button } from '@/components/ui/button'
12
+ import { Switch } from '@/components/ui/switch'
13
+ import { Label } from '@/components/ui/label'
14
+ import {
15
+ setProjectStarter,
16
+ useApplicationMode,
17
+ useRegistry,
18
+ useStartupDialog,
19
+ } from '@/store/project'
20
+ import { loadRemoteStarter } from '@/lib/api'
21
+
22
+ export default function StartupDialog() {
23
+ const mode = useApplicationMode()
24
+ const registry = useRegistry()
25
+ const { open, setOpen, dontShowAgain, setDontShowAgain } = useStartupDialog()
26
+
27
+ if (mode !== 'setup' || !registry) {
28
+ return null
29
+ }
30
+
31
+ async function onImport(registryUrl: string) {
32
+ const data = await loadRemoteStarter(registryUrl)
33
+
34
+ if ('error' in data) {
35
+ toast.error('Failed to load starter', {
36
+ description: data.error,
37
+ })
38
+ } else {
39
+ setProjectStarter(data)
40
+ setOpen(false)
41
+ }
42
+ }
43
+
44
+ return (
45
+ <Dialog modal open={open} onOpenChange={setOpen}>
46
+ <DialogContent className="sm:min-w-[425px] sm:max-w-fit">
47
+ <DialogHeader>
48
+ <DialogTitle className="text-center text-2xl font-bold">
49
+ Would you like to use a starter project?
50
+ </DialogTitle>
51
+ </DialogHeader>
52
+ {registry?.starters && (
53
+ <div>
54
+ <StartersCarousel onImport={onImport} />
55
+ </div>
56
+ )}
57
+ <DialogFooter className="flex sm:justify-between w-full">
58
+ <div className="flex items-center gap-2">
59
+ <Switch
60
+ id="show-startup-dialog"
61
+ checked={dontShowAgain}
62
+ onCheckedChange={setDontShowAgain}
63
+ />
64
+ <Label htmlFor="show-startup-dialog">Don't show this again</Label>
65
+ </div>
66
+ <Button onClick={() => setOpen(false)}>
67
+ No, I want to start from scratch
68
+ </Button>
69
+ </DialogFooter>
70
+ </DialogContent>
71
+ </Dialog>
72
+ )
73
+ }
@@ -0,0 +1,239 @@
1
+ import * as React from "react"
2
+ import useEmblaCarousel, {
3
+ type UseEmblaCarouselType,
4
+ } from "embla-carousel-react"
5
+ import { ArrowLeft, ArrowRight } from "lucide-react"
6
+
7
+ import { cn } from "@/lib/utils"
8
+ import { Button } from "@/components/ui/button"
9
+
10
+ type CarouselApi = UseEmblaCarouselType[1]
11
+ type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
12
+ type CarouselOptions = UseCarouselParameters[0]
13
+ type CarouselPlugin = UseCarouselParameters[1]
14
+
15
+ type CarouselProps = {
16
+ opts?: CarouselOptions
17
+ plugins?: CarouselPlugin
18
+ orientation?: "horizontal" | "vertical"
19
+ setApi?: (api: CarouselApi) => void
20
+ }
21
+
22
+ type CarouselContextProps = {
23
+ carouselRef: ReturnType<typeof useEmblaCarousel>[0]
24
+ api: ReturnType<typeof useEmblaCarousel>[1]
25
+ scrollPrev: () => void
26
+ scrollNext: () => void
27
+ canScrollPrev: boolean
28
+ canScrollNext: boolean
29
+ } & CarouselProps
30
+
31
+ const CarouselContext = React.createContext<CarouselContextProps | null>(null)
32
+
33
+ function useCarousel() {
34
+ const context = React.useContext(CarouselContext)
35
+
36
+ if (!context) {
37
+ throw new Error("useCarousel must be used within a <Carousel />")
38
+ }
39
+
40
+ return context
41
+ }
42
+
43
+ function Carousel({
44
+ orientation = "horizontal",
45
+ opts,
46
+ setApi,
47
+ plugins,
48
+ className,
49
+ children,
50
+ ...props
51
+ }: React.ComponentProps<"div"> & CarouselProps) {
52
+ const [carouselRef, api] = useEmblaCarousel(
53
+ {
54
+ ...opts,
55
+ axis: orientation === "horizontal" ? "x" : "y",
56
+ },
57
+ plugins
58
+ )
59
+ const [canScrollPrev, setCanScrollPrev] = React.useState(false)
60
+ const [canScrollNext, setCanScrollNext] = React.useState(false)
61
+
62
+ const onSelect = React.useCallback((api: CarouselApi) => {
63
+ if (!api) return
64
+ setCanScrollPrev(api.canScrollPrev())
65
+ setCanScrollNext(api.canScrollNext())
66
+ }, [])
67
+
68
+ const scrollPrev = React.useCallback(() => {
69
+ api?.scrollPrev()
70
+ }, [api])
71
+
72
+ const scrollNext = React.useCallback(() => {
73
+ api?.scrollNext()
74
+ }, [api])
75
+
76
+ const handleKeyDown = React.useCallback(
77
+ (event: React.KeyboardEvent<HTMLDivElement>) => {
78
+ if (event.key === "ArrowLeft") {
79
+ event.preventDefault()
80
+ scrollPrev()
81
+ } else if (event.key === "ArrowRight") {
82
+ event.preventDefault()
83
+ scrollNext()
84
+ }
85
+ },
86
+ [scrollPrev, scrollNext]
87
+ )
88
+
89
+ React.useEffect(() => {
90
+ if (!api || !setApi) return
91
+ setApi(api)
92
+ }, [api, setApi])
93
+
94
+ React.useEffect(() => {
95
+ if (!api) return
96
+ onSelect(api)
97
+ api.on("reInit", onSelect)
98
+ api.on("select", onSelect)
99
+
100
+ return () => {
101
+ api?.off("select", onSelect)
102
+ }
103
+ }, [api, onSelect])
104
+
105
+ return (
106
+ <CarouselContext.Provider
107
+ value={{
108
+ carouselRef,
109
+ api: api,
110
+ opts,
111
+ orientation:
112
+ orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
113
+ scrollPrev,
114
+ scrollNext,
115
+ canScrollPrev,
116
+ canScrollNext,
117
+ }}
118
+ >
119
+ <div
120
+ onKeyDownCapture={handleKeyDown}
121
+ className={cn("relative", className)}
122
+ role="region"
123
+ aria-roledescription="carousel"
124
+ data-slot="carousel"
125
+ {...props}
126
+ >
127
+ {children}
128
+ </div>
129
+ </CarouselContext.Provider>
130
+ )
131
+ }
132
+
133
+ function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
134
+ const { carouselRef, orientation } = useCarousel()
135
+
136
+ return (
137
+ <div
138
+ ref={carouselRef}
139
+ className="overflow-hidden"
140
+ data-slot="carousel-content"
141
+ >
142
+ <div
143
+ className={cn(
144
+ "flex",
145
+ orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
146
+ className
147
+ )}
148
+ {...props}
149
+ />
150
+ </div>
151
+ )
152
+ }
153
+
154
+ function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
155
+ const { orientation } = useCarousel()
156
+
157
+ return (
158
+ <div
159
+ role="group"
160
+ aria-roledescription="slide"
161
+ data-slot="carousel-item"
162
+ className={cn(
163
+ "min-w-0 shrink-0 grow-0 basis-full",
164
+ orientation === "horizontal" ? "pl-4" : "pt-4",
165
+ className
166
+ )}
167
+ {...props}
168
+ />
169
+ )
170
+ }
171
+
172
+ function CarouselPrevious({
173
+ className,
174
+ variant = "outline",
175
+ size = "icon",
176
+ ...props
177
+ }: React.ComponentProps<typeof Button>) {
178
+ const { orientation, scrollPrev, canScrollPrev } = useCarousel()
179
+
180
+ return (
181
+ <Button
182
+ data-slot="carousel-previous"
183
+ variant={variant}
184
+ size={size}
185
+ className={cn(
186
+ "absolute size-8 rounded-full",
187
+ orientation === "horizontal"
188
+ ? "top-1/2 -left-12 -translate-y-1/2"
189
+ : "-top-12 left-1/2 -translate-x-1/2 rotate-90",
190
+ className
191
+ )}
192
+ disabled={!canScrollPrev}
193
+ onClick={scrollPrev}
194
+ {...props}
195
+ >
196
+ <ArrowLeft />
197
+ <span className="sr-only">Previous slide</span>
198
+ </Button>
199
+ )
200
+ }
201
+
202
+ function CarouselNext({
203
+ className,
204
+ variant = "outline",
205
+ size = "icon",
206
+ ...props
207
+ }: React.ComponentProps<typeof Button>) {
208
+ const { orientation, scrollNext, canScrollNext } = useCarousel()
209
+
210
+ return (
211
+ <Button
212
+ data-slot="carousel-next"
213
+ variant={variant}
214
+ size={size}
215
+ className={cn(
216
+ "absolute size-8 rounded-full",
217
+ orientation === "horizontal"
218
+ ? "top-1/2 -right-12 -translate-y-1/2"
219
+ : "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
220
+ className
221
+ )}
222
+ disabled={!canScrollNext}
223
+ onClick={scrollNext}
224
+ {...props}
225
+ >
226
+ <ArrowRight />
227
+ <span className="sr-only">Next slide</span>
228
+ </Button>
229
+ )
230
+ }
231
+
232
+ export {
233
+ type CarouselApi,
234
+ Carousel,
235
+ CarouselContent,
236
+ CarouselItem,
237
+ CarouselPrevious,
238
+ CarouselNext,
239
+ }
@@ -17,9 +17,19 @@ import {
17
17
  getForcedRouterMode,
18
18
  getProjectOptions,
19
19
  getProjectPath,
20
+ getRegistry,
20
21
  } from './server-environment.js'
21
22
 
22
23
  import type { SerializedOptions } from '@tanstack/cta-engine'
24
+ import type { Registry } from '@/types.js'
25
+
26
+ function absolutizeUrl(originalUrl: string, relativeUrl: string) {
27
+ if (relativeUrl.startsWith('http') || relativeUrl.startsWith('https')) {
28
+ return relativeUrl
29
+ }
30
+ const baseUrl = originalUrl.replace(/registry.json$/, '')
31
+ return `${baseUrl}${relativeUrl.replace(/^\.\//, '')}`
32
+ }
23
33
 
24
34
  export async function generateInitialPayload() {
25
35
  registerFrameworks()
@@ -54,6 +64,21 @@ export async function generateInitialPayload() {
54
64
  }
55
65
  }
56
66
 
67
+ const registryUrl = getRegistry()
68
+ let registry: Registry | undefined
69
+ if (registryUrl) {
70
+ registry = (await fetch(registryUrl).then((res) => res.json())) as Registry
71
+ for (const addOn of registry['add-ons']) {
72
+ addOn.url = absolutizeUrl(registryUrl, addOn.url)
73
+ }
74
+ for (const starter of registry.starters) {
75
+ starter.url = absolutizeUrl(registryUrl, starter.url)
76
+ if (starter.banner) {
77
+ starter.banner = absolutizeUrl(registryUrl, starter.banner)
78
+ }
79
+ }
80
+ }
81
+
57
82
  const serializedOptions = getSerializedOptions()
58
83
 
59
84
  const output = await createAppWrapper(serializedOptions, {
@@ -95,5 +120,6 @@ export async function generateInitialPayload() {
95
120
  output,
96
121
  forcedRouterMode,
97
122
  forcedAddOns: getForcedAddOns(),
123
+ registry,
98
124
  }
99
125
  }
@@ -20,5 +20,11 @@ export function getForcedRouterMode(): Mode | undefined {
20
20
  }
21
21
 
22
22
  export function getForcedAddOns(): Array<string> | undefined {
23
- return process.env.CTA_FORCED_ADD_ONS?.split(',') || []
23
+ return (process.env.CTA_FORCED_ADD_ONS?.split(',') || []).filter(
24
+ (addOn: string) => addOn !== '',
25
+ )
26
+ }
27
+
28
+ export function getRegistry(): string | undefined {
29
+ return process.env.CTA_REGISTRY
24
30
  }
@@ -1,6 +1,7 @@
1
1
  import { createFileRoute } from '@tanstack/react-router'
2
2
 
3
3
  import FileNavigator from '@/components/file-navigator'
4
+ import StartupDialog from '@/components/startup-dialog'
4
5
 
5
6
  export const Route = createFileRoute('/')({
6
7
  component: App,
@@ -10,6 +11,7 @@ function App() {
10
11
  return (
11
12
  <div className="pl-3">
12
13
  <FileNavigator />
14
+ <StartupDialog />
13
15
  </div>
14
16
  )
15
17
  }
@@ -1,5 +1,6 @@
1
1
  import { useCallback, useMemo } from 'react'
2
2
  import { create } from 'zustand'
3
+ import { persist } from 'zustand/middleware'
3
4
  import { useQuery } from '@tanstack/react-query'
4
5
 
5
6
  import { getAddOnStatus } from './add-ons'
@@ -38,12 +39,15 @@ const useInitialData = () =>
38
39
  applicationMode: 'none',
39
40
  forcedRouterMode: undefined,
40
41
  forcedAddOns: [],
42
+ registry: undefined,
41
43
  },
42
44
  })
43
45
 
44
46
  const useForcedRouterMode = () => useInitialData().data.forcedRouterMode
45
47
  const useForcedAddOns = () => useInitialData().data.forcedAddOns
46
48
 
49
+ export const useRegistry = () => useInitialData().data.registry
50
+
47
51
  export const useProjectLocalFiles = () => useInitialData().data.localFiles
48
52
  export const useOriginalOutput = () => useInitialData().data.output
49
53
  export const useOriginalSelectedAddOns = () =>
@@ -263,6 +267,41 @@ export function useDryRun() {
263
267
  return dryRunOutput
264
268
  }
265
269
 
270
+ type StartupDialogState = {
271
+ open: boolean
272
+ dontShowAgain: boolean
273
+ setOpen: (open: boolean) => void
274
+ setDontShowAgain: (dontShowAgain: boolean) => void
275
+ }
276
+
277
+ export const useStartupDialog = create<StartupDialogState>()(
278
+ persist(
279
+ (set) => ({
280
+ open: false,
281
+ dontShowAgain: false,
282
+ setOpen: (open) => set({ open }),
283
+ setDontShowAgain: (dontShowAgain) => set({ dontShowAgain }),
284
+ }),
285
+ {
286
+ name: 'startup-dialog',
287
+ partialize: (state) => ({
288
+ dontShowAgain: state.dontShowAgain,
289
+ }),
290
+ merge: (persistedState: unknown, currentState) => {
291
+ if (
292
+ persistedState &&
293
+ (persistedState as { dontShowAgain?: boolean }).dontShowAgain
294
+ ) {
295
+ currentState.open = false
296
+ } else {
297
+ currentState.open = true
298
+ }
299
+ return currentState
300
+ },
301
+ },
302
+ ),
303
+ )
304
+
266
305
  export const setProjectName = (projectName: string) =>
267
306
  useProjectOptions.setState({
268
307
  projectName,
package/src/types.d.ts CHANGED
@@ -55,6 +55,20 @@ export type FileTreeItem = TreeDataItem & {
55
55
  modifiedFile?: string
56
56
  }
57
57
 
58
+ export type Registry = {
59
+ starters: Array<{
60
+ name: string
61
+ description: string
62
+ url: string
63
+ banner?: string
64
+ }>
65
+ 'add-ons': Array<{
66
+ name: string
67
+ description: string
68
+ url: string
69
+ }>
70
+ }
71
+
58
72
  export type InitialData = {
59
73
  options: SerializedOptions
60
74
  output: GeneratorOutput
@@ -66,6 +80,7 @@ export type InitialData = {
66
80
  applicationMode: ApplicationMode
67
81
  forcedRouterMode?: Mode
68
82
  forcedAddOns?: Array<string>
83
+ registry: Registry | undefined
69
84
  }
70
85
 
71
86
  export type EventItem = {