create-ej-app 24.0.0 → 26.0.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.
Files changed (161) hide show
  1. package/cli.js +6 -0
  2. package/cli.ts +8 -0
  3. package/package.json +2 -2
  4. package/template/api/package.json +2 -2
  5. package/template/nextjs/gitignore +1 -0
  6. package/template/nextjs/package.json +20 -20
  7. package/template/nextjs/postcss.config.mjs +3 -1
  8. package/template/saas-starter/.dockerignore +7 -0
  9. package/template/saas-starter/.env.example +25 -0
  10. package/template/saas-starter/.vscode/extensions.json +11 -0
  11. package/template/saas-starter/.vscode/settings.json +3 -0
  12. package/template/saas-starter/Dockerfile +15 -0
  13. package/template/saas-starter/README.md +3 -0
  14. package/template/saas-starter/components.json +25 -0
  15. package/template/saas-starter/drizzle.config.ts +10 -0
  16. package/template/saas-starter/eslint.config.mjs +23 -0
  17. package/template/saas-starter/gitignore +9 -0
  18. package/template/saas-starter/next-env.d.ts +6 -0
  19. package/template/saas-starter/next.config.ts +14 -0
  20. package/template/saas-starter/package.json +92 -0
  21. package/template/saas-starter/postcss.config.mjs +7 -0
  22. package/template/saas-starter/prettier.config.mjs +11 -0
  23. package/template/saas-starter/public/placeholder.svg +1 -0
  24. package/template/saas-starter/src/actions/uploads.ts +28 -0
  25. package/template/saas-starter/src/actions/uploads.validation.ts +14 -0
  26. package/template/saas-starter/src/app/(admin)/actions/users.ts +97 -0
  27. package/template/saas-starter/src/app/(admin)/admin/_components/admin-header.tsx +21 -0
  28. package/template/saas-starter/src/app/(admin)/admin/_components/admin-search.tsx +16 -0
  29. package/template/saas-starter/src/app/(admin)/admin/_components/admin-sidebar.tsx +40 -0
  30. package/template/saas-starter/src/app/(admin)/admin/layout.tsx +57 -0
  31. package/template/saas-starter/src/app/(admin)/admin/page.tsx +3 -0
  32. package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/create-user-dialog.tsx +183 -0
  33. package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/edit-name-dialog.tsx +129 -0
  34. package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/users-table.tsx +415 -0
  35. package/template/saas-starter/src/app/(admin)/admin/settings/users/page.tsx +15 -0
  36. package/template/saas-starter/src/app/(app)/actions/billing.ts +152 -0
  37. package/template/saas-starter/src/app/(app)/actions/members.ts +163 -0
  38. package/template/saas-starter/src/app/(app)/actions/organization.ts +92 -0
  39. package/template/saas-starter/src/app/(app)/app/[orgId]/dashboard/page.tsx +20 -0
  40. package/template/saas-starter/src/app/(app)/app/[orgId]/layout.tsx +34 -0
  41. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/billing-email-form.tsx +100 -0
  42. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/danger-zone.tsx +364 -0
  43. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/general-settings-form.tsx +155 -0
  44. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/plans-section.tsx +232 -0
  45. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/billing/page.tsx +116 -0
  46. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/_components/invite-member-dialog.tsx +169 -0
  47. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/_components/members-tabs.tsx +571 -0
  48. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/page.tsx +40 -0
  49. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/page.tsx +69 -0
  50. package/template/saas-starter/src/app/(app)/app/_components/app-header.tsx +29 -0
  51. package/template/saas-starter/src/app/(app)/app/_components/app-search.tsx +17 -0
  52. package/template/saas-starter/src/app/(app)/app/_components/app-sidebar.tsx +27 -0
  53. package/template/saas-starter/src/app/(app)/app/_components/organization-context.tsx +41 -0
  54. package/template/saas-starter/src/app/(app)/app/_components/organization-switcher.tsx +140 -0
  55. package/template/saas-starter/src/app/(app)/app/layout.tsx +52 -0
  56. package/template/saas-starter/src/app/(app)/app/onboarding/_components/create-organization-form.tsx +129 -0
  57. package/template/saas-starter/src/app/(app)/app/onboarding/page.tsx +42 -0
  58. package/template/saas-starter/src/app/(app)/app/page.tsx +81 -0
  59. package/template/saas-starter/src/app/(auth)/login/email-login-form.tsx +109 -0
  60. package/template/saas-starter/src/app/(auth)/login/page.tsx +87 -0
  61. package/template/saas-starter/src/app/(auth)/login/social-login-buttons.tsx +75 -0
  62. package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/accept-invitation-form.tsx +88 -0
  63. package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/page.tsx +167 -0
  64. package/template/saas-starter/src/app/(marketing)/layout.tsx +14 -0
  65. package/template/saas-starter/src/app/(marketing)/page.tsx +3 -0
  66. package/template/saas-starter/src/app/(marketing)/privacy/page.tsx +3 -0
  67. package/template/saas-starter/src/app/(marketing)/terms/page.tsx +3 -0
  68. package/template/saas-starter/src/app/api/auth/[...all]/route.ts +4 -0
  69. package/template/saas-starter/src/app/api/webhooks/stripe/route.ts +94 -0
  70. package/template/saas-starter/src/app/favicon.ico +0 -0
  71. package/template/saas-starter/src/app/globals.css +240 -0
  72. package/template/saas-starter/src/app/layout.tsx +53 -0
  73. package/template/saas-starter/src/app/not-found.tsx +27 -0
  74. package/template/saas-starter/src/app/proxy.ts +28 -0
  75. package/template/saas-starter/src/app/robots.ts +32 -0
  76. package/template/saas-starter/src/app/sitemap.ts +20 -0
  77. package/template/saas-starter/src/components/dashboard-header.tsx +64 -0
  78. package/template/saas-starter/src/components/data-table/enum.ts +5 -0
  79. package/template/saas-starter/src/components/data-table/index.tsx +960 -0
  80. package/template/saas-starter/src/components/data-table/types.ts +75 -0
  81. package/template/saas-starter/src/components/error.tsx +26 -0
  82. package/template/saas-starter/src/components/file-upload.tsx +215 -0
  83. package/template/saas-starter/src/components/impersonation-banner.tsx +44 -0
  84. package/template/saas-starter/src/components/loader.tsx +25 -0
  85. package/template/saas-starter/src/components/navigation-search.tsx +140 -0
  86. package/template/saas-starter/src/components/navigation-sidebar.tsx +241 -0
  87. package/template/saas-starter/src/components/page-heading.tsx +32 -0
  88. package/template/saas-starter/src/components/react-query-provider.tsx +12 -0
  89. package/template/saas-starter/src/components/screen-size.tsx +40 -0
  90. package/template/saas-starter/src/components/site-footer.tsx +23 -0
  91. package/template/saas-starter/src/components/theme-provider.tsx +67 -0
  92. package/template/saas-starter/src/components/theme-toggle.tsx +22 -0
  93. package/template/saas-starter/src/components/ui/avatar.tsx +112 -0
  94. package/template/saas-starter/src/components/ui/badge.tsx +49 -0
  95. package/template/saas-starter/src/components/ui/button.tsx +67 -0
  96. package/template/saas-starter/src/components/ui/card.tsx +103 -0
  97. package/template/saas-starter/src/components/ui/checkbox.tsx +32 -0
  98. package/template/saas-starter/src/components/ui/collapsible.tsx +33 -0
  99. package/template/saas-starter/src/components/ui/command.tsx +192 -0
  100. package/template/saas-starter/src/components/ui/dialog.tsx +164 -0
  101. package/template/saas-starter/src/components/ui/dropdown-menu.tsx +273 -0
  102. package/template/saas-starter/src/components/ui/field.tsx +238 -0
  103. package/template/saas-starter/src/components/ui/input-group.tsx +156 -0
  104. package/template/saas-starter/src/components/ui/input.tsx +19 -0
  105. package/template/saas-starter/src/components/ui/label.tsx +24 -0
  106. package/template/saas-starter/src/components/ui/popover.tsx +89 -0
  107. package/template/saas-starter/src/components/ui/select.tsx +195 -0
  108. package/template/saas-starter/src/components/ui/separator.tsx +28 -0
  109. package/template/saas-starter/src/components/ui/sheet.tsx +143 -0
  110. package/template/saas-starter/src/components/ui/sidebar.tsx +702 -0
  111. package/template/saas-starter/src/components/ui/skeleton.tsx +13 -0
  112. package/template/saas-starter/src/components/ui/sonner.tsx +45 -0
  113. package/template/saas-starter/src/components/ui/switch.tsx +33 -0
  114. package/template/saas-starter/src/components/ui/table.tsx +116 -0
  115. package/template/saas-starter/src/components/ui/tabs.tsx +90 -0
  116. package/template/saas-starter/src/components/ui/textarea.tsx +18 -0
  117. package/template/saas-starter/src/components/ui/tooltip.tsx +57 -0
  118. package/template/saas-starter/src/components/user-menu.tsx +90 -0
  119. package/template/saas-starter/src/db/common.ts +31 -0
  120. package/template/saas-starter/src/db/enums.ts +26 -0
  121. package/template/saas-starter/src/db/index.ts +51 -0
  122. package/template/saas-starter/src/db/relations.ts +75 -0
  123. package/template/saas-starter/src/db/schema.ts +191 -0
  124. package/template/saas-starter/src/emails/components/email-layout.tsx +63 -0
  125. package/template/saas-starter/src/emails/invitation.tsx +56 -0
  126. package/template/saas-starter/src/emails/magic-link.tsx +49 -0
  127. package/template/saas-starter/src/emails/test.tsx +32 -0
  128. package/template/saas-starter/src/emails/welcome.tsx +34 -0
  129. package/template/saas-starter/src/env.ts +66 -0
  130. package/template/saas-starter/src/hooks/use-mobile.ts +19 -0
  131. package/template/saas-starter/src/instrumentation.ts +26 -0
  132. package/template/saas-starter/src/lib/actions.ts +21 -0
  133. package/template/saas-starter/src/lib/admin-navigation.ts +31 -0
  134. package/template/saas-starter/src/lib/app-navigation.ts +59 -0
  135. package/template/saas-starter/src/lib/auth-client.ts +29 -0
  136. package/template/saas-starter/src/lib/auth-permissions.ts +22 -0
  137. package/template/saas-starter/src/lib/auth.ts +211 -0
  138. package/template/saas-starter/src/lib/billing.ts +117 -0
  139. package/template/saas-starter/src/lib/cn.ts +4 -0
  140. package/template/saas-starter/src/lib/constants.ts +22 -0
  141. package/template/saas-starter/src/lib/delay.ts +2 -0
  142. package/template/saas-starter/src/lib/email-service.ts +89 -0
  143. package/template/saas-starter/src/lib/format-currency.ts +27 -0
  144. package/template/saas-starter/src/lib/format-date.ts +12 -0
  145. package/template/saas-starter/src/lib/nodemailer.ts +43 -0
  146. package/template/saas-starter/src/lib/organization-access.ts +93 -0
  147. package/template/saas-starter/src/lib/organization-plan-limits.ts +38 -0
  148. package/template/saas-starter/src/lib/otel.ts +27 -0
  149. package/template/saas-starter/src/lib/plans.ts +63 -0
  150. package/template/saas-starter/src/lib/safe-action-client.ts +75 -0
  151. package/template/saas-starter/src/lib/safe-action.ts +67 -0
  152. package/template/saas-starter/src/lib/siteConfig.ts +4 -0
  153. package/template/saas-starter/src/lib/storage.ts +181 -0
  154. package/template/saas-starter/src/lib/stripe.ts +6 -0
  155. package/template/saas-starter/src/lib/toast-message.ts +15 -0
  156. package/template/saas-starter/src/lib/validations.ts +12 -0
  157. package/template/saas-starter/src/scripts/reset-db.ts +22 -0
  158. package/template/saas-starter/src/scripts/seed.ts +11 -0
  159. package/template/saas-starter/src/scripts/setup-stripe-plans.ts +200 -0
  160. package/template/saas-starter/src/scripts/test-mail.ts +35 -0
  161. package/template/saas-starter/tsconfig.json +51 -0
@@ -0,0 +1,241 @@
1
+ 'use client'
2
+
3
+ import { ChevronRight } from 'lucide-react'
4
+ import Link from 'next/link'
5
+ import { usePathname } from 'next/navigation'
6
+ import { ComponentType, ReactNode } from 'react'
7
+ import {
8
+ Collapsible,
9
+ CollapsibleContent,
10
+ CollapsibleTrigger
11
+ } from '~/components/ui/collapsible'
12
+ import {
13
+ DropdownMenu,
14
+ DropdownMenuContent,
15
+ DropdownMenuItem,
16
+ DropdownMenuLabel,
17
+ DropdownMenuSeparator,
18
+ DropdownMenuTrigger
19
+ } from '~/components/ui/dropdown-menu'
20
+ import {
21
+ Sidebar,
22
+ SidebarContent,
23
+ SidebarGroup,
24
+ SidebarGroupContent,
25
+ SidebarGroupLabel,
26
+ SidebarHeader,
27
+ SidebarMenu,
28
+ SidebarMenuButton,
29
+ SidebarMenuItem,
30
+ SidebarMenuSub,
31
+ SidebarMenuSubButton,
32
+ SidebarMenuSubItem,
33
+ SidebarRail,
34
+ useSidebar
35
+ } from '~/components/ui/sidebar'
36
+
37
+ type NavigationSidebarProps = {
38
+ header: ReactNode
39
+ groups: SidebarNavGroup[]
40
+ }
41
+
42
+ export type SidebarNavIcon = ComponentType<{ className?: string }>
43
+
44
+ export type SidebarNavSubItem = {
45
+ title: string
46
+ url: string
47
+ exact?: boolean
48
+ }
49
+
50
+ export type SidebarNavItem = {
51
+ title: string
52
+ url: string
53
+ icon?: SidebarNavIcon
54
+ exact?: boolean
55
+ items?: SidebarNavSubItem[]
56
+ }
57
+
58
+ export type SidebarNavGroup = {
59
+ label: string
60
+ items: SidebarNavItem[]
61
+ /** When true, the group is only shown to superadmins. */
62
+ superadminOnly?: boolean
63
+ }
64
+
65
+ export function NavigationSidebar({ header, groups }: NavigationSidebarProps) {
66
+ const pathname = usePathname()
67
+ const { state, isMobile, setOpenMobile } = useSidebar()
68
+ const isCollapsed = state === 'collapsed' && !isMobile
69
+
70
+ const handleLinkClick = () => {
71
+ if (isMobile) {
72
+ setOpenMobile(false)
73
+ }
74
+ }
75
+
76
+ const allItems = groups.flatMap(g => g.items)
77
+
78
+ const isItemActive = (item: { url: string; exact?: boolean }) => {
79
+ if (item.exact) {
80
+ return pathname === item.url
81
+ }
82
+
83
+ if (pathname !== item.url && !pathname.startsWith(`${item.url}/`)) {
84
+ return false
85
+ }
86
+
87
+ // Yield to a more specific sibling that also matches
88
+ const moreSpecificExists = allItems.some(
89
+ other =>
90
+ other.url !== item.url &&
91
+ other.url.length > item.url.length &&
92
+ other.url.startsWith(item.url) &&
93
+ (pathname === other.url || pathname.startsWith(`${other.url}/`))
94
+ )
95
+
96
+ return !moreSpecificExists
97
+ }
98
+
99
+ const isSubItemActive = (
100
+ item: SidebarNavSubItem,
101
+ siblings: SidebarNavSubItem[]
102
+ ) => {
103
+ if (!isItemActive(item)) {
104
+ return false
105
+ }
106
+
107
+ return !siblings.some(
108
+ sibling =>
109
+ sibling.url !== item.url &&
110
+ isItemActive(sibling) &&
111
+ sibling.url.length > item.url.length
112
+ )
113
+ }
114
+
115
+ const isGroupActive = (item: SidebarNavItem) => {
116
+ if (isItemActive(item)) {
117
+ return true
118
+ }
119
+
120
+ return item.items?.some(subItem => isItemActive(subItem)) ?? false
121
+ }
122
+
123
+ return (
124
+ <Sidebar collapsible="icon" variant="sidebar">
125
+ <SidebarHeader>{header}</SidebarHeader>
126
+ <SidebarContent>
127
+ {groups.map(group => (
128
+ <SidebarGroup key={group.label}>
129
+ <SidebarGroupLabel>{group.label}</SidebarGroupLabel>
130
+ <SidebarGroupContent>
131
+ <SidebarMenu>
132
+ {group.items.map(item =>
133
+ item.items ? (
134
+ isCollapsed ? (
135
+ <SidebarMenuItem key={item.title}>
136
+ <DropdownMenu>
137
+ <DropdownMenuTrigger asChild>
138
+ <SidebarMenuButton
139
+ tooltip={item.title}
140
+ isActive={isGroupActive(item)}
141
+ >
142
+ {item.icon ? <item.icon /> : null}
143
+ <span>{item.title}</span>
144
+ <ChevronRight className="ml-auto transition-transform duration-200" />
145
+ </SidebarMenuButton>
146
+ </DropdownMenuTrigger>
147
+ <DropdownMenuContent
148
+ side="right"
149
+ align="start"
150
+ sideOffset={4}
151
+ >
152
+ <DropdownMenuLabel>{item.title}</DropdownMenuLabel>
153
+ <DropdownMenuSeparator />
154
+ {item.items.map(subItem => (
155
+ <DropdownMenuItem
156
+ key={subItem.url}
157
+ asChild
158
+ className={
159
+ isSubItemActive(subItem, item.items!)
160
+ ? 'bg-accent font-medium'
161
+ : ''
162
+ }
163
+ >
164
+ <Link
165
+ href={subItem.url}
166
+ onClick={handleLinkClick}
167
+ >
168
+ {subItem.title}
169
+ </Link>
170
+ </DropdownMenuItem>
171
+ ))}
172
+ </DropdownMenuContent>
173
+ </DropdownMenu>
174
+ </SidebarMenuItem>
175
+ ) : (
176
+ <Collapsible
177
+ key={item.title}
178
+ asChild
179
+ defaultOpen={isGroupActive(item)}
180
+ className="group/collapsible"
181
+ >
182
+ <SidebarMenuItem>
183
+ <CollapsibleTrigger asChild>
184
+ <SidebarMenuButton
185
+ tooltip={item.title}
186
+ isActive={isGroupActive(item)}
187
+ >
188
+ {item.icon ? <item.icon /> : null}
189
+ <span>{item.title}</span>
190
+ <ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
191
+ </SidebarMenuButton>
192
+ </CollapsibleTrigger>
193
+ <CollapsibleContent>
194
+ <SidebarMenuSub>
195
+ {item.items.map(subItem => (
196
+ <SidebarMenuSubItem key={subItem.url}>
197
+ <SidebarMenuSubButton
198
+ asChild
199
+ isActive={isSubItemActive(
200
+ subItem,
201
+ item.items!
202
+ )}
203
+ >
204
+ <Link
205
+ href={subItem.url}
206
+ onClick={handleLinkClick}
207
+ >
208
+ <span>{subItem.title}</span>
209
+ </Link>
210
+ </SidebarMenuSubButton>
211
+ </SidebarMenuSubItem>
212
+ ))}
213
+ </SidebarMenuSub>
214
+ </CollapsibleContent>
215
+ </SidebarMenuItem>
216
+ </Collapsible>
217
+ )
218
+ ) : (
219
+ <SidebarMenuItem key={item.title}>
220
+ <SidebarMenuButton
221
+ asChild
222
+ tooltip={item.title}
223
+ isActive={isItemActive(item)}
224
+ >
225
+ <Link href={item.url} onClick={handleLinkClick}>
226
+ {item.icon ? <item.icon /> : null}
227
+ <span>{item.title}</span>
228
+ </Link>
229
+ </SidebarMenuButton>
230
+ </SidebarMenuItem>
231
+ )
232
+ )}
233
+ </SidebarMenu>
234
+ </SidebarGroupContent>
235
+ </SidebarGroup>
236
+ ))}
237
+ </SidebarContent>
238
+ <SidebarRail />
239
+ </Sidebar>
240
+ )
241
+ }
@@ -0,0 +1,32 @@
1
+ import { FC, ReactNode } from 'react'
2
+
3
+ export const PageHeading = ({
4
+ title,
5
+ description,
6
+ icon: Icon,
7
+ children
8
+ }: {
9
+ title: string
10
+ description: string
11
+ icon?: FC<{ className?: string }>
12
+ children?: ReactNode
13
+ }) => {
14
+ return (
15
+ <div className="flex items-center justify-between gap-3">
16
+ <div className="flex items-center gap-3">
17
+ {Icon && (
18
+ <div className="bg-primary/10 text-primary flex h-10 w-10 shrink-0 items-center justify-center rounded-lg">
19
+ <Icon className="h-5 w-5" />
20
+ </div>
21
+ )}
22
+ <div>
23
+ <h1 className="text-2xl font-bold tracking-tight">{title}</h1>
24
+ <p className="text-muted-foreground">{description}</p>
25
+ </div>
26
+ </div>
27
+ {children ? (
28
+ <div className="flex items-center gap-2">{children}</div>
29
+ ) : null}
30
+ </div>
31
+ )
32
+ }
@@ -0,0 +1,12 @@
1
+ 'use client'
2
+
3
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
4
+ import { FC, ReactNode } from 'react'
5
+
6
+ export const ReactQueryProvider: FC<{
7
+ children?: ReactNode
8
+ }> = ({ children }) => {
9
+ const client = new QueryClient({})
10
+
11
+ return <QueryClientProvider client={client}>{children}</QueryClientProvider>
12
+ }
@@ -0,0 +1,40 @@
1
+ 'use client'
2
+
3
+ import { FC, useEffect, useState } from 'react'
4
+
5
+ export const ScreenSize: FC = () => {
6
+ const [dimensions, setDimensions] = useState({ width: 0, height: 0 })
7
+
8
+ useEffect(() => {
9
+ function updateDimensions() {
10
+ setDimensions({
11
+ width: window.innerWidth,
12
+ height: window.innerHeight
13
+ })
14
+ }
15
+
16
+ updateDimensions()
17
+ window.addEventListener('resize', updateDimensions)
18
+
19
+ return () => {
20
+ window.removeEventListener('resize', updateDimensions)
21
+ }
22
+ }, [])
23
+
24
+ const { width, height } = dimensions
25
+
26
+ return (
27
+ <div className="bg-foreground text-background fixed right-4 bottom-4 flex items-center gap-2 rounded-full p-2 text-xs font-medium">
28
+ <span>
29
+ {width.toLocaleString()} x {height.toLocaleString()}
30
+ </span>
31
+ <div className="bg-muted-foreground h-4 w-px" />
32
+ <span className="sm:hidden">XS</span>
33
+ <span className="hidden sm:inline md:hidden">SM</span>
34
+ <span className="hidden md:inline lg:hidden">MD</span>
35
+ <span className="hidden lg:inline xl:hidden">LG</span>
36
+ <span className="hidden xl:inline 2xl:hidden">XL</span>
37
+ <span className="hidden 2xl:inline">2XL</span>
38
+ </div>
39
+ )
40
+ }
@@ -0,0 +1,23 @@
1
+ import Link from 'next/link'
2
+ import { siteConfig } from '~/lib/siteConfig'
3
+
4
+ export const SiteFooter = () => {
5
+ return (
6
+ <footer className="border-t">
7
+ <div className="text-muted-foreground container flex flex-col items-center justify-between gap-4 py-6 text-sm sm:flex-row">
8
+ <p>
9
+ &copy; {new Date().getUTCFullYear()} {siteConfig.name}. All rights
10
+ reserved.
11
+ </p>
12
+ <div className="flex gap-4">
13
+ <Link href="/terms" className="hover:text-foreground">
14
+ Terms
15
+ </Link>
16
+ <Link href="/privacy" className="hover:text-foreground">
17
+ Privacy
18
+ </Link>
19
+ </div>
20
+ </div>
21
+ </footer>
22
+ )
23
+ }
@@ -0,0 +1,67 @@
1
+ 'use client'
2
+
3
+ import { ThemeProvider as NextThemesProvider, useTheme } from 'next-themes'
4
+ import { FC, useEffect } from 'react'
5
+
6
+ function isTypingTarget(target: EventTarget | null) {
7
+ if (!(target instanceof HTMLElement)) {
8
+ return false
9
+ }
10
+
11
+ return (
12
+ target.isContentEditable ||
13
+ target.tagName === 'INPUT' ||
14
+ target.tagName === 'TEXTAREA' ||
15
+ target.tagName === 'SELECT'
16
+ )
17
+ }
18
+
19
+ function ThemeHotkey() {
20
+ const { resolvedTheme, setTheme } = useTheme()
21
+
22
+ useEffect(() => {
23
+ function onKeyDown(event: KeyboardEvent) {
24
+ if (event.defaultPrevented || event.repeat) {
25
+ return
26
+ }
27
+
28
+ if (event.metaKey || event.ctrlKey || event.altKey) {
29
+ return
30
+ }
31
+
32
+ if (event.key.toLowerCase() !== 'd') {
33
+ return
34
+ }
35
+
36
+ if (isTypingTarget(event.target)) {
37
+ return
38
+ }
39
+
40
+ setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')
41
+ }
42
+
43
+ window.addEventListener('keydown', onKeyDown)
44
+
45
+ return () => {
46
+ window.removeEventListener('keydown', onKeyDown)
47
+ }
48
+ }, [resolvedTheme, setTheme])
49
+
50
+ return null
51
+ }
52
+
53
+ export const ThemeProvider: FC<{
54
+ children: React.ReactNode
55
+ }> = ({ children }) => {
56
+ return (
57
+ <NextThemesProvider
58
+ attribute="class"
59
+ defaultTheme="system"
60
+ enableSystem
61
+ disableTransitionOnChange
62
+ >
63
+ <ThemeHotkey />
64
+ {children}
65
+ </NextThemesProvider>
66
+ )
67
+ }
@@ -0,0 +1,22 @@
1
+ 'use client'
2
+
3
+ import { Moon, Sun } from 'lucide-react'
4
+ import { useTheme } from 'next-themes'
5
+ import { FC } from 'react'
6
+ import { Button } from './ui/button'
7
+
8
+ export const ThemeToggle: FC = () => {
9
+ const { theme, setTheme } = useTheme()
10
+
11
+ return (
12
+ <Button
13
+ variant="ghost"
14
+ size="icon"
15
+ aria-label="Toggle theme"
16
+ onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
17
+ >
18
+ <Sun className="hidden h-5 w-5 dark:inline" />
19
+ <Moon className="inline h-5 w-5 dark:hidden" />
20
+ </Button>
21
+ )
22
+ }
@@ -0,0 +1,112 @@
1
+ 'use client'
2
+
3
+ import { Avatar as AvatarPrimitive } from 'radix-ui'
4
+ import * as React from 'react'
5
+
6
+ import { cn } from '~/lib/cn'
7
+
8
+ function Avatar({
9
+ className,
10
+ size = 'default',
11
+ ...props
12
+ }: React.ComponentProps<typeof AvatarPrimitive.Root> & {
13
+ size?: 'default' | 'sm' | 'lg'
14
+ }) {
15
+ return (
16
+ <AvatarPrimitive.Root
17
+ data-slot="avatar"
18
+ data-size={size}
19
+ className={cn(
20
+ 'group/avatar after:border-border relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten',
21
+ className
22
+ )}
23
+ {...props}
24
+ />
25
+ )
26
+ }
27
+
28
+ function AvatarImage({
29
+ className,
30
+ ...props
31
+ }: React.ComponentProps<typeof AvatarPrimitive.Image>) {
32
+ return (
33
+ <AvatarPrimitive.Image
34
+ data-slot="avatar-image"
35
+ className={cn(
36
+ 'aspect-square size-full rounded-full object-cover',
37
+ className
38
+ )}
39
+ {...props}
40
+ />
41
+ )
42
+ }
43
+
44
+ function AvatarFallback({
45
+ className,
46
+ ...props
47
+ }: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
48
+ return (
49
+ <AvatarPrimitive.Fallback
50
+ data-slot="avatar-fallback"
51
+ className={cn(
52
+ 'bg-muted text-muted-foreground flex size-full items-center justify-center rounded-full text-sm group-data-[size=sm]/avatar:text-xs',
53
+ className
54
+ )}
55
+ {...props}
56
+ />
57
+ )
58
+ }
59
+
60
+ function AvatarBadge({ className, ...props }: React.ComponentProps<'span'>) {
61
+ return (
62
+ <span
63
+ data-slot="avatar-badge"
64
+ className={cn(
65
+ 'bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-blend-color ring-2 select-none',
66
+ 'group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden',
67
+ 'group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2',
68
+ 'group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2',
69
+ className
70
+ )}
71
+ {...props}
72
+ />
73
+ )
74
+ }
75
+
76
+ function AvatarGroup({ className, ...props }: React.ComponentProps<'div'>) {
77
+ return (
78
+ <div
79
+ data-slot="avatar-group"
80
+ className={cn(
81
+ 'group/avatar-group *:data-[slot=avatar]:ring-background flex -space-x-2 *:data-[slot=avatar]:ring-2',
82
+ className
83
+ )}
84
+ {...props}
85
+ />
86
+ )
87
+ }
88
+
89
+ function AvatarGroupCount({
90
+ className,
91
+ ...props
92
+ }: React.ComponentProps<'div'>) {
93
+ return (
94
+ <div
95
+ data-slot="avatar-group-count"
96
+ className={cn(
97
+ 'bg-muted text-muted-foreground ring-background relative flex size-8 shrink-0 items-center justify-center rounded-full text-sm ring-2 group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3',
98
+ className
99
+ )}
100
+ {...props}
101
+ />
102
+ )
103
+ }
104
+
105
+ export {
106
+ Avatar,
107
+ AvatarBadge,
108
+ AvatarFallback,
109
+ AvatarGroup,
110
+ AvatarGroupCount,
111
+ AvatarImage
112
+ }
@@ -0,0 +1,49 @@
1
+ import { cva, type VariantProps } from 'class-variance-authority'
2
+ import { Slot } from 'radix-ui'
3
+ import * as React from 'react'
4
+
5
+ import { cn } from '~/lib/cn'
6
+
7
+ const badgeVariants = cva(
8
+ 'group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!',
9
+ {
10
+ variants: {
11
+ variant: {
12
+ default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
13
+ secondary:
14
+ 'bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80',
15
+ destructive:
16
+ 'bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20',
17
+ outline:
18
+ 'border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground',
19
+ ghost:
20
+ 'hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50',
21
+ link: 'text-primary underline-offset-4 hover:underline'
22
+ }
23
+ },
24
+ defaultVariants: {
25
+ variant: 'default'
26
+ }
27
+ }
28
+ )
29
+
30
+ function Badge({
31
+ className,
32
+ variant = 'default',
33
+ asChild = false,
34
+ ...props
35
+ }: React.ComponentProps<'span'> &
36
+ VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
37
+ const Comp = asChild ? Slot.Root : 'span'
38
+
39
+ return (
40
+ <Comp
41
+ data-slot="badge"
42
+ data-variant={variant}
43
+ className={cn(badgeVariants({ variant }), className)}
44
+ {...props}
45
+ />
46
+ )
47
+ }
48
+
49
+ export { Badge, badgeVariants }
@@ -0,0 +1,67 @@
1
+ import { cva, type VariantProps } from 'class-variance-authority'
2
+ import { Slot } from 'radix-ui'
3
+ import * as React from 'react'
4
+
5
+ import { cn } from '~/lib/cn'
6
+
7
+ const buttonVariants = cva(
8
+ "group/button inline-flex shrink-0 items-center justify-center rounded-md border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
9
+ {
10
+ variants: {
11
+ variant: {
12
+ default: 'bg-primary text-primary-foreground hover:bg-primary/80',
13
+ outline:
14
+ 'border-border bg-background shadow-xs hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
15
+ secondary:
16
+ 'bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
17
+ ghost:
18
+ 'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50',
19
+ destructive:
20
+ 'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40',
21
+ link: 'text-primary underline-offset-4 hover:underline'
22
+ },
23
+ size: {
24
+ default:
25
+ 'h-9 gap-1.5 px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
26
+ xs: "h-6 gap-1 rounded-[min(var(--radius-md),8px)] px-2 text-xs in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
27
+ sm: 'h-8 gap-1 rounded-[min(var(--radius-md),10px)] px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5',
28
+ lg: 'h-10 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
29
+ icon: 'size-9',
30
+ 'icon-xs':
31
+ "size-6 rounded-[min(var(--radius-md),8px)] in-data-[slot=button-group]:rounded-md [&_svg:not([class*='size-'])]:size-3",
32
+ 'icon-sm':
33
+ 'size-8 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-md',
34
+ 'icon-lg': 'size-10'
35
+ }
36
+ },
37
+ defaultVariants: {
38
+ variant: 'default',
39
+ size: 'default'
40
+ }
41
+ }
42
+ )
43
+
44
+ function Button({
45
+ className,
46
+ variant = 'default',
47
+ size = 'default',
48
+ asChild = false,
49
+ ...props
50
+ }: React.ComponentProps<'button'> &
51
+ VariantProps<typeof buttonVariants> & {
52
+ asChild?: boolean
53
+ }) {
54
+ const Comp = asChild ? Slot.Root : 'button'
55
+
56
+ return (
57
+ <Comp
58
+ data-slot="button"
59
+ data-variant={variant}
60
+ data-size={size}
61
+ className={cn(buttonVariants({ variant, size, className }))}
62
+ {...props}
63
+ />
64
+ )
65
+ }
66
+
67
+ export { Button, buttonVariants }