├── .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 |
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 |
19 | );
20 | }
21 | );
22 | Textarea.displayName = "Textarea";
23 |
24 | export { Textarea };
25 |
--------------------------------------------------------------------------------
/app/components/ui/toaster.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Toast,
3 | ToastClose,
4 | ToastDescription,
5 | ToastProvider,
6 | ToastTitle,
7 | ToastViewport,
8 | } from "~/components/ui/toast"
9 | import { useToast } from "~/components/ui/use-toast"
10 |
11 | export function Toaster() {
12 | const { toasts } = useToast()
13 |
14 | return (
15 |
16 | {toasts.map(function ({ id, title, description, action, ...props }) {
17 | return (
18 |
19 |
20 | {title && {title} }
21 | {description && (
22 | {description}
23 | )}
24 |
25 | {action}
26 |
27 |
28 | )
29 | })}
30 |
31 |
32 | )
33 | }
34 |
--------------------------------------------------------------------------------
/app/components/ui/use-toast.ts:
--------------------------------------------------------------------------------
1 | // Inspired by react-hot-toast library
2 | import * as React from "react"
3 |
4 | import type {
5 | ToastActionElement,
6 | ToastProps,
7 | } from "~/components/ui/toast"
8 |
9 | const TOAST_LIMIT = 1
10 | const TOAST_REMOVE_DELAY = 1000000
11 |
12 | type ToasterToast = ToastProps & {
13 | id: string
14 | title?: React.ReactNode
15 | description?: React.ReactNode
16 | action?: ToastActionElement
17 | }
18 |
19 | const actionTypes = {
20 | ADD_TOAST: "ADD_TOAST",
21 | UPDATE_TOAST: "UPDATE_TOAST",
22 | DISMISS_TOAST: "DISMISS_TOAST",
23 | REMOVE_TOAST: "REMOVE_TOAST",
24 | } as const
25 |
26 | let count = 0
27 |
28 | function genId() {
29 | count = (count + 1) % Number.MAX_VALUE
30 | return count.toString()
31 | }
32 |
33 | type ActionType = typeof actionTypes
34 |
35 | type Action =
36 | | {
37 | type: ActionType["ADD_TOAST"]
38 | toast: ToasterToast
39 | }
40 | | {
41 | type: ActionType["UPDATE_TOAST"]
42 | toast: Partial
43 | }
44 | | {
45 | type: ActionType["DISMISS_TOAST"]
46 | toastId?: ToasterToast["id"]
47 | }
48 | | {
49 | type: ActionType["REMOVE_TOAST"]
50 | toastId?: ToasterToast["id"]
51 | }
52 |
53 | interface State {
54 | toasts: ToasterToast[]
55 | }
56 |
57 | const toastTimeouts = new Map>()
58 |
59 | const addToRemoveQueue = (toastId: string) => {
60 | if (toastTimeouts.has(toastId)) {
61 | return
62 | }
63 |
64 | const timeout = setTimeout(() => {
65 | toastTimeouts.delete(toastId)
66 | dispatch({
67 | type: "REMOVE_TOAST",
68 | toastId: toastId,
69 | })
70 | }, TOAST_REMOVE_DELAY)
71 |
72 | toastTimeouts.set(toastId, timeout)
73 | }
74 |
75 | export const reducer = (state: State, action: Action): State => {
76 | switch (action.type) {
77 | case "ADD_TOAST":
78 | return {
79 | ...state,
80 | toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
81 | }
82 |
83 | case "UPDATE_TOAST":
84 | return {
85 | ...state,
86 | toasts: state.toasts.map((t) =>
87 | t.id === action.toast.id ? { ...t, ...action.toast } : t
88 | ),
89 | }
90 |
91 | case "DISMISS_TOAST": {
92 | const { toastId } = action
93 |
94 | // ! Side effects ! - This could be extracted into a dismissToast() action,
95 | // but I'll keep it here for simplicity
96 | if (toastId) {
97 | addToRemoveQueue(toastId)
98 | } else {
99 | state.toasts.forEach((toast) => {
100 | addToRemoveQueue(toast.id)
101 | })
102 | }
103 |
104 | return {
105 | ...state,
106 | toasts: state.toasts.map((t) =>
107 | t.id === toastId || toastId === undefined
108 | ? {
109 | ...t,
110 | open: false,
111 | }
112 | : t
113 | ),
114 | }
115 | }
116 | case "REMOVE_TOAST":
117 | if (action.toastId === undefined) {
118 | return {
119 | ...state,
120 | toasts: [],
121 | }
122 | }
123 | return {
124 | ...state,
125 | toasts: state.toasts.filter((t) => t.id !== action.toastId),
126 | }
127 | }
128 | }
129 |
130 | const listeners: Array<(state: State) => void> = []
131 |
132 | let memoryState: State = { toasts: [] }
133 |
134 | function dispatch(action: Action) {
135 | memoryState = reducer(memoryState, action)
136 | listeners.forEach((listener) => {
137 | listener(memoryState)
138 | })
139 | }
140 |
141 | type Toast = Omit
142 |
143 | function toast({ ...props }: Toast) {
144 | const id = genId()
145 |
146 | const update = (props: ToasterToast) =>
147 | dispatch({
148 | type: "UPDATE_TOAST",
149 | toast: { ...props, id },
150 | })
151 | const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
152 |
153 | dispatch({
154 | type: "ADD_TOAST",
155 | toast: {
156 | ...props,
157 | id,
158 | open: true,
159 | onOpenChange: (open) => {
160 | if (!open) dismiss()
161 | },
162 | },
163 | })
164 |
165 | return {
166 | id: id,
167 | dismiss,
168 | update,
169 | }
170 | }
171 |
172 | function useToast() {
173 | const [state, setState] = React.useState(memoryState)
174 |
175 | React.useEffect(() => {
176 | listeners.push(setState)
177 | return () => {
178 | const index = listeners.indexOf(setState)
179 | if (index > -1) {
180 | listeners.splice(index, 1)
181 | }
182 | }
183 | }, [state])
184 |
185 | return {
186 | ...state,
187 | toast,
188 | dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
189 | }
190 | }
191 |
192 | export { useToast, toast }
193 |
--------------------------------------------------------------------------------
/app/components/view-comments.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from "@remix-run/react";
2 | import { MessageCircle } from "lucide-react";
3 |
4 | type ViewCommentsProps = {
5 | number: number;
6 | pathname: string;
7 | readonly?: boolean;
8 | };
9 |
10 | export function ViewComments({
11 | number,
12 | pathname,
13 | readonly,
14 | }: ViewCommentsProps) {
15 | return (
16 | <>
17 | {readonly ? (
18 |
19 |
20 | {number}
21 |
22 | ) : (
23 |
28 |
29 |
30 |
31 | {number}
32 |
33 |
34 | )}
35 | >
36 | );
37 | }
38 |
--------------------------------------------------------------------------------
/app/components/view-likes.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from "@remix-run/react";
2 | import { Star } from "lucide-react";
3 |
4 | type ViewLikesProps = {
5 | likes: number;
6 | likedByUser: boolean;
7 | pathname: string;
8 | readonly?: boolean;
9 | };
10 |
11 | export function ViewLikes({
12 | likes,
13 | likedByUser,
14 | pathname,
15 | readonly,
16 | }: ViewLikesProps) {
17 | return (
18 | <>
19 |
24 | {likedByUser ? (
25 |
26 | ) : (
27 |
30 | )}
31 |
35 | {likes}
36 |
37 |
38 | >
39 | );
40 | }
41 |
--------------------------------------------------------------------------------
/app/components/write-post.tsx:
--------------------------------------------------------------------------------
1 | import { UpdateIcon } from "@radix-ui/react-icons";
2 | import { useFetcher } from "@remix-run/react";
3 | import { useState, useRef, useEffect } from "react";
4 | import { Button } from "./ui/button";
5 | import {
6 | Card,
7 | CardHeader,
8 | CardTitle,
9 | CardDescription,
10 | CardContent,
11 | CardFooter,
12 | } from "./ui/card";
13 | import { Textarea } from "./ui/textarea";
14 |
15 | type WritePostProps = {
16 | sessionUserId: string;
17 | postId?: string;
18 | isComment?: boolean;
19 | };
20 |
21 | export function WritePost({
22 | sessionUserId,
23 | isComment,
24 | postId,
25 | }: WritePostProps) {
26 | const fetcher = useFetcher();
27 | const [title, setTitle] = useState("");
28 | const isPosting = fetcher.state !== "idle";
29 | const isDisabled = isPosting || !title;
30 |
31 | const postActionUrl = isComment ? "/resources/comment" : "/resources/post";
32 |
33 | const formData = {
34 | title,
35 | userId: sessionUserId,
36 | ...(isComment ? { postId } : {}),
37 | };
38 |
39 | const postItem = async () => {
40 | fetcher.submit(formData, { method: "POST", action: postActionUrl });
41 | setTitle("");
42 | };
43 |
44 | const textareaRef = useRef(null);
45 |
46 | useEffect(() => {
47 | if (textareaRef.current) {
48 | textareaRef.current.style.height = "inherit"; // Reset height - important to shrink on delete
49 | const computed = window.getComputedStyle(textareaRef.current);
50 | const height =
51 | textareaRef.current.scrollHeight +
52 | parseInt(computed.getPropertyValue("border-top-width")) +
53 | parseInt(computed.getPropertyValue("border-bottom-width"));
54 | textareaRef.current.style.height = `${height}px`;
55 | }
56 | }, [title]);
57 |
58 | if (isComment) {
59 | return (
60 |
61 |
62 |
74 |
75 | );
76 | }
77 |
78 | return (
79 |
80 |
81 | Write Post
82 | You can write in Markdown
83 |
84 |
85 |
93 |
94 |
95 | {isPosting && }
96 | {isPosting ? "Posting" : "Post"}
97 |
98 |
99 |
100 | );
101 | }
102 |
--------------------------------------------------------------------------------
/app/entry.client.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * By default, Remix will handle hydrating your app on the client for you.
3 | * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
4 | * For more information, see https://remix.run/file-conventions/entry.client
5 | */
6 |
7 | import { RemixBrowser } from "@remix-run/react";
8 | import { startTransition, StrictMode } from "react";
9 | import { hydrateRoot } from "react-dom/client";
10 |
11 | startTransition(() => {
12 | hydrateRoot(
13 | document,
14 |
15 |
16 |
17 | );
18 | });
19 |
--------------------------------------------------------------------------------
/app/entry.server.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * By default, Remix will handle generating the HTTP Response for you.
3 | * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
4 | * For more information, see https://remix.run/file-conventions/entry.server
5 | */
6 |
7 | import { PassThrough } from "node:stream";
8 |
9 | import type { AppLoadContext, EntryContext } from "@remix-run/node";
10 | import { createReadableStreamFromReadable } from "@remix-run/node";
11 | import { RemixServer } from "@remix-run/react";
12 | import isbot from "isbot";
13 | import { renderToPipeableStream } from "react-dom/server";
14 |
15 | const ABORT_DELAY = 5_000;
16 |
17 | export default function handleRequest(
18 | request: Request,
19 | responseStatusCode: number,
20 | responseHeaders: Headers,
21 | remixContext: EntryContext,
22 | loadContext: AppLoadContext
23 | ) {
24 | return isbot(request.headers.get("user-agent"))
25 | ? handleBotRequest(
26 | request,
27 | responseStatusCode,
28 | responseHeaders,
29 | remixContext
30 | )
31 | : handleBrowserRequest(
32 | request,
33 | responseStatusCode,
34 | responseHeaders,
35 | remixContext
36 | );
37 | }
38 |
39 | function handleBotRequest(
40 | request: Request,
41 | responseStatusCode: number,
42 | responseHeaders: Headers,
43 | remixContext: EntryContext
44 | ) {
45 | return new Promise((resolve, reject) => {
46 | let shellRendered = false;
47 | const { pipe, abort } = renderToPipeableStream(
48 | ,
53 | {
54 | onAllReady() {
55 | shellRendered = true;
56 | const body = new PassThrough();
57 | const stream = createReadableStreamFromReadable(body);
58 |
59 | responseHeaders.set("Content-Type", "text/html");
60 |
61 | resolve(
62 | new Response(stream, {
63 | headers: responseHeaders,
64 | status: responseStatusCode,
65 | })
66 | );
67 |
68 | pipe(body);
69 | },
70 | onShellError(error: unknown) {
71 | reject(error);
72 | },
73 | onError(error: unknown) {
74 | responseStatusCode = 500;
75 | // Log streaming rendering errors from inside the shell. Don't log
76 | // errors encountered during initial shell rendering since they'll
77 | // reject and get logged in handleDocumentRequest.
78 | if (shellRendered) {
79 | console.error(error);
80 | }
81 | },
82 | }
83 | );
84 |
85 | setTimeout(abort, ABORT_DELAY);
86 | });
87 | }
88 |
89 | function handleBrowserRequest(
90 | request: Request,
91 | responseStatusCode: number,
92 | responseHeaders: Headers,
93 | remixContext: EntryContext
94 | ) {
95 | return new Promise((resolve, reject) => {
96 | let shellRendered = false;
97 | const { pipe, abort } = renderToPipeableStream(
98 | ,
103 | {
104 | onShellReady() {
105 | shellRendered = true;
106 | const body = new PassThrough();
107 | const stream = createReadableStreamFromReadable(body);
108 |
109 | responseHeaders.set("Content-Type", "text/html");
110 |
111 | resolve(
112 | new Response(stream, {
113 | headers: responseHeaders,
114 | status: responseStatusCode,
115 | })
116 | );
117 |
118 | pipe(body);
119 | },
120 | onShellError(error: unknown) {
121 | reject(error);
122 | },
123 | onError(error: unknown) {
124 | responseStatusCode = 500;
125 | // Log streaming rendering errors from inside the shell. Don't log
126 | // errors encountered during initial shell rendering since they'll
127 | // reject and get logged in handleDocumentRequest.
128 | if (shellRendered) {
129 | console.error(error);
130 | }
131 | },
132 | }
133 | );
134 |
135 | setTimeout(abort, ABORT_DELAY);
136 | });
137 | }
138 |
--------------------------------------------------------------------------------
/app/lib/client-hints.tsx:
--------------------------------------------------------------------------------
1 | import { getHintUtils } from "@epic-web/client-hints";
2 | import {
3 | clientHint as colorSchemeHint,
4 | subscribeToSchemeChange,
5 | } from "@epic-web/client-hints/color-scheme";
6 | import { useRevalidator, useRouteLoaderData } from "@remix-run/react";
7 | import * as React from "react";
8 | import type { loader as rootLoader } from "~/root";
9 | import type { SerializeFrom } from "@remix-run/node";
10 | import { useOptimisticTheme } from "~/routes/resources.theme-toggle";
11 |
12 | const hintsUtils = getHintUtils({
13 | theme: colorSchemeHint,
14 | });
15 |
16 | export const { getHints } = hintsUtils;
17 |
18 | // Remix theme utils below
19 | export function useRequestInfo() {
20 | const data = useRouteLoaderData("root") as SerializeFrom;
21 | return data.requestInfo;
22 | }
23 |
24 | export function useHints() {
25 | const requestInfo = useRequestInfo();
26 | return requestInfo.hints;
27 | }
28 |
29 | export function ClientHintCheck({ nonce }: { nonce: string }) {
30 | const { revalidate } = useRevalidator();
31 | React.useEffect(
32 | () => subscribeToSchemeChange(() => revalidate()),
33 | [revalidate]
34 | );
35 |
36 | return (
37 |
43 | );
44 | }
45 |
46 | /**
47 | * @returns the user's theme preference, or the client hint theme if the user
48 | * has not set a preference.
49 | */
50 | export function useTheme() {
51 | const hints = useHints();
52 | const requestInfo = useRequestInfo();
53 | const optimisticTheme = useOptimisticTheme();
54 | if (optimisticTheme) {
55 | return optimisticTheme === "system" ? hints.theme : optimisticTheme;
56 | }
57 | return requestInfo.userPrefs.theme ?? hints.theme;
58 | }
59 |
60 | // Use nonce for the script tag
61 | const NonceContext = React.createContext("");
62 | export const useNonce = () => React.useContext(NonceContext);
63 |
--------------------------------------------------------------------------------
/app/lib/database.server.ts:
--------------------------------------------------------------------------------
1 | import type { SupabaseClient } from "@supabase/supabase-js";
2 | import type { Database } from "database.types";
3 |
4 | export async function getAllPostsWithDetails({
5 | dbClient,
6 | query,
7 | page, // Default to page 1
8 | limit = 10, // Default to 25 posts per page
9 | }: {
10 | dbClient: SupabaseClient;
11 | query: string | null;
12 | page: number;
13 | limit?: number;
14 | }) {
15 | let postsQuery = dbClient
16 | .from("posts")
17 | .select("*, author: profiles(*), likes(user_id), comments(*)", {
18 | count: "exact",
19 | })
20 | .order("created_at", { ascending: false })
21 | .range((page - 1) * limit, page * limit - 1);
22 |
23 | if (query) {
24 | postsQuery = postsQuery.ilike("title", `%${query}%`);
25 | }
26 |
27 | const { data, error, count } = await postsQuery;
28 |
29 | if (error) {
30 | console.log("Error occured during getAllPostsWithDetails : ", error);
31 | }
32 |
33 | return {
34 | data,
35 | error,
36 | totalPages: count ? Math.ceil(count / limit) : 1,
37 | totalPosts: count,
38 | limit,
39 | };
40 | }
41 |
42 | export async function getPostWithDetailsById({
43 | dbClient,
44 | postId,
45 | }: {
46 | dbClient: SupabaseClient;
47 | postId: string;
48 | }) {
49 | const { data, error } = await dbClient
50 | .from("posts")
51 | .select(
52 | "*, author: profiles(*), likes(user_id), comments(*, author: profiles(username, avatar_url))"
53 | )
54 | .order("created_at", { foreignTable: "comments", ascending: false })
55 | .eq("id", postId);
56 |
57 | if (error) {
58 | console.error("Error occurred during getPostWithDetailsById: ", error);
59 | }
60 |
61 | return {
62 | data,
63 | error,
64 | };
65 | }
66 |
67 | export function delayAsync(delayMillis: number) {
68 | return new Promise((resolve) => setTimeout(resolve, delayMillis));
69 | }
70 |
71 | export async function getPostsForUser({
72 | dbClient,
73 | userId,
74 | page, // Default to page 1
75 | limit = 10, // Default to 25 posts per page
76 | }: {
77 | dbClient: SupabaseClient;
78 | userId: string;
79 | page: number;
80 | limit?: number;
81 | }) {
82 | // await delayAsync(3000);
83 |
84 | let postsQuery = dbClient
85 | .from("posts")
86 | .select("*, author: profiles(*), likes(user_id), comments(*)", {
87 | count: "exact",
88 | })
89 | .eq("user_id", userId)
90 | .order("created_at", { ascending: false })
91 | .range((page - 1) * limit, page * limit - 1);
92 |
93 | const { data, error, count } = await postsQuery;
94 |
95 | if (error) {
96 | console.log(`Error occured during getPostsForUser ${userId} : `, error);
97 | }
98 |
99 | return {
100 | data,
101 | error,
102 | totalPages: count ? Math.ceil(count / limit) : 1,
103 | limit,
104 | };
105 | }
106 |
107 | export async function getProfileForUsername({
108 | dbClient,
109 | username,
110 | }: {
111 | dbClient: SupabaseClient;
112 | username: string;
113 | }) {
114 | const profileQuery = dbClient
115 | .from("profiles")
116 | .select("*")
117 | .eq("username", username);
118 |
119 | const { data, error } = await profileQuery;
120 |
121 | if (error) {
122 | console.log("Error occured during getProfileForUsername : ", error);
123 | }
124 |
125 | return { data, error };
126 | }
127 |
--------------------------------------------------------------------------------
/app/lib/supabase.server.ts:
--------------------------------------------------------------------------------
1 | import { createServerClient, parse, serialize } from '@supabase/ssr';
2 | import type { Database } from 'database.types';
3 |
4 | export const getSupabaseEnv = () => ({
5 | SUPABASE_URL: process.env.SUPABASE_URL!,
6 | SUPABASE_ANON_KEY: process.env.SUPABASE_ANON_KEY!,
7 | });
8 |
9 | export function getSupabaseWithHeaders({ request }: { request: Request }) {
10 | const cookies = parse(request.headers.get('Cookie') ?? '');
11 | const headers = new Headers();
12 |
13 | const supabase = createServerClient(
14 | process.env.SUPABASE_URL!,
15 | process.env.SUPABASE_ANON_KEY!,
16 | {
17 | cookies: {
18 | get(key) {
19 | return cookies[key];
20 | },
21 | set(key, value, options) {
22 | headers.append('Set-Cookie', serialize(key, value, options));
23 | },
24 | remove(key, options) {
25 | headers.append('Set-Cookie', serialize(key, '', options));
26 | },
27 | },
28 | auth: {
29 | detectSessionInUrl: true,
30 | flowType: 'pkce',
31 | },
32 | }
33 | );
34 |
35 | return { supabase, headers };
36 | }
37 |
38 | export async function getSupabaseWithSessionHeaders({
39 | request,
40 | }: {
41 | request: Request;
42 | }) {
43 | const { supabase, headers } = getSupabaseWithHeaders({
44 | request,
45 | });
46 | const {
47 | data: { session },
48 | } = await supabase.auth.getSession();
49 |
50 | return { session, headers, supabase };
51 | }
52 |
--------------------------------------------------------------------------------
/app/lib/supabase.ts:
--------------------------------------------------------------------------------
1 | import { createBrowserClient } from '@supabase/ssr';
2 | import type { Session, SupabaseClient } from '@supabase/supabase-js';
3 | import { useEffect, useState } from 'react';
4 | import type { Database } from 'database.types';
5 | import { useRevalidator } from '@remix-run/react';
6 |
7 | export type TypedSupabaseClient = SupabaseClient;
8 |
9 | export type SupabaseOutletContext = {
10 | supabase: TypedSupabaseClient;
11 | domainUrl: string;
12 | };
13 |
14 | type SupabaseEnv = {
15 | SUPABASE_URL: string;
16 | SUPABASE_ANON_KEY: string;
17 | };
18 |
19 | type UseSupabase = {
20 | env: SupabaseEnv;
21 | session: Session | null;
22 | };
23 |
24 | export const useSupabase = ({ env, session }: UseSupabase) => {
25 | // Singleton
26 | const [supabase] = useState(() =>
27 | createBrowserClient(env.SUPABASE_URL!, env.SUPABASE_ANON_KEY!)
28 | );
29 | const revalidator = useRevalidator();
30 |
31 | const serverAccessToken = session?.access_token;
32 |
33 | useEffect(() => {
34 | const {
35 | data: { subscription },
36 | } = supabase.auth.onAuthStateChange((event, session) => {
37 | console.log('Auth event happened: ', event, session);
38 |
39 | if (session?.access_token !== serverAccessToken) {
40 | // call loaders
41 | revalidator.revalidate();
42 | }
43 | });
44 |
45 | return () => {
46 | subscription.unsubscribe();
47 | };
48 | }, [supabase, serverAccessToken, revalidator]);
49 |
50 | return { supabase };
51 | };
52 |
53 | export function getRealTimeSubscription(
54 | supabase: TypedSupabaseClient,
55 | callback: () => void
56 | ) {
57 | return supabase
58 | .channel('realtime posts and likes')
59 | .on(
60 | 'postgres_changes',
61 | {
62 | event: 'INSERT',
63 | schema: 'public',
64 | table: 'posts',
65 | },
66 | () => {
67 | callback();
68 | }
69 | )
70 | .subscribe();
71 | }
72 |
--------------------------------------------------------------------------------
/app/lib/theme.server.ts:
--------------------------------------------------------------------------------
1 | import * as cookie from "cookie";
2 |
3 | const cookieName = "en_theme";
4 | type Theme = "light" | "dark";
5 |
6 | export function getTheme(request: Request): Theme | null {
7 | const cookieHeader = request.headers.get("cookie");
8 | const parsed = cookieHeader
9 | ? cookie.parse(cookieHeader)[cookieName]
10 | : "light";
11 | if (parsed === "light" || parsed === "dark") return parsed;
12 | return null;
13 | }
14 |
15 | export function setTheme(theme: Theme | "system") {
16 | if (theme === "system") {
17 | return cookie.serialize(cookieName, "", { path: "/", maxAge: -1 });
18 | } else {
19 | return cookie.serialize(cookieName, theme, { path: "/", maxAge: 31536000 });
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/app/lib/types.ts:
--------------------------------------------------------------------------------
1 | import type { Database } from "database.types";
2 | import type { combinePostsWithLikes } from "./utils";
3 |
4 | type Post = Database["public"]["Tables"]["posts"]["Row"];
5 | type Profile = Database["public"]["Tables"]["profiles"]["Row"];
6 | type Comment = Database["public"]["Tables"]["comments"]["Row"];
7 |
8 | type CommentWithAuthor = Comment & {
9 | author: {
10 | avatar_url: string;
11 | username: string;
12 | } | null;
13 | };
14 |
15 | // Combine types to create PostWithAuthorAndLikes
16 | export type PostWithDetails = Post & {
17 | author: Profile | null;
18 | likes: { user_id: string }[];
19 | comments: Comment[];
20 | };
21 |
22 | export type PostWithCommentDetails = Omit & {
23 | comments: CommentWithAuthor[];
24 | };
25 |
26 | export type CombinedPostsWithAuthorAndLikes = ReturnType<
27 | typeof combinePostsWithLikes
28 | >;
29 |
30 | export type CombinedPostWithAuthorAndLikes =
31 | CombinedPostsWithAuthorAndLikes[number];
32 |
--------------------------------------------------------------------------------
/app/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx";
2 | import { twMerge } from "tailwind-merge";
3 | import type { Session } from "@supabase/supabase-js";
4 | import type { PostWithCommentDetails, PostWithDetails } from "./types";
5 |
6 | export function formatToTwitterDate(dateTimeString: string) {
7 | const date = new Date(dateTimeString);
8 |
9 | const day = date.getDate();
10 | const monthNames = [
11 | "Jan",
12 | "Feb",
13 | "Mar",
14 | "Apr",
15 | "May",
16 | "Jun",
17 | "Jul",
18 | "Aug",
19 | "Sep",
20 | "Oct",
21 | "Nov",
22 | "Dec",
23 | ];
24 | const month = monthNames[date.getMonth()];
25 | const year = date.getFullYear();
26 | let hours = date.getHours();
27 | const minutes = date.getMinutes();
28 |
29 | // Convert hours to AM/PM format
30 | const amPM = hours >= 12 ? "PM" : "AM";
31 | hours = hours % 12 || 12; // Convert hours to 12-hour format
32 |
33 | const formattedDate = `${hours}:${
34 | minutes < 10 ? "0" : ""
35 | }${minutes} ${amPM} · ${month} ${day}, ${year}`;
36 |
37 | return formattedDate;
38 | }
39 |
40 | export function cn(...inputs: ClassValue[]) {
41 | return twMerge(clsx(inputs));
42 | }
43 |
44 | export function getUserDataFromSession(session: Session) {
45 | const userId = session.user.id;
46 | const userAvatarUrl = session.user.user_metadata.avatar_url;
47 | const username = session.user.user_metadata.user_name;
48 |
49 | return { userId, userAvatarUrl, username };
50 | }
51 |
52 | export function combinePostsWithLikes(
53 | data: PostWithDetails[] | null,
54 | sessionUserId: string
55 | ) {
56 | const posts =
57 | data?.map((post) => {
58 | return {
59 | ...post,
60 | isLikedByUser: !!post.likes.find(
61 | (like) => like.user_id === sessionUserId
62 | ),
63 | likes: post.likes.length,
64 | comments: post.comments,
65 | author: post.author!, // cannot be null
66 | };
67 | }) ?? [];
68 |
69 | return posts;
70 | }
71 |
72 | export function combinePostsWithLikesAndComments(
73 | data: PostWithCommentDetails[] | null,
74 | sessionUserId: string
75 | ) {
76 | const posts =
77 | data?.map((post) => {
78 | // Map each comment to rename avatar_url to avatarUrl
79 | const commentsWithAvatarUrl = post.comments.map((comment) => ({
80 | ...comment,
81 | author: {
82 | username: comment.author!.username,
83 | avatarUrl: comment.author!.avatar_url,
84 | },
85 | }));
86 |
87 | return {
88 | ...post,
89 | isLikedByUser: !!post.likes.find(
90 | (like) => like.user_id === sessionUserId
91 | ),
92 | likes: post.likes.length,
93 | comments: commentsWithAvatarUrl, // Use the transformed comments
94 | author: post.author!, // author is guaranteed
95 | };
96 | }) ?? [];
97 |
98 | return posts;
99 | }
100 |
--------------------------------------------------------------------------------
/app/root.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | json,
3 | type LinksFunction,
4 | type LoaderFunctionArgs,
5 | } from '@remix-run/node';
6 | import {
7 | Links,
8 | LiveReload,
9 | Meta,
10 | Outlet,
11 | Scripts,
12 | ScrollRestoration,
13 | useLoaderData,
14 | } from '@remix-run/react';
15 | import styles from '~/tailwind.css';
16 | import {
17 | getSupabaseEnv,
18 | getSupabaseWithSessionHeaders,
19 | } from './lib/supabase.server';
20 | import { useSupabase } from './lib/supabase';
21 | import { Toaster } from './components/ui/toaster';
22 | import { Toaster as SonnerToaster } from './components/ui/sonner';
23 | import {
24 | getHints,
25 | ClientHintCheck,
26 | useTheme,
27 | useNonce,
28 | } from './lib/client-hints';
29 | import { getTheme } from './lib/theme.server';
30 | import clsx from 'clsx';
31 |
32 | export const links: LinksFunction = () => [{ rel: 'stylesheet', href: styles }];
33 |
34 | export const loader = async ({ request }: LoaderFunctionArgs) => {
35 | const { session, headers } = await getSupabaseWithSessionHeaders({
36 | request,
37 | });
38 | const domainUrl = process.env.DOMAIN_URL!;
39 |
40 | return json(
41 | {
42 | env: getSupabaseEnv(),
43 | session,
44 | domainUrl,
45 | requestInfo: {
46 | hints: getHints(request),
47 | userPrefs: {
48 | theme: getTheme(request),
49 | },
50 | },
51 | },
52 | { headers }
53 | );
54 | };
55 |
56 | export default function App() {
57 | const { env, session, domainUrl } = useLoaderData();
58 |
59 | const { supabase } = useSupabase({ env, session });
60 | const theme = useTheme();
61 | const nonce = useNonce();
62 |
63 | return (
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 | );
82 | }
83 |
--------------------------------------------------------------------------------
/app/routes/_home.gitposts.tsx:
--------------------------------------------------------------------------------
1 | import { redirect } from "@remix-run/node";
2 | import { type LoaderFunctionArgs, json } from "@remix-run/node";
3 | import type { ShouldRevalidateFunctionArgs } from "@remix-run/react";
4 | import { Outlet, useLoaderData, useNavigation } from "@remix-run/react";
5 | import { WritePost } from "~/components/write-post";
6 | import { getSupabaseWithSessionHeaders } from "~/lib/supabase.server";
7 | import { Separator } from "~/components/ui/separator";
8 | import { PostSearch } from "~/components/post-search";
9 | import { getAllPostsWithDetails } from "~/lib/database.server";
10 | import { combinePostsWithLikes, getUserDataFromSession } from "~/lib/utils";
11 | import { InfiniteVirtualList } from "~/routes/stateful/infinite-virtual-list";
12 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
13 |
14 | export let loader = async ({ request }: LoaderFunctionArgs) => {
15 | const { supabase, headers, session } = await getSupabaseWithSessionHeaders({
16 | request,
17 | });
18 |
19 | if (!session) {
20 | return redirect("/login", { headers });
21 | }
22 |
23 | const url = new URL(request.url);
24 | const searchParams = url.searchParams;
25 | const query = searchParams.get("query");
26 | const page = Number(searchParams.get("page")) || 1;
27 |
28 | const { data, totalPages, limit } = await getAllPostsWithDetails({
29 | dbClient: supabase,
30 | query,
31 | page: isNaN(page) ? 1 : page,
32 | });
33 |
34 | const {
35 | userId: sessionUserId,
36 | userAvatarUrl,
37 | username,
38 | } = getUserDataFromSession(session);
39 |
40 | const posts = combinePostsWithLikes(data, sessionUserId);
41 |
42 | return json(
43 | {
44 | posts,
45 | userDetails: { sessionUserId, userAvatarUrl, username },
46 | query,
47 | totalPages,
48 | limit,
49 | },
50 | { headers }
51 | );
52 | };
53 |
54 | export default function GitPosts() {
55 | const {
56 | posts,
57 | userDetails: { sessionUserId },
58 | query,
59 | totalPages,
60 | } = useLoaderData();
61 | const navigation = useNavigation();
62 |
63 | // When nothing is happening, navigation.location will be undefined,
64 | // but when the user navigates it will be populated with the next
65 | // location while data loads. Then we check if they're searching with
66 | // location.search.
67 | const isSearching = Boolean(
68 | navigation.location &&
69 | new URLSearchParams(navigation.location.search).has("query")
70 | );
71 |
72 | console.log("isSearching ", true, query);
73 |
74 | return (
75 |
76 |
77 |
78 | View Posts
79 | Write Post
80 |
81 |
82 |
83 |
84 |
85 |
90 |
91 |
92 |
93 |
94 |
95 |
96 | );
97 | }
98 |
99 | export function shouldRevalidate({
100 | actionResult,
101 | defaultShouldRevalidate,
102 | }: ShouldRevalidateFunctionArgs) {
103 | const skipRevalidation =
104 | actionResult?.skipRevalidation &&
105 | actionResult?.skipRevalidation?.includes("/gitposts");
106 |
107 | if (skipRevalidation) {
108 | console.log("Skipped revalidation");
109 | return false;
110 | }
111 |
112 | console.log("Did not skip revalidation");
113 | return defaultShouldRevalidate;
114 | }
115 |
--------------------------------------------------------------------------------
/app/routes/_home.profile.$username.$postId.tsx:
--------------------------------------------------------------------------------
1 | export { loader } from "./_home.gitposts.$postId";
2 | export { default } from "./_home.gitposts.$postId";
3 |
--------------------------------------------------------------------------------
/app/routes/_home.profile.$username.tsx:
--------------------------------------------------------------------------------
1 | import { json, redirect } from "@remix-run/node";
2 | import { type LoaderFunctionArgs } from "@remix-run/node";
3 | import type { ShouldRevalidateFunctionArgs } from "@remix-run/react";
4 | import { Link, Outlet, useLoaderData } from "@remix-run/react";
5 |
6 | import { InfiniteVirtualList } from "~/routes/stateful/infinite-virtual-list";
7 |
8 | import { Avatar, AvatarImage } from "~/components/ui/avatar";
9 | import { Separator } from "~/components/ui/separator";
10 | import { getPostsForUser, getProfileForUsername } from "~/lib/database.server";
11 | import { getSupabaseWithSessionHeaders } from "~/lib/supabase.server";
12 | import { combinePostsWithLikes } from "~/lib/utils";
13 |
14 | export let loader = async ({ request, params }: LoaderFunctionArgs) => {
15 | const { username } = params;
16 | const { supabase, headers, session } = await getSupabaseWithSessionHeaders({
17 | request,
18 | });
19 |
20 | if (!session) {
21 | return redirect("/login", { headers });
22 | }
23 |
24 | // Redirect to 404 page if username is invalid
25 | if (!username) {
26 | return redirect("/404", { headers });
27 | }
28 |
29 | const url = new URL(request.url);
30 | const searchParams = url.searchParams;
31 | const page = Number(searchParams.get("page")) || 1;
32 |
33 | const { data: profiles } = await getProfileForUsername({
34 | dbClient: supabase,
35 | username,
36 | });
37 |
38 | const profile = profiles ? profiles[0] : null;
39 |
40 | // User not found
41 | if (!profile) {
42 | return redirect("/404", { headers });
43 | }
44 |
45 | const {
46 | data: rawPosts,
47 | limit,
48 | totalPages,
49 | } = await getPostsForUser({
50 | dbClient: supabase,
51 | userId: profile.id,
52 | page,
53 | });
54 |
55 | const sessionUserId = session.user.id;
56 | const posts = combinePostsWithLikes(rawPosts, sessionUserId);
57 |
58 | return json(
59 | {
60 | profile,
61 | sessionUserId: session.user.id,
62 | posts,
63 | limit,
64 | totalPages,
65 | },
66 | { headers }
67 | );
68 | };
69 |
70 | export default function Profile() {
71 | const {
72 | profile: { avatar_url, name, username },
73 | sessionUserId,
74 | posts,
75 | totalPages,
76 | } = useLoaderData();
77 |
78 | return (
79 |
80 |
81 |
82 |
83 |
84 |
85 |
{name}
86 |
87 |
@{username}
88 |
89 |
90 |
91 |
92 |
93 |
{"User posts"}
94 |
95 |
101 |
102 | );
103 | }
104 |
105 | export function shouldRevalidate({
106 | actionResult,
107 | defaultShouldRevalidate,
108 | }: ShouldRevalidateFunctionArgs) {
109 | const skipRevalidation =
110 | actionResult?.skipRevalidation &&
111 | actionResult?.skipRevalidation?.includes("/profile.$username");
112 |
113 | if (skipRevalidation) {
114 | return false;
115 | }
116 |
117 | return defaultShouldRevalidate;
118 | }
119 |
--------------------------------------------------------------------------------
/app/routes/_home.tsx:
--------------------------------------------------------------------------------
1 | import { HamburgerMenuIcon, Cross2Icon } from "@radix-ui/react-icons";
2 | import { redirect } from "@remix-run/node";
3 | import { type LoaderFunctionArgs, json } from "@remix-run/node";
4 | import {
5 | useLoaderData,
6 | Outlet,
7 | useOutletContext,
8 | Link,
9 | } from "@remix-run/react";
10 | import { Logout } from "~/routes/stateful/logout";
11 | import type { SupabaseOutletContext } from "~/lib/supabase";
12 | import { getSupabaseWithSessionHeaders } from "~/lib/supabase.server";
13 | import { getUserDataFromSession } from "~/lib/utils";
14 | import { useState } from "react";
15 | import { AppLogo } from "~/components/app-logo";
16 | import { ThemeToggle } from "./resources.theme-toggle";
17 | import { Avatar, AvatarImage } from "@radix-ui/react-avatar";
18 |
19 | export let loader = async ({ request }: LoaderFunctionArgs) => {
20 | const { headers, session } = await getSupabaseWithSessionHeaders({
21 | request,
22 | });
23 |
24 | if (!session) {
25 | return redirect("/login", { headers });
26 | }
27 |
28 | const { userId, userAvatarUrl, username } = getUserDataFromSession(session);
29 |
30 | return json(
31 | { userDetails: { userId, userAvatarUrl, username } },
32 | { headers }
33 | );
34 | };
35 |
36 | export default function Index() {
37 | const {
38 | userDetails: { userAvatarUrl, username },
39 | } = useLoaderData();
40 | const { supabase } = useOutletContext();
41 | const [isNavOpen, setNavOpen] = useState(false);
42 |
43 | return (
44 |
45 |
46 |
47 |
48 | Gitposter
49 |
50 | setNavOpen(!isNavOpen)} className="md:hidden">
51 | {isNavOpen ? : }
52 |
53 |
60 |
61 | @{username}
62 |
63 |
64 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 | );
77 | }
78 |
--------------------------------------------------------------------------------
/app/routes/_index.tsx:
--------------------------------------------------------------------------------
1 | import type { LoaderFunctionArgs } from '@remix-run/node';
2 | import { redirect, json } from '@remix-run/node';
3 | import { Link } from '@remix-run/react';
4 | import { AppLogo } from '~/components/app-logo';
5 | import { Button } from '~/components/ui/button';
6 | import { Card, CardContent } from '~/components/ui/card';
7 | import { getSupabaseWithSessionHeaders } from '~/lib/supabase.server';
8 | import { ThemeToggle } from './resources.theme-toggle';
9 |
10 | export let loader = async ({ request }: LoaderFunctionArgs) => {
11 | const { headers, session } = await getSupabaseWithSessionHeaders({
12 | request,
13 | });
14 |
15 | console.log('Session: ', session);
16 |
17 | if (session) {
18 | return redirect('/gitposts', { headers });
19 | }
20 |
21 | return json({ success: true }, { headers });
22 | };
23 |
24 | export default function Index() {
25 | return (
26 |
27 |
28 |
29 |
30 | Gitposter
31 |
32 |
33 |
34 |
35 |
36 |
37 | A{' '}
38 |
39 | Community-Driven
40 | {' '}
41 | Minimalist Social Platform for Coders
42 |
43 |
44 |
45 | Powered by{' '}
46 | Remix and{' '}
47 | Supabase
48 |
49 |
50 |
51 | Join our Community
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 | );
64 | }
65 |
--------------------------------------------------------------------------------
/app/routes/login.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from '@remix-run/react';
2 | import { AppLogo } from '~/components/app-logo';
3 | import { Card, CardContent } from '~/components/ui/card';
4 | import { getSupabaseWithSessionHeaders } from '~/lib/supabase.server';
5 | import type { LoaderFunctionArgs } from '@remix-run/node';
6 | import { json, redirect } from '@remix-run/node';
7 | import { Login as GithubLogin } from './stateful/oauth-login';
8 | import { ThemeToggle } from './resources.theme-toggle';
9 | import { Label } from '~/components/ui/label';
10 | import { Input } from '~/components/ui/input';
11 | import { Button } from '~/components/ui/button';
12 | import { AuthForm } from '~/routes/stateful/auth-form';
13 |
14 | export let loader = async ({ request }: LoaderFunctionArgs) => {
15 | const { headers, session } = await getSupabaseWithSessionHeaders({
16 | request,
17 | });
18 |
19 | if (session) {
20 | return redirect('/gitposts', { headers });
21 | }
22 |
23 | return json({ success: true }, { headers });
24 | };
25 |
26 | export default function Login() {
27 | return (
28 |
29 |
30 |
31 |
32 | Gitposter
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 | Login
43 | {' '}
44 | and discover more
45 |
46 |
47 |
48 | Our posts and comments are powered by Markdown
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 | Or continue with
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 | );
71 | }
72 |
--------------------------------------------------------------------------------
/app/routes/resources.auth.callback.tsx:
--------------------------------------------------------------------------------
1 | import { redirect, type LoaderFunctionArgs } from '@remix-run/node';
2 | import { getSupabaseWithHeaders } from '~/lib/supabase.server';
3 |
4 | export async function loader({ request }: LoaderFunctionArgs) {
5 | const requestUrl = new URL(request.url);
6 | const code = requestUrl.searchParams.get('code');
7 | const next = requestUrl.searchParams.get('next') || '/';
8 |
9 | if (code) {
10 | const { headers, supabase } = getSupabaseWithHeaders({
11 | request,
12 | });
13 |
14 | const { error } = await supabase.auth.exchangeCodeForSession(code);
15 |
16 | console.log('Error: auth callback ', error);
17 |
18 | if (!error) {
19 | return redirect(next, { headers });
20 | }
21 | }
22 |
23 | // return the user to an error page with instructions
24 | return redirect('/login');
25 | }
26 |
--------------------------------------------------------------------------------
/app/routes/resources.auth.confirm-email.tsx:
--------------------------------------------------------------------------------
1 | import { redirect, type LoaderFunctionArgs } from '@remix-run/node';
2 | import { EmailOtpType } from '@supabase/supabase-js';
3 | import { getSupabaseWithHeaders } from '~/lib/supabase.server';
4 |
5 | export async function loader({ request }: LoaderFunctionArgs) {
6 | const requestUrl = new URL(request.url);
7 | const token_hash = requestUrl.searchParams.get('token_hash');
8 | const type = requestUrl.searchParams.get('type') as EmailOtpType | null;
9 | const next = requestUrl.searchParams.get('next') || '/';
10 | const headers = new Headers();
11 |
12 | if (token_hash && type) {
13 | const { headers, supabase } = getSupabaseWithHeaders({
14 | request,
15 | });
16 |
17 | const { error } = await supabase.auth.verifyOtp({
18 | type,
19 | token_hash,
20 | });
21 |
22 | console.log('Error: auth confirm email ', error);
23 |
24 | if (!error) {
25 | return redirect(next, { headers });
26 | }
27 | }
28 |
29 | // return the user to an error page with instructions
30 | return redirect('/login');
31 | }
32 |
--------------------------------------------------------------------------------
/app/routes/resources.comment.tsx:
--------------------------------------------------------------------------------
1 | import type { ActionFunctionArgs } from "@remix-run/node";
2 | import { json, redirect } from "@remix-run/node";
3 | import { getSupabaseWithSessionHeaders } from "~/lib/supabase.server";
4 |
5 | export async function action({ request }: ActionFunctionArgs) {
6 | const { supabase, headers, session } = await getSupabaseWithSessionHeaders({
7 | request,
8 | });
9 |
10 | if (!session) {
11 | return redirect("/login", { headers });
12 | }
13 |
14 | const formData = await request.formData();
15 | const title = formData.get("title")?.toString();
16 | const userId = formData.get("userId")?.toString();
17 | const postId = formData.get("postId")?.toString();
18 |
19 | // A skipRevalidation of the routes you want during this action
20 | const skipRevalidation = ["/gitposts", "/profile.$username"];
21 |
22 | // Check if userId and tweetId are present
23 | if (!userId || !title || !postId) {
24 | return json(
25 | { error: "Post/user information missing", skipRevalidation },
26 | { status: 400, headers }
27 | );
28 | }
29 |
30 | const { error } = await supabase
31 | .from("comments")
32 | .insert({ user_id: userId, title, post_id: postId });
33 |
34 | if (error) {
35 | console.log("Error occured ", error);
36 | return json(
37 | { error: "Failed to post", skipRevalidation },
38 | { status: 500, headers }
39 | );
40 | }
41 |
42 | return json({ ok: true, error: null, skipRevalidation }, { headers });
43 | }
44 |
--------------------------------------------------------------------------------
/app/routes/resources.like.tsx:
--------------------------------------------------------------------------------
1 | import type { ActionFunctionArgs } from "@remix-run/node";
2 | import { json, redirect } from "@remix-run/node";
3 | import { useEffect } from "react";
4 | import { useToast } from "~/components/ui/use-toast";
5 | import { getSupabaseWithSessionHeaders } from "~/lib/supabase.server";
6 | import { useFetcher } from "@remix-run/react";
7 | import { Star } from "lucide-react";
8 | // import { loader } from "../_home+/gitposts.$postId";
9 |
10 | export async function action({ request }: ActionFunctionArgs) {
11 | const { supabase, headers, session } = await getSupabaseWithSessionHeaders({
12 | request,
13 | });
14 |
15 | if (!session) {
16 | return redirect("/login", { headers });
17 | }
18 |
19 | const formData = await request.formData();
20 | const action = formData.get("action");
21 | const postId = formData.get("postId")?.toString();
22 | const userId = formData.get("userId")?.toString();
23 |
24 | // A skipRevalidation of the routes you want during this action
25 | const skipRevalidation = ["/gitposts", "/profile.$username"];
26 |
27 | if (!userId || !postId) {
28 | return json(
29 | { error: "User or Tweet Id missing", skipRevalidation },
30 | { status: 400, headers }
31 | );
32 | }
33 |
34 | if (action === "like") {
35 | // Created CREATE UNIQUE INDEX post_user ON likes(tweet_id, user_id);
36 | const { error } = await supabase
37 | .from("likes")
38 | .insert({ user_id: userId, post_id: postId });
39 |
40 | if (error) {
41 | return json(
42 | { error: "Failed to like", skipRevalidation },
43 | { status: 500, headers }
44 | );
45 | }
46 | } else {
47 | const { error } = await supabase
48 | .from("likes")
49 | .delete()
50 | .match({ user_id: userId, post_id: postId });
51 |
52 | if (error) {
53 | return json(
54 | { error: "Failed to unlike", skipRevalidation },
55 | { status: 500, headers }
56 | );
57 | }
58 | }
59 |
60 | return json({ ok: true, error: null, skipRevalidation }, { headers });
61 | }
62 |
63 | type LikeProps = {
64 | likedByUser: boolean;
65 | likes: number;
66 | postId: string;
67 | sessionUserId: string;
68 | };
69 |
70 | export function Like({ likedByUser, likes, postId, sessionUserId }: LikeProps) {
71 | const { toast } = useToast();
72 | const fetcher = useFetcher();
73 |
74 | const inFlightAction = fetcher.formData?.get("action");
75 | const isLoading = fetcher.state !== "idle";
76 |
77 | const optimisticLikedByUser = inFlightAction
78 | ? inFlightAction === "like"
79 | : likedByUser;
80 | const optimisticLikes = inFlightAction
81 | ? inFlightAction === "like"
82 | ? likes + 1
83 | : likes - 1
84 | : likes;
85 |
86 | // Show toast in error scenarios
87 | useEffect(() => {
88 | if (fetcher.data?.error && !isLoading) {
89 | toast({
90 | variant: "destructive",
91 | description: `Error occured: ${fetcher.data?.error}`,
92 | });
93 | } else {
94 | console.log("Fetcher data returned ", fetcher.data);
95 | }
96 | }, [fetcher.data, isLoading, toast]);
97 |
98 | return (
99 |
100 |
101 |
102 |
107 |
111 | {optimisticLikedByUser ? (
112 |
119 | ) : (
120 |
127 | )}
128 |
133 | {optimisticLikes}
134 |
135 |
136 |
137 | );
138 | }
139 |
--------------------------------------------------------------------------------
/app/routes/resources.likes.$postId.tsx:
--------------------------------------------------------------------------------
1 | import type { LoaderFunctionArgs } from "@remix-run/node";
2 | import { redirect, json } from "@remix-run/node";
3 | import { getSupabaseWithSessionHeaders } from "~/lib/supabase.server";
4 |
5 | export let loader = async ({ request, params }: LoaderFunctionArgs) => {
6 | const { postId } = params;
7 | const { supabase, headers, session } = await getSupabaseWithSessionHeaders({
8 | request,
9 | });
10 |
11 | if (!session) {
12 | return redirect("/login", { headers });
13 | }
14 |
15 | if (!postId) {
16 | return json({ error: "PostId is missing" }, { status: 400, headers });
17 | }
18 |
19 | const { count, error: countError } = await supabase
20 | .from("likes")
21 | .select("*", { count: "exact" })
22 | .eq("post_id", postId);
23 |
24 | if (countError) {
25 | return json(
26 | { error: "Failed to get recent likes" },
27 | { status: 500, headers }
28 | );
29 | }
30 |
31 | return json({ ok: true, error: null, count }, { headers });
32 | };
33 |
--------------------------------------------------------------------------------
/app/routes/resources.post.tsx:
--------------------------------------------------------------------------------
1 | import type { ActionFunctionArgs } from "@remix-run/node";
2 | import { json, redirect } from "@remix-run/node";
3 | import { getSupabaseWithSessionHeaders } from "~/lib/supabase.server";
4 |
5 | export async function action({ request }: ActionFunctionArgs) {
6 | const { supabase, headers, session } = await getSupabaseWithSessionHeaders({
7 | request,
8 | });
9 |
10 | if (!session) {
11 | return redirect("/login", { headers });
12 | }
13 |
14 | const formData = await request.formData();
15 | const title = formData.get("title")?.toString();
16 | const userId = formData.get("userId")?.toString();
17 |
18 | // Check if userId and tweetId are present
19 | if (!userId || !title) {
20 | return json(
21 | { error: "Post/user information missing" },
22 | { status: 400, headers }
23 | );
24 | }
25 |
26 | const { error } = await supabase
27 | .from("posts")
28 | .insert({ user_id: userId, title });
29 |
30 | if (error) {
31 | console.log("Error occured ", error);
32 | return json({ error: "Failed to post" }, { status: 500, headers });
33 | }
34 |
35 | return json({ ok: true, error: null }, { headers });
36 | }
37 |
--------------------------------------------------------------------------------
/app/routes/resources.theme-toggle.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Moon, Sun } from "lucide-react";
3 |
4 | import { Button } from "~/components/ui/button";
5 | import {
6 | DropdownMenu,
7 | DropdownMenuContent,
8 | DropdownMenuItem,
9 | DropdownMenuTrigger,
10 | } from "~/components/ui/dropdown-menu";
11 | import { useFetcher, useFetchers } from "@remix-run/react";
12 | import type { ActionFunction } from "@remix-run/node";
13 | import { json } from "@remix-run/node";
14 | import { setTheme } from "~/lib/theme.server";
15 |
16 | enum Theme {
17 | DARK = "dark",
18 | LIGHT = "light",
19 | SYSTEM = "system",
20 | }
21 |
22 | export const themes: Array = Object.values(Theme);
23 |
24 | function isTheme(value: unknown): value is Theme {
25 | return typeof value === "string" && themes.includes(value as Theme);
26 | }
27 |
28 | export const action: ActionFunction = async ({ request }) => {
29 | const requestText = await request.text();
30 | const form = new URLSearchParams(requestText);
31 | const theme = form.get("theme");
32 |
33 | if (!isTheme(theme)) {
34 | return json({
35 | success: false,
36 | message: `theme value of ${theme} is not a valid theme`,
37 | });
38 | }
39 |
40 | return json(
41 | { success: true },
42 | {
43 | headers: {
44 | "Set-Cookie": setTheme(theme),
45 | },
46 | }
47 | );
48 | };
49 |
50 | export function useOptimisticTheme(): Theme | null {
51 | const fetchers = useFetchers();
52 | const themeFetcher = fetchers.find(
53 | (f) => f.formAction === "/resources/theme-toggle"
54 | );
55 |
56 | const optimisticTheme = themeFetcher?.formData?.get("theme");
57 |
58 | if (optimisticTheme && isTheme(optimisticTheme)) {
59 | return optimisticTheme;
60 | }
61 |
62 | return null;
63 | }
64 |
65 | export function ThemeToggle() {
66 | const fetcher = useFetcher();
67 |
68 | const handleThemeChange = (theme: Theme) => {
69 | fetcher.submit(
70 | { theme },
71 | { method: "post", action: "/resources/theme-toggle" }
72 | );
73 | };
74 |
75 | return (
76 |
77 |
78 |
79 |
80 |
81 | Toggle theme
82 |
83 |
84 |
85 | handleThemeChange(Theme.LIGHT)}>
86 | Light
87 |
88 | handleThemeChange(Theme.DARK)}>
89 | Dark
90 |
91 | handleThemeChange(Theme.SYSTEM)}>
92 | System
93 |
94 |
95 |
96 | );
97 | }
98 |
--------------------------------------------------------------------------------
/app/routes/stateful/infinite-virtual-list.tsx:
--------------------------------------------------------------------------------
1 | import { type CombinedPostsWithAuthorAndLikes } from "~/lib/types";
2 | import type { SupabaseOutletContext } from "~/lib/supabase";
3 | import { useRevalidator, useOutletContext } from "@remix-run/react";
4 | import { useEffect, useState } from "react";
5 | import { PostSkeleton } from "../../components/post";
6 | import { Virtuoso, LogLevel } from "react-virtuoso";
7 | import { MemoizedPostListItem } from "../../components/memoized-post-list-item";
8 | import { useInfinitePosts } from "./use-infinite-posts";
9 | import { AppLogo } from "~/components/app-logo";
10 | import { Button } from "~/components/ui/button";
11 | import { BellPlus } from "lucide-react";
12 |
13 | export function InfiniteVirtualList({
14 | sessionUserId,
15 | totalPages,
16 | posts: incomingPosts,
17 | isProfile,
18 | }: {
19 | sessionUserId: string;
20 | totalPages: number;
21 | posts: CombinedPostsWithAuthorAndLikes;
22 | isProfile?: boolean;
23 | }) {
24 | const { supabase } = useOutletContext();
25 | const [showNewPosts, setShowNewPosts] = useState(false);
26 | const revalidator = useRevalidator();
27 | const postRouteId = isProfile
28 | ? "routes/_home.profile.$username.$postId"
29 | : "routes/_home.gitposts.$postId";
30 | const { posts, loadMore, hasMorePages } = useInfinitePosts({
31 | incomingPosts,
32 | totalPages,
33 | postRouteId,
34 | });
35 |
36 | useEffect(() => {
37 | const channel = supabase
38 | .channel("posts")
39 | .on(
40 | "postgres_changes",
41 | {
42 | event: "INSERT",
43 | schema: "public",
44 | table: "posts",
45 | },
46 | () => {
47 | setShowNewPosts(true);
48 | }
49 | )
50 | .subscribe();
51 |
52 | return () => {
53 | channel.unsubscribe();
54 | };
55 | }, [supabase]);
56 |
57 | console.log("Posts ", posts);
58 |
59 | if (!posts.length) {
60 | return (
61 |
62 |
63 |
No posts found !!
64 |
65 | );
66 | }
67 |
68 | return (
69 |
70 | {showNewPosts ? (
71 |
{
74 | setShowNewPosts(false);
75 | document.body.scrollTop = 0;
76 | document.documentElement.scrollTop = 0;
77 | revalidator.revalidate();
78 | }}
79 | >
80 |
81 | New Posts
82 |
83 | ) : null}
84 |
{
93 | if (!post) {
94 | return
;
95 | }
96 |
97 | return (
98 |
103 | );
104 | }}
105 | components={{
106 | Footer: () => {
107 | if (!hasMorePages) {
108 | return null;
109 | }
110 |
111 | return ;
112 | },
113 | }}
114 | />
115 |
116 | );
117 | }
118 |
--------------------------------------------------------------------------------
/app/routes/stateful/logout.tsx:
--------------------------------------------------------------------------------
1 | import { useOutletContext } from "@remix-run/react";
2 | import { Button } from "../../components/ui/button";
3 | import type { SupabaseOutletContext } from "~/lib/supabase";
4 |
5 | export function Logout() {
6 | const { supabase } = useOutletContext();
7 |
8 | const handleSignOut = async () => {
9 | await supabase.auth.signOut();
10 | };
11 |
12 | return Logout ;
13 | }
14 |
--------------------------------------------------------------------------------
/app/routes/stateful/oauth-login.tsx:
--------------------------------------------------------------------------------
1 | import type { SupabaseOutletContext } from '~/lib/supabase';
2 | import { Button } from '../../components/ui/button';
3 | import { Github, Loader2 } from 'lucide-react';
4 | import { useOutletContext } from '@remix-run/react';
5 | import { useToast } from '~/components/ui/use-toast';
6 |
7 | export function Login() {
8 | const { supabase, domainUrl } = useOutletContext();
9 | const { toast } = useToast();
10 |
11 | const handleSignIn = async () => {
12 | const { error } = await supabase.auth.signInWithOAuth({
13 | provider: 'github',
14 | options: {
15 | redirectTo: `${domainUrl}/resources/auth/callback`,
16 | },
17 | });
18 |
19 | if (error) {
20 | console.log('Sign in ', error);
21 | toast({
22 | variant: 'destructive',
23 | description: `Error occured: ${error}`,
24 | });
25 | }
26 | };
27 |
28 | return (
29 |
30 |
31 | Login with Github
32 |
33 |
34 | );
35 | }
36 |
--------------------------------------------------------------------------------
/app/routes/stateful/use-infinite-posts.ts:
--------------------------------------------------------------------------------
1 | import { useFetcher, useLocation, useRouteLoaderData } from "@remix-run/react";
2 | import { useState, useEffect } from "react";
3 | import type { CombinedPostsWithAuthorAndLikes } from "~/lib/types";
4 | import type { loader as postsLoader } from "../_home.gitposts";
5 | import type { loader as postLoader } from "../_home.gitposts.$postId";
6 |
7 | type UseInfinitePosts = {
8 | incomingPosts: CombinedPostsWithAuthorAndLikes;
9 | postRouteId: string; // Will be used to fetch updated post
10 | totalPages: number;
11 | };
12 |
13 | export const useInfinitePosts = ({
14 | incomingPosts,
15 | postRouteId,
16 | totalPages,
17 | }: UseInfinitePosts) => {
18 | const [posts, setPosts] =
19 | useState(incomingPosts);
20 | const fetcher = useFetcher();
21 | const [currentPage, setCurrentPage] = useState(1);
22 | const location = useLocation();
23 |
24 | // Use this route-data to update the list
25 | const data = useRouteLoaderData(postRouteId);
26 |
27 | // Since this list if read-only and doesn't revalidate
28 | // when there is an update on an individual post we update
29 | // the individual posts with new updated values.
30 | useEffect(() => {
31 | const updatedPost = data?.post;
32 |
33 | if (updatedPost) {
34 | const updatedComments = updatedPost.comments.map(
35 | ({ author, ...comment }) => comment
36 | );
37 |
38 | setPosts((posts) =>
39 | posts.map((post) =>
40 | post.id === updatedPost.id
41 | ? { ...updatedPost, comments: updatedComments }
42 | : post
43 | )
44 | );
45 | }
46 | }, [data]);
47 |
48 | // When user is searching then clear the list and only load
49 | // posts that are relevant to the searchQuery.
50 | const [prevPosts, setPrevPosts] = useState(incomingPosts);
51 | if (incomingPosts !== prevPosts) {
52 | setPrevPosts(incomingPosts);
53 | setPosts(incomingPosts);
54 | setCurrentPage(1);
55 | }
56 |
57 | const hasMorePages = currentPage < totalPages;
58 |
59 | const loadMore = () => {
60 | if (hasMorePages && fetcher.state === "idle") {
61 | let fullSearchQueryParams = "";
62 | if (location.search) {
63 | fullSearchQueryParams = `${location.search}&page=${currentPage + 1}`;
64 | } else {
65 | fullSearchQueryParams = `?page=${currentPage + 1}`;
66 | }
67 | fetcher.load(`${location.pathname}/${fullSearchQueryParams}`);
68 | }
69 | };
70 |
71 | useEffect(() => {
72 | if (fetcher.data?.posts) {
73 | setPosts((prevPosts) => {
74 | // // Check if any of the new posts already exist in the current posts
75 | // // Assumes all posts have a unique uuid
76 | // const hasDuplicates =
77 | // fetcher.data &&
78 | // fetcher.data.posts.some((newPost) =>
79 | // prevPosts.some((existingPost) => existingPost.id === newPost.id)
80 | // );
81 |
82 | // // If duplicates are found, return the current posts without change
83 | // if (hasDuplicates) {
84 | // return prevPosts;
85 | // }
86 |
87 | // If no duplicates, concatenate the new posts
88 | return [...prevPosts, ...(fetcher.data?.posts || [])];
89 | });
90 | setCurrentPage((currentPage) => currentPage + 1);
91 | }
92 | }, [fetcher.data]);
93 |
94 | return { posts, loadMore, hasMorePages };
95 | };
96 |
--------------------------------------------------------------------------------
/app/tailwind.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | :root {
7 | --background: 0 0% 100%;
8 | --foreground: 240 10% 3.9%;
9 |
10 | --card: 0 0% 100%;
11 | --card-foreground: 240 10% 3.9%;
12 |
13 | --popover: 0 0% 100%;
14 | --popover-foreground: 240 10% 3.9%;
15 |
16 | --primary: 240 5.9% 10%;
17 | --primary-foreground: 0 0% 98%;
18 |
19 | --secondary: 240 4.8% 95.9%;
20 | --secondary-foreground: 240 5.9% 10%;
21 |
22 | --muted: 240 4.8% 95.9%;
23 | --muted-foreground: 240 3.8% 46.1%;
24 |
25 | --accent: 240 4.8% 95.9%;
26 | --accent-foreground: 240 5.9% 10%;
27 |
28 | --destructive: 0 84.2% 60.2%;
29 | --destructive-foreground: 0 0% 98%;
30 |
31 | --border: 240 5.9% 90%;
32 | --input: 240 5.9% 90%;
33 | --ring: 240 10% 3.9%;
34 |
35 | --radius: 0.5rem;
36 | }
37 |
38 | .dark {
39 | --background: 240 10% 3.9%;
40 | --foreground: 0 0% 98%;
41 |
42 | --card: 240 10% 3.9%;
43 | --card-foreground: 0 0% 98%;
44 |
45 | --popover: 240 10% 3.9%;
46 | --popover-foreground: 0 0% 98%;
47 |
48 | --primary: 0 0% 98%;
49 | --primary-foreground: 240 5.9% 10%;
50 |
51 | --secondary: 240 3.7% 15.9%;
52 | --secondary-foreground: 0 0% 98%;
53 |
54 | --muted: 240 3.7% 15.9%;
55 | --muted-foreground: 240 5% 64.9%;
56 |
57 | --accent: 240 3.7% 15.9%;
58 | --accent-foreground: 0 0% 98%;
59 |
60 | --destructive: 0 62.8% 30.6%;
61 | --destructive-foreground: 0 0% 98%;
62 |
63 | --border: 240 3.7% 15.9%;
64 | --input: 240 3.7% 15.9%;
65 | --ring: 240 4.9% 83.9%;
66 | }
67 | }
68 |
69 | @layer base {
70 | * {
71 | @apply border-border;
72 | }
73 | body {
74 | @apply bg-background text-foreground;
75 | }
76 | }
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "new-york",
4 | "rsc": false,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "taiwind.config.ts",
8 | "css": "app/tailwind.css",
9 | "baseColor": "zinc",
10 | "cssVariables": true
11 | },
12 | "aliases": {
13 | "components": "~/components",
14 | "utils": "~/lib/utils"
15 | }
16 | }
--------------------------------------------------------------------------------
/database.types.ts:
--------------------------------------------------------------------------------
1 | export type Json =
2 | | string
3 | | number
4 | | boolean
5 | | null
6 | | { [key: string]: Json | undefined }
7 | | Json[]
8 |
9 | export interface Database {
10 | public: {
11 | Tables: {
12 | comments: {
13 | Row: {
14 | created_at: string
15 | id: string
16 | post_id: string
17 | title: string
18 | user_id: string
19 | }
20 | Insert: {
21 | created_at?: string
22 | id?: string
23 | post_id: string
24 | title: string
25 | user_id: string
26 | }
27 | Update: {
28 | created_at?: string
29 | id?: string
30 | post_id?: string
31 | title?: string
32 | user_id?: string
33 | }
34 | Relationships: [
35 | {
36 | foreignKeyName: "comments_post_id_fkey"
37 | columns: ["post_id"]
38 | isOneToOne: false
39 | referencedRelation: "posts"
40 | referencedColumns: ["id"]
41 | },
42 | {
43 | foreignKeyName: "comments_user_id_fkey"
44 | columns: ["user_id"]
45 | isOneToOne: false
46 | referencedRelation: "profiles"
47 | referencedColumns: ["id"]
48 | }
49 | ]
50 | }
51 | likes: {
52 | Row: {
53 | created_at: string
54 | id: string
55 | post_id: string
56 | user_id: string
57 | }
58 | Insert: {
59 | created_at?: string
60 | id?: string
61 | post_id: string
62 | user_id: string
63 | }
64 | Update: {
65 | created_at?: string
66 | id?: string
67 | post_id?: string
68 | user_id?: string
69 | }
70 | Relationships: [
71 | {
72 | foreignKeyName: "likes_post_id_fkey"
73 | columns: ["post_id"]
74 | isOneToOne: false
75 | referencedRelation: "posts"
76 | referencedColumns: ["id"]
77 | },
78 | {
79 | foreignKeyName: "likes_user_id_fkey"
80 | columns: ["user_id"]
81 | isOneToOne: false
82 | referencedRelation: "profiles"
83 | referencedColumns: ["id"]
84 | }
85 | ]
86 | }
87 | posts: {
88 | Row: {
89 | created_at: string
90 | id: string
91 | title: string
92 | user_id: string
93 | }
94 | Insert: {
95 | created_at?: string
96 | id?: string
97 | title: string
98 | user_id: string
99 | }
100 | Update: {
101 | created_at?: string
102 | id?: string
103 | title?: string
104 | user_id?: string
105 | }
106 | Relationships: [
107 | {
108 | foreignKeyName: "posts_user_id_fkey"
109 | columns: ["user_id"]
110 | isOneToOne: false
111 | referencedRelation: "profiles"
112 | referencedColumns: ["id"]
113 | }
114 | ]
115 | }
116 | profiles: {
117 | Row: {
118 | avatar_url: string
119 | created_at: string
120 | id: string
121 | name: string
122 | username: string
123 | }
124 | Insert: {
125 | avatar_url: string
126 | created_at?: string
127 | id?: string
128 | name: string
129 | username: string
130 | }
131 | Update: {
132 | avatar_url?: string
133 | created_at?: string
134 | id?: string
135 | name?: string
136 | username?: string
137 | }
138 | Relationships: []
139 | }
140 | }
141 | Views: {
142 | [_ in never]: never
143 | }
144 | Functions: {
145 | [_ in never]: never
146 | }
147 | Enums: {
148 | [_ in never]: never
149 | }
150 | CompositeTypes: {
151 | [_ in never]: never
152 | }
153 | }
154 | }
155 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "remix-supabase-course",
3 | "private": true,
4 | "sideEffects": false,
5 | "type": "module",
6 | "scripts": {
7 | "build": "remix build",
8 | "dev": "remix dev --manual",
9 | "start": "remix-serve ./build/index.js",
10 | "typecheck": "tsc"
11 | },
12 | "dependencies": {
13 | "@epic-web/client-hints": "^1.2.2",
14 | "@radix-ui/react-avatar": "^1.0.4",
15 | "@radix-ui/react-dialog": "^1.0.5",
16 | "@radix-ui/react-dropdown-menu": "^2.0.6",
17 | "@radix-ui/react-icons": "^1.3.0",
18 | "@radix-ui/react-label": "^2.0.2",
19 | "@radix-ui/react-separator": "^1.0.3",
20 | "@radix-ui/react-slot": "^1.0.2",
21 | "@radix-ui/react-tabs": "^1.0.4",
22 | "@radix-ui/react-toast": "^1.1.5",
23 | "@remix-run/css-bundle": "^2.2.0",
24 | "@remix-run/node": "^2.2.0",
25 | "@remix-run/react": "^2.2.0",
26 | "@remix-run/serve": "^2.2.0",
27 | "@supabase/auth-helpers-react": "^0.4.2",
28 | "@supabase/ssr": "^0.1.0",
29 | "@supabase/supabase-js": "^2.38.4",
30 | "@tanstack/react-query": "^5.8.3",
31 | "class-variance-authority": "^0.7.0",
32 | "clsx": "^2.0.0",
33 | "isbot": "^3.6.8",
34 | "lodash.debounce": "^4.0.8",
35 | "lucide-react": "^0.292.0",
36 | "next-themes": "^0.2.1",
37 | "react": "^18.2.0",
38 | "react-dom": "^18.2.0",
39 | "react-infinite-scroller": "^1.2.6",
40 | "react-intersection-observer": "^9.5.3",
41 | "react-markdown": "^9.0.0",
42 | "react-virtuoso": "^4.6.2",
43 | "remark-gfm": "^4.0.0",
44 | "sonner": "^1.4.0",
45 | "tailwind-merge": "^2.0.0",
46 | "tailwindcss-animate": "^1.0.7"
47 | },
48 | "devDependencies": {
49 | "@remix-run/dev": "^2.2.0",
50 | "@remix-run/eslint-config": "^2.2.0",
51 | "@tailwindcss/typography": "^0.5.10",
52 | "@types/lodash.debounce": "^4.0.9",
53 | "@types/react": "^18.2.20",
54 | "@types/react-dom": "^18.2.7",
55 | "@types/react-infinite-scroller": "^1.2.5",
56 | "autoprefixer": "^10.4.16",
57 | "eslint": "^8.38.0",
58 | "remix-flat-routes": "^0.6.2",
59 | "supabase": "^1.110.1",
60 | "tailwindcss": "^3.3.5",
61 | "typescript": "^5.1.6"
62 | },
63 | "engines": {
64 | "node": ">=18.0.0"
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/postcss.config.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: [require("tailwindcss"), require("autoprefixer")],
3 | };
4 |
--------------------------------------------------------------------------------
/public/assets/images/like-posts.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rajeshdavidbabu/remix-supabase-social/0d18b473197f395a25310761e43283b189b845a9/public/assets/images/like-posts.png
--------------------------------------------------------------------------------
/public/assets/images/search-posts.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rajeshdavidbabu/remix-supabase-social/0d18b473197f395a25310761e43283b189b845a9/public/assets/images/search-posts.png
--------------------------------------------------------------------------------
/public/assets/images/view-profiles.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rajeshdavidbabu/remix-supabase-social/0d18b473197f395a25310761e43283b189b845a9/public/assets/images/view-profiles.png
--------------------------------------------------------------------------------
/public/assets/images/write-post.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rajeshdavidbabu/remix-supabase-social/0d18b473197f395a25310761e43283b189b845a9/public/assets/images/write-post.png
--------------------------------------------------------------------------------
/public/assets/videos/demo.mp4:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rajeshdavidbabu/remix-supabase-social/0d18b473197f395a25310761e43283b189b845a9/public/assets/videos/demo.mp4
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rajeshdavidbabu/remix-supabase-social/0d18b473197f395a25310761e43283b189b845a9/public/favicon.ico
--------------------------------------------------------------------------------
/remix.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('@remix-run/dev').AppConfig} */
2 | // import { flatRoutes } from "remix-flat-routes";
3 |
4 | export default {
5 | ignoredRouteFiles: ["**/.*"],
6 | // routes: async (defineRoutes) => {
7 | // return flatRoutes("routes", defineRoutes);
8 | // },
9 | // appDirectory: "app",
10 | // assetsBuildDirectory: "public/build",
11 | // publicPath: "/build/",
12 | // serverBuildPath: "build/index.js",
13 | tailwind: true,
14 | postcss: true,
15 | };
16 |
--------------------------------------------------------------------------------
/remix.env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | darkMode: ["class"],
4 | content: [
5 | "./pages/**/*.{ts,tsx}",
6 | "./components/**/*.{ts,tsx}",
7 | "./app/**/*.{ts,tsx}",
8 | "./src/**/*.{ts,tsx}",
9 | ],
10 | theme: {
11 | container: {
12 | center: true,
13 | padding: "2rem",
14 | screens: {
15 | "2xl": "1400px",
16 | },
17 | },
18 | extend: {
19 | colors: {
20 | border: "hsl(var(--border))",
21 | input: "hsl(var(--input))",
22 | ring: "hsl(var(--ring))",
23 | background: "hsl(var(--background))",
24 | foreground: "hsl(var(--foreground))",
25 | primary: {
26 | DEFAULT: "hsl(var(--primary))",
27 | foreground: "hsl(var(--primary-foreground))",
28 | },
29 | secondary: {
30 | DEFAULT: "hsl(var(--secondary))",
31 | foreground: "hsl(var(--secondary-foreground))",
32 | },
33 | destructive: {
34 | DEFAULT: "hsl(var(--destructive))",
35 | foreground: "hsl(var(--destructive-foreground))",
36 | },
37 | muted: {
38 | DEFAULT: "hsl(var(--muted))",
39 | foreground: "hsl(var(--muted-foreground))",
40 | },
41 | accent: {
42 | DEFAULT: "hsl(var(--accent))",
43 | foreground: "hsl(var(--accent-foreground))",
44 | },
45 | popover: {
46 | DEFAULT: "hsl(var(--popover))",
47 | foreground: "hsl(var(--popover-foreground))",
48 | },
49 | card: {
50 | DEFAULT: "hsl(var(--card))",
51 | foreground: "hsl(var(--card-foreground))",
52 | },
53 | },
54 | borderRadius: {
55 | lg: "var(--radius)",
56 | md: "calc(var(--radius) - 2px)",
57 | sm: "calc(var(--radius) - 4px)",
58 | },
59 | keyframes: {
60 | "accordion-down": {
61 | from: { height: 0 },
62 | to: { height: "var(--radix-accordion-content-height)" },
63 | },
64 | "accordion-up": {
65 | from: { height: "var(--radix-accordion-content-height)" },
66 | to: { height: 0 },
67 | },
68 | animatedgradient: {
69 | "0%": { backgroundPosition: "0% 50%" },
70 | "50%": { backgroundPosition: "100% 50%" },
71 | "100%": { backgroundPosition: "0% 50%" },
72 | },
73 | },
74 | animation: {
75 | "accordion-down": "accordion-down 0.2s ease-out",
76 | "accordion-up": "accordion-up 0.2s ease-out",
77 | gradient: "animatedgradient 6s ease infinite alternate",
78 | },
79 | backgroundSize: {
80 | "300%": "300%",
81 | },
82 | },
83 | },
84 | plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")],
85 | };
86 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"],
3 | "compilerOptions": {
4 | "lib": ["DOM", "DOM.Iterable", "ES2022"],
5 | "isolatedModules": true,
6 | "esModuleInterop": true,
7 | "jsx": "react-jsx",
8 | "moduleResolution": "Bundler",
9 | "resolveJsonModule": true,
10 | "target": "ES2022",
11 | "strict": true,
12 | "allowJs": true,
13 | "forceConsistentCasingInFileNames": true,
14 | "baseUrl": ".",
15 | "paths": {
16 | "~/*": ["./app/*"]
17 | },
18 |
19 | // Remix takes care of building everything in `remix build`.
20 | "noEmit": true
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/youtube course/.env.example:
--------------------------------------------------------------------------------
1 | SUPABASE_URL=https://snaigoiciwagpbhfzyhq.supabase.co
2 | SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InNuYWlnb2ljaXdhZ3BiaGZ6eWhxIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MDEwMDU2NzUsImV4cCI6MjAxNjU4MTY3NX0.AXhXiJ14eKxgzXQv_N949HKoFWCfmZ6quDuDOODZnMM
3 | DOMAIN_URL=http://localhost:3000
--------------------------------------------------------------------------------
/youtube course/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | /** @type {import('eslint').Linter.Config} */
2 | module.exports = {
3 | extends: ["@remix-run/eslint-config", "@remix-run/eslint-config/node"],
4 | };
5 |
--------------------------------------------------------------------------------
/youtube course/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 |
3 | /.cache
4 | /build
5 | /public/build
6 | .env
7 |
--------------------------------------------------------------------------------
/youtube course/README.md:
--------------------------------------------------------------------------------
1 | # Welcome to the course!
2 |
3 | This folder contains the course material used to build the YT video, and you can run this locally to test the material in the video.
4 |
5 | ## Before Getting started
6 |
7 | - The supabase project used to build this course are in .env.example file, so copy that and create a new .env file.
8 | - A Github OAuth App is already configured to login "http://localhost:3000" and for this specific Supabase project.
9 | - So you just have to run the project at localhost:3000 to login and use the app with our sample data.
10 |
11 | ## Getting started
12 |
13 | From your terminal:
14 |
15 | ```sh
16 | cd
17 | npm install
18 | npm run dev
19 | ```
20 |
--------------------------------------------------------------------------------
/youtube course/app/components/infinite-virtual-list.tsx:
--------------------------------------------------------------------------------
1 | import type { CombinedPostsWithAuthorAndLikes } from "~/lib/types";
2 | import { useInfinitePosts } from "./use-infinite-posts";
3 | import { Virtuoso } from "react-virtuoso";
4 | import { MemoizedPostListItem } from "./memoized-post-list-item";
5 | import { PostSkeleton } from "./post";
6 | import { AppLogo } from "./app-logo";
7 |
8 | export function InfiniteVirtualList({
9 | totalPages,
10 | incomingPosts,
11 | isProfile,
12 | }: {
13 | totalPages: number;
14 | incomingPosts: CombinedPostsWithAuthorAndLikes;
15 | isProfile?: boolean;
16 | }) {
17 | const postRouteId = isProfile
18 | ? "routes/_home.profile.$username.$postId"
19 | : "routes/_home.gitposts.$postId";
20 | const { posts, loadMore, hasMorePages } = useInfinitePosts({
21 | incomingPosts,
22 | totalPages,
23 | postRouteId,
24 | });
25 |
26 | if (!posts.length) {
27 | return (
28 |
29 |
30 |
No posts found!
31 |
32 | );
33 | }
34 |
35 | return (
36 | {
44 | if (!post) {
45 | return
;
46 | }
47 |
48 | return ;
49 | }}
50 | components={{
51 | Footer: () => {
52 | if (!hasMorePages) {
53 | return null;
54 | }
55 |
56 | return ;
57 | },
58 | }}
59 | >
60 | );
61 | }
62 |
--------------------------------------------------------------------------------
/youtube course/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 "./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 | }: {
14 | post: CombinedPostWithAuthorAndLikes;
15 | index: number;
16 | }) => {
17 | const location = useLocation();
18 | let pathnameWithSearchQuery = "";
19 |
20 | if (location.search) {
21 | pathnameWithSearchQuery = `${location.pathname}/${post.id}${location.search}`;
22 | } else {
23 | pathnameWithSearchQuery = `${location.pathname}/${post.id}`;
24 | }
25 |
26 | return (
27 |
36 |
41 |
45 |
46 | );
47 | }
48 | );
49 |
50 | MemoizedPostListItem.displayName = "MemoizedPostListItem";
51 |
--------------------------------------------------------------------------------
/youtube course/app/components/post-search.tsx:
--------------------------------------------------------------------------------
1 | import { Form, useSubmit } from "@remix-run/react";
2 | import { Loader2 } from "lucide-react";
3 | import { useEffect, useRef, useState } from "react";
4 | import { Input } from "./ui/input";
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 formRef = useRef(null);
16 | const timeoutRef = useRef();
17 |
18 | useEffect(() => {
19 | // Only cleanup required for the timeout
20 | return () => {
21 | if (timeoutRef.current) {
22 | clearTimeout(timeoutRef.current);
23 | }
24 | };
25 | }, [timeoutRef]);
26 |
27 | return (
28 |
29 |
30 | {query ? `Results for "${query}"` : "All posts"}
31 |
32 |
33 | {isSearching && }
34 |
35 |
63 |
64 | );
65 | }
66 |
--------------------------------------------------------------------------------
/youtube course/app/components/post.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from "@remix-run/react";
2 | import { AppLogo } from "./app-logo";
3 | import { Card } from "./ui/card";
4 | import ReactMarkdown from "react-markdown";
5 | import { Skeleton } from "./ui/skeleton";
6 |
7 | export type PostProps = {
8 | avatarUrl: string;
9 | name: string;
10 | id: string;
11 | username: string;
12 | title: string;
13 | dateTimeString: string;
14 | userId: string;
15 | children?: React.ReactNode;
16 | };
17 |
18 | export function Post({
19 | avatarUrl,
20 | name,
21 | username,
22 | title,
23 | dateTimeString,
24 | id,
25 | userId,
26 | children,
27 | }: PostProps) {
28 | return (
29 |
30 |
31 |
32 |
33 |
34 |
35 |
46 |
47 |
48 | {name}
49 |
50 |
51 | {name}
52 |
53 |
54 |
55 |
56 |
57 |
58 | {title}
59 |
60 |
61 |
{children}
62 |
{dateTimeString}
63 |
64 |
65 |
66 |
67 |
68 | );
69 | }
70 |
71 | export function PostSkeleton() {
72 | return (
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 | );
81 | }
82 |
--------------------------------------------------------------------------------
/youtube course/app/components/show-comment.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from "@remix-run/react";
2 | import { Avatar, AvatarImage } from "./ui/avatar";
3 | import ReactMarkdown from "react-markdown";
4 |
5 | type CommentProps = {
6 | avatarUrl: string;
7 | username: string;
8 | title: string;
9 | };
10 |
11 | export const ShowComment = ({ avatarUrl, username, title }: CommentProps) => {
12 | return (
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
{username}
21 |
22 |
23 |
24 |
25 | {title}
26 |
27 |
28 | );
29 | };
30 |
--------------------------------------------------------------------------------
/youtube course/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 |
--------------------------------------------------------------------------------
/youtube course/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 |
--------------------------------------------------------------------------------
/youtube course/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 |
--------------------------------------------------------------------------------
/youtube course/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, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
77 |
--------------------------------------------------------------------------------
/youtube course/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 |
--------------------------------------------------------------------------------
/youtube course/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 |
--------------------------------------------------------------------------------
/youtube course/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 |
--------------------------------------------------------------------------------
/youtube course/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 |
--------------------------------------------------------------------------------
/youtube course/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 |
--------------------------------------------------------------------------------
/youtube course/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 |
19 | )
20 | }
21 | )
22 | Textarea.displayName = "Textarea"
23 |
24 | export { Textarea }
25 |
--------------------------------------------------------------------------------
/youtube course/app/components/ui/toast.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Cross2Icon } from "@radix-ui/react-icons"
3 | import * as ToastPrimitives from "@radix-ui/react-toast"
4 | import { cva, type VariantProps } from "class-variance-authority"
5 |
6 | import { cn } from "~/lib/utils"
7 |
8 | const ToastProvider = ToastPrimitives.Provider
9 |
10 | const ToastViewport = React.forwardRef<
11 | React.ElementRef,
12 | React.ComponentPropsWithoutRef
13 | >(({ className, ...props }, ref) => (
14 |
22 | ))
23 | ToastViewport.displayName = ToastPrimitives.Viewport.displayName
24 |
25 | const toastVariants = cva(
26 | "group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
27 | {
28 | variants: {
29 | variant: {
30 | default: "border bg-background text-foreground",
31 | destructive:
32 | "destructive group border-destructive bg-destructive text-destructive-foreground",
33 | },
34 | },
35 | defaultVariants: {
36 | variant: "default",
37 | },
38 | }
39 | )
40 |
41 | const Toast = React.forwardRef<
42 | React.ElementRef,
43 | React.ComponentPropsWithoutRef &
44 | VariantProps
45 | >(({ className, variant, ...props }, ref) => {
46 | return (
47 |
52 | )
53 | })
54 | Toast.displayName = ToastPrimitives.Root.displayName
55 |
56 | const ToastAction = React.forwardRef<
57 | React.ElementRef,
58 | React.ComponentPropsWithoutRef
59 | >(({ className, ...props }, ref) => (
60 |
68 | ))
69 | ToastAction.displayName = ToastPrimitives.Action.displayName
70 |
71 | const ToastClose = React.forwardRef<
72 | React.ElementRef,
73 | React.ComponentPropsWithoutRef
74 | >(({ className, ...props }, ref) => (
75 |
84 |
85 |
86 | ))
87 | ToastClose.displayName = ToastPrimitives.Close.displayName
88 |
89 | const ToastTitle = React.forwardRef<
90 | React.ElementRef,
91 | React.ComponentPropsWithoutRef
92 | >(({ className, ...props }, ref) => (
93 |
98 | ))
99 | ToastTitle.displayName = ToastPrimitives.Title.displayName
100 |
101 | const ToastDescription = React.forwardRef<
102 | React.ElementRef,
103 | React.ComponentPropsWithoutRef
104 | >(({ className, ...props }, ref) => (
105 |
110 | ))
111 | ToastDescription.displayName = ToastPrimitives.Description.displayName
112 |
113 | type ToastProps = React.ComponentPropsWithoutRef
114 |
115 | type ToastActionElement = React.ReactElement
116 |
117 | export {
118 | type ToastProps,
119 | type ToastActionElement,
120 | ToastProvider,
121 | ToastViewport,
122 | Toast,
123 | ToastTitle,
124 | ToastDescription,
125 | ToastClose,
126 | ToastAction,
127 | }
128 |
--------------------------------------------------------------------------------
/youtube course/app/components/ui/toaster.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Toast,
3 | ToastClose,
4 | ToastDescription,
5 | ToastProvider,
6 | ToastTitle,
7 | ToastViewport,
8 | } from "~/components/ui/toast"
9 | import { useToast } from "~/components/ui/use-toast"
10 |
11 | export function Toaster() {
12 | const { toasts } = useToast()
13 |
14 | return (
15 |
16 | {toasts.map(function ({ id, title, description, action, ...props }) {
17 | return (
18 |
19 |
20 | {title && {title} }
21 | {description && (
22 | {description}
23 | )}
24 |
25 | {action}
26 |
27 |
28 | )
29 | })}
30 |
31 |
32 | )
33 | }
34 |
--------------------------------------------------------------------------------
/youtube course/app/components/ui/use-toast.ts:
--------------------------------------------------------------------------------
1 | // Inspired by react-hot-toast library
2 | import * as React from "react"
3 |
4 | import type {
5 | ToastActionElement,
6 | ToastProps,
7 | } from "~/components/ui/toast"
8 |
9 | const TOAST_LIMIT = 1
10 | const TOAST_REMOVE_DELAY = 1000000
11 |
12 | type ToasterToast = ToastProps & {
13 | id: string
14 | title?: React.ReactNode
15 | description?: React.ReactNode
16 | action?: ToastActionElement
17 | }
18 |
19 | const actionTypes = {
20 | ADD_TOAST: "ADD_TOAST",
21 | UPDATE_TOAST: "UPDATE_TOAST",
22 | DISMISS_TOAST: "DISMISS_TOAST",
23 | REMOVE_TOAST: "REMOVE_TOAST",
24 | } as const
25 |
26 | let count = 0
27 |
28 | function genId() {
29 | count = (count + 1) % Number.MAX_SAFE_INTEGER
30 | return count.toString()
31 | }
32 |
33 | type ActionType = typeof actionTypes
34 |
35 | type Action =
36 | | {
37 | type: ActionType["ADD_TOAST"]
38 | toast: ToasterToast
39 | }
40 | | {
41 | type: ActionType["UPDATE_TOAST"]
42 | toast: Partial
43 | }
44 | | {
45 | type: ActionType["DISMISS_TOAST"]
46 | toastId?: ToasterToast["id"]
47 | }
48 | | {
49 | type: ActionType["REMOVE_TOAST"]
50 | toastId?: ToasterToast["id"]
51 | }
52 |
53 | interface State {
54 | toasts: ToasterToast[]
55 | }
56 |
57 | const toastTimeouts = new Map>()
58 |
59 | const addToRemoveQueue = (toastId: string) => {
60 | if (toastTimeouts.has(toastId)) {
61 | return
62 | }
63 |
64 | const timeout = setTimeout(() => {
65 | toastTimeouts.delete(toastId)
66 | dispatch({
67 | type: "REMOVE_TOAST",
68 | toastId: toastId,
69 | })
70 | }, TOAST_REMOVE_DELAY)
71 |
72 | toastTimeouts.set(toastId, timeout)
73 | }
74 |
75 | export const reducer = (state: State, action: Action): State => {
76 | switch (action.type) {
77 | case "ADD_TOAST":
78 | return {
79 | ...state,
80 | toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
81 | }
82 |
83 | case "UPDATE_TOAST":
84 | return {
85 | ...state,
86 | toasts: state.toasts.map((t) =>
87 | t.id === action.toast.id ? { ...t, ...action.toast } : t
88 | ),
89 | }
90 |
91 | case "DISMISS_TOAST": {
92 | const { toastId } = action
93 |
94 | // ! Side effects ! - This could be extracted into a dismissToast() action,
95 | // but I'll keep it here for simplicity
96 | if (toastId) {
97 | addToRemoveQueue(toastId)
98 | } else {
99 | state.toasts.forEach((toast) => {
100 | addToRemoveQueue(toast.id)
101 | })
102 | }
103 |
104 | return {
105 | ...state,
106 | toasts: state.toasts.map((t) =>
107 | t.id === toastId || toastId === undefined
108 | ? {
109 | ...t,
110 | open: false,
111 | }
112 | : t
113 | ),
114 | }
115 | }
116 | case "REMOVE_TOAST":
117 | if (action.toastId === undefined) {
118 | return {
119 | ...state,
120 | toasts: [],
121 | }
122 | }
123 | return {
124 | ...state,
125 | toasts: state.toasts.filter((t) => t.id !== action.toastId),
126 | }
127 | }
128 | }
129 |
130 | const listeners: Array<(state: State) => void> = []
131 |
132 | let memoryState: State = { toasts: [] }
133 |
134 | function dispatch(action: Action) {
135 | memoryState = reducer(memoryState, action)
136 | listeners.forEach((listener) => {
137 | listener(memoryState)
138 | })
139 | }
140 |
141 | type Toast = Omit
142 |
143 | function toast({ ...props }: Toast) {
144 | const id = genId()
145 |
146 | const update = (props: ToasterToast) =>
147 | dispatch({
148 | type: "UPDATE_TOAST",
149 | toast: { ...props, id },
150 | })
151 | const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
152 |
153 | dispatch({
154 | type: "ADD_TOAST",
155 | toast: {
156 | ...props,
157 | id,
158 | open: true,
159 | onOpenChange: (open) => {
160 | if (!open) dismiss()
161 | },
162 | },
163 | })
164 |
165 | return {
166 | id: id,
167 | dismiss,
168 | update,
169 | }
170 | }
171 |
172 | function useToast() {
173 | const [state, setState] = React.useState(memoryState)
174 |
175 | React.useEffect(() => {
176 | listeners.push(setState)
177 | return () => {
178 | const index = listeners.indexOf(setState)
179 | if (index > -1) {
180 | listeners.splice(index, 1)
181 | }
182 | }
183 | }, [state])
184 |
185 | return {
186 | ...state,
187 | toast,
188 | dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
189 | }
190 | }
191 |
192 | export { useToast, toast }
193 |
--------------------------------------------------------------------------------
/youtube course/app/components/use-infinite-posts.ts:
--------------------------------------------------------------------------------
1 | import { useFetcher, useLocation, useRouteLoaderData } from "@remix-run/react";
2 | import { useEffect, useState } from "react";
3 | import type { CombinedPostsWithAuthorAndLikes } from "~/lib/types";
4 | import type { loader as postsLoader } from "~/routes/_home.gitposts";
5 | import type { loader as postLoader } from "~/routes/_home.gitposts.$postId";
6 |
7 | type UseInfinitePosts = {
8 | incomingPosts: CombinedPostsWithAuthorAndLikes;
9 | totalPages: number;
10 | postRouteId: string;
11 | };
12 |
13 | export const useInfinitePosts = ({
14 | incomingPosts,
15 | totalPages,
16 | postRouteId,
17 | }: UseInfinitePosts) => {
18 | const [posts, setPosts] =
19 | useState(incomingPosts);
20 | const fetcher = useFetcher();
21 | const [currentPage, setCurrentPage] = useState(1);
22 | const location = useLocation();
23 | const data = useRouteLoaderData(postRouteId);
24 |
25 | useEffect(() => {
26 | const updatedPost = data?.post;
27 |
28 | if (updatedPost) {
29 | setPosts((posts) =>
30 | posts.map((post) =>
31 | post.id === updatedPost.id ? { ...updatedPost } : post
32 | )
33 | );
34 | }
35 | }, [data]);
36 |
37 | const hasMorePages = currentPage < totalPages;
38 |
39 | const [prevPosts, setPrevPosts] = useState(incomingPosts);
40 | if (incomingPosts !== prevPosts) {
41 | setPrevPosts(incomingPosts);
42 | setPosts(incomingPosts);
43 | setCurrentPage(1);
44 | }
45 |
46 | const loadMore = () => {
47 | if (hasMorePages && fetcher.state === "idle") {
48 | let fullSearchQueryParams = "";
49 |
50 | if (location.search) {
51 | fullSearchQueryParams = `${location.search}&page=${currentPage + 1}`;
52 | } else {
53 | fullSearchQueryParams = `?page=${currentPage + 1}`;
54 | }
55 |
56 | fetcher.load(`${location.pathname}/${fullSearchQueryParams}`);
57 | }
58 | };
59 |
60 | useEffect(() => {
61 | if (fetcher.data?.posts) {
62 | setPosts((prevPosts) => [...prevPosts, ...(fetcher.data?.posts || [])]);
63 | setCurrentPage((currentPage) => currentPage + 1);
64 | }
65 | }, [fetcher.data]);
66 |
67 | return { posts, loadMore, hasMorePages };
68 | };
69 |
--------------------------------------------------------------------------------
/youtube course/app/components/view-comments.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from "@remix-run/react";
2 | import { MessageCircle } from "lucide-react";
3 |
4 | type ViewCommentsProps = {
5 | comments: number;
6 | pathname: string;
7 | readonly?: boolean;
8 | };
9 |
10 | export const ViewComments = ({
11 | comments,
12 | pathname,
13 | readonly,
14 | }: ViewCommentsProps) => {
15 | return (
16 | <>
17 | {readonly ? (
18 |
19 |
20 | {comments}
21 |
22 | ) : (
23 |
24 |
25 |
28 | {comments}
29 |
30 |
31 | )}
32 | >
33 | );
34 | };
35 |
--------------------------------------------------------------------------------
/youtube course/app/components/view-likes.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from "@remix-run/react";
2 | import { Star } from "lucide-react";
3 |
4 | type ViewLikesProps = {
5 | likes: number;
6 | likedByUser: boolean;
7 | pathname: string;
8 | };
9 |
10 | export function ViewLikes({ likes, likedByUser, pathname }: ViewLikesProps) {
11 | return (
12 |
17 |
22 |
27 | {likes}
28 |
29 |
30 | );
31 | }
32 |
--------------------------------------------------------------------------------
/youtube course/app/components/write-post.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef, useState } from "react";
2 | import {
3 | Card,
4 | CardContent,
5 | CardDescription,
6 | CardFooter,
7 | CardHeader,
8 | CardTitle,
9 | } from "./ui/card";
10 | import { Textarea } from "./ui/textarea";
11 | import { Button } from "./ui/button";
12 | import { useFetcher } from "@remix-run/react";
13 | import { UpdateIcon } from "@radix-ui/react-icons";
14 |
15 | type WritePostProps = {
16 | sessionUserId: string;
17 | postId?: string;
18 | };
19 |
20 | export function WritePost({ sessionUserId, postId }: WritePostProps) {
21 | const [title, setTitle] = useState("");
22 | const textareaRef = useRef(null);
23 | const fetcher = useFetcher();
24 | const isPosting = fetcher.state !== "idle";
25 | const isDisabled = isPosting || !title;
26 | const isComment = Boolean(postId);
27 |
28 | const postActionUrl = isComment ? "/resources/comment" : "/resources/post";
29 |
30 | const postTitle = () => {
31 | const formData = {
32 | title,
33 | userId: sessionUserId,
34 | ...(isComment ? { postId } : {}),
35 | };
36 |
37 | fetcher.submit(formData, { method: "POST", action: postActionUrl });
38 | setTitle("");
39 | };
40 |
41 | useEffect(() => {
42 | if (textareaRef.current) {
43 | textareaRef.current.style.height = "inherit";
44 | const computed = window.getComputedStyle(textareaRef.current);
45 | const height =
46 | textareaRef.current.scrollHeight +
47 | parseInt(computed.getPropertyValue("border-top-width")) +
48 | parseInt(computed.getPropertyValue("border-bottom-width"));
49 | textareaRef.current.style.height = height + "px";
50 | }
51 | }, [title]);
52 |
53 | if (isComment) {
54 | return (
55 |
56 |
57 |
69 |
70 | );
71 | }
72 |
73 | return (
74 |
75 |
76 | Write Post
77 | You can write your post in Md
78 |
79 |
80 |
88 |
89 |
90 | {isPosting && }
91 | {isPosting ? "Posting" : "Post"}
92 |
93 |
94 |
95 | );
96 | }
97 |
--------------------------------------------------------------------------------
/youtube course/app/entry.client.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * By default, Remix will handle hydrating your app on the client for you.
3 | * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
4 | * For more information, see https://remix.run/file-conventions/entry.client
5 | */
6 |
7 | import { RemixBrowser } from "@remix-run/react";
8 | import { startTransition, StrictMode } from "react";
9 | import { hydrateRoot } from "react-dom/client";
10 |
11 | startTransition(() => {
12 | hydrateRoot(
13 | document,
14 |
15 |
16 |
17 | );
18 | });
19 |
--------------------------------------------------------------------------------
/youtube course/app/entry.server.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * By default, Remix will handle generating the HTTP Response for you.
3 | * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
4 | * For more information, see https://remix.run/file-conventions/entry.server
5 | */
6 |
7 | import { PassThrough } from "node:stream";
8 |
9 | import type { AppLoadContext, EntryContext } from "@remix-run/node";
10 | import { createReadableStreamFromReadable } from "@remix-run/node";
11 | import { RemixServer } from "@remix-run/react";
12 | import isbot from "isbot";
13 | import { renderToPipeableStream } from "react-dom/server";
14 |
15 | const ABORT_DELAY = 5_000;
16 |
17 | export default function handleRequest(
18 | request: Request,
19 | responseStatusCode: number,
20 | responseHeaders: Headers,
21 | remixContext: EntryContext,
22 | loadContext: AppLoadContext
23 | ) {
24 | return isbot(request.headers.get("user-agent"))
25 | ? handleBotRequest(
26 | request,
27 | responseStatusCode,
28 | responseHeaders,
29 | remixContext
30 | )
31 | : handleBrowserRequest(
32 | request,
33 | responseStatusCode,
34 | responseHeaders,
35 | remixContext
36 | );
37 | }
38 |
39 | function handleBotRequest(
40 | request: Request,
41 | responseStatusCode: number,
42 | responseHeaders: Headers,
43 | remixContext: EntryContext
44 | ) {
45 | return new Promise((resolve, reject) => {
46 | let shellRendered = false;
47 | const { pipe, abort } = renderToPipeableStream(
48 | ,
53 | {
54 | onAllReady() {
55 | shellRendered = true;
56 | const body = new PassThrough();
57 | const stream = createReadableStreamFromReadable(body);
58 |
59 | responseHeaders.set("Content-Type", "text/html");
60 |
61 | resolve(
62 | new Response(stream, {
63 | headers: responseHeaders,
64 | status: responseStatusCode,
65 | })
66 | );
67 |
68 | pipe(body);
69 | },
70 | onShellError(error: unknown) {
71 | reject(error);
72 | },
73 | onError(error: unknown) {
74 | responseStatusCode = 500;
75 | // Log streaming rendering errors from inside the shell. Don't log
76 | // errors encountered during initial shell rendering since they'll
77 | // reject and get logged in handleDocumentRequest.
78 | if (shellRendered) {
79 | console.error(error);
80 | }
81 | },
82 | }
83 | );
84 |
85 | setTimeout(abort, ABORT_DELAY);
86 | });
87 | }
88 |
89 | function handleBrowserRequest(
90 | request: Request,
91 | responseStatusCode: number,
92 | responseHeaders: Headers,
93 | remixContext: EntryContext
94 | ) {
95 | return new Promise((resolve, reject) => {
96 | let shellRendered = false;
97 | const { pipe, abort } = renderToPipeableStream(
98 | ,
103 | {
104 | onShellReady() {
105 | shellRendered = true;
106 | const body = new PassThrough();
107 | const stream = createReadableStreamFromReadable(body);
108 |
109 | responseHeaders.set("Content-Type", "text/html");
110 |
111 | resolve(
112 | new Response(stream, {
113 | headers: responseHeaders,
114 | status: responseStatusCode,
115 | })
116 | );
117 |
118 | pipe(body);
119 | },
120 | onShellError(error: unknown) {
121 | reject(error);
122 | },
123 | onError(error: unknown) {
124 | responseStatusCode = 500;
125 | // Log streaming rendering errors from inside the shell. Don't log
126 | // errors encountered during initial shell rendering since they'll
127 | // reject and get logged in handleDocumentRequest.
128 | if (shellRendered) {
129 | console.error(error);
130 | }
131 | },
132 | }
133 | );
134 |
135 | setTimeout(abort, ABORT_DELAY);
136 | });
137 | }
138 |
--------------------------------------------------------------------------------
/youtube course/app/lib/database.server.ts:
--------------------------------------------------------------------------------
1 | import type { SupabaseClient } from "@supabase/supabase-js";
2 | import type { Database } from "database.types";
3 |
4 | export async function getAllPostsWithDetails({
5 | dbClient,
6 | page,
7 | searchQuery,
8 | limit = 10, // 10 posts per page
9 | }: {
10 | dbClient: SupabaseClient;
11 | page: number;
12 | searchQuery: string | null;
13 | limit?: number;
14 | }) {
15 | let postQuery = dbClient
16 | .from("posts")
17 | .select("*, author: profiles(*), likes(user_id), comments(*)", {
18 | count: "exact",
19 | })
20 | .order("created_at", { ascending: false })
21 | .range((page - 1) * limit, page * limit - 1);
22 |
23 | if (searchQuery) {
24 | postQuery = postQuery.ilike("title", `%${searchQuery}%`);
25 | }
26 |
27 | const { data, error, count } = await postQuery;
28 |
29 | if (error) {
30 | console.log("Error occured at getAllPostsWithDetails ", error);
31 | }
32 |
33 | return {
34 | data,
35 | error,
36 | totalPosts: count,
37 | limit,
38 | totalPages: count ? Math.ceil(count / limit) : 1,
39 | };
40 | }
41 |
42 | export async function createPost({
43 | dbClient,
44 | userId,
45 | title,
46 | }: {
47 | dbClient: SupabaseClient;
48 | userId: string;
49 | title: string;
50 | }) {
51 | const { error } = await dbClient
52 | .from("posts")
53 | .insert({ user_id: userId, title });
54 |
55 | return { error };
56 | }
57 |
58 | export async function getProfileForUsername({
59 | dbClient,
60 | username,
61 | }: {
62 | dbClient: SupabaseClient;
63 | username: string;
64 | }) {
65 | const profileQuery = dbClient
66 | .from("profiles")
67 | .select("*")
68 | .eq("username", username)
69 | .single();
70 |
71 | const { data, error } = await profileQuery;
72 |
73 | if (error) {
74 | console.log("Error occured during getProfileForUsername : ", error);
75 | }
76 |
77 | return { data, error };
78 | }
79 |
80 | export async function getPostsForUser({
81 | dbClient,
82 | page,
83 | userId,
84 | limit = 10, // 10 posts per page
85 | }: {
86 | dbClient: SupabaseClient;
87 | page: number;
88 | userId: string;
89 | limit?: number;
90 | }) {
91 | let postQuery = dbClient
92 | .from("posts")
93 | .select("*, author: profiles(*), likes(user_id), comments(*)", {
94 | count: "exact",
95 | })
96 | .eq("user_id", userId)
97 | .order("created_at", { ascending: false })
98 | .range((page - 1) * limit, page * limit - 1);
99 |
100 | const { data, error, count } = await postQuery;
101 |
102 | if (error) {
103 | console.log("Error occured at getPostsForUser ", error);
104 | }
105 |
106 | return {
107 | data,
108 | error,
109 | totalPosts: count,
110 | limit,
111 | totalPages: count ? Math.ceil(count / limit) : 1,
112 | };
113 | }
114 |
115 | export async function insertLike({
116 | dbClient,
117 | userId,
118 | postId,
119 | }: {
120 | dbClient: SupabaseClient;
121 | postId: string;
122 | userId: string;
123 | }) {
124 | const { error } = await dbClient
125 | .from("likes")
126 | .insert({ user_id: userId, post_id: postId });
127 |
128 | if (error) {
129 | console.log("Error occured at insertLike ", error);
130 | }
131 |
132 | return { error };
133 | }
134 |
135 | export async function deleteLike({
136 | dbClient,
137 | userId,
138 | postId,
139 | }: {
140 | dbClient: SupabaseClient;
141 | postId: string;
142 | userId: string;
143 | }) {
144 | const { error } = await dbClient
145 | .from("likes")
146 | .delete()
147 | .match({ user_id: userId, post_id: postId });
148 |
149 | if (error) {
150 | console.log("Error occured at deleteLike ", error);
151 | }
152 |
153 | return { error };
154 | }
155 |
156 | export async function insertComment({
157 | dbClient,
158 | userId,
159 | postId,
160 | title,
161 | }: {
162 | dbClient: SupabaseClient;
163 | postId: string;
164 | userId: string;
165 | title: string;
166 | }) {
167 | const { error } = await dbClient
168 | .from("comments")
169 | .insert({ user_id: userId, post_id: postId, title });
170 |
171 | if (error) {
172 | console.log("Error occured at insertComment ", error);
173 | }
174 |
175 | return { error };
176 | }
177 |
178 | export async function getPostWithDetailsById({
179 | dbClient,
180 | postId,
181 | }: {
182 | dbClient: SupabaseClient;
183 | postId: string;
184 | }) {
185 | let postQuery = dbClient
186 | .from("posts")
187 | .select(
188 | "*, author: profiles(*), likes(user_id), comments(*, author: profiles(username, avatar_url))"
189 | )
190 | .order("created_at", { foreignTable: "comments", ascending: false })
191 | .eq("id", postId);
192 |
193 | const { data, error } = await postQuery;
194 |
195 | if (error) {
196 | console.error("Error occurred during getPostWithDetailsById: ", error);
197 | }
198 |
199 | return {
200 | data,
201 | error,
202 | };
203 | }
204 |
--------------------------------------------------------------------------------
/youtube course/app/lib/supabase.server.ts:
--------------------------------------------------------------------------------
1 | import { createServerClient, parse, serialize } from "@supabase/ssr";
2 |
3 | export const getSupabaseEnv = () => ({
4 | SUPABASE_URL: process.env.SUPABASE_URL!,
5 | SUPABASE_ANON_KEY: process.env.SUPABASE_ANON_KEY!,
6 | });
7 |
8 | export function getSupabaseWithHeaders({ request }: { request: Request }) {
9 | const cookies = parse(request.headers.get("Cookie") ?? "");
10 | const headers = new Headers();
11 |
12 | const supabase = createServerClient(
13 | process.env.SUPABASE_URL!,
14 | process.env.SUPABASE_ANON_KEY!,
15 | {
16 | cookies: {
17 | get(key) {
18 | return cookies[key];
19 | },
20 | set(key, value, options) {
21 | headers.append("Set-Cookie", serialize(key, value, options));
22 | },
23 | remove(key, options) {
24 | headers.append("Set-Cookie", serialize(key, "", options));
25 | },
26 | },
27 | }
28 | );
29 |
30 | return { supabase, headers };
31 | }
32 |
33 | export async function getSupabaseWithSessionAndHeaders({
34 | request,
35 | }: {
36 | request: Request;
37 | }) {
38 | const { supabase, headers } = getSupabaseWithHeaders({
39 | request,
40 | });
41 | const {
42 | data: { session: serverSession },
43 | } = await supabase.auth.getSession();
44 |
45 | return { serverSession, headers, supabase };
46 | }
47 |
--------------------------------------------------------------------------------
/youtube course/app/lib/supabase.ts:
--------------------------------------------------------------------------------
1 | import { useRevalidator } from "@remix-run/react";
2 | import { createBrowserClient } from "@supabase/ssr";
3 | import type { Session, SupabaseClient } from "@supabase/supabase-js";
4 | import type { Database } from "database.types";
5 | import { useEffect, useState } from "react";
6 |
7 | export type TypedSupabaseClient = SupabaseClient;
8 |
9 | export type SupabaseOutletContext = {
10 | supabase: TypedSupabaseClient;
11 | domainUrl: string;
12 | };
13 |
14 | type SupabaseEnv = {
15 | SUPABASE_URL: string;
16 | SUPABASE_ANON_KEY: string;
17 | };
18 |
19 | type UseSupabase = {
20 | env: SupabaseEnv;
21 | serverSession: Session | null;
22 | };
23 |
24 | export const useSupabase = ({ env, serverSession }: UseSupabase) => {
25 | const [supabase] = useState(() =>
26 | createBrowserClient(env.SUPABASE_URL!, env.SUPABASE_ANON_KEY!)
27 | );
28 | const serverAccessToken = serverSession?.access_token;
29 | const revalidator = useRevalidator();
30 |
31 | useEffect(() => {
32 | const {
33 | data: { subscription },
34 | } = supabase.auth.onAuthStateChange((event, session) => {
35 | if (session?.access_token !== serverAccessToken) {
36 | // Revalidate the app.
37 | revalidator.revalidate();
38 | }
39 | });
40 |
41 | return () => {
42 | subscription.unsubscribe();
43 | };
44 | }, [supabase.auth, serverAccessToken, revalidator]);
45 |
46 | return { supabase };
47 | };
48 |
--------------------------------------------------------------------------------
/youtube course/app/lib/types.ts:
--------------------------------------------------------------------------------
1 | import type { Database } from "database.types";
2 | import type { combinePostsWithLikes } from "./utils";
3 |
4 | type Post = Database["public"]["Tables"]["posts"]["Row"];
5 | type Profile = Database["public"]["Tables"]["profiles"]["Row"];
6 | type Comment = Database["public"]["Tables"]["comments"]["Row"];
7 |
8 | type Like = {
9 | user_id: string;
10 | };
11 |
12 | type CommentWithAuthor = Comment & {
13 | author: {
14 | avatar_url: string;
15 | username: string;
16 | } | null;
17 | };
18 |
19 | export type PostWithDetails = Post & {
20 | author: Profile | null;
21 | likes: Like[];
22 | comments: Comment[];
23 | };
24 |
25 | export type PostWithCommentDetails = Omit & {
26 | comments: CommentWithAuthor[];
27 | };
28 |
29 | export type CombinedPostsWithAuthorAndLikes = ReturnType<
30 | typeof combinePostsWithLikes
31 | >;
32 |
33 | export type CombinedPostWithAuthorAndLikes =
34 | CombinedPostsWithAuthorAndLikes[number];
35 |
--------------------------------------------------------------------------------
/youtube course/app/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import type { Session } from "@supabase/supabase-js";
2 | import { type ClassValue, clsx } from "clsx";
3 | import { twMerge } from "tailwind-merge";
4 | import type { PostWithCommentDetails, PostWithDetails } from "./types";
5 |
6 | export function cn(...inputs: ClassValue[]) {
7 | return twMerge(clsx(inputs));
8 | }
9 |
10 | export function getUserDataFromSession(session: Session) {
11 | const userId = session.user.id;
12 | const userAvatarUrl = session.user.user_metadata.avatar_url;
13 | const username = session.user.user_metadata.user_name;
14 |
15 | return { userId, userAvatarUrl, username };
16 | }
17 |
18 | export function combinePostsWithLikes(
19 | data: PostWithDetails[] | null,
20 | sessionUserId: string
21 | ) {
22 | const posts =
23 | data?.map((post) => {
24 | return {
25 | ...post,
26 | isLikedByUser: !!post.likes.find(
27 | (like) => like.user_id === sessionUserId
28 | ),
29 | likes: post.likes,
30 | comments: post.comments,
31 | author: post.author!,
32 | };
33 | }) ?? [];
34 |
35 | return posts;
36 | }
37 |
38 | export function formatToTwitterDate(dateTimeString: string) {
39 | const date = new Date(dateTimeString);
40 |
41 | const day = date.getDate();
42 | const monthNames = [
43 | "Jan",
44 | "Feb",
45 | "Mar",
46 | "Apr",
47 | "May",
48 | "Jun",
49 | "Jul",
50 | "Aug",
51 | "Sep",
52 | "Oct",
53 | "Nov",
54 | "Dec",
55 | ];
56 | const month = monthNames[date.getMonth()];
57 | const year = date.getFullYear();
58 | let hours = date.getHours();
59 | const minutes = date.getMinutes();
60 |
61 | // Convert hours to AM/PM format
62 | const amPM = hours >= 12 ? "PM" : "AM";
63 | hours = hours % 12 || 12; // Convert hours to 12-hour format
64 |
65 | const formattedDate = `${hours}:${
66 | minutes < 10 ? "0" : ""
67 | }${minutes} ${amPM} · ${month} ${day}, ${year}`;
68 |
69 | return formattedDate;
70 | }
71 |
72 | export function combinePostsWithLikesAndComments(
73 | data: PostWithCommentDetails[] | null,
74 | sessionUserId: string
75 | ) {
76 | const posts =
77 | data?.map((post) => {
78 | const commentsWithAvatarUrl = post.comments.map((comment) => ({
79 | ...comment,
80 | author: {
81 | username: comment.author!.username,
82 | avatarUrl: comment.author!.avatar_url,
83 | },
84 | }));
85 |
86 | return {
87 | ...post,
88 | isLikedByUser: !!post.likes.find(
89 | (like) => like.user_id === sessionUserId
90 | ),
91 | likes: post.likes,
92 | comments: commentsWithAvatarUrl,
93 | author: post.author!,
94 | };
95 | }) ?? [];
96 |
97 | return posts;
98 | }
99 |
--------------------------------------------------------------------------------
/youtube course/app/root.tsx:
--------------------------------------------------------------------------------
1 | import { cssBundleHref } from "@remix-run/css-bundle";
2 | import {
3 | json,
4 | type LinksFunction,
5 | type LoaderFunctionArgs,
6 | } from "@remix-run/node";
7 | import {
8 | Links,
9 | LiveReload,
10 | Meta,
11 | Outlet,
12 | Scripts,
13 | ScrollRestoration,
14 | useLoaderData,
15 | } from "@remix-run/react";
16 | import styles from "./tailwind.css";
17 | import {
18 | getSupabaseEnv,
19 | getSupabaseWithSessionAndHeaders,
20 | } from "./lib/supabase.server";
21 | import { useSupabase } from "./lib/supabase";
22 | import { Toaster } from "./components/ui/toaster";
23 |
24 | export const links: LinksFunction = () => [
25 | { rel: "stylesheet", href: styles },
26 | ...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : []),
27 | ];
28 |
29 | export const loader = async ({ request }: LoaderFunctionArgs) => {
30 | const { serverSession, headers } = await getSupabaseWithSessionAndHeaders({
31 | request,
32 | });
33 | const domainUrl = process.env.DOMAIN_URL!;
34 |
35 | const env = getSupabaseEnv();
36 |
37 | return json({ serverSession, env, domainUrl }, { headers });
38 | };
39 |
40 | export default function App() {
41 | const { env, serverSession, domainUrl } = useLoaderData();
42 |
43 | const { supabase } = useSupabase({ env, serverSession });
44 |
45 | return (
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 | );
62 | }
63 |
--------------------------------------------------------------------------------
/youtube course/app/routes/_home.gitposts.$postId.tsx:
--------------------------------------------------------------------------------
1 | import type { LoaderFunctionArgs } from "@remix-run/node";
2 | import { json, redirect } from "@remix-run/node";
3 | import { useLoaderData, useNavigate } from "@remix-run/react";
4 | import { useState } from "react";
5 | import { Post } from "~/components/post";
6 | import {
7 | Dialog,
8 | DialogContent,
9 | DialogDescription,
10 | DialogHeader,
11 | } from "~/components/ui/dialog";
12 | import { ViewComments } from "~/components/view-comments";
13 | import { getPostWithDetailsById } from "~/lib/database.server";
14 | import { getSupabaseWithSessionAndHeaders } from "~/lib/supabase.server";
15 | import {
16 | combinePostsWithLikesAndComments,
17 | formatToTwitterDate,
18 | getUserDataFromSession,
19 | } from "~/lib/utils";
20 | import { Like } from "./resources.like";
21 | import { WritePost } from "~/components/write-post";
22 | import { AppLogo } from "~/components/app-logo";
23 | import { ShowComment } from "~/components/show-comment";
24 | import { Card } from "~/components/ui/card";
25 |
26 | export let loader = async ({ request, params }: LoaderFunctionArgs) => {
27 | const { postId } = params;
28 |
29 | const { supabase, headers, serverSession } =
30 | await getSupabaseWithSessionAndHeaders({
31 | request,
32 | });
33 |
34 | if (!serverSession) {
35 | return redirect("/login", { headers });
36 | }
37 |
38 | if (!postId) {
39 | return redirect("/404", { headers });
40 | }
41 |
42 | const { userId: sessionUserId } = getUserDataFromSession(serverSession);
43 |
44 | const { data } = await getPostWithDetailsById({ dbClient: supabase, postId });
45 |
46 | const posts = combinePostsWithLikesAndComments(data, sessionUserId);
47 |
48 | return json(
49 | {
50 | post: posts[0],
51 | sessionUserId,
52 | },
53 | { headers }
54 | );
55 | };
56 |
57 | export default function CurrentPost() {
58 | const [open, setOpen] = useState(true);
59 | const navigate = useNavigate();
60 | const { post, sessionUserId } = useLoaderData();
61 |
62 | return (
63 | {
66 | navigate(-1);
67 | setOpen(open);
68 | }}
69 | >
70 |
71 |
72 |
73 |
82 |
88 |
93 |
94 |
95 | {post.comments.length ? (
96 |
97 | {post.comments.map(({ title, author }, index) => (
98 |
99 |
104 |
105 | ))}
106 |
107 | ) : (
108 |
109 |
110 |
No comments yet !!
111 |
112 | )}
113 |
114 |
115 |
116 |
117 | );
118 | }
119 |
--------------------------------------------------------------------------------
/youtube course/app/routes/_home.gitposts.tsx:
--------------------------------------------------------------------------------
1 | import { Separator } from "@radix-ui/react-separator";
2 | import { json, redirect, type LoaderFunctionArgs } from "@remix-run/node";
3 | import {
4 | Outlet,
5 | ShouldRevalidateFunctionArgs,
6 | useLoaderData,
7 | useNavigation,
8 | } from "@remix-run/react";
9 | import { InfiniteVirtualList } from "~/components/infinite-virtual-list";
10 | import { PostSearch } from "~/components/post-search";
11 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
12 | import { WritePost } from "~/components/write-post";
13 | import { getAllPostsWithDetails } from "~/lib/database.server";
14 | import { getSupabaseWithSessionAndHeaders } from "~/lib/supabase.server";
15 | import { combinePostsWithLikes, getUserDataFromSession } from "~/lib/utils";
16 |
17 | export const loader = async ({ request }: LoaderFunctionArgs) => {
18 | const { headers, supabase, serverSession } =
19 | await getSupabaseWithSessionAndHeaders({
20 | request,
21 | });
22 |
23 | if (!serverSession) {
24 | return redirect("/login", { headers });
25 | }
26 |
27 | const url = new URL(request.url);
28 | const searchParams = url.searchParams;
29 | const query = searchParams.get("query");
30 | const page = Number(searchParams.get("page")) || 1;
31 |
32 | const { data, totalPages } = await getAllPostsWithDetails({
33 | dbClient: supabase,
34 | page: isNaN(page) ? 1 : page,
35 | searchQuery: query,
36 | });
37 |
38 | const {
39 | userId: sessionUserId,
40 | // username,
41 | // userAvatarUrl,
42 | } = getUserDataFromSession(serverSession);
43 |
44 | const posts = combinePostsWithLikes(data, sessionUserId);
45 |
46 | return json(
47 | { query, posts, totalPages, userDetails: { sessionUserId } },
48 | { headers }
49 | );
50 | };
51 |
52 | export default function Gitposts() {
53 | const {
54 | query,
55 | posts,
56 | totalPages,
57 | userDetails: { sessionUserId },
58 | } = useLoaderData();
59 | const navigation = useNavigation();
60 |
61 | // means that I am typing something in my search field and my page is reloading
62 | const isSearching = Boolean(
63 | navigation.location &&
64 | new URLSearchParams(navigation.location.search).has("query")
65 | );
66 |
67 | return (
68 |
69 |
70 |
71 |
72 | View Posts
73 | Write Post
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 | );
86 | }
87 |
88 | export function shouldRevalidate({
89 | actionResult,
90 | defaultShouldRevalidate,
91 | }: ShouldRevalidateFunctionArgs) {
92 | const skipRevalidation =
93 | actionResult?.skipRevalidation &&
94 | actionResult?.skipRevalidation?.includes("gitposts");
95 |
96 | if (skipRevalidation) {
97 | return false;
98 | }
99 |
100 | return defaultShouldRevalidate;
101 | }
102 |
--------------------------------------------------------------------------------
/youtube course/app/routes/_home.profile.$username.$postId.tsx:
--------------------------------------------------------------------------------
1 | export { loader } from "./_home.gitposts.$postId";
2 | export { default } from "./_home.gitposts.$postId";
3 |
--------------------------------------------------------------------------------
/youtube course/app/routes/_home.profile.$username.tsx:
--------------------------------------------------------------------------------
1 | import { Avatar, AvatarImage } from "~/components/ui/avatar";
2 | import type { LoaderFunctionArgs } from "@remix-run/node";
3 | import { json, redirect } from "@remix-run/node";
4 | import {
5 | Link,
6 | Outlet,
7 | ShouldRevalidateFunctionArgs,
8 | useLoaderData,
9 | } from "@remix-run/react";
10 | import { InfiniteVirtualList } from "~/components/infinite-virtual-list";
11 | import { Separator } from "~/components/ui/separator";
12 | import { getPostsForUser, getProfileForUsername } from "~/lib/database.server";
13 | import { getSupabaseWithSessionAndHeaders } from "~/lib/supabase.server";
14 | import { getUserDataFromSession, combinePostsWithLikes } from "~/lib/utils";
15 |
16 | export let loader = async ({ request, params }: LoaderFunctionArgs) => {
17 | const { username } = params;
18 | const { supabase, headers, serverSession } =
19 | await getSupabaseWithSessionAndHeaders({
20 | request,
21 | });
22 |
23 | if (!serverSession) {
24 | return redirect("/login", { headers });
25 | }
26 |
27 | // Redirect to 404 page if username is invalid
28 | if (!username) {
29 | return redirect("/404", { headers });
30 | }
31 |
32 | const url = new URL(request.url);
33 | const searchParams = url.searchParams;
34 | const page = Number(searchParams.get("page")) || 1;
35 |
36 | const { data: profile } = await getProfileForUsername({
37 | dbClient: supabase,
38 | username,
39 | });
40 |
41 | // User not found
42 | if (!profile) {
43 | return redirect("/404", { headers });
44 | }
45 |
46 | const { data: rawPosts, totalPages } = await getPostsForUser({
47 | dbClient: supabase,
48 | page: isNaN(page) ? 1 : page,
49 | userId: profile.id,
50 | });
51 |
52 | const {
53 | userId: sessionUserId,
54 | // username,
55 | // userAvatarUrl,
56 | } = getUserDataFromSession(serverSession);
57 |
58 | const posts = combinePostsWithLikes(rawPosts, sessionUserId);
59 |
60 | return json(
61 | { posts, totalPages, profile, userDetails: { sessionUserId } },
62 | { headers }
63 | );
64 | };
65 |
66 | export default function Profile() {
67 | const {
68 | profile: { avatar_url, name, username },
69 | posts,
70 | totalPages,
71 | } = useLoaderData();
72 |
73 | return (
74 |
75 |
76 |
77 |
78 |
79 |
80 |
{name}
81 |
82 |
@{username}
83 |
84 |
85 |
86 |
87 |
88 |
{"User posts"}
89 |
90 |
95 |
96 | );
97 | }
98 |
99 | export function shouldRevalidate({
100 | actionResult,
101 | defaultShouldRevalidate,
102 | }: ShouldRevalidateFunctionArgs) {
103 | const skipRevalidation =
104 | actionResult?.skipRevalidation &&
105 | actionResult?.skipRevalidation?.includes("profile.$username");
106 |
107 | if (skipRevalidation) {
108 | return false;
109 | }
110 |
111 | return defaultShouldRevalidate;
112 | }
113 |
--------------------------------------------------------------------------------
/youtube course/app/routes/_home.tsx:
--------------------------------------------------------------------------------
1 | import { Cross2Icon, HamburgerMenuIcon } from "@radix-ui/react-icons";
2 | import type { LoaderFunctionArgs } from "@remix-run/node";
3 | import { json, redirect } from "@remix-run/node";
4 | import {
5 | Link,
6 | Outlet,
7 | useLoaderData,
8 | useOutletContext,
9 | } from "@remix-run/react";
10 | import { useState } from "react";
11 | import { AppLogo } from "~/components/app-logo";
12 | import { Button } from "~/components/ui/button";
13 | import type { SupabaseOutletContext } from "~/lib/supabase";
14 | import { getSupabaseWithSessionAndHeaders } from "~/lib/supabase.server";
15 | import { getUserDataFromSession } from "~/lib/utils";
16 |
17 | export let loader = async ({ request }: LoaderFunctionArgs) => {
18 | const { headers, serverSession } = await getSupabaseWithSessionAndHeaders({
19 | request,
20 | });
21 |
22 | if (!serverSession) {
23 | return redirect("/login", { headers });
24 | }
25 |
26 | const { userId, userAvatarUrl, username } =
27 | getUserDataFromSession(serverSession);
28 |
29 | return json(
30 | { userDetails: { userId, userAvatarUrl, username } },
31 | { headers }
32 | );
33 | };
34 |
35 | export default function Home() {
36 | const {
37 | userDetails: { username, userAvatarUrl },
38 | } = useLoaderData();
39 |
40 | const [isNavOpen, setNavOpen] = useState(false);
41 | const { supabase } = useOutletContext();
42 |
43 | const handleSignOut = async () => {
44 | await supabase.auth.signOut();
45 | };
46 |
47 | return (
48 |
49 |
50 |
51 |
52 | Gitposter
53 |
54 | setNavOpen(!isNavOpen)} className="md:hidden">
55 | {isNavOpen ? : }
56 |
57 |
64 |
@{username}
65 |
76 |
Logout
77 |
78 |
79 |
80 |
81 | );
82 | }
83 |
--------------------------------------------------------------------------------
/youtube course/app/routes/_index.tsx:
--------------------------------------------------------------------------------
1 | import type { LoaderFunctionArgs } from "@remix-run/node";
2 | import { redirect, json } from "@remix-run/node";
3 | import { Link } from "@remix-run/react";
4 | import { AppLogo } from "~/components/app-logo";
5 | import { Button } from "~/components/ui/button";
6 | import { Card, CardContent } from "~/components/ui/card";
7 | import { getSupabaseWithSessionAndHeaders } from "~/lib/supabase.server";
8 |
9 | export let loader = async ({ request }: LoaderFunctionArgs) => {
10 | const { headers, serverSession } = await getSupabaseWithSessionAndHeaders({
11 | request,
12 | });
13 |
14 | if (serverSession) {
15 | return redirect("/gitposts", { headers });
16 | }
17 |
18 | return json({ success: true }, { headers });
19 | };
20 |
21 | export default function Index() {
22 | return (
23 |
24 |
25 |
26 | Gitposter
27 |
28 |
29 |
30 |
31 | A{" "}
32 |
33 | Community-Driven
34 | {" "}
35 | Minimalist Social Platform for Coders
36 |
37 |
38 |
39 | Powered by{" "}
40 | Remix and{" "}
41 | Supabase
42 |
43 |
44 |
45 | Join our Community
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 | );
58 | }
59 |
--------------------------------------------------------------------------------
/youtube course/app/routes/login.tsx:
--------------------------------------------------------------------------------
1 | import { Link, useOutletContext } from '@remix-run/react';
2 | import { AppLogo } from '~/components/app-logo';
3 | import { Button } from '~/components/ui/button';
4 | import { Card, CardContent } from '~/components/ui/card';
5 | import { Github } from 'lucide-react';
6 | import type { SupabaseOutletContext } from '~/lib/supabase';
7 | import { getSupabaseWithSessionAndHeaders } from '~/lib/supabase.server';
8 | import type { LoaderFunctionArgs } from '@remix-run/node';
9 | import { json, redirect } from '@remix-run/node';
10 |
11 | export let loader = async ({ request }: LoaderFunctionArgs) => {
12 | const { headers, serverSession } = await getSupabaseWithSessionAndHeaders({
13 | request,
14 | });
15 |
16 | if (serverSession) {
17 | return redirect('/gitposts', { headers });
18 | }
19 |
20 | return json({ success: true }, { headers });
21 | };
22 |
23 | export default function Login() {
24 | const { supabase, domainUrl } = useOutletContext();
25 |
26 | const handleSignIn = async () => {
27 | await supabase.auth.signInWithOAuth({
28 | provider: 'github',
29 | options: {
30 | redirectTo: `${domainUrl}/resources/auth/callback`,
31 | },
32 | });
33 | };
34 |
35 | return (
36 |
37 |
38 |
39 |
40 |
41 | Gitposter
42 |
43 |
44 |
45 |
46 | Login in using
47 |
48 | Github
49 | {' '}
50 |
51 | and discover more
52 |
53 |
54 |
55 | Our posts and comments are powered by Markdown
56 |
57 |
58 |
59 |
60 |
61 |
62 | Github
63 |
64 |
65 |
66 |
67 |
68 | );
69 | }
70 |
--------------------------------------------------------------------------------
/youtube course/app/routes/resources.auth.callback.tsx:
--------------------------------------------------------------------------------
1 | import { redirect, type LoaderFunctionArgs } from "@remix-run/node";
2 | import { getSupabaseWithHeaders } from "~/lib/supabase.server";
3 |
4 | export async function loader({ request }: LoaderFunctionArgs) {
5 | const requestUrl = new URL(request.url);
6 | const code = requestUrl.searchParams.get("code");
7 | const next = requestUrl.searchParams.get("next") || "/";
8 |
9 | if (code) {
10 | const { headers, supabase } = getSupabaseWithHeaders({
11 | request,
12 | });
13 |
14 | const { error } = await supabase.auth.exchangeCodeForSession(code);
15 |
16 | if (!error) {
17 | return redirect(next, { headers });
18 | }
19 | }
20 |
21 | // return the user to an error page with instructions
22 | return redirect("/login");
23 | }
24 |
--------------------------------------------------------------------------------
/youtube course/app/routes/resources.comment.tsx:
--------------------------------------------------------------------------------
1 | import type { ActionFunctionArgs } from "@remix-run/node";
2 | import { redirect, json } from "@remix-run/node";
3 | import { insertComment } from "~/lib/database.server";
4 | import { getSupabaseWithSessionAndHeaders } from "~/lib/supabase.server";
5 |
6 | export async function action({ request }: ActionFunctionArgs) {
7 | const { supabase, headers, serverSession } =
8 | await getSupabaseWithSessionAndHeaders({
9 | request,
10 | });
11 |
12 | if (!serverSession) {
13 | return redirect("/login", { headers });
14 | }
15 |
16 | const formData = await request.formData();
17 | const title = formData.get("title")?.toString();
18 | const postId = formData.get("postId")?.toString();
19 | const userId = formData.get("userId")?.toString();
20 |
21 | const skipRevalidation = ["gitposts", "profile.$username"];
22 |
23 | // Check if userId and tweetId are present
24 | if (!userId || !title || !postId) {
25 | return json(
26 | { error: "Post/user information missing" },
27 | { status: 400, headers }
28 | );
29 | }
30 |
31 | const { error } = await insertComment({
32 | dbClient: supabase,
33 | userId,
34 | postId,
35 | title,
36 | });
37 |
38 | if (error) {
39 | return json(
40 | { error: "Failed to comment", skipRevalidation },
41 | { status: 500, headers }
42 | );
43 | }
44 |
45 | return json({ ok: true, error: null, skipRevalidation }, { headers });
46 | }
47 |
--------------------------------------------------------------------------------
/youtube course/app/routes/resources.like.tsx:
--------------------------------------------------------------------------------
1 | import { redirect, type ActionFunctionArgs, json } from "@remix-run/node";
2 | import { useFetcher } from "@remix-run/react";
3 | import { Star } from "lucide-react";
4 | import { useEffect } from "react";
5 | import { useToast } from "~/components/ui/use-toast";
6 | import { insertLike, deleteLike } from "~/lib/database.server";
7 | import { getSupabaseWithSessionAndHeaders } from "~/lib/supabase.server";
8 |
9 | export async function action({ request }: ActionFunctionArgs) {
10 | const { supabase, headers, serverSession } =
11 | await getSupabaseWithSessionAndHeaders({
12 | request,
13 | });
14 | if (!serverSession) {
15 | return redirect("/login", { headers });
16 | }
17 |
18 | const formData = await request.formData();
19 | const action = formData.get("action");
20 | const postId = formData.get("postId")?.toString();
21 | const userId = formData.get("userId")?.toString();
22 |
23 | const skipRevalidation = ["gitposts", "profile.$username"];
24 |
25 | if (!userId || !postId) {
26 | return json(
27 | { error: "User or Tweet Id missing" },
28 | { status: 400, headers }
29 | );
30 | }
31 |
32 | if (action === "like") {
33 | const { error } = await insertLike({ dbClient: supabase, userId, postId });
34 |
35 | if (error) {
36 | return json(
37 | { error: "Failed to like", skipRevalidation },
38 | { status: 500, headers }
39 | );
40 | }
41 | } else {
42 | const { error } = await deleteLike({ dbClient: supabase, userId, postId });
43 |
44 | if (error) {
45 | return json(
46 | { error: "Failed to like", skipRevalidation },
47 | { status: 500, headers }
48 | );
49 | }
50 | }
51 |
52 | return json({ ok: true, error: null, skipRevalidation }, { headers });
53 | }
54 |
55 | type LikeProps = {
56 | likedByUser: boolean;
57 | likes: number;
58 | postId: string;
59 | sessionUserId: string;
60 | };
61 |
62 | export function Like({ likedByUser, likes, postId, sessionUserId }: LikeProps) {
63 | const fetcher = useFetcher();
64 | const inFlightAction = fetcher.formData?.get("action");
65 | const isLoading = fetcher.state !== "idle";
66 | const { toast } = useToast();
67 |
68 | const optimisticLikedByUser = inFlightAction
69 | ? inFlightAction === "like"
70 | : likedByUser;
71 | const optimisticLikes = inFlightAction
72 | ? inFlightAction === "like"
73 | ? likes + 1
74 | : likes - 1
75 | : likes;
76 |
77 | useEffect(() => {
78 | if (fetcher.data?.error && !isLoading) {
79 | console.log("error occured");
80 | toast({
81 | variant: "destructive",
82 | description: `Error occured: ${fetcher.data?.error}`,
83 | });
84 | }
85 | }, [fetcher.data, isLoading, toast]);
86 |
87 | return (
88 |
89 |
90 |
91 |
96 |
100 |
105 |
110 | {optimisticLikes}
111 |
112 |
113 |
114 | );
115 | }
116 |
--------------------------------------------------------------------------------
/youtube course/app/routes/resources.post.tsx:
--------------------------------------------------------------------------------
1 | import type { ActionFunctionArgs } from "@remix-run/node";
2 | import { json, redirect } from "@remix-run/node";
3 | import { getSupabaseWithSessionAndHeaders } from "~/lib/supabase.server";
4 | import { createPost } from "~/lib/database.server";
5 |
6 | export async function action({ request }: ActionFunctionArgs) {
7 | const { supabase, headers, serverSession } =
8 | await getSupabaseWithSessionAndHeaders({
9 | request,
10 | });
11 |
12 | if (!serverSession) {
13 | return redirect("/login", { headers });
14 | }
15 |
16 | const formData = await request.formData();
17 | const title = formData.get("title")?.toString();
18 | const userId = formData.get("userId")?.toString();
19 |
20 | // Check if userId and tweetId are present
21 | if (!userId || !title) {
22 | return json(
23 | { error: "Post/user information missing" },
24 | { status: 400, headers }
25 | );
26 | }
27 |
28 | const { error } = await createPost({ dbClient: supabase, userId, title });
29 |
30 | if (error) {
31 | console.log("Error occured ", error);
32 |
33 | return json({ error: "Failed to post" }, { status: 500, headers });
34 | }
35 |
36 | return json({ ok: true, error: null }, { headers });
37 | }
38 |
--------------------------------------------------------------------------------
/youtube course/app/tailwind.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | :root {
7 | --background: 0 0% 100%;
8 | --foreground: 240 10% 3.9%;
9 |
10 | --card: 0 0% 100%;
11 | --card-foreground: 240 10% 3.9%;
12 |
13 | --popover: 0 0% 100%;
14 | --popover-foreground: 240 10% 3.9%;
15 |
16 | --primary: 240 5.9% 10%;
17 | --primary-foreground: 0 0% 98%;
18 |
19 | --secondary: 240 4.8% 95.9%;
20 | --secondary-foreground: 240 5.9% 10%;
21 |
22 | --muted: 240 4.8% 95.9%;
23 | --muted-foreground: 240 3.8% 46.1%;
24 |
25 | --accent: 240 4.8% 95.9%;
26 | --accent-foreground: 240 5.9% 10%;
27 |
28 | --destructive: 0 84.2% 60.2%;
29 | --destructive-foreground: 0 0% 98%;
30 |
31 | --border: 240 5.9% 90%;
32 | --input: 240 5.9% 90%;
33 | --ring: 240 10% 3.9%;
34 |
35 | --radius: 0.5rem;
36 | }
37 |
38 | .dark {
39 | --background: 240 10% 3.9%;
40 | --foreground: 0 0% 98%;
41 |
42 | --card: 240 10% 3.9%;
43 | --card-foreground: 0 0% 98%;
44 |
45 | --popover: 240 10% 3.9%;
46 | --popover-foreground: 0 0% 98%;
47 |
48 | --primary: 0 0% 98%;
49 | --primary-foreground: 240 5.9% 10%;
50 |
51 | --secondary: 240 3.7% 15.9%;
52 | --secondary-foreground: 0 0% 98%;
53 |
54 | --muted: 240 3.7% 15.9%;
55 | --muted-foreground: 240 5% 64.9%;
56 |
57 | --accent: 240 3.7% 15.9%;
58 | --accent-foreground: 0 0% 98%;
59 |
60 | --destructive: 0 62.8% 30.6%;
61 | --destructive-foreground: 0 0% 98%;
62 |
63 | --border: 240 3.7% 15.9%;
64 | --input: 240 3.7% 15.9%;
65 | --ring: 240 4.9% 83.9%;
66 | }
67 | }
68 |
69 | @layer base {
70 | * {
71 | @apply border-border;
72 | }
73 | body {
74 | @apply bg-background text-foreground;
75 | }
76 | }
--------------------------------------------------------------------------------
/youtube course/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "new-york",
4 | "rsc": false,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.js",
8 | "css": "app/tailwind.css",
9 | "baseColor": "zinc",
10 | "cssVariables": true
11 | },
12 | "aliases": {
13 | "components": "~/components",
14 | "utils": "~/lib/utils"
15 | }
16 | }
--------------------------------------------------------------------------------
/youtube course/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "remix-supabase-course",
3 | "private": true,
4 | "sideEffects": false,
5 | "type": "module",
6 | "scripts": {
7 | "build": "remix build",
8 | "dev": "remix dev --manual",
9 | "start": "remix-serve ./build/index.js",
10 | "typecheck": "tsc"
11 | },
12 | "dependencies": {
13 | "@radix-ui/react-avatar": "^1.0.4",
14 | "@radix-ui/react-dialog": "^1.0.5",
15 | "@radix-ui/react-icons": "^1.3.0",
16 | "@radix-ui/react-separator": "^1.0.3",
17 | "@radix-ui/react-slot": "^1.0.2",
18 | "@radix-ui/react-tabs": "^1.0.4",
19 | "@radix-ui/react-toast": "^1.1.5",
20 | "@remix-run/css-bundle": "^2.3.1",
21 | "@remix-run/node": "^2.3.1",
22 | "@remix-run/react": "^2.3.1",
23 | "@remix-run/serve": "^2.3.1",
24 | "@supabase/ssr": "^0.0.10",
25 | "@supabase/supabase-js": "^2.39.0",
26 | "class-variance-authority": "^0.7.0",
27 | "clsx": "^2.0.0",
28 | "isbot": "^3.6.8",
29 | "lucide-react": "^0.293.0",
30 | "react": "^18.2.0",
31 | "react-dom": "^18.2.0",
32 | "react-markdown": "^9.0.1",
33 | "react-virtuoso": "^4.6.2",
34 | "tailwind-merge": "^2.0.0",
35 | "tailwindcss-animate": "^1.0.7"
36 | },
37 | "devDependencies": {
38 | "@remix-run/dev": "^2.3.1",
39 | "@remix-run/eslint-config": "^2.3.1",
40 | "@tailwindcss/typography": "^0.5.10",
41 | "@types/react": "^18.2.20",
42 | "@types/react-dom": "^18.2.7",
43 | "autoprefixer": "^10.4.16",
44 | "eslint": "^8.38.0",
45 | "tailwindcss": "^3.3.5",
46 | "typescript": "^5.1.6"
47 | },
48 | "engines": {
49 | "node": ">=18.0.0"
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/youtube course/postcss.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/youtube course/public/assets/videos/demo.mp4:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rajeshdavidbabu/remix-supabase-social/0d18b473197f395a25310761e43283b189b845a9/youtube course/public/assets/videos/demo.mp4
--------------------------------------------------------------------------------
/youtube course/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rajeshdavidbabu/remix-supabase-social/0d18b473197f395a25310761e43283b189b845a9/youtube course/public/favicon.ico
--------------------------------------------------------------------------------
/youtube course/remix.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('@remix-run/dev').AppConfig} */
2 | export default {
3 | ignoredRouteFiles: ["**/.*"],
4 | // appDirectory: "app",
5 | // assetsBuildDirectory: "public/build",
6 | // publicPath: "/build/",
7 | // serverBuildPath: "build/index.js",
8 | tailwind: true,
9 | postcss: true,
10 | };
11 |
--------------------------------------------------------------------------------
/youtube course/remix.env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
--------------------------------------------------------------------------------
/youtube course/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | darkMode: ["class"],
4 | content: [
5 | "./pages/**/*.{ts,tsx}",
6 | "./components/**/*.{ts,tsx}",
7 | "./app/**/*.{ts,tsx}",
8 | "./src/**/*.{ts,tsx}",
9 | ],
10 | theme: {
11 | container: {
12 | center: true,
13 | padding: "2rem",
14 | screens: {
15 | "2xl": "1400px",
16 | },
17 | },
18 | extend: {
19 | colors: {
20 | border: "hsl(var(--border))",
21 | input: "hsl(var(--input))",
22 | ring: "hsl(var(--ring))",
23 | background: "hsl(var(--background))",
24 | foreground: "hsl(var(--foreground))",
25 | primary: {
26 | DEFAULT: "hsl(var(--primary))",
27 | foreground: "hsl(var(--primary-foreground))",
28 | },
29 | secondary: {
30 | DEFAULT: "hsl(var(--secondary))",
31 | foreground: "hsl(var(--secondary-foreground))",
32 | },
33 | destructive: {
34 | DEFAULT: "hsl(var(--destructive))",
35 | foreground: "hsl(var(--destructive-foreground))",
36 | },
37 | muted: {
38 | DEFAULT: "hsl(var(--muted))",
39 | foreground: "hsl(var(--muted-foreground))",
40 | },
41 | accent: {
42 | DEFAULT: "hsl(var(--accent))",
43 | foreground: "hsl(var(--accent-foreground))",
44 | },
45 | popover: {
46 | DEFAULT: "hsl(var(--popover))",
47 | foreground: "hsl(var(--popover-foreground))",
48 | },
49 | card: {
50 | DEFAULT: "hsl(var(--card))",
51 | foreground: "hsl(var(--card-foreground))",
52 | },
53 | },
54 | borderRadius: {
55 | lg: "var(--radius)",
56 | md: "calc(var(--radius) - 2px)",
57 | sm: "calc(var(--radius) - 4px)",
58 | },
59 | keyframes: {
60 | "accordion-down": {
61 | from: { height: 0 },
62 | to: { height: "var(--radix-accordion-content-height)" },
63 | },
64 | "accordion-up": {
65 | from: { height: "var(--radix-accordion-content-height)" },
66 | to: { height: 0 },
67 | },
68 | animatedgradient: {
69 | "0%": { backgroundPosition: "0% 50%" },
70 | "50%": { backgroundPosition: "100% 50%" },
71 | "100%": { backgroundPosition: "0% 50%" },
72 | },
73 | },
74 | animation: {
75 | "accordion-down": "accordion-down 0.2s ease-out",
76 | "accordion-up": "accordion-up 0.2s ease-out",
77 | gradient: "animatedgradient 6s ease infinite alternate",
78 | },
79 | backgroundSize: {
80 | "300%": "300%",
81 | },
82 | },
83 | },
84 | plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")],
85 | };
86 |
--------------------------------------------------------------------------------
/youtube course/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"],
3 | "compilerOptions": {
4 | "lib": ["DOM", "DOM.Iterable", "ES2022"],
5 | "isolatedModules": true,
6 | "esModuleInterop": true,
7 | "jsx": "react-jsx",
8 | "moduleResolution": "Bundler",
9 | "resolveJsonModule": true,
10 | "target": "ES2022",
11 | "strict": true,
12 | "allowJs": true,
13 | "forceConsistentCasingInFileNames": true,
14 | "baseUrl": ".",
15 | "paths": {
16 | "~/*": ["./app/*"]
17 | },
18 |
19 | // Remix takes care of building everything in `remix build`.
20 | "noEmit": true
21 | }
22 | }
23 |
--------------------------------------------------------------------------------