AI prompts
base on <div align="center">
<br />
<a href="https://youtu.be/zfAb95tJvZQ" target="_blank">
<img src="https://github.com/adrianhajdin/jsm_podcastr/assets/151519281/f61a58c2-f144-41f7-8bc9-5ad14752ceb3" alt="Project Banner">
</a>
<br />
<div>
<img src="https://img.shields.io/badge/-Typescript-black?style=for-the-badge&logoColor=white&logo=typescript&color=3178C6" alt="typescript" />
<img src="https://img.shields.io/badge/-Next_._JS-black?style=for-the-badge&logoColor=white&logo=nextdotjs&color=000000" alt="nextdotjs" />
<img src="https://img.shields.io/badge/-Tailwind_CSS-black?style=for-the-badge&logoColor=white&logo=tailwindcss&color=06B6D4" alt="tailwindcss" />
<img src="https://img.shields.io/badge/-OpenAI-black?style=for-the-badge&logoColor=white&logo=openai&color=412991" alt="openai" />
</div>
<h3 align="center">AI Podcast Platform</h3>
<div align="center">
Build this project step by step with our detailed tutorial on <a href="https://www.youtube.com/@javascriptmastery/videos" target="_blank"><b>JavaScript Mastery</b></a> YouTube. Join the JSM family!
</div>
</div>
## π <a name="table">Table of Contents</a>
1. π€ [Introduction](#introduction)
2. βοΈ [Tech Stack](#tech-stack)
3. π [Features](#features)
4. π€Έ [Quick Start](#quick-start)
5. πΈοΈ [Snippets (Code to Copy)](#snippets)
6. π [Assets](#links)
7. π [More](#more)
## π¨ Tutorial
This repository contains the code corresponding to an in-depth tutorial available on our YouTube channel, <a href="https://www.youtube.com/@javascriptmastery/videos" target="_blank"><b>JavaScript Mastery</b></a>.
If you prefer visual learning, this is the perfect resource for you. Follow our tutorial to learn how to build projects like these step-by-step in a beginner-friendly manner!
<a href="https://youtu.be/zfAb95tJvZQ" target="_blank"><img src="https://github.com/sujatagunale/EasyRead/assets/151519281/1736fca5-a031-4854-8c09-bc110e3bc16d" /></a>
## <a name="introduction">π€ Introduction</a>
A cutting-edge AI SaaS platform that enables users to create, discover, and enjoy podcasts with advanced features like text-to-audio conversion with multi-voice AI, podcast thumbnail Image generation and seamless playback.
If you're getting started and need assistance or face any bugs, join our active Discord community with over **34k+** members. It's a place where people help each other out.
<a href="https://discord.com/invite/n6EdbFJ" target="_blank"><img src="https://github.com/sujatagunale/EasyRead/assets/151519281/618f4872-1e10-42da-8213-1d69e486d02e" /></a>
## <a name="tech-stack">βοΈ Tech Stack</a>
- Next.js
- TypeScript
- Convex
- OpenAI
- Clerk
- ShadCN
- Tailwind CSS
## <a name="features">π Features</a>
π **Robust Authentication**: Secure and reliable user login and registration system.
π **Modern Home Page**: Showcases trending podcasts with a sticky podcast player for continuous listening.
π **Discover Podcasts Page**: Dedicated page for users to explore new and popular podcasts.
π **Fully Functional Search**: Allows users to find podcasts easily using various search criteria.
π **Create Podcast Page**: Enables podcast creation with text-to-audio conversion, AI image generation, and previews.
π **Multi Voice AI Functionality**: Supports multiple AI-generated voices for dynamic podcast creation.
π **Profile Page**: View all created podcasts with options to delete them.
π **Podcast Details Page**: Displays detailed information about each podcast, including creator details, number of listeners, and transcript.
π **Podcast Player**: Features backward/forward controls, as well as mute/unmute functionality for a seamless listening experience.
π **Responsive Design**: Fully functional and visually appealing across all devices and screen sizes.
and many more, including code architecture and reusability
## <a name="quick-start">π€Έ Quick Start</a>
Follow these steps to set up the project locally on your machine.
**Prerequisites**
Make sure you have the following installed on your machine:
- [Git](https://git-scm.com/)
- [Node.js](https://nodejs.org/en)
- [npm](https://www.npmjs.com/) (Node Package Manager)
**Cloning the Repository**
```bash
git clone https://github.com/adrianhajdin/jsm_podcastr.git
cd jsm_podcastr
```
**Installation**
Install the project dependencies using npm:
```bash
npm install
```
**Set Up Environment Variables**
Create a new file named `.env` in the root of your project and add the following content:
```env
CONVEX_DEPLOYMENT=
NEXT_PUBLIC_CONVEX_URL=
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
CLERK_SECRET_KEY=
NEXT_PUBLIC_CLERK_SIGN_IN_URL='/sign-in'
NEXT_PUBLIC_CLERK_SIGN_UP_URL='/sign-up'
```
Replace the placeholder values with your actual Convex & Clerk credentials. You can obtain these credentials by signing up on the [Convex](https://www.convex.dev/) and [Clerk](https://clerk.com/) websites.
**Running the Project**
```bash
npm run dev
```
Open [http://localhost:3000](http://localhost:3000) in your browser to view the project.
## <a name="snippets">πΈοΈ Snippets</a>
<details>
<summary><code>app/globals.css</code></summary>
```css
@tailwind base;
@tailwind components;
@tailwind utilities;
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
background-color: #101114;
}
@layer utilities {
.input-class {
@apply text-16 placeholder:text-16 bg-black-1 rounded-[6px] placeholder:text-gray-1 border-none text-gray-1;
}
.podcast_grid {
@apply grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4;
}
.right_sidebar {
@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;
}
.left_sidebar {
@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;
}
.generate_thumbnail {
@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;
}
.image_div {
@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;
}
.carousel_box {
@apply relative flex h-fit aspect-square w-full flex-none cursor-pointer flex-col justify-end rounded-xl border-none;
}
.button_bold-16 {
@apply text-[16px] font-bold text-white-1 transition-all duration-500;
}
.flex-center {
@apply flex items-center justify-center;
}
.text-12 {
@apply text-[12px] leading-normal;
}
.text-14 {
@apply text-[14px] leading-normal;
}
.text-16 {
@apply text-[16px] leading-normal;
}
.text-18 {
@apply text-[18px] leading-normal;
}
.text-20 {
@apply text-[20px] leading-normal;
}
.text-24 {
@apply text-[24px] leading-normal;
}
.text-32 {
@apply text-[32px] leading-normal;
}
}
/* ===== custom classes ===== */
.custom-scrollbar::-webkit-scrollbar {
width: 3px;
height: 3px;
border-radius: 2px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: #15171c;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #222429;
border-radius: 50px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #555;
}
/* Hide scrollbar for Chrome, Safari and Opera */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
.no-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
.glassmorphism {
background: rgba(255, 255, 255, 0.25);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
.glassmorphism-auth {
background: rgba(6, 3, 3, 0.711);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
.glassmorphism-black {
background: rgba(18, 18, 18, 0.64);
backdrop-filter: blur(37px);
-webkit-backdrop-filter: blur(37px);
}
/* ======= clerk overrides ======== */
.cl-socialButtonsIconButton {
border: 2px solid #222429;
}
.cl-button {
color: white;
}
.cl-socialButtonsProviderIcon__github {
filter: invert(1);
}
.cl-internal-b3fm6y {
background: #f97535;
}
.cl-formButtonPrimary {
background: #f97535;
}
.cl-footerActionLink {
color: #f97535;
}
.cl-headerSubtitle {
color: #c5d0e6;
}
.cl-logoImage {
width: 10rem;
height: 3rem;
}
.cl-internal-4a7e9l {
color: white;
}
.cl-userButtonPopoverActionButtonIcon {
color: white;
}
.cl-internal-wkkub3 {
color: #f97535;
}
```
</details>
<details>
<summary><code>tailwind.config.ts</code></summary>
```typescript
import type { Config } from "tailwindcss";
const config = {
darkMode: ["class"],
content: [
"./pages/**/*.{ts,tsx}",
"./components/**/*.{ts,tsx}",
"./app/**/*.{ts,tsx}",
"./src/**/*.{ts,tsx}",
],
prefix: "",
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
colors: {
white: {
1: "#FFFFFF",
2: "rgba(255, 255, 255, 0.72)",
3: "rgba(255, 255, 255, 0.4)",
4: "rgba(255, 255, 255, 0.64)",
5: "rgba(255, 255, 255, 0.80)",
},
black: {
1: "#15171C",
2: "#222429",
3: "#101114",
4: "#252525",
5: "#2E3036",
6: "#24272C",
},
orange: {
1: "#F97535",
},
gray: {
1: "#71788B",
},
},
backgroundImage: {
"nav-focus":
"linear-gradient(270deg, rgba(255, 255, 255, 0.06) 0%, rgba(255, 255, 255, 0.00) 100%)",
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [require("tailwindcss-animate")],
} satisfies Config;
export default config;
```
</details>
<details>
<summary><code>constants/index.ts</code></summary>
```typescript
export const sidebarLinks = [
{
imgURL: "/icons/home.svg",
route: "/",
label: "Home",
},
{
imgURL: "/icons/discover.svg",
route: "/discover",
label: "Discover",
},
{
imgURL: "/icons/microphone.svg",
route: "/create-podcast",
label: "Create Podcast",
},
];
export const voiceDetails = [
{
id: 1,
name: "alloy",
},
{
id: 2,
name: "echo",
},
{
id: 3,
name: "fable",
},
{
id: 4,
name: "onyx",
},
{
id: 5,
name: "nova",
},
{
id: 6,
name: "shimmer",
},
];
export const podcastData = [
{
id: 1,
title: "The Joe Rogan Experience",
description: "A long form, in-depth conversation",
imgURL:
"https://lovely-flamingo-139.convex.cloud/api/storage/3106b884-548d-4ba0-a179-785901f69806",
},
{
id: 2,
title: "The Futur",
description: "This is how the news should sound",
imgURL:
"https://lovely-flamingo-139.convex.cloud/api/storage/16fbf9bd-d800-42bc-ac95-d5a586447bf6",
},
{
id: 3,
title: "Waveform",
description: "Join Michelle Obama in conversation",
imgURL:
"https://lovely-flamingo-139.convex.cloud/api/storage/60f0c1d9-f2ac-4a96-9178-f01d78fa3733",
},
{
id: 4,
title: "The Tech Talks Daily Podcast",
description: "This is how the news should sound",
imgURL:
"https://lovely-flamingo-139.convex.cloud/api/storage/5ba7ed1b-88b4-4c32-8d71-270f1c502445",
},
{
id: 5,
title: "GaryVee Audio Experience",
description: "A long form, in-depth conversation",
imgURL:
"https://lovely-flamingo-139.convex.cloud/api/storage/ca7cb1a6-4919-4b2c-a73e-279a79ac6d23",
},
{
id: 6,
title: "Syntax ",
description: "Join Michelle Obama in conversation",
imgURL:
"https://lovely-flamingo-139.convex.cloud/api/storage/b8ea40c7-aafb-401a-9129-73c515a73ab5",
},
{
id: 7,
title: "IMPAULSIVE",
description: "A long form, in-depth conversation",
imgURL:
"https://lovely-flamingo-139.convex.cloud/api/storage/8a55d662-fe3f-4bcf-b78b-3b2f3d3def5c",
},
{
id: 8,
title: "Ted Tech",
description: "This is how the news should sound",
imgURL:
"https://lovely-flamingo-139.convex.cloud/api/storage/221ee4bd-435f-42c3-8e98-4a001e0d806e",
},
];
```
</details>
<details>
<summary><code>convex/http.ts</code></summary>
```typescript
// ===== reference links =====
// https://www.convex.dev/templates (open the link and choose for clerk than you will get the github link mentioned below)
// https://github.dev/webdevcody/thumbnail-critique/blob/6637671d72513cfe13d00cb7a2990b23801eb327/convex/schema.ts
import type { WebhookEvent } from "@clerk/nextjs/server";
import { httpRouter } from "convex/server";
import { Webhook } from "svix";
import { internal } from "./_generated/api";
import { httpAction } from "./_generated/server";
const handleClerkWebhook = httpAction(async (ctx, request) => {
const event = await validateRequest(request);
if (!event) {
return new Response("Invalid request", { status: 400 });
}
switch (event.type) {
case "user.created":
await ctx.runMutation(internal.users.createUser, {
clerkId: event.data.id,
email: event.data.email_addresses[0].email_address,
imageUrl: event.data.image_url,
name: event.data.first_name as string,
});
break;
case "user.updated":
await ctx.runMutation(internal.users.updateUser, {
clerkId: event.data.id,
imageUrl: event.data.image_url,
email: event.data.email_addresses[0].email_address,
});
break;
case "user.deleted":
await ctx.runMutation(internal.users.deleteUser, {
clerkId: event.data.id as string,
});
break;
}
return new Response(null, {
status: 200,
});
});
const http = httpRouter();
http.route({
path: "/clerk",
method: "POST",
handler: handleClerkWebhook,
});
const validateRequest = async (
req: Request
): Promise<WebhookEvent | undefined> => {
// key note : add the webhook secret variable to the environment variables field in convex dashboard setting
const webhookSecret = process.env.CLERK_WEBHOOK_SECRET!;
if (!webhookSecret) {
throw new Error("CLERK_WEBHOOK_SECRET is not defined");
}
const payloadString = await req.text();
const headerPayload = req.headers;
const svixHeaders = {
"svix-id": headerPayload.get("svix-id")!,
"svix-timestamp": headerPayload.get("svix-timestamp")!,
"svix-signature": headerPayload.get("svix-signature")!,
};
const wh = new Webhook(webhookSecret);
const event = wh.verify(payloadString, svixHeaders);
return event as unknown as WebhookEvent;
};
export default http;
```
</details>
<details>
<summary><code>convex/users.ts</code></summary>
```typescript
import { ConvexError, v } from "convex/values";
import { internalMutation, query } from "./_generated/server";
export const getUserById = query({
args: { clerkId: v.string() },
handler: async (ctx, args) => {
const user = await ctx.db
.query("users")
.filter((q) => q.eq(q.field("clerkId"), args.clerkId))
.unique();
if (!user) {
throw new ConvexError("User not found");
}
return user;
},
});
// 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.
export const getTopUserByPodcastCount = query({
args: {},
handler: async (ctx, args) => {
const user = await ctx.db.query("users").collect();
const userData = await Promise.all(
user.map(async (u) => {
const podcasts = await ctx.db
.query("podcasts")
.filter((q) => q.eq(q.field("authorId"), u.clerkId))
.collect();
const sortedPodcasts = podcasts.sort((a, b) => b.views - a.views);
return {
...u,
totalPodcasts: podcasts.length,
podcast: sortedPodcasts.map((p) => ({
podcastTitle: p.podcastTitle,
pocastId: p._id,
})),
};
})
);
return userData.sort((a, b) => b.totalPodcasts - a.totalPodcasts);
},
});
export const createUser = internalMutation({
args: {
clerkId: v.string(),
email: v.string(),
imageUrl: v.string(),
name: v.string(),
},
handler: async (ctx, args) => {
await ctx.db.insert("users", {
clerkId: args.clerkId,
email: args.email,
imageUrl: args.imageUrl,
name: args.name,
});
},
});
export const updateUser = internalMutation({
args: {
clerkId: v.string(),
imageUrl: v.string(),
email: v.string(),
},
async handler(ctx, args) {
const user = await ctx.db
.query("users")
.filter((q) => q.eq(q.field("clerkId"), args.clerkId))
.unique();
if (!user) {
throw new ConvexError("User not found");
}
await ctx.db.patch(user._id, {
imageUrl: args.imageUrl,
email: args.email,
});
const podcast = await ctx.db
.query("podcasts")
.filter((q) => q.eq(q.field("authorId"), args.clerkId))
.collect();
await Promise.all(
podcast.map(async (p) => {
await ctx.db.patch(p._id, {
authorImageUrl: args.imageUrl,
});
})
);
},
});
export const deleteUser = internalMutation({
args: { clerkId: v.string() },
async handler(ctx, args) {
const user = await ctx.db
.query("users")
.filter((q) => q.eq(q.field("clerkId"), args.clerkId))
.unique();
if (!user) {
throw new ConvexError("User not found");
}
await ctx.db.delete(user._id);
},
});
```
</details>
<details>
<summary><code>types/index.ts</code></summary>
```typescript
/* eslint-disable no-unused-vars */
import { Dispatch, SetStateAction } from "react";
import { Id } from "@/convex/_generated/dataModel";
export interface EmptyStateProps {
title: string;
search?: boolean;
buttonText?: string;
buttonLink?: string;
}
export interface TopPodcastersProps {
_id: Id<"users">;
_creationTime: number;
email: string;
imageUrl: string;
clerkId: string;
name: string;
podcast: {
podcastTitle: string;
pocastId: Id<"podcasts">;
}[];
totalPodcasts: number;
}
export interface PodcastProps {
_id: Id<"podcasts">;
_creationTime: number;
audioStorageId: Id<"_storage"> | null;
user: Id<"users">;
podcastTitle: string;
podcastDescription: string;
audioUrl: string | null;
imageUrl: string | null;
imageStorageId: Id<"_storage"> | null;
author: string;
authorId: string;
authorImageUrl: string;
voicePrompt: string;
imagePrompt: string | null;
voiceType: string;
audioDuration: number;
views: number;
}
export interface ProfilePodcastProps {
podcasts: PodcastProps[];
listeners: number;
}
export type VoiceType =
| "alloy"
| "echo"
| "fable"
| "onyx"
| "nova"
| "shimmer";
export interface GeneratePodcastProps {
voiceType: VoiceType;
setAudio: Dispatch<SetStateAction<string>>;
audio: string;
setAudioStorageId: Dispatch<SetStateAction<Id<"_storage"> | null>>;
voicePrompt: string;
setVoicePrompt: Dispatch<SetStateAction<string>>;
setAudioDuration: Dispatch<SetStateAction<number>>;
}
export interface GenerateThumbnailProps {
setImage: Dispatch<SetStateAction<string>>;
setImageStorageId: Dispatch<SetStateAction<Id<"_storage"> | null>>;
image: string;
imagePrompt: string;
setImagePrompt: Dispatch<SetStateAction<string>>;
}
export interface LatestPodcastCardProps {
imgUrl: string;
title: string;
duration: string;
index: number;
audioUrl: string;
author: string;
views: number;
podcastId: Id<"podcasts">;
}
export interface PodcastDetailPlayerProps {
audioUrl: string;
podcastTitle: string;
author: string;
isOwner: boolean;
imageUrl: string;
podcastId: Id<"podcasts">;
imageStorageId: Id<"_storage">;
audioStorageId: Id<"_storage">;
authorImageUrl: string;
authorId: string;
}
export interface AudioProps {
title: string;
audioUrl: string;
author: string;
imageUrl: string;
podcastId: string;
}
export interface AudioContextType {
audio: AudioProps | undefined;
setAudio: React.Dispatch<React.SetStateAction<AudioProps | undefined>>;
}
export interface PodcastCardProps {
imgUrl: string;
title: string;
description: string;
podcastId: Id<"podcasts">;
}
export interface CarouselProps {
fansLikeDetail: TopPodcastersProps[];
}
export interface ProfileCardProps {
podcastData: ProfilePodcastProps;
imageUrl: string;
userFirstName: string;
}
export type UseDotButtonType = {
selectedIndex: number;
scrollSnaps: number[];
onDotButtonClick: (index: number) => void;
};
```
</details>
<details>
<summary><code>convex/podcasts.ts</code></summary>
```typescript
import { ConvexError, v } from "convex/values";
import { mutation, query } from "./_generated/server";
// create podcast mutation
export const createPodcast = mutation({
args: {
audioStorageId: v.union(v.id("_storage"), v.null()),
podcastTitle: v.string(),
podcastDescription: v.string(),
audioUrl: v.string(),
imageUrl: v.string(),
imageStorageId: v.union(v.id("_storage"), v.null()),
voicePrompt: v.string(),
imagePrompt: v.string(),
voiceType: v.string(),
views: v.number(),
audioDuration: v.number(),
},
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) {
throw new ConvexError("User not authenticated");
}
const user = await ctx.db
.query("users")
.filter((q) => q.eq(q.field("email"), identity.email))
.collect();
if (user.length === 0) {
throw new ConvexError("User not found");
}
return await ctx.db.insert("podcasts", {
audioStorageId: args.audioStorageId,
user: user[0]._id,
podcastTitle: args.podcastTitle,
podcastDescription: args.podcastDescription,
audioUrl: args.audioUrl,
imageUrl: args.imageUrl,
imageStorageId: args.imageStorageId,
author: user[0].name,
authorId: user[0].clerkId,
voicePrompt: args.voicePrompt,
imagePrompt: args.imagePrompt,
voiceType: args.voiceType,
views: args.views,
authorImageUrl: user[0].imageUrl,
audioDuration: args.audioDuration,
});
},
});
// this mutation is required to generate the url after uploading the file to the storage.
export const getUrl = mutation({
args: {
storageId: v.id("_storage"),
},
handler: async (ctx, args) => {
return await ctx.storage.getUrl(args.storageId);
},
});
// this query will get all the podcasts based on the voiceType of the podcast , which we are showing in the Similar Podcasts section.
export const getPodcastByVoiceType = query({
args: {
podcastId: v.id("podcasts"),
},
handler: async (ctx, args) => {
const podcast = await ctx.db.get(args.podcastId);
return await ctx.db
.query("podcasts")
.filter((q) =>
q.and(
q.eq(q.field("voiceType"), podcast?.voiceType),
q.neq(q.field("_id"), args.podcastId)
)
)
.collect();
},
});
// this query will get all the podcasts.
export const getAllPodcasts = query({
handler: async (ctx) => {
return await ctx.db.query("podcasts").order("desc").collect();
},
});
// this query will get the podcast by the podcastId.
export const getPodcastById = query({
args: {
podcastId: v.id("podcasts"),
},
handler: async (ctx, args) => {
return await ctx.db.get(args.podcastId);
},
});
// this query will get the podcasts based on the views of the podcast , which we are showing in the Trending Podcasts section.
export const getTrendingPodcasts = query({
handler: async (ctx) => {
const podcast = await ctx.db.query("podcasts").collect();
return podcast.sort((a, b) => b.views - a.views).slice(0, 8);
},
});
// this query will get the podcast by the authorId.
export const getPodcastByAuthorId = query({
args: {
authorId: v.string(),
},
handler: async (ctx, args) => {
const podcasts = await ctx.db
.query("podcasts")
.filter((q) => q.eq(q.field("authorId"), args.authorId))
.collect();
const totalListeners = podcasts.reduce(
(sum, podcast) => sum + podcast.views,
0
);
return { podcasts, listeners: totalListeners };
},
});
// this query will get the podcast by the search query.
export const getPodcastBySearch = query({
args: {
search: v.string(),
},
handler: async (ctx, args) => {
if (args.search === "") {
return await ctx.db.query("podcasts").order("desc").collect();
}
const authorSearch = await ctx.db
.query("podcasts")
.withSearchIndex("search_author", (q) => q.search("author", args.search))
.take(10);
if (authorSearch.length > 0) {
return authorSearch;
}
const titleSearch = await ctx.db
.query("podcasts")
.withSearchIndex("search_title", (q) =>
q.search("podcastTitle", args.search)
)
.take(10);
if (titleSearch.length > 0) {
return titleSearch;
}
return await ctx.db
.query("podcasts")
.withSearchIndex("search_body", (q) =>
q.search("podcastDescription" || "podcastTitle", args.search)
)
.take(10);
},
});
// this mutation will update the views of the podcast.
export const updatePodcastViews = mutation({
args: {
podcastId: v.id("podcasts"),
},
handler: async (ctx, args) => {
const podcast = await ctx.db.get(args.podcastId);
if (!podcast) {
throw new ConvexError("Podcast not found");
}
return await ctx.db.patch(args.podcastId, {
views: podcast.views + 1,
});
},
});
// this mutation will delete the podcast.
export const deletePodcast = mutation({
args: {
podcastId: v.id("podcasts"),
imageStorageId: v.id("_storage"),
audioStorageId: v.id("_storage"),
},
handler: async (ctx, args) => {
const podcast = await ctx.db.get(args.podcastId);
if (!podcast) {
throw new ConvexError("Podcast not found");
}
await ctx.storage.delete(args.imageStorageId);
await ctx.storage.delete(args.audioStorageId);
return await ctx.db.delete(args.podcastId);
},
});
```
</details>
<details>
<summary><code>components/PodcastDetailPlayer.ts</code></summary>
```typescript
"use client";
import { useMutation } from "convex/react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { api } from "@/convex/_generated/api";
import { useAudio } from "@/providers/AudioProvider";
import { PodcastDetailPlayerProps } from "@/types";
import LoaderSpinner from "./Loader";
import { Button } from "./ui/button";
import { useToast } from "./ui/use-toast";
const PodcastDetailPlayer = ({
audioUrl,
podcastTitle,
author,
imageUrl,
podcastId,
imageStorageId,
audioStorageId,
isOwner,
authorImageUrl,
authorId,
}: PodcastDetailPlayerProps) => {
const router = useRouter();
const { setAudio } = useAudio();
const { toast } = useToast();
const [isDeleting, setIsDeleting] = useState(false);
const deletePodcast = useMutation(api.podcasts.deletePodcast);
const handleDelete = async () => {
try {
await deletePodcast({ podcastId, imageStorageId, audioStorageId });
toast({
title: "Podcast deleted",
});
router.push("/");
} catch (error) {
console.error("Error deleting podcast", error);
toast({
title: "Error deleting podcast",
variant: "destructive",
});
}
};
const handlePlay = () => {
setAudio({
title: podcastTitle,
audioUrl,
imageUrl,
author,
podcastId,
});
};
if (!imageUrl || !authorImageUrl) return <LoaderSpinner />;
return (
<div className="mt-6 flex w-full justify-between max-md:justify-center">
<div className="flex flex-col gap-8 max-md:items-center md:flex-row">
<Image
src={imageUrl}
width={250}
height={250}
alt="Podcast image"
className="aspect-square rounded-lg"
/>
<div className="flex w-full flex-col gap-5 max-md:items-center md:gap-9">
<article className="flex flex-col gap-2 max-md:items-center">
<h1 className="text-32 font-extrabold tracking-[-0.32px] text-white-1">
{podcastTitle}
</h1>
<figure
className="flex cursor-pointer items-center gap-2"
onClick={() => {
router.push(`/profile/${authorId}`);
}}
>
<Image
src={authorImageUrl}
width={30}
height={30}
alt="Caster icon"
className="size-[30px] rounded-full object-cover"
/>
<h2 className="text-16 font-normal text-white-3">{author}</h2>
</figure>
</article>
<Button
onClick={handlePlay}
className="text-16 w-full max-w-[250px] bg-orange-1 font-extrabold text-white-1"
>
<Image
src="/icons/Play.svg"
width={20}
height={20}
alt="random play"
/>{" "}
Play podcast
</Button>
</div>
</div>
{isOwner && (
<div className="relative mt-2">
<Image
src="/icons/three-dots.svg"
width={20}
height={30}
alt="Three dots icon"
className="cursor-pointer"
onClick={() => setIsDeleting((prev) => !prev)}
/>
{isDeleting && (
<div
className="absolute -left-32 -top-2 z-10 flex w-32 cursor-pointer justify-center gap-2 rounded-md bg-black-6 py-1.5 hover:bg-black-2"
onClick={handleDelete}
>
<Image
src="/icons/delete.svg"
width={16}
height={16}
alt="Delete icon"
/>
<h2 className="text-16 font-normal text-white-1">Delete</h2>
</div>
)}
</div>
)}
</div>
);
};
export default PodcastDetailPlayer;
```
</details>
<details>
<summary><code>components/PodcastPlayer.ts</code></summary>
```typescript
"use client";
import Image from "next/image";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { formatTime } from "@/lib/formatTime";
import { cn } from "@/lib/utils";
import { useAudio } from "@/providers/AudioProvider";
import { Progress } from "./ui/progress";
const PodcastPlayer = () => {
const audioRef = useRef<HTMLAudioElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [duration, setDuration] = useState(0);
const [isMuted, setIsMuted] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const { audio } = useAudio();
const togglePlayPause = () => {
if (audioRef.current?.paused) {
audioRef.current?.play();
setIsPlaying(true);
} else {
audioRef.current?.pause();
setIsPlaying(false);
}
};
const toggleMute = () => {
if (audioRef.current) {
audioRef.current.muted = !isMuted;
setIsMuted((prev) => !prev);
}
};
const forward = () => {
if (
audioRef.current &&
audioRef.current.currentTime &&
audioRef.current.duration &&
audioRef.current.currentTime + 5 < audioRef.current.duration
) {
audioRef.current.currentTime += 5;
}
};
const rewind = () => {
if (audioRef.current && audioRef.current.currentTime - 5 > 0) {
audioRef.current.currentTime -= 5;
} else if (audioRef.current) {
audioRef.current.currentTime = 0;
}
};
useEffect(() => {
const updateCurrentTime = () => {
if (audioRef.current) {
setCurrentTime(audioRef.current.currentTime);
}
};
const audioElement = audioRef.current;
if (audioElement) {
audioElement.addEventListener("timeupdate", updateCurrentTime);
return () => {
audioElement.removeEventListener("timeupdate", updateCurrentTime);
};
}
}, []);
useEffect(() => {
const audioElement = audioRef.current;
if (audio?.audioUrl) {
if (audioElement) {
audioElement.play().then(() => {
setIsPlaying(true);
});
}
} else {
audioElement?.pause();
setIsPlaying(true);
}
}, [audio]);
const handleLoadedMetadata = () => {
if (audioRef.current) {
setDuration(audioRef.current.duration);
}
};
const handleAudioEnded = () => {
setIsPlaying(false);
};
return (
<div
className={cn("sticky bottom-0 left-0 flex size-full flex-col", {
hidden: !audio?.audioUrl || audio?.audioUrl === "",
})}
>
{/* change the color for indicator inside the Progress component in ui folder */}
<Progress
value={(currentTime / duration) * 100}
className="w-full"
max={duration}
/>
<section className="glassmorphism-black flex h-[112px] w-full items-center justify-between px-4 max-md:justify-center max-md:gap-5 md:px-12">
<audio
ref={audioRef}
src={audio?.audioUrl}
className="hidden"
onLoadedMetadata={handleLoadedMetadata}
onEnded={handleAudioEnded}
/>
<div className="flex items-center gap-4 max-md:hidden">
<Link href={`/podcast/${audio?.podcastId}`}>
<Image
src={audio?.imageUrl! || "/images/player1.png"}
width={64}
height={64}
alt="player1"
className="aspect-square rounded-xl"
/>
</Link>
<div className="flex w-[160px] flex-col">
<h2 className="text-14 truncate font-semibold text-white-1">
{audio?.title}
</h2>
<p className="text-12 font-normal text-white-2">{audio?.author}</p>
</div>
</div>
<div className="flex-center cursor-pointer gap-3 md:gap-6">
<div className="flex items-center gap-1.5">
<Image
src={"/icons/reverse.svg"}
width={24}
height={24}
alt="rewind"
onClick={rewind}
/>
<h2 className="text-12 font-bold text-white-4">-5</h2>
</div>
<Image
src={isPlaying ? "/icons/Pause.svg" : "/icons/Play.svg"}
width={30}
height={30}
alt="play"
onClick={togglePlayPause}
/>
<div className="flex items-center gap-1.5">
<h2 className="text-12 font-bold text-white-4">+5</h2>
<Image
src={"/icons/forward.svg"}
width={24}
height={24}
alt="forward"
onClick={forward}
/>
</div>
</div>
<div className="flex items-center gap-6">
<h2 className="text-16 font-normal text-white-2 max-md:hidden">
{formatTime(duration)}
</h2>
<div className="flex w-full gap-2">
<Image
src={isMuted ? "/icons/unmute.svg" : "/icons/mute.svg"}
width={24}
height={24}
alt="mute unmute"
onClick={toggleMute}
className="cursor-pointer"
/>
</div>
</div>
</section>
</div>
);
};
export default PodcastPlayer;
```
</details>
<details>
<summary><code>lib/formatTime.ts</code></summary>
```typescript
export const formatTime = (seconds: number) => {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
return `${minutes}:${remainingSeconds < 10 ? "0" : ""}${remainingSeconds}`;
};
```
</details>
<details>
<summary><code>lib/useDebounce.ts</code></summary>
```typescript
import { useEffect, useState } from "react";
export const useDebounce = <T>(value: T, delay = 500) => {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const timeout = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(timeout);
};
}, [value, delay]);
return debouncedValue;
};
```
</details>
<details>
<summary><code>(root)/profile/[profiled]/page.tsx</code></summary>
```typescript
"use client";
import { useQuery } from "convex/react";
import EmptyState from "@/components/EmptyState";
import LoaderSpinner from "@/components/Loader";
import PodcastCard from "@/components/PodcastCard";
import ProfileCard from "@/components/ProfileCard";
import { api } from "@/convex/_generated/api";
const ProfilePage = ({
params,
}: {
params: {
profileId: string;
};
}) => {
const user = useQuery(api.users.getUserById, {
clerkId: params.profileId,
});
const podcastsData = useQuery(api.podcasts.getPodcastByAuthorId, {
authorId: params.profileId,
});
if (!user || !podcastsData) return <LoaderSpinner />;
return (
<section className="mt-9 flex flex-col">
<h1 className="text-20 font-bold text-white-1 max-md:text-center">
Podcaster Profile
</h1>
<div className="mt-6 flex flex-col gap-6 max-md:items-center md:flex-row">
<ProfileCard
podcastData={podcastsData!}
imageUrl={user?.imageUrl!}
userFirstName={user?.name!}
/>
</div>
<section className="mt-9 flex flex-col gap-5">
<h1 className="text-20 font-bold text-white-1">All Podcasts</h1>
{podcastsData && podcastsData.podcasts.length > 0 ? (
<div className="podcast_grid">
{podcastsData?.podcasts
?.slice(0, 4)
.map((podcast) => (
<PodcastCard
key={podcast._id}
imgUrl={podcast.imageUrl!}
title={podcast.podcastTitle!}
description={podcast.podcastDescription}
podcastId={podcast._id}
/>
))}
</div>
) : (
<EmptyState
title="You have not created any podcasts yet"
buttonLink="/create-podcast"
/>
)}
</section>
</section>
);
};
export default ProfilePage;
```
</details>
<details>
<summary><code>componenets/ProfileCard.tsx</code></summary>
```typescript
"use client";
import Image from "next/image";
import { useEffect, useState } from "react";
import { useAudio } from "@/providers/AudioProvider";
import { PodcastProps, ProfileCardProps } from "@/types";
import LoaderSpinner from "./Loader";
import { Button } from "./ui/button";
const ProfileCard = ({
podcastData,
imageUrl,
userFirstName,
}: ProfileCardProps) => {
const { setAudio } = useAudio();
const [randomPodcast, setRandomPodcast] = useState<PodcastProps | null>(null);
const playRandomPodcast = () => {
const randomIndex = Math.floor(Math.random() * podcastData.podcasts.length);
setRandomPodcast(podcastData.podcasts[randomIndex]);
};
useEffect(() => {
if (randomPodcast) {
setAudio({
title: randomPodcast.podcastTitle,
audioUrl: randomPodcast.audioUrl || "",
imageUrl: randomPodcast.imageUrl || "",
author: randomPodcast.author,
podcastId: randomPodcast._id,
});
}
}, [randomPodcast, setAudio]);
if (!imageUrl) return <LoaderSpinner />;
return (
<div className="mt-6 flex flex-col gap-6 max-md:items-center md:flex-row">
<Image
src={imageUrl}
width={250}
height={250}
alt="Podcaster"
className="aspect-square rounded-lg"
/>
<div className="flex flex-col justify-center max-md:items-center">
<div className="flex flex-col gap-2.5">
<figure className="flex gap-2 max-md:justify-center">
<Image
src="/icons/verified.svg"
width={15}
height={15}
alt="verified"
/>
<h2 className="text-14 font-medium text-white-2">
Verified Creator
</h2>
</figure>
<h1 className="text-32 font-extrabold tracking-[-0.32px] text-white-1">
{userFirstName}
</h1>
</div>
<figure className="flex gap-3 py-6">
<Image
src="/icons/headphone.svg"
width={24}
height={24}
alt="headphones"
/>
<h2 className="text-16 font-semibold text-white-1">
{podcastData?.listeners}
<span className="font-normal text-white-2">monthly listeners</span>
</h2>
</figure>
{podcastData?.podcasts.length > 0 && (
<Button
onClick={playRandomPodcast}
className="text-16 bg-orange-1 font-extrabold text-white-1"
>
<Image
src="/icons/Play.svg"
width={20}
height={20}
alt="random play"
/>{" "}
Play a random podcast
</Button>
)}
</div>
</div>
);
};
export default ProfileCard;
```
</details>
## <a name="links">π Assets</a>
Public assets used in the project can be found [here](https://drive.google.com/file/d/18tLuq1QY1Wxr4sqnMony2LCLDcyYCWdG/view?usp=sharing)
## <a name="more">π More</a>
**Advance your skills with Next.js 14 Pro Course**
Enjoyed creating this project? Dive deeper into our PRO courses for a richer learning adventure. They're packed with detailed explanations, cool features, and exercises to boost your skills. Give it a go!
<a href="https://jsmastery.pro/next14" target="_blank">
<img src="https://github.com/sujatagunale/EasyRead/assets/151519281/557837ce-f612-4530-ab24-189e75133c71" alt="Project Banner">
</a>
<br />
<br />
**Accelerate your professional journey with the Expert Training program**
And if you're hungry for more than just a course and want to understand how we learn and tackle tech challenges, hop into our personalized masterclass. We cover best practices, different web skills, and offer mentorship to boost your confidence. Let's learn and grow together!
<a href="https://www.jsmastery.pro/masterclass" target="_blank">
<img src="https://github.com/sujatagunale/EasyRead/assets/151519281/fed352ad-f27b-400d-9b8f-c7fe628acb84" alt="Project Banner">
</a>
#
", Assign "at most 3 tags" to the expected json: {"id":"10595","tags":[]} "only from the tags list I provide: [{"id":77,"name":"3d"},{"id":89,"name":"agent"},{"id":17,"name":"ai"},{"id":54,"name":"algorithm"},{"id":24,"name":"api"},{"id":44,"name":"authentication"},{"id":3,"name":"aws"},{"id":27,"name":"backend"},{"id":60,"name":"benchmark"},{"id":72,"name":"best-practices"},{"id":39,"name":"bitcoin"},{"id":37,"name":"blockchain"},{"id":1,"name":"blog"},{"id":45,"name":"bundler"},{"id":58,"name":"cache"},{"id":21,"name":"chat"},{"id":49,"name":"cicd"},{"id":4,"name":"cli"},{"id":64,"name":"cloud-native"},{"id":48,"name":"cms"},{"id":61,"name":"compiler"},{"id":68,"name":"containerization"},{"id":92,"name":"crm"},{"id":34,"name":"data"},{"id":47,"name":"database"},{"id":8,"name":"declarative-gui "},{"id":9,"name":"deploy-tool"},{"id":53,"name":"desktop-app"},{"id":6,"name":"dev-exp-lib"},{"id":59,"name":"dev-tool"},{"id":13,"name":"ecommerce"},{"id":26,"name":"editor"},{"id":66,"name":"emulator"},{"id":62,"name":"filesystem"},{"id":80,"name":"finance"},{"id":15,"name":"firmware"},{"id":73,"name":"for-fun"},{"id":2,"name":"framework"},{"id":11,"name":"frontend"},{"id":22,"name":"game"},{"id":81,"name":"game-engine "},{"id":23,"name":"graphql"},{"id":84,"name":"gui"},{"id":91,"name":"http"},{"id":5,"name":"http-client"},{"id":51,"name":"iac"},{"id":30,"name":"ide"},{"id":78,"name":"iot"},{"id":40,"name":"json"},{"id":83,"name":"julian"},{"id":38,"name":"k8s"},{"id":31,"name":"language"},{"id":10,"name":"learning-resource"},{"id":33,"name":"lib"},{"id":41,"name":"linter"},{"id":28,"name":"lms"},{"id":16,"name":"logging"},{"id":76,"name":"low-code"},{"id":90,"name":"message-queue"},{"id":42,"name":"mobile-app"},{"id":18,"name":"monitoring"},{"id":36,"name":"networking"},{"id":7,"name":"node-version"},{"id":55,"name":"nosql"},{"id":57,"name":"observability"},{"id":46,"name":"orm"},{"id":52,"name":"os"},{"id":14,"name":"parser"},{"id":74,"name":"react"},{"id":82,"name":"real-time"},{"id":56,"name":"robot"},{"id":65,"name":"runtime"},{"id":32,"name":"sdk"},{"id":71,"name":"search"},{"id":63,"name":"secrets"},{"id":25,"name":"security"},{"id":85,"name":"server"},{"id":86,"name":"serverless"},{"id":70,"name":"storage"},{"id":75,"name":"system-design"},{"id":79,"name":"terminal"},{"id":29,"name":"testing"},{"id":12,"name":"ui"},{"id":50,"name":"ux"},{"id":88,"name":"video"},{"id":20,"name":"web-app"},{"id":35,"name":"web-server"},{"id":43,"name":"webassembly"},{"id":69,"name":"workflow"},{"id":87,"name":"yaml"}]" returns me the "expected json"