├── .env.copy ├── .eslintrc.json ├── .gitignore ├── README.md ├── app ├── categories │ └── [slug] │ │ ├── layout.tsx │ │ └── page.tsx ├── channels │ └── [slug] │ │ └── page.tsx ├── dashboard │ └── page.tsx ├── favicon.ico ├── favicon.png ├── feed │ └── page.tsx ├── forgot-password │ └── page.tsx ├── globals.css ├── layout.tsx ├── login │ └── page.tsx ├── page.tsx ├── reset-password │ └── page.tsx ├── signup │ └── page.tsx ├── verify │ └── page.tsx └── watch │ └── [id] │ └── page.tsx ├── bun.lockb ├── components ├── Footer.tsx ├── GitHubLink.tsx ├── Loader.tsx ├── SideBar.tsx ├── TimeAgo.tsx ├── TopNav.tsx ├── tailwind-indicator.tsx ├── theme-provider.tsx └── theme-toggle.tsx ├── cosmic ├── blocks │ ├── comments │ │ ├── CommentForm.tsx │ │ ├── Comments.tsx │ │ └── actions.ts │ ├── user-management │ │ ├── AuthButtons.tsx │ │ ├── AuthContext.tsx │ │ ├── AuthForm.tsx │ │ ├── DashboardClient.tsx │ │ ├── ForgotPasswordForm.tsx │ │ ├── LoginClient.tsx │ │ ├── ResetPasswordForm.tsx │ │ ├── SignUpClient.tsx │ │ ├── UserProfileForm.tsx │ │ ├── VerifyClient.tsx │ │ └── actions.ts │ └── videos │ │ ├── CategoriesList.tsx │ │ ├── CategoryPill.tsx │ │ ├── ChannelPill.tsx │ │ ├── ChannelsList.tsx │ │ ├── FollowButton.tsx │ │ ├── PlayArea.tsx │ │ ├── SingleChannel.tsx │ │ ├── SingleVideo.tsx │ │ ├── VideoCard.tsx │ │ ├── VideoList.tsx │ │ └── followActions.ts ├── client.ts ├── elements │ ├── Button.tsx │ ├── Input.tsx │ ├── Label.tsx │ └── TextArea.tsx └── utils.ts ├── helpers └── timeAgo.ts ├── next.config.mjs ├── package.json ├── postcss.config.mjs ├── tailwind.config.ts └── tsconfig.json /.env.copy: -------------------------------------------------------------------------------- 1 | COSMIC_BUCKET_SLUG= 2 | COSMIC_READ_KEY= 3 | COSMIC_WRITE_KEY= 4 | 5 | RESEND_API_KEY= 6 | SUPPORT_EMAIL= 7 | NEXT_PUBLIC_APP_URL= 8 | NEXT_PUBLIC_APP_NAME= -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cosmic Podcast Network 2 | 3 | 4 | 5 | 6 | 7 | [[View the demo](https://cosmic-podcast-network.vercel.app)] 8 | 9 | [[Install the template](https://www.cosmicjs.com/templates/podcast-network)] 10 | 11 | A podcast network website powered by the [Cosmic CMS](https://www.cosmicjs.com/) and Next.js. NOTE: uses a canary version of the [Cosmic JavaScript SDK](https://www.npmjs.com/package/@cosmicjs/sdk) that includes experimental features including [media data fetching](https://github.com/cosmicjs/cosmic-sdk-js/pull/38) and [props graph syntax](https://github.com/cosmicjs/cosmic-sdk-js/pull/37). 12 | 13 | ## Features 14 | 15 | ✨ NEW: Now includes account creation and login using the [User Management Block](https://blocks.cosmicjs.com/blocks/user-management) 16 | 17 | 🔥 Performance optimized 18 | 19 | 🪄 Partial prerendering 20 | 21 | 📱 Mobile ready 22 | 23 | 🌓 Dark mode 24 | 25 | performance 26 | 27 | ## Getting Started 28 | 29 | First, clone this repo. 30 | 31 | ```bash 32 | git clone https://github.com/cosmicjs/cosmic-podcast-network 33 | cd cosmic-podcast-network 34 | ``` 35 | 36 | Then install packages. 37 | 38 | ```bash 39 | npm i 40 | # or 41 | yarn 42 | # or 43 | pnpm 44 | # or 45 | bun i 46 | ``` 47 | 48 | ## Install the template and connect to Cosmic 49 | 50 | 1. Log in to the [Cosmic dashboard](https://app.cosmicjs.com/) and create a new Project and select the [Podcast Network template](https://www.cosmicjs.com/templates/podcast-network). 51 | 52 | 2. Then copy the `.env.copy` to a new `.env.local` file. And add your API keys found in the Cosmic dashboard at _Project / API keys_. 53 | 54 | ``` 55 | # .env.local 56 | COSMIC_BUCKET_SLUG=your_bucket_slug 57 | COSMIC_READ_KEY=your_bucket_read_key 58 | COSMIC_WRITE_KEY=your_bucket_write_key 59 | 60 | RESEND_API_KEY=change_to_your_resend_api_key 61 | NEXT_PUBLIC_APP_URL=change_to_your_app_url 62 | NEXT_PUBLIC_APP_NAME="Change to your app name" 63 | SUPPORT_EMAIL=change_to_your_support_email 64 | CONTACT_EMAIL=change_to_your_contact_email 65 | ``` 66 | 67 | ## Run the app 68 | 69 | Then run the development server: 70 | 71 | ```bash 72 | npm run dev 73 | # or 74 | yarn dev 75 | # or 76 | pnpm dev 77 | # or 78 | bun dev 79 | ``` 80 | 81 | Open [http://localhost:3000](http://localhost:3000) with your browser to see your message app. Add / delete your messages. See your messages in the Cosmic dashboard as well. 82 | 83 | ## Contributing 84 | 85 | Contributions welcome! 86 | -------------------------------------------------------------------------------- /app/categories/[slug]/layout.tsx: -------------------------------------------------------------------------------- 1 | // app/categories/[slug]/layout.tsx 2 | import { CategoriesList } from "@/cosmic/blocks/videos/CategoriesList"; 3 | import { cosmic } from "@/cosmic/client"; 4 | import { notFound } from "next/navigation"; 5 | import { Metadata } from "next"; 6 | 7 | export async function generateMetadata({ 8 | params, 9 | }: { 10 | params: Promise<{ slug: string }>; 11 | }): Promise { 12 | const resolvedParams = await params; 13 | const { object: category } = await cosmic.objects 14 | .findOne({ 15 | slug: resolvedParams.slug, 16 | type: "categories", 17 | }) 18 | .props("title") 19 | .depth(1); 20 | const { object: globalSettings } = await cosmic.objects 21 | .findOne({ 22 | type: "settings", 23 | slut: "settings", 24 | }) 25 | .props("metadata.site_title,metadata.description") 26 | .depth(1); 27 | return { 28 | title: `${globalSettings.metadata.site_title} | ${category.title}`, 29 | description: globalSettings.metadata.description, 30 | openGraph: { 31 | images: [ 32 | "https://imgix.cosmicjs.com/daec0820-4dd1-11ef-b1ea-f56c65dfade9-podcast-network-screenshot-3.png?w=2000&auto=forat,compression", 33 | ], 34 | }, 35 | }; 36 | } 37 | 38 | export default async function Layout({ 39 | children, 40 | params, 41 | }: { 42 | children: React.ReactNode; 43 | params: Promise<{ slug: string }>; 44 | }) { 45 | const resolvedParams = await params; 46 | 47 | try { 48 | return ( 49 |
50 | 57 | {children} 58 |
59 | ); 60 | } catch (e: any) { 61 | if (e.status === 404) return notFound(); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /app/categories/[slug]/page.tsx: -------------------------------------------------------------------------------- 1 | // app/categories/[slug]/page.tsx 2 | import { VideoList } from "@/cosmic/blocks/videos/VideoList"; 3 | import { cosmic } from "@/cosmic/client"; 4 | import { notFound } from "next/navigation"; 5 | import { Suspense } from "react"; 6 | import { Loader } from "@/components/Loader"; 7 | 8 | export const revalidate = 60; 9 | 10 | export async function generateStaticParams() { 11 | const { objects: categories } = await cosmic.objects.find({ 12 | type: "categories", 13 | }); 14 | return categories.map((category: { slug: string }) => ({ 15 | slug: category.slug, 16 | })); 17 | } 18 | 19 | export default async function CategoryPage({ 20 | params, 21 | }: { 22 | params: Promise<{ slug: string }>; 23 | }) { 24 | try { 25 | const resolvedParams = await params; 26 | const { object: category } = await cosmic.objects 27 | .findOne({ 28 | slug: resolvedParams.slug, 29 | type: "categories", 30 | }) 31 | .props("id,title") 32 | .depth(1); 33 | return ( 34 | <> 35 |

36 | {category.title} 37 |

38 | }> 39 | 45 | 46 | 47 | ); 48 | } catch (e: any) { 49 | if (e.status === 404) return notFound(); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /app/channels/[slug]/page.tsx: -------------------------------------------------------------------------------- 1 | // app/channels/[slug]/page.tsx 2 | import { SingleChannel } from "@/cosmic/blocks/videos/SingleChannel"; 3 | import { Suspense } from "react"; 4 | import { Loader } from "@/components/Loader"; 5 | import { cosmic } from "@/cosmic/client"; 6 | 7 | export const revalidate = 60; 8 | 9 | export async function generateStaticParams() { 10 | const { objects: channels } = await cosmic.objects.find({ 11 | type: "channels", 12 | }); 13 | return channels.map((channel: { slug: string }) => ({ 14 | slug: channel.slug, 15 | })); 16 | } 17 | 18 | export default async function SingleVideoPage({ 19 | params, 20 | }: { 21 | params: Promise<{ slug: string }>; 22 | }) { 23 | const resolvedParams = await params; 24 | 25 | return ( 26 | }> 27 | 28 | 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /app/dashboard/page.tsx: -------------------------------------------------------------------------------- 1 | // app/dashboard/page.tsx 2 | import { Suspense } from "react"; 3 | import DashboardClient from "@/cosmic/blocks/user-management/DashboardClient"; 4 | import { Loader } from "@/components/Loader"; 5 | 6 | export default function DashboardPage() { 7 | return ( 8 |
9 | }> 10 | 11 | 12 |
13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cosmicjs/cosmic-podcast-network/262338b068a63ab075283483fcdd1ba698307a4c/app/favicon.ico -------------------------------------------------------------------------------- /app/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cosmicjs/cosmic-podcast-network/262338b068a63ab075283483fcdd1ba698307a4c/app/favicon.png -------------------------------------------------------------------------------- /app/feed/page.tsx: -------------------------------------------------------------------------------- 1 | import { VideoList } from "@/cosmic/blocks/videos/VideoList"; 2 | import { Suspense } from "react"; 3 | import { Loader } from "@/components/Loader"; 4 | import { redirect } from "next/navigation"; 5 | import { 6 | getUserFromCookie, 7 | getUserData, 8 | } from "@/cosmic/blocks/user-management/actions"; 9 | 10 | export default async function FeedPage() { 11 | const user = await getUserFromCookie(); 12 | 13 | if (!user) { 14 | redirect("/login"); 15 | } 16 | 17 | const { data: userData, error } = await getUserData(user.id); 18 | 19 | if (error === "Account is not active") { 20 | redirect("/login?error=Your account is no longer active"); 21 | } 22 | 23 | if (!userData?.metadata?.channels?.length) { 24 | return ( 25 |
26 |

27 | My Feed 28 |

29 |

30 | You haven't followed any channels yet. Follow some channels to 31 | see their videos here! 32 |

33 |
34 | ); 35 | } 36 | 37 | return ( 38 |
39 |

40 | My Feed 41 |

42 | }> 43 | 54 | 55 |
56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /app/forgot-password/page.tsx: -------------------------------------------------------------------------------- 1 | // app/forgot-password/page.tsx 2 | import ForgotPasswordForm from "@/cosmic/blocks/user-management/ForgotPasswordForm"; 3 | import { forgotPassword } from "@/cosmic/blocks/user-management/actions"; 4 | 5 | export default function ForgotPasswordPage() { 6 | return ( 7 |
8 | 9 |
10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --foreground-rgb: 0, 0, 0; 7 | --background-start-rgb: 214, 219, 220; 8 | --background-end-rgb: 255, 255, 255; 9 | } 10 | 11 | @media (prefers-color-scheme: dark) { 12 | :root { 13 | --foreground-rgb: 255, 255, 255; 14 | --background-start-rgb: 0, 0, 0; 15 | --background-end-rgb: 0, 0, 0; 16 | } 17 | } 18 | 19 | body { 20 | color: rgb(var(--foreground-rgb)); 21 | background: rgb(var(--background-end-rgb)); 22 | } -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Inter } from "next/font/google"; 2 | import "./globals.css"; 3 | import { TopNav } from "@/components/TopNav"; 4 | import { SideBar } from "@/components/SideBar"; 5 | import { TailwindIndicator } from "@/components/tailwind-indicator"; 6 | import { ThemeProvider } from "@/components/theme-provider"; 7 | import { cn } from "@/cosmic/utils"; 8 | import { Footer } from "@/components/Footer"; 9 | import { cosmic } from "@/cosmic/client"; 10 | import { AuthProvider } from "@/cosmic/blocks/user-management/AuthContext"; 11 | 12 | export const revalidate = 60; 13 | 14 | const inter = Inter({ subsets: ["latin"] }); 15 | 16 | export async function generateMetadata() { 17 | const { object: globalSettings } = await cosmic.objects 18 | .findOne({ 19 | type: "settings", 20 | slut: "settings", 21 | }) 22 | .props( 23 | `{ 24 | metadata { 25 | site_title 26 | description 27 | } 28 | }` 29 | ) 30 | .depth(1); 31 | return { 32 | title: globalSettings.metadata.site_title, 33 | description: globalSettings.metadata.description, 34 | openGraph: { 35 | images: [ 36 | "https://imgix.cosmicjs.com/daec0820-4dd1-11ef-b1ea-f56c65dfade9-podcast-network-screenshot-3.png?w=2000&auto=forat,compression", 37 | ], 38 | }, 39 | }; 40 | } 41 | 42 | export default function RootLayout({ 43 | children, 44 | params, 45 | }: { 46 | children: React.ReactNode; 47 | params?: any; 48 | }) { 49 | return ( 50 | 51 | 58 | 59 | 60 | 61 | 62 |
{children}
63 | 64 |