├── .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 | [](https://github.com/mastrangelis/podcaster-ai/commits)
4 | [](https://github.com/mastrangelis)
5 | [](https://www.typescriptlang.org/)
6 | 
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 | [](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 |
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 |
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 |
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 |
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 |
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 |
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 |
72 | );
73 | };
74 |
--------------------------------------------------------------------------------
/components/EmptyState.tsx:
--------------------------------------------------------------------------------
1 | import { EmptyStateProps } from "@/types";
2 | import Image from "next/image";
3 | import React from "react";
4 | import { Button } from "./ui/button";
5 | import Link from "next/link";
6 |
7 | const EmptyState = ({
8 | title,
9 | search,
10 | buttonLink,
11 | buttonText,
12 | }: EmptyStateProps) => {
13 | return (
14 |
15 |
21 |
22 |
23 | {title}
24 |
25 | {search && (
26 |
27 | Try adjusting your search to find what you are looking for
28 |
29 | )}
30 | {buttonLink && (
31 |
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 |
130 |
131 |
132 |
139 |
140 |
141 |
142 | {audio?.title}
143 |
144 |
{audio?.author}
145 |
146 |
147 |
148 |
149 |
156 |
-5
157 |
158 |
165 |
166 |
+5
167 |
174 |
175 |
176 |
177 |
178 | {formatTime(duration)}
179 |
180 |
181 |
189 |
190 |
191 |
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 |
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 |
56 |
57 | {isOwner && (
58 |
62 |
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 |
41 |
42 |
43 |
47 |
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 |
94 |
95 |
96 |
97 |
98 |
99 |
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 |
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 |
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 |
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 |
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 |
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 |
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 } =
109 | useFormField();
110 |
111 | return (
112 |
123 | );
124 | });
125 | FormControl.displayName = "FormControl";
126 |
127 | const FormDescription = React.forwardRef<
128 | HTMLParagraphElement,
129 | React.HTMLAttributes
130 | >(({ className, ...props }, ref) => {
131 | const { formDescriptionId } = useFormField();
132 |
133 | return (
134 |
140 | );
141 | });
142 | FormDescription.displayName = "FormDescription";
143 |
144 | const FormMessage = React.forwardRef<
145 | HTMLParagraphElement,
146 | React.HTMLAttributes
147 | >(({ className, children, ...props }, ref) => {
148 | const { error, formMessageId } = useFormField();
149 | const body = error ? String(error?.message) : children;
150 |
151 | if (!body) {
152 | return null;
153 | }
154 |
155 | return (
156 |
162 | {body}
163 |
164 | );
165 | });
166 | FormMessage.displayName = "FormMessage";
167 |
168 | export {
169 | useFormField,
170 | Form,
171 | FormItem,
172 | FormLabel,
173 | FormControl,
174 | FormDescription,
175 | FormMessage,
176 | FormField,
177 | };
178 |
--------------------------------------------------------------------------------
/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/progress.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as ProgressPrimitive from "@radix-ui/react-progress";
5 |
6 | import { cn } from "@/lib/utils";
7 |
8 | const Progress = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, value, ...props }, ref) => (
12 |
20 |
24 |
25 | ));
26 | Progress.displayName = ProgressPrimitive.Root.displayName;
27 |
28 | export { Progress };
29 |
--------------------------------------------------------------------------------
/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/sheet.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as SheetPrimitive from "@radix-ui/react-dialog";
5 | import { cva, type VariantProps } from "class-variance-authority";
6 | import { X } from "lucide-react";
7 |
8 | import { cn } from "@/lib/utils";
9 |
10 | const Sheet = SheetPrimitive.Root;
11 |
12 | const SheetTrigger = SheetPrimitive.Trigger;
13 |
14 | const SheetClose = SheetPrimitive.Close;
15 |
16 | const SheetPortal = SheetPrimitive.Portal;
17 |
18 | const SheetOverlay = React.forwardRef<
19 | React.ElementRef,
20 | React.ComponentPropsWithoutRef
21 | >(({ className, ...props }, ref) => (
22 |
30 | ));
31 | SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
32 |
33 | const sheetVariants = cva(
34 | "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
35 | {
36 | variants: {
37 | side: {
38 | top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
39 | bottom:
40 | "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
41 | left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
42 | right:
43 | "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
44 | },
45 | },
46 | defaultVariants: {
47 | side: "right",
48 | },
49 | }
50 | );
51 |
52 | interface SheetContentProps
53 | extends React.ComponentPropsWithoutRef,
54 | VariantProps {}
55 |
56 | const SheetContent = React.forwardRef<
57 | React.ElementRef,
58 | SheetContentProps
59 | >(({ side = "right", className, children, ...props }, ref) => (
60 |
61 |
62 |
67 | {children}
68 |
69 |
70 | Close
71 |
72 |
73 |
74 | ));
75 | SheetContent.displayName = SheetPrimitive.Content.displayName;
76 |
77 | const SheetHeader = ({
78 | className,
79 | ...props
80 | }: React.HTMLAttributes) => (
81 |
88 | );
89 | SheetHeader.displayName = "SheetHeader";
90 |
91 | const SheetFooter = ({
92 | className,
93 | ...props
94 | }: React.HTMLAttributes) => (
95 |
102 | );
103 | SheetFooter.displayName = "SheetFooter";
104 |
105 | const SheetTitle = React.forwardRef<
106 | React.ElementRef,
107 | React.ComponentPropsWithoutRef
108 | >(({ className, ...props }, ref) => (
109 |
114 | ));
115 | SheetTitle.displayName = SheetPrimitive.Title.displayName;
116 |
117 | const SheetDescription = React.forwardRef<
118 | React.ElementRef,
119 | React.ComponentPropsWithoutRef
120 | >(({ className, ...props }, ref) => (
121 |
126 | ));
127 | SheetDescription.displayName = SheetPrimitive.Description.displayName;
128 |
129 | export {
130 | Sheet,
131 | SheetPortal,
132 | SheetOverlay,
133 | SheetTrigger,
134 | SheetClose,
135 | SheetContent,
136 | SheetHeader,
137 | SheetFooter,
138 | SheetTitle,
139 | SheetDescription,
140 | };
141 |
--------------------------------------------------------------------------------
/components/ui/table.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | const Table = React.forwardRef<
6 | HTMLTableElement,
7 | React.HTMLAttributes
8 | >(({ className, ...props }, ref) => (
9 |
16 | ))
17 | Table.displayName = "Table"
18 |
19 | const TableHeader = React.forwardRef<
20 | HTMLTableSectionElement,
21 | React.HTMLAttributes
22 | >(({ className, ...props }, ref) => (
23 |
24 | ))
25 | TableHeader.displayName = "TableHeader"
26 |
27 | const TableBody = React.forwardRef<
28 | HTMLTableSectionElement,
29 | React.HTMLAttributes
30 | >(({ className, ...props }, ref) => (
31 |
36 | ))
37 | TableBody.displayName = "TableBody"
38 |
39 | const TableFooter = React.forwardRef<
40 | HTMLTableSectionElement,
41 | React.HTMLAttributes
42 | >(({ className, ...props }, ref) => (
43 | tr]:last:border-b-0",
47 | className
48 | )}
49 | {...props}
50 | />
51 | ))
52 | TableFooter.displayName = "TableFooter"
53 |
54 | const TableRow = React.forwardRef<
55 | HTMLTableRowElement,
56 | React.HTMLAttributes
57 | >(({ className, ...props }, ref) => (
58 |
66 | ))
67 | TableRow.displayName = "TableRow"
68 |
69 | const TableHead = React.forwardRef<
70 | HTMLTableCellElement,
71 | React.ThHTMLAttributes
72 | >(({ className, ...props }, ref) => (
73 | |
81 | ))
82 | TableHead.displayName = "TableHead"
83 |
84 | const TableCell = React.forwardRef<
85 | HTMLTableCellElement,
86 | React.TdHTMLAttributes
87 | >(({ className, ...props }, ref) => (
88 | |
93 | ))
94 | TableCell.displayName = "TableCell"
95 |
96 | const TableCaption = React.forwardRef<
97 | HTMLTableCaptionElement,
98 | React.HTMLAttributes
99 | >(({ className, ...props }, ref) => (
100 |
105 | ))
106 | TableCaption.displayName = "TableCaption"
107 |
108 | export {
109 | Table,
110 | TableHeader,
111 | TableBody,
112 | TableFooter,
113 | TableHead,
114 | TableRow,
115 | TableCell,
116 | TableCaption,
117 | }
118 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/components/ui/toast.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as ToastPrimitives from "@radix-ui/react-toast";
5 | import { cva, type VariantProps } from "class-variance-authority";
6 | import { X } from "lucide-react";
7 |
8 | import { cn } from "@/lib/utils";
9 |
10 | const ToastProvider = ToastPrimitives.Provider;
11 |
12 | const ToastViewport = React.forwardRef<
13 | React.ElementRef,
14 | React.ComponentPropsWithoutRef
15 | >(({ className, ...props }, ref) => (
16 |
24 | ));
25 | ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
26 |
27 | const toastVariants = cva(
28 | "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
29 | {
30 | variants: {
31 | variant: {
32 | default: "border bg-orange-1 text-white-1",
33 | destructive:
34 | "destructive border-red-500 bg-red-500 text-slate-50 dark:border-red-900 dark:bg-red-900",
35 | success:
36 | "success border-green-500 bg-green-500 text-slate-50 dark:border-green-900 dark:bg-green-900",
37 | },
38 | },
39 | defaultVariants: {
40 | variant: "default",
41 | },
42 | }
43 | );
44 |
45 | const Toast = React.forwardRef<
46 | React.ElementRef,
47 | React.ComponentPropsWithoutRef &
48 | VariantProps
49 | >(({ className, variant, ...props }, ref) => {
50 | return (
51 |
57 | );
58 | });
59 | Toast.displayName = ToastPrimitives.Root.displayName;
60 |
61 | const ToastAction = React.forwardRef<
62 | React.ElementRef,
63 | React.ComponentPropsWithoutRef
64 | >(({ className, ...props }, ref) => (
65 |
73 | ));
74 | ToastAction.displayName = ToastPrimitives.Action.displayName;
75 |
76 | const ToastClose = React.forwardRef<
77 | React.ElementRef,
78 | React.ComponentPropsWithoutRef
79 | >(({ className, ...props }, ref) => (
80 |
89 |
90 |
91 | ));
92 | ToastClose.displayName = ToastPrimitives.Close.displayName;
93 |
94 | const ToastTitle = React.forwardRef<
95 | React.ElementRef,
96 | React.ComponentPropsWithoutRef
97 | >(({ className, ...props }, ref) => (
98 |
103 | ));
104 | ToastTitle.displayName = ToastPrimitives.Title.displayName;
105 |
106 | const ToastDescription = React.forwardRef<
107 | React.ElementRef,
108 | React.ComponentPropsWithoutRef
109 | >(({ className, ...props }, ref) => (
110 |
115 | ));
116 | ToastDescription.displayName = ToastPrimitives.Description.displayName;
117 |
118 | type ToastProps = React.ComponentPropsWithoutRef;
119 |
120 | type ToastActionElement = React.ReactElement;
121 |
122 | export {
123 | type ToastProps,
124 | type ToastActionElement,
125 | ToastProvider,
126 | ToastViewport,
127 | Toast,
128 | ToastTitle,
129 | ToastDescription,
130 | ToastClose,
131 | ToastAction,
132 | };
133 |
--------------------------------------------------------------------------------
/components/ui/toaster.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import {
4 | Toast,
5 | ToastClose,
6 | ToastDescription,
7 | ToastProvider,
8 | ToastTitle,
9 | ToastViewport,
10 | } from "@/components/ui/toast"
11 | import { useToast } from "@/components/ui/use-toast"
12 |
13 | export function Toaster() {
14 | const { toasts } = useToast()
15 |
16 | return (
17 |
18 | {toasts.map(function ({ id, title, description, action, ...props }) {
19 | return (
20 |
21 |
22 | {title && {title}}
23 | {description && (
24 | {description}
25 | )}
26 |
27 | {action}
28 |
29 |
30 | )
31 | })}
32 |
33 |
34 | )
35 | }
36 |
--------------------------------------------------------------------------------
/components/ui/tooltip.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as TooltipPrimitive from "@radix-ui/react-tooltip"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const TooltipProvider = TooltipPrimitive.Provider
9 |
10 | const Tooltip = TooltipPrimitive.Root
11 |
12 | const TooltipTrigger = TooltipPrimitive.Trigger
13 |
14 | const TooltipContent = React.forwardRef<
15 | React.ElementRef,
16 | React.ComponentPropsWithoutRef
17 | >(({ className, sideOffset = 4, ...props }, ref) => (
18 |
27 | ))
28 | TooltipContent.displayName = TooltipPrimitive.Content.displayName
29 |
30 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
31 |
--------------------------------------------------------------------------------
/components/ui/use-toast.ts:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | // Inspired by react-hot-toast library
4 | import * as React from "react"
5 |
6 | import type {
7 | ToastActionElement,
8 | ToastProps,
9 | } from "@/components/ui/toast"
10 |
11 | const TOAST_LIMIT = 1
12 | const TOAST_REMOVE_DELAY = 1000000
13 |
14 | type ToasterToast = ToastProps & {
15 | id: string
16 | title?: React.ReactNode
17 | description?: React.ReactNode
18 | action?: ToastActionElement
19 | }
20 |
21 | const actionTypes = {
22 | ADD_TOAST: "ADD_TOAST",
23 | UPDATE_TOAST: "UPDATE_TOAST",
24 | DISMISS_TOAST: "DISMISS_TOAST",
25 | REMOVE_TOAST: "REMOVE_TOAST",
26 | } as const
27 |
28 | let count = 0
29 |
30 | function genId() {
31 | count = (count + 1) % Number.MAX_SAFE_INTEGER
32 | return count.toString()
33 | }
34 |
35 | type ActionType = typeof actionTypes
36 |
37 | type Action =
38 | | {
39 | type: ActionType["ADD_TOAST"]
40 | toast: ToasterToast
41 | }
42 | | {
43 | type: ActionType["UPDATE_TOAST"]
44 | toast: Partial
45 | }
46 | | {
47 | type: ActionType["DISMISS_TOAST"]
48 | toastId?: ToasterToast["id"]
49 | }
50 | | {
51 | type: ActionType["REMOVE_TOAST"]
52 | toastId?: ToasterToast["id"]
53 | }
54 |
55 | interface State {
56 | toasts: ToasterToast[]
57 | }
58 |
59 | const toastTimeouts = new Map>()
60 |
61 | const addToRemoveQueue = (toastId: string) => {
62 | if (toastTimeouts.has(toastId)) {
63 | return
64 | }
65 |
66 | const timeout = setTimeout(() => {
67 | toastTimeouts.delete(toastId)
68 | dispatch({
69 | type: "REMOVE_TOAST",
70 | toastId: toastId,
71 | })
72 | }, TOAST_REMOVE_DELAY)
73 |
74 | toastTimeouts.set(toastId, timeout)
75 | }
76 |
77 | export const reducer = (state: State, action: Action): State => {
78 | switch (action.type) {
79 | case "ADD_TOAST":
80 | return {
81 | ...state,
82 | toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
83 | }
84 |
85 | case "UPDATE_TOAST":
86 | return {
87 | ...state,
88 | toasts: state.toasts.map((t) =>
89 | t.id === action.toast.id ? { ...t, ...action.toast } : t
90 | ),
91 | }
92 |
93 | case "DISMISS_TOAST": {
94 | const { toastId } = action
95 |
96 | // ! Side effects ! - This could be extracted into a dismissToast() action,
97 | // but I'll keep it here for simplicity
98 | if (toastId) {
99 | addToRemoveQueue(toastId)
100 | } else {
101 | state.toasts.forEach((toast) => {
102 | addToRemoveQueue(toast.id)
103 | })
104 | }
105 |
106 | return {
107 | ...state,
108 | toasts: state.toasts.map((t) =>
109 | t.id === toastId || toastId === undefined
110 | ? {
111 | ...t,
112 | open: false,
113 | }
114 | : t
115 | ),
116 | }
117 | }
118 | case "REMOVE_TOAST":
119 | if (action.toastId === undefined) {
120 | return {
121 | ...state,
122 | toasts: [],
123 | }
124 | }
125 | return {
126 | ...state,
127 | toasts: state.toasts.filter((t) => t.id !== action.toastId),
128 | }
129 | }
130 | }
131 |
132 | const listeners: Array<(state: State) => void> = []
133 |
134 | let memoryState: State = { toasts: [] }
135 |
136 | function dispatch(action: Action) {
137 | memoryState = reducer(memoryState, action)
138 | listeners.forEach((listener) => {
139 | listener(memoryState)
140 | })
141 | }
142 |
143 | type Toast = Omit
144 |
145 | function toast({ ...props }: Toast) {
146 | const id = genId()
147 |
148 | const update = (props: ToasterToast) =>
149 | dispatch({
150 | type: "UPDATE_TOAST",
151 | toast: { ...props, id },
152 | })
153 | const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
154 |
155 | dispatch({
156 | type: "ADD_TOAST",
157 | toast: {
158 | ...props,
159 | id,
160 | open: true,
161 | onOpenChange: (open) => {
162 | if (!open) dismiss()
163 | },
164 | },
165 | })
166 |
167 | return {
168 | id: id,
169 | dismiss,
170 | update,
171 | }
172 | }
173 |
174 | function useToast() {
175 | const [state, setState] = React.useState(memoryState)
176 |
177 | React.useEffect(() => {
178 | listeners.push(setState)
179 | return () => {
180 | const index = listeners.indexOf(setState)
181 | if (index > -1) {
182 | listeners.splice(index, 1)
183 | }
184 | }
185 | }, [state])
186 |
187 | return {
188 | ...state,
189 | toast,
190 | dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
191 | }
192 | }
193 |
194 | export { useToast, toast }
195 |
--------------------------------------------------------------------------------
/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.13.2.
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 files from "../files.js";
18 | import type * as http from "../http.js";
19 | import type * as openai from "../openai.js";
20 | import type * as podcasts from "../podcasts.js";
21 | import type * as users from "../users.js";
22 |
23 | /**
24 | * A utility for referencing Convex functions in your app's API.
25 | *
26 | * Usage:
27 | * ```js
28 | * const myFunctionReference = api.myModule.myFunction;
29 | * ```
30 | */
31 | declare const fullApi: ApiFromModules<{
32 | files: typeof files;
33 | http: typeof http;
34 | openai: typeof openai;
35 | podcasts: typeof podcasts;
36 | users: typeof users;
37 | }>;
38 | export declare const api: FilterApi<
39 | typeof fullApi,
40 | FunctionReference
41 | >;
42 | export declare const internal: FilterApi<
43 | typeof fullApi,
44 | FunctionReference
45 | >;
46 |
--------------------------------------------------------------------------------
/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.13.2.
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.13.2.
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.13.2.
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.13.2.
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 | const authConfig = {
2 | providers: [
3 | {
4 | domain: process.env.NEXT_PUBLIC_CLERK_CONVEX_ISSUER_URL,
5 | applicationID: "convex",
6 | },
7 | ],
8 | };
9 |
10 | export default authConfig;
11 |
--------------------------------------------------------------------------------
/convex/files.ts:
--------------------------------------------------------------------------------
1 | import { mutation } from "./_generated/server";
2 |
3 | export const generateUploadUrl = mutation({
4 | args: {},
5 | handler: async (ctx, args) => {
6 | return await ctx.storage.generateUploadUrl();
7 | },
8 | });
9 |
--------------------------------------------------------------------------------
/convex/http.ts:
--------------------------------------------------------------------------------
1 | // ===== reference links =====
2 | // https://www.convex.dev/templates (open the link and choose for clerk than you will get the github link mentioned below)
3 | // https://github.dev/webdevcody/thumbnail-critique/blob/6637671d72513cfe13d00cb7a2990b23801eb327/convex/schema.ts
4 |
5 | import type { WebhookEvent } from "@clerk/nextjs/server";
6 | import { httpRouter } from "convex/server";
7 | import { Webhook } from "svix";
8 |
9 | import { internal } from "./_generated/api";
10 | import { httpAction } from "./_generated/server";
11 |
12 | function generateGuestName(): string {
13 | const prefix = "Guest";
14 | const randomNumber = Math.floor(10000 + Math.random() * 90000); // Generates a 5-digit number
15 |
16 | return `${prefix}${randomNumber}`;
17 | }
18 |
19 | function isLessThanFiveMinutesAgo(timestamp: number): boolean {
20 | const currentTime = Date.now(); // Current time in milliseconds since epoch
21 | const fiveMinutesInMilliseconds = 5 * 60 * 1000; // 5 minutes in milliseconds
22 |
23 | return currentTime - timestamp < fiveMinutesInMilliseconds;
24 | }
25 |
26 | const handleClerkWebhook = httpAction(async (ctx, request) => {
27 | const event = await validateRequest(request);
28 |
29 | if (!event) {
30 | return new Response("Invalid request", { status: 400 });
31 | }
32 |
33 | if (event.type === "user.created") {
34 | await ctx.runMutation(internal.users.createUser, {
35 | clerkId: event.data.id,
36 | email: event.data.email_addresses[0].email_address,
37 | imageUrl: event.data.image_url,
38 | name:
39 | event.data.first_name || event.data.last_name
40 | ? `${event.data.first_name} ${event.data.last_name}`
41 | : generateGuestName(),
42 | });
43 | }
44 |
45 | if (event.type === "user.updated") {
46 | await ctx.runMutation(internal.users.updateUser, {
47 | clerkId: event.data.id,
48 | imageUrl: event.data.image_url,
49 | email: event.data.email_addresses[0].email_address,
50 | name: `${event.data.first_name ?? ""} ${event.data.last_name ?? ""}`,
51 | });
52 | }
53 |
54 | if (event.type === "user.deleted") {
55 | await ctx.runMutation(internal.users.deleteUserInternal, {
56 | clerkId: event.data.id as string,
57 | });
58 | }
59 |
60 | return new Response(null, {
61 | status: 200,
62 | });
63 | });
64 |
65 | const http = httpRouter();
66 |
67 | http.route({
68 | path: "/clerk",
69 | method: "POST",
70 | handler: handleClerkWebhook,
71 | });
72 |
73 | const validateRequest = async (
74 | req: Request
75 | ): Promise => {
76 | const webhookSecret = process.env.NEXT_CLERK_WEBHOOK_SECRET!;
77 | if (!webhookSecret) {
78 | throw new Error("CLERK_WEBHOOK_SECRET is not defined");
79 | }
80 | const payloadString = await req.text();
81 | const headerPayload = req.headers;
82 | const svixHeaders = {
83 | "svix-id": headerPayload.get("svix-id")!,
84 | "svix-timestamp": headerPayload.get("svix-timestamp")!,
85 | "svix-signature": headerPayload.get("svix-signature")!,
86 | };
87 | const wh = new Webhook(webhookSecret);
88 | const event = wh.verify(payloadString, svixHeaders);
89 | return event as unknown as WebhookEvent;
90 | };
91 |
92 | export default http;
93 |
--------------------------------------------------------------------------------
/convex/openai.ts:
--------------------------------------------------------------------------------
1 | import { action } from "./_generated/server";
2 | import { v } from "convex/values";
3 |
4 | import OpenAI from "openai";
5 | import { SpeechCreateParams } from "openai/resources/audio/speech.mjs";
6 |
7 | const openai = new OpenAI({
8 | apiKey: process.env.OPENAI_API_KEY,
9 | });
10 |
11 | export const generateAudioAction = action({
12 | args: { input: v.string(), voice: v.string() },
13 | handler: async (_, { voice, input }) => {
14 | const mp3 = await openai.audio.speech.create({
15 | model: "tts-1",
16 | voice: voice as SpeechCreateParams["voice"],
17 | input,
18 | });
19 |
20 | const buffer = await mp3.arrayBuffer();
21 |
22 | return buffer;
23 | },
24 | });
25 |
26 | export const generateThumbnailAction = action({
27 | args: { prompt: v.string() },
28 | handler: async (_, { prompt }) => {
29 | try {
30 | const response = await openai.images.generate({
31 | model: "dall-e-3",
32 | prompt,
33 | size: "1024x1024",
34 | quality: "standard",
35 | n: 1,
36 | });
37 |
38 | const url = response.data[0].url;
39 |
40 | if (!url) {
41 | throw new Error("Error generating thumbnail");
42 | }
43 |
44 | const imageResponse = await fetch(url);
45 | const buffer = await imageResponse.arrayBuffer();
46 | return buffer;
47 | } catch (e) {
48 | console.error(e);
49 | throw new Error("Error generating thumbnail");
50 | }
51 | },
52 | });
53 |
--------------------------------------------------------------------------------
/convex/schema.ts:
--------------------------------------------------------------------------------
1 | import { defineSchema, defineTable } from "convex/server";
2 | import { v } from "convex/values";
3 |
4 | export default defineSchema({
5 | podcasts: defineTable({
6 | user: v.id("users"),
7 | podcastTitle: v.string(),
8 | podcastDescription: v.string(),
9 | audioUrl: v.optional(v.string()),
10 | audioStorageId: v.optional(v.id("_storage")),
11 | imageUrl: v.optional(v.string()),
12 | imageStorageId: v.optional(v.id("_storage")),
13 | author: v.string(),
14 | authorId: v.string(),
15 | authorImageUrl: v.string(),
16 | voicePrompt: v.string(),
17 | imagePrompt: v.optional(v.string()),
18 | voiceType: v.string(),
19 | audioDuration: v.number(),
20 | viewedBy: v.array(v.id("users")),
21 | })
22 | .searchIndex("search_author", { searchField: "author" })
23 | .searchIndex("search_title", { searchField: "podcastTitle" })
24 | .searchIndex("search_body", { searchField: "podcastDescription" }),
25 | users: defineTable({
26 | email: v.string(),
27 | imageUrl: v.string(),
28 | clerkId: v.string(),
29 | name: v.string(),
30 | firstName: v.optional(v.string()),
31 | lastName: v.optional(v.string()),
32 | }),
33 | });
34 |
--------------------------------------------------------------------------------
/convex/users.ts:
--------------------------------------------------------------------------------
1 | import { ConvexError, v } from "convex/values";
2 |
3 | import { internalMutation, mutation, query } from "./_generated/server";
4 |
5 | export const getUserById = query({
6 | args: { clerkId: v.string() },
7 | handler: async (ctx, args) => {
8 | if (!args.clerkId) return;
9 |
10 | const user = await ctx.db
11 | .query("users")
12 | .filter((q) => q.eq(q.field("clerkId"), args.clerkId))
13 | .unique();
14 |
15 | if (!user) {
16 | return;
17 | }
18 |
19 | return user;
20 | },
21 | });
22 |
23 | // this query is used to get the top user by podcast count. first the podcast is sorted by views and then the user is sorted by total podcasts, so the user with the most podcasts will be at the top.
24 | export const getTopUserByPodcastCount = query({
25 | args: {
26 | clerkId: v.string(),
27 | },
28 | handler: async (ctx, args) => {
29 | const users = await ctx.db.query("users").collect();
30 |
31 | const usersData = await Promise.all(
32 | users.map(async (u) => {
33 | const podcasts = await ctx.db
34 | .query("podcasts")
35 | .filter((q) => q.eq(q.field("authorId"), u.clerkId))
36 | .collect();
37 |
38 | const sortedPodcasts = podcasts
39 | ?.map((podcast) => ({
40 | ...podcast,
41 | views: podcast.viewedBy.length || 0,
42 | }))
43 | .sort((a, b) => b.views - a.views);
44 |
45 | return {
46 | ...u,
47 | totalPodcasts: podcasts.length,
48 | podcast: sortedPodcasts.map((p) => ({
49 | podcastTitle: p.podcastTitle,
50 | podcastId: p._id,
51 | })),
52 | };
53 | })
54 | );
55 |
56 | return usersData
57 | ?.filter(
58 | (podcaster) =>
59 | podcaster.clerkId !== args.clerkId && podcaster.totalPodcasts > 0
60 | )
61 | ?.sort((a, b) => b.totalPodcasts - a.totalPodcasts);
62 | },
63 | });
64 |
65 | export const createUser = internalMutation({
66 | args: {
67 | clerkId: v.string(),
68 | email: v.string(),
69 | imageUrl: v.string(),
70 | name: v.string(),
71 | },
72 | handler: async (ctx, args) => {
73 | let firstName, lastName;
74 |
75 | const name = args.name.split(" ");
76 | if (name.length === 1) {
77 | firstName = name[0];
78 | lastName = "";
79 | } else {
80 | firstName = name[0];
81 | lastName = name.slice(1).join(" ");
82 | }
83 |
84 | await ctx.db.insert("users", {
85 | clerkId: args.clerkId,
86 | email: args.email,
87 | imageUrl: args.imageUrl,
88 | name: args.name,
89 | firstName,
90 | lastName,
91 | });
92 | },
93 | });
94 |
95 | export const updateUser = internalMutation({
96 | args: {
97 | clerkId: v.string(),
98 | imageUrl: v.string(),
99 | email: v.string(),
100 | name: v.string(),
101 | },
102 | async handler(ctx, args) {
103 | let firstName, lastName;
104 |
105 | const name = args.name.split(" ");
106 | if (name.length === 1) {
107 | firstName = name[0];
108 | lastName = "";
109 | } else {
110 | firstName = name[0];
111 | lastName = name.slice(1).join(" ");
112 | }
113 |
114 | const user = await ctx.db
115 | .query("users")
116 | .filter((q) => q.eq(q.field("clerkId"), args.clerkId))
117 | .unique();
118 |
119 | if (!user) {
120 | throw new ConvexError("User not found");
121 | }
122 |
123 | await ctx.db.patch(user._id, {
124 | imageUrl: args.imageUrl,
125 | email: args.email,
126 | name: args.name,
127 | firstName,
128 | lastName,
129 | });
130 |
131 | const podcast = await ctx.db
132 | .query("podcasts")
133 | .filter((q) => q.eq(q.field("authorId"), args.clerkId))
134 | .collect();
135 |
136 | await Promise.all(
137 | podcast.map(async (p) => {
138 | await ctx.db.patch(p._id, {
139 | authorImageUrl: args.imageUrl,
140 | });
141 | })
142 | );
143 | },
144 | });
145 |
146 | export const deleteUser = mutation({
147 | args: { clerkId: v.string() },
148 | async handler(ctx, args) {
149 | const user = await ctx.db
150 | .query("users")
151 | .filter((q) => q.eq(q.field("clerkId"), args.clerkId))
152 | .unique();
153 |
154 | if (!user) {
155 | throw new ConvexError("User not found");
156 | }
157 |
158 | const allPodcasts = await ctx.db.query("podcasts").collect();
159 | const userPodcasts = allPodcasts.filter((p) => p.authorId === user.clerkId);
160 | const followedPodcasts = allPodcasts.filter((p) =>
161 | p.viewedBy.includes(user._id)
162 | );
163 |
164 | if (userPodcasts.length > 0) {
165 | await Promise.all(
166 | userPodcasts.map(async (p) => {
167 | if (p.audioStorageId) await ctx.storage.delete(p.audioStorageId);
168 | if (p.imageStorageId) await ctx.storage.delete(p.imageStorageId);
169 |
170 | await ctx.db.delete(p._id);
171 | })
172 | );
173 | }
174 |
175 | if (followedPodcasts.length > 0) {
176 | await Promise.all(
177 | followedPodcasts.map(async (p) => {
178 | if (p.audioStorageId)
179 | await ctx.db.patch(p._id, {
180 | viewedBy: p.viewedBy.filter((u) => u !== user._id),
181 | });
182 | })
183 | );
184 | }
185 |
186 | await ctx.db.delete(user._id);
187 | },
188 | });
189 |
190 | export const deleteUserInternal = internalMutation({
191 | args: { clerkId: v.string() },
192 | async handler(ctx, args) {
193 | const user = await ctx.db
194 | .query("users")
195 | .filter((q) => q.eq(q.field("clerkId"), args.clerkId))
196 | .unique();
197 |
198 | if (!user) {
199 | throw new ConvexError("User not found");
200 | }
201 |
202 | await ctx.db.delete(user._id);
203 |
204 | const podcasts = await ctx.db
205 | .query("podcasts")
206 | .filter((q) => q.eq(q.field("authorId"), user.clerkId))
207 | .collect();
208 |
209 | if (podcasts.length > 0) {
210 | await Promise.all(
211 | podcasts.map(async (p) => {
212 | if (p.audioStorageId) await ctx.storage.delete(p.audioStorageId);
213 | if (p.imageStorageId) await ctx.storage.delete(p.imageStorageId);
214 |
215 | await ctx.db.delete(p._id);
216 | })
217 | );
218 | }
219 | },
220 | });
221 |
222 | export const getFollowersByPodcastId = query({
223 | args: {
224 | podcastId: v.id("podcasts"),
225 | },
226 | handler: async (ctx, args) => {
227 | if (!args.podcastId) return;
228 |
229 | const podcast = await ctx.db
230 | .query("podcasts")
231 | .filter((q) => q.eq(q.field("_id"), args.podcastId))
232 | .unique();
233 |
234 | if (!podcast) {
235 | return;
236 | }
237 |
238 | const users = await Promise.all(
239 | podcast.viewedBy.map(async (user) => {
240 | return await ctx.db.get(user);
241 | })
242 | );
243 |
244 | return users;
245 | },
246 | });
247 |
--------------------------------------------------------------------------------
/instrumentation.ts:
--------------------------------------------------------------------------------
1 | export async function register() {
2 | if (process.env.NEXT_RUNTIME === 'nodejs') {
3 | await import('./sentry.server.config');
4 | }
5 |
6 | if (process.env.NEXT_RUNTIME === 'edge') {
7 | await import('./sentry.edge.config');
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/lib/constants/index.ts:
--------------------------------------------------------------------------------
1 | import { ClerkMiddlewareOptions } from "@clerk/nextjs/server";
2 |
3 | export const sidebarLinks = [
4 | {
5 | imgURL: "/icons/home.svg",
6 | route: "/",
7 | label: "Home",
8 | },
9 | {
10 | imgURL: "/icons/discover.svg",
11 | route: "/discover",
12 | label: "Discover",
13 | },
14 | {
15 | imgURL: "/icons/microphone.svg",
16 | route: "/create-podcast",
17 | label: "Create Podcast",
18 | },
19 | {
20 | imgURL: "/icons/user.svg",
21 | route: "/profile",
22 | label: "My Profile",
23 | },
24 | ];
25 |
26 | export const voiceDetails = [
27 | {
28 | id: 1,
29 | name: "alloy",
30 | },
31 | {
32 | id: 2,
33 | name: "echo",
34 | },
35 | {
36 | id: 3,
37 | name: "fable",
38 | },
39 | {
40 | id: 4,
41 | name: "onyx",
42 | },
43 | {
44 | id: 5,
45 | name: "nova",
46 | },
47 | {
48 | id: 6,
49 | name: "shimmer",
50 | },
51 | ];
52 |
53 | export const podcastData = [
54 | {
55 | id: 1,
56 | title: "The Joe Rogan Experience",
57 | description: "A long form, in-depth conversation",
58 | imgURL:
59 | "https://lovely-flamingo-139.convex.cloud/api/storage/3106b884-548d-4ba0-a179-785901f69806",
60 | },
61 | {
62 | id: 2,
63 | title: "The Futur",
64 | description: "This is how the news should sound",
65 | imgURL:
66 | "https://lovely-flamingo-139.convex.cloud/api/storage/16fbf9bd-d800-42bc-ac95-d5a586447bf6",
67 | },
68 | {
69 | id: 3,
70 | title: "Waveform",
71 | description: "Join Michelle Obama in conversation",
72 | imgURL:
73 | "https://lovely-flamingo-139.convex.cloud/api/storage/60f0c1d9-f2ac-4a96-9178-f01d78fa3733",
74 | },
75 | {
76 | id: 4,
77 | title: "The Tech Talks Daily Podcast",
78 | description: "This is how the news should sound",
79 | imgURL:
80 | "https://lovely-flamingo-139.convex.cloud/api/storage/5ba7ed1b-88b4-4c32-8d71-270f1c502445",
81 | },
82 | {
83 | id: 5,
84 | title: "GaryVee Audio Experience",
85 | description: "A long form, in-depth conversation",
86 | imgURL:
87 | "https://lovely-flamingo-139.convex.cloud/api/storage/ca7cb1a6-4919-4b2c-a73e-279a79ac6d23",
88 | },
89 | {
90 | id: 6,
91 | title: "Syntax ",
92 | description: "Join Michelle Obama in conversation",
93 | imgURL:
94 | "https://lovely-flamingo-139.convex.cloud/api/storage/b8ea40c7-aafb-401a-9129-73c515a73ab5",
95 | },
96 | {
97 | id: 7,
98 | title: "IMPAULSIVE",
99 | description: "A long form, in-depth conversation",
100 | imgURL:
101 | "https://lovely-flamingo-139.convex.cloud/api/storage/8a55d662-fe3f-4bcf-b78b-3b2f3d3def5c",
102 | },
103 | {
104 | id: 8,
105 | title: "Ted Tech",
106 | description: "This is how the news should sound",
107 | imgURL:
108 | "https://lovely-flamingo-139.convex.cloud/api/storage/221ee4bd-435f-42c3-8e98-4a001e0d806e",
109 | },
110 | ];
111 |
112 | export const clerkAppearanceOptions = {
113 | layout: {
114 | socialButtonsVariant: "iconButton",
115 | logoImageUrl: "/icons/auth-logo.svg",
116 | },
117 | elements: {
118 | socialButtonsIconButton: {
119 | border: "1px solid rgba(255, 255, 255, 0.2) !important",
120 | },
121 | dividerLine: {
122 | textColor: "white",
123 | borderColor: "white",
124 | backgroundColor: "rgba(255, 255, 255, 0.2)",
125 | },
126 | input: {
127 | backgroundColor: "#1b1f29",
128 | textColor: "white",
129 | placeholderColor: "#15171C",
130 | },
131 | },
132 | variables: {
133 | colorBackground: "#15171c",
134 | colorPrimary: "",
135 | colorText: "white",
136 | },
137 | };
138 |
--------------------------------------------------------------------------------
/lib/hooks/useDebounce.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 |
3 | export const useDebounce = (value: T, delay = 500) => {
4 | const [debouncedValue, setDebouncedValue] = useState(value);
5 |
6 | useEffect(() => {
7 | const timeout = setTimeout(() => {
8 | setDebouncedValue(value);
9 | }, delay);
10 |
11 | return () => {
12 | clearTimeout(timeout);
13 | };
14 | }, [value, delay]);
15 |
16 | return debouncedValue;
17 | };
18 |
--------------------------------------------------------------------------------
/lib/hooks/useGeneratePodcast.ts:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { GeneratePodcastProps } from "@/types";
4 | import { useState } from "react";
5 | import { useAction, useMutation } from "convex/react";
6 | import { api } from "@/convex/_generated/api";
7 | import { useToast } from "@/components/ui/use-toast";
8 | import { v4 as uuidv4 } from "uuid";
9 |
10 | import { useUploadFiles } from "@xixixao/uploadstuff/react";
11 |
12 | const useGeneratePodcast = ({
13 | setAudio,
14 | voiceType,
15 | voicePrompt,
16 | }: GeneratePodcastProps) => {
17 | const { toast } = useToast();
18 |
19 | const [isGenerating, setIsGenerating] = useState(false);
20 |
21 | const generateUploadUrl = useMutation(api.files.generateUploadUrl);
22 | const { startUpload } = useUploadFiles(generateUploadUrl);
23 |
24 | const getPodcastAudio = useAction(api.openai.generateAudioAction);
25 |
26 | const getAudioUrl = useMutation(api.podcasts.getUrl);
27 |
28 | const generatePodcast = async () => {
29 | setIsGenerating(true);
30 | setAudio({
31 | url: "",
32 | storageId: "",
33 | });
34 |
35 | if (!voicePrompt) {
36 | toast({
37 | title: "Please provide a voice type to generate a podcast",
38 | });
39 | return setIsGenerating(false);
40 | }
41 |
42 | try {
43 | const response = await getPodcastAudio({
44 | voice: voiceType,
45 | input: voicePrompt,
46 | });
47 |
48 | const fileName = `podcast-${uuidv4()}.mp3`;
49 | const blob = new Blob([response], { type: "audio/mpeg" });
50 | const file = new File([blob], fileName, { type: "audio/mpeg" });
51 |
52 | const uploaded = await startUpload([file]);
53 | const storageId = (uploaded[0].response as any).storageId;
54 |
55 | const audioUrl = await getAudioUrl({ storageId });
56 | setAudio({ url: audioUrl!, storageId });
57 |
58 | setIsGenerating(false);
59 |
60 | toast({
61 | title: "Podcast generated successfully",
62 | variant: "success",
63 | });
64 | } catch (error) {
65 | console.log("Error generating podcast", error);
66 | toast({
67 | title: "Error creating a podcast",
68 | variant: "destructive",
69 | });
70 | setIsGenerating(false);
71 | }
72 | };
73 |
74 | return { isGenerating, generatePodcast };
75 | };
76 |
77 | export default useGeneratePodcast;
78 |
--------------------------------------------------------------------------------
/lib/hooks/useGenerateThumbnail.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useToast } from "@/components/ui/use-toast";
4 | import { api } from "@/convex/_generated/api";
5 | import { GenerateThumbnailProps } from "@/types";
6 | import { useUploadFiles } from "@xixixao/uploadstuff/react";
7 | import { useAction, useMutation } from "convex/react";
8 | import { useRef, useState } from "react";
9 | import { v4 as uuidv4 } from "uuid";
10 |
11 | const useGenerateThumbnail = ({
12 | imagePrompt,
13 | setThumbnail,
14 | }: GenerateThumbnailProps) => {
15 | const { toast } = useToast();
16 |
17 | const generateUploadUrl = useMutation(api.files.generateUploadUrl);
18 | const getImageUrl = useMutation(api.podcasts.getUrl);
19 |
20 | const { startUpload } = useUploadFiles(generateUploadUrl);
21 |
22 | const handleGenerateThumbnail = useAction(api.openai.generateThumbnailAction);
23 |
24 | const uploadThumbnailRef = useRef(null);
25 |
26 | const [isAiThumbnail, setIsAiThumbnail] = useState(false);
27 | const [isThumbnailLoading, setIsThumbnailLoading] = useState(false);
28 |
29 | const handleImage = async (blob: Blob, fileName: string) => {
30 | setThumbnail({
31 | url: "",
32 | storageId: "",
33 | });
34 |
35 | try {
36 | const file = new File([blob], fileName, { type: "image/png" });
37 |
38 | const uploaded = await startUpload([file]);
39 | const storageId = (uploaded[0].response as any).storageId;
40 |
41 | const imageUrl = await getImageUrl({ storageId });
42 | setThumbnail({ url: imageUrl!, storageId });
43 |
44 | setIsThumbnailLoading(false);
45 |
46 | toast({
47 | title: "Thumbnail generated successfully",
48 | variant: "success",
49 | });
50 | } catch (error) {
51 | setIsThumbnailLoading(false);
52 |
53 | toast({
54 | title: "Error generating thumbnail",
55 | variant: "destructive",
56 | });
57 | }
58 | };
59 |
60 | const generateImage = async () => {
61 | setIsThumbnailLoading(true);
62 |
63 | try {
64 | const response = await handleGenerateThumbnail({
65 | prompt: imagePrompt,
66 | });
67 |
68 | const blob = new Blob([response], { type: "image/png" });
69 | handleImage(blob, `thumbnail-${uuidv4()}`);
70 | } catch (error) {
71 | setIsThumbnailLoading(false);
72 |
73 | toast({
74 | title: "Error generating thumbnail",
75 | variant: "destructive",
76 | });
77 | }
78 | };
79 |
80 | const uploadImage = async (e: React.ChangeEvent) => {
81 | e.preventDefault();
82 |
83 | try {
84 | const files = e.target.files;
85 | if (!files) return;
86 |
87 | const file = files[0];
88 | const blob = await file.arrayBuffer().then((ab) => new Blob([ab]));
89 |
90 | handleImage(blob, file.name);
91 | } catch (error) {
92 | toast({ title: "Error uploading image", variant: "destructive" });
93 | }
94 | };
95 |
96 | return {
97 | isAiThumbnail,
98 | uploadThumbnailRef,
99 | isThumbnailLoading,
100 | generateImage,
101 | uploadImage,
102 | setIsAiThumbnail,
103 | };
104 | };
105 |
106 | export default useGenerateThumbnail;
107 |
--------------------------------------------------------------------------------
/lib/providers/AudioProvider.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { AudioContextType, AudioProps } from "@/types";
4 | import { usePathname } from "next/navigation";
5 | import React, { createContext, useContext, useEffect, useState } from "react";
6 |
7 | const AudioContext = createContext(undefined);
8 |
9 | const AudioProvider = ({ children }: { children: React.ReactNode }) => {
10 | const [audio, setAudio] = useState();
11 | const pathname = usePathname();
12 |
13 | useEffect(() => {
14 | if (
15 | pathname === "/create-podcast" ||
16 | /^\/podcasts\/edit\/.*$/i.test(pathname)
17 | ) {
18 | setAudio(undefined);
19 | }
20 | }, [pathname]);
21 |
22 | return (
23 |
24 | {children}
25 |
26 | );
27 | };
28 |
29 | export const useAudio = () => {
30 | const context = useContext(AudioContext);
31 |
32 | if (!context)
33 | throw new Error("useAudio must be used within an AudioProvider");
34 |
35 | return context;
36 | };
37 |
38 | export default AudioProvider;
39 |
--------------------------------------------------------------------------------
/lib/providers/ConvexClerkProvider.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { ClerkProvider, useAuth } from "@clerk/nextjs";
4 | import { ConvexProviderWithClerk } from "convex/react-clerk";
5 | import { ConvexReactClient } from "convex/react";
6 | import { ReactNode } from "react";
7 |
8 | const convex = new ConvexReactClient(
9 | process.env.NEXT_PUBLIC_CONVEX_URL as string
10 | );
11 |
12 | const ConvexClerkProvider = ({ children }: { children: ReactNode }) => (
13 |
53 |
54 | {children}
55 |
56 |
57 | );
58 |
59 | export default ConvexClerkProvider;
60 |
--------------------------------------------------------------------------------
/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 |
8 | export const formatTime = (seconds: number) => {
9 | const minutes = Math.floor(seconds / 60);
10 | const remainingSeconds = Math.floor(seconds % 60);
11 | return `${minutes}:${remainingSeconds < 10 ? "0" : ""}${remainingSeconds}`;
12 | };
13 |
--------------------------------------------------------------------------------
/lib/validations.ts:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mastrangelis/podcaster-ai/0e0427ba490b77e3ed63d03c55d376a100d5d4ee/lib/validations.ts
--------------------------------------------------------------------------------
/middleware.ts:
--------------------------------------------------------------------------------
1 | import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
2 |
3 | const isPublicRoute = createRouteMatcher(["/sign-in(.*)", "/sign-up(.*)", "/"]);
4 |
5 | export default clerkMiddleware(
6 | (auth, req) => {
7 | if (!isPublicRoute(req)) auth().protect();
8 | },
9 | { debug: true }
10 | );
11 |
12 | export const config = {
13 | matcher: [
14 | // Skip Next.js internals and all static files, unless found in search params
15 | "/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
16 | // Always run for API routes
17 | "/(api|trpc)(.*)",
18 | ],
19 | };
20 |
--------------------------------------------------------------------------------
/next.config.mjs:
--------------------------------------------------------------------------------
1 | import {withSentryConfig} from "@sentry/nextjs";
2 | /** @type {import('next').NextConfig} */
3 | const nextConfig = {
4 | images: {
5 | remotePatterns: [
6 | {
7 | protocol: "https",
8 | hostname: "lovely-flamingo-139.convex.cloud",
9 | port: "",
10 | pathname: "/**",
11 | },
12 | {
13 | protocol: "https",
14 | hostname: "dutiful-husky-222.convex.cloud",
15 | port: "",
16 | pathname: "/**",
17 | },
18 | {
19 | protocol: "https",
20 | hostname: "img.clerk.com",
21 | port: "",
22 | pathname: "/**",
23 | },
24 | ],
25 | },
26 | };
27 |
28 | export default withSentryConfig(nextConfig, {
29 | // For all available options, see:
30 | // https://github.com/getsentry/sentry-webpack-plugin#options
31 |
32 | org: "nikos-mastrangelis",
33 | project: "podcaster-ai",
34 |
35 | // Only print logs for uploading source maps in CI
36 | silent: !process.env.CI,
37 |
38 | // For all available options, see:
39 | // https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
40 |
41 | // Upload a larger set of source maps for prettier stack traces (increases build time)
42 | widenClientFileUpload: true,
43 |
44 | // Uncomment to route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers.
45 | // This can increase your server load as well as your hosting bill.
46 | // Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client-
47 | // side errors will fail.
48 | // tunnelRoute: "/monitoring",
49 |
50 | // Hides source maps from generated client bundles
51 | hideSourceMaps: true,
52 |
53 | // Automatically tree-shake Sentry logger statements to reduce bundle size
54 | disableLogger: true,
55 |
56 | // Enables automatic instrumentation of Vercel Cron Monitors. (Does not yet work with App Router route handlers.)
57 | // See the following for more information:
58 | // https://docs.sentry.io/product/crons/
59 | // https://vercel.com/docs/cron-jobs
60 | automaticVercelMonitors: true,
61 | });
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "podcaster-ai",
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 | "@clerk/nextjs": "^5.2.8",
13 | "@hookform/resolvers": "^3.9.0",
14 | "@radix-ui/react-alert-dialog": "^1.1.1",
15 | "@radix-ui/react-avatar": "^1.1.0",
16 | "@radix-ui/react-dialog": "^1.1.1",
17 | "@radix-ui/react-label": "^2.1.0",
18 | "@radix-ui/react-progress": "^1.1.0",
19 | "@radix-ui/react-select": "^2.1.1",
20 | "@radix-ui/react-slot": "^1.1.0",
21 | "@radix-ui/react-toast": "^1.2.1",
22 | "@radix-ui/react-tooltip": "^1.1.2",
23 | "@sentry/nextjs": "^8.22.0",
24 | "@tanstack/react-table": "^8.19.3",
25 | "@xixixao/uploadstuff": "^0.0.5",
26 | "class-variance-authority": "^0.7.0",
27 | "clsx": "^2.1.1",
28 | "convex": "^1.13.2",
29 | "embla-carousel": "^8.1.7",
30 | "embla-carousel-autoplay": "^8.1.7",
31 | "embla-carousel-react": "^8.1.7",
32 | "lucide-react": "^0.417.0",
33 | "next": "14.2.5",
34 | "openai": "^4.53.2",
35 | "react": "^18",
36 | "react-dom": "^18",
37 | "react-hook-form": "^7.51.5",
38 | "svix": "^1.25.0",
39 | "tailwind-merge": "^2.4.0",
40 | "tailwindcss-animate": "^1.0.7",
41 | "uuid": "^10.0.0",
42 | "zod": "^3.23.8"
43 | },
44 | "devDependencies": {
45 | "@types/node": "^20",
46 | "@types/react": "^18",
47 | "@types/react-dom": "^18",
48 | "@types/uuid": "^10.0.0",
49 | "eslint": "^8",
50 | "eslint-config-next": "14.2.5",
51 | "postcss": "^8",
52 | "tailwindcss": "^3.4.1",
53 | "typescript": "^5"
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('postcss-load-config').Config} */
2 | const config = {
3 | plugins: {
4 | tailwindcss: {},
5 | },
6 | };
7 |
8 | export default config;
9 |
--------------------------------------------------------------------------------
/public/alloy.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mastrangelis/podcaster-ai/0e0427ba490b77e3ed63d03c55d376a100d5d4ee/public/alloy.mp3
--------------------------------------------------------------------------------
/public/avatars/11.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mastrangelis/podcaster-ai/0e0427ba490b77e3ed63d03c55d376a100d5d4ee/public/avatars/11.png
--------------------------------------------------------------------------------
/public/avatars/22.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mastrangelis/podcaster-ai/0e0427ba490b77e3ed63d03c55d376a100d5d4ee/public/avatars/22.png
--------------------------------------------------------------------------------
/public/avatars/32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mastrangelis/podcaster-ai/0e0427ba490b77e3ed63d03c55d376a100d5d4ee/public/avatars/32.png
--------------------------------------------------------------------------------
/public/avatars/37.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mastrangelis/podcaster-ai/0e0427ba490b77e3ed63d03c55d376a100d5d4ee/public/avatars/37.png
--------------------------------------------------------------------------------
/public/avatars/4.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mastrangelis/podcaster-ai/0e0427ba490b77e3ed63d03c55d376a100d5d4ee/public/avatars/4.png
--------------------------------------------------------------------------------
/public/echo.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mastrangelis/podcaster-ai/0e0427ba490b77e3ed63d03c55d376a100d5d4ee/public/echo.mp3
--------------------------------------------------------------------------------
/public/fable.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mastrangelis/podcaster-ai/0e0427ba490b77e3ed63d03c55d376a100d5d4ee/public/fable.mp3
--------------------------------------------------------------------------------
/public/icons/Pause.svg:
--------------------------------------------------------------------------------
1 |
12 |
--------------------------------------------------------------------------------
/public/icons/Play.svg:
--------------------------------------------------------------------------------
1 |
12 |
--------------------------------------------------------------------------------
/public/icons/Restart.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/icons/account.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/icons/avatar.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/public/icons/clock.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/public/icons/delete.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/icons/discover.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/icons/edit.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/public/icons/emptyState.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/icons/forward.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/icons/hamburger.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/public/icons/headphone.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/icons/home.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/icons/logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/icons/logout.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/public/icons/microphone.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/icons/mute.svg:
--------------------------------------------------------------------------------
1 |
9 |
--------------------------------------------------------------------------------
/public/icons/play-gray.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/icons/profile.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/icons/randomPlay.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/icons/reverse.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/public/icons/right-arrow.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/icons/search.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/icons/three-dots.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/icons/unmute.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/icons/upload-image.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/public/icons/user.svg:
--------------------------------------------------------------------------------
1 |
7 |
--------------------------------------------------------------------------------
/public/icons/verified.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/public/icons/watch.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/images/bg-img.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mastrangelis/podcaster-ai/0e0427ba490b77e3ed63d03c55d376a100d5d4ee/public/images/bg-img.png
--------------------------------------------------------------------------------
/public/images/player1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mastrangelis/podcaster-ai/0e0427ba490b77e3ed63d03c55d376a100d5d4ee/public/images/player1.png
--------------------------------------------------------------------------------
/public/nova.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mastrangelis/podcaster-ai/0e0427ba490b77e3ed63d03c55d376a100d5d4ee/public/nova.mp3
--------------------------------------------------------------------------------
/public/onyx.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mastrangelis/podcaster-ai/0e0427ba490b77e3ed63d03c55d376a100d5d4ee/public/onyx.mp3
--------------------------------------------------------------------------------
/public/shimmer.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Mastrangelis/podcaster-ai/0e0427ba490b77e3ed63d03c55d376a100d5d4ee/public/shimmer.mp3
--------------------------------------------------------------------------------
/sentry.client.config.ts:
--------------------------------------------------------------------------------
1 | // This file configures the initialization of Sentry on the client.
2 | // The config you add here will be used whenever a users loads a page in their browser.
3 | // https://docs.sentry.io/platforms/javascript/guides/nextjs/
4 |
5 | import * as Sentry from "@sentry/nextjs";
6 |
7 | Sentry.init({
8 | dsn: "https://0b3f372d26a9c907a861f248c3f034d9@o4507463695663104.ingest.de.sentry.io/4507723025219664",
9 |
10 | // Adjust this value in production, or use tracesSampler for greater control
11 | tracesSampleRate: 1,
12 |
13 | // Setting this option to true will print useful information to the console while you're setting up Sentry.
14 | debug: false,
15 | replaysOnErrorSampleRate: 1.0,
16 |
17 | // This sets the sample rate to be 10%. You may want this to be 100% while
18 | // in development and sample at a lower rate in production
19 | replaysSessionSampleRate: 0.1,
20 |
21 | // You can remove this option if you're not planning to use the Sentry Session Replay feature:
22 | integrations: [
23 | Sentry.replayIntegration({
24 | // Additional Replay configuration goes in here, for example:
25 | maskAllText: true,
26 | blockAllMedia: true,
27 | }),
28 | ],
29 | });
30 |
--------------------------------------------------------------------------------
/sentry.edge.config.ts:
--------------------------------------------------------------------------------
1 | // This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on).
2 | // The config you add here will be used whenever one of the edge features is loaded.
3 | // Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally.
4 | // https://docs.sentry.io/platforms/javascript/guides/nextjs/
5 |
6 | import * as Sentry from "@sentry/nextjs";
7 |
8 | Sentry.init({
9 | dsn: "https://0b3f372d26a9c907a861f248c3f034d9@o4507463695663104.ingest.de.sentry.io/4507723025219664",
10 |
11 | // Adjust this value in production, or use tracesSampler for greater control
12 | tracesSampleRate: 1,
13 |
14 | // Setting this option to true will print useful information to the console while you're setting up Sentry.
15 | debug: false,
16 | });
17 |
--------------------------------------------------------------------------------
/sentry.server.config.ts:
--------------------------------------------------------------------------------
1 | // This file configures the initialization of Sentry on the server.
2 | // The config you add here will be used whenever the server handles a request.
3 | // https://docs.sentry.io/platforms/javascript/guides/nextjs/
4 |
5 | import * as Sentry from "@sentry/nextjs";
6 |
7 | Sentry.init({
8 | dsn: "https://0b3f372d26a9c907a861f248c3f034d9@o4507463695663104.ingest.de.sentry.io/4507723025219664",
9 |
10 | // Adjust this value in production, or use tracesSampler for greater control
11 | tracesSampleRate: 1,
12 |
13 | // Setting this option to true will print useful information to the console while you're setting up Sentry.
14 | debug: false,
15 |
16 | // Uncomment the line below to enable Spotlight (https://spotlightjs.com)
17 | // spotlight: process.env.NODE_ENV === 'development',
18 |
19 | });
20 |
--------------------------------------------------------------------------------
/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 | white: {
23 | 1: "#FFFFFF",
24 | 2: "rgba(255, 255, 255, 0.72)",
25 | 3: "rgba(255, 255, 255, 0.4)",
26 | 4: "rgba(255, 255, 255, 0.64)",
27 | 5: "rgba(255, 255, 255, 0.80)",
28 | },
29 | black: {
30 | 1: "#15171C",
31 | 2: "#222429",
32 | 3: "#101114",
33 | 4: "#252525",
34 | 5: "#2E3036",
35 | 6: "#24272C",
36 | },
37 | orange: {
38 | 1: "#F97535",
39 | },
40 | gray: {
41 | 1: "#71788B",
42 | },
43 | },
44 | backgroundImage: {
45 | "nav-focus":
46 | "linear-gradient(270deg, rgba(255, 255, 255, 0.06) 0%, rgba(255, 255, 255, 0.00) 100%)",
47 | "nav-hover":
48 | "linear-gradient(270deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.00) 100%)",
49 | },
50 | keyframes: {
51 | "accordion-down": {
52 | from: { height: "0" },
53 | to: { height: "var(--radix-accordion-content-height)" },
54 | },
55 | "accordion-up": {
56 | from: { height: "var(--radix-accordion-content-height)" },
57 | to: { height: "0" },
58 | },
59 | },
60 | animation: {
61 | "accordion-down": "accordion-down 0.2s ease-out",
62 | "accordion-up": "accordion-up 0.2s ease-out",
63 | },
64 | },
65 | },
66 | plugins: [require("tailwindcss-animate")],
67 | } satisfies Config;
68 |
69 | export default config;
70 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/types/index.d.ts:
--------------------------------------------------------------------------------
1 | import { Dispatch, SetStateAction } from "react";
2 |
3 | import { Id } from "@/convex/_generated/dataModel";
4 | import { UseFormSetValue } from "react-hook-form";
5 |
6 | declare type SearchParamProps = {
7 | params: { [key: string]: string | Id };
8 | searchParams: { [key: string]: string | string[] | undefined };
9 | };
10 |
11 | export interface EmptyStateProps {
12 | title: string;
13 | search?: boolean;
14 | buttonText?: string;
15 | buttonLink?: string;
16 | }
17 |
18 | export interface TopPodcastersProps {
19 | _id: Id<"users">;
20 | _creationTime: number;
21 | email: string;
22 | imageUrl: string;
23 | clerkId: string;
24 | name: string;
25 | podcast: {
26 | podcastTitle: string;
27 | podcastId: Id<"podcasts">;
28 | }[];
29 | totalPodcasts: number;
30 | }
31 |
32 | export interface PodcastProps {
33 | _id: Id<"podcasts">;
34 | _creationTime: number;
35 | audioStorageId?: Id<"_storage">;
36 | user: Id<"users">;
37 | podcastTitle: string;
38 | podcastDescription: string;
39 | audioUrl?: string;
40 | imageUrl?: string;
41 | imageStorageId?: Id<"_storage">;
42 | author: string;
43 | authorId: string;
44 | authorImageUrl: string;
45 | voicePrompt: string;
46 | imagePrompt?: string;
47 | voiceType: string;
48 | audioDuration: number;
49 | viewedBy: Id<"users">[];
50 | }
51 |
52 | export interface ProfilePodcastProps {
53 | podcasts: PodcastProps[];
54 | listeners: number;
55 | }
56 |
57 | export interface GeneratePodcastProps {
58 | voiceType: string;
59 | voicePrompt: string;
60 | setAudio: (val: { url: string; storageId: string }) => void;
61 | }
62 |
63 | export interface GenerateThumbnailProps {
64 | imagePrompt: string;
65 | setThumbnail: (val: { url: string; storageId: string }) => void;
66 | }
67 |
68 | export interface LatestPodcastCardProps {
69 | imgUrl: string;
70 | title: string;
71 | duration: string;
72 | index: number;
73 | audioUrl: string;
74 | author: string;
75 | viewedBy: Id<"users">[];
76 | podcastId: Id<"podcasts">;
77 | }
78 |
79 | export interface PodcastDetailPlayerProps {
80 | audioUrl: string;
81 | podcastTitle: string;
82 | author: string;
83 | isOwner: boolean;
84 | imageUrl: string;
85 | podcastId: Id<"podcasts">;
86 | imageStorageId: Id<"_storage">;
87 | audioStorageId: Id<"_storage">;
88 | authorImageUrl: string;
89 | authorId: string;
90 | }
91 |
92 | export interface AudioProps {
93 | title?: string;
94 | audioUrl?: string;
95 | author?: string;
96 | imageUrl?: string;
97 | podcastId?: string;
98 | isPlaying?: boolean;
99 | canRestart?: boolean;
100 | }
101 |
102 | export interface AudioContextType {
103 | audio: AudioProps | undefined;
104 | setAudio: React.Dispatch>;
105 | }
106 |
107 | export interface PodcastCardProps {
108 | imgUrl: string;
109 | title: string;
110 | description: string;
111 | isOwner?: boolean;
112 | podcastId: Id<"podcasts">;
113 | }
114 |
115 | export interface CarouselProps {
116 | fansLikeDetail: TopPodcastersProps[];
117 | }
118 |
119 | export interface ProfileCardProps {
120 | podcastData: ProfilePodcastProps;
121 | imageUrl: string;
122 | userName: string;
123 | isOwner?: boolean;
124 | clerkId?: string;
125 | }
126 |
127 | export type UseDotButtonType = {
128 | selectedIndex: number;
129 | scrollSnaps: number[];
130 | onDotButtonClick: (index: number) => void;
131 | };
132 |
--------------------------------------------------------------------------------