create-croissant 0.1.24 → 0.1.26
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/template/apps/web/src/components/app-sidebar.tsx +11 -8
- package/template/apps/web/src/routes/__root.tsx +5 -0
- package/template/apps/web/src/routes/_auth/examples/ssr-orpc-auth.tsx +11 -8
- package/template/apps/web/src/routes/_public/examples/isr.tsx +17 -12
- package/template/apps/web/src/routes/_public/examples/ssr-orpc.tsx +9 -5
- package/template/apps/web/src/routes/_public/index.tsx +18 -13
- package/template/packages/ui/src/components/mode-toggle.tsx +39 -0
- package/template/packages/ui/src/components/sidebar.tsx +11 -1
- package/template/packages/ui/src/components/theme-provider.tsx +93 -0
package/package.json
CHANGED
|
@@ -16,6 +16,8 @@ import {
|
|
|
16
16
|
SidebarRail,
|
|
17
17
|
} from "@workspace/ui/components/sidebar"
|
|
18
18
|
import { Avatar, AvatarFallback, AvatarImage } from "@workspace/ui/components/avatar"
|
|
19
|
+
import { ModeToggle } from "@workspace/ui/components/mode-toggle"
|
|
20
|
+
|
|
19
21
|
import { authClient } from "@/lib/auth-client"
|
|
20
22
|
|
|
21
23
|
// This is sample data.
|
|
@@ -113,8 +115,9 @@ export function AppSidebar({ items = authNavItems, ...props }: AppSidebarProps)
|
|
|
113
115
|
return (
|
|
114
116
|
<Sidebar {...props}>
|
|
115
117
|
<SidebarHeader>
|
|
116
|
-
<div className="flex items-center
|
|
118
|
+
<div className="flex items-center justify-between px-4 py-2">
|
|
117
119
|
<span className="font-bold">Croissant Stack</span>
|
|
120
|
+
<ModeToggle />
|
|
118
121
|
</div>
|
|
119
122
|
</SidebarHeader>
|
|
120
123
|
<SidebarContent>
|
|
@@ -163,25 +166,25 @@ export function AppSidebar({ items = authNavItems, ...props }: AppSidebarProps)
|
|
|
163
166
|
<span className="truncate text-xs">{user.email}</span>
|
|
164
167
|
</div>
|
|
165
168
|
<div className="flex items-center gap-1 ml-auto">
|
|
166
|
-
<
|
|
167
|
-
to="/account"
|
|
168
|
-
className="p-1 rounded hover:bg-sidebar-accent-foreground/10"
|
|
169
|
+
<SidebarMenuButton
|
|
170
|
+
render={<Link to="/account" />}
|
|
171
|
+
className="p-1 h-auto w-auto rounded hover:bg-sidebar-accent-foreground/10"
|
|
169
172
|
title="Account Settings"
|
|
170
173
|
>
|
|
171
174
|
<Settings className="h-4 w-4" />
|
|
172
|
-
</
|
|
173
|
-
<
|
|
175
|
+
</SidebarMenuButton>
|
|
176
|
+
<SidebarMenuButton
|
|
174
177
|
onClick={async (e) => {
|
|
175
178
|
e.preventDefault()
|
|
176
179
|
e.stopPropagation()
|
|
177
180
|
await authClient.signOut()
|
|
178
181
|
window.location.reload()
|
|
179
182
|
}}
|
|
180
|
-
className="p-1 rounded hover:bg-sidebar-accent-foreground/10"
|
|
183
|
+
className="p-1 h-auto w-auto rounded hover:bg-sidebar-accent-foreground/10"
|
|
181
184
|
title="Sign Out"
|
|
182
185
|
>
|
|
183
186
|
<LogOut className="h-4 w-4" />
|
|
184
|
-
</
|
|
187
|
+
</SidebarMenuButton>
|
|
185
188
|
</div>
|
|
186
189
|
</div>
|
|
187
190
|
</SidebarMenuButton>
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { HeadContent, Scripts, createRootRoute } from "@tanstack/react-router"
|
|
2
2
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
|
3
3
|
import { Toaster } from "@workspace/ui/components/sonner"
|
|
4
|
+
import { ThemeProvider } from "@workspace/ui/components/theme-provider"
|
|
4
5
|
|
|
5
6
|
import appCss from "@workspace/ui/globals.css?url"
|
|
6
7
|
|
|
8
|
+
|
|
7
9
|
const queryClient = new QueryClient()
|
|
8
10
|
|
|
9
11
|
export const Route = createRootRoute({
|
|
@@ -37,10 +39,13 @@ function RootDocument({ children }: { children: React.ReactNode }) {
|
|
|
37
39
|
<HeadContent />
|
|
38
40
|
</head>
|
|
39
41
|
<body>
|
|
42
|
+
<ThemeProvider defaultTheme="system" storageKey="theme">
|
|
43
|
+
|
|
40
44
|
<QueryClientProvider client={queryClient}>
|
|
41
45
|
{children}
|
|
42
46
|
<Toaster />
|
|
43
47
|
</QueryClientProvider>
|
|
48
|
+
</ThemeProvider>
|
|
44
49
|
<Scripts />
|
|
45
50
|
</body>
|
|
46
51
|
</html>
|
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
import { createFileRoute, redirect } from "@tanstack/react-router"
|
|
2
|
+
import { createServerFn } from "@tanstack/react-start"
|
|
2
3
|
import { getSessionFn } from "@/lib/auth-utils"
|
|
3
4
|
import { orpc } from "@/lib/orpc"
|
|
4
5
|
|
|
6
|
+
const getSecretData = createServerFn({ method: "GET" }).handler(async () => {
|
|
7
|
+
try {
|
|
8
|
+
const secretData = await orpc.getSecretData()
|
|
9
|
+
return { secretData }
|
|
10
|
+
} catch (err) {
|
|
11
|
+
return { secretData: null, error: "Failed to fetch secret data" }
|
|
12
|
+
}
|
|
13
|
+
})
|
|
14
|
+
|
|
5
15
|
export const Route = createFileRoute("/_auth/examples/ssr-orpc-auth")({
|
|
6
16
|
beforeLoad: async () => {
|
|
7
17
|
const session = await getSessionFn()
|
|
@@ -15,14 +25,7 @@ export const Route = createFileRoute("/_auth/examples/ssr-orpc-auth")({
|
|
|
15
25
|
}
|
|
16
26
|
return { session }
|
|
17
27
|
},
|
|
18
|
-
loader:
|
|
19
|
-
try {
|
|
20
|
-
const secretData = await orpc.getSecretData()
|
|
21
|
-
return { secretData }
|
|
22
|
-
} catch (err) {
|
|
23
|
-
return { secretData: null, error: "Failed to fetch secret data" }
|
|
24
|
-
}
|
|
25
|
-
},
|
|
28
|
+
loader: () => getSecretData(),
|
|
26
29
|
component: SSRORPCAuth,
|
|
27
30
|
})
|
|
28
31
|
|
|
@@ -1,6 +1,20 @@
|
|
|
1
1
|
import { createFileRoute } from "@tanstack/react-router"
|
|
2
|
+
import { createServerFn } from "@tanstack/react-start"
|
|
2
3
|
import { orpc } from "@/lib/orpc"
|
|
3
4
|
|
|
5
|
+
const getISRData = createServerFn({ method: "GET" }).handler(async () => {
|
|
6
|
+
// In a real ISR scenario, this would be cached on the server
|
|
7
|
+
// For this example, we'll fetch planets via oRPC
|
|
8
|
+
const planets = await orpc.planets.getPlanets()
|
|
9
|
+
|
|
10
|
+
return {
|
|
11
|
+
generatedAt: new Date().toISOString(),
|
|
12
|
+
planets,
|
|
13
|
+
message:
|
|
14
|
+
"This page is an example of ISR. In a production build with proper configuration, this data would be cached and updated in the background.",
|
|
15
|
+
}
|
|
16
|
+
})
|
|
17
|
+
|
|
4
18
|
export const Route = createFileRoute("/_public/examples/isr")({
|
|
5
19
|
head: () => ({
|
|
6
20
|
meta: [
|
|
@@ -9,21 +23,12 @@ export const Route = createFileRoute("/_public/examples/isr")({
|
|
|
9
23
|
},
|
|
10
24
|
{
|
|
11
25
|
name: "description",
|
|
12
|
-
content:
|
|
26
|
+
content:
|
|
27
|
+
"Experience high-performance page loads with ISR in Croissant Stack.",
|
|
13
28
|
},
|
|
14
29
|
],
|
|
15
30
|
}),
|
|
16
|
-
loader:
|
|
17
|
-
// In a real ISR scenario, this would be cached on the server
|
|
18
|
-
// For this example, we'll fetch planets via oRPC
|
|
19
|
-
const planets = await orpc.planets.getPlanets()
|
|
20
|
-
|
|
21
|
-
return {
|
|
22
|
-
generatedAt: new Date().toISOString(),
|
|
23
|
-
planets,
|
|
24
|
-
message: "This page is an example of ISR. In a production build with proper configuration, this data would be cached and updated in the background.",
|
|
25
|
-
}
|
|
26
|
-
},
|
|
31
|
+
loader: () => getISRData(),
|
|
27
32
|
headers: () => ({
|
|
28
33
|
// Cache at CDN for 10 seconds, allow stale content for up to 1 minute
|
|
29
34
|
"Cache-Control": "public, max-age=10, s-maxage=10, stale-while-revalidate=60",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as React from "react"
|
|
2
2
|
import { createFileRoute, useRouter } from "@tanstack/react-router"
|
|
3
|
+
import { createServerFn } from "@tanstack/react-start"
|
|
3
4
|
import { Check, Pencil, Plus, Trash2 } from "lucide-react"
|
|
4
5
|
import { toast } from "sonner"
|
|
5
6
|
import { useForm } from "@tanstack/react-form"
|
|
@@ -27,6 +28,11 @@ const planetSchema = z.object({
|
|
|
27
28
|
diameter: z.string().refine((val) => !isNaN(parseFloat(val)), "Must be a number"),
|
|
28
29
|
})
|
|
29
30
|
|
|
31
|
+
const getPlanets = createServerFn({ method: "GET" }).handler(async () => {
|
|
32
|
+
const planets = await orpc.planets.getPlanets()
|
|
33
|
+
return { planets }
|
|
34
|
+
})
|
|
35
|
+
|
|
30
36
|
export const Route = createFileRoute("/_public/examples/ssr-orpc")({
|
|
31
37
|
head: () => ({
|
|
32
38
|
meta: [
|
|
@@ -35,14 +41,12 @@ export const Route = createFileRoute("/_public/examples/ssr-orpc")({
|
|
|
35
41
|
},
|
|
36
42
|
{
|
|
37
43
|
name: "description",
|
|
38
|
-
content:
|
|
44
|
+
content:
|
|
45
|
+
"Learn how to use Server-Side Rendering (SSR) with oRPC in Croissant Stack.",
|
|
39
46
|
},
|
|
40
47
|
],
|
|
41
48
|
}),
|
|
42
|
-
loader:
|
|
43
|
-
const planets = await orpc.planets.getPlanets()
|
|
44
|
-
return { planets }
|
|
45
|
-
},
|
|
49
|
+
loader: () => getPlanets(),
|
|
46
50
|
component: SSRORPC,
|
|
47
51
|
})
|
|
48
52
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Link, createFileRoute } from "@tanstack/react-router"
|
|
2
|
+
import { createServerFn } from "@tanstack/react-start"
|
|
2
3
|
import { Button } from "@workspace/ui/components/button"
|
|
3
4
|
import type { router } from "@workspace/orpc/router"
|
|
4
5
|
import type { InferRouterOutputs } from "@orpc/server"
|
|
@@ -7,6 +8,17 @@ import { orpc } from "@/lib/orpc"
|
|
|
7
8
|
type Outputs = InferRouterOutputs<typeof router>
|
|
8
9
|
type Planet = Outputs["planets"]["getPlanets"][number]
|
|
9
10
|
|
|
11
|
+
const getHomeData = createServerFn({ method: "GET" }).handler(async () => {
|
|
12
|
+
const [helloRes, planets] = await Promise.all([
|
|
13
|
+
orpc.hello({ name: "Croissant Stack" }),
|
|
14
|
+
orpc.planets.getPlanets(),
|
|
15
|
+
])
|
|
16
|
+
return {
|
|
17
|
+
message: helloRes.message,
|
|
18
|
+
planets,
|
|
19
|
+
}
|
|
20
|
+
})
|
|
21
|
+
|
|
10
22
|
export const Route = createFileRoute("/_public/")({
|
|
11
23
|
head: () => ({
|
|
12
24
|
meta: [
|
|
@@ -15,7 +27,8 @@ export const Route = createFileRoute("/_public/")({
|
|
|
15
27
|
},
|
|
16
28
|
{
|
|
17
29
|
name: "description",
|
|
18
|
-
content:
|
|
30
|
+
content:
|
|
31
|
+
"Build full-stack applications faster with Croissant Stack. Featuring TanStack Start, oRPC, and Better Auth.",
|
|
19
32
|
},
|
|
20
33
|
{
|
|
21
34
|
property: "og:title",
|
|
@@ -31,18 +44,10 @@ export const Route = createFileRoute("/_public/")({
|
|
|
31
44
|
},
|
|
32
45
|
],
|
|
33
46
|
}),
|
|
34
|
-
loader:
|
|
35
|
-
const [helloRes, planets] = await Promise.all([
|
|
36
|
-
orpc.hello({ name: "Croissant Stack" }),
|
|
37
|
-
orpc.planets.getPlanets(),
|
|
38
|
-
])
|
|
39
|
-
return {
|
|
40
|
-
message: helloRes.message,
|
|
41
|
-
planets,
|
|
42
|
-
}
|
|
43
|
-
},
|
|
47
|
+
loader: () => getHomeData(),
|
|
44
48
|
headers: () => ({
|
|
45
|
-
"Cache-Control":
|
|
49
|
+
"Cache-Control":
|
|
50
|
+
"public, max-age=10, s-maxage=10, stale-while-revalidate=60",
|
|
46
51
|
}),
|
|
47
52
|
component: App,
|
|
48
53
|
})
|
|
@@ -64,7 +69,7 @@ function App() {
|
|
|
64
69
|
) : (
|
|
65
70
|
<ul className="grid grid-cols-1 gap-2">
|
|
66
71
|
{planets.map((planet: Planet) => (
|
|
67
|
-
<li key={planet.id} className="rounded-md border p-3
|
|
72
|
+
<li key={planet.id} className="rounded-md border p-3 shadow-sm">
|
|
68
73
|
<span className="font-bold">{planet.name}</span> - {planet.description}
|
|
69
74
|
</li>
|
|
70
75
|
))}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Moon, Sun } from "lucide-react"
|
|
2
|
+
|
|
3
|
+
import { Button } from "@workspace/ui/components/button"
|
|
4
|
+
import {
|
|
5
|
+
DropdownMenu,
|
|
6
|
+
DropdownMenuContent,
|
|
7
|
+
DropdownMenuItem,
|
|
8
|
+
DropdownMenuTrigger,
|
|
9
|
+
} from "@workspace/ui/components/dropdown-menu"
|
|
10
|
+
import { useTheme } from "@workspace/ui/components/theme-provider"
|
|
11
|
+
|
|
12
|
+
export function ModeToggle() {
|
|
13
|
+
const { setTheme } = useTheme()
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<DropdownMenu>
|
|
17
|
+
<DropdownMenuTrigger
|
|
18
|
+
render={
|
|
19
|
+
<Button variant="outline" size="icon">
|
|
20
|
+
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
|
21
|
+
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
|
22
|
+
<span className="sr-only">Toggle theme</span>
|
|
23
|
+
</Button>
|
|
24
|
+
}
|
|
25
|
+
/>
|
|
26
|
+
<DropdownMenuContent align="end">
|
|
27
|
+
<DropdownMenuItem onClick={() => setTheme("light")}>
|
|
28
|
+
Light
|
|
29
|
+
</DropdownMenuItem>
|
|
30
|
+
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
|
31
|
+
Dark
|
|
32
|
+
</DropdownMenuItem>
|
|
33
|
+
<DropdownMenuItem onClick={() => setTheme("system")}>
|
|
34
|
+
System
|
|
35
|
+
</DropdownMenuItem>
|
|
36
|
+
</DropdownMenuContent>
|
|
37
|
+
</DropdownMenu>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
@@ -506,18 +506,28 @@ function SidebarMenuButton({
|
|
|
506
506
|
size = "default",
|
|
507
507
|
tooltip,
|
|
508
508
|
className,
|
|
509
|
+
onClick,
|
|
509
510
|
...props
|
|
510
511
|
}: useRender.ComponentProps<"button"> &
|
|
511
512
|
React.ComponentProps<"button"> & {
|
|
512
513
|
isActive?: boolean
|
|
513
514
|
tooltip?: string | React.ComponentProps<typeof TooltipContent>
|
|
514
515
|
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
|
515
|
-
const { isMobile, state } = useSidebar()
|
|
516
|
+
const { isMobile, state, setOpenMobile } = useSidebar()
|
|
517
|
+
|
|
518
|
+
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
|
519
|
+
if (isMobile) {
|
|
520
|
+
setOpenMobile(false)
|
|
521
|
+
}
|
|
522
|
+
onClick?.(event)
|
|
523
|
+
}
|
|
524
|
+
|
|
516
525
|
const comp = useRender({
|
|
517
526
|
defaultTagName: "button",
|
|
518
527
|
props: mergeProps<"button">(
|
|
519
528
|
{
|
|
520
529
|
className: cn(sidebarMenuButtonVariants({ variant, size }), className),
|
|
530
|
+
onClick: handleClick,
|
|
521
531
|
},
|
|
522
532
|
props
|
|
523
533
|
),
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { createContext, useContext, useEffect, useState } from "react"
|
|
2
|
+
import { ScriptOnce } from "@tanstack/react-router"
|
|
3
|
+
|
|
4
|
+
type Theme = "dark" | "light" | "system"
|
|
5
|
+
|
|
6
|
+
type ThemeProviderProps = {
|
|
7
|
+
children: React.ReactNode
|
|
8
|
+
defaultTheme?: Theme
|
|
9
|
+
storageKey?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type ThemeProviderState = {
|
|
13
|
+
theme: Theme
|
|
14
|
+
setTheme: (theme: Theme) => void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function getThemeScript(storageKey: string, defaultTheme: Theme) {
|
|
18
|
+
const key = JSON.stringify(storageKey)
|
|
19
|
+
const fallback = JSON.stringify(defaultTheme)
|
|
20
|
+
|
|
21
|
+
return `(function(){try{var t=localStorage.getItem(${key});if(t!=='light'&&t!=='dark'&&t!=='system'){t=${fallback}}var d=matchMedia('(prefers-color-scheme: dark)').matches;var r=t==='system'?(d?'dark':'light'):t;var e=document.documentElement;e.classList.add(r);e.style.colorScheme=r}catch(e){}})();`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const ThemeProviderContext = createContext<ThemeProviderState | undefined>(
|
|
25
|
+
undefined
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
function applyTheme(theme: Theme) {
|
|
29
|
+
const root = document.documentElement
|
|
30
|
+
root.classList.remove("light", "dark")
|
|
31
|
+
|
|
32
|
+
const resolved =
|
|
33
|
+
theme === "system"
|
|
34
|
+
? window.matchMedia("(prefers-color-scheme: dark)").matches
|
|
35
|
+
? "dark"
|
|
36
|
+
: "light"
|
|
37
|
+
: theme
|
|
38
|
+
|
|
39
|
+
root.classList.add(resolved)
|
|
40
|
+
root.style.colorScheme = resolved
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function ThemeProvider({
|
|
44
|
+
children,
|
|
45
|
+
defaultTheme = "system",
|
|
46
|
+
storageKey = "theme",
|
|
47
|
+
}: ThemeProviderProps) {
|
|
48
|
+
const [theme, setThemeState] = useState<Theme>(defaultTheme)
|
|
49
|
+
const [mounted, setMounted] = useState(false)
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
const stored = localStorage.getItem(storageKey)
|
|
53
|
+
setThemeState(
|
|
54
|
+
stored === "light" || stored === "dark" || stored === "system"
|
|
55
|
+
? stored
|
|
56
|
+
: defaultTheme
|
|
57
|
+
)
|
|
58
|
+
setMounted(true)
|
|
59
|
+
}, [defaultTheme, storageKey])
|
|
60
|
+
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (!mounted) return
|
|
63
|
+
applyTheme(theme)
|
|
64
|
+
}, [theme, mounted])
|
|
65
|
+
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
if (!mounted || theme !== "system") return
|
|
68
|
+
|
|
69
|
+
const media = window.matchMedia("(prefers-color-scheme: dark)")
|
|
70
|
+
const onChange = () => applyTheme("system")
|
|
71
|
+
media.addEventListener("change", onChange)
|
|
72
|
+
return () => media.removeEventListener("change", onChange)
|
|
73
|
+
}, [theme, mounted])
|
|
74
|
+
|
|
75
|
+
const setTheme = (next: Theme) => {
|
|
76
|
+
localStorage.setItem(storageKey, next)
|
|
77
|
+
setThemeState(next)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<ThemeProviderContext value={{ theme, setTheme }}>
|
|
82
|
+
<ScriptOnce>{getThemeScript(storageKey, defaultTheme)}</ScriptOnce>
|
|
83
|
+
{children}
|
|
84
|
+
</ThemeProviderContext>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function useTheme() {
|
|
89
|
+
const context = useContext(ThemeProviderContext)
|
|
90
|
+
if (context === undefined)
|
|
91
|
+
throw new Error("useTheme must be used within a ThemeProvider")
|
|
92
|
+
return context
|
|
93
|
+
}
|