├── .env.example ├── .eslintrc ├── .gitignore ├── .prettierignore ├── .prettierrc ├── LICENSE ├── README.md ├── app-screenshot.png ├── app.config.ts ├── app ├── actions │ ├── auth.ts │ └── post.ts ├── api.ts ├── auth │ └── index.ts ├── client.tsx ├── components │ ├── default-catch-boundary.tsx │ ├── not-found.tsx │ └── ui │ │ ├── avatar.tsx │ │ ├── button.tsx │ │ ├── card.tsx │ │ ├── form.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── sonner.tsx │ │ └── textarea.tsx ├── db │ ├── client.ts │ ├── schema │ │ ├── auth.ts │ │ ├── index.ts │ │ └── post.ts │ └── seed.ts ├── env.ts ├── hooks │ ├── post.ts │ └── useMutation.ts ├── images │ └── og.png ├── lib │ ├── cache.ts │ ├── seo.ts │ └── utils.ts ├── routeTree.gen.ts ├── router.tsx ├── routes │ ├── __root.tsx │ ├── api │ │ └── auth │ │ │ └── callback.github.ts │ └── index.tsx ├── ssr.tsx ├── styles │ └── globals.css └── trpc │ └── init.ts ├── components.json ├── drizzle.config.ts ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── public ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── apple-touch-icon.png ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon.ico └── site.webmanifest ├── tailwind.config.js └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | # Since the ".env" file is gitignored, you can use the ".env.example" file to 2 | # build a new ".env" file when you clone the repo. Keep this file up-to-date 3 | # when you add new variables to `.env`. 4 | 5 | # This file will be committed to version control, so make sure not to have any 6 | # secrets in it. If you are cloning this repo, create a copy of this file named 7 | # ".env" and populate it with your secrets. 8 | 9 | # When adding additional environment variables, the schema in "/app/env.ts" 10 | # should be updated accordingly. 11 | 12 | # Drizzle 13 | DATABASE_URL="file:./db.sqlite" 14 | 15 | #OAuth 16 | GITHUB_CLIENT_ID="" 17 | GITHUB_CLIENT_SECRET="" -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["react-app"], 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": ["react-hooks"] 5 | } 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | /test-results/ 11 | /playwright-report/ 12 | /blob-report/ 13 | /playwright/.cache/ 14 | 15 | # database 16 | /prisma/db.sqlite 17 | /prisma/db.sqlite-journal 18 | db.sqlite 19 | 20 | # next.js 21 | /.next/ 22 | /out/ 23 | next-env.d.ts 24 | 25 | # production 26 | /build 27 | /api/ 28 | /server/build 29 | /public/build 30 | .vinxi 31 | 32 | # misc 33 | .DS_Store 34 | *.pem 35 | 36 | # debug 37 | npm-debug.log* 38 | yarn-debug.log* 39 | yarn-error.log* 40 | .pnpm-debug.log* 41 | 42 | # local env files 43 | # do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables 44 | .env 45 | .env*.local 46 | count.txt 47 | .output 48 | .cache 49 | 50 | # vercel 51 | .vercel 52 | 53 | # typescript 54 | *.tsbuildinfo 55 | 56 | # idea files 57 | .idea 58 | 59 | # Sentry Config File 60 | .env.sentry-build-plugin -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | **/build 2 | **/public 3 | pnpm-lock.yaml 4 | routeTree.gen.ts -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": false, 4 | "trailingComma": "all" 5 | } 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Ahmed Ali 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TSS App 🏝️ 2 | 3 | Tanstack Start simple starter project. 4 | 5 | 6 | ![App Screenshot](./app-screenshot.png) 7 | 8 | 9 | ## Tech Stack 10 | 11 | - [Tanstack Start](https://tanstack.com/router/latest/docs/framework/react/guide/tanstack-start#tanstack-start) 12 | - [Tailwind CSS](https://tailwindcss.com) 13 | - [shadcn/ui](https://ui.shadcn.com/) 14 | - [tRPC](https://trpc.io) 15 | - [Drizzle](https://orm.drizzle.team) 16 | - [Lucia Auth - Adapters Removed RFC](https://github.com/lucia-auth/lucia/issues/1639) 17 | 18 | 19 | ## Acknowledgements 20 | 21 | - [Julius's tss app with tRPC implementation.](https://github.com/juliusmarminge/tss) 22 | - [tanstack.com for the useMutation wrapper to help with redirects.](https://github.com/TanStack/tanstack.com/blob/b7e54b4fdec169b86dc45b99eb74baa44df998f5/app/hooks/useMutation.ts) 23 | - [dotnize beat me to it and ceated a starter project. I liked their API routes structure so the API routes for auth is inspired by theirs.](https://github.com/dotnize/tanstarter) 24 | - [ethanniser drizzle config to handle local, remote, local-replica for libsql.](https://github.com/ethanniser/beth-b2b-saas/blob/main/src/db/primary/index.ts) 25 | - [create-t3-app I am sure I copied some stuff from here as well](https://github.com/t3-oss/create-t3-app) 26 | - [UI inspired by again the legend Julius's create-t3-turbo](https://github.com/t3-oss/create-t3-turbo) 27 | - [shadcn-ui/taxonomy](https://github.com/shadcn-ui/taxonomy/tree/651f984e52edd65d40ccd55e299c1baeea3ff017) 28 | 29 | ## Getting Started 30 | 31 | Clone project 32 | 33 | ```bash 34 | git clone git@github.com:ally-ahmed/tss-app.git 35 | cd tss-app 36 | ``` 37 | 38 | Install depndencies 39 | 40 | ```bash 41 | pnpm install 42 | ``` 43 | 44 | Create db and tables 45 | ```bash 46 | pnpm db:push 47 | ``` 48 | 49 | Run app 50 | ```bash 51 | pnpm dev 52 | ``` 53 | 54 | ## Environment Variables 55 | 56 | To run this project, you will need to add the following environment variables to your .env file 57 | 58 | 59 | `GITHUB_CLIENT_ID=""` 60 | 61 | `GITHUB_CLIENT_SECRET=""` 62 | 63 | 64 | 65 | ## TODO 66 | - Cache auth validation 67 | - Custom fonts 68 | 69 | - Investigate if there is a way to return a redirect from the server function without it being an error and 70 | it automatically does the redirect in the client. Tanner suggest `userServerFn` should work and it does for only URLs within the app but not external URL. So the `useServerFn` works for logout but not for Github login. And even with the logout it still goes throw the error channel 71 | 72 | - Maybe look at ESLint, Prettier or even jump ship and use biomejs 73 | 74 | 75 | ## challenges/skill issue 76 | 77 | - CSS flicker on hard refresh (ctrl/command + shift + r) so I had to import the css like this then it stopped. 78 | ```ts 79 | import '@/styles/globals.css' 80 | import appCss from '@/styles/globals.css?url' 81 | ``` 82 | and also a link to Route options 83 | ```ts 84 | links: () => [{ rel: 'stylesheet', href: appCss }], 85 | ``` 86 | - useMutation hook considers a server function that return a redirect as an error so you get the result of the redirect in the error channel. 87 | ``` 88 | 89 | -------------------------------------------------------------------------------- /app-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ally-ahmed/tss-app/20822c1fde8658a9d7398798fecd1fb0aa8dd98d/app-screenshot.png -------------------------------------------------------------------------------- /app.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from '@tanstack/start/config' 2 | 3 | import tsConfigPaths from 'vite-tsconfig-paths' 4 | 5 | export default defineConfig({ 6 | vite: { 7 | plugins: () => [ 8 | tsConfigPaths({ 9 | projects: ['./tsconfig.json'], 10 | }), 11 | ], 12 | }, 13 | }) 14 | -------------------------------------------------------------------------------- /app/actions/auth.ts: -------------------------------------------------------------------------------- 1 | import { auth, github, lucia } from '@/auth' 2 | import { useStorage } from '@/lib/cache' 3 | import { redirect } from '@tanstack/react-router' 4 | import { createServerFn } from '@tanstack/start' 5 | import { TRPCError } from '@trpc/server' 6 | import { generateState } from 'arctic' 7 | import { parseCookies, setCookie, setHeader } from 'vinxi/http' 8 | 9 | export const authLoader = createServerFn('GET', async () => { 10 | console.log('########### authLoader ###########') 11 | await useStorage().removeItem('cache:nitro:functions:auth:.json') 12 | return await auth() 13 | }) 14 | 15 | export const logout = createServerFn('POST', async (_, { request }) => { 16 | console.log('logout') 17 | const sessionId = parseCookies()[lucia.sessionCookieName] 18 | if (!sessionId) { 19 | throw new TRPCError({ code: 'UNAUTHORIZED' }) 20 | } 21 | await lucia.invalidateSession(sessionId) 22 | const sessionCookie = lucia.createBlankSessionCookie() 23 | setCookie(sessionCookie.name, sessionCookie.value, { 24 | ...sessionCookie.npmCookieOptions(), 25 | }) 26 | return redirect({ 27 | to: '/', 28 | }) 29 | }) 30 | export const logInWithGithub = createServerFn('POST', async () => { 31 | setHeader('Access-Control-Allow-Origin', 'http://127.0.0.1:3000') 32 | const state = generateState() 33 | const url = await github.createAuthorizationURL(state, { 34 | scopes: ['user:email'], 35 | }) 36 | setCookie('github_oauth_state', state, { 37 | path: '/', 38 | secure: process.env.NODE_ENV === 'production', 39 | httpOnly: true, 40 | maxAge: 60 * 10, 41 | sameSite: 'lax', 42 | }) 43 | setHeader('Location', url.toString()) 44 | setHeader('Access-Control-Allow-Origin', 'http://127.0.0.1:3000') 45 | return { 46 | to: url.toString(), 47 | } 48 | }) 49 | -------------------------------------------------------------------------------- /app/actions/post.ts: -------------------------------------------------------------------------------- 1 | import { CreatePostSchema, Post } from '@/db/schema/post' 2 | import { protectedProcedure, publicProcedure } from '@/trpc/init' 3 | import { createServerFn } from '@tanstack/start' 4 | import { TRPCError } from '@trpc/server' 5 | import { and, eq } from 'drizzle-orm' 6 | import { z } from 'zod' 7 | 8 | export const byId = createServerFn( 9 | 'GET', 10 | publicProcedure.input(z.string()).query(async ({ input: postId, ctx }) => { 11 | console.log(`Fetching post with id ${postId}...`) 12 | 13 | const post = await ctx.db.query.Post.findFirst({ 14 | where: (fields, { eq }) => eq(fields.id, postId), 15 | }) 16 | if (!post) throw new TRPCError({ code: 'NOT_FOUND' }) 17 | return post 18 | }), 19 | ) 20 | 21 | export const list = createServerFn( 22 | 'GET', 23 | publicProcedure.query(async ({ ctx }) => { 24 | console.log('Fetching posts...') 25 | const posts = await ctx.db.query.Post.findMany({ 26 | orderBy: (fields, { desc }) => desc(fields.createdAt), 27 | with: { 28 | user: true, 29 | }, 30 | }) 31 | 32 | return posts 33 | }), 34 | ) 35 | 36 | export const remove = createServerFn( 37 | 'POST', 38 | protectedProcedure 39 | .input(z.string()) 40 | .mutation(async ({ input: postId, ctx }) => { 41 | console.log(`Deleting post with id ${postId}...`) 42 | const post = await ctx.db.query.Post.findFirst({ 43 | where: (fields, { eq }) => 44 | and(eq(fields.id, postId), eq(fields.userId, ctx.auth.user.id)), 45 | }) 46 | if (!post) throw new TRPCError({ code: 'NOT_FOUND' }) 47 | return await ctx.db 48 | .delete(Post) 49 | .where(and(eq(Post.id, postId), eq(Post.userId, ctx.auth.user.id))) 50 | .returning({ postId: Post.id }) 51 | }), 52 | ) 53 | 54 | export const create = createServerFn( 55 | 'POST', 56 | protectedProcedure 57 | .input(CreatePostSchema) 58 | .mutation(async ({ input, ctx }) => { 59 | console.log(`Creating post with title ${input.title}...`) 60 | const post = await ctx.db.insert(Post).values({ 61 | title: input.title, 62 | body: input.body, 63 | userId: ctx.auth.user.id, 64 | }) 65 | 66 | return Number(post.lastInsertRowid) 67 | }), 68 | ) 69 | -------------------------------------------------------------------------------- /app/api.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createStartAPIHandler, 3 | defaultAPIFileRouteHandler, 4 | } from '@tanstack/start/api' 5 | 6 | export default createStartAPIHandler(defaultAPIFileRouteHandler) 7 | -------------------------------------------------------------------------------- /app/auth/index.ts: -------------------------------------------------------------------------------- 1 | import { db } from '@/db/client' 2 | import type { SessionType, UserType } from '@/db/schema/auth' 3 | import { Session, User } from '@/db/schema/auth' 4 | import { GitHub } from 'arctic' 5 | import { eq } from 'drizzle-orm' 6 | import { generateSessionId, Lucia } from 'lucia' 7 | 8 | import { env } from '@/env' 9 | import type { DatabaseAdapter, SessionAndUser } from 'lucia' 10 | import { parseCookies, setCookie } from 'vinxi/http' 11 | import { cachedFunction } from '@/lib/cache' 12 | 13 | const adapter: DatabaseAdapter = { 14 | getSessionAndUser: async ( 15 | sessionId: string, 16 | ): Promise> => { 17 | const result = 18 | (await db 19 | .select({ 20 | user: User, 21 | session: Session, 22 | }) 23 | .from(Session) 24 | .innerJoin(User, eq(Session.userId, User.id)) 25 | .where(eq(Session.id, sessionId)) 26 | .get()) ?? null 27 | if (result === null) { 28 | return { session: null, user: null } 29 | } 30 | return result 31 | }, 32 | deleteSession: async (sessionId: string): Promise => { 33 | db.delete(Session).where(eq(Session.id, sessionId)).run() 34 | }, 35 | updateSessionExpiration: async ( 36 | sessionId: string, 37 | expiresAt: Date, 38 | ): Promise => { 39 | db.update(Session) 40 | .set({ 41 | expiresAt, 42 | }) 43 | .where(eq(Session.id, sessionId)) 44 | .run() 45 | }, 46 | } 47 | 48 | export const lucia = new Lucia(adapter, { 49 | secureCookies: !import.meta.env.DEV, 50 | }) 51 | 52 | export const github = new GitHub( 53 | env.GITHUB_CLIENT_ID, 54 | env.GITHUB_CLIENT_SECRET, 55 | {}, 56 | ) 57 | 58 | export function createSession(userId: string): SessionType { 59 | const session: SessionType = { 60 | id: generateSessionId(), 61 | userId: userId, 62 | expiresAt: lucia.getNewSessionExpiration(), 63 | loginAt: new Date(), 64 | } 65 | db.insert(Session).values(session).run() 66 | return session 67 | } 68 | 69 | export const auth = cachedFunction(async () => { 70 | const sessionId = parseCookies()[lucia.sessionCookieName] 71 | console.log(`########### calling auth ${sessionId} ###########`) 72 | if (!sessionId) { 73 | return { 74 | user: null, 75 | session: null, 76 | } 77 | } 78 | const result = await lucia.validateSession(sessionId) 79 | if ( 80 | result.session !== null && 81 | Date.now() >= result.session.expiresAt.getTime() 82 | ) { 83 | const session = createSession(result.user.id) 84 | const sessionCookie = lucia.createSessionCookie( 85 | result.session.id, 86 | session.expiresAt, 87 | ) 88 | setCookie(sessionCookie.name, sessionCookie.value, { 89 | ...sessionCookie.npmCookieOptions(), 90 | }) 91 | } 92 | if (!result.session) { 93 | const sessionCookie = lucia.createBlankSessionCookie() 94 | setCookie(sessionCookie.name, sessionCookie.value, { 95 | ...sessionCookie.npmCookieOptions(), 96 | }) 97 | } 98 | return { 99 | ...result, 100 | } 101 | }) 102 | // export const authLoader = createServerFn('GET', async (_, { request }) => {}) 103 | export type Auth = Awaited> 104 | -------------------------------------------------------------------------------- /app/client.tsx: -------------------------------------------------------------------------------- 1 | import { StartClient } from '@tanstack/start' 2 | import { hydrateRoot } from 'react-dom/client' 3 | import { createRouter } from './router' 4 | 5 | const router = createRouter() 6 | 7 | hydrateRoot(document.getElementById('root')!, ) 8 | -------------------------------------------------------------------------------- /app/components/default-catch-boundary.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | ErrorComponent, 3 | ErrorComponentProps, 4 | Link, 5 | rootRouteId, 6 | useMatch, 7 | useRouter, 8 | } from '@tanstack/react-router' 9 | 10 | export function DefaultCatchBoundary({ error }: ErrorComponentProps) { 11 | const router = useRouter() 12 | const isRoot = useMatch({ 13 | strict: false, 14 | select: (state) => state.id === rootRouteId, 15 | }) 16 | 17 | console.error(error) 18 | 19 | return ( 20 |
21 | 22 |
23 | 31 | {isRoot ? ( 32 | 36 | Home 37 | 38 | ) : ( 39 | { 43 | e.preventDefault() 44 | window.history.back() 45 | }} 46 | > 47 | Go Back 48 | 49 | )} 50 |
51 |
52 | ) 53 | } 54 | -------------------------------------------------------------------------------- /app/components/not-found.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from '@tanstack/react-router' 2 | 3 | export function NotFound({ children }: { children?: any }) { 4 | return ( 5 |
6 |
7 | {children ||

The page you are looking for does not exist.

} 8 |
9 |

10 | 16 | 20 | Start Over 21 | 22 |

23 |
24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /app/components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AvatarPrimitive from "@radix-ui/react-avatar" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Avatar = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | )) 21 | Avatar.displayName = AvatarPrimitive.Root.displayName 22 | 23 | const AvatarImage = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, ...props }, ref) => ( 27 | 32 | )) 33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName 34 | 35 | const AvatarFallback = React.forwardRef< 36 | React.ElementRef, 37 | React.ComponentPropsWithoutRef 38 | >(({ className, ...props }, ref) => ( 39 | 47 | )) 48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName 49 | 50 | export { Avatar, AvatarImage, AvatarFallback } 51 | -------------------------------------------------------------------------------- /app/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", 16 | outline: 17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", 20 | ghost: "hover:bg-accent hover:text-accent-foreground", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | }, 23 | size: { 24 | default: "h-9 px-4 py-2", 25 | sm: "h-8 rounded-md px-3 text-xs", 26 | lg: "h-10 rounded-md px-8", 27 | icon: "h-9 w-9", 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: "default", 32 | size: "default", 33 | }, 34 | } 35 | ) 36 | 37 | export interface ButtonProps 38 | extends React.ButtonHTMLAttributes, 39 | VariantProps { 40 | asChild?: boolean 41 | } 42 | 43 | const Button = React.forwardRef( 44 | ({ className, variant, size, asChild = false, ...props }, ref) => { 45 | const Comp = asChild ? Slot : "button" 46 | return ( 47 | 52 | ) 53 | } 54 | ) 55 | Button.displayName = "Button" 56 | 57 | export { Button, buttonVariants } 58 | -------------------------------------------------------------------------------- /app/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | import { cn } from '@/lib/utils' 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )) 18 | Card.displayName = 'Card' 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )) 30 | CardHeader.displayName = 'CardHeader' 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLParagraphElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |

41 | )) 42 | CardTitle.displayName = 'CardTitle' 43 | 44 | const CardDescription = React.forwardRef< 45 | HTMLParagraphElement, 46 | React.HTMLAttributes 47 | >(({ className, ...props }, ref) => ( 48 |

53 | )) 54 | CardDescription.displayName = 'CardDescription' 55 | 56 | const CardContent = React.forwardRef< 57 | HTMLDivElement, 58 | React.HTMLAttributes 59 | >(({ className, ...props }, ref) => ( 60 |

61 | )) 62 | CardContent.displayName = 'CardContent' 63 | 64 | const CardFooter = React.forwardRef< 65 | HTMLDivElement, 66 | React.HTMLAttributes 67 | >(({ className, ...props }, ref) => ( 68 |
73 | )) 74 | CardFooter.displayName = 'CardFooter' 75 | 76 | export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } 77 | -------------------------------------------------------------------------------- /app/components/ui/form.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | import { Slot } from "@radix-ui/react-slot" 6 | import { 7 | Controller, 8 | ControllerProps, 9 | FieldPath, 10 | FieldValues, 11 | FormProvider, 12 | useFormContext, 13 | } from "react-hook-form" 14 | 15 | import { cn } from "@/lib/utils" 16 | import { Label } from "@/components/ui/label" 17 | 18 | const Form = FormProvider 19 | 20 | type FormFieldContextValue< 21 | TFieldValues extends FieldValues = FieldValues, 22 | TName extends FieldPath = FieldPath 23 | > = { 24 | name: TName 25 | } 26 | 27 | const FormFieldContext = React.createContext( 28 | {} as FormFieldContextValue 29 | ) 30 | 31 | const FormField = < 32 | TFieldValues extends FieldValues = FieldValues, 33 | TName extends FieldPath = FieldPath 34 | >({ 35 | ...props 36 | }: ControllerProps) => { 37 | return ( 38 | 39 | 40 | 41 | ) 42 | } 43 | 44 | const useFormField = () => { 45 | const fieldContext = React.useContext(FormFieldContext) 46 | const itemContext = React.useContext(FormItemContext) 47 | const { getFieldState, formState } = useFormContext() 48 | 49 | const fieldState = getFieldState(fieldContext.name, formState) 50 | 51 | if (!fieldContext) { 52 | throw new Error("useFormField should be used within ") 53 | } 54 | 55 | const { id } = itemContext 56 | 57 | return { 58 | id, 59 | name: fieldContext.name, 60 | formItemId: `${id}-form-item`, 61 | formDescriptionId: `${id}-form-item-description`, 62 | formMessageId: `${id}-form-item-message`, 63 | ...fieldState, 64 | } 65 | } 66 | 67 | type FormItemContextValue = { 68 | id: string 69 | } 70 | 71 | const FormItemContext = React.createContext( 72 | {} as FormItemContextValue 73 | ) 74 | 75 | const FormItem = React.forwardRef< 76 | HTMLDivElement, 77 | React.HTMLAttributes 78 | >(({ className, ...props }, ref) => { 79 | const id = React.useId() 80 | 81 | return ( 82 | 83 |
84 | 85 | ) 86 | }) 87 | FormItem.displayName = "FormItem" 88 | 89 | const FormLabel = React.forwardRef< 90 | React.ElementRef, 91 | React.ComponentPropsWithoutRef 92 | >(({ className, ...props }, ref) => { 93 | const { error, formItemId } = useFormField() 94 | 95 | return ( 96 |