create-reactor 0.1.0
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/LICENSE +21 -0
- package/README.md +123 -0
- package/create-app.mjs +712 -0
- package/lib/build.mjs +434 -0
- package/lib/pm.mjs +85 -0
- package/lib/presets.mjs +122 -0
- package/lib/templates/ai-docs.mjs +80 -0
- package/lib/templates/app.mjs +961 -0
- package/lib/templates/backend.mjs +715 -0
- package/lib/templates/base.mjs +671 -0
- package/lib/templates/biome.mjs +107 -0
- package/lib/templates/extras.mjs +360 -0
- package/lib/templates/features.mjs +463 -0
- package/lib/templates/quality.mjs +159 -0
- package/lib/templates/readme.mjs +351 -0
- package/lib/templates/security.mjs +70 -0
- package/lib/templates/server.mjs +141 -0
- package/lib/templates/state.mjs +192 -0
- package/package.json +52 -0
|
@@ -0,0 +1,961 @@
|
|
|
1
|
+
// React application source files: main.tsx (provider composition), routes/App,
|
|
2
|
+
// header, theme toggle, demos and auth UI.
|
|
3
|
+
|
|
4
|
+
/** Pretty label for the AI provider. */
|
|
5
|
+
export function aiLabel(ai) {
|
|
6
|
+
return { anthropic: "Anthropic", openai: "OpenAI", google: "Google" }[ai] ?? "";
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Stack items shown as badges on the home page. */
|
|
10
|
+
export function stackList(c) {
|
|
11
|
+
const items = ["React 19", "TypeScript", "Vite", "Tailwind CSS v4", "shadcn/ui"];
|
|
12
|
+
if (c.router === "tanstack") items.push("TanStack Router");
|
|
13
|
+
if (c.router === "react-router") items.push("React Router");
|
|
14
|
+
if (c.backend === "convex") items.push("Convex");
|
|
15
|
+
if (c.backend === "supabase") items.push("Supabase");
|
|
16
|
+
if (c.backend === "hono") items.push("Hono", "tRPC");
|
|
17
|
+
if (c.orm === "drizzle") items.push("Drizzle ORM");
|
|
18
|
+
if (c.orm === "prisma") items.push("Prisma");
|
|
19
|
+
if (c.dbProvider === "neon") items.push("Neon");
|
|
20
|
+
if (c.dbProvider === "turso") items.push("Turso");
|
|
21
|
+
if (c.auth === "clerk") items.push("Clerk");
|
|
22
|
+
if (c.auth === "convex-auth") items.push("Convex Auth");
|
|
23
|
+
if (c.auth === "better-auth") items.push("Better Auth");
|
|
24
|
+
if (c.auth === "supabase-auth") items.push("Supabase Auth");
|
|
25
|
+
if (c.extras.includes("redis")) items.push("Upstash Redis");
|
|
26
|
+
if (c.ai !== "none") items.push(`AI SDK (${aiLabel(c.ai)})`);
|
|
27
|
+
if (c.state === "zustand") items.push("Zustand");
|
|
28
|
+
if (c.state === "jotai") items.push("Jotai");
|
|
29
|
+
if (c.state === "redux") items.push("Redux Toolkit");
|
|
30
|
+
if (c.extras.includes("query")) items.push("TanStack Query");
|
|
31
|
+
if (c.extras.includes("table")) items.push("TanStack Table");
|
|
32
|
+
if (c.extras.includes("forms")) items.push("React Hook Form + Zod");
|
|
33
|
+
if (c.extras.includes("charts")) items.push("Recharts");
|
|
34
|
+
if (c.extras.includes("motion")) items.push("Motion");
|
|
35
|
+
if (c.extras.includes("gsap")) items.push("GSAP");
|
|
36
|
+
if (c.extras.includes("editor")) items.push("Tiptap");
|
|
37
|
+
if (c.extras.includes("maps")) items.push("Leaflet");
|
|
38
|
+
if (c.extras.includes("dates")) items.push("date-fns");
|
|
39
|
+
if (c.extras.includes("nuqs")) items.push("nuqs");
|
|
40
|
+
if (c.extras.includes("stripe")) items.push("Stripe");
|
|
41
|
+
if (c.extras.includes("resend")) items.push("Resend");
|
|
42
|
+
if (c.extras.includes("posthog")) items.push("PostHog");
|
|
43
|
+
if (c.extras.includes("i18n")) items.push("i18next");
|
|
44
|
+
if (c.extras.includes("pwa")) items.push("PWA");
|
|
45
|
+
if (c.extras.includes("testing")) items.push("Vitest");
|
|
46
|
+
if (c.extras.includes("e2e")) items.push("Playwright");
|
|
47
|
+
if (c.extras.includes("sentry")) items.push("Sentry");
|
|
48
|
+
return items;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Nest JSX wrappers around inner content with indentation. Wrappers: innermost first. */
|
|
52
|
+
function nest(inner, wrappers, baseIndent = " ") {
|
|
53
|
+
let lines = Array.isArray(inner) ? inner : [inner];
|
|
54
|
+
for (const [open, close] of wrappers) {
|
|
55
|
+
lines = [open, ...lines.map((l) => " " + l), close];
|
|
56
|
+
}
|
|
57
|
+
return lines.map((l) => baseIndent + l).join("\n");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** src/main.tsx — composes providers based on selected features. */
|
|
61
|
+
export function mainTsx(c) {
|
|
62
|
+
const imports = [
|
|
63
|
+
`import { StrictMode } from "react";`,
|
|
64
|
+
`import { createRoot } from "react-dom/client";`,
|
|
65
|
+
];
|
|
66
|
+
const setup = [];
|
|
67
|
+
|
|
68
|
+
// Side-effect imports: Sentry first (instruments everything), then analytics/i18n
|
|
69
|
+
if (c.extras.includes("i18n")) {
|
|
70
|
+
imports.unshift(`import "./lib/i18n";`);
|
|
71
|
+
}
|
|
72
|
+
if (c.extras.includes("posthog")) {
|
|
73
|
+
imports.unshift(`import "./lib/posthog";`);
|
|
74
|
+
}
|
|
75
|
+
if (c.extras.includes("sentry")) {
|
|
76
|
+
imports.unshift(`import "./lib/sentry";`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Router imports
|
|
80
|
+
if (c.router === "tanstack") {
|
|
81
|
+
imports.push(`import { RouterProvider, createRouter } from "@tanstack/react-router";`);
|
|
82
|
+
imports.push(`import { routeTree } from "./routeTree.gen";`);
|
|
83
|
+
} else {
|
|
84
|
+
imports.push(`import App from "./App";`);
|
|
85
|
+
if (c.router === "react-router") {
|
|
86
|
+
imports.push(`import { BrowserRouter } from "react-router";`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Backend / auth imports
|
|
91
|
+
if (c.backend === "convex") {
|
|
92
|
+
if (c.auth === "clerk") {
|
|
93
|
+
imports.push(`import { ClerkProvider, useAuth } from "@clerk/clerk-react";`);
|
|
94
|
+
imports.push(`import { ConvexProviderWithClerk } from "convex/react-clerk";`);
|
|
95
|
+
imports.push(`import { ConvexReactClient } from "convex/react";`);
|
|
96
|
+
} else if (c.auth === "convex-auth") {
|
|
97
|
+
imports.push(`import { ConvexAuthProvider } from "@convex-dev/auth/react";`);
|
|
98
|
+
imports.push(`import { ConvexReactClient } from "convex/react";`);
|
|
99
|
+
} else if (c.auth === "better-auth") {
|
|
100
|
+
imports.push(`import { ConvexBetterAuthProvider } from "@convex-dev/better-auth/react";`);
|
|
101
|
+
imports.push(`import { ConvexReactClient } from "convex/react";`);
|
|
102
|
+
imports.push(`import { authClient } from "@/lib/auth-client";`);
|
|
103
|
+
} else {
|
|
104
|
+
imports.push(`import { ConvexProvider, ConvexReactClient } from "convex/react";`);
|
|
105
|
+
}
|
|
106
|
+
} else if (c.auth === "clerk") {
|
|
107
|
+
imports.push(`import { ClerkProvider } from "@clerk/clerk-react";`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Hono/tRPC: the QueryClient lives in src/lib/trpc.ts next to the typed client
|
|
111
|
+
if (c.backend === "hono") {
|
|
112
|
+
imports.push(`import { QueryClientProvider } from "@tanstack/react-query";`);
|
|
113
|
+
imports.push(`import { queryClient } from "@/lib/trpc";`);
|
|
114
|
+
} else if (c.extras.includes("query")) {
|
|
115
|
+
imports.push(`import { QueryClient, QueryClientProvider } from "@tanstack/react-query";`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Redux needs a Provider; Zustand/Jotai work without one
|
|
119
|
+
if (c.state === "redux") {
|
|
120
|
+
imports.push(`import { Provider as ReduxProvider } from "react-redux";`);
|
|
121
|
+
imports.push(`import { store } from "@/stores/store";`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// nuqs: type-safe URL search params adapter
|
|
125
|
+
if (c.extras.includes("nuqs")) {
|
|
126
|
+
imports.push(`import { NuqsAdapter } from "nuqs/adapters/react";`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
imports.push(`import "./index.css";`);
|
|
130
|
+
|
|
131
|
+
// Setup code
|
|
132
|
+
if (c.router === "tanstack") {
|
|
133
|
+
setup.push(`const router = createRouter({ routeTree });
|
|
134
|
+
|
|
135
|
+
declare module "@tanstack/react-router" {
|
|
136
|
+
interface Register {
|
|
137
|
+
router: typeof router;
|
|
138
|
+
}
|
|
139
|
+
}`);
|
|
140
|
+
}
|
|
141
|
+
if (c.backend === "convex") {
|
|
142
|
+
setup.push(`const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL);`);
|
|
143
|
+
}
|
|
144
|
+
if (c.auth === "clerk") {
|
|
145
|
+
setup.push(`const CLERK_PUBLISHABLE_KEY = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY;
|
|
146
|
+
if (!CLERK_PUBLISHABLE_KEY) {
|
|
147
|
+
throw new Error("Add VITE_CLERK_PUBLISHABLE_KEY to .env.local (see README)");
|
|
148
|
+
}`);
|
|
149
|
+
}
|
|
150
|
+
if (c.backend !== "hono" && c.extras.includes("query")) {
|
|
151
|
+
setup.push(`const queryClient = new QueryClient();`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// JSX tree (wrappers listed innermost-first)
|
|
155
|
+
const inner = c.router === "tanstack" ? `<RouterProvider router={router} />` : `<App />`;
|
|
156
|
+
const wrappers = [];
|
|
157
|
+
|
|
158
|
+
if (c.router === "react-router") {
|
|
159
|
+
wrappers.push([`<BrowserRouter>`, `</BrowserRouter>`]);
|
|
160
|
+
}
|
|
161
|
+
if (c.extras.includes("nuqs")) {
|
|
162
|
+
wrappers.push([`<NuqsAdapter>`, `</NuqsAdapter>`]);
|
|
163
|
+
}
|
|
164
|
+
if (c.backend === "hono" || c.extras.includes("query")) {
|
|
165
|
+
wrappers.push([`<QueryClientProvider client={queryClient}>`, `</QueryClientProvider>`]);
|
|
166
|
+
}
|
|
167
|
+
if (c.state === "redux") {
|
|
168
|
+
wrappers.push([`<ReduxProvider store={store}>`, `</ReduxProvider>`]);
|
|
169
|
+
}
|
|
170
|
+
if (c.backend === "convex") {
|
|
171
|
+
if (c.auth === "clerk") {
|
|
172
|
+
wrappers.push([
|
|
173
|
+
`<ConvexProviderWithClerk client={convex} useAuth={useAuth}>`,
|
|
174
|
+
`</ConvexProviderWithClerk>`,
|
|
175
|
+
]);
|
|
176
|
+
wrappers.push([
|
|
177
|
+
`<ClerkProvider publishableKey={CLERK_PUBLISHABLE_KEY} afterSignOutUrl="/">`,
|
|
178
|
+
`</ClerkProvider>`,
|
|
179
|
+
]);
|
|
180
|
+
} else if (c.auth === "convex-auth") {
|
|
181
|
+
wrappers.push([`<ConvexAuthProvider client={convex}>`, `</ConvexAuthProvider>`]);
|
|
182
|
+
} else if (c.auth === "better-auth") {
|
|
183
|
+
wrappers.push([
|
|
184
|
+
`<ConvexBetterAuthProvider client={convex} authClient={authClient}>`,
|
|
185
|
+
`</ConvexBetterAuthProvider>`,
|
|
186
|
+
]);
|
|
187
|
+
} else {
|
|
188
|
+
wrappers.push([`<ConvexProvider client={convex}>`, `</ConvexProvider>`]);
|
|
189
|
+
}
|
|
190
|
+
} else if (c.auth === "clerk") {
|
|
191
|
+
wrappers.push([
|
|
192
|
+
`<ClerkProvider publishableKey={CLERK_PUBLISHABLE_KEY} afterSignOutUrl="/">`,
|
|
193
|
+
`</ClerkProvider>`,
|
|
194
|
+
]);
|
|
195
|
+
}
|
|
196
|
+
wrappers.push([`<StrictMode>`, `</StrictMode>`]);
|
|
197
|
+
|
|
198
|
+
return `${imports.join("\n")}
|
|
199
|
+
|
|
200
|
+
${setup.join("\n\n")}
|
|
201
|
+
|
|
202
|
+
createRoot(document.getElementById("root")!).render(
|
|
203
|
+
${nest(inner, wrappers)},
|
|
204
|
+
);
|
|
205
|
+
`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const LAYOUT_OPEN = `<div className="bg-background min-h-svh">
|
|
209
|
+
<Header />
|
|
210
|
+
<main className="container mx-auto px-4 py-10">`;
|
|
211
|
+
const LAYOUT_CLOSE = `</main>
|
|
212
|
+
</div>`;
|
|
213
|
+
|
|
214
|
+
/** src/App.tsx — only generated when NOT using TanStack Router. */
|
|
215
|
+
export function appTsx(c) {
|
|
216
|
+
if (c.router === "react-router") {
|
|
217
|
+
return `import { Route, Routes } from "react-router";
|
|
218
|
+
import { Header } from "@/components/header";
|
|
219
|
+
import { Home } from "@/components/home";
|
|
220
|
+
|
|
221
|
+
function About() {
|
|
222
|
+
return (
|
|
223
|
+
<div className="mx-auto max-w-3xl">
|
|
224
|
+
<h1 className="text-3xl font-bold tracking-tight">About</h1>
|
|
225
|
+
<p className="text-muted-foreground mt-4">
|
|
226
|
+
Edit this page in <code>src/App.tsx</code>. Add more routes inside the{" "}
|
|
227
|
+
<code><Routes></code> element.
|
|
228
|
+
</p>
|
|
229
|
+
</div>
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export default function App() {
|
|
234
|
+
return (
|
|
235
|
+
${LAYOUT_OPEN}
|
|
236
|
+
<Routes>
|
|
237
|
+
<Route path="/" element={<Home />} />
|
|
238
|
+
<Route path="/about" element={<About />} />
|
|
239
|
+
</Routes>
|
|
240
|
+
${LAYOUT_CLOSE}
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
`;
|
|
244
|
+
}
|
|
245
|
+
return `import { Header } from "@/components/header";
|
|
246
|
+
import { Home } from "@/components/home";
|
|
247
|
+
|
|
248
|
+
export default function App() {
|
|
249
|
+
return (
|
|
250
|
+
${LAYOUT_OPEN}
|
|
251
|
+
<Home />
|
|
252
|
+
${LAYOUT_CLOSE}
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
`;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** src/routes/__root.tsx (TanStack Router). */
|
|
259
|
+
export function rootRoute() {
|
|
260
|
+
return `import { Outlet, createRootRoute } from "@tanstack/react-router";
|
|
261
|
+
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
|
|
262
|
+
import { Header } from "@/components/header";
|
|
263
|
+
|
|
264
|
+
export const Route = createRootRoute({
|
|
265
|
+
component: RootLayout,
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
function RootLayout() {
|
|
269
|
+
return (
|
|
270
|
+
<div className="bg-background min-h-svh">
|
|
271
|
+
<Header />
|
|
272
|
+
<main className="container mx-auto px-4 py-10">
|
|
273
|
+
<Outlet />
|
|
274
|
+
</main>
|
|
275
|
+
<TanStackRouterDevtools position="bottom-right" />
|
|
276
|
+
</div>
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
`;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** src/routes/index.tsx (TanStack Router). */
|
|
283
|
+
export function indexRoute() {
|
|
284
|
+
return `import { createFileRoute } from "@tanstack/react-router";
|
|
285
|
+
import { Home } from "@/components/home";
|
|
286
|
+
|
|
287
|
+
export const Route = createFileRoute("/")({
|
|
288
|
+
component: Home,
|
|
289
|
+
});
|
|
290
|
+
`;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** src/routes/about.tsx (TanStack Router). */
|
|
294
|
+
export function aboutRoute() {
|
|
295
|
+
return `import { createFileRoute } from "@tanstack/react-router";
|
|
296
|
+
|
|
297
|
+
export const Route = createFileRoute("/about")({
|
|
298
|
+
component: About,
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
function About() {
|
|
302
|
+
return (
|
|
303
|
+
<div className="mx-auto max-w-3xl">
|
|
304
|
+
<h1 className="text-3xl font-bold tracking-tight">About</h1>
|
|
305
|
+
<p className="text-muted-foreground mt-4">
|
|
306
|
+
This page lives at <code>src/routes/about.tsx</code>. Add files to{" "}
|
|
307
|
+
<code>src/routes/</code> and TanStack Router picks them up automatically.
|
|
308
|
+
</p>
|
|
309
|
+
</div>
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
`;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** src/components/header.tsx */
|
|
316
|
+
export function header(c) {
|
|
317
|
+
const imports = [`import { Zap } from "lucide-react";`];
|
|
318
|
+
let linkImport = "";
|
|
319
|
+
if (c.router === "tanstack") linkImport = `import { Link } from "@tanstack/react-router";`;
|
|
320
|
+
if (c.router === "react-router") linkImport = `import { Link } from "react-router";`;
|
|
321
|
+
if (linkImport) imports.unshift(linkImport);
|
|
322
|
+
imports.push(`import { ThemeToggle } from "@/components/theme-toggle";`);
|
|
323
|
+
if (c.auth !== "none") imports.push(`import { AuthButtons } from "@/components/auth-buttons";`);
|
|
324
|
+
|
|
325
|
+
const brand =
|
|
326
|
+
c.router !== "none"
|
|
327
|
+
? `<Link to="/" className="flex items-center gap-2 font-semibold">
|
|
328
|
+
<Zap className="size-5" />
|
|
329
|
+
${c.name}
|
|
330
|
+
</Link>`
|
|
331
|
+
: `<span className="flex items-center gap-2 font-semibold">
|
|
332
|
+
<Zap className="size-5" />
|
|
333
|
+
${c.name}
|
|
334
|
+
</span>`;
|
|
335
|
+
|
|
336
|
+
const navLink = (to, label) =>
|
|
337
|
+
c.router === "tanstack"
|
|
338
|
+
? `<Link to="${to}" className="hover:text-foreground transition-colors [&.active]:text-foreground">
|
|
339
|
+
${label}
|
|
340
|
+
</Link>`
|
|
341
|
+
: `<Link to="${to}" className="hover:text-foreground transition-colors">
|
|
342
|
+
${label}
|
|
343
|
+
</Link>`;
|
|
344
|
+
|
|
345
|
+
const nav =
|
|
346
|
+
c.router !== "none"
|
|
347
|
+
? `
|
|
348
|
+
<nav className="text-muted-foreground flex items-center gap-4 text-sm">
|
|
349
|
+
${navLink("/", "Home")}
|
|
350
|
+
${navLink("/about", "About")}
|
|
351
|
+
</nav>`
|
|
352
|
+
: "";
|
|
353
|
+
|
|
354
|
+
return `${imports.join("\n")}
|
|
355
|
+
|
|
356
|
+
export function Header() {
|
|
357
|
+
return (
|
|
358
|
+
<header className="border-b">
|
|
359
|
+
<div className="container mx-auto flex h-14 items-center justify-between px-4">
|
|
360
|
+
<div className="flex items-center gap-6">
|
|
361
|
+
${brand}${nav}
|
|
362
|
+
</div>
|
|
363
|
+
<div className="flex items-center gap-2">
|
|
364
|
+
<ThemeToggle />${c.auth !== "none" ? "\n <AuthButtons />" : ""}
|
|
365
|
+
</div>
|
|
366
|
+
</div>
|
|
367
|
+
</header>
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
`;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** src/components/theme-toggle.tsx */
|
|
374
|
+
export function themeToggle() {
|
|
375
|
+
return `import { useEffect, useState } from "react";
|
|
376
|
+
import { Moon, Sun } from "lucide-react";
|
|
377
|
+
import { Button } from "@/components/ui/button";
|
|
378
|
+
|
|
379
|
+
export function ThemeToggle() {
|
|
380
|
+
const [dark, setDark] = useState(
|
|
381
|
+
() =>
|
|
382
|
+
localStorage.getItem("theme") === "dark" ||
|
|
383
|
+
(!localStorage.getItem("theme") &&
|
|
384
|
+
window.matchMedia("(prefers-color-scheme: dark)").matches),
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
useEffect(() => {
|
|
388
|
+
document.documentElement.classList.toggle("dark", dark);
|
|
389
|
+
localStorage.setItem("theme", dark ? "dark" : "light");
|
|
390
|
+
}, [dark]);
|
|
391
|
+
|
|
392
|
+
return (
|
|
393
|
+
<Button
|
|
394
|
+
variant="ghost"
|
|
395
|
+
size="icon"
|
|
396
|
+
onClick={() => setDark(!dark)}
|
|
397
|
+
aria-label="Toggle theme"
|
|
398
|
+
>
|
|
399
|
+
{dark ? <Sun className="size-4" /> : <Moon className="size-4" />}
|
|
400
|
+
</Button>
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
`;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** src/components/home.tsx — hero, stack badges and feature demos. */
|
|
407
|
+
export function home(c) {
|
|
408
|
+
const stack = stackList(c);
|
|
409
|
+
const imports = [
|
|
410
|
+
`import { Badge } from "@/components/ui/badge";`,
|
|
411
|
+
`import {
|
|
412
|
+
Card,
|
|
413
|
+
CardContent,
|
|
414
|
+
CardDescription,
|
|
415
|
+
CardHeader,
|
|
416
|
+
CardTitle,
|
|
417
|
+
} from "@/components/ui/card";`,
|
|
418
|
+
];
|
|
419
|
+
const sections = [];
|
|
420
|
+
|
|
421
|
+
if (c.backend === "convex") {
|
|
422
|
+
imports.push(`import { TasksDemo } from "@/components/tasks-demo";`);
|
|
423
|
+
sections.push(` <TasksDemo />`);
|
|
424
|
+
}
|
|
425
|
+
if (c.auth === "convex-auth" || c.auth === "better-auth") {
|
|
426
|
+
const authName = c.auth === "convex-auth" ? "Convex Auth" : "Better Auth";
|
|
427
|
+
// Both providers propagate auth state through Convex, so the same
|
|
428
|
+
// Authenticated/Unauthenticated helpers work for either.
|
|
429
|
+
imports.unshift(`import { Authenticated, Unauthenticated } from "convex/react";`);
|
|
430
|
+
imports.push(`import { SignInForm } from "@/components/sign-in-form";`);
|
|
431
|
+
sections.push(` <Unauthenticated>
|
|
432
|
+
<Card>
|
|
433
|
+
<CardHeader>
|
|
434
|
+
<CardTitle>Sign in</CardTitle>
|
|
435
|
+
<CardDescription>
|
|
436
|
+
${authName} with email + password. Create an account to try it.
|
|
437
|
+
</CardDescription>
|
|
438
|
+
</CardHeader>
|
|
439
|
+
<CardContent>
|
|
440
|
+
<SignInForm />
|
|
441
|
+
</CardContent>
|
|
442
|
+
</Card>
|
|
443
|
+
</Unauthenticated>
|
|
444
|
+
<Authenticated>
|
|
445
|
+
<Card>
|
|
446
|
+
<CardHeader>
|
|
447
|
+
<CardTitle>Signed in</CardTitle>
|
|
448
|
+
<CardDescription>You are authenticated with ${authName}.</CardDescription>
|
|
449
|
+
</CardHeader>
|
|
450
|
+
</Card>
|
|
451
|
+
</Authenticated>`);
|
|
452
|
+
}
|
|
453
|
+
if (c.backend === "hono") {
|
|
454
|
+
imports.push(`import { TrpcDemo } from "@/components/trpc-demo";`);
|
|
455
|
+
sections.push(` <TrpcDemo />`);
|
|
456
|
+
}
|
|
457
|
+
if (c.ai !== "none" && c.backend === "convex") {
|
|
458
|
+
// The chat demo needs a server endpoint; Convex HTTP actions provide one.
|
|
459
|
+
imports.push(`import { ChatDemo } from "@/components/chat-demo";`);
|
|
460
|
+
sections.push(` <ChatDemo />`);
|
|
461
|
+
}
|
|
462
|
+
if (c.extras.includes("table")) {
|
|
463
|
+
imports.push(`import { DataTableDemo } from "@/components/data-table-demo";`);
|
|
464
|
+
sections.push(` <DataTableDemo />`);
|
|
465
|
+
}
|
|
466
|
+
if (c.extras.includes("charts")) {
|
|
467
|
+
imports.push(`import { ChartDemo } from "@/components/chart-demo";`);
|
|
468
|
+
sections.push(` <ChartDemo />`);
|
|
469
|
+
}
|
|
470
|
+
if (c.state && c.state !== "none") {
|
|
471
|
+
imports.push(`import { CounterDemo } from "@/components/counter-demo";`);
|
|
472
|
+
sections.push(` <CounterDemo />`);
|
|
473
|
+
}
|
|
474
|
+
if (c.extras.includes("gsap")) {
|
|
475
|
+
imports.push(`import { GsapDemo } from "@/components/gsap-demo";`);
|
|
476
|
+
sections.push(` <GsapDemo />`);
|
|
477
|
+
}
|
|
478
|
+
if (c.extras.includes("editor")) {
|
|
479
|
+
imports.push(`import { EditorDemo } from "@/components/editor-demo";`);
|
|
480
|
+
sections.push(` <EditorDemo />`);
|
|
481
|
+
}
|
|
482
|
+
if (c.extras.includes("maps")) {
|
|
483
|
+
imports.push(`import { MapDemo } from "@/components/map-demo";`);
|
|
484
|
+
sections.push(` <MapDemo />`);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
return `${imports.join("\n")}
|
|
488
|
+
|
|
489
|
+
const stack = ${JSON.stringify(stack, null, 2).replace(/\n/g, "\n")};
|
|
490
|
+
|
|
491
|
+
export function Home() {
|
|
492
|
+
return (
|
|
493
|
+
<div className="mx-auto max-w-3xl space-y-8">
|
|
494
|
+
<section className="space-y-4 py-8 text-center">
|
|
495
|
+
<h1 className="text-4xl font-bold tracking-tight">${c.name}</h1>
|
|
496
|
+
<p className="text-muted-foreground text-lg">
|
|
497
|
+
Your stack is wired up and ready to build.
|
|
498
|
+
</p>
|
|
499
|
+
<div className="flex flex-wrap justify-center gap-2">
|
|
500
|
+
{stack.map((item) => (
|
|
501
|
+
<Badge key={item} variant="secondary">
|
|
502
|
+
{item}
|
|
503
|
+
</Badge>
|
|
504
|
+
))}
|
|
505
|
+
</div>
|
|
506
|
+
</section>
|
|
507
|
+
${sections.length ? sections.join("\n") + "\n" : ""} <Card>
|
|
508
|
+
<CardHeader>
|
|
509
|
+
<CardTitle>Make it yours</CardTitle>
|
|
510
|
+
<CardDescription>Where to go from here.</CardDescription>
|
|
511
|
+
</CardHeader>
|
|
512
|
+
<CardContent className="text-muted-foreground space-y-2 text-sm">
|
|
513
|
+
<p>
|
|
514
|
+
• Edit <code className="text-foreground">src/components/home.tsx</code> to change
|
|
515
|
+
this page.
|
|
516
|
+
</p>
|
|
517
|
+
<p>
|
|
518
|
+
• Add shadcn/ui components with{" "}
|
|
519
|
+
<code className="text-foreground">${c.pmDlxLabel("shadcn@latest")} add <component></code>.
|
|
520
|
+
</p>
|
|
521
|
+
<p>
|
|
522
|
+
• See <code className="text-foreground">README.md</code> for the full setup
|
|
523
|
+
checklist.
|
|
524
|
+
</p>
|
|
525
|
+
</CardContent>
|
|
526
|
+
</Card>
|
|
527
|
+
</div>
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
`;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** src/components/tasks-demo.tsx (Convex) */
|
|
534
|
+
export function tasksDemo(c) {
|
|
535
|
+
return `import { useQuery } from "convex/react";
|
|
536
|
+
import { api } from "../../convex/_generated/api";
|
|
537
|
+
import { Badge } from "@/components/ui/badge";
|
|
538
|
+
import {
|
|
539
|
+
Card,
|
|
540
|
+
CardContent,
|
|
541
|
+
CardDescription,
|
|
542
|
+
CardHeader,
|
|
543
|
+
CardTitle,
|
|
544
|
+
} from "@/components/ui/card";
|
|
545
|
+
|
|
546
|
+
export function TasksDemo() {
|
|
547
|
+
const tasks = useQuery(api.tasks.get);
|
|
548
|
+
|
|
549
|
+
return (
|
|
550
|
+
<Card>
|
|
551
|
+
<CardHeader>
|
|
552
|
+
<CardTitle>Convex live query</CardTitle>
|
|
553
|
+
<CardDescription>
|
|
554
|
+
Data from the <code>tasks</code> table — updates in real time.
|
|
555
|
+
</CardDescription>
|
|
556
|
+
</CardHeader>
|
|
557
|
+
<CardContent>
|
|
558
|
+
{tasks === undefined ? (
|
|
559
|
+
<p className="text-muted-foreground text-sm">
|
|
560
|
+
Waiting for Convex… make sure <code>${c.pmRunLabel("dev")}</code> is running and you
|
|
561
|
+
ran <code>${c.pmRunLabel("setup")}</code> once.
|
|
562
|
+
</p>
|
|
563
|
+
) : tasks.length === 0 ? (
|
|
564
|
+
<p className="text-muted-foreground text-sm">
|
|
565
|
+
No tasks yet. Import sample data:{" "}
|
|
566
|
+
<code>${c.pmDlxLabel("convex")} import --table tasks sampleData.jsonl</code>
|
|
567
|
+
</p>
|
|
568
|
+
) : (
|
|
569
|
+
<ul className="space-y-2">
|
|
570
|
+
{tasks.map((task) => (
|
|
571
|
+
<li
|
|
572
|
+
key={task._id}
|
|
573
|
+
className="flex items-center justify-between rounded-md border px-3 py-2 text-sm"
|
|
574
|
+
>
|
|
575
|
+
<span>{task.text}</span>
|
|
576
|
+
<Badge variant={task.isCompleted ? "default" : "outline"}>
|
|
577
|
+
{task.isCompleted ? "Done" : "Open"}
|
|
578
|
+
</Badge>
|
|
579
|
+
</li>
|
|
580
|
+
))}
|
|
581
|
+
</ul>
|
|
582
|
+
)}
|
|
583
|
+
</CardContent>
|
|
584
|
+
</Card>
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
`;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** src/components/auth-buttons.tsx */
|
|
591
|
+
export function authButtons(c) {
|
|
592
|
+
if (c.auth === "clerk") {
|
|
593
|
+
return `import {
|
|
594
|
+
SignInButton,
|
|
595
|
+
SignedIn,
|
|
596
|
+
SignedOut,
|
|
597
|
+
UserButton,
|
|
598
|
+
} from "@clerk/clerk-react";
|
|
599
|
+
import { Button } from "@/components/ui/button";
|
|
600
|
+
|
|
601
|
+
export function AuthButtons() {
|
|
602
|
+
return (
|
|
603
|
+
<>
|
|
604
|
+
<SignedOut>
|
|
605
|
+
<SignInButton mode="modal">
|
|
606
|
+
<Button size="sm">Sign in</Button>
|
|
607
|
+
</SignInButton>
|
|
608
|
+
</SignedOut>
|
|
609
|
+
<SignedIn>
|
|
610
|
+
<UserButton />
|
|
611
|
+
</SignedIn>
|
|
612
|
+
</>
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
`;
|
|
616
|
+
}
|
|
617
|
+
if (c.auth === "convex-auth") {
|
|
618
|
+
return `import { useAuthActions } from "@convex-dev/auth/react";
|
|
619
|
+
import { Authenticated } from "convex/react";
|
|
620
|
+
import { Button } from "@/components/ui/button";
|
|
621
|
+
|
|
622
|
+
export function AuthButtons() {
|
|
623
|
+
const { signOut } = useAuthActions();
|
|
624
|
+
|
|
625
|
+
return (
|
|
626
|
+
<Authenticated>
|
|
627
|
+
<Button variant="outline" size="sm" onClick={() => void signOut()}>
|
|
628
|
+
Sign out
|
|
629
|
+
</Button>
|
|
630
|
+
</Authenticated>
|
|
631
|
+
);
|
|
632
|
+
}
|
|
633
|
+
`;
|
|
634
|
+
}
|
|
635
|
+
if (c.auth === "supabase-auth") {
|
|
636
|
+
return `import { supabase } from "@/lib/supabase";
|
|
637
|
+
import { useSession } from "@/hooks/use-session";
|
|
638
|
+
import { Button } from "@/components/ui/button";
|
|
639
|
+
|
|
640
|
+
export function AuthButtons() {
|
|
641
|
+
const session = useSession();
|
|
642
|
+
|
|
643
|
+
if (session) {
|
|
644
|
+
return (
|
|
645
|
+
<Button
|
|
646
|
+
variant="outline"
|
|
647
|
+
size="sm"
|
|
648
|
+
onClick={() => void supabase.auth.signOut()}
|
|
649
|
+
>
|
|
650
|
+
Sign out
|
|
651
|
+
</Button>
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
return (
|
|
656
|
+
<Button
|
|
657
|
+
size="sm"
|
|
658
|
+
onClick={() =>
|
|
659
|
+
void supabase.auth.signInWithOAuth({ provider: "github" })
|
|
660
|
+
}
|
|
661
|
+
>
|
|
662
|
+
Sign in with GitHub
|
|
663
|
+
</Button>
|
|
664
|
+
);
|
|
665
|
+
}
|
|
666
|
+
`;
|
|
667
|
+
}
|
|
668
|
+
if (c.auth === "better-auth") {
|
|
669
|
+
return `import { authClient } from "@/lib/auth-client";
|
|
670
|
+
import { Button } from "@/components/ui/button";
|
|
671
|
+
|
|
672
|
+
export function AuthButtons() {
|
|
673
|
+
const { data: session } = authClient.useSession();
|
|
674
|
+
|
|
675
|
+
if (!session) return null;
|
|
676
|
+
|
|
677
|
+
return (
|
|
678
|
+
<Button
|
|
679
|
+
variant="outline"
|
|
680
|
+
size="sm"
|
|
681
|
+
onClick={() => void authClient.signOut()}
|
|
682
|
+
>
|
|
683
|
+
Sign out
|
|
684
|
+
</Button>
|
|
685
|
+
);
|
|
686
|
+
}
|
|
687
|
+
`;
|
|
688
|
+
}
|
|
689
|
+
return "";
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/** src/components/sign-in-form.tsx (Better Auth email + password) */
|
|
693
|
+
export function signInFormBetterAuth() {
|
|
694
|
+
return `import { useState } from "react";
|
|
695
|
+
import { authClient } from "@/lib/auth-client";
|
|
696
|
+
import { Button } from "@/components/ui/button";
|
|
697
|
+
import { Input } from "@/components/ui/input";
|
|
698
|
+
|
|
699
|
+
export function SignInForm() {
|
|
700
|
+
const [flow, setFlow] = useState<"signIn" | "signUp">("signIn");
|
|
701
|
+
const [error, setError] = useState<string | null>(null);
|
|
702
|
+
const [pending, setPending] = useState(false);
|
|
703
|
+
|
|
704
|
+
return (
|
|
705
|
+
<form
|
|
706
|
+
className="flex max-w-sm flex-col gap-3"
|
|
707
|
+
onSubmit={(e) => {
|
|
708
|
+
e.preventDefault();
|
|
709
|
+
setError(null);
|
|
710
|
+
setPending(true);
|
|
711
|
+
const formData = new FormData(e.currentTarget);
|
|
712
|
+
const email = String(formData.get("email"));
|
|
713
|
+
const password = String(formData.get("password"));
|
|
714
|
+
const request =
|
|
715
|
+
flow === "signIn"
|
|
716
|
+
? authClient.signIn.email({ email, password })
|
|
717
|
+
: authClient.signUp.email({
|
|
718
|
+
email,
|
|
719
|
+
password,
|
|
720
|
+
name: email.split("@")[0],
|
|
721
|
+
});
|
|
722
|
+
void request
|
|
723
|
+
.then((result) => {
|
|
724
|
+
if (result.error) {
|
|
725
|
+
setError(result.error.message ?? "Something went wrong");
|
|
726
|
+
}
|
|
727
|
+
})
|
|
728
|
+
.finally(() => setPending(false));
|
|
729
|
+
}}
|
|
730
|
+
>
|
|
731
|
+
<Input name="email" type="email" placeholder="Email" required />
|
|
732
|
+
<Input name="password" type="password" placeholder="Password" required />
|
|
733
|
+
<Button type="submit" disabled={pending}>
|
|
734
|
+
{flow === "signIn" ? "Sign in" : "Sign up"}
|
|
735
|
+
</Button>
|
|
736
|
+
<button
|
|
737
|
+
type="button"
|
|
738
|
+
className="text-muted-foreground hover:text-foreground text-sm"
|
|
739
|
+
onClick={() => setFlow(flow === "signIn" ? "signUp" : "signIn")}
|
|
740
|
+
>
|
|
741
|
+
{flow === "signIn"
|
|
742
|
+
? "Don't have an account? Sign up"
|
|
743
|
+
: "Already have an account? Sign in"}
|
|
744
|
+
</button>
|
|
745
|
+
{error && <p className="text-destructive text-sm">{error}</p>}
|
|
746
|
+
</form>
|
|
747
|
+
);
|
|
748
|
+
}
|
|
749
|
+
`;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/** src/components/sign-in-form.tsx (Convex Auth password provider) */
|
|
753
|
+
export function signInForm() {
|
|
754
|
+
return `import { useState } from "react";
|
|
755
|
+
import { useAuthActions } from "@convex-dev/auth/react";
|
|
756
|
+
import { Button } from "@/components/ui/button";
|
|
757
|
+
import { Input } from "@/components/ui/input";
|
|
758
|
+
|
|
759
|
+
export function SignInForm() {
|
|
760
|
+
const { signIn } = useAuthActions();
|
|
761
|
+
const [flow, setFlow] = useState<"signIn" | "signUp">("signIn");
|
|
762
|
+
const [error, setError] = useState<string | null>(null);
|
|
763
|
+
|
|
764
|
+
return (
|
|
765
|
+
<form
|
|
766
|
+
className="flex max-w-sm flex-col gap-3"
|
|
767
|
+
onSubmit={(e) => {
|
|
768
|
+
e.preventDefault();
|
|
769
|
+
setError(null);
|
|
770
|
+
const formData = new FormData(e.currentTarget);
|
|
771
|
+
formData.set("flow", flow);
|
|
772
|
+
void signIn("password", formData).catch((err: Error) => {
|
|
773
|
+
setError(err.message);
|
|
774
|
+
});
|
|
775
|
+
}}
|
|
776
|
+
>
|
|
777
|
+
<Input name="email" type="email" placeholder="Email" required />
|
|
778
|
+
<Input name="password" type="password" placeholder="Password" required />
|
|
779
|
+
<Button type="submit">{flow === "signIn" ? "Sign in" : "Sign up"}</Button>
|
|
780
|
+
<button
|
|
781
|
+
type="button"
|
|
782
|
+
className="text-muted-foreground hover:text-foreground text-sm"
|
|
783
|
+
onClick={() => setFlow(flow === "signIn" ? "signUp" : "signIn")}
|
|
784
|
+
>
|
|
785
|
+
{flow === "signIn"
|
|
786
|
+
? "Don't have an account? Sign up"
|
|
787
|
+
: "Already have an account? Sign in"}
|
|
788
|
+
</button>
|
|
789
|
+
{error && <p className="text-destructive text-sm">{error}</p>}
|
|
790
|
+
</form>
|
|
791
|
+
);
|
|
792
|
+
}
|
|
793
|
+
`;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/** src/hooks/use-session.ts (Supabase Auth) */
|
|
797
|
+
export function useSessionHook() {
|
|
798
|
+
return `import { useEffect, useState } from "react";
|
|
799
|
+
import type { Session } from "@supabase/supabase-js";
|
|
800
|
+
import { supabase } from "@/lib/supabase";
|
|
801
|
+
|
|
802
|
+
export function useSession() {
|
|
803
|
+
const [session, setSession] = useState<Session | null>(null);
|
|
804
|
+
|
|
805
|
+
useEffect(() => {
|
|
806
|
+
void supabase.auth.getSession().then(({ data }) => {
|
|
807
|
+
setSession(data.session);
|
|
808
|
+
});
|
|
809
|
+
const { data: sub } = supabase.auth.onAuthStateChange((_event, session) => {
|
|
810
|
+
setSession(session);
|
|
811
|
+
});
|
|
812
|
+
return () => sub.subscription.unsubscribe();
|
|
813
|
+
}, []);
|
|
814
|
+
|
|
815
|
+
return session;
|
|
816
|
+
}
|
|
817
|
+
`;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/** src/components/chat-demo.tsx (AI SDK) */
|
|
821
|
+
export function chatDemo(c) {
|
|
822
|
+
const endpoint =
|
|
823
|
+
c.backend === "convex"
|
|
824
|
+
? `// Convex HTTP actions are served from the .site domain
|
|
825
|
+
const convexSiteUrl = import.meta.env.VITE_CONVEX_URL.replace(/\\.cloud$/, ".site");
|
|
826
|
+
const CHAT_API = \`\${convexSiteUrl}/api/chat\`;`
|
|
827
|
+
: `// TODO: point this at your server endpoint that runs the AI SDK (see README)
|
|
828
|
+
const CHAT_API = "/api/chat";`;
|
|
829
|
+
|
|
830
|
+
return `import { useState } from "react";
|
|
831
|
+
import { useChat } from "@ai-sdk/react";
|
|
832
|
+
import { DefaultChatTransport } from "ai";
|
|
833
|
+
import { Button } from "@/components/ui/button";
|
|
834
|
+
import { Input } from "@/components/ui/input";
|
|
835
|
+
import {
|
|
836
|
+
Card,
|
|
837
|
+
CardContent,
|
|
838
|
+
CardDescription,
|
|
839
|
+
CardHeader,
|
|
840
|
+
CardTitle,
|
|
841
|
+
} from "@/components/ui/card";
|
|
842
|
+
import { cn } from "@/lib/utils";
|
|
843
|
+
|
|
844
|
+
${endpoint}
|
|
845
|
+
|
|
846
|
+
export function ChatDemo() {
|
|
847
|
+
const [input, setInput] = useState("");
|
|
848
|
+
const { messages, sendMessage, status } = useChat({
|
|
849
|
+
transport: new DefaultChatTransport({ api: CHAT_API }),
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
return (
|
|
853
|
+
<Card>
|
|
854
|
+
<CardHeader>
|
|
855
|
+
<CardTitle>AI chat</CardTitle>
|
|
856
|
+
<CardDescription>
|
|
857
|
+
Streaming chat with the AI SDK (${aiLabel(c.ai)})${c.backend === "convex" ? " via a Convex HTTP action" : ""}.
|
|
858
|
+
</CardDescription>
|
|
859
|
+
</CardHeader>
|
|
860
|
+
<CardContent className="space-y-4">
|
|
861
|
+
{messages.length > 0 && (
|
|
862
|
+
<div className="max-h-80 space-y-3 overflow-y-auto">
|
|
863
|
+
{messages.map((message) => (
|
|
864
|
+
<div
|
|
865
|
+
key={message.id}
|
|
866
|
+
className={cn(
|
|
867
|
+
"max-w-[80%] rounded-lg px-3 py-2 text-sm",
|
|
868
|
+
message.role === "user"
|
|
869
|
+
? "bg-primary text-primary-foreground ml-auto"
|
|
870
|
+
: "bg-muted",
|
|
871
|
+
)}
|
|
872
|
+
>
|
|
873
|
+
{message.parts.map((part, i) =>
|
|
874
|
+
part.type === "text" ? <span key={i}>{part.text}</span> : null,
|
|
875
|
+
)}
|
|
876
|
+
</div>
|
|
877
|
+
))}
|
|
878
|
+
</div>
|
|
879
|
+
)}
|
|
880
|
+
<form
|
|
881
|
+
className="flex gap-2"
|
|
882
|
+
onSubmit={(e) => {
|
|
883
|
+
e.preventDefault();
|
|
884
|
+
if (!input.trim()) return;
|
|
885
|
+
void sendMessage({ text: input });
|
|
886
|
+
setInput("");
|
|
887
|
+
}}
|
|
888
|
+
>
|
|
889
|
+
<Input
|
|
890
|
+
value={input}
|
|
891
|
+
onChange={(e) => setInput(e.target.value)}
|
|
892
|
+
placeholder="Ask something…"
|
|
893
|
+
/>
|
|
894
|
+
<Button type="submit" disabled={status !== "ready"}>
|
|
895
|
+
Send
|
|
896
|
+
</Button>
|
|
897
|
+
</form>
|
|
898
|
+
</CardContent>
|
|
899
|
+
</Card>
|
|
900
|
+
);
|
|
901
|
+
}
|
|
902
|
+
`;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/** src/components/counter-demo.tsx + src/stores/counter.ts (Zustand) */
|
|
906
|
+
export function counterStore() {
|
|
907
|
+
return `import { create } from "zustand";
|
|
908
|
+
|
|
909
|
+
interface CounterState {
|
|
910
|
+
count: number;
|
|
911
|
+
increment: () => void;
|
|
912
|
+
decrement: () => void;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
export const useCounterStore = create<CounterState>((set) => ({
|
|
916
|
+
count: 0,
|
|
917
|
+
increment: () => set((s) => ({ count: s.count + 1 })),
|
|
918
|
+
decrement: () => set((s) => ({ count: s.count - 1 })),
|
|
919
|
+
}));
|
|
920
|
+
`;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
export function counterDemo() {
|
|
924
|
+
return `import { Minus, Plus } from "lucide-react";
|
|
925
|
+
import { Button } from "@/components/ui/button";
|
|
926
|
+
import {
|
|
927
|
+
Card,
|
|
928
|
+
CardContent,
|
|
929
|
+
CardDescription,
|
|
930
|
+
CardHeader,
|
|
931
|
+
CardTitle,
|
|
932
|
+
} from "@/components/ui/card";
|
|
933
|
+
import { useCounterStore } from "@/stores/counter";
|
|
934
|
+
|
|
935
|
+
export function CounterDemo() {
|
|
936
|
+
const { count, increment, decrement } = useCounterStore();
|
|
937
|
+
|
|
938
|
+
return (
|
|
939
|
+
<Card>
|
|
940
|
+
<CardHeader>
|
|
941
|
+
<CardTitle>Zustand store</CardTitle>
|
|
942
|
+
<CardDescription>
|
|
943
|
+
Global state from <code>src/stores/counter.ts</code>.
|
|
944
|
+
</CardDescription>
|
|
945
|
+
</CardHeader>
|
|
946
|
+
<CardContent className="flex items-center gap-4">
|
|
947
|
+
<Button variant="outline" size="icon" onClick={decrement}>
|
|
948
|
+
<Minus className="size-4" />
|
|
949
|
+
</Button>
|
|
950
|
+
<span className="min-w-10 text-center text-2xl font-bold tabular-nums">
|
|
951
|
+
{count}
|
|
952
|
+
</span>
|
|
953
|
+
<Button variant="outline" size="icon" onClick={increment}>
|
|
954
|
+
<Plus className="size-4" />
|
|
955
|
+
</Button>
|
|
956
|
+
</CardContent>
|
|
957
|
+
</Card>
|
|
958
|
+
);
|
|
959
|
+
}
|
|
960
|
+
`;
|
|
961
|
+
}
|