├── .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 | 
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 |
95 |
96 | );
97 | };
--------------------------------------------------------------------------------
/app/[groupId]/_components/group-navbar.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Button } from "@/components/ui/button"
4 | import { Id } from "@/convex/_generated/dataModel";
5 | import { useParams, useRouter } from "next/navigation";
6 |
7 | export const GroupNavbar = () => {
8 | const router = useRouter();
9 | const { groupId } = useParams();
10 |
11 | if (groupId.length === 0 || groupId === undefined) {
12 | router.push("/");
13 | }
14 |
15 | return (
16 |
17 |
18 |
19 |
20 |
21 |
22 | )
23 | }
--------------------------------------------------------------------------------
/app/[groupId]/_components/post-card.tsx:
--------------------------------------------------------------------------------
1 | import { Avatar } from "@/components/ui/avatar";
2 | import { Separator } from "@/components/ui/separator";
3 | import { api } from "@/convex/_generated/api";
4 | import { Doc } from "@/convex/_generated/dataModel";
5 | import { useApiMutation } from "@/hooks/use-api-mutation";
6 | import { cn } from "@/lib/utils";
7 | import { AvatarFallback, AvatarImage } from "@radix-ui/react-avatar";
8 | import { useMutation, useQuery } from "convex/react";
9 | import {
10 | formatDistanceToNow
11 | } from 'date-fns';
12 | import { MessageSquare, PenBox, ThumbsUp, Trash2 } from "lucide-react";
13 | import { Content } from "../../../components/content";
14 | import {
15 | Dialog,
16 | DialogContent,
17 | DialogDescription,
18 | DialogHeader,
19 | DialogTitle,
20 | DialogTrigger,
21 | } from "@/components/ui/dialog"
22 | import { ScrollArea } from "@/components/ui/scroll-area";
23 | import { useEffect } from "react";
24 |
25 |
26 | interface PostCardProps {
27 | post: Doc<"posts"> & {
28 | likes: Doc<"likes">[];
29 | comments: Doc<"comments">[];
30 | author: Doc<"users">;
31 | };
32 | className?: string;
33 | }
34 |
35 | export const PostCard = ({
36 | post,
37 | className,
38 | }: PostCardProps) => {
39 | const currentUser = useQuery(api.users.currentUser, {});
40 | const timeAgo = formatDistanceToNow(post._creationTime);
41 | const likeCount = post.likes.length;
42 | const commentCount = post.comments.length;
43 | const {
44 | mutate: like,
45 | pending: likePending
46 | } = useApiMutation(api.likes.add);
47 | const {
48 | mutate: remove,
49 | pending: removePending
50 | } = useApiMutation(api.posts.remove);
51 |
52 | const handleLike = () => {
53 | like({ postId: post._id });
54 | }
55 |
56 | const handleRemove = () => {
57 | remove({ id: post._id })
58 | }
59 |
60 | const handleAttackToLesson = () => {
61 |
73 |
74 | }
75 |
76 | const isOwner = post.author._id === currentUser?._id;
77 |
78 | return (
79 |
80 | {(isOwner &&
81 |
85 | )}
86 |
87 |
88 |
89 | {post.author.name.charAt(0)}
90 |
91 |
92 |
{post.author.name}
93 |
{timeAgo}
94 |
95 |
96 |
97 |
98 |
{post.title}
99 |
100 |
101 |
107 |
108 |
109 |
110 |
111 |
{likeCount}
112 |
113 |
114 |
115 |
{commentCount}
116 |
117 |
118 |
119 |
120 | );
121 | };
--------------------------------------------------------------------------------
/app/[groupId]/_components/post-modal.tsx:
--------------------------------------------------------------------------------
1 | import { Doc } from "@/convex/_generated/dataModel";
2 | import { PostCard } from "./post-card";
3 | import { CommentList } from "./comment-list";
4 |
5 |
6 | interface PostProps {
7 | post: Doc<"posts"> & {
8 | likes: Doc<"likes">[];
9 | comments: Doc<"comments">[];
10 | author: Doc<"users">;
11 | };
12 | };
13 |
14 | export const Post = ({
15 | post,
16 | }: PostProps) => {
17 |
18 | return (
19 |
26 | );
27 | };
--------------------------------------------------------------------------------
/app/[groupId]/about/_components/join-group-page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { AboutSide } from "@/components/about-side";
4 | import { Loading } from "@/components/auth/loading";
5 | import { Button } from "@/components/ui/button";
6 | import { api } from "@/convex/_generated/api";
7 | import { Id } from "@/convex/_generated/dataModel";
8 | import { useQuery } from "convex/react";
9 | import { Lock } from "lucide-react";
10 | import { useRouter } from "next/navigation";
11 | import { DescriptionEditor } from "../../edit/_components/description-editor";
12 |
13 | interface JoinGroupPageProps {
14 | groupId: Id<"groups">;
15 | };
16 |
17 | export const About = ({
18 | groupId
19 | }: JoinGroupPageProps) => {
20 | const group = useQuery(api.groups.get, { id: groupId });
21 | const currentUser = useQuery(api.users.currentUser, {});
22 | const router = useRouter();
23 |
24 | if (group === undefined) {
25 | return ;
26 | }
27 |
28 | if (group === null) {
29 | router.push("/");
30 | return
31 | }
32 |
33 | const handleEdit = () => {
34 | router.push(`/${groupId}/edit`);
35 | }
36 |
37 | const membersText = group.memberNumber === 1 ? "Member" : "Members";
38 |
39 | return (
40 |
41 |
42 |
{group.name}
43 | {group.aboutUrl && (
44 | <>
45 |
53 | >
54 | )}
55 |
61 |
62 |
63 |
64 | );
65 | }
--------------------------------------------------------------------------------
/app/[groupId]/about/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Id } from "@/convex/_generated/dataModel";
4 |
5 | import { About } from "./_components/join-group-page";
6 |
7 | interface ChatPageProps {
8 | params: {
9 | groupId: Id<"groups">;
10 | }
11 | }
12 |
13 | const Group = ({ params }: ChatPageProps) => {
14 | return (
15 |
18 | )
19 | }
20 |
21 | export default Group;
--------------------------------------------------------------------------------
/app/[groupId]/classroom/[courseId]/_components/curriculum.tsx:
--------------------------------------------------------------------------------
1 | import { Doc, Id } from "@/convex/_generated/dataModel";
2 | import { BookCheck, CaseSensitive, ChevronRight, ChevronsRight, Component, Pen } from "lucide-react";
3 | import { LessonView } from "./lesson-view";
4 | import { useState } from "react";
5 | import { useQuery } from "convex/react";
6 | import { api } from "@/convex/_generated/api";
7 | import { Button } from "@/components/ui/button";
8 | import { useRouter } from "next/navigation";
9 |
10 | interface CurriculumProps {
11 | course: Doc<"courses"> & {
12 | modules: (Doc<"modules"> & {
13 | lessons: Doc<"lessons">[];
14 | })[];
15 | };
16 | groupId: Id<"groups">;
17 | }
18 |
19 |
20 | export const Curriculum = ({ course, groupId }: CurriculumProps) => {
21 | const currentUser = useQuery(api.users.currentUser, {});
22 | const group = useQuery(api.groups.get, { id: course.groupId });
23 | const router = useRouter();
24 | const [selectedLesson, setSelectedLesson] = useState>();
25 |
26 | const handleEditClick = () => {
27 | router.push(`/${groupId}/classroom/${course._id}/edit`);
28 | }
29 |
30 | const isOwner = currentUser?._id === group?.ownerId;
31 |
32 | return (
33 |
34 |
35 | {isOwner && (
36 |
40 | )}
41 |
42 |
43 |
{course.title}
44 |
45 |
46 | {course.modules.map((module) => (
47 |
48 |
49 |
50 |
{module.title}
51 |
52 |
53 |
54 | {module.lessons.map((lesson) => (
55 | - setSelectedLesson(lesson)}
62 | >
63 |
64 |
{lesson.title}
65 |
66 | ))}
67 |
68 |
69 | ))}
70 |
71 |
72 |
73 | {selectedLesson && }
74 |
75 |
76 | );
77 | }
--------------------------------------------------------------------------------
/app/[groupId]/classroom/[courseId]/_components/lesson-view.tsx:
--------------------------------------------------------------------------------
1 | import { Doc } from "@/convex/_generated/dataModel";
2 | import { AspectRatio } from "@/components/ui/aspect-ratio";
3 | import { CaseSensitive, Text } from "lucide-react";
4 |
5 | interface LessonViewProps {
6 | lesson: Doc<"lessons">;
7 | };
8 |
9 | export const LessonView = ({ lesson }: LessonViewProps) => {
10 | return (
11 |
12 |
13 |
14 |
{lesson.title}
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
{lesson.description}
23 |
24 |
25 | )
26 | };
--------------------------------------------------------------------------------
/app/[groupId]/classroom/[courseId]/edit/_components/lesson-editor-view.tsx:
--------------------------------------------------------------------------------
1 | import { Doc } from "@/convex/_generated/dataModel";
2 | import { AspectRatio } from "@/components/ui/aspect-ratio";
3 | import { CaseSensitive, Text } from "lucide-react";
4 | import { Input } from "@/components/ui/input";
5 | import { useEffect, useState } from "react";
6 | import { useMutation } from "convex/react";
7 | import { api } from "@/convex/_generated/api";
8 | import { Button } from "@/components/ui/button";
9 | import { useApiMutation } from "@/hooks/use-api-mutation";
10 | import { toast } from "sonner";
11 |
12 | interface LessonEditorViewProps {
13 | lesson: Doc<"lessons">;
14 | };
15 |
16 | export const LessonEditorView = ({ lesson }: LessonEditorViewProps) => {
17 | const [title, setTitle] = useState(lesson.title);
18 | const [description, setDescription] = useState(lesson.description);
19 | const [videoUrl, setVideoUrl] = useState(lesson.youtubeUrl);
20 | const {
21 | mutate: update,
22 | pending
23 | } = useApiMutation(api.lessons.update);
24 |
25 | useEffect(() => {
26 | setTitle(lesson.title);
27 | setDescription(lesson.description);
28 | setVideoUrl(lesson.youtubeUrl);
29 | }, [lesson]);
30 |
31 |
32 | console.log(title);
33 | const handleSave = () => {
34 | update({
35 | lessonId: lesson._id,
36 | title,
37 | description,
38 | youtubeUrl: videoUrl
39 | });
40 | toast.success("Lesson updated");
41 | }
42 |
43 | return (
44 |
45 |
46 |
47 | setTitle(e.target.value)} />
48 |
49 |
50 |
setVideoUrl(e.target.value)} />
51 |
52 |
Muse be embed link, not a normal link. Go to Share video > Embed and copy the link from IFrame.
53 |
Example: https://www.youtube.com/embed/TalBbvAhdIY?si=lFIwtjTGxE5AgZHe
54 |
55 |
56 |
57 |
58 |
59 |
60 | setDescription(e.target.value)} />
61 |
62 |
65 |
66 | )
67 | };
--------------------------------------------------------------------------------
/app/[groupId]/classroom/[courseId]/edit/_components/module-name-editor.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { api } from "@/convex/_generated/api";
4 | import { Id } from "@/convex/_generated/dataModel";
5 | import { useMutation } from "convex/react";
6 | import { ElementRef, useRef, useState } from "react";
7 | import TextareaAutosize from "react-textarea-autosize";
8 |
9 | interface NameEditorProps {
10 | id: Id<"modules">;
11 | name: string;
12 | }
13 |
14 | export const ModuleNameEditor = ({
15 | id,
16 | name
17 | }: NameEditorProps) => {
18 | const inputRef = useRef>(null);
19 | const [isEditing, setIsEditing] = useState(false);
20 | const [value, setValue] = useState(name);
21 |
22 | const update = useMutation(api.modules.updateTitle);
23 |
24 | const enableInput = () => {
25 | setIsEditing(true);
26 | setTimeout(() => {
27 | setValue(name);
28 | const inputElement = inputRef.current;
29 | inputRef.current?.focus();
30 | inputElement?.setSelectionRange(inputElement.value.length, inputElement.value.length);
31 | }, 0);
32 | };
33 |
34 | const disableEditing = () => setIsEditing(false);
35 |
36 | const onInput = (value: string) => {
37 | setValue(value);
38 | update({
39 | id: id,
40 | title: value || "Untitled"
41 | });
42 | };
43 |
44 | const onKeyDown = (
45 | event: React.KeyboardEvent
46 | ) => {
47 | if (event.key === "Enter") {
48 | event.preventDefault();
49 | disableEditing();
50 | }
51 | };
52 |
53 | return (
54 |
55 | {isEditing ? (
56 |
onInput(e.target.value)}
62 | className="w-full text-md bg-transparent font-bold break-words outline-none text-[#3F3F3F]"
63 | maxLength={60}
64 | />
65 | ) : (
66 |
70 | {name}
71 |
72 | )}
73 |
74 | )
75 | }
--------------------------------------------------------------------------------
/app/[groupId]/classroom/[courseId]/edit/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Doc, Id } from "@/convex/_generated/dataModel";
4 | import { BookCheck, CaseSensitive, ChevronRight, ChevronsRight, Component, Fullscreen, Pen, Plus, Trash2 } from "lucide-react";
5 | import { useState } from "react";
6 | import { useMutation, useQuery } from "convex/react";
7 | import { api } from "@/convex/_generated/api";
8 | import { Button } from "@/components/ui/button";
9 | import { useRouter } from "next/navigation";
10 | import { Input } from "@/components/ui/input";
11 | import { LessonEditorView } from "./_components/lesson-editor-view";
12 | import { ModuleNameEditor } from "./_components/module-name-editor";
13 |
14 | interface CourseEditPageProps {
15 | params: {
16 | groupId: Id<"groups">;
17 | courseId: Id<"courses">;
18 | }
19 | };
20 |
21 |
22 | const CourseEditPage = ({ params }: CourseEditPageProps) => {
23 | const course = useQuery(api.courses.get, { id: params.courseId });
24 | const updateTitle = useMutation(api.courses.updateTitle);
25 | // const updateModuleTitle = useMutation(api.modules.updateTitle);
26 | const updateDescription = useMutation(api.courses.updateDescription);
27 |
28 | const currentUser = useQuery(api.users.currentUser, {});
29 | const group = useQuery(api.groups.get, { id: params.groupId });
30 | const router = useRouter();
31 | const [selectedLesson, setSelectedLesson] = useState>();
32 | const addLesson = useMutation(api.lessons.add);
33 | const addModule = useMutation(api.modules.add);
34 | const removeLesson = useMutation(api.lessons.remove);
35 | const removeModule = useMutation(api.modules.remove);
36 |
37 | const [moduleTitle, setModuleTitle] = useState("");
38 |
39 | if (!course || Array.isArray(course)) return Loading...
;
40 |
41 | const handleEditClick = () => {
42 | router.push(`/${params.groupId}/classroom/${course._id}`);
43 | }
44 |
45 | const handleTitleUpdate = (e: React.ChangeEvent) => {
46 | updateTitle({ title: e.target.value, id: course._id })
47 | }
48 |
49 | // const handleModuleTitleUpdate = (e: React.ChangeEvent) => {
50 | // console.log(e.target.value);
51 | // updateModuleTitle({ title: e.target.value, id: course._id })
52 | // }
53 |
54 | const handleAddLesson = (moduleId: Id<"modules">) => {
55 | addLesson({ moduleId: moduleId });
56 | console.log("Add lesson");
57 | }
58 |
59 | const handleAddModule = (courseId: Id<"courses">) => {
60 | addModule({ courseId: courseId });
61 | console.log("Add module");
62 | }
63 |
64 | const isOwner = currentUser?._id === group?.ownerId;
65 |
66 | if (!isOwner) return Unauthorized
;
67 |
68 | return (
69 |
70 |
71 | {isOwner && (
72 |
76 | )}
77 |
78 |
79 | {/*
{course.title}
*/}
80 |
81 |
82 |
83 | {course.modules.map((module) => (
84 |
85 |
86 |
87 |
92 |
99 |
100 |
101 |
102 | {module.lessons.map((lesson) => (
103 | - setSelectedLesson(lesson)}
110 | >
111 |
112 |
{lesson.title}
113 |
120 |
121 | ))}
122 |
123 |
124 |
128 |
129 | ))}
130 |
134 |
135 |
136 | {selectedLesson && }
137 |
138 |
139 | );
140 | }
141 |
142 | export default CourseEditPage;
--------------------------------------------------------------------------------
/app/[groupId]/classroom/[courseId]/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Id } from "@/convex/_generated/dataModel";
4 | import { Curriculum } from "./_components/curriculum";
5 | import { useQuery } from "convex/react";
6 | import { api } from "@/convex/_generated/api";
7 |
8 | interface CourseProps {
9 | params: {
10 | groupId: Id<"groups">;
11 | courseId: Id<"courses">;
12 | }
13 | };
14 |
15 | const CoursePage = ({ params }: CourseProps) => {
16 | const course = useQuery(api.courses.get, { id: params.courseId });
17 | if (!course || Array.isArray(course)) return Loading...
;
18 | return (
19 |
20 | )
21 | };
22 |
23 | export default CoursePage;
--------------------------------------------------------------------------------
/app/[groupId]/classroom/_components/course-list/course-card.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button";
2 | import { Id } from "@/convex/_generated/dataModel";
3 | import Image from "next/image";
4 | import { useRouter } from "next/navigation";
5 |
6 | interface CourseCardProps {
7 | title: string;
8 | description: string;
9 | thumbnailStorageId: string;
10 | groupId: Id<"groups">;
11 | courseId: Id<"courses">;
12 | };
13 |
14 | export const CourseCard = ({
15 | title,
16 | description,
17 | thumbnailStorageId,
18 | groupId,
19 | courseId
20 | }: CourseCardProps) => {
21 | const router = useRouter();
22 |
23 | const handleClick = () => {
24 | router.push(`/${groupId}/classroom/${courseId}`);
25 | }
26 | return (
27 |
31 |
32 |
33 |
34 |
35 |
{title}
36 |
{description}
37 |
38 |
39 |
40 | );
41 | };
--------------------------------------------------------------------------------
/app/[groupId]/classroom/_components/course-list/index.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { api } from "@/convex/_generated/api";
4 | import { Id } from "@/convex/_generated/dataModel";
5 | import { useQuery } from "convex/react";
6 | import { CourseCard } from "./course-card";
7 | import { useRouter } from "next/navigation";
8 | import { Plus } from "lucide-react";
9 |
10 | interface CourseListProps {
11 | groupId: Id<"groups">;
12 | };
13 |
14 | export const CourseList = ({ groupId }: CourseListProps) => {
15 | const courses = useQuery(api.courses.list, { groupId: groupId });
16 | const currentUser = useQuery(api.users.currentUser, {});
17 | const group = useQuery(api.groups.get, { id: groupId });
18 | const router = useRouter();
19 |
20 | const isOwnerOfGroup = currentUser?._id === group?.ownerId;
21 |
22 | if (courses === undefined) {
23 | return Loading...
;
24 | }
25 |
26 | const handleCreate = () => {
27 | router.push(`/${groupId}/classroom/create`);
28 | };
29 |
30 | return (
31 |
32 | {isOwnerOfGroup && (
33 |
40 | )}
41 | {courses.map((course) => (
42 |
50 | ))}
51 |
52 | );
53 | };
--------------------------------------------------------------------------------
/app/[groupId]/classroom/create/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Logo } from "@/components/logo";
4 | import { Button } from "@/components/ui/button";
5 | import { Input } from "@/components/ui/input";
6 | import { api } from "@/convex/_generated/api";
7 | import { Id } from "@/convex/_generated/dataModel";
8 | import { useApiMutation } from "@/hooks/use-api-mutation";
9 | import { useAction } from "convex/react";
10 | import { useRouter } from "next/navigation";
11 | import { useState } from "react";
12 |
13 | interface CreateCourseProps {
14 | params: {
15 | groupId: Id<"groups">;
16 | }
17 | }
18 |
19 | const CreateCourse = ({ params }: CreateCourseProps) => {
20 | const router = useRouter();
21 | const {
22 | mutate: create,
23 | pending
24 | } = useApiMutation(api.courses.create);
25 | const [title, setTitle] = useState("");
26 | const [description, setDescription] = useState("");
27 |
28 | const handleCreate = async () => {
29 | const courseId = await create({
30 | title,
31 | description,
32 | groupId: params.groupId
33 | });
34 | setTitle("");
35 | setDescription("");
36 | router.push(`/${params.groupId}/classroom/${courseId}`);
37 | }
38 |
39 |
40 | return (
41 |
42 |
43 |
44 |
🎓 Create and share your knowledge with the world through an engaging online course.
45 |
🚀 Drive exceptional learning outcomes
46 |
💖 Set up your course seamlessly
47 |
😄 Enjoy a delightful learning experience
48 |
💸 Monetize through course enrollment
49 |
📱 Accessible via iOS and Android apps
50 |
🌍 Connect with learners worldwide
51 |
52 |
53 |
54 |
55 |
Create a course
56 |
Create your course today and share your knowledge with the world.
57 |
58 |
setTitle(e.target.value)} />
59 |
setDescription(e.target.value)} />
60 |
61 |
62 |
63 | );
64 | }
65 |
66 | export default CreateCourse;
--------------------------------------------------------------------------------
/app/[groupId]/classroom/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Id } from "@/convex/_generated/dataModel";
4 | import { CourseList } from "./_components/course-list";
5 | import { useQuery } from "convex/react";
6 | import { api } from "@/convex/_generated/api";
7 |
8 | interface ClassroomProps {
9 | params: {
10 | groupId: Id<"groups">;
11 | }
12 | };
13 |
14 | const ClassroomPage = ({ params }: ClassroomProps) => {
15 | const group = useQuery(api.groups.get, { id: params.groupId })
16 | if (!group?.endsOn || group?.endsOn < Date.now()) {
17 | return Subscription expired.
;
18 | }
19 | return (
20 |
21 |
22 |
23 | )
24 | };
25 |
26 | export default ClassroomPage;
--------------------------------------------------------------------------------
/app/[groupId]/edit/_components/description-editor.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { api } from "@/convex/_generated/api";
4 | import { Id } from "@/convex/_generated/dataModel";
5 | import { Block } from "@blocknote/core";
6 | import "@blocknote/core/fonts/inter.css";
7 | import { BlockNoteView, useCreateBlockNote } from "@blocknote/react";
8 | import "@blocknote/react/style.css";
9 | import { useMutation } from "convex/react";
10 | import { AlertOctagon } from "lucide-react";
11 | import { toast } from "sonner";
12 | import { useState } from "react";
13 | import { ScrollArea } from "@/components/ui/scroll-area";
14 | import { AnyMxRecord } from "dns";
15 |
16 | interface DescriptionEditorProps {
17 | groupId: Id<"groups">;
18 | initialContent?: string;
19 | editable: boolean;
20 | className?: string;
21 | }
22 |
23 | export const DescriptionEditor = ({
24 | groupId,
25 | initialContent,
26 | editable,
27 | className
28 | }: DescriptionEditorProps) => {
29 | const update = useMutation(api.groups.updateDescription);
30 |
31 | const editor = useCreateBlockNote({
32 | initialContent:
33 | initialContent
34 | ? JSON.parse(initialContent)
35 | : undefined,
36 | });
37 |
38 | const handleChange = () => {
39 | if (editor.document) {
40 | const contentLength = JSON.stringify(editor.document).length;
41 | if (contentLength < 40000) {
42 | update({
43 | id: groupId,
44 | descripton: JSON.stringify(editor.document, null, 2),
45 | });
46 | } else {
47 | toast.error('Content is too long. Not saved.', {
48 | duration: 2000,
49 | icon: ,
50 | });
51 | }
52 | }
53 | };
54 |
55 | return (
56 |
63 | );
64 | }
--------------------------------------------------------------------------------
/app/[groupId]/edit/_components/name-editor.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { api } from "@/convex/_generated/api";
4 | import { Id } from "@/convex/_generated/dataModel";
5 | import { useMutation } from "convex/react";
6 | import { ElementRef, useRef, useState } from "react";
7 | import TextareaAutosize from "react-textarea-autosize";
8 |
9 | interface NameEditorProps {
10 | id: Id<"groups">;
11 | name: string;
12 | }
13 |
14 | export const NameEditor = ({
15 | id,
16 | name
17 | }: NameEditorProps) => {
18 | const inputRef = useRef>(null);
19 | const [isEditing, setIsEditing] = useState(false);
20 | const [value, setValue] = useState(name);
21 |
22 | const update = useMutation(api.groups.updateName);
23 |
24 | const enableInput = () => {
25 | setIsEditing(true);
26 | setTimeout(() => {
27 | setValue(name);
28 | const inputElement = inputRef.current;
29 | inputRef.current?.focus();
30 | inputElement?.setSelectionRange(inputElement.value.length, inputElement.value.length);
31 | }, 0);
32 | };
33 |
34 | const disableEditing = () => setIsEditing(false);
35 |
36 | const onInput = (value: string) => {
37 | setValue(value);
38 | update({
39 | id: id,
40 | name: value || "Untitled"
41 | });
42 | };
43 |
44 | const onKeyDown = (
45 | event: React.KeyboardEvent
46 | ) => {
47 | if (event.key === "Enter") {
48 | event.preventDefault();
49 | disableEditing();
50 | }
51 | };
52 |
53 | return (
54 |
55 | {isEditing ? (
56 |
onInput(e.target.value)}
62 | className="w-full text-5xl bg-transparent font-bold break-words outline-none text-[#3F3F3F]"
63 | maxLength={60}
64 | />
65 | ) : (
66 |
70 | {name}
71 |
72 | )}
73 |
74 | )
75 | }
--------------------------------------------------------------------------------
/app/[groupId]/edit/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Loading } from "@/components/auth/loading";
4 | import { Button } from "@/components/ui/button";
5 | import { Input } from "@/components/ui/input";
6 | import { api } from "@/convex/_generated/api";
7 | import { Id } from "@/convex/_generated/dataModel";
8 | import { useMutation, useQuery } from "convex/react";
9 | import { Lock } from "lucide-react";
10 | import { useRouter } from "next/navigation";
11 | import { NameEditor } from "./_components/name-editor";
12 | import { DescriptionEditor } from "./_components/description-editor";
13 |
14 | interface EditProps {
15 | params: {
16 | groupId: Id<"groups">;
17 | }
18 | };
19 |
20 | const EditPage = ({
21 | params
22 | }: EditProps) => {
23 | const group = useQuery(api.groups.get, { id: params.groupId });
24 | const currentUser = useQuery(api.users.currentUser, {});
25 | const router = useRouter();
26 |
27 | if (group === undefined || currentUser === undefined) {
28 | return ;
29 | }
30 |
31 | if (group === null || currentUser === null) {
32 | router.push("/");
33 | return;
34 | }
35 |
36 | if (currentUser._id !== group.ownerId) {
37 | router.push(`/${params.groupId}`);
38 | }
39 |
40 | const membersText = group.memberNumber === 1 ? "Member" : "Members";
41 |
42 | return (
43 |
44 |
45 |
46 | {group.aboutUrl && (
47 | <>
48 |
56 | >
57 | )}
58 |
64 |
65 |
66 |
{group.name}
67 |
Private group
68 |
{group.shortDescription}
69 |
{group.memberNumber} {membersText}
70 |
71 |
72 |
73 | );
74 | };
75 |
76 | export default EditPage;
--------------------------------------------------------------------------------
/app/[groupId]/layout.tsx:
--------------------------------------------------------------------------------
1 | import { Navbar } from "@/components/navbar";
2 | import { GroupNavbar } from "./_components/group-navbar";
3 |
4 | interface ChatLayoutProps {
5 | children: React.ReactNode;
6 | }
7 |
8 | export default function ChatLayout({ children }: ChatLayoutProps) {
9 | return (
10 |
11 |
12 |
13 |
14 | {children}
15 |
16 |
17 | );
18 | };
--------------------------------------------------------------------------------
/app/[groupId]/members/_components/add-member.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 { useApiMutation } from "@/hooks/use-api-mutation";
6 | import { useMutation } from "convex/react";
7 | import { useState } from "react";
8 | import { toast } from "sonner";
9 |
10 | interface AddMemberProps {
11 | groupId: Id<"groups">;
12 | }
13 |
14 | export const AddMember = ({
15 | groupId
16 | }: AddMemberProps) => {
17 | const [email, setEmail] = useState("");
18 | const {
19 | mutate,
20 | pending
21 | } = useApiMutation(api.users.addToGroup);
22 |
23 | const handleAddMember = () => {
24 | try {
25 | mutate({
26 | groupId: groupId,
27 | email: email
28 | });
29 | } catch (error) {
30 | toast.error("Something went wrong!");
31 | }
32 |
33 | };
34 |
35 | return (
36 |
37 |
Add a user with email:
38 |
setEmail(e.target.value)} />
39 |
40 |
41 | )
42 | }
--------------------------------------------------------------------------------
/app/[groupId]/members/_components/member-card.tsx:
--------------------------------------------------------------------------------
1 | import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
2 | import { api } from "@/convex/_generated/api";
3 | import { Doc, Id } from "@/convex/_generated/dataModel";
4 | import { useQuery } from "convex/react";
5 | import { useParams } from "next/navigation";
6 | import { format } from "date-fns";
7 | import { Calendar } from "lucide-react";
8 |
9 | interface MemberCardProps {
10 | member: Doc<"users">;
11 | }
12 |
13 | export const MemberCard = ({
14 | member
15 | }: MemberCardProps) => {
16 | const { groupId } = useParams();
17 | const group = useQuery(api.groups.get, { id: groupId as Id<"groups"> });
18 |
19 | const formattedDate = format(member._creationTime, 'MMM dd, yyyy');
20 |
21 |
22 | return (
23 |
24 |
25 |
26 | {member.name.charAt(0)}
27 |
28 |
29 |
30 |
{member.name}
31 | {group?.ownerId === member._id &&
(Owner)}
32 |
33 |
{member.about}
34 |
35 |
36 | Joined {formattedDate}
37 |
38 |
39 |
40 | );
41 | };
--------------------------------------------------------------------------------
/app/[groupId]/members/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { api } from "@/convex/_generated/api";
4 | import { Id } from "@/convex/_generated/dataModel";
5 | import { useQuery } from "convex/react";
6 | import { MemberCard } from "./_components/member-card";
7 | import { AddMember } from "./_components/add-member";
8 |
9 | interface MebersPageProps {
10 | params: {
11 | groupId: Id<"groups">;
12 | };
13 | };
14 |
15 | const MebersPage = ({
16 | params
17 | }: MebersPageProps) => {
18 | const members = useQuery(api.groups.getMembers, { id: params.groupId });
19 | const currentUser = useQuery(api.users.currentUser, {});
20 | const group = useQuery(api.groups.get, { id: params.groupId });
21 | if (members === undefined) {
22 | return Loading...
;
23 | }
24 |
25 | if (group === undefined) {
26 | return Loading...
;
27 | }
28 |
29 | if (currentUser === undefined) {
30 | return Loading...
;
31 | }
32 |
33 | const isOwner = group?.ownerId === currentUser?._id;
34 |
35 | return (
36 |
37 | {(isOwner &&
38 |
39 | )}
40 | {members.map((member) => (
41 |
42 | ))}
43 |
44 | )
45 | }
46 | export default MebersPage;
--------------------------------------------------------------------------------
/app/[groupId]/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { api } from "@/convex/_generated/api";
4 | import { Id } from "@/convex/_generated/dataModel";
5 | import { useQuery } from "convex/react";
6 | import { useRouter } from "next/navigation";
7 | import { GroupNavbar } from "./_components/group-navbar";
8 | import { CreatePostModal } from "./_components/create-post-modal";
9 | import { AboutSide } from "@/components/about-side";
10 | import { Post } from "./_components/post-modal";
11 |
12 | interface ChatPageProps {
13 | params: {
14 | groupId: Id<"groups">;
15 | }
16 | }
17 |
18 | const Community = ({ params }: ChatPageProps) => {
19 | const group = useQuery(api.groups.get, { id: params.groupId });
20 | const currentUser = useQuery(api.users.currentUser, {});
21 | const router = useRouter();
22 | const posts = useQuery(api.posts.list, { groupId: params.groupId });
23 |
24 | if (group === undefined) {
25 | return Loading...
;
26 | }
27 |
28 | if (!group?.endsOn || group?.endsOn < Date.now()) {
29 | return Subscription expired.
;
30 | }
31 |
32 |
33 |
34 | if (group === null) {
35 | router.push("/");
36 | return
37 | }
38 |
39 | const handleEdit = () => {
40 | router.push(`/${params.groupId}/edit`);
41 | }
42 |
43 | const membersText = group.memberNumber === 1 ? "Member" : "Members";
44 |
45 |
46 | if (posts === undefined) {
47 | return Loading...
;
48 | }
49 |
50 | return (
51 |
52 |
53 |
54 |
55 | {posts.map((post) => (
56 |
57 | ))}
58 |
59 |
60 |
61 |
62 | )
63 | }
64 |
65 | export default Community;
--------------------------------------------------------------------------------
/app/create/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Logo } from "@/components/logo";
4 | import { Button } from "@/components/ui/button";
5 | import { Input } from "@/components/ui/input";
6 | import { api } from "@/convex/_generated/api";
7 | import { useAction } from "convex/react";
8 | import { useRouter } from "next/navigation";
9 | import { useState } from "react";
10 |
11 | const Create = () => {
12 | const pay = useAction(api.stripe.pay);
13 | const router = useRouter();
14 |
15 | const [name, setName] = useState("");
16 |
17 | const handleCreate = async () => {
18 | const url = await pay({ name });
19 | router.push(url);
20 | }
21 | return (
22 |
23 |
24 |
25 |
🌟 Empower your community and generate income online effortlessly.
26 |
🚀 Drive exceptional engagement
27 |
💖 Set up seamlessly
28 |
😄 Enjoy a delightful user experience
29 |
💸 Monetize through membership fees
30 |
📱 Accessible via iOS and Android apps
31 |
🌍 Connect with millions of daily users around the globe
32 |
33 |
34 |
35 |
36 | Create a group
37 |
38 |
39 | $99/month. Cancel anytime hassle-free.
40 | Access all features with unlimited usage and absolutely no hidden charges.
41 |
42 |
setName(e.target.value)}
46 | />
47 |
50 |
51 |
52 | );
53 | }
54 |
55 | export default Create;
--------------------------------------------------------------------------------
/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vukrosic/next14-skool/637f73cba766c303ca921f9eb481e5a084d16ddd/app/favicon.ico
--------------------------------------------------------------------------------
/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | :root,
6 | html,
7 | body {
8 | height: 100%;
9 | }
10 |
11 | @layer base {
12 | :root {
13 | --background: 0 0% 100%;
14 | --foreground: 222.2 84% 4.9%;
15 |
16 | --card: 0 0% 100%;
17 | --card-foreground: 222.2 84% 4.9%;
18 |
19 | --popover: 0 0% 100%;
20 | --popover-foreground: 222.2 84% 4.9%;
21 |
22 | --primary: 222.2 47.4% 11.2%;
23 | --primary-foreground: 210 40% 98%;
24 |
25 | --secondary: 210 40% 96.1%;
26 | --secondary-foreground: 222.2 47.4% 11.2%;
27 |
28 | --muted: 210 40% 96.1%;
29 | --muted-foreground: 215.4 16.3% 46.9%;
30 |
31 | --accent: 210 40% 96.1%;
32 | --accent-foreground: 222.2 47.4% 11.2%;
33 |
34 | --destructive: 0 84.2% 60.2%;
35 | --destructive-foreground: 210 40% 98%;
36 |
37 | --border: 214.3 31.8% 91.4%;
38 | --input: 214.3 31.8% 91.4%;
39 | --ring: 222.2 84% 4.9%;
40 |
41 | --radius: 0.5rem;
42 | }
43 |
44 | .dark {
45 | --background: 222.2 84% 4.9%;
46 | --foreground: 210 40% 98%;
47 |
48 | --card: 222.2 84% 4.9%;
49 | --card-foreground: 210 40% 98%;
50 |
51 | --popover: 222.2 84% 4.9%;
52 | --popover-foreground: 210 40% 98%;
53 |
54 | --primary: 210 40% 98%;
55 | --primary-foreground: 222.2 47.4% 11.2%;
56 |
57 | --secondary: 217.2 32.6% 17.5%;
58 | --secondary-foreground: 210 40% 98%;
59 |
60 | --muted: 217.2 32.6% 17.5%;
61 | --muted-foreground: 215 20.2% 65.1%;
62 |
63 | --accent: 217.2 32.6% 17.5%;
64 | --accent-foreground: 210 40% 98%;
65 |
66 | --destructive: 0 62.8% 30.6%;
67 | --destructive-foreground: 210 40% 98%;
68 |
69 | --border: 217.2 32.6% 17.5%;
70 | --input: 217.2 32.6% 17.5%;
71 | --ring: 212.7 26.8% 83.9%;
72 | }
73 | }
74 |
75 | @layer base {
76 | * {
77 | @apply border-border;
78 | }
79 |
80 | body {
81 | @apply bg-background text-foreground;
82 | }
83 | }
--------------------------------------------------------------------------------
/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 | import { Inter } from "next/font/google";
3 | import "./globals.css";
4 | import { ConvexClientProvider } from "@/providers/convex-client-provider";
5 | import { Toaster } from "sonner";
6 |
7 | const inter = Inter({ subsets: ["latin"] });
8 |
9 | export const metadata: Metadata = {
10 | title: "Create Next App",
11 | description: "Generated by create next app",
12 | };
13 |
14 | export default function RootLayout({
15 | children,
16 | }: Readonly<{
17 | children: React.ReactNode;
18 | }>) {
19 | return (
20 |
21 |
22 |
23 |
24 | {children}
25 |
26 |
27 |
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "default",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.ts",
8 | "css": "app/globals.css",
9 | "baseColor": "slate",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils"
16 | }
17 | }
--------------------------------------------------------------------------------
/components/about-side.tsx:
--------------------------------------------------------------------------------
1 | import { Lock } from "lucide-react";
2 | import { Button } from "./ui/button";
3 | import { Doc } from "@/convex/_generated/dataModel";
4 |
5 | interface AboutSideProps {
6 | group: Doc<"groups">;
7 | currentUser?: Doc<"users"> | null;
8 | handleEdit: () => void;
9 | membersText: string;
10 | }
11 |
12 | export const AboutSide = ({
13 | group,
14 | currentUser,
15 | handleEdit,
16 | membersText
17 | }: AboutSideProps) => {
18 | return (
19 |
20 |
{group.name}
21 |
Private group
22 |
{group.shortDescription}
23 |
{group.memberNumber} {membersText}
24 | {currentUser?._id !== group.ownerId && (
25 |
26 | )}
27 | {currentUser?._id === group.ownerId && (
28 |
29 | )}
30 |
31 |
32 | );
33 | };
--------------------------------------------------------------------------------
/components/auth/loading.tsx:
--------------------------------------------------------------------------------
1 | import Image from "next/image"
2 |
3 | export const Loading = () => {
4 | return (
5 |
6 |
13 |
14 | )
15 | }
--------------------------------------------------------------------------------
/components/content.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { api } from "@/convex/_generated/api";
4 | import { Id } from "@/convex/_generated/dataModel";
5 | import { Block } from "@blocknote/core";
6 | import "@blocknote/core/fonts/inter.css";
7 | import { BlockNoteView, useCreateBlockNote } from "@blocknote/react";
8 | import "@blocknote/react/style.css";
9 | import { useMutation } from "convex/react";
10 | import { AlertOctagon } from "lucide-react";
11 | import { toast } from "sonner";
12 | import { useState } from "react";
13 | import { ScrollArea } from "./ui/scroll-area";
14 | import { AnyMxRecord } from "dns";
15 |
16 | interface ContentProps {
17 | postId: Id<"posts">;
18 | initialContent?: string;
19 | editable: boolean;
20 | className?: string;
21 | }
22 |
23 | export const Content = ({
24 | postId,
25 | initialContent,
26 | editable,
27 | className
28 | }: ContentProps) => {
29 | const update = useMutation(api.posts.updateContent);
30 |
31 | const editor = useCreateBlockNote({
32 | initialContent:
33 | initialContent
34 | ? JSON.parse(initialContent)
35 | : undefined,
36 | });
37 |
38 | const handleChange = () => {
39 | if (editor.document) {
40 | const contentLength = JSON.stringify(editor.document).length;
41 | if (contentLength < 40000) {
42 | update({
43 | id: postId,
44 | content: JSON.stringify(editor.document, null, 2),
45 | });
46 | } else {
47 | toast.error('Content is too long. Not saved.', {
48 | duration: 2000,
49 | icon: ,
50 | });
51 | }
52 | }
53 | };
54 |
55 | return (
56 |
63 | );
64 | }
--------------------------------------------------------------------------------
/components/logo.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { cn } from "@/lib/utils";
4 | import { useRouter } from "next/navigation";
5 |
6 | interface LogoProps {
7 | className?: string;
8 | }
9 |
10 | export const Logo = ({
11 | className
12 | }: LogoProps) => {
13 | return (
14 |
15 | s
16 | k
17 | u
18 | u
19 | l
20 |
21 | );
22 | }
--------------------------------------------------------------------------------
/components/navbar/index.tsx:
--------------------------------------------------------------------------------
1 | import { UserButton } from "@clerk/nextjs"
2 | import { Logo } from "../logo"
3 | import { SelectModal } from "./select-modal"
4 |
5 | export const Navbar = () => {
6 | return (
7 |
8 |
9 |
10 |
11 | )
12 | }
--------------------------------------------------------------------------------
/components/navbar/select-modal.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { api } from "@/convex/_generated/api"
4 | import { useQuery } from "convex/react"
5 | import { ChevronDown, Compass, Plus, Sparkles, Zap } from "lucide-react";
6 |
7 | import {
8 | Popover,
9 | PopoverContent,
10 | PopoverTrigger,
11 | } from "@/components/ui/popover"
12 | import { useState } from "react";
13 | import { useParams, useRouter } from "next/navigation";
14 | import { Id } from "@/convex/_generated/dataModel";
15 | import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar";
16 | import { Loading } from "../auth/loading";
17 | import { Logo } from "../logo";
18 |
19 |
20 | export const SelectModal = () => {
21 | const currentUser = useQuery(api.users.currentUser, {});
22 | const { groupId } = useParams();
23 | const group = useQuery(api.groups.get, { id: groupId as Id<"groups"> });
24 | const groups = useQuery(api.groups.list);
25 | const router = useRouter();
26 |
27 | const [openSelect, setOpenSelect] = useState(false);
28 |
29 |
30 | if (currentUser === undefined) {
31 | return Loading...
;
32 | }
33 |
34 | if (currentUser === null) {
35 | return User Not Found
;
36 | }
37 |
38 | if (group === undefined) {
39 | return Loading...
;
40 | }
41 |
42 | const toggleOpen = () => {
43 | setOpenSelect(!openSelect);
44 | }
45 |
46 | const handleCreate = () => {
47 | router.push("/create");
48 | toggleOpen();
49 | }
50 |
51 | const handleDiscover = () => {
52 | router.push("/");
53 | toggleOpen();
54 | }
55 |
56 | const handleSelect = (groupId: Id<"groups">) => {
57 | router.push(`/${groupId}`);
58 | toggleOpen();
59 | }
60 |
61 | return (
62 | <>
63 |
64 |
68 | {(group &&
69 | <>
70 |
71 |
72 |
73 | {group.name[0]}
74 |
75 |
76 | {group.name}
77 | >
78 | )}
79 | {(!group &&
80 |
81 | )}
82 |
83 |
84 |
85 |
86 |
87 |
Create a group
88 |
89 |
90 |
91 |
Discover groups
92 |
93 | {groups?.map((group) => (
94 | handleSelect(group._id)}>
95 |
96 |
97 |
98 | {group.name[0]}
99 |
100 |
101 |
{group.name}
102 |
103 |
104 | ))}
105 |
106 |
107 |
108 | >
109 | )
110 | }
--------------------------------------------------------------------------------
/components/ui/aspect-ratio.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
4 |
5 | const AspectRatio = AspectRatioPrimitive.Root
6 |
7 | export { AspectRatio }
8 |
--------------------------------------------------------------------------------
/components/ui/avatar.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as AvatarPrimitive from "@radix-ui/react-avatar"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Avatar = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 | ))
21 | Avatar.displayName = AvatarPrimitive.Root.displayName
22 |
23 | const AvatarImage = React.forwardRef<
24 | React.ElementRef,
25 | React.ComponentPropsWithoutRef
26 | >(({ className, ...props }, ref) => (
27 |
32 | ))
33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName
34 |
35 | const AvatarFallback = React.forwardRef<
36 | React.ElementRef,
37 | React.ComponentPropsWithoutRef
38 | >(({ className, ...props }, ref) => (
39 |
47 | ))
48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
49 |
50 | export { Avatar, AvatarImage, AvatarFallback }
51 |
--------------------------------------------------------------------------------
/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 ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
9 | {
10 | variants: {
11 | variant: {
12 | default: "bg-primary text-primary-foreground hover:bg-primary/90",
13 | destructive:
14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90",
15 | outline:
16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
17 | secondary:
18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80",
19 | ghost: "hover:bg-accent hover:text-accent-foreground",
20 | link: "text-primary underline-offset-4 hover:underline",
21 | },
22 | size: {
23 | default: "h-10 px-4 py-2",
24 | sm: "h-9 rounded-md px-3",
25 | lg: "h-11 rounded-md px-8",
26 | icon: "h-10 w-10",
27 | },
28 | },
29 | defaultVariants: {
30 | variant: "default",
31 | size: "default",
32 | },
33 | }
34 | )
35 |
36 | export interface ButtonProps
37 | extends React.ButtonHTMLAttributes,
38 | VariantProps {
39 | asChild?: boolean
40 | }
41 |
42 | const Button = React.forwardRef(
43 | ({ className, variant, size, asChild = false, ...props }, ref) => {
44 | const Comp = asChild ? Slot : "button"
45 | return (
46 |
51 | )
52 | }
53 | )
54 | Button.displayName = "Button"
55 |
56 | export { Button, buttonVariants }
57 |
--------------------------------------------------------------------------------
/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as DialogPrimitive from "@radix-ui/react-dialog"
5 | import { X } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const Dialog = DialogPrimitive.Root
10 |
11 | const DialogTrigger = DialogPrimitive.Trigger
12 |
13 | const DialogPortal = DialogPrimitive.Portal
14 |
15 | const DialogClose = DialogPrimitive.Close
16 |
17 | const DialogOverlay = React.forwardRef<
18 | React.ElementRef,
19 | React.ComponentPropsWithoutRef
20 | >(({ className, ...props }, ref) => (
21 |
29 | ))
30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
31 |
32 | const DialogContent = React.forwardRef<
33 | React.ElementRef,
34 | React.ComponentPropsWithoutRef
35 | >(({ className, children, ...props }, ref) => (
36 |
37 |
38 |
46 | {children}
47 |
48 |
49 | Close
50 |
51 |
52 |
53 | ))
54 | DialogContent.displayName = DialogPrimitive.Content.displayName
55 |
56 | const DialogHeader = ({
57 | className,
58 | ...props
59 | }: React.HTMLAttributes) => (
60 |
67 | )
68 | DialogHeader.displayName = "DialogHeader"
69 |
70 | const DialogFooter = ({
71 | className,
72 | ...props
73 | }: React.HTMLAttributes) => (
74 |
81 | )
82 | DialogFooter.displayName = "DialogFooter"
83 |
84 | const DialogTitle = React.forwardRef<
85 | React.ElementRef,
86 | React.ComponentPropsWithoutRef
87 | >(({ className, ...props }, ref) => (
88 |
96 | ))
97 | DialogTitle.displayName = DialogPrimitive.Title.displayName
98 |
99 | const DialogDescription = React.forwardRef<
100 | React.ElementRef,
101 | React.ComponentPropsWithoutRef
102 | >(({ className, ...props }, ref) => (
103 |
108 | ))
109 | DialogDescription.displayName = DialogPrimitive.Description.displayName
110 |
111 | export {
112 | Dialog,
113 | DialogPortal,
114 | DialogOverlay,
115 | DialogClose,
116 | DialogTrigger,
117 | DialogContent,
118 | DialogHeader,
119 | DialogFooter,
120 | DialogTitle,
121 | DialogDescription,
122 | }
123 |
--------------------------------------------------------------------------------
/components/ui/form.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as LabelPrimitive from "@radix-ui/react-label"
3 | import { Slot } from "@radix-ui/react-slot"
4 | import {
5 | Controller,
6 | ControllerProps,
7 | FieldPath,
8 | FieldValues,
9 | FormProvider,
10 | useFormContext,
11 | } from "react-hook-form"
12 |
13 | import { cn } from "@/lib/utils"
14 | import { Label } from "@/components/ui/label"
15 |
16 | const Form = FormProvider
17 |
18 | type FormFieldContextValue<
19 | TFieldValues extends FieldValues = FieldValues,
20 | TName extends FieldPath = FieldPath
21 | > = {
22 | name: TName
23 | }
24 |
25 | const FormFieldContext = React.createContext(
26 | {} as FormFieldContextValue
27 | )
28 |
29 | const FormField = <
30 | TFieldValues extends FieldValues = FieldValues,
31 | TName extends FieldPath = FieldPath
32 | >({
33 | ...props
34 | }: ControllerProps) => {
35 | return (
36 |
37 |
38 |
39 | )
40 | }
41 |
42 | const useFormField = () => {
43 | const fieldContext = React.useContext(FormFieldContext)
44 | const itemContext = React.useContext(FormItemContext)
45 | const { getFieldState, formState } = useFormContext()
46 |
47 | const fieldState = getFieldState(fieldContext.name, formState)
48 |
49 | if (!fieldContext) {
50 | throw new Error("useFormField should be used within ")
51 | }
52 |
53 | const { id } = itemContext
54 |
55 | return {
56 | id,
57 | name: fieldContext.name,
58 | formItemId: `${id}-form-item`,
59 | formDescriptionId: `${id}-form-item-description`,
60 | formMessageId: `${id}-form-item-message`,
61 | ...fieldState,
62 | }
63 | }
64 |
65 | type FormItemContextValue = {
66 | id: string
67 | }
68 |
69 | const FormItemContext = React.createContext(
70 | {} as FormItemContextValue
71 | )
72 |
73 | const FormItem = React.forwardRef<
74 | HTMLDivElement,
75 | React.HTMLAttributes
76 | >(({ className, ...props }, ref) => {
77 | const id = React.useId()
78 |
79 | return (
80 |
81 |
82 |
83 | )
84 | })
85 | FormItem.displayName = "FormItem"
86 |
87 | const FormLabel = React.forwardRef<
88 | React.ElementRef,
89 | React.ComponentPropsWithoutRef
90 | >(({ className, ...props }, ref) => {
91 | const { error, formItemId } = useFormField()
92 |
93 | return (
94 |
100 | )
101 | })
102 | FormLabel.displayName = "FormLabel"
103 |
104 | const FormControl = React.forwardRef<
105 | React.ElementRef,
106 | React.ComponentPropsWithoutRef
107 | >(({ ...props }, ref) => {
108 | const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
109 |
110 | return (
111 |
122 | )
123 | })
124 | FormControl.displayName = "FormControl"
125 |
126 | const FormDescription = React.forwardRef<
127 | HTMLParagraphElement,
128 | React.HTMLAttributes
129 | >(({ className, ...props }, ref) => {
130 | const { formDescriptionId } = useFormField()
131 |
132 | return (
133 |
139 | )
140 | })
141 | FormDescription.displayName = "FormDescription"
142 |
143 | const FormMessage = React.forwardRef<
144 | HTMLParagraphElement,
145 | React.HTMLAttributes
146 | >(({ className, children, ...props }, ref) => {
147 | const { error, formMessageId } = useFormField()
148 | const body = error ? String(error?.message) : children
149 |
150 | if (!body) {
151 | return null
152 | }
153 |
154 | return (
155 |
161 | {body}
162 |
163 | )
164 | })
165 | FormMessage.displayName = "FormMessage"
166 |
167 | export {
168 | useFormField,
169 | Form,
170 | FormItem,
171 | FormLabel,
172 | FormControl,
173 | FormDescription,
174 | FormMessage,
175 | FormField,
176 | }
177 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as LabelPrimitive from "@radix-ui/react-label"
5 | import { cva, type VariantProps } from "class-variance-authority"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const labelVariants = cva(
10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
11 | )
12 |
13 | const Label = React.forwardRef<
14 | React.ElementRef,
15 | React.ComponentPropsWithoutRef &
16 | VariantProps
17 | >(({ className, ...props }, ref) => (
18 |
23 | ))
24 | Label.displayName = LabelPrimitive.Root.displayName
25 |
26 | export { Label }
27 |
--------------------------------------------------------------------------------
/components/ui/popover.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as PopoverPrimitive from "@radix-ui/react-popover"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Popover = PopoverPrimitive.Root
9 |
10 | const PopoverTrigger = PopoverPrimitive.Trigger
11 |
12 | const PopoverContent = React.forwardRef<
13 | React.ElementRef,
14 | React.ComponentPropsWithoutRef
15 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
16 |
17 |
27 |
28 | ))
29 | PopoverContent.displayName = PopoverPrimitive.Content.displayName
30 |
31 | export { Popover, PopoverTrigger, PopoverContent }
32 |
--------------------------------------------------------------------------------
/components/ui/scroll-area.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const ScrollArea = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, children, ...props }, ref) => (
12 |
17 |
18 | {children}
19 |
20 |
21 |
22 |
23 | ))
24 | ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
25 |
26 | const ScrollBar = React.forwardRef<
27 | React.ElementRef,
28 | React.ComponentPropsWithoutRef
29 | >(({ className, orientation = "vertical", ...props }, ref) => (
30 |
43 |
44 |
45 | ))
46 | ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
47 |
48 | export { ScrollArea, ScrollBar }
49 |
--------------------------------------------------------------------------------
/components/ui/select.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SelectPrimitive from "@radix-ui/react-select"
5 | import { Check, ChevronDown, ChevronUp } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const Select = SelectPrimitive.Root
10 |
11 | const SelectGroup = SelectPrimitive.Group
12 |
13 | const SelectValue = SelectPrimitive.Value
14 |
15 | const SelectTrigger = React.forwardRef<
16 | React.ElementRef,
17 | React.ComponentPropsWithoutRef
18 | >(({ className, children, ...props }, ref) => (
19 | span]:line-clamp-1",
23 | className
24 | )}
25 | {...props}
26 | >
27 | {children}
28 |
29 |
30 |
31 |
32 | ))
33 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
34 |
35 | const SelectScrollUpButton = React.forwardRef<
36 | React.ElementRef,
37 | React.ComponentPropsWithoutRef
38 | >(({ className, ...props }, ref) => (
39 |
47 |
48 |
49 | ))
50 | SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
51 |
52 | const SelectScrollDownButton = React.forwardRef<
53 | React.ElementRef,
54 | React.ComponentPropsWithoutRef
55 | >(({ className, ...props }, ref) => (
56 |
64 |
65 |
66 | ))
67 | SelectScrollDownButton.displayName =
68 | SelectPrimitive.ScrollDownButton.displayName
69 |
70 | const SelectContent = React.forwardRef<
71 | React.ElementRef,
72 | React.ComponentPropsWithoutRef
73 | >(({ className, children, position = "popper", ...props }, ref) => (
74 |
75 |
86 |
87 |
94 | {children}
95 |
96 |
97 |
98 |
99 | ))
100 | SelectContent.displayName = SelectPrimitive.Content.displayName
101 |
102 | const SelectLabel = React.forwardRef<
103 | React.ElementRef,
104 | React.ComponentPropsWithoutRef
105 | >(({ className, ...props }, ref) => (
106 |
111 | ))
112 | SelectLabel.displayName = SelectPrimitive.Label.displayName
113 |
114 | const SelectItem = React.forwardRef<
115 | React.ElementRef,
116 | React.ComponentPropsWithoutRef
117 | >(({ className, children, ...props }, ref) => (
118 |
126 |
127 |
128 |
129 |
130 |
131 |
132 | {children}
133 |
134 | ))
135 | SelectItem.displayName = SelectPrimitive.Item.displayName
136 |
137 | const SelectSeparator = React.forwardRef<
138 | React.ElementRef,
139 | React.ComponentPropsWithoutRef
140 | >(({ className, ...props }, ref) => (
141 |
146 | ))
147 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName
148 |
149 | export {
150 | Select,
151 | SelectGroup,
152 | SelectValue,
153 | SelectTrigger,
154 | SelectContent,
155 | SelectLabel,
156 | SelectItem,
157 | SelectSeparator,
158 | SelectScrollUpButton,
159 | SelectScrollDownButton,
160 | }
161 |
--------------------------------------------------------------------------------
/components/ui/separator.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SeparatorPrimitive from "@radix-ui/react-separator"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Separator = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(
12 | (
13 | { className, orientation = "horizontal", decorative = true, ...props },
14 | ref
15 | ) => (
16 |
27 | )
28 | )
29 | Separator.displayName = SeparatorPrimitive.Root.displayName
30 |
31 | export { Separator }
32 |
--------------------------------------------------------------------------------
/components/ui/sonner.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useTheme } from "next-themes"
4 | import { Toaster as Sonner } from "sonner"
5 |
6 | type ToasterProps = React.ComponentProps
7 |
8 | const Toaster = ({ ...props }: ToasterProps) => {
9 | const { theme = "system" } = useTheme()
10 |
11 | return (
12 |
28 | )
29 | }
30 |
31 | export { Toaster }
32 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/convex/README.md:
--------------------------------------------------------------------------------
1 | # Welcome to your Convex functions directory!
2 |
3 | Write your Convex functions here.
4 | See https://docs.convex.dev/functions for more.
5 |
6 | A query function that takes two arguments looks like:
7 |
8 | ```ts
9 | // functions.js
10 | import { query } from "./_generated/server";
11 | import { v } from "convex/values";
12 |
13 | export const myQueryFunction = query({
14 | // Validators for arguments.
15 | args: {
16 | first: v.number(),
17 | second: v.string(),
18 | },
19 |
20 | // Function implementation.
21 | handler: async (ctx, args) => {
22 | // Read the database as many times as you need here.
23 | // See https://docs.convex.dev/database/reading-data.
24 | const documents = await ctx.db.query("tablename").collect();
25 |
26 | // Arguments passed from the client are properties of the args object.
27 | console.log(args.first, args.second);
28 |
29 | // Write arbitrary JavaScript here: filter, aggregate, build derived data,
30 | // remove non-public properties, or create new objects.
31 | return documents;
32 | },
33 | });
34 | ```
35 |
36 | Using this query function in a React component looks like:
37 |
38 | ```ts
39 | const data = useQuery(api.functions.myQueryFunction, {
40 | first: 10,
41 | second: "hello",
42 | });
43 | ```
44 |
45 | A mutation function looks like:
46 |
47 | ```ts
48 | // functions.js
49 | import { mutation } from "./_generated/server";
50 | import { v } from "convex/values";
51 |
52 | export const myMutationFunction = mutation({
53 | // Validators for arguments.
54 | args: {
55 | first: v.string(),
56 | second: v.string(),
57 | },
58 |
59 | // Function implementation.
60 | handler: async (ctx, args) => {
61 | // Insert or modify documents in the database here.
62 | // Mutations can also read from the database like queries.
63 | // See https://docs.convex.dev/database/writing-data.
64 | const message = { body: args.first, author: args.second };
65 | const id = await ctx.db.insert("messages", message);
66 |
67 | // Optionally, return a value from your mutation.
68 | return await ctx.db.get(id);
69 | },
70 | });
71 | ```
72 |
73 | Using this mutation function in a React component looks like:
74 |
75 | ```ts
76 | const mutation = useMutation(api.functions.myMutationFunction);
77 | function handleButtonPress() {
78 | // fire and forget, the most common way to use mutations
79 | mutation({ first: "Hello!", second: "me" });
80 | // OR
81 | // use the result once the mutation has completed
82 | mutation({ first: "Hello!", second: "me" }).then((result) =>
83 | console.log(result)
84 | );
85 | }
86 | ```
87 |
88 | Use the Convex CLI to push your functions to a deployment. See everything
89 | the Convex CLI can do by running `npx convex -h` in your project root
90 | directory. To learn more, launch the docs with `npx convex docs`.
91 |
--------------------------------------------------------------------------------
/convex/_generated/api.d.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | /**
3 | * Generated `api` utility.
4 | *
5 | * THIS CODE IS AUTOMATICALLY GENERATED.
6 | *
7 | * Generated by convex@1.10.0.
8 | * To regenerate, run `npx convex dev`.
9 | * @module
10 | */
11 |
12 | import type {
13 | ApiFromModules,
14 | FilterApi,
15 | FunctionReference,
16 | } from "convex/server";
17 | import type * as comments from "../comments.js";
18 | import type * as courses from "../courses.js";
19 | import type * as groups from "../groups.js";
20 | import type * as http from "../http.js";
21 | import type * as lessons from "../lessons.js";
22 | import type * as likes from "../likes.js";
23 | import type * as modules from "../modules.js";
24 | import type * as posts from "../posts.js";
25 | import type * as stripe from "../stripe.js";
26 | import type * as users from "../users.js";
27 |
28 | /**
29 | * A utility for referencing Convex functions in your app's API.
30 | *
31 | * Usage:
32 | * ```js
33 | * const myFunctionReference = api.myModule.myFunction;
34 | * ```
35 | */
36 | declare const fullApi: ApiFromModules<{
37 | comments: typeof comments;
38 | courses: typeof courses;
39 | groups: typeof groups;
40 | http: typeof http;
41 | lessons: typeof lessons;
42 | likes: typeof likes;
43 | modules: typeof modules;
44 | posts: typeof posts;
45 | stripe: typeof stripe;
46 | users: typeof users;
47 | }>;
48 | export declare const api: FilterApi<
49 | typeof fullApi,
50 | FunctionReference
51 | >;
52 | export declare const internal: FilterApi<
53 | typeof fullApi,
54 | FunctionReference
55 | >;
56 |
--------------------------------------------------------------------------------
/convex/_generated/api.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | /**
3 | * Generated `api` utility.
4 | *
5 | * THIS CODE IS AUTOMATICALLY GENERATED.
6 | *
7 | * Generated by convex@1.10.0.
8 | * To regenerate, run `npx convex dev`.
9 | * @module
10 | */
11 |
12 | import { anyApi } from "convex/server";
13 |
14 | /**
15 | * A utility for referencing Convex functions in your app's API.
16 | *
17 | * Usage:
18 | * ```js
19 | * const myFunctionReference = api.myModule.myFunction;
20 | * ```
21 | */
22 | export const api = anyApi;
23 | export const internal = anyApi;
24 |
--------------------------------------------------------------------------------
/convex/_generated/dataModel.d.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | /**
3 | * Generated data model types.
4 | *
5 | * THIS CODE IS AUTOMATICALLY GENERATED.
6 | *
7 | * Generated by convex@1.10.0.
8 | * To regenerate, run `npx convex dev`.
9 | * @module
10 | */
11 |
12 | import type {
13 | DataModelFromSchemaDefinition,
14 | DocumentByName,
15 | TableNamesInDataModel,
16 | SystemTableNames,
17 | } from "convex/server";
18 | import type { GenericId } from "convex/values";
19 | import schema from "../schema.js";
20 |
21 | /**
22 | * The names of all of your Convex tables.
23 | */
24 | export type TableNames = TableNamesInDataModel;
25 |
26 | /**
27 | * The type of a document stored in Convex.
28 | *
29 | * @typeParam TableName - A string literal type of the table name (like "users").
30 | */
31 | export type Doc = DocumentByName<
32 | DataModel,
33 | TableName
34 | >;
35 |
36 | /**
37 | * An identifier for a document in Convex.
38 | *
39 | * Convex documents are uniquely identified by their `Id`, which is accessible
40 | * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids).
41 | *
42 | * Documents can be loaded using `db.get(id)` in query and mutation functions.
43 | *
44 | * IDs are just strings at runtime, but this type can be used to distinguish them from other
45 | * strings when type checking.
46 | *
47 | * @typeParam TableName - A string literal type of the table name (like "users").
48 | */
49 | export type Id =
50 | GenericId;
51 |
52 | /**
53 | * A type describing your Convex data model.
54 | *
55 | * This type includes information about what tables you have, the type of
56 | * documents stored in those tables, and the indexes defined on them.
57 | *
58 | * This type is used to parameterize methods like `queryGeneric` and
59 | * `mutationGeneric` to make them type-safe.
60 | */
61 | export type DataModel = DataModelFromSchemaDefinition;
62 |
--------------------------------------------------------------------------------
/convex/_generated/server.d.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | /**
3 | * Generated utilities for implementing server-side Convex query and mutation functions.
4 | *
5 | * THIS CODE IS AUTOMATICALLY GENERATED.
6 | *
7 | * Generated by convex@1.10.0.
8 | * To regenerate, run `npx convex dev`.
9 | * @module
10 | */
11 |
12 | import {
13 | ActionBuilder,
14 | HttpActionBuilder,
15 | MutationBuilder,
16 | QueryBuilder,
17 | GenericActionCtx,
18 | GenericMutationCtx,
19 | GenericQueryCtx,
20 | GenericDatabaseReader,
21 | GenericDatabaseWriter,
22 | } from "convex/server";
23 | import type { DataModel } from "./dataModel.js";
24 |
25 | /**
26 | * Define a query in this Convex app's public API.
27 | *
28 | * This function will be allowed to read your Convex database and will be accessible from the client.
29 | *
30 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument.
31 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible.
32 | */
33 | export declare const query: QueryBuilder;
34 |
35 | /**
36 | * Define a query that is only accessible from other Convex functions (but not from the client).
37 | *
38 | * This function will be allowed to read from your Convex database. It will not be accessible from the client.
39 | *
40 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument.
41 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible.
42 | */
43 | export declare const internalQuery: QueryBuilder;
44 |
45 | /**
46 | * Define a mutation in this Convex app's public API.
47 | *
48 | * This function will be allowed to modify your Convex database and will be accessible from the client.
49 | *
50 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
51 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
52 | */
53 | export declare const mutation: MutationBuilder;
54 |
55 | /**
56 | * Define a mutation that is only accessible from other Convex functions (but not from the client).
57 | *
58 | * This function will be allowed to modify your Convex database. It will not be accessible from the client.
59 | *
60 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
61 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
62 | */
63 | export declare const internalMutation: MutationBuilder;
64 |
65 | /**
66 | * Define an action in this Convex app's public API.
67 | *
68 | * An action is a function which can execute any JavaScript code, including non-deterministic
69 | * code and code with side-effects, like calling third-party services.
70 | * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
71 | * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
72 | *
73 | * @param func - The action. It receives an {@link ActionCtx} as its first argument.
74 | * @returns The wrapped action. Include this as an `export` to name it and make it accessible.
75 | */
76 | export declare const action: ActionBuilder;
77 |
78 | /**
79 | * Define an action that is only accessible from other Convex functions (but not from the client).
80 | *
81 | * @param func - The function. It receives an {@link ActionCtx} as its first argument.
82 | * @returns The wrapped function. Include this as an `export` to name it and make it accessible.
83 | */
84 | export declare const internalAction: ActionBuilder;
85 |
86 | /**
87 | * Define an HTTP action.
88 | *
89 | * This function will be used to respond to HTTP requests received by a Convex
90 | * deployment if the requests matches the path and method where this action
91 | * is routed. Be sure to route your action in `convex/http.js`.
92 | *
93 | * @param func - The function. It receives an {@link ActionCtx} as its first argument.
94 | * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
95 | */
96 | export declare const httpAction: HttpActionBuilder;
97 |
98 | /**
99 | * A set of services for use within Convex query functions.
100 | *
101 | * The query context is passed as the first argument to any Convex query
102 | * function run on the server.
103 | *
104 | * This differs from the {@link MutationCtx} because all of the services are
105 | * read-only.
106 | */
107 | export type QueryCtx = GenericQueryCtx;
108 |
109 | /**
110 | * A set of services for use within Convex mutation functions.
111 | *
112 | * The mutation context is passed as the first argument to any Convex mutation
113 | * function run on the server.
114 | */
115 | export type MutationCtx = GenericMutationCtx;
116 |
117 | /**
118 | * A set of services for use within Convex action functions.
119 | *
120 | * The action context is passed as the first argument to any Convex action
121 | * function run on the server.
122 | */
123 | export type ActionCtx = GenericActionCtx;
124 |
125 | /**
126 | * An interface to read from the database within Convex query functions.
127 | *
128 | * The two entry points are {@link DatabaseReader.get}, which fetches a single
129 | * document by its {@link Id}, or {@link DatabaseReader.query}, which starts
130 | * building a query.
131 | */
132 | export type DatabaseReader = GenericDatabaseReader;
133 |
134 | /**
135 | * An interface to read from and write to the database within Convex mutation
136 | * functions.
137 | *
138 | * Convex guarantees that all writes within a single mutation are
139 | * executed atomically, so you never have to worry about partial writes leaving
140 | * your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control)
141 | * for the guarantees Convex provides your functions.
142 | */
143 | export type DatabaseWriter = GenericDatabaseWriter;
144 |
--------------------------------------------------------------------------------
/convex/_generated/server.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | /**
3 | * Generated utilities for implementing server-side Convex query and mutation functions.
4 | *
5 | * THIS CODE IS AUTOMATICALLY GENERATED.
6 | *
7 | * Generated by convex@1.10.0.
8 | * To regenerate, run `npx convex dev`.
9 | * @module
10 | */
11 |
12 | import {
13 | actionGeneric,
14 | httpActionGeneric,
15 | queryGeneric,
16 | mutationGeneric,
17 | internalActionGeneric,
18 | internalMutationGeneric,
19 | internalQueryGeneric,
20 | } from "convex/server";
21 |
22 | /**
23 | * Define a query in this Convex app's public API.
24 | *
25 | * This function will be allowed to read your Convex database and will be accessible from the client.
26 | *
27 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument.
28 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible.
29 | */
30 | export const query = queryGeneric;
31 |
32 | /**
33 | * Define a query that is only accessible from other Convex functions (but not from the client).
34 | *
35 | * This function will be allowed to read from your Convex database. It will not be accessible from the client.
36 | *
37 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument.
38 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible.
39 | */
40 | export const internalQuery = internalQueryGeneric;
41 |
42 | /**
43 | * Define a mutation in this Convex app's public API.
44 | *
45 | * This function will be allowed to modify your Convex database and will be accessible from the client.
46 | *
47 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
48 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
49 | */
50 | export const mutation = mutationGeneric;
51 |
52 | /**
53 | * Define a mutation that is only accessible from other Convex functions (but not from the client).
54 | *
55 | * This function will be allowed to modify your Convex database. It will not be accessible from the client.
56 | *
57 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
58 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
59 | */
60 | export const internalMutation = internalMutationGeneric;
61 |
62 | /**
63 | * Define an action in this Convex app's public API.
64 | *
65 | * An action is a function which can execute any JavaScript code, including non-deterministic
66 | * code and code with side-effects, like calling third-party services.
67 | * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
68 | * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
69 | *
70 | * @param func - The action. It receives an {@link ActionCtx} as its first argument.
71 | * @returns The wrapped action. Include this as an `export` to name it and make it accessible.
72 | */
73 | export const action = actionGeneric;
74 |
75 | /**
76 | * Define an action that is only accessible from other Convex functions (but not from the client).
77 | *
78 | * @param func - The function. It receives an {@link ActionCtx} as its first argument.
79 | * @returns The wrapped function. Include this as an `export` to name it and make it accessible.
80 | */
81 | export const internalAction = internalActionGeneric;
82 |
83 | /**
84 | * Define a Convex HTTP action.
85 | *
86 | * @param func - The function. It receives an {@link ActionCtx} as its first argument, and a `Request` object
87 | * as its second.
88 | * @returns The wrapped endpoint function. Route a URL path to this function in `convex/http.js`.
89 | */
90 | export const httpAction = httpActionGeneric;
91 |
--------------------------------------------------------------------------------
/convex/auth.config.ts:
--------------------------------------------------------------------------------
1 | export default {
2 | providers: [
3 | {
4 | domain: "https://solid-airedale-92.clerk.accounts.dev/",
5 | applicationID: "convex",
6 | },
7 | ]
8 | };
--------------------------------------------------------------------------------
/convex/comments.ts:
--------------------------------------------------------------------------------
1 | import { v } from "convex/values";
2 | import { mutation, query } from "./_generated/server";
3 |
4 |
5 | export const add = mutation({
6 | args: { postId: v.id("posts"), content: v.string() },
7 | handler: async (ctx, args) => {
8 | const identity = await ctx.auth.getUserIdentity();
9 | if (!identity) {
10 | throw new Error("Called storeUser without authenticated user");
11 | }
12 |
13 | const user = await ctx.db
14 | .query("users")
15 | .withIndex("by_token", (q) =>
16 | q.eq("tokenIdentifier", identity.tokenIdentifier))
17 | .unique();
18 |
19 | if (user === null) {
20 | return;
21 | }
22 |
23 | const commentId = await ctx.db.insert("comments", {
24 | postId: args.postId,
25 | content: args.content,
26 | authorId: user._id,
27 | });
28 |
29 | return commentId;
30 | }
31 | });
32 |
33 | export const list = query({
34 | args: { postId: v.id("posts") },
35 | handler: async (ctx, args) => {
36 | const comments = await ctx.db
37 | .query("comments")
38 | .withIndex("by_postId", (q) => q.eq("postId", args.postId))
39 | .collect();
40 |
41 | const commentsWithAuthors = await Promise.all(
42 | comments.map(async (comment) => {
43 | const author = await ctx.db.get(comment.authorId);
44 | if (!author) {
45 | throw new Error("Author not found");
46 | }
47 | return {
48 | ...comment,
49 | author,
50 | };
51 | })
52 | );
53 |
54 | return commentsWithAuthors;
55 | }
56 | });
57 |
58 | export const remove = mutation({
59 | args: { id: v.id("comments") },
60 | handler: async (ctx, args) => {
61 | await ctx.db.delete(args.id);
62 | }
63 | });
--------------------------------------------------------------------------------
/convex/courses.ts:
--------------------------------------------------------------------------------
1 | import { v } from "convex/values";
2 | import { mutation, query } from "./_generated/server";
3 |
4 | export const list = query({
5 | args: { groupId: v.id("groups") },
6 | handler: async (ctx, args) => {
7 | const courses = await ctx.db.query("courses")
8 | .withIndex("by_groupId", (q) => q.eq("groupId", args.groupId))
9 | .collect();
10 |
11 | const coursesWithModules = await Promise.all(courses.map(async (course) => {
12 | const modules = await ctx.db.query("modules")
13 | .withIndex("by_courseId", (q) => q.eq("courseId", course._id))
14 | .collect();
15 | return { ...course, modules };
16 | }));
17 |
18 | const coursesWithModulesAndLessons = await Promise.all(coursesWithModules.map(async (course) => {
19 | const modulesWithLessons = await Promise.all(course.modules.map(async (module) => {
20 | const lessons = await ctx.db.query("lessons")
21 | .withIndex("by_moduleId", (q) => q.eq("moduleId", module._id))
22 | .collect();
23 | return { ...module, lessons };
24 | }));
25 | return { ...course, modules: modulesWithLessons };
26 | }));
27 |
28 | return coursesWithModulesAndLessons;
29 | }
30 | });
31 |
32 |
33 | export const create = mutation({
34 | args: {
35 | title: v.string(),
36 | description: v.string(),
37 | groupId: v.id("groups"),
38 | },
39 | handler: async (ctx, { title, description, groupId }) => {
40 | const identity = await ctx.auth.getUserIdentity();
41 | if (!identity) {
42 | throw new Error("Called createGroup without authenticated user");
43 | }
44 |
45 | const user = await ctx.db
46 | .query("users")
47 | .withIndex("by_token", (q) =>
48 | q.eq("tokenIdentifier", identity.tokenIdentifier))
49 | .unique();
50 |
51 | if (!user) {
52 | throw new Error("User not found");
53 | }
54 |
55 | const courseId = await ctx.db.insert("courses", {
56 | title,
57 | description,
58 | groupId,
59 | });
60 |
61 | return courseId;
62 | }
63 | })
64 |
65 | export const get = query({
66 | args: { id: v.id("courses") },
67 | handler: async (ctx, args) => {
68 | const course = await ctx.db.get(args.id);
69 | if (!course) return null;
70 | const modules = await ctx.db.query("modules")
71 | .withIndex("by_courseId", (q) => q.eq("courseId", args.id))
72 | .collect();
73 | const modulesWithLessons = await Promise.all(modules.map(async (module) => {
74 | const lessons = await ctx.db.query("lessons")
75 | .withIndex("by_moduleId", (q) => q.eq("moduleId", module._id))
76 | .collect();
77 | return { ...module, lessons };
78 | }));
79 | return { ...course, modules: modulesWithLessons };
80 | }
81 | });
82 |
83 |
84 | export const updateTitle = mutation({
85 | args: {
86 | id: v.id("courses"),
87 | title: v.string(),
88 | },
89 | handler: async (ctx, { id, title }) => {
90 | await ctx.db.patch(id, { title });
91 | }
92 | });
93 |
94 | export const updateDescription = mutation({
95 | args: {
96 | id: v.id("courses"),
97 | description: v.string(),
98 | },
99 | handler: async (ctx, { id, description }) => {
100 | await ctx.db.patch(id, { description });
101 | }
102 | });
--------------------------------------------------------------------------------
/convex/groups.ts:
--------------------------------------------------------------------------------
1 | import { v } from "convex/values";
2 | import { internalMutation, mutation, query } from "./_generated/server";
3 | import { Doc } from "./_generated/dataModel";
4 |
5 | export const create = mutation({
6 | args: { name: v.string(), description: v.optional(v.string()) },
7 | handler: async (ctx, args) => {
8 | const identity = await ctx.auth.getUserIdentity();
9 | if (!identity) {
10 | throw new Error("Called storeUser without authenticated user");
11 | }
12 |
13 | const user = await ctx.db
14 | .query("users")
15 | .withIndex("by_token", (q) =>
16 | q.eq("tokenIdentifier", identity.tokenIdentifier))
17 | .unique();
18 |
19 | if (user === null) {
20 | return;
21 | throw new Error("User not stored in database.");
22 | }
23 |
24 | const groupId = await ctx.db.insert("groups", {
25 | name: args.name,
26 | description: args.description,
27 | ownerId: user._id,
28 | price: 0,
29 | memberNumber: 1,
30 | });
31 |
32 | const userGroup = await ctx.db.insert("userGroups", {
33 | userId: user._id,
34 | groupId: groupId,
35 | });
36 |
37 | return groupId;
38 | }
39 | });
40 |
41 |
42 | export const get = query({
43 | args: { id: v.optional(v.id("groups")) },
44 | handler: async (ctx, { id }) => {
45 | if (!id) {
46 | return null;
47 | }
48 | const group = await ctx.db.get(id);
49 | return group;
50 | },
51 | });
52 |
53 |
54 | export const list = query({
55 | args: {},
56 | handler: async (ctx) => {
57 | const identity = await ctx.auth.getUserIdentity();
58 | if (!identity) {
59 | throw new Error("Called storeUser without authenticated user");
60 | }
61 |
62 | const user = await ctx.db
63 | .query("users")
64 | .withIndex("by_token", (q) =>
65 | q.eq("tokenIdentifier", identity.tokenIdentifier))
66 | .unique();
67 |
68 | if (user === null) {
69 | throw new Error("User not stored in database.");
70 | }
71 |
72 | const userGroups = await ctx.db
73 | .query("userGroups")
74 | .withIndex("by_userId", (q) => q.eq("userId", user._id))
75 | .collect();
76 |
77 | // now get all groups that this user belongs to
78 | const groups = userGroups.map(async (userGroup) => {
79 | const group = await ctx.db.get(userGroup.groupId);
80 | return group;
81 | });
82 |
83 | const resolvedGroups = await Promise.all(groups);
84 |
85 | const filteredGroups = resolvedGroups.filter(group => group !== null) as Doc<"groups">[];
86 |
87 | return filteredGroups;
88 | }
89 | });
90 |
91 |
92 | export const getMembers = query({
93 | args: { id: v.id("groups") },
94 | handler: async (ctx, { id }) => {
95 | const members = await ctx.db
96 | .query("userGroups")
97 | .withIndex("by_groupId", (q) => q.eq("groupId", id))
98 | .collect();
99 |
100 | const resolvedMembers = await Promise.all(members.map(async (member) => {
101 | const user = await ctx.db.get(member.userId);
102 | return user;
103 | }));
104 |
105 | const filteredMembers = resolvedMembers.filter(member => member !== null) as Doc<"users">[];
106 |
107 | return filteredMembers;
108 | },
109 | });
110 |
111 |
112 | export const listAll = query({
113 | args: {},
114 | handler: async (ctx) => {
115 | const identity = await ctx.auth.getUserIdentity();
116 | if (!identity) {
117 | throw new Error("Called storeUser without authenticated user");
118 | }
119 |
120 | const groups = await ctx.db.query("groups").collect();
121 |
122 | return groups;
123 | }
124 | });
125 |
126 |
127 | export const updateName = mutation({
128 | args: { id: v.id("groups"), name: v.string() },
129 | handler: async (ctx, args) => {
130 | const identity = await ctx.auth.getUserIdentity();
131 |
132 | if (!identity) {
133 | throw new Error("Unauthorized");
134 | }
135 |
136 | const name = args.name.trim();
137 |
138 | if (!name) {
139 | throw new Error("name is required");
140 | }
141 |
142 | if (name.length > 60) {
143 | throw new Error("name cannot be longer than 60 characters")
144 | }
145 |
146 | const group = await ctx.db.patch(args.id, {
147 | name: args.name,
148 | });
149 |
150 | return group;
151 | },
152 | });
153 |
154 | export const updateDescription = mutation({
155 | args: { id: v.id("groups"), descripton: v.string() },
156 | handler: async (ctx, args) => {
157 | const identity = await ctx.auth.getUserIdentity();
158 |
159 | if (!identity) {
160 | throw new Error("Unauthorized");
161 | }
162 |
163 | const description = args.descripton.trim();
164 |
165 | if (!description) {
166 | throw new Error("Description is required");
167 | }
168 |
169 | if (description.length > 40000) {
170 | throw new Error("Description is too long.")
171 | }
172 |
173 | const group = await ctx.db.patch(args.id, {
174 | description: args.descripton,
175 | });
176 |
177 | return group;
178 | },
179 | });
180 |
181 |
182 | //update subscription
183 | export const updateSubscription = internalMutation({
184 | args: { subscriptionId: v.string(), groupId: v.id("groups"), endsOn: v.number() },
185 | handler: async (ctx, { subscriptionId, groupId, endsOn }) => {
186 | await ctx.db.patch(groupId, {
187 | subscriptionId: subscriptionId,
188 | endsOn: endsOn
189 | });
190 | },
191 | });
192 |
193 | //update subscription by id
194 | export const updateSubscriptionById = internalMutation({
195 | args: { subscriptionId: v.string(), endsOn: v.number() },
196 | handler: async (ctx, { subscriptionId, endsOn }) => {
197 | const user = await ctx.db.query("groups")
198 | .withIndex("by_subscriptionId", (q) => q.eq("subscriptionId", subscriptionId))
199 | .unique();
200 |
201 | if (!user) {
202 | throw new Error("User not found!");
203 | }
204 |
205 | await ctx.db.patch(user._id, {
206 | endsOn: endsOn
207 | });
208 | },
209 | });
--------------------------------------------------------------------------------
/convex/http.ts:
--------------------------------------------------------------------------------
1 | import { httpRouter } from "convex/server";
2 | import { httpAction } from "./_generated/server";
3 | import { internal } from "./_generated/api";
4 |
5 | const http = httpRouter();
6 |
7 | http.route({
8 | path: "/stripe",
9 | method: "POST",
10 | handler: httpAction(async (ctx, request) => {
11 | const signature: string = request.headers.get("stripe-signature") as string;
12 | const result = await ctx.runAction(internal.stripe.fulfill, {
13 | signature,
14 | payload: await request.text(),
15 | });
16 | if (result.success) {
17 | return new Response(null, {
18 | status: 200,
19 | });
20 | } else {
21 | return new Response("Webhook Error", {
22 | status: 400,
23 | });
24 | }
25 | }),
26 | });
27 |
28 | export default http;
--------------------------------------------------------------------------------
/convex/lessons.ts:
--------------------------------------------------------------------------------
1 | import { v } from "convex/values";
2 | import { mutation } from "./_generated/server";
3 |
4 | export const add = mutation({
5 | args: {
6 | moduleId: v.id("modules"),
7 | },
8 | handler: async (ctx, { moduleId }) => {
9 | const identity = await ctx.auth.getUserIdentity();
10 | if (!identity) {
11 | throw new Error("Called createGroup without authenticated user");
12 | }
13 | const lessonId = await ctx.db.insert("lessons", {
14 | moduleId,
15 | title: "New Lesson",
16 | description: "New Lesson Description",
17 | youtubeUrl: "",
18 | });
19 | return lessonId;
20 | },
21 | });
22 |
23 | export const remove = mutation({
24 | args: {
25 | lessonId: v.id("lessons"),
26 | },
27 | handler: async (ctx, { lessonId }) => {
28 | const identity = await ctx.auth.getUserIdentity();
29 | if (!identity) {
30 | throw new Error("Called createGroup without authenticated user");
31 | }
32 | await ctx.db.delete(lessonId);
33 | },
34 | });
35 |
36 | export const update = mutation({
37 | args: {
38 | title: v.string(),
39 | description: v.string(),
40 | youtubeUrl: v.string(),
41 | lessonId: v.id("lessons"),
42 | },
43 | handler: async (ctx, { title, description, youtubeUrl, lessonId }) => {
44 | const identity = await ctx.auth.getUserIdentity();
45 | if (!identity) {
46 | throw new Error("Called createGroup without authenticated user");
47 | }
48 | await ctx.db.patch(lessonId, {
49 | title,
50 | description,
51 | youtubeUrl,
52 | });
53 | },
54 | })
--------------------------------------------------------------------------------
/convex/likes.ts:
--------------------------------------------------------------------------------
1 | import { v } from "convex/values";
2 | import { mutation } from "./_generated/server";
3 |
4 | export const add = mutation({
5 | args: { postId: v.id("posts") },
6 | handler: async (ctx, args) => {
7 | const identity = await ctx.auth.getUserIdentity();
8 | if (!identity) {
9 | throw new Error("Called storeUser without authenticated user");
10 | }
11 |
12 | const user = await ctx.db
13 | .query("users")
14 | .withIndex("by_token", (q) =>
15 | q.eq("tokenIdentifier", identity.tokenIdentifier))
16 | .unique();
17 |
18 | if (user === null) {
19 | return;
20 | }
21 |
22 | // check if user already liked the post
23 | const liked = await ctx.db
24 | .query("likes")
25 | .withIndex("by_postId_userId", (q) =>
26 | q.eq("postId", args.postId).eq("userId", user._id))
27 | .unique();
28 |
29 | if (liked) {
30 | await ctx.db.delete(liked._id);
31 | }
32 | else {
33 | await ctx.db.insert("likes", {
34 | postId: args.postId,
35 | userId: user._id,
36 | });
37 | }
38 | }
39 | });
--------------------------------------------------------------------------------
/convex/modules.ts:
--------------------------------------------------------------------------------
1 | import { v } from "convex/values";
2 | import { mutation } from "./_generated/server";
3 |
4 | export const add = mutation({
5 | args: {
6 | courseId: v.id("courses"),
7 | },
8 | handler: async (ctx, { courseId }) => {
9 | const identity = await ctx.auth.getUserIdentity();
10 | if (!identity) {
11 | throw new Error("Called createGroup without authenticated user");
12 | }
13 | const lessonId = await ctx.db.insert("modules", {
14 | courseId,
15 | title: "New Module",
16 | });
17 | return lessonId;
18 | },
19 | });
20 |
21 | export const remove = mutation({
22 | args: {
23 | moduleId: v.id("modules"),
24 | },
25 | handler: async (ctx, { moduleId }) => {
26 | const identity = await ctx.auth.getUserIdentity();
27 | if (!identity) {
28 | throw new Error("Called createGroup without authenticated user");
29 | }
30 | await ctx.db.delete(moduleId);
31 | },
32 | })
33 |
34 | export const updateTitle = mutation({
35 | args: {
36 | id: v.id("modules"),
37 | title: v.string(),
38 | },
39 | handler: async (ctx, { id, title }) => {
40 | await ctx.db.patch(id, { title });
41 | },
42 | });
--------------------------------------------------------------------------------
/convex/posts.ts:
--------------------------------------------------------------------------------
1 | import { v } from "convex/values";
2 | import { mutation, query } from "./_generated/server";
3 |
4 | export const list = query({
5 | args: { groupId: v.id("groups") },
6 | handler: async (ctx, { groupId }) => {
7 | const posts = await ctx.db
8 | .query("posts")
9 | .withIndex("by_groupId", (q) => q.eq("groupId", groupId))
10 | .collect();
11 |
12 | const postsWithAuthors = await Promise.all(
13 | posts.map(async (post) => {
14 | const author = await ctx.db.get(post.authorId);
15 | if (!author) {
16 | throw new Error("Author not found");
17 | }
18 | return {
19 | ...post,
20 | author,
21 | };
22 | })
23 | );
24 |
25 | const postsWithAuthorsAndComments = await Promise.all(
26 | postsWithAuthors.map(async (post) => {
27 | const comments = await ctx.db
28 | .query("comments")
29 | .withIndex("by_postId", (q) => q.eq("postId", post._id))
30 | .collect();
31 |
32 | const commentsWithAuthors = await Promise.all(
33 | comments.map(async (comment) => {
34 | const author = await ctx.db.get(comment.authorId);
35 | if (!author) {
36 | throw new Error("Author not found");
37 | }
38 | return {
39 | ...comment,
40 | author,
41 | };
42 | })
43 | );
44 |
45 | return {
46 | ...post,
47 | comments: commentsWithAuthors,
48 | };
49 | })
50 | );
51 |
52 | const postsWithAuthorsAndCommentsAndLikes = await Promise.all(
53 | postsWithAuthorsAndComments.map(async (post) => {
54 | const likes = await ctx.db
55 | .query("likes")
56 | .withIndex("by_postId", (q) => q.eq("postId", post._id))
57 | .collect();
58 |
59 | return {
60 | ...post,
61 | likes,
62 | };
63 | })
64 | );
65 | return postsWithAuthorsAndCommentsAndLikes;
66 | }
67 | });
68 |
69 |
70 | export const create = mutation({
71 | args: {
72 | title: v.string(),
73 | content: v.string(),
74 | groupId: v.id("groups"),
75 | },
76 | handler: async (ctx, { title, content, groupId }) => {
77 | const identity = await ctx.auth.getUserIdentity();
78 | if (!identity) {
79 | throw new Error("Called createGroup without authenticated user");
80 | }
81 |
82 | const user = await ctx.db
83 | .query("users")
84 | .withIndex("by_token", (q) =>
85 | q.eq("tokenIdentifier", identity.tokenIdentifier))
86 | .unique();
87 |
88 | if (!user) {
89 | throw new Error("User not found");
90 | }
91 |
92 | const postId = await ctx.db.insert("posts", {
93 | title,
94 | content,
95 | authorId: user._id,
96 | groupId,
97 | });
98 |
99 | return postId;
100 | }
101 | });
102 |
103 |
104 |
105 | export const remove = mutation({
106 | args: { id: v.id("posts") },
107 | handler: async (ctx, { id }) => {
108 |
109 | // delete all comments
110 | const comments = await ctx.db
111 | .query("comments")
112 | .withIndex("by_postId", (q) => q.eq("postId", id))
113 | .collect();
114 |
115 | await Promise.all(comments.map(async (comment) => {
116 | await ctx.db.delete(comment._id);
117 | }));
118 |
119 | // delete all likes
120 | const likes = await ctx.db
121 | .query("likes")
122 | .withIndex("by_postId", (q) => q.eq("postId", id))
123 | .collect();
124 |
125 | await Promise.all(likes.map(async (like) => {
126 | await ctx.db.delete(like._id);
127 | }));
128 |
129 | // delete the post
130 | await ctx.db.delete(id);
131 | },
132 | });
133 |
134 |
135 | export const updateContent = mutation({
136 | args: { id: v.id("posts"), content: v.string() },
137 | handler: async (ctx, args) => {
138 | const identity = await ctx.auth.getUserIdentity();
139 |
140 | if (!identity) {
141 | throw new Error("Unauthorized");
142 | }
143 |
144 | const content = args.content.trim();
145 |
146 | if (!content) {
147 | throw new Error("Content is required");
148 | }
149 |
150 | if (content.length > 40000) {
151 | throw new Error("Content is too long!")
152 | }
153 |
154 | const post = await ctx.db.patch(args.id, {
155 | content: args.content,
156 | });
157 |
158 | return post;
159 | },
160 | });
--------------------------------------------------------------------------------
/convex/schema.ts:
--------------------------------------------------------------------------------
1 | import { defineSchema, defineTable } from "convex/server";
2 | import { v } from "convex/values";
3 |
4 | export default defineSchema({
5 | users: defineTable({
6 | tokenIdentifier: v.string(),
7 | name: v.string(),
8 | profileUrl: v.optional(v.string()),
9 | about: v.optional(v.string()),
10 | email: v.string(),
11 | })
12 | .index("by_token", ["tokenIdentifier"])
13 | .index("by_email", ["email"]),
14 | groups: defineTable({
15 | name: v.string(),
16 | description: v.optional(v.string()),
17 | shortDescription: v.optional(v.string()),
18 | aboutUrl: v.optional(v.string()),
19 | ownerId: v.id("users"),
20 | price: v.number(),
21 | memberNumber: v.number(),
22 | endsOn: v.optional(v.number()),
23 | subscriptionId: v.optional(v.string()),
24 | })
25 | .index("by_name", ["name"])
26 | .index("by_ownerId", ["ownerId"])
27 | .index("by_subscriptionId", ["subscriptionId"]),
28 | userGroups: defineTable({
29 | userId: v.id("users"),
30 | groupId: v.id("groups"),
31 | })
32 | .index("by_userId", ["userId"])
33 | .index("by_groupId", ["groupId"]),
34 | posts: defineTable({
35 | title: v.string(),
36 | content: v.string(),
37 | authorId: v.id("users"),
38 | groupId: v.id("groups"),
39 | lessonId: v.optional(v.id("lessons")),
40 | })
41 | .index("by_title", ["title"])
42 | .index("by_groupId", ["groupId"]),
43 | comments: defineTable({
44 | postId: v.id("posts"),
45 | content: v.string(),
46 | authorId: v.id("users"),
47 | })
48 | .index("by_postId", ["postId"]),
49 | likes: defineTable({
50 | postId: v.id("posts"),
51 | userId: v.id("users"),
52 | })
53 | .index("by_postId", ["postId"])
54 | .index("by_postId_userId", ["postId", "userId"]),
55 | courses: defineTable({
56 | title: v.string(),
57 | description: v.string(),
58 | groupId: v.id("groups"),
59 | })
60 | .index("by_groupId", ["groupId"]),
61 | modules: defineTable({
62 | title: v.string(),
63 | courseId: v.id("courses"),
64 | })
65 | .index("by_courseId", ["courseId"]),
66 | lessons: defineTable({
67 | title: v.string(),
68 | description: v.string(),
69 | moduleId: v.id("modules"),
70 | youtubeUrl: v.string(),
71 | })
72 | .index("by_moduleId", ["moduleId"]),
73 | })
--------------------------------------------------------------------------------
/convex/stripe.ts:
--------------------------------------------------------------------------------
1 | import { v } from "convex/values";
2 | import { api, internal } from "./_generated/api";
3 | import { action, internalAction } from "./_generated/server";
4 | import Stripe from 'stripe';
5 | import { Id } from "./_generated/dataModel";
6 |
7 | export const pay = action({
8 | args: { name: v.string() },
9 | handler: async (ctx, args) => {
10 | const clerkUser = await ctx.auth.getUserIdentity();
11 | const user = await ctx.runQuery(api.users.currentUser, {});
12 |
13 | if (!user || !clerkUser) {
14 | throw new Error("User not authenticated!");
15 | }
16 |
17 | if (!clerkUser.emailVerified) {
18 | throw new Error("User email not verified!");
19 | }
20 |
21 | const groupId = await ctx.runMutation(api.groups.create, { name: args.name });
22 |
23 | if (!groupId) {
24 | throw new Error("Group not created!");
25 | }
26 |
27 | const stripe = new Stripe(process.env.NEXT_STRIPE_SECRET_KEY!, {
28 | apiVersion: "2023-10-16"
29 | });
30 |
31 | const domain = process.env.NEXT_PUBLIC_HOSTING_URL!;
32 | // const session: Stripe.Response = await stripe.checkout.sessions.create(
33 | const session: any = await stripe.checkout.sessions.create(
34 | {
35 | mode: "subscription",
36 | line_items: [
37 | {
38 | price: process.env.STRIPE_SUBSCRIPTION_PRICE_ID!,
39 | quantity: 1,
40 | },
41 | ],
42 | customer_email: clerkUser.email,
43 | metadata: {
44 | groupId: groupId,
45 | },
46 | success_url: `${domain}/${groupId}`,
47 | cancel_url: `${domain}`,
48 | }
49 | );
50 | return session.url;
51 | },
52 | });
53 |
54 | type Metadata = {
55 | groupId: Id<"groups">;
56 | }
57 |
--------------------------------------------------------------------------------
/convex/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | /* This TypeScript project config describes the environment that
3 | * Convex functions run in and is used to typecheck them.
4 | * You can modify it, but some settings required to use Convex.
5 | */
6 | "compilerOptions": {
7 | /* These settings are not required by Convex and can be modified. */
8 | "allowJs": true,
9 | "strict": true,
10 |
11 | /* These compiler options are required by Convex */
12 | "target": "ESNext",
13 | "lib": ["ES2021", "dom"],
14 | "forceConsistentCasingInFileNames": true,
15 | "allowSyntheticDefaultImports": true,
16 | "module": "ESNext",
17 | "moduleResolution": "Node",
18 | "isolatedModules": true,
19 | "skipLibCheck": true,
20 | "noEmit": true
21 | },
22 | "include": ["./**/*"],
23 | "exclude": ["./_generated"]
24 | }
25 |
--------------------------------------------------------------------------------
/convex/users.ts:
--------------------------------------------------------------------------------
1 | import { v } from "convex/values";
2 | import { mutation, query } from "./_generated/server";
3 |
4 |
5 | export const store = mutation({
6 | args: {},
7 | handler: async (ctx) => {
8 | const identity = await ctx.auth.getUserIdentity();
9 | if (!identity) {
10 | throw new Error("Called storeUser without authenticated user");
11 | }
12 |
13 | // check if user is already stored
14 | const user = await ctx.db
15 | .query("users")
16 | .withIndex("by_token", (q) =>
17 | q.eq("tokenIdentifier", identity.tokenIdentifier))
18 | .unique();
19 |
20 | if (user !== null) {
21 | return user._id;
22 | }
23 |
24 | const userId = await ctx.db.insert("users", {
25 | tokenIdentifier: identity.tokenIdentifier,
26 | name: identity.name!,
27 | profileUrl: identity.profileUrl,
28 | email: identity.email!,
29 | });
30 |
31 | return userId;
32 | }
33 | });
34 |
35 |
36 |
37 | export const currentUser = query({
38 | args: {},
39 | handler: async (ctx) => {
40 | const identity = await ctx.auth.getUserIdentity();
41 | if (!identity) {
42 | throw new Error("Called selectGPT without authenticated user");
43 | }
44 |
45 | return await ctx.db
46 | .query("users")
47 | .withIndex("by_token", (q) =>
48 | q.eq("tokenIdentifier", identity.tokenIdentifier))
49 | .unique();
50 | }
51 | })
52 |
53 |
54 | export const addToGroup = mutation({
55 | args: {
56 | email: v.string(),
57 | groupId: v.id("groups"),
58 | },
59 | handler: async (ctx, { email, groupId }) => {
60 | const identity = await ctx.auth.getUserIdentity();
61 | if (!identity) {
62 | throw new Error("Called addToGroup without authenticated user");
63 | }
64 |
65 | const currentUser = await ctx.db
66 | .query("users")
67 | .withIndex("by_token", (q) =>
68 | q.eq("tokenIdentifier", identity.tokenIdentifier))
69 | .unique();
70 |
71 | if (!currentUser) {
72 | throw new Error("User not found!");
73 | }
74 |
75 | const group = await ctx.db.get(groupId);
76 |
77 | if (!group) {
78 | throw new Error("Group not found!");
79 | }
80 |
81 | if (currentUser._id !== group.ownerId) {
82 | return;
83 | throw new Error("User is not the owner of the group!");
84 | }
85 |
86 | const newUser = await ctx.db
87 | .query("users")
88 | .withIndex("by_email", (q) => q.eq("email", email))
89 | .unique();
90 |
91 | if (!newUser) {
92 | throw new Error("User not found!");
93 | }
94 |
95 | await ctx.db.insert("userGroups", {
96 | userId: newUser._id,
97 | groupId
98 | })
99 | },
100 | });
--------------------------------------------------------------------------------
/hooks/use-api-mutation.ts:
--------------------------------------------------------------------------------
1 | import { useMutation } from "convex/react";
2 | import { useState } from "react"
3 |
4 | export const useApiMutation = (mutationFunction: any) => {
5 | const [pending, setPending] = useState(false);
6 | const apiMutation = useMutation(mutationFunction);
7 |
8 | const mutate = (payload: any) => {
9 | setPending(true);
10 | return apiMutation(payload)
11 | .then((result) => {
12 | return result;
13 | })
14 | .catch((error) => {
15 | throw error;
16 | })
17 | .finally(() => {
18 | setPending(false)
19 | })
20 | };
21 |
22 | return {
23 | mutate,
24 | pending,
25 | }
26 | };
--------------------------------------------------------------------------------
/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx"
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/middleware.ts:
--------------------------------------------------------------------------------
1 | import { authMiddleware } from "@clerk/nextjs";
2 |
3 | export default authMiddleware({
4 | // Routes that can be accessed while signed out
5 | publicRoutes: [],
6 | // Routes that can always be accessed, and have
7 | // no authentication information
8 | ignoredRoutes: [],
9 | });
10 |
11 | export const config = {
12 | // Protects all routes, including api/trpc.
13 | // See https://clerk.com/docs/references/nextjs/auth-middleware
14 | // for more information about configuring your Middleware
15 | matcher: ["/((?!.+\\.[\\w]+$|_next).*)", "/", "/(api|trpc)(.*)"],
16 | };
--------------------------------------------------------------------------------
/next.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {};
3 |
4 | export default nextConfig;
5 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "next14-skool",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@blocknote/core": "^0.12.1",
13 | "@blocknote/react": "^0.12.2",
14 | "@clerk/nextjs": "^4.29.9",
15 | "@hookform/resolvers": "^3.3.4",
16 | "@radix-ui/react-aspect-ratio": "^1.0.3",
17 | "@radix-ui/react-avatar": "^1.0.4",
18 | "@radix-ui/react-dialog": "^1.0.5",
19 | "@radix-ui/react-label": "^2.0.2",
20 | "@radix-ui/react-popover": "^1.0.7",
21 | "@radix-ui/react-scroll-area": "^1.0.5",
22 | "@radix-ui/react-select": "^2.0.0",
23 | "@radix-ui/react-separator": "^1.0.3",
24 | "@radix-ui/react-slot": "^1.0.2",
25 | "class-variance-authority": "^0.7.0",
26 | "clsx": "^2.1.0",
27 | "convex": "^1.10.0",
28 | "date-fns": "^3.6.0",
29 | "lucide-react": "^0.358.0",
30 | "next": "14.1.3",
31 | "next-themes": "^0.3.0",
32 | "react": "^18",
33 | "react-dom": "^18",
34 | "react-hook-form": "^7.51.1",
35 | "sonner": "^1.4.3",
36 | "stripe": "^14.21.0",
37 | "tailwind-merge": "^2.2.2",
38 | "tailwindcss-animate": "^1.0.7",
39 | "zod": "^3.22.4"
40 | },
41 | "devDependencies": {
42 | "@types/node": "^20",
43 | "@types/react": "^18",
44 | "@types/react-dom": "^18",
45 | "autoprefixer": "^10.0.1",
46 | "eslint": "^8",
47 | "eslint-config-next": "14.1.3",
48 | "postcss": "^8",
49 | "tailwindcss": "^3.3.0",
50 | "typescript": "^5"
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/providers/convex-client-provider.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 |
4 | import { Loading } from "@/components/auth/loading";
5 | import { ClerkProvider, useAuth } from "@clerk/nextjs";
6 | import { AuthLoading, Authenticated, ConvexReactClient, Unauthenticated } from "convex/react";
7 | import { ConvexProviderWithClerk } from "convex/react-clerk";
8 |
9 | interface ConvexClientProviderProps {
10 | children: React.ReactNode;
11 | };
12 |
13 | const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL!;
14 |
15 | const convex = new ConvexReactClient(convexUrl);
16 |
17 | export const ConvexClientProvider = ({
18 | children
19 | }: ConvexClientProviderProps) => {
20 | return (
21 |
22 |
23 |
24 |
25 |
26 |
27 | {children}
28 |
29 |
30 | {children}
31 |
32 |
33 |
34 | )
35 | }
--------------------------------------------------------------------------------
/public/logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/thumbnail.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vukrosic/next14-skool/637f73cba766c303ca921f9eb481e5a084d16ddd/public/thumbnail.png
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss"
2 |
3 | const config = {
4 | darkMode: ["class"],
5 | content: [
6 | './pages/**/*.{ts,tsx}',
7 | './components/**/*.{ts,tsx}',
8 | './app/**/*.{ts,tsx}',
9 | './src/**/*.{ts,tsx}',
10 | ],
11 | prefix: "",
12 | theme: {
13 | container: {
14 | center: true,
15 | padding: "2rem",
16 | screens: {
17 | "2xl": "1400px",
18 | },
19 | },
20 | extend: {
21 | colors: {
22 | border: "hsl(var(--border))",
23 | input: "hsl(var(--input))",
24 | ring: "hsl(var(--ring))",
25 | background: "hsl(var(--background))",
26 | foreground: "hsl(var(--foreground))",
27 | primary: {
28 | DEFAULT: "hsl(var(--primary))",
29 | foreground: "hsl(var(--primary-foreground))",
30 | },
31 | secondary: {
32 | DEFAULT: "hsl(var(--secondary))",
33 | foreground: "hsl(var(--secondary-foreground))",
34 | },
35 | destructive: {
36 | DEFAULT: "hsl(var(--destructive))",
37 | foreground: "hsl(var(--destructive-foreground))",
38 | },
39 | muted: {
40 | DEFAULT: "hsl(var(--muted))",
41 | foreground: "hsl(var(--muted-foreground))",
42 | },
43 | accent: {
44 | DEFAULT: "hsl(var(--accent))",
45 | foreground: "hsl(var(--accent-foreground))",
46 | },
47 | popover: {
48 | DEFAULT: "hsl(var(--popover))",
49 | foreground: "hsl(var(--popover-foreground))",
50 | },
51 | card: {
52 | DEFAULT: "hsl(var(--card))",
53 | foreground: "hsl(var(--card-foreground))",
54 | },
55 | },
56 | borderRadius: {
57 | lg: "var(--radius)",
58 | md: "calc(var(--radius) - 2px)",
59 | sm: "calc(var(--radius) - 4px)",
60 | },
61 | keyframes: {
62 | "accordion-down": {
63 | from: { height: "0" },
64 | to: { height: "var(--radix-accordion-content-height)" },
65 | },
66 | "accordion-up": {
67 | from: { height: "var(--radix-accordion-content-height)" },
68 | to: { height: "0" },
69 | },
70 | },
71 | animation: {
72 | "accordion-down": "accordion-down 0.2s ease-out",
73 | "accordion-up": "accordion-up 0.2s ease-out",
74 | },
75 | },
76 | },
77 | plugins: [require("tailwindcss-animate")],
78 | } satisfies Config
79 |
80 | export default config
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": ["dom", "dom.iterable", "esnext"],
4 | "allowJs": true,
5 | "skipLibCheck": true,
6 | "strict": true,
7 | "noEmit": true,
8 | "esModuleInterop": true,
9 | "module": "esnext",
10 | "moduleResolution": "bundler",
11 | "resolveJsonModule": true,
12 | "isolatedModules": true,
13 | "jsx": "preserve",
14 | "incremental": true,
15 | "plugins": [
16 | {
17 | "name": "next"
18 | }
19 | ],
20 | "paths": {
21 | "@/*": ["./*"]
22 | }
23 | },
24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
25 | "exclude": ["node_modules"]
26 | }
27 |
--------------------------------------------------------------------------------