├── .eslintrc.json ├── .gitignore ├── LICENSE ├── README.md ├── app ├── (homepage) │ ├── _components │ │ ├── group-card.tsx │ │ └── group-list.tsx │ ├── layout.tsx │ └── page.tsx ├── [groupId] │ ├── _components │ │ ├── comment-list │ │ │ ├── comment-card.tsx │ │ │ ├── comment-input.tsx │ │ │ └── index.tsx │ │ ├── create-post-modal │ │ │ └── index.tsx │ │ ├── group-navbar.tsx │ │ ├── post-card.tsx │ │ └── post-modal.tsx │ ├── about │ │ ├── _components │ │ │ └── join-group-page.tsx │ │ └── page.tsx │ ├── classroom │ │ ├── [courseId] │ │ │ ├── _components │ │ │ │ ├── curriculum.tsx │ │ │ │ └── lesson-view.tsx │ │ │ ├── edit │ │ │ │ ├── _components │ │ │ │ │ ├── lesson-editor-view.tsx │ │ │ │ │ └── module-name-editor.tsx │ │ │ │ └── page.tsx │ │ │ └── page.tsx │ │ ├── _components │ │ │ └── course-list │ │ │ │ ├── course-card.tsx │ │ │ │ └── index.tsx │ │ ├── create │ │ │ └── page.tsx │ │ └── page.tsx │ ├── edit │ │ ├── _components │ │ │ ├── description-editor.tsx │ │ │ └── name-editor.tsx │ │ └── page.tsx │ ├── layout.tsx │ ├── members │ │ ├── _components │ │ │ ├── add-member.tsx │ │ │ └── member-card.tsx │ │ └── page.tsx │ └── page.tsx ├── create │ └── page.tsx ├── favicon.ico ├── globals.css └── layout.tsx ├── components.json ├── components ├── about-side.tsx ├── auth │ └── loading.tsx ├── content.tsx ├── logo.tsx ├── navbar │ ├── index.tsx │ └── select-modal.tsx └── ui │ ├── aspect-ratio.tsx │ ├── avatar.tsx │ ├── button.tsx │ ├── dialog.tsx │ ├── form.tsx │ ├── input.tsx │ ├── label.tsx │ ├── popover.tsx │ ├── scroll-area.tsx │ ├── select.tsx │ ├── separator.tsx │ ├── sonner.tsx │ └── textarea.tsx ├── convex ├── README.md ├── _generated │ ├── api.d.ts │ ├── api.js │ ├── dataModel.d.ts │ ├── server.d.ts │ └── server.js ├── auth.config.ts ├── comments.ts ├── courses.ts ├── groups.ts ├── http.ts ├── lessons.ts ├── likes.ts ├── modules.ts ├── posts.ts ├── schema.ts ├── stripe.ts ├── tsconfig.json └── users.ts ├── hooks └── use-api-mutation.ts ├── lib └── utils.ts ├── middleware.ts ├── next.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.js ├── providers └── convex-client-provider.tsx ├── public ├── logo.svg ├── next.svg ├── thumbnail.png └── vercel.svg ├── tailwind.config.ts └── tsconfig.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Vuk Rosić 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This is code for the following tutorial: 2 | 3 | ![Skool Clone](https://github.com/vukrosic/next14-skool/blob/main/public/thumbnail.png?raw=true) 4 | 5 | [VIDEO TUTORIAL](https://youtu.be/7Ox2ljF05Vo) 6 | 7 | 8 | If you want to run this I recommend following the tutorial. You can also try the following: 9 | 10 | ## Required: 11 | Node version 14.x 12 | 13 | ## Clone this repo 14 | ```bash 15 | git clone https://github.com/vukrosic/next14-skool 16 | ``` 17 | 18 | ## Install packages 19 | ```bash 20 | cd next14-skool & npm install 21 | ``` 22 | 23 | ## Run Convex 24 | ```bash 25 | npx convex dev 26 | ``` 27 | 28 | You will need to setup Clerk, Convex and other things. Don't forget to add your variables to convex website as well. Here is example .env.local file: 29 | 30 | ```env 31 | NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= 32 | CLERK_SECRET_KEY= 33 | 34 | NEXT_PUBLIC_HOSTING_URL= 35 | 36 | STRIPE_SUBSCRIPTION_PRICE_ID= 37 | NEXT_STRIPE_PUBLISHABLE_KEY= 38 | NEXT_STRIPE_SECRET_KEY= 39 | STRIPE_WEBHOOK_SECRET= 40 | ``` 41 | 42 | ## Run the app 43 | ```bash 44 | npm run dev 45 | ``` 46 | 47 | At this point, you application should work, but it probably doesn't. You may check timesteps in the video description or try to read and solve errors by yourself. You can also ask in comments. 48 | 49 | 50 | 51 | 52 | # Next JS documentation below 53 | 54 | 55 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 56 | 57 | ## Getting Started 58 | 59 | First, run the development server: 60 | 61 | ```bash 62 | npm run dev 63 | # or 64 | yarn dev 65 | # or 66 | pnpm dev 67 | # or 68 | bun dev 69 | ``` 70 | 71 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 72 | 73 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 74 | 75 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. 76 | 77 | ## Learn More 78 | 79 | To learn more about Next.js, take a look at the following resources: 80 | 81 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 82 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 83 | 84 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 85 | 86 | ## Deploy on Vercel 87 | 88 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 89 | 90 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 91 | -------------------------------------------------------------------------------- /app/(homepage)/_components/group-card.tsx: -------------------------------------------------------------------------------- 1 | import { ScrollArea } from "@/components/ui/scroll-area"; 2 | import { Doc } from "@/convex/_generated/dataModel"; 3 | import { useRouter } from "next/navigation"; 4 | 5 | interface GroupCardProps { 6 | group: Doc<"groups">; 7 | } 8 | 9 | export const GroupCard = ({ group }: GroupCardProps) => { 10 | const router = useRouter(); 11 | 12 | const handleClick = () => { 13 | router.push(`/${group._id}`); 14 | } 15 | return ( 16 | 17 |

{group.name}

18 |

{group.description}

19 |
20 | ); 21 | } -------------------------------------------------------------------------------- /app/(homepage)/_components/group-list.tsx: -------------------------------------------------------------------------------- 1 | import { Loading } from "@/components/auth/loading"; 2 | import { api } from "@/convex/_generated/api"; 3 | import { useMutation, useQuery } from "convex/react"; 4 | import { GroupCard } from "./group-card"; 5 | import { Button } from "@/components/ui/button"; 6 | import { useRouter } from "next/navigation"; 7 | import { useEffect } from "react"; 8 | 9 | export const GroupList = () => { 10 | const groups = useQuery(api.groups.listAll, {}); 11 | const router = useRouter(); 12 | 13 | const handleCreate = () => { 14 | router.push("/create"); 15 | } 16 | 17 | if (groups === undefined) { 18 | return ; 19 | } 20 | 21 | if (groups.length === 0) { 22 | return
23 | 24 |
; 25 | } 26 | 27 | return ( 28 |
29 | {groups.map((group) => ( 30 | 31 | ))} 32 |
33 | ); 34 | }; -------------------------------------------------------------------------------- /app/(homepage)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Navbar } from "@/components/navbar"; 2 | 3 | interface ChatLayoutProps { 4 | children: React.ReactNode; 5 | } 6 | 7 | export default function ChatLayout({ children }: ChatLayoutProps) { 8 | return ( 9 |
10 | 11 |
12 | {children} 13 |
14 |
15 | ); 16 | }; -------------------------------------------------------------------------------- /app/(homepage)/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { api } from "@/convex/_generated/api"; 4 | import { useMutation } from "convex/react"; 5 | import { useEffect } from "react"; 6 | import { GroupList } from "./_components/group-list"; 7 | 8 | export default function Home() { 9 | const store = useMutation(api.users.store); 10 | useEffect(() => { 11 | const storeUser = async () => { 12 | await store({}); 13 | } 14 | storeUser(); 15 | }, [store]) 16 | return ( 17 |
18 | 19 |
20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /app/[groupId]/_components/comment-list/comment-card.tsx: -------------------------------------------------------------------------------- 1 | import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 2 | import { api } from "@/convex/_generated/api"; 3 | import { Doc } from "@/convex/_generated/dataModel"; 4 | import { useApiMutation } from "@/hooks/use-api-mutation"; 5 | import { useQuery } from "convex/react"; 6 | import { formatDistanceToNow } from "date-fns"; 7 | import { Trash2 } from "lucide-react"; 8 | 9 | interface CommentCardProps { 10 | comment: Doc<"comments">; 11 | author: Doc<"users">; 12 | } 13 | 14 | export const CommentCard = ({ comment, author }: CommentCardProps) => { 15 | const timeAgo = formatDistanceToNow(comment._creationTime); 16 | const currentUser = useQuery(api.users.currentUser, {}); 17 | const isOwner = comment.authorId === currentUser?._id; 18 | 19 | const { 20 | mutate: remove, 21 | pending: removePending 22 | } = useApiMutation(api.comments.remove); 23 | 24 | const handleRemove = () => { 25 | remove({ id: comment._id }); 26 | } 27 | 28 | return ( 29 |
30 | {(isOwner && 31 |
32 | 33 |
34 | )} 35 | 36 | 37 | {author.name.charAt(0)} 38 | 39 |
40 |
41 |

{author.name}

42 |

{timeAgo}

43 |
44 |

{comment.content}

45 |
46 |
47 | ); 48 | }; -------------------------------------------------------------------------------- /app/[groupId]/_components/comment-list/comment-input.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import { Input } from "@/components/ui/input"; 3 | import { api } from "@/convex/_generated/api"; 4 | import { Id } from "@/convex/_generated/dataModel"; 5 | import { useMutation } from "convex/react"; 6 | import { useState } from "react"; 7 | 8 | interface CommentInputProps { 9 | postId: Id<"posts">; 10 | } 11 | 12 | export const CommentInput = ({ 13 | postId 14 | }: CommentInputProps) => { 15 | const add = useMutation(api.comments.add); 16 | const [comment, setComment] = useState(""); 17 | 18 | const handleAdd = async () => { 19 | await add({ postId, content: comment }); 20 | setComment(""); 21 | } 22 | 23 | const handleKeyDown = (e: React.KeyboardEvent) => { 24 | if (e.key === "Enter") { 25 | e.preventDefault(); 26 | handleAdd(); 27 | } 28 | } 29 | 30 | return ( 31 |
32 | setComment(e.target.value)} 36 | onKeyDown={handleKeyDown} 37 | /> 38 | 39 |
40 | ); 41 | } -------------------------------------------------------------------------------- /app/[groupId]/_components/comment-list/index.tsx: -------------------------------------------------------------------------------- 1 | import { Doc, Id } from "@/convex/_generated/dataModel"; 2 | import { CommentCard } from "./comment-card"; 3 | import { Input } from "@/components/ui/input"; 4 | import { CommentInput } from "./comment-input"; 5 | import { ScrollArea } from "@/components/ui/scroll-area"; 6 | import { useEffect, useRef } from "react"; 7 | import { useQuery } from "convex/react"; 8 | import { api } from "@/convex/_generated/api"; 9 | 10 | interface CommentListProps { 11 | post: Doc<"posts"> & { 12 | likes: Doc<"likes">[]; 13 | comments: Doc<"comments">[]; 14 | author: Doc<"users">; 15 | }; 16 | } 17 | 18 | export const CommentList = ({ post }: CommentListProps) => { 19 | const scrollRef = useRef(null); 20 | const comments = useQuery(api.comments.list, { postId: post._id }) || []; 21 | useEffect(() => { 22 | scrollToBottom(); 23 | }, [comments]) 24 | 25 | const scrollToBottom = () => { 26 | if (scrollRef.current) { 27 | scrollRef.current.scrollIntoView({ behavior: "smooth" }); 28 | } 29 | }; 30 | return ( 31 |
32 | 33 | 34 |
35 | {comments.map((comment) => ( 36 | 37 | ))} 38 |
39 |
40 | 41 |
42 | ); 43 | }; -------------------------------------------------------------------------------- /app/[groupId]/_components/create-post-modal/index.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import { 3 | Dialog, 4 | DialogClose, 5 | DialogContent, 6 | DialogDescription, 7 | DialogHeader, 8 | DialogTitle, 9 | DialogTrigger, 10 | } from "@/components/ui/dialog"; 11 | import { Input } from "@/components/ui/input"; 12 | import { Textarea } from "@/components/ui/textarea"; 13 | import { api } from "@/convex/_generated/api"; 14 | import { useApiMutation } from "@/hooks/use-api-mutation"; 15 | import { useState } from "react"; 16 | 17 | interface CreatePostModalProps { 18 | groupId: string; 19 | } 20 | 21 | export const CreatePostModal = ({ 22 | groupId 23 | }: CreatePostModalProps) => { 24 | const { 25 | mutate: createPost, 26 | pending: createPostPending, 27 | } = useApiMutation(api.posts.create); 28 | const [title, setTitle] = useState(""); 29 | const [content, setContent] = useState(""); 30 | 31 | const handlePost = async () => { 32 | if (title === "") return; 33 | console.log("title sent"); 34 | await createPost({ 35 | title, 36 | content, 37 | groupId 38 | }); 39 | } 40 | 41 | const handleKeyDown = (e: React.KeyboardEvent) => { 42 | if (e.key === "Enter") { 43 | e.preventDefault(); 44 | handlePost(); 45 | } 46 | } 47 | 48 | return ( 49 |
50 | 51 | 52 |
Write something
53 |
54 | 55 | 56 | Create a post 57 | 58 | Share your thoughts with the community 59 | 60 | 61 | setTitle(e.target.value)} 66 | /> 67 | {/*