├── .env.example ├── .env.stage ├── .eslintrc.cjs ├── .gitignore ├── README.md ├── app ├── components │ ├── app-logo.tsx │ ├── memoized-post-list-item.tsx │ ├── post-search.tsx │ ├── post.tsx │ ├── ui │ │ ├── alert.tsx │ │ ├── avatar.tsx │ │ ├── button.tsx │ │ ├── card.tsx │ │ ├── dialog.tsx │ │ ├── dropdown-menu.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── separator.tsx │ │ ├── skeleton.tsx │ │ ├── sonner.tsx │ │ ├── tabs.tsx │ │ ├── textarea.tsx │ │ ├── toast.tsx │ │ ├── toaster.tsx │ │ └── use-toast.ts │ ├── view-comments.tsx │ ├── view-likes.tsx │ └── write-post.tsx ├── entry.client.tsx ├── entry.server.tsx ├── lib │ ├── client-hints.tsx │ ├── database.server.ts │ ├── supabase.server.ts │ ├── supabase.ts │ ├── theme.server.ts │ ├── types.ts │ └── utils.ts ├── root.tsx ├── routes │ ├── _home.gitposts.$postId.tsx │ ├── _home.gitposts.tsx │ ├── _home.profile.$username.$postId.tsx │ ├── _home.profile.$username.tsx │ ├── _home.tsx │ ├── _index.tsx │ ├── login.tsx │ ├── resources.auth.callback.tsx │ ├── resources.auth.confirm-email.tsx │ ├── resources.comment.tsx │ ├── resources.like.tsx │ ├── resources.likes.$postId.tsx │ ├── resources.post.tsx │ ├── resources.theme-toggle.tsx │ └── stateful │ │ ├── auth-form.tsx │ │ ├── infinite-virtual-list.tsx │ │ ├── logout.tsx │ │ ├── oauth-login.tsx │ │ └── use-infinite-posts.ts └── tailwind.css ├── components.json ├── database.types.ts ├── package-lock.json ├── package.json ├── postcss.config.cjs ├── public ├── assets │ ├── images │ │ ├── like-posts.png │ │ ├── search-posts.png │ │ ├── view-profiles.png │ │ └── write-post.png │ └── videos │ │ └── demo.mp4 └── favicon.ico ├── remix.config.js ├── remix.env.d.ts ├── tailwind.config.ts ├── tsconfig.json └── youtube course ├── .env.example ├── .eslintrc.cjs ├── .gitignore ├── README.md ├── app ├── components │ ├── app-logo.tsx │ ├── infinite-virtual-list.tsx │ ├── memoized-post-list-item.tsx │ ├── post-search.tsx │ ├── post.tsx │ ├── show-comment.tsx │ ├── ui │ │ ├── alert.tsx │ │ ├── avatar.tsx │ │ ├── button.tsx │ │ ├── card.tsx │ │ ├── dialog.tsx │ │ ├── input.tsx │ │ ├── separator.tsx │ │ ├── skeleton.tsx │ │ ├── tabs.tsx │ │ ├── textarea.tsx │ │ ├── toast.tsx │ │ ├── toaster.tsx │ │ └── use-toast.ts │ ├── use-infinite-posts.ts │ ├── view-comments.tsx │ ├── view-likes.tsx │ └── write-post.tsx ├── entry.client.tsx ├── entry.server.tsx ├── lib │ ├── database.server.ts │ ├── supabase.server.ts │ ├── supabase.ts │ ├── types.ts │ └── utils.ts ├── root.tsx ├── routes │ ├── _home.gitposts.$postId.tsx │ ├── _home.gitposts.tsx │ ├── _home.profile.$username.$postId.tsx │ ├── _home.profile.$username.tsx │ ├── _home.tsx │ ├── _index.tsx │ ├── login.tsx │ ├── resources.auth.callback.tsx │ ├── resources.comment.tsx │ ├── resources.like.tsx │ └── resources.post.tsx └── tailwind.css ├── components.json ├── database.types.ts ├── package-lock.json ├── package.json ├── postcss.config.js ├── public ├── assets │ └── videos │ │ └── demo.mp4 └── favicon.ico ├── remix.config.js ├── remix.env.d.ts ├── tailwind.config.js └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | DOMAIN_URL=http://localhost:3000 2 | SUPABASE_URL=https://PROJECT_ID.supabase.co 3 | SUPABASE_ANON_KEY=YOUR_SUPABASE_ANON_KEY 4 | -------------------------------------------------------------------------------- /.env.stage: -------------------------------------------------------------------------------- 1 | SUPABASE_URL=https://snaigoiciwagpbhfzyhq.supabase.co 2 | # The table has RLS setup so it should be safe 3 | SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InNuYWlnb2ljaXdhZ3BiaGZ6eWhxIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MDEwMDU2NzUsImV4cCI6MjAxNjU4MTY3NX0.AXhXiJ14eKxgzXQv_N949HKoFWCfmZ6quDuDOODZnMM 4 | DOMAIN_URL=http://localhost:3000 -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('eslint').Linter.Config} */ 2 | module.exports = { 3 | extends: ["@remix-run/eslint-config", "@remix-run/eslint-config/node"], 4 | }; 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /.cache 4 | /build 5 | /public/build 6 | .env 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Gitposter logo 3 |

4 | 5 | # Welcome to Gitposter 6 | 7 | - A social platform app built using Remix and Supabase. 8 | 9 | ## Demo 10 | 11 | 12 | 13 | https://github.com/rajeshdavidbabu/remix-supabase-social/assets/15684795/1fcdaff5-0715-4cfe-9820-d5dfeed270a7 14 | 15 | 16 | 17 | ## Motivation 18 | 19 | x.com is a noisy social-media app and I want to try building an actual social-media platform for coders. 20 | 21 | - Vision: 22 | - Gather feedback from community regarding features. 23 | - Keep it open-source and attract PRs. 24 | - If it scales beyond a certain traffic then look for sponsors. 25 | 26 | ## How do I build this ? 27 | 28 | Checkout the video -> [YTVideo](https://www.youtube.com/watch?v=ocWc_FFc5jE) 29 | 30 | ## Getting Started 31 | 32 | ### Local Development 33 | 34 | I have already prepared a supabase project based on my YT course, and Github OAuth app, which would enable you to login and build features. The details are in `.env.stage`. 35 | 36 | You can copy the `.env.stage` into your `.env` file, and run the remix-app at `localhost:3000` to use all the features. 37 | 38 | If you do the above step right, then your app should be running locally. 39 | 40 | From your terminal: 41 | 42 | ```sh 43 | npm install 44 | npm run dev 45 | ``` 46 | 47 | ## Roadmap 48 | 49 | Features I would like to add: 50 | - Adding dark-mode using Kent's latest client-hints library ✅ 51 | - Adding notifications for "new posts" and when clicked users need to be scrolled to top 52 | - Adding support for uploading images, preferably using S3 53 | 54 | ## Want to participate and contribute ? 55 | 56 | Come chat with us on https://discord.gg/Ye9dsJzNPj 57 | -------------------------------------------------------------------------------- /app/components/memoized-post-list-item.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from "react"; 2 | import type { CombinedPostWithAuthorAndLikes } from "~/lib/types"; 3 | import { formatToTwitterDate } from "~/lib/utils"; 4 | import { Post } from "~/components/post"; 5 | import { ViewComments } from "./view-comments"; 6 | import { ViewLikes } from "./view-likes"; 7 | import { useLocation } from "@remix-run/react"; 8 | 9 | export const MemoizedPostListItem = memo( 10 | ({ 11 | post, 12 | index, 13 | sessionUserId, 14 | }: { 15 | post: CombinedPostWithAuthorAndLikes; 16 | sessionUserId: string; 17 | index: number; 18 | }) => { 19 | const location = useLocation(); 20 | let pathnameWithSearchQuery = ""; 21 | 22 | if (location.search) { 23 | pathnameWithSearchQuery = `${location.pathname}/${post.id}${location.search}`; 24 | } else { 25 | pathnameWithSearchQuery = `${location.pathname}/${post.id}`; 26 | } 27 | 28 | return ( 29 | 39 |
40 |
41 | 46 |
47 |
48 | 52 |
53 |
54 |
55 | ); 56 | } 57 | ); 58 | MemoizedPostListItem.displayName = "MemoizedPostListItem"; 59 | -------------------------------------------------------------------------------- /app/components/post-search.tsx: -------------------------------------------------------------------------------- 1 | import { Form, useSubmit } from "@remix-run/react"; 2 | import { Input } from "./ui/input"; 3 | import { useState, useEffect, useRef } from "react"; 4 | import { Loader2 } from "lucide-react"; 5 | 6 | export function PostSearch({ 7 | searchQuery, 8 | isSearching, 9 | }: { 10 | searchQuery: string | null; 11 | isSearching: boolean; 12 | }) { 13 | const [query, setQuery] = useState(searchQuery || ""); 14 | const submit = useSubmit(); 15 | const timeoutRef = useRef(); 16 | const formRef = useRef(null); 17 | 18 | useEffect(() => { 19 | setQuery(query || ""); 20 | }, [query]); 21 | 22 | useEffect(() => { 23 | // Only cleanup required for the timeout 24 | return () => { 25 | if (timeoutRef.current) { 26 | clearTimeout(timeoutRef.current); 27 | } 28 | }; 29 | }, [timeoutRef]); 30 | 31 | return ( 32 |
33 |

34 | {query ? `Results for "${query}"` : "All posts"} 35 |

36 |
37 | {isSearching && } 38 |
39 | 68 |
69 | ); 70 | } 71 | -------------------------------------------------------------------------------- /app/components/post.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "@remix-run/react"; 2 | import { Card } from "~/components/ui/card"; 3 | import { Skeleton } from "~/components/ui/skeleton"; 4 | import ReactMarkdown from "react-markdown"; 5 | import remarkGfm from "remark-gfm" 6 | import { AppLogo } from "./app-logo"; 7 | import { Avatar, AvatarImage } from "@radix-ui/react-avatar"; 8 | 9 | export type PostProps = { 10 | avatarUrl: string; 11 | name: string; 12 | id: string; 13 | username: string; 14 | title: string; 15 | dateTimeString: string; 16 | userId: string; 17 | children?: React.ReactNode; 18 | }; 19 | 20 | export function PostSkeleton() { 21 | return ( 22 |
23 | 24 |
25 | 26 | 27 |
28 |
29 | ); 30 | } 31 | 32 | export function Post({ 33 | avatarUrl, 34 | name, 35 | username, 36 | title, 37 | dateTimeString, 38 | id, 39 | userId, 40 | children, 41 | }: PostProps) { 42 | return ( 43 | // Using padding instead of margin on the card 44 | // https://virtuoso.dev/troubleshooting#list-does-not-scroll-to-the-bottom--items-jump-around 45 |
46 | 50 |
51 |
52 | 53 |
54 |
55 |
56 |
57 | 58 | 63 | 64 |
65 |
66 | 67 | {name} 68 | 69 |
70 |
71 | 72 | @{username} 73 | 74 |
75 |
76 |
77 | 78 |
79 |
80 | {title} 81 |
82 |
83 |
{children}
84 |
85 | {dateTimeString} 86 |
87 |
88 |
89 |
90 |
91 |
92 | ); 93 | } 94 | -------------------------------------------------------------------------------- /app/components/ui/alert.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { cva, type VariantProps } from "class-variance-authority"; 3 | 4 | import { cn } from "~/lib/utils"; 5 | 6 | const alertVariants = cva( 7 | "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7", 8 | { 9 | variants: { 10 | variant: { 11 | default: "bg-background text-foreground", 12 | destructive: 13 | "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", 14 | }, 15 | }, 16 | defaultVariants: { 17 | variant: "default", 18 | }, 19 | } 20 | ); 21 | 22 | const Alert = React.forwardRef< 23 | HTMLDivElement, 24 | React.HTMLAttributes & VariantProps 25 | >(({ className, variant, ...props }, ref) => ( 26 |
32 | )); 33 | Alert.displayName = "Alert"; 34 | 35 | const AlertTitle = React.forwardRef< 36 | HTMLParagraphElement, 37 | React.HTMLAttributes 38 | >(({ className, ...props }, ref) => ( 39 |
44 | )); 45 | AlertTitle.displayName = "AlertTitle"; 46 | 47 | const AlertDescription = React.forwardRef< 48 | HTMLParagraphElement, 49 | React.HTMLAttributes 50 | >(({ className, ...props }, ref) => ( 51 |
56 | )); 57 | AlertDescription.displayName = "AlertDescription"; 58 | 59 | export { Alert, AlertTitle, AlertDescription }; 60 | -------------------------------------------------------------------------------- /app/components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as AvatarPrimitive from "@radix-ui/react-avatar"; 3 | 4 | import { cn } from "~/lib/utils"; 5 | 6 | const Avatar = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >(({ className, ...props }, ref) => ( 10 | 18 | )); 19 | Avatar.displayName = AvatarPrimitive.Root.displayName; 20 | 21 | const AvatarImage = React.forwardRef< 22 | React.ElementRef, 23 | React.ComponentPropsWithoutRef 24 | >(({ className, ...props }, ref) => ( 25 | 30 | )); 31 | AvatarImage.displayName = AvatarPrimitive.Image.displayName; 32 | 33 | const AvatarFallback = React.forwardRef< 34 | React.ElementRef, 35 | React.ComponentPropsWithoutRef 36 | >(({ className, ...props }, ref) => ( 37 | 45 | )); 46 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; 47 | 48 | export { Avatar, AvatarImage, AvatarFallback }; 49 | -------------------------------------------------------------------------------- /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-transparent 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 { 77 | Card, 78 | CardHeader, 79 | CardFooter, 80 | CardTitle, 81 | CardDescription, 82 | CardContent, 83 | }; 84 | -------------------------------------------------------------------------------- /app/components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as DialogPrimitive from "@radix-ui/react-dialog"; 3 | import { Cross2Icon } from "@radix-ui/react-icons"; 4 | 5 | import { cn } from "~/lib/utils"; 6 | 7 | const Dialog = DialogPrimitive.Root; 8 | 9 | const DialogTrigger = DialogPrimitive.Trigger; 10 | 11 | const DialogPortal = DialogPrimitive.Portal; 12 | 13 | const DialogClose = DialogPrimitive.Close; 14 | 15 | const DialogOverlay = React.forwardRef< 16 | React.ElementRef, 17 | React.ComponentPropsWithoutRef 18 | >(({ className, ...props }, ref) => ( 19 | 27 | )); 28 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; 29 | 30 | const DialogContent = React.forwardRef< 31 | React.ElementRef, 32 | React.ComponentPropsWithoutRef 33 | >(({ className, children, ...props }, ref) => ( 34 | 35 | 36 | 44 | {children} 45 | 46 | 47 | Close 48 | 49 | 50 | 51 | )); 52 | DialogContent.displayName = DialogPrimitive.Content.displayName; 53 | 54 | const DialogHeader = ({ 55 | className, 56 | ...props 57 | }: React.HTMLAttributes) => ( 58 |
65 | ); 66 | DialogHeader.displayName = "DialogHeader"; 67 | 68 | const DialogFooter = ({ 69 | className, 70 | ...props 71 | }: React.HTMLAttributes) => ( 72 |
79 | ); 80 | DialogFooter.displayName = "DialogFooter"; 81 | 82 | const DialogTitle = React.forwardRef< 83 | React.ElementRef, 84 | React.ComponentPropsWithoutRef 85 | >(({ className, ...props }, ref) => ( 86 | 94 | )); 95 | DialogTitle.displayName = DialogPrimitive.Title.displayName; 96 | 97 | const DialogDescription = React.forwardRef< 98 | React.ElementRef, 99 | React.ComponentPropsWithoutRef 100 | >(({ className, ...props }, ref) => ( 101 | 106 | )); 107 | DialogDescription.displayName = DialogPrimitive.Description.displayName; 108 | 109 | export { 110 | Dialog, 111 | DialogPortal, 112 | DialogOverlay, 113 | DialogTrigger, 114 | DialogClose, 115 | DialogContent, 116 | DialogHeader, 117 | DialogFooter, 118 | DialogTitle, 119 | DialogDescription, 120 | }; 121 | -------------------------------------------------------------------------------- /app/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "~/lib/utils"; 4 | 5 | export interface InputProps 6 | extends React.InputHTMLAttributes {} 7 | 8 | const Input = React.forwardRef( 9 | ({ className, type, ...props }, ref) => { 10 | return ( 11 | 20 | ); 21 | } 22 | ); 23 | Input.displayName = "Input"; 24 | 25 | export { Input }; 26 | -------------------------------------------------------------------------------- /app/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as LabelPrimitive from "@radix-ui/react-label" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "~/lib/utils" 6 | 7 | const labelVariants = cva( 8 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 9 | ) 10 | 11 | const Label = React.forwardRef< 12 | React.ElementRef, 13 | React.ComponentPropsWithoutRef & 14 | VariantProps 15 | >(({ className, ...props }, ref) => ( 16 | 21 | )) 22 | Label.displayName = LabelPrimitive.Root.displayName 23 | 24 | export { Label } 25 | -------------------------------------------------------------------------------- /app/components/ui/separator.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as SeparatorPrimitive from "@radix-ui/react-separator"; 3 | 4 | import { cn } from "~/lib/utils"; 5 | 6 | const Separator = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >( 10 | ( 11 | { className, orientation = "horizontal", decorative = true, ...props }, 12 | ref 13 | ) => ( 14 | 25 | ) 26 | ); 27 | Separator.displayName = SeparatorPrimitive.Root.displayName; 28 | 29 | export { Separator }; 30 | -------------------------------------------------------------------------------- /app/components/ui/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "~/lib/utils"; 2 | 3 | function Skeleton({ 4 | className, 5 | ...props 6 | }: React.HTMLAttributes) { 7 | return ( 8 |
12 | ); 13 | } 14 | 15 | export { Skeleton }; 16 | -------------------------------------------------------------------------------- /app/components/ui/sonner.tsx: -------------------------------------------------------------------------------- 1 | import { useTheme } from "next-themes" 2 | import { Toaster as Sonner } from "sonner" 3 | 4 | type ToasterProps = React.ComponentProps 5 | 6 | const Toaster = ({ ...props }: ToasterProps) => { 7 | const { theme = "system" } = useTheme() 8 | 9 | return ( 10 | 26 | ) 27 | } 28 | 29 | export { Toaster } 30 | -------------------------------------------------------------------------------- /app/components/ui/tabs.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as TabsPrimitive from "@radix-ui/react-tabs"; 3 | 4 | import { cn } from "~/lib/utils"; 5 | 6 | const Tabs = TabsPrimitive.Root; 7 | 8 | const TabsList = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | )); 21 | TabsList.displayName = TabsPrimitive.List.displayName; 22 | 23 | const TabsTrigger = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, ...props }, ref) => ( 27 | 35 | )); 36 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName; 37 | 38 | const TabsContent = React.forwardRef< 39 | React.ElementRef, 40 | React.ComponentPropsWithoutRef 41 | >(({ className, ...props }, ref) => ( 42 | 50 | )); 51 | TabsContent.displayName = TabsPrimitive.Content.displayName; 52 | 53 | export { Tabs, TabsList, TabsTrigger, TabsContent }; 54 | -------------------------------------------------------------------------------- /app/components/ui/textarea.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "~/lib/utils"; 4 | 5 | export interface TextareaProps 6 | extends React.TextareaHTMLAttributes {} 7 | 8 | const Textarea = React.forwardRef( 9 | ({ className, ...props }, ref) => { 10 | return ( 11 |