├── public
├── echo.mp3
├── nova.mp3
├── onyx.mp3
├── alloy.mp3
├── fable.mp3
├── shimmer.mp3
├── images
│ ├── bg-img.png
│ └── player1.png
└── icons
│ ├── profile.svg
│ ├── search.svg
│ ├── home.svg
│ ├── hamburger.svg
│ ├── forward.svg
│ ├── reverse.svg
│ ├── watch.svg
│ ├── randomPlay.svg
│ ├── discover.svg
│ ├── play-gray.svg
│ ├── upload-image.svg
│ ├── Pause.svg
│ ├── Play.svg
│ ├── headphone.svg
│ ├── right-arrow.svg
│ ├── microphone.svg
│ ├── mute.svg
│ ├── verified.svg
│ ├── three-dots.svg
│ ├── unmute.svg
│ ├── edit.svg
│ ├── delete.svg
│ ├── emptyState.svg
│ ├── avatar.svg
│ ├── logo.svg
│ └── auth-logo.svg
├── postcss.config.mjs
├── lib
├── utils.ts
├── formatTime.ts
└── useDebounce.ts
├── convex
├── auth.config.ts
├── files.ts
├── _generated
│ ├── api.js
│ ├── api.d.ts
│ ├── dataModel.d.ts
│ ├── server.js
│ └── server.d.ts
├── tsconfig.json
├── schema.ts
├── openai.ts
├── http.ts
├── README.md
├── users.ts
└── podcasts.ts
├── app
├── (auth)
│ ├── sign-in
│ │ └── [[...sign-in]]
│ │ │ └── page.tsx
│ ├── sign-up
│ │ └── [[...sign-up]]
│ │ │ └── page.tsx
│ └── layout.tsx
├── layout.tsx
├── (root)
│ ├── page.tsx
│ ├── layout.tsx
│ ├── discover
│ │ └── page.tsx
│ ├── profile
│ │ └── [profileId]
│ │ │ └── page.tsx
│ ├── podcast
│ │ └── [podcastId]
│ │ │ └── page.tsx
│ └── create-podcast
│ │ └── page.tsx
└── globals.css
├── components
├── Loader.tsx
├── Header.tsx
├── ui
│ ├── label.tsx
│ ├── progress.tsx
│ ├── toaster.tsx
│ ├── textarea.tsx
│ ├── input.tsx
│ ├── button.tsx
│ ├── use-toast.ts
│ ├── sheet.tsx
│ ├── form.tsx
│ ├── toast.tsx
│ └── select.tsx
├── Searchbar.tsx
├── PodcastCard.tsx
├── EmptyState.tsx
├── DotButton.tsx
├── MobileNav.tsx
├── Carousel.tsx
├── RightSidebar.tsx
├── LeftSidebar.tsx
├── ProfileCard.tsx
├── GeneratePodcast.tsx
├── PodcastDetailPlayer.tsx
├── PodcastPlayer.tsx
└── GenerateThumbnail.tsx
├── components.json
├── middleware.ts
├── next.config.mjs
├── .gitignore
├── .eslintrc.json
├── tsconfig.json
├── .vscode
└── settings.json
├── providers
├── AudioProvider.tsx
└── ConvexClerkProvider.tsx
├── README.md
├── package.json
├── tailwind.config.ts
├── constants
└── index.ts
└── types
└── index.ts
/public/echo.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JavaScript-Mastery-Pro/podcastr/HEAD/public/echo.mp3
--------------------------------------------------------------------------------
/public/nova.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JavaScript-Mastery-Pro/podcastr/HEAD/public/nova.mp3
--------------------------------------------------------------------------------
/public/onyx.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JavaScript-Mastery-Pro/podcastr/HEAD/public/onyx.mp3
--------------------------------------------------------------------------------
/public/alloy.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JavaScript-Mastery-Pro/podcastr/HEAD/public/alloy.mp3
--------------------------------------------------------------------------------
/public/fable.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JavaScript-Mastery-Pro/podcastr/HEAD/public/fable.mp3
--------------------------------------------------------------------------------
/public/shimmer.mp3:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JavaScript-Mastery-Pro/podcastr/HEAD/public/shimmer.mp3
--------------------------------------------------------------------------------
/public/images/bg-img.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JavaScript-Mastery-Pro/podcastr/HEAD/public/images/bg-img.png
--------------------------------------------------------------------------------
/public/images/player1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/JavaScript-Mastery-Pro/podcastr/HEAD/public/images/player1.png
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/convex/auth.config.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/no-anonymous-default-export */
2 | export default {
3 | providers: [
4 | {
5 | domain: "https://quality-hound-80.clerk.accounts.dev",
6 | applicationID: "convex",
7 | },
8 | ],
9 | };
10 |
--------------------------------------------------------------------------------
/lib/formatTime.ts:
--------------------------------------------------------------------------------
1 | export const formatTime = (seconds: number) => {
2 | const minutes = Math.floor(seconds / 60);
3 | const remainingSeconds = Math.floor(seconds % 60);
4 | return `${minutes}:${remainingSeconds < 10 ? "0" : ""}${remainingSeconds}`;
5 | };
6 |
--------------------------------------------------------------------------------
/app/(auth)/sign-in/[[...sign-in]]/page.tsx:
--------------------------------------------------------------------------------
1 | import { SignIn } from "@clerk/nextjs";
2 |
3 | export default function Page() {
4 | return (
5 |
6 |
7 |
8 | );
9 | }
10 |
--------------------------------------------------------------------------------
/app/(auth)/sign-up/[[...sign-up]]/page.tsx:
--------------------------------------------------------------------------------
1 | import { SignUp } from "@clerk/nextjs";
2 |
3 | export default function Page() {
4 | return (
5 |
6 |
7 |
8 | );
9 | }
10 |
--------------------------------------------------------------------------------
/components/Loader.tsx:
--------------------------------------------------------------------------------
1 | import { Loader } from "lucide-react";
2 |
3 | const LoaderSpinner = () => {
4 | return (
5 |
6 |
7 |
8 | );
9 | };
10 |
11 | export default LoaderSpinner;
12 |
--------------------------------------------------------------------------------
/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": false,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils"
16 | }
17 | }
--------------------------------------------------------------------------------
/convex/files.ts:
--------------------------------------------------------------------------------
1 | // mutation to generate a signed url for uploading a file to the storage service
2 | // this mutation should should be called inside useUploadFiles hook from uploadStuff in frontend
3 | import { mutation } from "./_generated/server";
4 |
5 | export const generateUploadUrl = mutation({
6 | args: {},
7 | handler: async (ctx, args) => {
8 | return await ctx.storage.generateUploadUrl();
9 | },
10 | });
11 |
--------------------------------------------------------------------------------
/lib/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 |
--------------------------------------------------------------------------------
/public/icons/profile.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/middleware.ts:
--------------------------------------------------------------------------------
1 | import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
2 |
3 | const isProtectedRoute = createRouteMatcher([
4 | "/",
5 | "/discover",
6 | "/podcast(.*)",
7 | "/sign-in(.*)",
8 | "/sign-up(.*)",
9 | ]);
10 |
11 | export default clerkMiddleware((auth, req) => {
12 | if (!isProtectedRoute(req)) auth().protect();
13 | });
14 |
15 | export const config = {
16 | matcher: ["/((?!.*\\..*|_next).*)", "/", "/(api|trpc)(.*)"],
17 | };
18 |
--------------------------------------------------------------------------------
/next.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | images: {
4 | remotePatterns: [
5 | {
6 | protocol: "https",
7 | hostname: "img.clerk.com",
8 | },
9 | {
10 | protocol: "https",
11 | hostname: "oaidalleapiprodscus.blob.core.windows.net",
12 | },
13 | {
14 | protocol: "https",
15 | hostname: "lovely-flamingo-139.convex.cloud",
16 | },
17 | ],
18 | },
19 | };
20 |
21 | export default nextConfig;
22 |
--------------------------------------------------------------------------------
/public/icons/search.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/icons/home.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/(auth)/layout.tsx:
--------------------------------------------------------------------------------
1 | import Image from "next/image";
2 | import { ReactNode } from "react";
3 |
4 | const AuthLayout = ({ children }: { children: ReactNode }) => {
5 | return (
6 |
7 |
8 |
14 |
15 | {children}
16 |
17 | );
18 | };
19 |
20 | export default AuthLayout;
21 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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.11.1.
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 |
--------------------------------------------------------------------------------
/public/icons/hamburger.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/public/icons/forward.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/icons/reverse.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/public/icons/watch.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/icons/randomPlay.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "plugins": ["import"],
3 | "extends": [
4 | "next/core-web-vitals",
5 | "standard",
6 | "plugin:tailwindcss/recommended",
7 | "prettier"
8 | ],
9 | "rules": {
10 | "no-undef": "off",
11 | "import/order": [
12 | "error",
13 | {
14 | "groups": [
15 | "builtin",
16 | "external",
17 | "internal",
18 | "parent",
19 | "sibling",
20 | "index"
21 | ],
22 | "newlines-between": "always",
23 | "alphabetize": {
24 | "order": "asc",
25 | "caseInsensitive": true
26 | }
27 | }
28 | ]
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/public/icons/discover.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/public/icons/play-gray.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/icons/upload-image.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.defaultFormatter": "esbenp.prettier-vscode",
3 | "editor.formatOnSave": true,
4 | "editor.codeActionsOnSave": {
5 | "source.fixAll.eslint": "explicit",
6 | "source.addMissingImports": "explicit"
7 | },
8 | "prettier.tabWidth": 2,
9 | "prettier.useTabs": false,
10 | "prettier.semi": true,
11 | "prettier.singleQuote": false,
12 | "prettier.jsxSingleQuote": false,
13 | "prettier.trailingComma": "es5",
14 | "prettier.arrowParens": "always",
15 | "[typescriptreact]": {
16 | "editor.defaultFormatter": "esbenp.prettier-vscode"
17 | },
18 | "files.associations": {
19 | "*.css": "tailwindcss"
20 | },
21 | "editor.quickSuggestions": {
22 | "strings": "on"
23 | }
24 | }
--------------------------------------------------------------------------------
/components/Header.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 |
3 | import { cn } from "@/lib/utils";
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 |
25 | See all
26 |
27 |
28 | );
29 | };
30 |
31 | export default Header;
32 |
--------------------------------------------------------------------------------
/public/icons/Pause.svg:
--------------------------------------------------------------------------------
1 |
12 |
--------------------------------------------------------------------------------
/convex/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | /* This TypeScript project config describes the environment that
3 | * Convex functions run in and is used to typecheck them.
4 | * You can modify it, but some settings required to use Convex.
5 | */
6 | "compilerOptions": {
7 | /* These settings are not required by Convex and can be modified. */
8 | "allowJs": true,
9 | "strict": true,
10 |
11 | /* These compiler options are required by Convex */
12 | "target": "ESNext",
13 | "lib": ["ES2021", "dom"],
14 | "forceConsistentCasingInFileNames": true,
15 | "allowSyntheticDefaultImports": true,
16 | "module": "ESNext",
17 | "moduleResolution": "Node",
18 | "isolatedModules": true,
19 | "skipLibCheck": true,
20 | "noEmit": true
21 | },
22 | "include": ["./**/*"],
23 | "exclude": ["./_generated"]
24 | }
25 |
--------------------------------------------------------------------------------
/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as LabelPrimitive from "@radix-ui/react-label";
4 | import { cva, type VariantProps } from "class-variance-authority";
5 | import * as React from "react";
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 |
--------------------------------------------------------------------------------
/public/icons/Play.svg:
--------------------------------------------------------------------------------
1 |
12 |
--------------------------------------------------------------------------------
/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 | import { Manrope } from "next/font/google";
3 |
4 | import "./globals.css";
5 | import AudioProvider from "@/providers/AudioProvider";
6 | import ConvexClerkProvider from "@/providers/ConvexClerkProvider";
7 |
8 | const manrope = Manrope({ subsets: ["latin"] });
9 |
10 | export const metadata: Metadata = {
11 | title: "Podcastr",
12 | description: "Generate your podcast using AI",
13 | icons: {
14 | icon: "/icons/logo.svg",
15 | },
16 | };
17 |
18 | export default function RootLayout({
19 | children,
20 | }: Readonly<{
21 | children: React.ReactNode;
22 | }>) {
23 | return (
24 |
25 |
26 |
27 | {children}
28 |
29 |
30 |
31 | );
32 | }
33 |
--------------------------------------------------------------------------------
/public/icons/headphone.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/components/ui/progress.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as ProgressPrimitive from "@radix-ui/react-progress";
4 | import * as React from "react";
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/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/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 |
--------------------------------------------------------------------------------
/public/icons/right-arrow.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/providers/AudioProvider.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { usePathname } from "next/navigation";
4 | import React, { createContext, useState, useContext, useEffect } from "react";
5 |
6 | import { AudioContextType, AudioProps } from "@/types";
7 |
8 | const AudioContext = createContext(undefined);
9 |
10 | const AudioProvider = ({ children }: { children: React.ReactNode }) => {
11 | const [audio, setAudio] = useState();
12 | const pathname = usePathname();
13 |
14 | useEffect(() => {
15 | if (pathname === "/create-podcast") setAudio(undefined);
16 | }, [pathname]);
17 |
18 | return (
19 |
20 | {children}
21 |
22 | );
23 | };
24 |
25 | export const useAudio = () => {
26 | const context = useContext(AudioContext);
27 |
28 | if (context === undefined) {
29 | throw new Error("useAudio must be used within a AudioProvider");
30 | }
31 |
32 | return context;
33 | };
34 |
35 | export default AudioProvider;
36 |
--------------------------------------------------------------------------------
/public/icons/microphone.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/providers/ConvexClerkProvider.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { ClerkProvider, useAuth } from "@clerk/nextjs";
4 | import { ConvexReactClient } from "convex/react";
5 | import { ConvexProviderWithClerk } from "convex/react-clerk";
6 | import { ReactNode } from "react";
7 |
8 | const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
9 |
10 | const ConvexClerkProvider = ({ children }: { children: ReactNode }) => {
11 | return (
12 |
28 |
29 | {children}
30 |
31 |
32 | );
33 | };
34 |
35 | export default ConvexClerkProvider;
36 |
--------------------------------------------------------------------------------
/public/icons/mute.svg:
--------------------------------------------------------------------------------
1 |
9 |
--------------------------------------------------------------------------------
/convex/schema.ts:
--------------------------------------------------------------------------------
1 | import { defineSchema, defineTable } from "convex/server";
2 | import { v } from "convex/values";
3 |
4 | export default defineSchema({
5 | users: defineTable({
6 | email: v.string(),
7 | imageUrl: v.string(),
8 | clerkId: v.string(),
9 | name: v.string(),
10 | }),
11 | podcasts: defineTable({
12 | audioStorageId: v.union(v.id("_storage"), v.null()),
13 | user: v.id("users"),
14 | podcastTitle: v.string(),
15 | podcastDescription: v.string(),
16 | audioUrl: v.union(v.string(), v.null()),
17 | imageUrl: v.union(v.string(), v.null()),
18 | imageStorageId: v.union(v.id("_storage"), v.null()),
19 | author: v.string(),
20 | authorId: v.string(),
21 | authorImageUrl: v.string(),
22 | voicePrompt: v.string(),
23 | imagePrompt: v.union(v.string(), v.null()),
24 | voiceType: v.string(),
25 | audioDuration: v.number(),
26 | views: v.number(),
27 | })
28 | .searchIndex("search_author", {
29 | searchField: "author",
30 | })
31 | .searchIndex("search_title", {
32 | searchField: "podcastTitle",
33 | })
34 | .searchIndex("search_body", {
35 | searchField: "podcastDescription",
36 | }),
37 | });
38 |
--------------------------------------------------------------------------------
/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.11.1.
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 |
--------------------------------------------------------------------------------
/app/(root)/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useQuery } from "convex/react";
4 |
5 | import Header from "@/components/Header";
6 | import LoaderSpinner from "@/components/Loader";
7 | import PodcastCard from "@/components/PodcastCard";
8 | import { api } from "@/convex/_generated/api";
9 |
10 | const Home = () => {
11 | const trendingPodcasts = useQuery(api.podcasts.getTrendingPodcasts);
12 | const latestPodcasts = useQuery(api.podcasts.getAllPodcasts);
13 |
14 | if (!trendingPodcasts || !latestPodcasts) return ;
15 |
16 | return (
17 |
18 |
19 | Trending Podcasts
20 |
21 | {trendingPodcasts?.map((podcast) => (
22 |
29 | ))}
30 |
31 |
32 |
33 |
34 | );
35 | };
36 | export default Home;
37 |
--------------------------------------------------------------------------------
/public/icons/verified.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/components/Searchbar.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import Image from "next/image";
3 | import { usePathname, useRouter } from "next/navigation";
4 | import React, { useEffect, useState } from "react";
5 |
6 | import { useDebounce } from "@/lib/useDebounce";
7 |
8 | import { Input } from "./ui/input";
9 |
10 | const Searchbar = () => {
11 | const router = useRouter();
12 | const [search, setSearch] = useState("");
13 | const pathname = usePathname();
14 |
15 | const debouncedValue = useDebounce(search, 500);
16 |
17 | useEffect(() => {
18 | if (debouncedValue) {
19 | router.push(`/discover?search=${debouncedValue}`);
20 | } else if (!debouncedValue && pathname === "/discover") {
21 | router.push(`/discover`);
22 | }
23 | }, [debouncedValue, router, pathname]);
24 |
25 | return (
26 |
27 | setSearch(e.target.value)}
32 | onLoad={() => setSearch("")}
33 | />
34 |
41 |
42 | );
43 | };
44 |
45 | export default Searchbar;
46 |
--------------------------------------------------------------------------------
/components/PodcastCard.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { useMutation } from "convex/react";
3 | import Image from "next/image";
4 | import { useRouter } from "next/navigation";
5 |
6 | import { api } from "@/convex/_generated/api";
7 | import { PodcastCardProps } from "@/types";
8 |
9 | const PodcastCard = ({
10 | imgUrl,
11 | title,
12 | description,
13 | podcastId,
14 | }: PodcastCardProps) => {
15 | const router = useRouter();
16 | const updateViews = useMutation(api.podcasts.updatePodcastViews);
17 |
18 | const handleViews = async () => {
19 | await updateViews({ podcastId });
20 | router.push(`/podcast/${podcastId}`, {
21 | scroll: true,
22 | });
23 | };
24 | return (
25 |
26 |
27 |
34 |
35 |
{title}
36 |
37 | {description}
38 |
39 |
40 |
41 |
42 | );
43 | };
44 |
45 | export default PodcastCard;
46 |
--------------------------------------------------------------------------------
/public/icons/three-dots.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/(root)/layout.tsx:
--------------------------------------------------------------------------------
1 | import Image from "next/image";
2 |
3 | import LeftSidebar from "@/components/LeftSidebar";
4 | import MobileNav from "@/components/MobileNav";
5 | import PodcastPlayer from "@/components/PodcastPlayer";
6 | import RightSidebar from "@/components/RightSidebar";
7 | import { Toaster } from "@/components/ui/toaster";
8 |
9 | export default function RootLayout({
10 | children,
11 | }: Readonly<{
12 | children: React.ReactNode;
13 | }>) {
14 | return (
15 |
16 |
17 |
18 |
19 |
20 |
31 |
32 |
33 | {children}
34 |
35 |
36 |
37 |
38 | {/* */}
39 |
40 |
41 |
42 | );
43 | }
44 |
--------------------------------------------------------------------------------
/public/icons/unmute.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/convex/openai.ts:
--------------------------------------------------------------------------------
1 | import { v } from "convex/values";
2 | import OpenAI from "openai";
3 |
4 | import { action } from "./_generated/server";
5 |
6 | const openai = new OpenAI({
7 | apiKey: process.env.OPENAI_API_KEY,
8 | });
9 |
10 | export const generateThumbnailAction = action({
11 | args: {
12 | prompt: v.string(),
13 | },
14 | handler: async (ctx, args) => {
15 | const response = await openai.images.generate({
16 | model: "dall-e-3",
17 | prompt: args.prompt,
18 | size: "1024x1024",
19 | quality: "standard",
20 | n: 1,
21 | });
22 |
23 | const url = response.data[0].url;
24 | if (!url) {
25 | throw new Error("Failed to generate image from prompt");
26 | }
27 |
28 | const imageResponse = await fetch(url);
29 | const buffer = await imageResponse.arrayBuffer();
30 | return buffer;
31 | },
32 | });
33 |
34 | export const generateAudioAction = action({
35 | args: {
36 | input: v.string(),
37 | voice: v.union(
38 | v.literal("alloy"),
39 | v.literal("echo"),
40 | v.literal("fable"),
41 | v.literal("onyx"),
42 | v.literal("nova"),
43 | v.literal("shimmer")
44 | ),
45 | },
46 | handler: async (ctx, args) => {
47 | const response = await openai.audio.speech.create({
48 | model: "tts-1",
49 | voice: args.voice,
50 | input: args.input,
51 | });
52 |
53 | const buffer = await response.arrayBuffer();
54 |
55 | return buffer;
56 | },
57 | });
58 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app)
2 |
3 | ## Getting Started
4 |
5 | First, run the development server:
6 |
7 | ```bash
8 | npm run dev
9 | # or
10 | yarn dev
11 | # or
12 | pnpm dev
13 | # or
14 | bun dev
15 | ```
16 |
17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
18 |
19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
20 |
21 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
22 |
23 | ## Learn More
24 |
25 | To learn more about Next.js, take a look at the following resources:
26 |
27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
29 |
30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
31 |
32 | ## Deploy on Vercel
33 |
34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
35 |
36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
37 |
--------------------------------------------------------------------------------
/public/icons/edit.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/public/icons/delete.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/components/EmptyState.tsx:
--------------------------------------------------------------------------------
1 | import Image from "next/image";
2 | import Link from "next/link";
3 |
4 | import { EmptyStateProps } from "@/types";
5 |
6 | import { Button } from "./ui/button";
7 |
8 | const EmptyState = ({
9 | title,
10 | search,
11 | buttonLink,
12 | buttonText,
13 | }: EmptyStateProps) => {
14 | return (
15 |
16 |
22 |
23 |
{title}
24 | {search && (
25 |
26 | Try adjusting your search to find what you are looking for
27 |
28 | )}
29 | {buttonLink && (
30 |
45 | )}
46 |
47 |
48 | );
49 | };
50 |
51 | export default EmptyState;
52 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "podcastr",
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.1.3",
13 | "@hookform/resolvers": "^3.3.4",
14 | "@radix-ui/react-dialog": "^1.0.5",
15 | "@radix-ui/react-dropdown-menu": "^2.0.6",
16 | "@radix-ui/react-label": "^2.0.2",
17 | "@radix-ui/react-progress": "^1.0.3",
18 | "@radix-ui/react-select": "^2.0.0",
19 | "@radix-ui/react-slider": "^1.1.2",
20 | "@radix-ui/react-slot": "^1.0.2",
21 | "@radix-ui/react-toast": "^1.1.5",
22 | "@xixixao/uploadstuff": "^0.0.5",
23 | "class-variance-authority": "^0.7.0",
24 | "clsx": "^2.1.0",
25 | "convex": "^1.11.1",
26 | "embla-carousel": "^8.0.2",
27 | "embla-carousel-autoplay": "^8.0.2",
28 | "embla-carousel-react": "^8.0.2",
29 | "lucide-react": "^0.368.0",
30 | "next": "14.2.1",
31 | "openai": "^4.38.5",
32 | "react": "^18",
33 | "react-dom": "^18",
34 | "react-hook-form": "^7.51.3",
35 | "svix": "^1.21.0",
36 | "tailwind-merge": "^2.2.2",
37 | "tailwindcss-animate": "^1.0.7",
38 | "uuid": "^9.0.1",
39 | "zod": "^3.22.5"
40 | },
41 | "devDependencies": {
42 | "@types/node": "^20",
43 | "@types/react": "^18",
44 | "@types/react-dom": "^18",
45 | "@types/uuid": "^9.0.8",
46 | "eslint": "^8",
47 | "eslint-config-next": "14.2.1",
48 | "eslint-config-prettier": "^9.1.0",
49 | "eslint-config-standard": "^17.1.0",
50 | "eslint-plugin-import": "^2.29.1",
51 | "eslint-plugin-tailwindcss": "^3.15.1",
52 | "postcss": "^8",
53 | "prettier": "^3.2.5",
54 | "tailwindcss": "^3.4.1",
55 | "typescript": "^5"
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/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 | },
48 | keyframes: {
49 | "accordion-down": {
50 | from: { height: "0" },
51 | to: { height: "var(--radix-accordion-content-height)" },
52 | },
53 | "accordion-up": {
54 | from: { height: "var(--radix-accordion-content-height)" },
55 | to: { height: "0" },
56 | },
57 | },
58 | animation: {
59 | "accordion-down": "accordion-down 0.2s ease-out",
60 | "accordion-up": "accordion-up 0.2s ease-out",
61 | },
62 | },
63 | },
64 | plugins: [require("tailwindcss-animate")],
65 | } satisfies Config;
66 |
67 | export default config;
68 |
--------------------------------------------------------------------------------
/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.11.1.
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 |
--------------------------------------------------------------------------------
/app/(root)/discover/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useQuery } from "convex/react";
4 |
5 | import EmptyState from "@/components/EmptyState";
6 | import LoaderSpinner from "@/components/Loader";
7 | import PodcastCard from "@/components/PodcastCard";
8 | import Searchbar from "@/components/Searchbar";
9 | import { api } from "@/convex/_generated/api";
10 | import { cn } from "@/lib/utils";
11 |
12 | const DiscoverPage = ({
13 | searchParams,
14 | }: {
15 | searchParams: {
16 | search: string;
17 | };
18 | }) => {
19 | const search = searchParams.search || "";
20 | const podcastsData = useQuery(api.podcasts.getPodcastBySearch, {
21 | search,
22 | });
23 |
24 | return (
25 |
26 |
27 |
28 |
33 |
34 | {!search ? "Discover Trending Podcasts" : `Search results for: `}
35 | {search && {search}}
36 |
37 |
38 | {podcastsData ? (
39 | <>
40 | {podcastsData.length > 0 ? (
41 |
42 | {podcastsData?.map((podcast) => (
43 |
50 | ))}
51 |
52 | ) : (
53 |
54 | )}
55 | >
56 | ) : (
57 |
58 | )}
59 |
60 |
61 | );
62 | };
63 |
64 | export default DiscoverPage;
65 |
--------------------------------------------------------------------------------
/components/DotButton.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { EmblaCarouselType } from "embla-carousel";
3 | import React, { useCallback, useEffect, useState } from "react";
4 |
5 | import { cn } from "@/lib/utils";
6 | import { UseDotButtonType } from "@/types";
7 |
8 | export const useDotButton = (
9 | emblaApi: EmblaCarouselType | undefined,
10 | onButtonClick?: (emblaApi: EmblaCarouselType) => void
11 | ): UseDotButtonType => {
12 | const [selectedIndex, setSelectedIndex] = useState(0);
13 | const [scrollSnaps, setScrollSnaps] = useState([]);
14 |
15 | const onDotButtonClick = useCallback(
16 | (index: number) => {
17 | if (!emblaApi) return;
18 | emblaApi.scrollTo(index);
19 | if (onButtonClick) onButtonClick(emblaApi);
20 | },
21 | [emblaApi, onButtonClick]
22 | );
23 |
24 | const onInit = useCallback((emblaApi: EmblaCarouselType) => {
25 | setScrollSnaps(emblaApi.scrollSnapList());
26 | }, []);
27 |
28 | const onSelect = useCallback((emblaApi: EmblaCarouselType) => {
29 | setSelectedIndex(emblaApi.selectedScrollSnap());
30 | }, []);
31 |
32 | useEffect(() => {
33 | if (!emblaApi) return;
34 |
35 | onInit(emblaApi);
36 | onSelect(emblaApi);
37 | emblaApi.on("reInit", onInit);
38 | emblaApi.on("reInit", onSelect);
39 | emblaApi.on("select", onSelect);
40 | }, [emblaApi, onInit, onSelect]);
41 |
42 | return {
43 | selectedIndex,
44 | scrollSnaps,
45 | onDotButtonClick,
46 | };
47 | };
48 |
49 | type DotButtonProps = {
50 | selected: boolean;
51 | onClick: () => void;
52 | };
53 |
54 | export const DotButton: React.FC = ({ selected, onClick }) => {
55 | return (
56 | // eveything is similar to EmblaCarouselDotButton.tsx just modify the button as per your design.
57 |
67 | );
68 | };
69 |
--------------------------------------------------------------------------------
/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/Loader";
7 | import PodcastCard from "@/components/PodcastCard";
8 | import ProfileCard from "@/components/ProfileCard";
9 | import { api } from "@/convex/_generated/api";
10 |
11 | const ProfilePage = ({
12 | params,
13 | }: {
14 | params: {
15 | profileId: string;
16 | };
17 | }) => {
18 | const user = useQuery(api.users.getUserById, {
19 | clerkId: params.profileId,
20 | });
21 | const podcastsData = useQuery(api.podcasts.getPodcastByAuthorId, {
22 | authorId: params.profileId,
23 | });
24 |
25 | if (!user || !podcastsData) return ;
26 |
27 | return (
28 |
29 |
30 | Podcaster Profile
31 |
32 |
39 |
40 | All Podcasts
41 | {podcastsData && podcastsData.podcasts.length > 0 ? (
42 |
43 | {podcastsData?.podcasts
44 | ?.slice(0, 4)
45 | .map((podcast) => (
46 |
53 | ))}
54 |
55 | ) : (
56 |
60 | )}
61 |
62 |
63 | );
64 | };
65 |
66 | export default ProfilePage;
67 |
--------------------------------------------------------------------------------
/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import { Slot } from "@radix-ui/react-slot";
2 | import { cva, type VariantProps } from "class-variance-authority";
3 | import * as React from "react";
4 |
5 | import { cn } from "@/lib/utils";
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-white-1 transition-colors duration-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-950 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 dark:ring-offset-slate-950 dark:focus-visible:ring-slate-300",
9 | {
10 | variants: {
11 | variant: {
12 | default:
13 | "bg-slate-900 text-slate-50 hover:bg-black-6 dark:bg-black-6 dark:text-slate-900 dark:hover:bg-slate-50/90",
14 | destructive:
15 | "bg-red-500 text-slate-50 hover:bg-red-500/90 dark:bg-red-900 dark:text-slate-50 dark:hover:bg-red-900/90",
16 | outline:
17 | "border border-slate-200 bg-white-1 hover:bg-slate-100 hover:text-slate-900 dark:border-slate-800 dark:bg-slate-950 dark:hover:bg-slate-800 dark:hover:text-slate-50",
18 | secondary:
19 | "bg-slate-100 text-slate-900 hover:bg-slate-100/80 dark:bg-slate-800 dark:text-slate-50 dark:hover:bg-slate-800/80",
20 | ghost:
21 | "hover:bg-slate-100 hover:text-slate-900 dark:hover:bg-slate-800 dark:hover:text-slate-50",
22 | link: "text-slate-900 underline-offset-4 hover:underline dark:text-slate-50",
23 | plain:
24 | "border-none bg-transparent text-[16px] font-bold leading-normal text-white-1",
25 | },
26 | size: {
27 | default: "h-10 px-4 py-2",
28 | sm: "h-9 rounded-md px-3",
29 | lg: "h-11 rounded-md px-8",
30 | icon: "size-10",
31 | },
32 | },
33 | defaultVariants: {
34 | variant: "default",
35 | size: "default",
36 | },
37 | }
38 | );
39 |
40 | export interface ButtonProps
41 | extends React.ButtonHTMLAttributes,
42 | VariantProps {
43 | asChild?: boolean;
44 | }
45 |
46 | const Button = React.forwardRef(
47 | ({ className, variant, size, asChild = false, ...props }, ref) => {
48 | const Comp = asChild ? Slot : "button";
49 | return (
50 |
55 | );
56 | }
57 | );
58 | Button.displayName = "Button";
59 |
60 | export { Button, buttonVariants };
61 |
--------------------------------------------------------------------------------
/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 | const handleClerkWebhook = httpAction(async (ctx, request) => {
13 | const event = await validateRequest(request);
14 | if (!event) {
15 | return new Response("Invalid request", { status: 400 });
16 | }
17 | switch (event.type) {
18 | case "user.created":
19 | await ctx.runMutation(internal.users.createUser, {
20 | clerkId: event.data.id,
21 | email: event.data.email_addresses[0].email_address,
22 | imageUrl: event.data.image_url,
23 | name: event.data.first_name as string,
24 | });
25 | break;
26 | case "user.updated":
27 | await ctx.runMutation(internal.users.updateUser, {
28 | clerkId: event.data.id,
29 | imageUrl: event.data.image_url,
30 | email: event.data.email_addresses[0].email_address,
31 | });
32 | break;
33 | case "user.deleted":
34 | await ctx.runMutation(internal.users.deleteUser, {
35 | clerkId: event.data.id as string,
36 | });
37 | break;
38 | }
39 | return new Response(null, {
40 | status: 200,
41 | });
42 | });
43 |
44 | const http = httpRouter();
45 |
46 | http.route({
47 | path: "/clerk",
48 | method: "POST",
49 | handler: handleClerkWebhook,
50 | });
51 |
52 | const validateRequest = async (
53 | req: Request
54 | ): Promise => {
55 | // key note : add the webhook secret variable to the environment variables field in convex dashboard setting
56 | const webhookSecret = process.env.CLERK_WEBHOOK_SECRET!;
57 | if (!webhookSecret) {
58 | throw new Error("CLERK_WEBHOOK_SECRET is not defined");
59 | }
60 | const payloadString = await req.text();
61 | const headerPayload = req.headers;
62 | const svixHeaders = {
63 | "svix-id": headerPayload.get("svix-id")!,
64 | "svix-timestamp": headerPayload.get("svix-timestamp")!,
65 | "svix-signature": headerPayload.get("svix-signature")!,
66 | };
67 | const wh = new Webhook(webhookSecret);
68 | const event = wh.verify(payloadString, svixHeaders);
69 | return event as unknown as WebhookEvent;
70 | };
71 |
72 | export default http;
73 |
--------------------------------------------------------------------------------
/constants/index.ts:
--------------------------------------------------------------------------------
1 | export const sidebarLinks = [
2 | {
3 | imgURL: "/icons/home.svg",
4 | route: "/",
5 | label: "Home",
6 | },
7 | {
8 | imgURL: "/icons/discover.svg",
9 | route: "/discover",
10 | label: "Discover",
11 | },
12 | {
13 | imgURL: "/icons/microphone.svg",
14 | route: "/create-podcast",
15 | label: "Create Podcast",
16 | },
17 | ];
18 |
19 | export const voiceDetails = [
20 | {
21 | id: 1,
22 | name: "alloy",
23 | },
24 | {
25 | id: 2,
26 | name: "echo",
27 | },
28 | {
29 | id: 3,
30 | name: "fable",
31 | },
32 | {
33 | id: 4,
34 | name: "onyx",
35 | },
36 | {
37 | id: 5,
38 | name: "nova",
39 | },
40 | {
41 | id: 6,
42 | name: "shimmer",
43 | },
44 | ];
45 |
46 | export const podcastData = [
47 | {
48 | id: 1,
49 | title: "The Joe Rogan Experience",
50 | description: "A long form, in-depth conversation",
51 | imgURL:
52 | "https://lovely-flamingo-139.convex.cloud/api/storage/3106b884-548d-4ba0-a179-785901f69806",
53 | },
54 | {
55 | id: 2,
56 | title: "The Futur",
57 | description: "This is how the news should sound",
58 | imgURL:
59 | "https://lovely-flamingo-139.convex.cloud/api/storage/16fbf9bd-d800-42bc-ac95-d5a586447bf6",
60 | },
61 | {
62 | id: 3,
63 | title: "Waveform",
64 | description: "Join Michelle Obama in conversation",
65 | imgURL:
66 | "https://lovely-flamingo-139.convex.cloud/api/storage/60f0c1d9-f2ac-4a96-9178-f01d78fa3733",
67 | },
68 | {
69 | id: 4,
70 | title: "The Tech Talks Daily Podcast",
71 | description: "This is how the news should sound",
72 | imgURL:
73 | "https://lovely-flamingo-139.convex.cloud/api/storage/5ba7ed1b-88b4-4c32-8d71-270f1c502445",
74 | },
75 | {
76 | id: 5,
77 | title: "GaryVee Audio Experience",
78 | description: "A long form, in-depth conversation",
79 | imgURL:
80 | "https://lovely-flamingo-139.convex.cloud/api/storage/ca7cb1a6-4919-4b2c-a73e-279a79ac6d23",
81 | },
82 | {
83 | id: 6,
84 | title: "Syntax ",
85 | description: "Join Michelle Obama in conversation",
86 | imgURL:
87 | "https://lovely-flamingo-139.convex.cloud/api/storage/b8ea40c7-aafb-401a-9129-73c515a73ab5",
88 | },
89 | {
90 | id: 7,
91 | title: "IMPAULSIVE",
92 | description: "A long form, in-depth conversation",
93 | imgURL:
94 | "https://lovely-flamingo-139.convex.cloud/api/storage/8a55d662-fe3f-4bcf-b78b-3b2f3d3def5c",
95 | },
96 | {
97 | id: 8,
98 | title: "Ted Tech",
99 | description: "This is how the news should sound",
100 | imgURL:
101 | "https://lovely-flamingo-139.convex.cloud/api/storage/221ee4bd-435f-42c3-8e98-4a001e0d806e",
102 | },
103 | ];
104 |
--------------------------------------------------------------------------------
/convex/README.md:
--------------------------------------------------------------------------------
1 | # Welcome to your Convex functions directory!
2 |
3 | Write your Convex functions here.
4 | See https://docs.convex.dev/functions for more.
5 |
6 | A query function that takes two arguments looks like:
7 |
8 | ```ts
9 | // functions.js
10 | import { query } from "./_generated/server";
11 | import { v } from "convex/values";
12 |
13 | export const myQueryFunction = query({
14 | // Validators for arguments.
15 | args: {
16 | first: v.number(),
17 | second: v.string(),
18 | },
19 |
20 | // Function implementation.
21 | handler: async (ctx, args) => {
22 | // Read the database as many times as you need here.
23 | // See https://docs.convex.dev/database/reading-data.
24 | const documents = await ctx.db.query("tablename").collect();
25 |
26 | // Arguments passed from the client are properties of the args object.
27 | console.log(args.first, args.second);
28 |
29 | // Write arbitrary JavaScript here: filter, aggregate, build derived data,
30 | // remove non-public properties, or create new objects.
31 | return documents;
32 | },
33 | });
34 | ```
35 |
36 | Using this query function in a React component looks like:
37 |
38 | ```ts
39 | const data = useQuery(api.functions.myQueryFunction, {
40 | first: 10,
41 | second: "hello",
42 | });
43 | ```
44 |
45 | A mutation function looks like:
46 |
47 | ```ts
48 | // functions.js
49 | import { mutation } from "./_generated/server";
50 | import { v } from "convex/values";
51 |
52 | export const myMutationFunction = mutation({
53 | // Validators for arguments.
54 | args: {
55 | first: v.string(),
56 | second: v.string(),
57 | },
58 |
59 | // Function implementation.
60 | handler: async (ctx, args) => {
61 | // Insert or modify documents in the database here.
62 | // Mutations can also read from the database like queries.
63 | // See https://docs.convex.dev/database/writing-data.
64 | const message = { body: args.first, author: args.second };
65 | const id = await ctx.db.insert("messages", message);
66 |
67 | // Optionally, return a value from your mutation.
68 | return await ctx.db.get(id);
69 | },
70 | });
71 | ```
72 |
73 | Using this mutation function in a React component looks like:
74 |
75 | ```ts
76 | const mutation = useMutation(api.functions.myMutationFunction);
77 | function handleButtonPress() {
78 | // fire and forget, the most common way to use mutations
79 | mutation({ first: "Hello!", second: "me" });
80 | // OR
81 | // use the result once the mutation has completed
82 | mutation({ first: "Hello!", second: "me" }).then((result) =>
83 | console.log(result),
84 | );
85 | }
86 | ```
87 |
88 | Use the Convex CLI to push your functions to a deployment. See everything
89 | the Convex CLI can do by running `npx convex -h` in your project root
90 | directory. To learn more, launch the docs with `npx convex docs`.
91 |
--------------------------------------------------------------------------------
/public/icons/emptyState.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/components/MobileNav.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import Image from "next/image";
4 | import Link from "next/link";
5 | import { usePathname } from "next/navigation";
6 |
7 | import {
8 | Sheet,
9 | SheetClose,
10 | SheetContent,
11 | SheetTrigger,
12 | } from "@/components/ui/sheet";
13 | import { sidebarLinks } from "@/constants";
14 | import { cn } from "@/lib/utils";
15 |
16 | const MobileNav = () => {
17 | const pathname = usePathname();
18 | return (
19 |
20 |
21 |
22 |
29 |
30 |
31 |
35 |
36 | Podcastr
37 |
38 |
39 |
40 |
74 |
75 |
76 |
77 |
78 |
79 | );
80 | };
81 |
82 | export default MobileNav;
83 |
--------------------------------------------------------------------------------
/components/Carousel.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { EmblaCarouselType } from "embla-carousel";
3 | import Autoplay from "embla-carousel-autoplay";
4 | import useEmblaCarousel from "embla-carousel-react";
5 | import Image from "next/image";
6 | import { useRouter } from "next/navigation";
7 | import { useCallback } from "react";
8 |
9 | import { CarouselProps } from "@/types";
10 |
11 | import { DotButton, useDotButton } from "./DotButton";
12 |
13 | const Carousel = ({ fansLikeDetail }: CarouselProps) => {
14 | const router = useRouter();
15 |
16 | const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true }, [Autoplay()]);
17 |
18 | const onNavButtonClick = useCallback((emblaApi: EmblaCarouselType) => {
19 | const autoplay = emblaApi?.plugins()?.autoplay;
20 |
21 | // everything is similar to code from EmblaCarousel.tsx . except we have satisfy the typescript error by adding the some additional types.
22 | if (!autoplay || !("stopOnInteraction" in autoplay.options)) return;
23 | const resetOrStop =
24 | autoplay.options.stopOnInteraction === false
25 | ? (autoplay.reset as () => void)
26 | : (autoplay.stop as () => void);
27 |
28 | resetOrStop();
29 | }, []);
30 |
31 | const { selectedIndex, scrollSnaps, onDotButtonClick } = useDotButton(
32 | emblaApi,
33 | onNavButtonClick
34 | );
35 |
36 | const filteredFansLikeDetail =
37 | fansLikeDetail && fansLikeDetail?.filter((item) => item.totalPodcasts > 0);
38 |
39 | return (
40 | // modify the code as per your design
41 | // provide emblaref to the parent container
42 |
46 |
47 | {filteredFansLikeDetail?.slice(0, 5).map((item) => (
48 |
router.push(`/podcast/${item.podcast[0]?.pocastId}`)}
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 Carousel;
82 |
--------------------------------------------------------------------------------
/components/RightSidebar.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { SignedIn, UserButton, useUser } from "@clerk/nextjs";
4 | import { useQuery } from "convex/react";
5 | import Image from "next/image";
6 | import Link from "next/link";
7 | import { useRouter } from "next/navigation";
8 | import React from "react";
9 |
10 | import { api } from "@/convex/_generated/api";
11 | import { cn } from "@/lib/utils";
12 | import { useAudio } from "@/providers/AudioProvider";
13 |
14 | import Carousel from "./Carousel";
15 | import Header from "./Header";
16 |
17 | const RightSidebar = () => {
18 | const { user } = useUser();
19 | const { audio } = useAudio();
20 | const router = useRouter();
21 | const topPodcasters = useQuery(api.users.getTopUserByPodcastCount);
22 | return (
23 |
28 |
29 |
30 |
31 |
32 |
33 | {`${user?.firstName}`} {user?.lastName && `${user?.lastName}`}
34 |
35 |
41 |
42 |
43 |
44 |
48 |
49 |
50 |
51 | {topPodcasters?.slice(0, 4).map((podcaster) => (
52 |
router.push(`/profile/${podcaster.clerkId}`)}
56 | >
57 |
58 |
65 |
66 | {podcaster.name}
67 |
68 |
69 |
70 |
71 | {podcaster.totalPodcasts} podcasts
72 |
73 |
74 |
75 | ))}
76 |
77 |
78 |
79 | );
80 | };
81 |
82 | export default RightSidebar;
83 |
--------------------------------------------------------------------------------
/components/LeftSidebar.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { SignedIn, SignedOut, useClerk } from "@clerk/nextjs";
4 | import Image from "next/image";
5 | import Link from "next/link";
6 | import { usePathname, useRouter } from "next/navigation";
7 |
8 | import { sidebarLinks } from "@/constants";
9 | import { cn } from "@/lib/utils";
10 | import { useAudio } from "@/providers/AudioProvider";
11 |
12 | import { Button } from "./ui/button";
13 |
14 | const LeftSidebar = () => {
15 | const pathname = usePathname();
16 | const { signOut } = useClerk();
17 | const router = useRouter();
18 | const { audio } = useAudio();
19 |
20 | return (
21 |
26 |
72 |
73 |
74 |
77 |
78 |
79 |
80 |
81 |
87 |
88 |
89 |
90 | );
91 | };
92 |
93 | export default LeftSidebar;
94 |
--------------------------------------------------------------------------------
/components/ProfileCard.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import Image from "next/image";
3 | import { useEffect, useState } from "react";
4 |
5 | import { useAudio } from "@/providers/AudioProvider";
6 | import { PodcastProps, ProfileCardProps } from "@/types";
7 |
8 | import LoaderSpinner from "./Loader";
9 | import { Button } from "./ui/button";
10 |
11 | const ProfileCard = ({
12 | podcastData,
13 | imageUrl,
14 | userFirstName,
15 | }: ProfileCardProps) => {
16 | const { setAudio } = useAudio();
17 |
18 | const [randomPodcast, setRandomPodcast] = useState(null);
19 |
20 | const playRandomPodcast = () => {
21 | const randomIndex = Math.floor(Math.random() * podcastData.podcasts.length);
22 |
23 | setRandomPodcast(podcastData.podcasts[randomIndex]);
24 | };
25 |
26 | useEffect(() => {
27 | if (randomPodcast) {
28 | setAudio({
29 | title: randomPodcast.podcastTitle,
30 | audioUrl: randomPodcast.audioUrl || "",
31 | imageUrl: randomPodcast.imageUrl || "",
32 | author: randomPodcast.author,
33 | podcastId: randomPodcast._id,
34 | });
35 | }
36 | }, [randomPodcast, setAudio]);
37 |
38 | if (!imageUrl) return ;
39 |
40 | return (
41 |
42 |
49 |
50 |
51 |
52 |
58 |
59 | Verified Creator
60 |
61 |
62 |
63 | {userFirstName}
64 |
65 |
66 |
67 |
73 |
74 | {podcastData?.listeners}
75 | monthly listeners
76 |
77 |
78 | {podcastData?.podcasts.length > 0 && (
79 |
91 | )}
92 |
93 |
94 | );
95 | };
96 |
97 | export default ProfileCard;
98 |
--------------------------------------------------------------------------------
/convex/users.ts:
--------------------------------------------------------------------------------
1 | import { ConvexError, v } from "convex/values";
2 |
3 | import { internalMutation, query } from "./_generated/server";
4 |
5 | export const getUserById = query({
6 | args: { clerkId: v.string() },
7 | handler: async (ctx, args) => {
8 | const user = await ctx.db
9 | .query("users")
10 | .filter((q) => q.eq(q.field("clerkId"), args.clerkId))
11 | .unique();
12 |
13 | if (!user) {
14 | throw new ConvexError("User not found");
15 | }
16 |
17 | return user;
18 | },
19 | });
20 |
21 | // 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.
22 | export const getTopUserByPodcastCount = query({
23 | args: {},
24 | handler: async (ctx, args) => {
25 | const user = await ctx.db.query("users").collect();
26 |
27 | const userData = await Promise.all(
28 | user.map(async (u) => {
29 | const podcasts = await ctx.db
30 | .query("podcasts")
31 | .filter((q) => q.eq(q.field("authorId"), u.clerkId))
32 | .collect();
33 |
34 | const sortedPodcasts = podcasts.sort((a, b) => b.views - a.views);
35 |
36 | return {
37 | ...u,
38 | totalPodcasts: podcasts.length,
39 | podcast: sortedPodcasts.map((p) => ({
40 | podcastTitle: p.podcastTitle,
41 | pocastId: p._id,
42 | })),
43 | };
44 | })
45 | );
46 |
47 | return userData.sort((a, b) => b.totalPodcasts - a.totalPodcasts);
48 | },
49 | });
50 |
51 | export const createUser = internalMutation({
52 | args: {
53 | clerkId: v.string(),
54 | email: v.string(),
55 | imageUrl: v.string(),
56 | name: v.string(),
57 | },
58 | handler: async (ctx, args) => {
59 | await ctx.db.insert("users", {
60 | clerkId: args.clerkId,
61 | email: args.email,
62 | imageUrl: args.imageUrl,
63 | name: args.name,
64 | });
65 | },
66 | });
67 |
68 | export const updateUser = internalMutation({
69 | args: {
70 | clerkId: v.string(),
71 | imageUrl: v.string(),
72 | email: v.string(),
73 | },
74 | async handler(ctx, args) {
75 | const user = await ctx.db
76 | .query("users")
77 | .filter((q) => q.eq(q.field("clerkId"), args.clerkId))
78 | .unique();
79 |
80 | if (!user) {
81 | throw new ConvexError("User not found");
82 | }
83 |
84 | await ctx.db.patch(user._id, {
85 | imageUrl: args.imageUrl,
86 | email: args.email,
87 | });
88 |
89 | const podcast = await ctx.db
90 | .query("podcasts")
91 | .filter((q) => q.eq(q.field("authorId"), args.clerkId))
92 | .collect();
93 |
94 | await Promise.all(
95 | podcast.map(async (p) => {
96 | await ctx.db.patch(p._id, {
97 | authorImageUrl: args.imageUrl,
98 | });
99 | })
100 | );
101 | },
102 | });
103 |
104 | export const deleteUser = internalMutation({
105 | args: { clerkId: v.string() },
106 | async handler(ctx, args) {
107 | const user = await ctx.db
108 | .query("users")
109 | .filter((q) => q.eq(q.field("clerkId"), args.clerkId))
110 | .unique();
111 |
112 | if (!user) {
113 | throw new ConvexError("User not found");
114 | }
115 |
116 | await ctx.db.delete(user._id);
117 | },
118 | });
119 |
--------------------------------------------------------------------------------
/types/index.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-unused-vars */
2 |
3 | import { Dispatch, SetStateAction } from "react";
4 |
5 | import { Id } from "@/convex/_generated/dataModel";
6 |
7 | export interface EmptyStateProps {
8 | title: string;
9 | search?: boolean;
10 | buttonText?: string;
11 | buttonLink?: string;
12 | }
13 |
14 | export interface TopPodcastersProps {
15 | _id: Id<"users">;
16 | _creationTime: number;
17 | email: string;
18 | imageUrl: string;
19 | clerkId: string;
20 | name: string;
21 | podcast: {
22 | podcastTitle: string;
23 | pocastId: Id<"podcasts">;
24 | }[];
25 | totalPodcasts: number;
26 | }
27 |
28 | export interface PodcastProps {
29 | _id: Id<"podcasts">;
30 | _creationTime: number;
31 | audioStorageId: Id<"_storage"> | null;
32 | user: Id<"users">;
33 | podcastTitle: string;
34 | podcastDescription: string;
35 | audioUrl: string | null;
36 | imageUrl: string | null;
37 | imageStorageId: Id<"_storage"> | null;
38 | author: string;
39 | authorId: string;
40 | authorImageUrl: string;
41 | voicePrompt: string;
42 | imagePrompt: string | null;
43 | voiceType: string;
44 | audioDuration: number;
45 | views: number;
46 | }
47 |
48 | export interface ProfilePodcastProps {
49 | podcasts: PodcastProps[];
50 | listeners: number;
51 | }
52 |
53 | export type VoiceType =
54 | | "alloy"
55 | | "echo"
56 | | "fable"
57 | | "onyx"
58 | | "nova"
59 | | "shimmer";
60 |
61 | export interface GeneratePodcastProps {
62 | voiceType: VoiceType;
63 | setAudio: Dispatch>;
64 | audio: string;
65 | setAudioStorageId: Dispatch | null>>;
66 | voicePrompt: string;
67 | setVoicePrompt: Dispatch>;
68 | setAudioDuration: Dispatch>;
69 | }
70 |
71 | export interface GenerateThumbnailProps {
72 | setImage: Dispatch>;
73 | setImageStorageId: Dispatch | null>>;
74 | image: string;
75 | imagePrompt: string;
76 | setImagePrompt: Dispatch>;
77 | }
78 |
79 | export interface LatestPodcastCardProps {
80 | imgUrl: string;
81 | title: string;
82 | duration: string;
83 | index: number;
84 | audioUrl: string;
85 | author: string;
86 | views: number;
87 | podcastId: Id<"podcasts">;
88 | }
89 |
90 | export interface PodcastDetailPlayerProps {
91 | audioUrl: string;
92 | podcastTitle: string;
93 | author: string;
94 | isOwner: boolean;
95 | imageUrl: string;
96 | podcastId: Id<"podcasts">;
97 | imageStorageId: Id<"_storage">;
98 | audioStorageId: Id<"_storage">;
99 | authorImageUrl: string;
100 | authorId: string;
101 | }
102 |
103 | export interface AudioProps {
104 | title: string;
105 | audioUrl: string;
106 | author: string;
107 | imageUrl: string;
108 | podcastId: string;
109 | }
110 |
111 | export interface AudioContextType {
112 | audio: AudioProps | undefined;
113 | setAudio: React.Dispatch>;
114 | }
115 |
116 | export interface PodcastCardProps {
117 | imgUrl: string;
118 | title: string;
119 | description: string;
120 | podcastId: Id<"podcasts">;
121 | }
122 |
123 | export interface CarouselProps {
124 | fansLikeDetail: TopPodcastersProps[];
125 | }
126 |
127 | export interface ProfileCardProps {
128 | podcastData: ProfilePodcastProps;
129 | imageUrl: string;
130 | userFirstName: string;
131 | }
132 |
133 | export type UseDotButtonType = {
134 | selectedIndex: number;
135 | scrollSnaps: number[];
136 | onDotButtonClick: (index: number) => void;
137 | };
138 |
--------------------------------------------------------------------------------
/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.11.1.
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 |
--------------------------------------------------------------------------------
/app/(root)/podcast/[podcastId]/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { useUser } from "@clerk/nextjs";
3 | import { useQuery } from "convex/react";
4 | import Image from "next/image";
5 |
6 | import EmptyState from "@/components/EmptyState";
7 | import LoaderSpinner from "@/components/Loader";
8 | import PodcastCard from "@/components/PodcastCard";
9 | import PodcastDetailPlayer from "@/components/PodcastDetailPlayer";
10 | import { api } from "@/convex/_generated/api";
11 | import { Id } from "@/convex/_generated/dataModel";
12 |
13 | const Prompt = ({
14 | title,
15 | description,
16 | }: {
17 | title: string;
18 | description: string;
19 | }) => {
20 | return (
21 |
22 |
{title}
23 |
{description}
24 |
25 | );
26 | };
27 |
28 | const PodcastDetailPage = ({
29 | params,
30 | }: {
31 | params: {
32 | podcastId: Id<"podcasts">;
33 | };
34 | }) => {
35 | const { user } = useUser();
36 |
37 | const podcast = useQuery(api.podcasts.getPodcastById, {
38 | podcastId: params.podcastId,
39 | });
40 |
41 | const similarPodcast = useQuery(api.podcasts.getPodcastByVoiceType, {
42 | podcastId: params.podcastId,
43 | });
44 |
45 | const isOwner = user?.id === podcast?.authorId;
46 |
47 | if (!similarPodcast || !podcast) return ;
48 |
49 | return (
50 |
51 |
52 | Currently Playing
53 |
54 |
60 | {podcast?.views}
61 |
62 |
63 |
75 |
76 | {podcast?.podcastDescription}
77 |
78 |
79 |
80 |
81 | {podcast?.imagePrompt && (
82 |
86 | )}
87 |
88 |
89 | Similar Podcasts
90 | {similarPodcast && similarPodcast.length > 0 ? (
91 |
92 | {similarPodcast?.map((podcast) => (
93 |
100 | ))}
101 |
102 | ) : (
103 |
108 | )}
109 |
110 |
111 | );
112 | };
113 |
114 | export default PodcastDetailPage;
115 |
--------------------------------------------------------------------------------
/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-[310px] 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 mt-[30px] 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 | .text-12 {
44 | @apply text-[12px] leading-normal;
45 | }
46 | .text-14 {
47 | @apply text-[14px] leading-normal;
48 | }
49 | .text-16 {
50 | @apply text-[16px] leading-normal;
51 | }
52 | .text-18 {
53 | @apply text-[18px] leading-normal;
54 | }
55 | .text-20 {
56 | @apply text-[20px] leading-normal;
57 | }
58 | .text-24 {
59 | @apply text-[24px] leading-normal;
60 | }
61 | .text-32 {
62 | @apply text-[32px] leading-normal;
63 | }
64 | }
65 |
66 | /* ===== custom classes ===== */
67 |
68 | .custom-scrollbar::-webkit-scrollbar {
69 | width: 3px;
70 | height: 3px;
71 | border-radius: 2px;
72 | }
73 |
74 | .custom-scrollbar::-webkit-scrollbar-track {
75 | background: #15171c;
76 | }
77 |
78 | .custom-scrollbar::-webkit-scrollbar-thumb {
79 | background: #222429;
80 | border-radius: 50px;
81 | }
82 |
83 | .custom-scrollbar::-webkit-scrollbar-thumb:hover {
84 | background: #555;
85 | }
86 | /* Hide scrollbar for Chrome, Safari and Opera */
87 | .no-scrollbar::-webkit-scrollbar {
88 | display: none;
89 | }
90 |
91 | /* Hide scrollbar for IE, Edge and Firefox */
92 | .no-scrollbar {
93 | -ms-overflow-style: none; /* IE and Edge */
94 | scrollbar-width: none; /* Firefox */
95 | }
96 | .glassmorphism {
97 | background: rgba(255, 255, 255, 0.25);
98 | backdrop-filter: blur(4px);
99 | -webkit-backdrop-filter: blur(4px);
100 | }
101 | .glassmorphism-auth {
102 | background: rgba(6, 3, 3, 0.711);
103 | backdrop-filter: blur(4px);
104 | -webkit-backdrop-filter: blur(4px);
105 | }
106 | .glassmorphism-black {
107 | background: rgba(18, 18, 18, 0.64);
108 | backdrop-filter: blur(37px);
109 | -webkit-backdrop-filter: blur(37px);
110 | }
111 |
112 | /* ======= clerk overrides ======== */
113 | .cl-socialButtonsIconButton {
114 | border: 2px solid #222429;
115 | }
116 | .cl-button {
117 | color: white;
118 | }
119 | .cl-socialButtonsProviderIcon__github {
120 | filter: invert(1);
121 | }
122 | .cl-internal-b3fm6y {
123 | background: #f97535;
124 | }
125 | .cl-formButtonPrimary {
126 | background: #f97535;
127 | }
128 | .cl-footerActionLink {
129 | color: #f97535;
130 | }
131 | .cl-headerSubtitle {
132 | color: #c5d0e6;
133 | }
134 | .cl-logoImage {
135 | width: 10rem;
136 | height: 3rem;
137 | }
138 | .cl-internal-4a7e9l {
139 | color: white;
140 | }
141 |
142 | .cl-userButtonPopoverActionButtonIcon {
143 | color: white;
144 | }
145 | .cl-internal-wkkub3 {
146 | color: #f97535;
147 | }
148 |
--------------------------------------------------------------------------------
/components/GeneratePodcast.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useUploadFiles } from "@xixixao/uploadstuff/react";
4 | import { useAction, useMutation } from "convex/react";
5 | import { Loader } from "lucide-react";
6 | import { useState } from "react";
7 | import { v4 as uuidv4 } from "uuid";
8 |
9 | import { api } from "@/convex/_generated/api";
10 | import { GeneratePodcastProps } from "@/types";
11 |
12 | import { Button } from "./ui/button";
13 | import { Label } from "./ui/label";
14 | import { Textarea } from "./ui/textarea";
15 | import { useToast } from "./ui/use-toast";
16 |
17 | // we can keep the hook in separate file as well.
18 | const useGeneratePodcast = (props: GeneratePodcastProps) => {
19 | const { toast } = useToast();
20 | const [isGenerating, setIsGenerating] = useState(false);
21 | const generateUploadUrl = useMutation(api.files.generateUploadUrl);
22 | const getPodcastAudio = useAction(api.openai.generateAudioAction);
23 | const { startUpload } = useUploadFiles(generateUploadUrl);
24 | const getAudioUrl = useMutation(api.podcasts.getUrl);
25 |
26 | const generatePodcast = async () => {
27 | setIsGenerating(true);
28 | props.setAudio("");
29 | if (!props.voicePrompt) {
30 | toast({
31 | title: "Please provide a voiceType to generate podcast",
32 | });
33 | setIsGenerating(false);
34 | return;
35 | }
36 | try {
37 | const response = await getPodcastAudio({
38 | voice: props.voiceType,
39 | input: props.voicePrompt,
40 | });
41 |
42 | const blob = new Blob([response], { type: "audio/mpeg" });
43 | const fileName = `podcast-${uuidv4()}.mp3`;
44 | const file = new File([blob], fileName, {
45 | type: "audio/mpeg",
46 | });
47 | const uploaded = await startUpload([file]);
48 | const storageId = (uploaded[0].response as any).storageId;
49 | props.setAudioStorageId(storageId);
50 |
51 | const audioUrl = await getAudioUrl({ storageId });
52 | props.setAudio(audioUrl!);
53 | setIsGenerating(false);
54 | toast({
55 | title: "Podcast generated successfully",
56 | });
57 | } catch (error) {
58 | console.error("Error generating and uploading podcast:", error);
59 | toast({
60 | title: "Error generating podcast",
61 | variant: "destructive",
62 | });
63 | setIsGenerating(false);
64 | }
65 | };
66 |
67 | return { isGenerating, generatePodcast };
68 | };
69 |
70 | const GeneratePodcast = ({
71 | voiceType,
72 | setAudio,
73 | setAudioStorageId,
74 | audio,
75 | voicePrompt,
76 | setVoicePrompt,
77 | setAudioDuration,
78 | }: GeneratePodcastProps) => {
79 | const { isGenerating, generatePodcast } = useGeneratePodcast({
80 | voiceType,
81 | setAudio,
82 | audio,
83 | setAudioStorageId,
84 | voicePrompt,
85 | setVoicePrompt,
86 | setAudioDuration,
87 | });
88 | return (
89 |
90 |
91 |
94 |
102 |
103 |
116 |
117 | {audio && (
118 |
127 | );
128 | };
129 |
130 | export default GeneratePodcast;
131 |
--------------------------------------------------------------------------------
/components/PodcastDetailPlayer.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { useMutation } from "convex/react";
3 | import Image from "next/image";
4 | import { useRouter } from "next/navigation";
5 | import { useState } from "react";
6 |
7 | import { api } from "@/convex/_generated/api";
8 | import { useAudio } from "@/providers/AudioProvider";
9 | import { PodcastDetailPlayerProps } from "@/types";
10 |
11 | import LoaderSpinner from "./Loader";
12 | import { Button } from "./ui/button";
13 | import { useToast } from "./ui/use-toast";
14 |
15 | const PodcastDetailPlayer = ({
16 | audioUrl,
17 | podcastTitle,
18 | author,
19 | imageUrl,
20 | podcastId,
21 | imageStorageId,
22 | audioStorageId,
23 | isOwner,
24 | authorImageUrl,
25 | authorId,
26 | }: PodcastDetailPlayerProps) => {
27 | const router = useRouter();
28 | const { setAudio } = useAudio();
29 | const { toast } = useToast();
30 | const [isDeleting, setIsDeleting] = useState(false);
31 | const deletePodcast = useMutation(api.podcasts.deletePodcast);
32 |
33 | const handleDelete = async () => {
34 | try {
35 | await deletePodcast({ podcastId, imageStorageId, audioStorageId });
36 | toast({
37 | title: "Podcast deleted",
38 | });
39 | router.push("/");
40 | } catch (error) {
41 | console.error("Error deleting podcast", error);
42 | toast({
43 | title: "Error deleting podcast",
44 | variant: "destructive",
45 | });
46 | }
47 | };
48 |
49 | const handlePlay = () => {
50 | setAudio({
51 | title: podcastTitle,
52 | audioUrl,
53 | imageUrl,
54 | author,
55 | podcastId,
56 | });
57 | };
58 |
59 | if (!imageUrl || !authorImageUrl) return ;
60 |
61 | return (
62 |
63 |
64 |
71 |
72 |
73 |
74 | {podcastTitle}
75 |
76 | {
79 | router.push(`/profile/${authorId}`);
80 | }}
81 | >
82 |
89 | {author}
90 |
91 |
92 |
93 |
105 |
106 |
107 | {isOwner && (
108 |
109 |
setIsDeleting((prev) => !prev)}
116 | />
117 | {isDeleting && (
118 |
122 |
128 |
Delete
129 |
130 | )}
131 |
132 | )}
133 |
134 | );
135 | };
136 |
137 | export default PodcastDetailPlayer;
138 |
--------------------------------------------------------------------------------
/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 { ToastActionElement, ToastProps } from "@/components/ui/toast";
7 |
8 | const TOAST_LIMIT = 1;
9 | const TOAST_REMOVE_DELAY = 1000000;
10 |
11 | type ToasterToast = ToastProps & {
12 | id: string;
13 | title?: React.ReactNode;
14 | description?: React.ReactNode;
15 | action?: ToastActionElement;
16 | };
17 |
18 | const actionTypes = {
19 | ADD_TOAST: "ADD_TOAST",
20 | UPDATE_TOAST: "UPDATE_TOAST",
21 | DISMISS_TOAST: "DISMISS_TOAST",
22 | REMOVE_TOAST: "REMOVE_TOAST",
23 | } as const;
24 |
25 | let count = 0;
26 |
27 | function genId() {
28 | count = (count + 1) % Number.MAX_SAFE_INTEGER;
29 | return count.toString();
30 | }
31 |
32 | type ActionType = typeof actionTypes;
33 |
34 | type Action =
35 | | {
36 | type: ActionType["ADD_TOAST"];
37 | toast: ToasterToast;
38 | }
39 | | {
40 | type: ActionType["UPDATE_TOAST"];
41 | toast: Partial;
42 | }
43 | | {
44 | type: ActionType["DISMISS_TOAST"];
45 | toastId?: ToasterToast["id"];
46 | }
47 | | {
48 | type: ActionType["REMOVE_TOAST"];
49 | toastId?: ToasterToast["id"];
50 | };
51 |
52 | interface State {
53 | toasts: ToasterToast[];
54 | }
55 |
56 | const toastTimeouts = new Map>();
57 |
58 | const addToRemoveQueue = (toastId: string) => {
59 | if (toastTimeouts.has(toastId)) {
60 | return;
61 | }
62 |
63 | const timeout = setTimeout(() => {
64 | toastTimeouts.delete(toastId);
65 | dispatch({
66 | type: "REMOVE_TOAST",
67 | toastId,
68 | });
69 | }, TOAST_REMOVE_DELAY);
70 |
71 | toastTimeouts.set(toastId, timeout);
72 | };
73 |
74 | export const reducer = (state: State, action: Action): State => {
75 | switch (action.type) {
76 | case "ADD_TOAST":
77 | return {
78 | ...state,
79 | toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
80 | };
81 |
82 | case "UPDATE_TOAST":
83 | return {
84 | ...state,
85 | toasts: state.toasts.map((t) =>
86 | t.id === action.toast.id ? { ...t, ...action.toast } : t
87 | ),
88 | };
89 |
90 | case "DISMISS_TOAST": {
91 | const { toastId } = action;
92 |
93 | // ! Side effects ! - This could be extracted into a dismissToast() action,
94 | // but I'll keep it here for simplicity
95 | if (toastId) {
96 | addToRemoveQueue(toastId);
97 | } else {
98 | state.toasts.forEach((toast) => {
99 | addToRemoveQueue(toast.id);
100 | });
101 | }
102 |
103 | return {
104 | ...state,
105 | toasts: state.toasts.map((t) =>
106 | t.id === toastId || toastId === undefined
107 | ? {
108 | ...t,
109 | open: false,
110 | }
111 | : t
112 | ),
113 | };
114 | }
115 | case "REMOVE_TOAST":
116 | if (action.toastId === undefined) {
117 | return {
118 | ...state,
119 | toasts: [],
120 | };
121 | }
122 | return {
123 | ...state,
124 | toasts: state.toasts.filter((t) => t.id !== action.toastId),
125 | };
126 | }
127 | };
128 |
129 | const listeners: Array<(state: State) => void> = [];
130 |
131 | let memoryState: State = { toasts: [] };
132 |
133 | function dispatch(action: Action) {
134 | memoryState = reducer(memoryState, action);
135 | listeners.forEach((listener) => {
136 | listener(memoryState);
137 | });
138 | }
139 |
140 | type Toast = Omit;
141 |
142 | function toast({ ...props }: Toast) {
143 | const id = genId();
144 |
145 | const update = (props: ToasterToast) =>
146 | dispatch({
147 | type: "UPDATE_TOAST",
148 | toast: { ...props, id },
149 | });
150 | const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
151 |
152 | dispatch({
153 | type: "ADD_TOAST",
154 | toast: {
155 | ...props,
156 | id,
157 | open: true,
158 | onOpenChange: (open) => {
159 | if (!open) dismiss();
160 | },
161 | },
162 | });
163 |
164 | return {
165 | id,
166 | dismiss,
167 | update,
168 | };
169 | }
170 |
171 | function useToast() {
172 | const [state, setState] = React.useState(memoryState);
173 |
174 | React.useEffect(() => {
175 | listeners.push(setState);
176 | return () => {
177 | const index = listeners.indexOf(setState);
178 | if (index > -1) {
179 | listeners.splice(index, 1);
180 | }
181 | };
182 | }, [state]);
183 |
184 | return {
185 | ...state,
186 | toast,
187 | dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
188 | };
189 | }
190 |
191 | export { useToast, toast };
192 |
--------------------------------------------------------------------------------
/components/ui/sheet.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as SheetPrimitive from "@radix-ui/react-dialog";
4 | import { cva, type VariantProps } from "class-variance-authority";
5 | import { X } from "lucide-react";
6 | import * as React from "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-white-1 p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out dark:bg-slate-950",
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 |
117 | ));
118 | SheetTitle.displayName = SheetPrimitive.Title.displayName;
119 |
120 | const SheetDescription = React.forwardRef<
121 | React.ElementRef,
122 | React.ComponentPropsWithoutRef
123 | >(({ className, ...props }, ref) => (
124 |
129 | ));
130 | SheetDescription.displayName = SheetPrimitive.Description.displayName;
131 |
132 | export {
133 | Sheet,
134 | SheetPortal,
135 | SheetOverlay,
136 | SheetTrigger,
137 | SheetClose,
138 | SheetContent,
139 | SheetHeader,
140 | SheetFooter,
141 | SheetTitle,
142 | SheetDescription,
143 | };
144 |
--------------------------------------------------------------------------------
/components/ui/form.tsx:
--------------------------------------------------------------------------------
1 | import * as LabelPrimitive from "@radix-ui/react-label";
2 | import { Slot } from "@radix-ui/react-slot";
3 | import * as React from "react";
4 | import {
5 | Controller,
6 | ControllerProps,
7 | FieldPath,
8 | FieldValues,
9 | FormProvider,
10 | useFormContext,
11 | } from "react-hook-form";
12 |
13 | import { Label } from "@/components/ui/label";
14 | import { cn } from "@/lib/utils";
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 |
165 | {body}
166 |
167 | );
168 | });
169 | FormMessage.displayName = "FormMessage";
170 |
171 | export {
172 | useFormField,
173 | Form,
174 | FormItem,
175 | FormLabel,
176 | FormControl,
177 | FormDescription,
178 | FormMessage,
179 | FormField,
180 | };
181 |
--------------------------------------------------------------------------------
/public/icons/avatar.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/components/ui/toast.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as ToastPrimitives from "@radix-ui/react-toast";
4 | import { cva, type VariantProps } from "class-variance-authority";
5 | import { X } from "lucide-react";
6 | import * as React from "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 border-slate-200 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 dark:border-slate-800 data-[state=open]:sm:slide-in-from-bottom-full",
29 | {
30 | variants: {
31 | variant: {
32 | default:
33 | "border bg-orange-1 text-white-1 dark:bg-slate-950 dark:text-slate-50",
34 | destructive:
35 | "group border-red-500 bg-red-500 text-slate-50 dark:border-red-900 dark:bg-red-900 dark:text-slate-50",
36 | },
37 | },
38 | defaultVariants: {
39 | variant: "default",
40 | },
41 | }
42 | );
43 |
44 | const Toast = React.forwardRef<
45 | React.ElementRef,
46 | React.ComponentPropsWithoutRef &
47 | VariantProps
48 | >(({ className, variant, ...props }, ref) => {
49 | return (
50 |
55 | );
56 | });
57 | Toast.displayName = ToastPrimitives.Root.displayName;
58 |
59 | const ToastAction = React.forwardRef<
60 | React.ElementRef,
61 | React.ComponentPropsWithoutRef
62 | >(({ className, ...props }, ref) => (
63 |
71 | ));
72 | ToastAction.displayName = ToastPrimitives.Action.displayName;
73 |
74 | const ToastClose = React.forwardRef<
75 | React.ElementRef,
76 | React.ComponentPropsWithoutRef
77 | >(({ className, ...props }, ref) => (
78 |
87 |
88 |
89 | ));
90 | ToastClose.displayName = ToastPrimitives.Close.displayName;
91 |
92 | const ToastTitle = React.forwardRef<
93 | React.ElementRef,
94 | React.ComponentPropsWithoutRef
95 | >(({ className, ...props }, ref) => (
96 |
101 | ));
102 | ToastTitle.displayName = ToastPrimitives.Title.displayName;
103 |
104 | const ToastDescription = React.forwardRef<
105 | React.ElementRef,
106 | React.ComponentPropsWithoutRef
107 | >(({ className, ...props }, ref) => (
108 |
113 | ));
114 | ToastDescription.displayName = ToastPrimitives.Description.displayName;
115 |
116 | type ToastProps = React.ComponentPropsWithoutRef;
117 |
118 | type ToastActionElement = React.ReactElement;
119 |
120 | export {
121 | type ToastProps,
122 | type ToastActionElement,
123 | ToastProvider,
124 | ToastViewport,
125 | Toast,
126 | ToastTitle,
127 | ToastDescription,
128 | ToastClose,
129 | ToastAction,
130 | };
131 |
--------------------------------------------------------------------------------
/components/PodcastPlayer.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import Image from "next/image";
3 | import Link from "next/link";
4 | import { useEffect, useRef, useState } from "react";
5 |
6 | import { formatTime } from "@/lib/formatTime";
7 | import { cn } from "@/lib/utils";
8 | import { useAudio } from "@/providers/AudioProvider";
9 |
10 | import { Progress } from "./ui/progress";
11 |
12 | const PodcastPlayer = () => {
13 | const audioRef = useRef(null);
14 | const [isPlaying, setIsPlaying] = useState(false);
15 | const [duration, setDuration] = useState(0);
16 | const [isMuted, setIsMuted] = useState(false);
17 | const [currentTime, setCurrentTime] = useState(0);
18 | const { audio } = useAudio();
19 |
20 | const togglePlayPause = () => {
21 | if (audioRef.current?.paused) {
22 | audioRef.current?.play();
23 | setIsPlaying(true);
24 | } else {
25 | audioRef.current?.pause();
26 | setIsPlaying(false);
27 | }
28 | };
29 |
30 | const toggleMute = () => {
31 | if (audioRef.current) {
32 | audioRef.current.muted = !isMuted;
33 | setIsMuted((prev) => !prev);
34 | }
35 | };
36 |
37 | const forward = () => {
38 | if (
39 | audioRef.current &&
40 | audioRef.current.currentTime &&
41 | audioRef.current.duration &&
42 | audioRef.current.currentTime + 5 < audioRef.current.duration
43 | ) {
44 | audioRef.current.currentTime += 5;
45 | }
46 | };
47 |
48 | const rewind = () => {
49 | if (audioRef.current && audioRef.current.currentTime - 5 > 0) {
50 | audioRef.current.currentTime -= 5;
51 | } else if (audioRef.current) {
52 | audioRef.current.currentTime = 0;
53 | }
54 | };
55 |
56 | useEffect(() => {
57 | const updateCurrentTime = () => {
58 | if (audioRef.current) {
59 | setCurrentTime(audioRef.current.currentTime);
60 | }
61 | };
62 |
63 | const audioElement = audioRef.current;
64 | if (audioElement) {
65 | audioElement.addEventListener("timeupdate", updateCurrentTime);
66 |
67 | return () => {
68 | audioElement.removeEventListener("timeupdate", updateCurrentTime);
69 | };
70 | }
71 | }, []);
72 |
73 | useEffect(() => {
74 | const audioElement = audioRef.current;
75 | if (audio?.audioUrl) {
76 | if (audioElement) {
77 | audioElement.play().then(() => {
78 | setIsPlaying(true);
79 | });
80 | }
81 | } else {
82 | audioElement?.pause();
83 | setIsPlaying(true);
84 | }
85 | }, [audio]);
86 | const handleLoadedMetadata = () => {
87 | if (audioRef.current) {
88 | setDuration(audioRef.current.duration);
89 | }
90 | };
91 |
92 | const handleAudioEnded = () => {
93 | setIsPlaying(false);
94 | };
95 |
96 | return (
97 |
102 | {/* change the color for indicator inside the Progress component in ui folder */}
103 |
108 |
109 |
116 |
117 |
118 |
125 |
126 |
127 |
128 | {audio?.title}
129 |
130 |
{audio?.author}
131 |
132 |
133 |
134 |
135 |
142 |
-5
143 |
144 |
151 |
152 |
+5
153 |
160 |
161 |
162 |
163 |
164 | {formatTime(duration)}
165 |
166 |
167 |
175 |
176 |
177 |
178 |
179 | );
180 | };
181 |
182 | export default PodcastPlayer;
183 |
--------------------------------------------------------------------------------
/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.11.1.
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/podcasts.ts:
--------------------------------------------------------------------------------
1 | import { ConvexError, v } from "convex/values";
2 |
3 | import { mutation, query } from "./_generated/server";
4 |
5 | // create podcast mutation
6 | export const createPodcast = mutation({
7 | args: {
8 | audioStorageId: v.union(v.id("_storage"), v.null()),
9 | podcastTitle: v.string(),
10 | podcastDescription: v.string(),
11 | audioUrl: v.string(),
12 | imageUrl: v.string(),
13 | imageStorageId: v.union(v.id("_storage"), v.null()),
14 | voicePrompt: v.string(),
15 | imagePrompt: v.string(),
16 | voiceType: v.string(),
17 | views: v.number(),
18 | audioDuration: v.number(),
19 | },
20 | handler: async (ctx, args) => {
21 | const identity = await ctx.auth.getUserIdentity();
22 |
23 | if (!identity) {
24 | throw new ConvexError("User not authenticated");
25 | }
26 |
27 | const user = await ctx.db
28 | .query("users")
29 | .filter((q) => q.eq(q.field("email"), identity.email))
30 | .collect();
31 |
32 | if (user.length === 0) {
33 | throw new ConvexError("User not found");
34 | }
35 |
36 | return await ctx.db.insert("podcasts", {
37 | audioStorageId: args.audioStorageId,
38 | user: user[0]._id,
39 | podcastTitle: args.podcastTitle,
40 | podcastDescription: args.podcastDescription,
41 | audioUrl: args.audioUrl,
42 | imageUrl: args.imageUrl,
43 | imageStorageId: args.imageStorageId,
44 | author: user[0].name,
45 | authorId: user[0].clerkId,
46 | voicePrompt: args.voicePrompt,
47 | imagePrompt: args.imagePrompt,
48 | voiceType: args.voiceType,
49 | views: args.views,
50 | authorImageUrl: user[0].imageUrl,
51 | audioDuration: args.audioDuration,
52 | });
53 | },
54 | });
55 |
56 | // this mutation is required to generate the url after uploading the file to the storage.
57 | export const getUrl = mutation({
58 | args: {
59 | storageId: v.id("_storage"),
60 | },
61 | handler: async (ctx, args) => {
62 | return await ctx.storage.getUrl(args.storageId);
63 | },
64 | });
65 |
66 | // this query will get all the podcasts based on the voiceType of the podcast , which we are showing in the Similar Podcasts section.
67 | export const getPodcastByVoiceType = query({
68 | args: {
69 | podcastId: v.id("podcasts"),
70 | },
71 | handler: async (ctx, args) => {
72 | const podcast = await ctx.db.get(args.podcastId);
73 |
74 | return await ctx.db
75 | .query("podcasts")
76 | .filter((q) =>
77 | q.and(
78 | q.eq(q.field("voiceType"), podcast?.voiceType),
79 | q.neq(q.field("_id"), args.podcastId)
80 | )
81 | )
82 | .collect();
83 | },
84 | });
85 |
86 | // this query will get all the podcasts.
87 | export const getAllPodcasts = query({
88 | handler: async (ctx) => {
89 | return await ctx.db.query("podcasts").order("desc").collect();
90 | },
91 | });
92 |
93 | // this query will get the podcast by the podcastId.
94 | export const getPodcastById = query({
95 | args: {
96 | podcastId: v.id("podcasts"),
97 | },
98 | handler: async (ctx, args) => {
99 | return await ctx.db.get(args.podcastId);
100 | },
101 | });
102 |
103 | // this query will get the podcasts based on the views of the podcast , which we are showing in the Trending Podcasts section.
104 | export const getTrendingPodcasts = query({
105 | handler: async (ctx) => {
106 | const podcast = await ctx.db.query("podcasts").collect();
107 |
108 | return podcast.sort((a, b) => b.views - a.views).slice(0, 8);
109 | },
110 | });
111 |
112 | // this query will get the podcast by the authorId.
113 | export const getPodcastByAuthorId = query({
114 | args: {
115 | authorId: v.string(),
116 | },
117 | handler: async (ctx, args) => {
118 | const podcasts = await ctx.db
119 | .query("podcasts")
120 | .filter((q) => q.eq(q.field("authorId"), args.authorId))
121 | .collect();
122 |
123 | const totalListeners = podcasts.reduce(
124 | (sum, podcast) => sum + podcast.views,
125 | 0
126 | );
127 |
128 | return { podcasts, listeners: totalListeners };
129 | },
130 | });
131 |
132 | // this query will get the podcast by the search query.
133 | export const getPodcastBySearch = query({
134 | args: {
135 | search: v.string(),
136 | },
137 | handler: async (ctx, args) => {
138 | if (args.search === "") {
139 | return await ctx.db.query("podcasts").order("desc").collect();
140 | }
141 |
142 | const authorSearch = await ctx.db
143 | .query("podcasts")
144 | .withSearchIndex("search_author", (q) => q.search("author", args.search))
145 | .take(10);
146 |
147 | if (authorSearch.length > 0) {
148 | return authorSearch;
149 | }
150 |
151 | const titleSearch = await ctx.db
152 | .query("podcasts")
153 | .withSearchIndex("search_title", (q) =>
154 | q.search("podcastTitle", args.search)
155 | )
156 | .take(10);
157 |
158 | if (titleSearch.length > 0) {
159 | return titleSearch;
160 | }
161 |
162 | return await ctx.db
163 | .query("podcasts")
164 | .withSearchIndex("search_body", (q) =>
165 | q.search("podcastDescription" || "podcastTitle", args.search)
166 | )
167 | .take(10);
168 | },
169 | });
170 |
171 | // this mutation will update the views of the podcast.
172 | export const updatePodcastViews = mutation({
173 | args: {
174 | podcastId: v.id("podcasts"),
175 | },
176 | handler: async (ctx, args) => {
177 | const podcast = await ctx.db.get(args.podcastId);
178 |
179 | if (!podcast) {
180 | throw new ConvexError("Podcast not found");
181 | }
182 |
183 | return await ctx.db.patch(args.podcastId, {
184 | views: podcast.views + 1,
185 | });
186 | },
187 | });
188 |
189 | // this mutation will delete the podcast.
190 | export const deletePodcast = mutation({
191 | args: {
192 | podcastId: v.id("podcasts"),
193 | imageStorageId: v.id("_storage"),
194 | audioStorageId: v.id("_storage"),
195 | },
196 | handler: async (ctx, args) => {
197 | const podcast = await ctx.db.get(args.podcastId);
198 |
199 | if (!podcast) {
200 | throw new ConvexError("Podcast not found");
201 | }
202 |
203 | await ctx.storage.delete(args.imageStorageId);
204 | await ctx.storage.delete(args.audioStorageId);
205 | return await ctx.db.delete(args.podcastId);
206 | },
207 | });
208 |
--------------------------------------------------------------------------------
/public/icons/logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/components/ui/select.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as SelectPrimitive from "@radix-ui/react-select";
4 | import { Check, ChevronDown, ChevronUp } from "lucide-react";
5 | import * as React from "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 dark:border-slate-800 dark:bg-slate-950 dark:ring-offset-slate-950 dark:placeholder:text-slate-400 dark:focus:ring-slate-300",
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/GenerateThumbnail.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useUploadFiles } from "@xixixao/uploadstuff/react";
4 | import { useAction, useMutation } from "convex/react";
5 | import { Loader } from "lucide-react";
6 | import Image from "next/image";
7 | import { useRef, useState } from "react";
8 | import { v4 as uuidv4 } from "uuid";
9 |
10 | import { api } from "@/convex/_generated/api";
11 | import { cn } from "@/lib/utils";
12 | import { GenerateThumbnailProps } from "@/types";
13 |
14 | import { Button } from "./ui/button";
15 | import { Input } from "./ui/input";
16 | import { Label } from "./ui/label";
17 | import { Textarea } from "./ui/textarea";
18 | import { useToast } from "./ui/use-toast";
19 |
20 | const GenerateThumbnail = ({
21 | setImage,
22 | setImageStorageId,
23 | image,
24 | imagePrompt,
25 | setImagePrompt,
26 | }: GenerateThumbnailProps) => {
27 | const [isImageLoading, setIsImageLoading] = useState(false);
28 | const [isAiThumbnail, setIsAiThumbnail] = useState(false);
29 | const imageRef = useRef(null);
30 | const handleGenerateThumbnail = useAction(api.openai.generateThumbnailAction);
31 | const generateUploadUrl = useMutation(api.files.generateUploadUrl);
32 | const getImageUrl = useMutation(api.podcasts.getUrl);
33 | const { startUpload } = useUploadFiles(generateUploadUrl);
34 | const { toast } = useToast();
35 |
36 | const handleImage = async (blob: Blob, fileName: string) => {
37 | setImage("");
38 | try {
39 | const file = new File([blob], fileName, {
40 | type: "image/png",
41 | });
42 | const uploaded = await startUpload([file]);
43 | const storageId = (uploaded[0].response as any).storageId;
44 | setImageStorageId(storageId);
45 | const imageUrl = await getImageUrl({ storageId });
46 | setImage(imageUrl!);
47 | toast({
48 | title: "Thumbnail generated successfully",
49 | });
50 | } catch (error) {
51 | console.error("Error handling image", error);
52 | toast({
53 | title: "Error generating thumbnail",
54 | variant: "destructive",
55 | });
56 | }
57 | };
58 |
59 | const generateImage = async () => {
60 | try {
61 | setIsImageLoading(true);
62 | const response = await handleGenerateThumbnail({ prompt: imagePrompt });
63 | const blob = new Blob([response], { type: "image/png" });
64 | await handleImage(blob, `thumbnail-${uuidv4()}`);
65 | setIsImageLoading(false);
66 | } catch (error) {
67 | console.error("Error generating thumbnail", error);
68 | toast({
69 | title: "Error generating thumbnail",
70 | variant: "destructive",
71 | });
72 | setIsImageLoading(false);
73 | }
74 | };
75 |
76 | const uploadImage = async (e: React.ChangeEvent) => {
77 | e.preventDefault();
78 | try {
79 | setIsImageLoading(true);
80 | const files = e.target.files;
81 | if (!files) return;
82 | const file = files[0];
83 | const blob = await file.arrayBuffer().then((ab) => new Blob([ab]));
84 | await handleImage(blob, file.name);
85 | setIsImageLoading(false);
86 | } catch (error) {
87 | console.error("Error uploading image", error);
88 | toast({
89 | title: "Error generating thumbnail",
90 | variant: "destructive",
91 | });
92 | setIsImageLoading(false);
93 | }
94 | };
95 |
96 | return (
97 | <>
98 |
99 |
109 |
119 |
120 | {isAiThumbnail ? (
121 |
122 |
123 |
126 |
133 |
134 |
148 |
149 |
150 | ) : (
151 | {
154 | imageRef?.current?.click();
155 | }}
156 | >
157 |
uploadImage(e)}
163 | />
164 | {!isImageLoading ? (
165 |
171 | ) : (
172 |
173 | Uploading...
174 |
175 | )}
176 |
177 |
178 | Click to upload{" "}
179 |
180 |
181 | SVG, PNG, JPG or GIF (max. 1080x1080px)
182 |
183 |
184 |
185 | )}
186 | {image && (
187 |
188 |
195 |
196 | )}
197 | >
198 | );
199 | };
200 |
201 | export default GenerateThumbnail;
202 |
--------------------------------------------------------------------------------
/app/(root)/create-podcast/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { zodResolver } from "@hookform/resolvers/zod";
4 | import { useMutation } from "convex/react";
5 | import { Loader } from "lucide-react";
6 | import { useRouter } from "next/navigation";
7 | import { useState } from "react";
8 | import { useForm } from "react-hook-form";
9 | import { z } from "zod";
10 |
11 | import GeneratePodcast from "@/components/GeneratePodcast";
12 | import GenerateThumbnail from "@/components/GenerateThumbnail";
13 | import { Button } from "@/components/ui/button";
14 | import {
15 | Form,
16 | FormControl,
17 | FormField,
18 | FormItem,
19 | FormLabel,
20 | FormMessage,
21 | } from "@/components/ui/form";
22 | import { Input } from "@/components/ui/input";
23 | import { Label } from "@/components/ui/label";
24 | import {
25 | Select,
26 | SelectContent,
27 | SelectItem,
28 | SelectTrigger,
29 | SelectValue,
30 | } from "@/components/ui/select";
31 | import { Textarea } from "@/components/ui/textarea";
32 | import { useToast } from "@/components/ui/use-toast";
33 | import { api } from "@/convex/_generated/api";
34 | import { Id } from "@/convex/_generated/dataModel";
35 | import { cn } from "@/lib/utils";
36 | import { VoiceType } from "@/types";
37 |
38 | const formSchema = z.object({
39 | podcastTitle: z.string().min(1, "Podcast Title is required"),
40 | podcastDescription: z.string().min(1, "Podcast Description is required"),
41 | });
42 |
43 | const voiceCategories = ["alloy", "shimmer", "nova", "echo", "fable", "onyx"];
44 |
45 | const CreatePodcast = () => {
46 | const router = useRouter();
47 | const { toast } = useToast();
48 | const [audioUrl, setAudioUrl] = useState("");
49 | const [audioDuration, setAudioDuration] = useState(0);
50 | const [imageStorageId, setImageStorageId] = useState | null>(
51 | null
52 | );
53 | const [imagePrompt, setImagePrompt] = useState("");
54 | const [audioStorageId, setAudioStorageId] = useState | null>(
55 | null
56 | );
57 | const [voicePrompt, setVoicePrompt] = useState("");
58 | const [isSubmitting, setIsSubmitting] = useState(false);
59 | const [imageUrl, setImageUrl] = useState("");
60 | const createPodcast = useMutation(api.podcasts.createPodcast);
61 | const [voiceType, setVoiceType] = useState();
62 | console.log("voiceType", voiceType);
63 |
64 | const form = useForm>({
65 | resolver: zodResolver(formSchema),
66 | defaultValues: {
67 | podcastTitle: "",
68 | podcastDescription: "",
69 | },
70 | });
71 |
72 | const handleCreatePodcast = async (data: z.infer) => {
73 | try {
74 | setIsSubmitting(true);
75 | if (!audioUrl || !imageUrl || !voiceType) {
76 | toast({
77 | title: "Please generate audio and image",
78 | });
79 | setIsSubmitting(false);
80 | return;
81 | }
82 | const podcast = await createPodcast({
83 | audioStorageId: audioStorageId as Id<"_storage">,
84 | podcastTitle: data.podcastTitle,
85 | podcastDescription: data.podcastDescription,
86 | audioUrl,
87 | imageUrl,
88 | imageStorageId: imageStorageId as Id<"_storage">,
89 | voiceType: voiceType as VoiceType,
90 | voicePrompt,
91 | imagePrompt,
92 | views: 0,
93 | audioDuration,
94 | });
95 | toast({
96 | title: "Podcast created successfully",
97 | });
98 | setIsSubmitting(false);
99 | router.push(`/podcast/${podcast}`);
100 | } catch (error) {
101 | console.error("Error creating podcast", error);
102 | toast({
103 | title: "Error creating podcast",
104 | variant: "destructive",
105 | });
106 | setIsSubmitting(false);
107 | }
108 | };
109 |
110 | return (
111 |
112 | Create a Podcast
113 |
233 |
234 |
235 | );
236 | };
237 |
238 | export default CreatePodcast;
239 |
--------------------------------------------------------------------------------
/public/icons/auth-logo.svg:
--------------------------------------------------------------------------------
1 |
28 |
--------------------------------------------------------------------------------