├── .env.example ├── .eslintrc.json ├── .gitignore ├── README.md ├── app ├── (auth) │ ├── layout.tsx │ ├── sign-in │ │ └── [[...sign-in]] │ │ │ └── page.tsx │ └── sign-up │ │ └── [[...sign-up]] │ │ └── page.tsx ├── (root) │ ├── create-podcast │ │ └── page.tsx │ ├── discover │ │ └── page.tsx │ ├── layout.tsx │ ├── page.tsx │ ├── podcasts │ │ ├── [podcastid] │ │ │ └── page.tsx │ │ └── edit │ │ │ └── [podcastid] │ │ │ └── page.tsx │ └── profile │ │ ├── [profileid] │ │ └── page.tsx │ │ └── page.tsx ├── global-error.tsx ├── globals.css └── layout.tsx ├── components.json ├── components ├── Carousel.tsx ├── EmblaCarouselDotButton.tsx ├── EmptyState.tsx ├── GenerateThumbnail.tsx ├── Header.tsx ├── LoaderSpinner.tsx ├── PodcastDetailPlayer.tsx ├── PodcastPlayer.tsx ├── Searchbar.tsx ├── cards │ ├── PodcastCard.tsx │ └── ProfileCard.tsx ├── forms │ └── PodcastForm.tsx ├── navbar │ ├── LeftSidebar.tsx │ ├── MobileNav.tsx │ └── RightSidebar.tsx ├── table │ ├── DataTable.tsx │ └── columns.tsx └── ui │ ├── alert-dialog.tsx │ ├── avatar.tsx │ ├── button.tsx │ ├── form.tsx │ ├── input.tsx │ ├── label.tsx │ ├── progress.tsx │ ├── select.tsx │ ├── sheet.tsx │ ├── table.tsx │ ├── textarea.tsx │ ├── toast.tsx │ ├── toaster.tsx │ ├── tooltip.tsx │ └── use-toast.ts ├── convex ├── _generated │ ├── api.d.ts │ ├── api.js │ ├── dataModel.d.ts │ ├── server.d.ts │ └── server.js ├── auth.config.ts ├── files.ts ├── http.ts ├── openai.ts ├── podcasts.ts ├── schema.ts └── users.ts ├── instrumentation.ts ├── lib ├── constants │ └── index.ts ├── hooks │ ├── useDebounce.ts │ ├── useGeneratePodcast.ts │ └── useGenerateThumbnail.tsx ├── providers │ ├── AudioProvider.tsx │ └── ConvexClerkProvider.tsx ├── utils.ts └── validations.ts ├── middleware.ts ├── next.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.mjs ├── public ├── alloy.mp3 ├── avatars │ ├── 11.png │ ├── 22.png │ ├── 32.png │ ├── 37.png │ └── 4.png ├── echo.mp3 ├── fable.mp3 ├── icons │ ├── Pause.svg │ ├── Play.svg │ ├── Restart.svg │ ├── account.svg │ ├── auth-logo.svg │ ├── avatar.svg │ ├── clock.svg │ ├── delete.svg │ ├── discover.svg │ ├── edit.svg │ ├── emptyState.svg │ ├── forward.svg │ ├── hamburger.svg │ ├── headphone.svg │ ├── home.svg │ ├── logo.svg │ ├── logout.svg │ ├── microphone.svg │ ├── mute.svg │ ├── play-gray.svg │ ├── profile.svg │ ├── randomPlay.svg │ ├── reverse.svg │ ├── right-arrow.svg │ ├── search.svg │ ├── three-dots.svg │ ├── unmute.svg │ ├── upload-image.svg │ ├── user.svg │ ├── verified.svg │ └── watch.svg ├── images │ ├── bg-img.png │ └── player1.png ├── nova.mp3 ├── onyx.mp3 └── shimmer.mp3 ├── sentry.client.config.ts ├── sentry.edge.config.ts ├── sentry.server.config.ts ├── tailwind.config.ts ├── tsconfig.json └── types └── index.d.ts /.env.example: -------------------------------------------------------------------------------- 1 | # Clerk 2 | NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=XXXX 3 | CLERK_SECRET_KEY=XXXX 4 | NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in 5 | NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up 6 | NEXT_CLERK_WEBHOOK_SECRET=XXXX 7 | 8 | # Convex 9 | CONVEX_DEPLOYMENT=XXXX 10 | NEXT_PUBLIC_CONVEX_URL=XXXX 11 | NEXT_PUBLIC_CLERK_CONVEX_ISSUER_URL=XXXX 12 | 13 | # Openai 14 | OPENAI_API_KEY=XXXX 15 | 16 | # Sentry 17 | SENTRY_AUTH_TOKEN=XXXX -------------------------------------------------------------------------------- /.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 | 38 | # Sentry Config File 39 | .env.sentry-build-plugin 40 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Podcaster-ai 2 | 3 | [![GitHub commits](https://img.shields.io/github/commit-activity/t/mastrangelis/podcaster-ai?style=social&logo=github)](https://github.com/mastrangelis/podcaster-ai/commits) 4 | [![mastrangelis](https://custom-icon-badges.demolab.com/badge/made%20by%20-mastrangelis-556bf2?logo=github&logoColor=white&labelColor=101827)](https://github.com/mastrangelis) 5 | [![Top Language](https://img.shields.io/github/languages/top/mastrangelis/podcaster-ai?logo=github&logoColor=%23007ACC&label=TypeScript)](https://www.typescriptlang.org/) 6 | ![deployment](https://img.shields.io/github/deployments/mastrangelis/podcaster-ai/Production?logo=vercel&label=Website) 7 | 8 | ## 📋 Table of Contents 9 | 10 |
Table of Contents 11 | 12 | - 🤖 [Introduction](#-introduction) 13 | - ⚙️ [Tech Stack](#️-tech-stack) 14 | - 🔋 [Features](#-features) 15 | - 🔨 [Deployment](#️-deployment) 16 | - 🤸 [Quick Start](#-quick-start) 17 | 18 |
19 | 20 | ## 🤖 Introduction 21 | 22 | A cutting-edge AI SaaS platform that enables users to create, discover, and enjoy podcasts with advanced features like text-to-audio conversion with multi-voice AI, podcast thumbnail Image generation and seamless playback. 23 | 24 | ## ⚙️ Tech Stack 25 | 26 |
Podcaster-ai is built using the following technologies: 27 | 28 | - [TypeScript](https://www.typescriptlang.org/): TypeScript is a typed superset of JavaScript that compiles to plain JavaScript. 29 | - [Next.js](https://nextjs.org/): Next.js is a React framework for building server-side rendered and statically generated web applications. 30 | - [Convex](https://www.convex.dev/) Convex is a platform designed to simplify the development of real-time, scalable web applications. It offers a suite of tools and services that allow developers to build and deploy complex app features quickly and efficiently. Key features include real-time data synchronization, serverless functions, and built-in authentication and security measures 31 | - [Sentry](https://sentry.io) Sentry is an open-source error tracking and performance monitoring tool that helps developers identify, diagnose, and fix issues in their applications in real-time. 32 | - [Tailwind CSS](https://tailwindcss.com/): Tailwind CSS is a utility-first CSS framework for rapidly building custom user interfaces. 33 | - [ESLint](https://eslint.org/): ESLint is a static code analysis tool for identifying problematic patterns found in JavaScript code. 34 | - [Prettier](https://prettier.io/): Prettier is an opinionated code formatter. 35 | - [Shadcn-UI](https://ui.shadcn.com/): Shadcn UI is a React UI library that helps developers rapidly build modern web applications. 36 | - [Zod](https://zod.dev/): Zod is a TypeScript-first schema declaration and validation library. 37 | - [Vercel](https://vercel.com/): Vercel is a cloud platform for frontend developers, providing the frameworks, workflows, and infrastructure to build a faster, more personalized Web. 38 | - [OpenAI](https://openai.com/): OpenAI is known for the GPT family of large language models, the DALL-E series of text-to-image models, and a text-to-video model named Sora. 39 | 40 |

41 | 42 | [![Technologies Used](https://skillicons.dev/icons?i=ts,nextjs,tailwind,sentry,vercel)](https://skillicons.dev) 43 | 44 | ## 🔋 Features 45 | 46 | 👉 Robust Authentication: Secure and reliable user login and registration system using Clerk 47 | 48 | 👉 Modern Home Page: Showcases trending and latest podcasts with a sticky podcast player for continuous listening. 49 | 50 | 👉 Discover Podcasts Page: Dedicated page for users to explore new and popular podcasts. 51 | 52 | 👉 Fully Functional Search: Allows users to find podcasts easily using various search criteria. 53 | 54 | 👉 Create Podcast Page: Enables podcast creation with text-to-audio conversion, AI image generation, and previews. 55 | 56 | 👉 Edit Podcast Page: Enables podcast edit for podcasts owners. 57 | 58 | 👉 Multi Voice AI Functionality: Supports multiple AI-generated voices for dynamic podcast creation. 59 | 60 | 👉 Profile Page: View all created podcasts with options to delete and edit them. 61 | 62 | 👉 Podcast Details Page: Displays detailed information about each podcast, including creator details, number of listeners, and transcript. 63 | 64 | 👉 Podcast Player: Features backward/forward controls, as well as mute/unmute functionality for a seamless listening experience. 65 | 66 | 👉 Responsive Design: Fully functional and visually appealing across all devices and screen sizes. 67 | 68 | 👉 Sentry Monirtoring: Integration with Sentry for monitoring and tracing 69 | 70 | and many more, including code architecture and reusability 71 | 72 | ## 🛠️ Deployment 73 | 74 | You can check the project live here [podcaster-ai](https://podcaster-ai-tawny.vercel.app/) 75 | 76 | ## 🤸 Quick Start 77 | 78 | Follow these steps to set up the project locally on your machine. 79 | 80 | ### Prerequisites 81 | 82 | Make sure you have the following installed on your machine: 83 | 84 | - [Git](https://git-scm.com/) 85 | - [Node.js](https://nodejs.org/en) 86 | - [npm](https://www.npmjs.com/) (Node Package Manager) 87 | 88 | ### Cloning the Repository 89 | 90 | ```bash 91 | git clone https://github.com/Mastrangelis/podcaster-ai.git 92 | cd podcaster-ai 93 | ``` 94 | 95 | ### Installation 96 | 97 | Install the project dependencies using npm: 98 | 99 | ```bash 100 | npm install 101 | ``` 102 | 103 | ### Running the Project 104 | 105 | ```bash 106 | npm run dev 107 | ``` 108 | 109 | Open [http://localhost:3000](http://localhost:3000) in your browser to view the project. 110 | -------------------------------------------------------------------------------- /app/(auth)/layout.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | 3 | export default function AuthLayout({ 4 | children, 5 | }: Readonly<{ 6 | children: React.ReactNode; 7 | }>) { 8 | return ( 9 |
10 |
11 | background 17 |
18 | 19 | {children} 20 |
21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /app/(auth)/sign-in/[[...sign-in]]/page.tsx: -------------------------------------------------------------------------------- 1 | import { SignIn } from "@clerk/nextjs"; 2 | 3 | const SignInPage = () => { 4 | return ( 5 |
6 | 7 |
8 | ); 9 | }; 10 | 11 | export default SignInPage; 12 | -------------------------------------------------------------------------------- /app/(auth)/sign-up/[[...sign-up]]/page.tsx: -------------------------------------------------------------------------------- 1 | import { SignUp } from "@clerk/nextjs"; 2 | 3 | const SignUpPage = () => { 4 | return ( 5 |
6 | 7 |
8 | ); 9 | }; 10 | 11 | export default SignUpPage; 12 | -------------------------------------------------------------------------------- /app/(root)/create-podcast/page.tsx: -------------------------------------------------------------------------------- 1 | import PodcastForm from "@/components/forms/PodcastForm"; 2 | import React from "react"; 3 | 4 | const CreatePodcastPage = () => { 5 | return ( 6 |
7 |

Create Podcast

8 | 9 |
10 | ); 11 | }; 12 | 13 | export default CreatePodcastPage; 14 | -------------------------------------------------------------------------------- /app/(root)/discover/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import EmptyState from "@/components/EmptyState"; 4 | import LoaderSpinner from "@/components/LoaderSpinner"; 5 | import PodcastCard from "@/components/cards/PodcastCard"; 6 | import Searchbar from "@/components/Searchbar"; 7 | import { api } from "@/convex/_generated/api"; 8 | import { useQuery } from "convex/react"; 9 | import React from "react"; 10 | import { SearchParamProps } from "@/types"; 11 | import { useUser } from "@clerk/nextjs"; 12 | import * as Sentry from "@sentry/nextjs"; 13 | 14 | const Discover = ({ searchParams: { search } }: SearchParamProps) => { 15 | const { user: clerkUser } = useUser(); 16 | 17 | Sentry.metrics.set( 18 | "discover-podcasts", 19 | clerkUser?.emailAddresses[0].emailAddress! 20 | ); 21 | 22 | const podcastsData = useQuery(api.podcasts.getPodcastBySearch, { 23 | search: (search as string) || "", 24 | clerkId: clerkUser?.id || "", 25 | }); 26 | 27 | return ( 28 |
29 | 30 |
31 |

32 | {!search ? "Discover Community Podcasts" : "Search results for "} 33 | {search && {search}} 34 |

35 | {podcastsData ? ( 36 | <> 37 | {podcastsData.length > 0 ? ( 38 |
39 | {podcastsData?.map( 40 | ({ _id, podcastTitle, podcastDescription, imageUrl }) => ( 41 | 48 | ) 49 | )} 50 |
51 | ) : ( 52 | 53 | )} 54 | 55 | ) : ( 56 | 57 | )} 58 |
59 |
60 | ); 61 | }; 62 | 63 | export default Discover; 64 | -------------------------------------------------------------------------------- /app/(root)/layout.tsx: -------------------------------------------------------------------------------- 1 | import LeftSidebar from "@/components/navbar/LeftSidebar"; 2 | import MobileNav from "@/components/navbar/MobileNav"; 3 | import RightSidebar from "@/components/navbar/RightSidebar"; 4 | import Image from "next/image"; 5 | import { Toaster } from "@/components/ui/toaster"; 6 | import PodcastPlayer from "@/components/PodcastPlayer"; 7 | 8 | export default function RootLayout({ 9 | children, 10 | }: Readonly<{ 11 | children: React.ReactNode; 12 | }>) { 13 | return ( 14 |
15 |
16 | 17 | 18 |
19 |
20 |
21 | menu icon 27 | 28 |
29 |
30 | 31 | 32 | {children} 33 |
34 |
35 |
36 | 37 | 38 |
39 | 40 | 41 |
42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /app/(root)/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import PodcastCard from "@/components/cards/PodcastCard"; 4 | import LoaderSpinner from "@/components/LoaderSpinner"; 5 | import { columns } from "@/components/table/columns"; 6 | import { DataTable } from "@/components/table/DataTable"; 7 | import { api } from "@/convex/_generated/api"; 8 | import { useQuery } from "convex/react"; 9 | import Link from "next/link"; 10 | 11 | const Home = () => { 12 | const trendingPodcasts = useQuery(api.podcasts.getTrendingPodcasts); 13 | 14 | const latestPodcasts = useQuery(api.podcasts.getLatestPodcasts); 15 | 16 | if (!trendingPodcasts || !latestPodcasts) return ; 17 | 18 | return ( 19 |
20 |
21 | {Array.isArray(trendingPodcasts) && trendingPodcasts.length > 0 && ( 22 | <> 23 |

24 | Trending Podcasts 25 |

26 |
27 | {trendingPodcasts?.map((podcast) => ( 28 | 35 | ))} 36 |
37 | 38 | )} 39 | 40 | {Array.isArray(latestPodcasts) && latestPodcasts.length > 0 && ( 41 |
42 |
43 |

44 | Latest Podcasts 45 |

46 | 47 | See All 48 | 49 |
50 | 51 |
52 | )} 53 |
54 |
55 | ); 56 | }; 57 | 58 | export default Home; 59 | -------------------------------------------------------------------------------- /app/(root)/podcasts/[podcastid]/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import EmptyState from "@/components/EmptyState"; 4 | import LoaderSpinner from "@/components/LoaderSpinner"; 5 | import PodcastCard from "@/components/cards/PodcastCard"; 6 | import PodcastDetailPlayer from "@/components/PodcastDetailPlayer"; 7 | import { api } from "@/convex/_generated/api"; 8 | import { useUser } from "@clerk/nextjs"; 9 | import { useQuery } from "convex/react"; 10 | import Image from "next/image"; 11 | import React from "react"; 12 | import { SearchParamProps } from "@/types"; 13 | import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 14 | import { 15 | Tooltip, 16 | TooltipContent, 17 | TooltipTrigger, 18 | } from "@/components/ui/tooltip"; 19 | 20 | const PodcastDetailsPage = ({ params: { podcastid } }: SearchParamProps) => { 21 | const { user } = useUser(); 22 | 23 | const podcast = useQuery(api.podcasts.getPodcastById, { 24 | podcastId: podcastid, 25 | }); 26 | 27 | const similarPodcasts = useQuery(api.podcasts.getPodcastByVoiceType, { 28 | podcastId: podcastid, 29 | }); 30 | 31 | const podcastFollowers = useQuery(api.users.getFollowersByPodcastId, { 32 | podcastId: podcastid, 33 | }); 34 | 35 | const podcastViews = podcast?.viewedBy.length || 0; 36 | 37 | const isOwner = user?.id === podcast?.authorId; 38 | 39 | if (!similarPodcasts || !podcast || !podcastFollowers) 40 | return ; 41 | 42 | return ( 43 |
44 |
45 |

Currenty Playing

46 |
47 |
48 | {Array.isArray(podcastFollowers) && 49 | podcastFollowers.length > 0 && 50 | podcastFollowers.slice(0, 3).map((follower) => ( 51 | 52 | 53 | 57 | 58 | 59 | {follower?.name?.split(" ")[0][0]} 60 | 61 | 62 | 63 | 64 |

{follower?.name}

65 |
66 |
67 | ))} 68 | {Array.isArray(podcastFollowers) && podcastFollowers.length > 3 && ( 69 | 70 | 71 | 72 | 73 | +{podcastFollowers.length - 3} 74 | 75 | 76 | 77 | 78 |

79 | {podcastFollowers.length - 3} more 80 |

81 |
82 |
83 | )} 84 |
85 | headphone 91 |

{podcastViews}

92 |
93 |
94 | 95 | 104 | 105 |

106 | {podcast?.podcastDescription} 107 |

108 | 109 |
110 |
111 |

Transcription

112 |

113 | {podcast?.voicePrompt} 114 |

115 |
116 | {podcast?.imagePrompt && ( 117 |
118 |

Thumbnail Prompt

119 |

120 | {podcast?.imagePrompt} 121 |

122 |
123 | )} 124 |
125 |
126 |

Similar Podcasts

127 | 128 | {similarPodcasts && similarPodcasts.length > 0 ? ( 129 |
130 | {similarPodcasts?.map( 131 | ({ _id, podcastTitle, podcastDescription, imageUrl }) => ( 132 | 139 | ) 140 | )} 141 |
142 | ) : ( 143 | <> 144 | 149 | 150 | )} 151 |
152 |
153 | ); 154 | }; 155 | 156 | export default PodcastDetailsPage; 157 | -------------------------------------------------------------------------------- /app/(root)/podcasts/edit/[podcastid]/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import PodcastForm from "@/components/forms/PodcastForm"; 4 | import LoaderSpinner from "@/components/LoaderSpinner"; 5 | import { api } from "@/convex/_generated/api"; 6 | import { Id } from "@/convex/_generated/dataModel"; 7 | import { SearchParamProps } from "@/types"; 8 | import { useQuery } from "convex/react"; 9 | import React from "react"; 10 | 11 | const EditPodcastPage = ({ params: { podcastid } }: SearchParamProps) => { 12 | const podcast = useQuery(api.podcasts.getPodcastById, { 13 | podcastId: podcastid as Id<"podcasts">, 14 | }); 15 | 16 | if (!podcast) return ; 17 | 18 | return ( 19 |
20 |

Edit Podcast

21 | 22 |
23 | ); 24 | }; 25 | 26 | export default EditPodcastPage; 27 | -------------------------------------------------------------------------------- /app/(root)/profile/[profileid]/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useQuery } from "convex/react"; 4 | 5 | import EmptyState from "@/components/EmptyState"; 6 | import LoaderSpinner from "@/components/LoaderSpinner"; 7 | import PodcastCard from "@/components/cards/PodcastCard"; 8 | import ProfileCard from "@/components/cards/ProfileCard"; 9 | import { api } from "@/convex/_generated/api"; 10 | import { SearchParamProps } from "@/types"; 11 | 12 | const ProfilePage = ({ params }: SearchParamProps) => { 13 | const user = useQuery(api.users.getUserById, { 14 | clerkId: params.profileid, 15 | }); 16 | 17 | const podcastsData = useQuery(api.podcasts.getPodcastByAuthorId, { 18 | authorId: params.profileid, 19 | }); 20 | 21 | if (!user || !podcastsData) return ; 22 | 23 | return ( 24 |
25 |

26 | Podcaster Profile 27 |

28 |
29 | 34 |
35 |
36 |

All Podcasts

37 | {podcastsData && podcastsData.podcasts.length > 0 ? ( 38 |
39 | {podcastsData?.podcasts 40 | ?.slice(0, 4) 41 | .map((podcast) => ( 42 | 49 | ))} 50 |
51 | ) : ( 52 | 57 | )} 58 |
59 |
60 | ); 61 | }; 62 | 63 | export default ProfilePage; 64 | -------------------------------------------------------------------------------- /app/(root)/profile/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import PodcastCard from "@/components/cards/PodcastCard"; 4 | import ProfileCard from "@/components/cards/ProfileCard"; 5 | import EmptyState from "@/components/EmptyState"; 6 | import LoaderSpinner from "@/components/LoaderSpinner"; 7 | import { api } from "@/convex/_generated/api"; 8 | import { useUser } from "@clerk/nextjs"; 9 | import { useQuery } from "convex/react"; 10 | import React from "react"; 11 | 12 | const MyProfilePage = () => { 13 | const { user: clerkUser } = useUser(); 14 | 15 | const user = useQuery(api.users.getUserById, { 16 | clerkId: clerkUser?.id || "", 17 | }); 18 | 19 | const podcastsData = useQuery(api.podcasts.getPodcastByAuthorId, { 20 | authorId: clerkUser?.id || "", 21 | }); 22 | 23 | if (!user || !podcastsData) return ; 24 | 25 | return ( 26 |
27 |

28 | My Profile 29 |

30 |
31 | 38 |
39 |
40 |

41 | My Podcasts 42 |

43 | {podcastsData && podcastsData.podcasts.length > 0 ? ( 44 |
45 | {podcastsData?.podcasts 46 | ?.slice(0, 4) 47 | .map((podcast) => ( 48 | 56 | ))} 57 |
58 | ) : ( 59 | 64 | )} 65 |
66 |
67 | ); 68 | }; 69 | 70 | export default MyProfilePage; 71 | -------------------------------------------------------------------------------- /app/global-error.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as Sentry from "@sentry/nextjs"; 4 | import NextError from "next/error"; 5 | import { useEffect } from "react"; 6 | 7 | export default function GlobalError({ error }: { error: Error & { digest?: string } }) { 8 | useEffect(() => { 9 | Sentry.captureException(error); 10 | }, [error]); 11 | 12 | return ( 13 | 14 | 15 | {/* `NextError` is the default Next.js error page component. Its type 16 | definition requires a `statusCode` prop. However, since the App Router 17 | does not expose status codes for errors, we simply pass 0 to render a 18 | generic error message. */} 19 | 20 | 21 | 22 | ); 23 | } -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | * { 6 | margin: 0; 7 | padding: 0; 8 | box-sizing: border-box; 9 | } 10 | 11 | html { 12 | background-color: #101114; 13 | } 14 | 15 | @layer utilities { 16 | .input-class { 17 | @apply text-16 placeholder:text-16 bg-black-1 rounded-[6px] placeholder:text-gray-1 border-none text-gray-1; 18 | } 19 | .podcast_grid { 20 | @apply grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4; 21 | } 22 | .right_sidebar { 23 | @apply sticky right-0 top-0 flex w-[360px] flex-col overflow-y-hidden border-none bg-black-1 px-[30px] pt-8 max-xl:hidden; 24 | } 25 | .left_sidebar { 26 | @apply sticky left-0 top-0 flex w-fit flex-col justify-between border-none bg-black-1 pt-8 text-white-1 max-md:hidden lg:w-[270px] lg:pl-8; 27 | } 28 | .generate_thumbnail { 29 | @apply flex w-full max-w-[520px] flex-col justify-between gap-2 rounded-lg border border-black-6 bg-black-1 px-2.5 py-2 md:flex-row md:gap-0; 30 | } 31 | .image_div { 32 | @apply flex-center mt-5 h-[142px] w-full cursor-pointer flex-col gap-3 rounded-xl border-[3.2px] border-dashed border-black-6 bg-black-1; 33 | } 34 | .carousel_box { 35 | @apply relative flex h-fit aspect-square w-full flex-none cursor-pointer flex-col justify-end rounded-xl border-none; 36 | } 37 | .button_bold-16 { 38 | @apply text-[16px] font-bold text-white-1 transition-all duration-500; 39 | } 40 | .flex-center { 41 | @apply flex items-center justify-center; 42 | } 43 | 44 | .text-12 { 45 | @apply text-[12px] leading-normal; 46 | } 47 | .text-14 { 48 | @apply text-[14px] leading-normal; 49 | } 50 | .text-16 { 51 | @apply text-[16px] leading-normal; 52 | } 53 | .text-18 { 54 | @apply text-[18px] leading-normal; 55 | } 56 | .text-20 { 57 | @apply text-[20px] leading-normal; 58 | } 59 | .text-24 { 60 | @apply text-[24px] leading-normal; 61 | } 62 | .text-32 { 63 | @apply text-[32px] leading-normal; 64 | } 65 | /* Data Table */ 66 | .data-table { 67 | @apply z-10 w-full overflow-hidden rounded-lg border border-black-4 shadow-lg; 68 | } 69 | .table-actions { 70 | @apply flex w-full items-center justify-between space-x-2 p-4; 71 | } 72 | 73 | .shad-table { 74 | @apply rounded-lg overflow-hidden !important; 75 | } 76 | .shad-table-row-header { 77 | @apply border-b border-black-4 text-white-2 hover:bg-transparent !important; 78 | } 79 | 80 | .shad-table-row { 81 | @apply border-b border-black-4 text-white-2 !important; 82 | } 83 | } 84 | 85 | /* ===== custom classes ===== */ 86 | 87 | /* Hide scrollbar for Chrome, Safari and Opera */ 88 | .no-scrollbar::-webkit-scrollbar { 89 | display: none; 90 | } 91 | 92 | /* Hide scrollbar for IE, Edge and Firefox */ 93 | .no-scrollbar { 94 | -ms-overflow-style: none; /* IE and Edge */ 95 | scrollbar-width: none; /* Firefox */ 96 | } 97 | .glassmorphism { 98 | background: rgba(255, 255, 255, 0.25); 99 | backdrop-filter: blur(4px); 100 | -webkit-backdrop-filter: blur(4px); 101 | } 102 | .glassmorphism-auth { 103 | background: rgba(6, 3, 3, 0.711); 104 | backdrop-filter: blur(4px); 105 | -webkit-backdrop-filter: blur(4px); 106 | } 107 | .glassmorphism-black { 108 | background: rgba(18, 18, 18, 0.64); 109 | backdrop-filter: blur(37px); 110 | -webkit-backdrop-filter: blur(37px); 111 | } 112 | 113 | /* ======= clerk overrides ======== */ 114 | .cl-socialButtonsIconButton { 115 | border: 2px solid #222429; 116 | } 117 | .cl-button { 118 | color: white; 119 | } 120 | .cl-socialButtonsProviderIcon__github { 121 | filter: invert(1); 122 | } 123 | .cl-internal-b3fm6y { 124 | background: #f97535; 125 | } 126 | .cl-formButtonPrimary { 127 | background: #f97535; 128 | } 129 | .cl-footerActionLink { 130 | color: #f97535; 131 | } 132 | .cl-headerSubtitle { 133 | color: #c5d0e6; 134 | } 135 | .cl-logoImage { 136 | width: 10rem; 137 | height: 3rem; 138 | } 139 | .cl-internal-4a7e9l { 140 | color: white; 141 | } 142 | 143 | .cl-userButtonPopoverActionButtonIcon { 144 | color: white; 145 | } 146 | .cl-internal-wkkub3 { 147 | color: #f97535; 148 | } 149 | .cl-badge { 150 | color: #f97535; 151 | } 152 | 153 | /* Scrollbar */ 154 | ::-webkit-scrollbar { 155 | width: 4px; 156 | height: 4px; 157 | border-radius: 2px; 158 | } 159 | 160 | ::-webkit-scrollbar-track { 161 | background: #ffffff; 162 | } 163 | 164 | ::-webkit-scrollbar-thumb { 165 | background: #f97535; 166 | border-radius: 50px; 167 | } 168 | 169 | ::-webkit-scrollbar-thumb:hover { 170 | background: #b7592a; 171 | } 172 | 173 | input:-webkit-autofill, 174 | input:-webkit-autofill:hover, 175 | input:-webkit-autofill:active { 176 | -webkit-background-clip: text; 177 | -webkit-text-fill-color: #ffffff; 178 | transition: background-color 5000s ease-in-out 0s; 179 | box-shadow: inset 0 0 20px 20px #23232329; 180 | } 181 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Manrope } from "next/font/google"; 3 | import "./globals.css"; 4 | import AudioProvider from "@/lib/providers/AudioProvider"; 5 | import ConvexClerkProvider from "@/lib/providers/ConvexClerkProvider"; 6 | import { TooltipProvider } from "@/components/ui/tooltip"; 7 | 8 | const manrope = Manrope({ subsets: ["latin"] }); 9 | 10 | export const metadata: Metadata = { 11 | title: "Podcaster-AI", 12 | description: 13 | "A cutting-edge AI SaaS platform that enables users to create, discover, and enjoy podcasts with advanced features like text-to-audio conversion with multi-voice AI, podcast thumbnail Image generation and seamless playback.", 14 | icons: { 15 | icon: "/icons/logo.svg", 16 | }, 17 | }; 18 | 19 | export default function RootLayout({ 20 | children, 21 | }: Readonly<{ 22 | children: React.ReactNode; 23 | }>) { 24 | return ( 25 | 26 | 27 | 28 | 29 | {children} 30 | 31 | 32 | 33 | 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /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/Carousel.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from "react"; 2 | import { EmblaOptionsType, EmblaCarouselType } from "embla-carousel"; 3 | import { DotButton, useDotButton } from "./EmblaCarouselDotButton"; 4 | import Autoplay from "embla-carousel-autoplay"; 5 | import useEmblaCarousel from "embla-carousel-react"; 6 | import { CarouselProps } from "@/types"; 7 | import { useRouter } from "next/navigation"; 8 | import Image from "next/image"; 9 | import LoaderSpinner from "./LoaderSpinner"; 10 | 11 | const EmblaCarousel = ({ fansLikeDetail }: CarouselProps) => { 12 | const router = useRouter(); 13 | 14 | const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true }, [Autoplay()]); 15 | 16 | const onNavButtonClick = useCallback((emblaApi: EmblaCarouselType) => { 17 | const autoplay = emblaApi?.plugins()?.autoplay; 18 | if (!autoplay || !("stopOnInteraction" in autoplay.options)) return; 19 | 20 | const resetOrStop = 21 | autoplay.options.stopOnInteraction === false 22 | ? (autoplay.reset as () => void) 23 | : (autoplay.stop as () => void); 24 | 25 | resetOrStop(); 26 | }, []); 27 | 28 | const { selectedIndex, scrollSnaps, onDotButtonClick } = useDotButton( 29 | emblaApi, 30 | onNavButtonClick 31 | ); 32 | 33 | const slides = 34 | fansLikeDetail && 35 | fansLikeDetail?.filter((item: any) => item.totalPodcasts > 0); 36 | 37 | if (!slides) return ; 38 | 39 | return ( 40 |
44 |
45 | {slides.slice(0, 5).map((item) => ( 46 |
50 | router.push(`/podcasts/${item.podcast[0]?.podcastId}`) 51 | } 52 | > 53 | card 59 |
60 |

61 | {item.podcast[0]?.podcastTitle} 62 |

63 |

{item.name}

64 |
65 |
66 | ))} 67 |
68 |
69 | {scrollSnaps.map((_, index) => ( 70 | onDotButtonClick(index)} 73 | selected={index === selectedIndex} 74 | /> 75 | ))} 76 |
77 |
78 | ); 79 | }; 80 | 81 | export default EmblaCarousel; 82 | -------------------------------------------------------------------------------- /components/EmblaCarouselDotButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | PropsWithChildren, 3 | useCallback, 4 | useEffect, 5 | useState, 6 | } from "react"; 7 | import { EmblaCarouselType } from "embla-carousel"; 8 | import { cn } from "@/lib/utils"; 9 | 10 | type UseDotButtonType = { 11 | selectedIndex: number; 12 | scrollSnaps: number[]; 13 | onDotButtonClick: (index: number) => void; 14 | }; 15 | 16 | export const useDotButton = ( 17 | emblaApi: EmblaCarouselType | undefined, 18 | onButtonClick?: (emblaApi: EmblaCarouselType) => void 19 | ): UseDotButtonType => { 20 | const [selectedIndex, setSelectedIndex] = useState(0); 21 | const [scrollSnaps, setScrollSnaps] = useState([]); 22 | 23 | const onDotButtonClick = useCallback( 24 | (index: number) => { 25 | if (!emblaApi) return; 26 | emblaApi.scrollTo(index); 27 | if (onButtonClick) onButtonClick(emblaApi); 28 | }, 29 | [emblaApi, onButtonClick] 30 | ); 31 | 32 | const onInit = useCallback((emblaApi: EmblaCarouselType) => { 33 | setScrollSnaps(emblaApi.scrollSnapList()); 34 | }, []); 35 | 36 | const onSelect = useCallback((emblaApi: EmblaCarouselType) => { 37 | setSelectedIndex(emblaApi.selectedScrollSnap()); 38 | }, []); 39 | 40 | useEffect(() => { 41 | if (!emblaApi) return; 42 | 43 | onInit(emblaApi); 44 | onSelect(emblaApi); 45 | emblaApi.on("reInit", onInit).on("reInit", onSelect).on("select", onSelect); 46 | }, [emblaApi, onInit, onSelect]); 47 | 48 | return { 49 | selectedIndex, 50 | scrollSnaps, 51 | onDotButtonClick, 52 | }; 53 | }; 54 | 55 | type DotButtonProps = { 56 | selected: boolean; 57 | onClick: () => void; 58 | }; 59 | 60 | export const DotButton: React.FC = ({ selected, onClick }) => { 61 | return ( 62 | 44 | )} 45 | 46 | 47 | ); 48 | }; 49 | 50 | export default EmptyState; 51 | -------------------------------------------------------------------------------- /components/GenerateThumbnail.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const GenerateThumbnail = () => { 4 | return
GenerateThumbnail
; 5 | }; 6 | 7 | export default GenerateThumbnail; 8 | -------------------------------------------------------------------------------- /components/Header.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | import Link from "next/link"; 3 | import React from "react"; 4 | 5 | const Header = ({ 6 | headerTitle, 7 | titleClassName, 8 | }: { 9 | headerTitle?: string; 10 | titleClassName?: string; 11 | }) => { 12 | return ( 13 |
14 | {headerTitle ? ( 15 |

16 | {headerTitle} 17 |

18 | ) : ( 19 |
20 | )} 21 | 22 | See all 23 | 24 |
25 | ); 26 | }; 27 | 28 | export default Header; 29 | -------------------------------------------------------------------------------- /components/LoaderSpinner.tsx: -------------------------------------------------------------------------------- 1 | import { Loader } from "lucide-react"; 2 | import React from "react"; 3 | 4 | const LoaderSpinner = () => { 5 | return ( 6 |
7 | 8 |
9 | ); 10 | }; 11 | 12 | export default LoaderSpinner; 13 | -------------------------------------------------------------------------------- /components/PodcastPlayer.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Image from "next/image"; 4 | import Link from "next/link"; 5 | import { useEffect, useRef, useState } from "react"; 6 | 7 | import { formatTime } from "@/lib/utils"; 8 | import { cn } from "@/lib/utils"; 9 | import { useAudio } from "@/lib/providers/AudioProvider"; 10 | 11 | import { Progress } from "./ui/progress"; 12 | 13 | const PodcastPlayer = () => { 14 | const audioRef = useRef(null); 15 | // const [isPlaying, setIsPlaying] = useState(false); 16 | const [duration, setDuration] = useState(0); 17 | const [isMuted, setIsMuted] = useState(false); 18 | const [currentTime, setCurrentTime] = useState(0); 19 | const { audio, setAudio } = useAudio(); 20 | 21 | const togglePlayPause = () => { 22 | if (audioRef.current?.paused) { 23 | audioRef.current?.play(); 24 | setAudio((prev) => ({ ...prev, isPlaying: true })); 25 | } else { 26 | audioRef.current?.pause(); 27 | setAudio((prev) => ({ ...prev, isPlaying: false })); 28 | } 29 | }; 30 | 31 | const toggleMute = () => { 32 | if (audioRef.current) { 33 | audioRef.current.muted = !isMuted; 34 | setIsMuted((prev) => !prev); 35 | } 36 | }; 37 | 38 | const forward = () => { 39 | if ( 40 | audioRef.current && 41 | audioRef.current.currentTime && 42 | audioRef.current.duration && 43 | audioRef.current.currentTime + 5 < audioRef.current.duration 44 | ) { 45 | audioRef.current.currentTime += 5; 46 | } 47 | }; 48 | 49 | const rewind = () => { 50 | if (audioRef.current && audioRef.current.currentTime - 5 > 0) { 51 | audioRef.current.currentTime -= 5; 52 | } else if (audioRef.current) { 53 | audioRef.current.currentTime = 0; 54 | } 55 | }; 56 | 57 | useEffect(() => { 58 | const updateCurrentTime = () => { 59 | if (audioRef.current) { 60 | setCurrentTime(audioRef.current.currentTime); 61 | } 62 | }; 63 | 64 | const audioElement = audioRef.current; 65 | if (audioElement) { 66 | audioElement.addEventListener("timeupdate", updateCurrentTime); 67 | 68 | return () => { 69 | audioElement.removeEventListener("timeupdate", updateCurrentTime); 70 | }; 71 | } 72 | }, []); 73 | 74 | useEffect(() => { 75 | const audioElement = audioRef.current; 76 | 77 | if (audio?.audioUrl && audioElement && audio?.isPlaying) { 78 | audioElement?.play(); 79 | } else { 80 | setAudio((prev) => ({ ...prev, isPlaying: false })); 81 | audioElement?.pause(); 82 | } 83 | // eslint-disable-next-line react-hooks/exhaustive-deps 84 | }, [audio?.isPlaying, audio?.audioUrl]); 85 | 86 | const handleLoadedMetadata = () => { 87 | if (audioRef.current) { 88 | setDuration(audioRef.current.duration); 89 | } 90 | }; 91 | 92 | const handleAudioEnded = () => { 93 | setAudio((prev) => ({ ...prev, isPlaying: false })); 94 | }; 95 | 96 | const onProgressBarClick = (e: React.MouseEvent) => { 97 | if (audioRef.current) { 98 | audioRef.current.currentTime = 99 | (e.nativeEvent.offsetX / e.currentTarget.offsetWidth) * duration; 100 | } 101 | }; 102 | 103 | return ( 104 |
113 | {duration > 0 && ( 114 | 120 | )} 121 | 122 |
123 |
192 |
193 | ); 194 | }; 195 | 196 | export default PodcastPlayer; 197 | -------------------------------------------------------------------------------- /components/Searchbar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { useEffect, useState } from "react"; 4 | import { Input } from "./ui/input"; 5 | import Image from "next/image"; 6 | import { usePathname, useRouter } from "next/navigation"; 7 | import { useDebounce } from "@/lib/hooks/useDebounce"; 8 | 9 | const Searchbar = () => { 10 | const [search, setSearch] = useState(""); 11 | const router = useRouter(); 12 | const pathname = usePathname(); 13 | 14 | const debouncedValue = useDebounce(search, 500); 15 | 16 | useEffect(() => { 17 | if (debouncedValue) { 18 | router.push(`/discover?search=${debouncedValue}`); 19 | } else if (!debouncedValue && pathname === "/discover") 20 | router.push("/discover"); 21 | }, [router, pathname, debouncedValue]); 22 | 23 | return ( 24 |
25 | setSearch(e.target.value)} 30 | onLoad={() => setSearch("")} 31 | /> 32 | search 39 |
40 | ); 41 | }; 42 | 43 | export default Searchbar; 44 | -------------------------------------------------------------------------------- /components/cards/PodcastCard.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { api } from "@/convex/_generated/api"; 4 | import { PodcastCardProps } from "@/types"; 5 | import { useMutation } from "convex/react"; 6 | import Image from "next/image"; 7 | import { useRouter } from "next/navigation"; 8 | import React from "react"; 9 | import { useUser } from "@clerk/nextjs"; 10 | import { Id } from "@/convex/_generated/dataModel"; 11 | 12 | const PodcastCard = ({ 13 | imgUrl, 14 | title, 15 | description, 16 | podcastId, 17 | isOwner, 18 | }: PodcastCardProps) => { 19 | const router = useRouter(); 20 | const { user } = useUser(); 21 | 22 | const updatePodcastViews = useMutation(api.podcasts.updatePodcastViews); 23 | 24 | const handleViews = async () => { 25 | if (user?.id) { 26 | await updatePodcastViews({ 27 | podcastId: podcastId as Id<"podcasts">, 28 | clerkId: user.id, 29 | }); 30 | } 31 | 32 | router.push(`/podcasts/${podcastId}`, { 33 | scroll: true, 34 | }); 35 | }; 36 | 37 | const onEditClick = (e: React.MouseEvent) => { 38 | e.stopPropagation(); 39 | router.push(`/podcasts/edit/${podcastId}`); 40 | }; 41 | 42 | return ( 43 |
47 |
48 |
49 | {title} 56 | 57 | {isOwner && ( 58 |
62 | edit podcast 69 |
70 | )} 71 |
72 | 73 |
74 |

{title}

75 |

76 | {description} 77 |

78 |
79 |
80 |
81 | ); 82 | }; 83 | 84 | export default PodcastCard; 85 | -------------------------------------------------------------------------------- /components/navbar/LeftSidebar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { sidebarLinks } from "@/lib/constants"; 4 | import { cn } from "@/lib/utils"; 5 | import { SignedIn, SignedOut, useClerk } from "@clerk/nextjs"; 6 | import Image from "next/image"; 7 | import Link from "next/link"; 8 | import { usePathname, useRouter } from "next/navigation"; 9 | import React from "react"; 10 | import { Button } from "../ui/button"; 11 | import { useAudio } from "@/lib/providers/AudioProvider"; 12 | 13 | const LeftSidebar = () => { 14 | const pathname = usePathname(); 15 | const router = useRouter(); 16 | const { signOut } = useClerk(); 17 | const { audio, setAudio } = useAudio(); 18 | const { user } = useClerk(); 19 | 20 | const handleLogout = () => { 21 | setAudio(undefined); 22 | signOut(() => router.push("/")); 23 | }; 24 | 25 | return ( 26 |
31 | 66 | 67 |
68 | 82 |
83 |
84 | 85 |
86 | 98 |
99 |
100 |
101 | ); 102 | }; 103 | 104 | export default LeftSidebar; 105 | -------------------------------------------------------------------------------- /components/navbar/MobileNav.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | Sheet, 5 | SheetClose, 6 | SheetContent, 7 | SheetTrigger, 8 | } from "@/components/ui/sheet"; 9 | import { sidebarLinks } from "@/lib/constants"; 10 | import { cn } from "@/lib/utils"; 11 | import { SignedIn, SignedOut, useClerk } from "@clerk/nextjs"; 12 | import Image from "next/image"; 13 | import Link from "next/link"; 14 | import { usePathname, useRouter } from "next/navigation"; 15 | import { Button } from "../ui/button"; 16 | import { useAudio } from "@/lib/providers/AudioProvider"; 17 | 18 | const MobileNav = () => { 19 | const router = useRouter(); 20 | const pathname = usePathname(); 21 | 22 | const { signOut } = useClerk(); 23 | const { setAudio } = useAudio(); 24 | 25 | const handleLogout = () => { 26 | setAudio(undefined); 27 | signOut(() => router.push("/")); 28 | }; 29 | 30 | return ( 31 |
32 | 33 | 34 | menu 41 | 42 | 43 | 47 | logo 48 |

49 | Podcaster-AI 50 |

51 | 52 |
53 | 54 | 83 | 84 | 85 | 86 |
87 | 104 |
105 |
106 | 107 |
108 | 120 |
121 |
122 |
123 |
124 |
125 |
126 | ); 127 | }; 128 | 129 | export default MobileNav; 130 | -------------------------------------------------------------------------------- /components/navbar/RightSidebar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { SignedIn, UserButton, useUser } from "@clerk/nextjs"; 4 | import Image from "next/image"; 5 | import Link from "next/link"; 6 | import React, { useEffect } from "react"; 7 | import Header from "../Header"; 8 | import Carousel from "../Carousel"; 9 | import { useQuery } from "convex/react"; 10 | import { api } from "@/convex/_generated/api"; 11 | import { useRouter } from "next/navigation"; 12 | import { useAudio } from "@/lib/providers/AudioProvider"; 13 | import { cn } from "@/lib/utils"; 14 | import clsx from "clsx"; 15 | 16 | const RightSidebar = () => { 17 | const router = useRouter(); 18 | 19 | const { user } = useUser(); 20 | 21 | const { audio } = useAudio(); 22 | 23 | const topPodcasters = useQuery(api.users.getTopUserByPodcastCount, { 24 | clerkId: user?.id ?? "", 25 | }); 26 | 27 | const dbUser = useQuery(api.users.getUserById, { 28 | clerkId: user?.id ?? "", 29 | }); 30 | 31 | useEffect(() => { 32 | if (!dbUser || !user) return; 33 | 34 | let dataToUpdate = {}; 35 | 36 | if ( 37 | dbUser?.firstName && 38 | dbUser?.firstName !== null && 39 | dbUser?.firstName !== user.firstName 40 | ) { 41 | dataToUpdate = { ...dataToUpdate, firstName: dbUser.firstName }; 42 | } 43 | 44 | if ( 45 | dbUser?.lastName && 46 | dbUser?.lastName !== null && 47 | dbUser?.lastName !== user.lastName 48 | ) { 49 | dataToUpdate = { ...dataToUpdate, lastName: dbUser.lastName }; 50 | } 51 | 52 | if (Object.keys(dataToUpdate).length > 0) { 53 | user.update(dataToUpdate); 54 | } 55 | 56 | // eslint-disable-next-line react-hooks/exhaustive-deps 57 | }, [dbUser?.firstName, dbUser?.lastName, dbUser?.imageUrl]); 58 | 59 | return ( 60 |
0, 65 | })} 66 | > 67 | 68 | 72 |
73 | 83 |
84 |

85 | {dbUser?.firstName} {dbUser?.lastName} 86 |

87 | arrow 94 |
95 |
96 |
97 | 98 |
99 |
100 |
101 | 102 |
103 |
104 |
105 |
106 | {topPodcasters?.slice(0, 8).map((podcaster) => ( 107 |
{ 111 | if (podcaster.clerkId === user?.id) router.push("/profile"); 112 | 113 | router.push(`/profile/${podcaster.clerkId}`); 114 | }} 115 | > 116 |
117 | {podcaster.name} 124 |

125 | {podcaster.name} 126 |

127 |
128 |
129 |

130 | {podcaster.totalPodcasts}   podcast 131 | {podcaster.totalPodcasts > 1 ? "s" : ""} 132 |

133 |
134 |
135 | ))} 136 |
137 |
138 |
139 | ); 140 | }; 141 | 142 | export default RightSidebar; 143 | -------------------------------------------------------------------------------- /components/table/DataTable.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | getPaginationRowModel, 5 | ColumnDef, 6 | flexRender, 7 | getCoreRowModel, 8 | useReactTable, 9 | } from "@tanstack/react-table"; 10 | 11 | import { 12 | Table, 13 | TableBody, 14 | TableCell, 15 | TableHead, 16 | TableHeader, 17 | TableRow, 18 | } from "@/components/ui/table"; 19 | 20 | interface DataTableProps { 21 | columns: ColumnDef[]; 22 | data: TData[]; 23 | } 24 | 25 | export function DataTable({ 26 | columns, 27 | data, 28 | }: DataTableProps) { 29 | const table = useReactTable({ 30 | data, 31 | columns, 32 | getCoreRowModel: getCoreRowModel(), 33 | getPaginationRowModel: getPaginationRowModel(), 34 | }); 35 | 36 | return ( 37 |
38 | 39 | 40 | {table.getHeaderGroups().map((headerGroup) => ( 41 | 42 | {headerGroup.headers.map((header) => { 43 | return ( 44 | 45 | {header.column.columnDef.cell?.length === 0 46 | ? null 47 | : flexRender( 48 | header.column.columnDef.header, 49 | header.getContext() 50 | )} 51 | 52 | ); 53 | })} 54 | 55 | ))} 56 | 57 | 58 | {table.getRowModel().rows?.length ? ( 59 | table.getRowModel().rows.map((row) => ( 60 | 65 | {row.getVisibleCells().map((cell) => ( 66 | 67 | {flexRender(cell.column.columnDef.cell, cell.getContext())} 68 | 69 | ))} 70 | 71 | )) 72 | ) : ( 73 | 74 | 75 | No results. 76 | 77 | 78 | )} 79 | 80 |
81 |
82 | ); 83 | } 84 | -------------------------------------------------------------------------------- /components/table/columns.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react-hooks/rules-of-hooks */ 2 | "use client"; 3 | 4 | import { ColumnDef } from "@tanstack/react-table"; 5 | import Image from "next/image"; 6 | 7 | import { PodcastProps } from "@/types"; 8 | import { formatTime } from "@/lib/utils"; 9 | import { useAudio } from "@/lib/providers/AudioProvider"; 10 | import clsx from "clsx"; 11 | import Link from "next/link"; 12 | 13 | export const columns: ColumnDef[] = [ 14 | { 15 | accessorKey: "podcast", 16 | header: "Podcast", 17 | cell: ({ row }) => { 18 | const { audio } = useAudio(); 19 | const podcast = row.original; 20 | 21 | const isActive = audio?.audioUrl === podcast.audioUrl; 22 | 23 | return ( 24 | 28 | podcast 35 | 36 |

44 | {podcast.podcastTitle} 45 |

46 | 47 | ); 48 | }, 49 | }, 50 | { 51 | accessorKey: "listeners", 52 | header: "Listeners", 53 | cell: ({ row }) => { 54 | const podcast = row.original; 55 | 56 | return ( 57 |
58 | headphones 65 |

66 | {podcast.viewedBy.length ?? 0} 67 |

68 |
69 | ); 70 | }, 71 | }, 72 | { 73 | accessorKey: "duration", 74 | header: "Duration", 75 | cell: ({ row }) => { 76 | const podcast = row.original; 77 | 78 | return ( 79 |
80 | clock 87 |

88 | {formatTime(podcast.audioDuration)} 89 |

90 |
91 | ); 92 | }, 93 | }, 94 | 95 | { 96 | id: "actions", 97 | header: () =>
Actions
, 98 | cell: ({ row }) => { 99 | // eslint-disable-next-line react-hooks/rules-of-hooks 100 | const { audio, setAudio } = useAudio(); 101 | 102 | const podcast = row.original; 103 | 104 | const handlePlay = () => { 105 | if (audio?.audioUrl === podcast.audioUrl) { 106 | setAudio((prev) => ({ 107 | ...prev, 108 | isPlaying: !prev?.isPlaying, 109 | })); 110 | return; 111 | } 112 | 113 | debugger; 114 | 115 | setAudio({ ...podcast, isPlaying: true }); 116 | }; 117 | 118 | return ( 119 |
120 | {audio?.isPlaying 134 |
135 | ); 136 | }, 137 | }, 138 | ]; 139 | -------------------------------------------------------------------------------- /components/ui/alert-dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | import { buttonVariants } from "@/components/ui/button"; 8 | 9 | const AlertDialog = AlertDialogPrimitive.Root; 10 | 11 | const AlertDialogTrigger = AlertDialogPrimitive.Trigger; 12 | 13 | const AlertDialogPortal = AlertDialogPrimitive.Portal; 14 | 15 | const AlertDialogOverlay = React.forwardRef< 16 | React.ElementRef, 17 | React.ComponentPropsWithoutRef 18 | >(({ className, ...props }, ref) => ( 19 | 27 | )); 28 | AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName; 29 | 30 | const AlertDialogContent = React.forwardRef< 31 | React.ElementRef, 32 | React.ComponentPropsWithoutRef 33 | >(({ className, ...props }, ref) => ( 34 | 35 | 36 | 44 | 45 | )); 46 | AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; 47 | 48 | const AlertDialogHeader = ({ 49 | className, 50 | ...props 51 | }: React.HTMLAttributes) => ( 52 |
59 | ); 60 | AlertDialogHeader.displayName = "AlertDialogHeader"; 61 | 62 | const AlertDialogFooter = ({ 63 | className, 64 | ...props 65 | }: React.HTMLAttributes) => ( 66 |
73 | ); 74 | AlertDialogFooter.displayName = "AlertDialogFooter"; 75 | 76 | const AlertDialogTitle = React.forwardRef< 77 | React.ElementRef, 78 | React.ComponentPropsWithoutRef 79 | >(({ className, ...props }, ref) => ( 80 | 85 | )); 86 | AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName; 87 | 88 | const AlertDialogDescription = React.forwardRef< 89 | React.ElementRef, 90 | React.ComponentPropsWithoutRef 91 | >(({ className, ...props }, ref) => ( 92 | 97 | )); 98 | AlertDialogDescription.displayName = 99 | AlertDialogPrimitive.Description.displayName; 100 | 101 | const AlertDialogAction = React.forwardRef< 102 | React.ElementRef, 103 | React.ComponentPropsWithoutRef 104 | >(({ className, ...props }, ref) => ( 105 | 110 | )); 111 | AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName; 112 | 113 | const AlertDialogCancel = React.forwardRef< 114 | React.ElementRef, 115 | React.ComponentPropsWithoutRef 116 | >(({ className, ...props }, ref) => ( 117 | 126 | )); 127 | AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName; 128 | 129 | export { 130 | AlertDialog, 131 | AlertDialogPortal, 132 | AlertDialogOverlay, 133 | AlertDialogTrigger, 134 | AlertDialogContent, 135 | AlertDialogHeader, 136 | AlertDialogFooter, 137 | AlertDialogTitle, 138 | AlertDialogDescription, 139 | AlertDialogAction, 140 | AlertDialogCancel, 141 | }; 142 | -------------------------------------------------------------------------------- /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 transition-colors !focus:ring-offset-0 !focus:ring-0 outline-none 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 | plain: 22 | "border-none bg-transparent text-[16px] font-bold leading-normal text-white-1", 23 | }, 24 | size: { 25 | default: "h-10 px-4 py-2", 26 | sm: "h-9 rounded-md px-3", 27 | lg: "h-11 rounded-md px-8", 28 | icon: "h-10 w-10", 29 | }, 30 | }, 31 | defaultVariants: { 32 | variant: "default", 33 | size: "default", 34 | }, 35 | } 36 | ); 37 | 38 | export interface ButtonProps 39 | extends React.ButtonHTMLAttributes, 40 | VariantProps { 41 | asChild?: boolean; 42 | } 43 | 44 | const Button = React.forwardRef( 45 | ({ className, variant, size, asChild = false, ...props }, ref) => { 46 | const Comp = asChild ? Slot : "button"; 47 | return ( 48 | 53 | ); 54 | } 55 | ); 56 | Button.displayName = "Button"; 57 | 58 | export { Button, buttonVariants }; 59 | -------------------------------------------------------------------------------- /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 |