├── .eslintrc.json
├── app
├── favicon.ico
├── (auth)
│ ├── sign-in
│ │ └── [[...sign-in]]
│ │ │ └── page.tsx
│ ├── sign-up
│ │ └── [[...sign-up]]
│ │ │ └── page.tsx
│ ├── layout.tsx
│ └── _components
│ │ └── logo.tsx
├── api
│ ├── uploadthing
│ │ ├── route.ts
│ │ └── core.ts
│ └── webhooks
│ │ ├── livekit
│ │ └── route.ts
│ │ └── clerk
│ │ └── route.ts
├── (browse)
│ ├── [username]
│ │ ├── loading.tsx
│ │ ├── error.tsx
│ │ ├── not-found.tsx
│ │ ├── page.tsx
│ │ └── _components
│ │ │ └── actions.tsx
│ ├── (home)
│ │ ├── page.tsx
│ │ └── _components
│ │ │ ├── results.tsx
│ │ │ └── result-card.tsx
│ ├── _components
│ │ ├── navbar
│ │ │ ├── index.tsx
│ │ │ ├── logo.tsx
│ │ │ ├── actions.tsx
│ │ │ └── search.tsx
│ │ ├── container.tsx
│ │ └── sidebar
│ │ │ ├── index.tsx
│ │ │ ├── wrapper.tsx
│ │ │ ├── recommended.tsx
│ │ │ ├── following.tsx
│ │ │ ├── toggle.tsx
│ │ │ └── user-item.tsx
│ ├── layout.tsx
│ └── search
│ │ ├── page.tsx
│ │ └── _components
│ │ ├── results.tsx
│ │ └── result-card.tsx
├── (dashboard)
│ └── u
│ │ └── [username]
│ │ ├── (home)
│ │ ├── loading.tsx
│ │ └── page.tsx
│ │ ├── _components
│ │ ├── sidebar
│ │ │ ├── index.tsx
│ │ │ ├── wrapper.tsx
│ │ │ ├── toggle.tsx
│ │ │ ├── navigation.tsx
│ │ │ └── nav-item.tsx
│ │ ├── navbar
│ │ │ ├── index.tsx
│ │ │ ├── actions.tsx
│ │ │ └── logo.tsx
│ │ └── container.tsx
│ │ ├── chat
│ │ ├── loading.tsx
│ │ ├── page.tsx
│ │ └── _components
│ │ │ └── toggle-card.tsx
│ │ ├── keys
│ │ ├── _components
│ │ │ ├── url-card.tsx
│ │ │ ├── copy-button.tsx
│ │ │ ├── key-card.tsx
│ │ │ └── connect-modal.tsx
│ │ └── page.tsx
│ │ ├── layout.tsx
│ │ └── community
│ │ ├── page.tsx
│ │ └── _components
│ │ ├── unblock-button.tsx
│ │ ├── columns.tsx
│ │ └── data-table.tsx
├── error.tsx
├── not-found.tsx
├── layout.tsx
└── globals.css
├── postcss.config.js
├── next.config.js
├── lib
├── stream-service.ts
├── db.ts
├── uploadthing.ts
├── utils.ts
├── auth-service.ts
├── user-service.ts
├── feed-service.ts
├── recommended-service.ts
├── search-service.ts
├── block-service.ts
└── follow-service.ts
├── components
├── verified-mark.tsx
├── ui
│ ├── skeleton.tsx
│ ├── label.tsx
│ ├── textarea.tsx
│ ├── separator.tsx
│ ├── input.tsx
│ ├── slider.tsx
│ ├── switch.tsx
│ ├── tooltip.tsx
│ ├── avatar.tsx
│ ├── alert.tsx
│ ├── scroll-area.tsx
│ ├── button.tsx
│ ├── table.tsx
│ ├── dialog.tsx
│ └── select.tsx
├── theme-provider.tsx
├── live-badge.tsx
├── stream-player
│ ├── loading-video.tsx
│ ├── offline-video.tsx
│ ├── fullscreen-control.tsx
│ ├── chat-header.tsx
│ ├── chat-toggle.tsx
│ ├── chat-message.tsx
│ ├── variant-toggle.tsx
│ ├── chat-list.tsx
│ ├── volume-control.tsx
│ ├── about-card.tsx
│ ├── video.tsx
│ ├── chat-info.tsx
│ ├── community-item.tsx
│ ├── actions.tsx
│ ├── info-card.tsx
│ ├── bio-modal.tsx
│ ├── chat-community.tsx
│ ├── chat-form.tsx
│ ├── live-video.tsx
│ ├── header.tsx
│ ├── chat.tsx
│ ├── index.tsx
│ └── info-modal.tsx
├── hint.tsx
├── user-avatar.tsx
└── thumbnail.tsx
├── store
├── use-sidebar.ts
├── use-creator-sidebar.ts
└── use-chat-sidebar.ts
├── components.json
├── .gitignore
├── middleware.ts
├── public
├── vercel.svg
├── spooky.svg
└── next.svg
├── actions
├── user.ts
├── follow.ts
├── block.ts
├── stream.ts
├── token.ts
└── ingress.ts
├── tsconfig.json
├── hooks
└── use-viewer-token.ts
├── README.md
├── tailwind.config.ts
├── package.json
└── prisma
└── schema.prisma
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals"
3 | }
4 |
--------------------------------------------------------------------------------
/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yash-mewada/NexStream/HEAD/app/favicon.ico
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/app/(auth)/sign-in/[[...sign-in]]/page.tsx:
--------------------------------------------------------------------------------
1 | import { SignIn } from "@clerk/nextjs";
2 |
3 | export default function Page() {
4 | return ;
5 | }
6 |
--------------------------------------------------------------------------------
/app/(auth)/sign-up/[[...sign-up]]/page.tsx:
--------------------------------------------------------------------------------
1 | import { SignUp } from "@clerk/nextjs";
2 |
3 | export default function Page() {
4 | return ;
5 | }
6 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | images: {
4 | domains: ["utfs.io"],
5 | },
6 | };
7 |
8 | module.exports = nextConfig;
9 |
--------------------------------------------------------------------------------
/lib/stream-service.ts:
--------------------------------------------------------------------------------
1 | import { db } from "./db";
2 |
3 | export const getStreamByUserId = async (userId: string) => {
4 | const stream = await db.stream.findUnique({
5 | where: { userId },
6 | });
7 |
8 | return stream;
9 | };
10 |
--------------------------------------------------------------------------------
/app/api/uploadthing/route.ts:
--------------------------------------------------------------------------------
1 | import { createRouteHandler } from "uploadthing/next";
2 |
3 | import { ourFileRouter } from "./core";
4 |
5 | // Export routes for Next App Router
6 | export const { GET, POST } = createRouteHandler({
7 | router: ourFileRouter,
8 | });
9 |
--------------------------------------------------------------------------------
/lib/db.ts:
--------------------------------------------------------------------------------
1 | import { PrismaClient } from "@prisma/client";
2 | declare global {
3 | var prisma: PrismaClient | undefined;
4 | }
5 |
6 | export const db = globalThis.prisma || new PrismaClient();
7 |
8 | if (process.env.NODE_ENV !== "production") globalThis.prisma = db;
9 |
--------------------------------------------------------------------------------
/app/(browse)/[username]/loading.tsx:
--------------------------------------------------------------------------------
1 | import { StreamPlayerSkeleton } from "@/components/stream-player";
2 |
3 | const UserLoading = () => {
4 | return (
5 |
6 |
7 |
8 | );
9 | };
10 |
11 | export default UserLoading;
12 |
--------------------------------------------------------------------------------
/app/(dashboard)/u/[username]/(home)/loading.tsx:
--------------------------------------------------------------------------------
1 | import { StreamPlayerSkeleton } from "@/components/stream-player";
2 |
3 | const CreatorLoading = () => {
4 | return (
5 |
6 |
7 |
8 | );
9 | };
10 |
11 | export default CreatorLoading;
12 |
--------------------------------------------------------------------------------
/components/verified-mark.tsx:
--------------------------------------------------------------------------------
1 | import { Check } from "lucide-react";
2 |
3 | export const VerifiedMark = () => {
4 | return (
5 |
6 |
7 |
8 | );
9 | };
10 |
--------------------------------------------------------------------------------
/app/(dashboard)/u/[username]/_components/sidebar/index.tsx:
--------------------------------------------------------------------------------
1 | import { Navigation } from "./navigation";
2 | import { Toggle } from "./toggle";
3 | import { Wrapper } from "./wrapper";
4 |
5 | export const Sidebar = () => {
6 | return (
7 |
8 |
9 |
10 |
11 | );
12 | };
13 |
--------------------------------------------------------------------------------
/lib/uploadthing.ts:
--------------------------------------------------------------------------------
1 | import {
2 | generateUploadButton,
3 | generateUploadDropzone,
4 | } from "@uploadthing/react";
5 |
6 | import type { OurFileRouter } from "@/app/api/uploadthing/core";
7 |
8 | export const UploadButton = generateUploadButton();
9 | export const UploadDropzone = generateUploadDropzone();
10 |
--------------------------------------------------------------------------------
/components/ui/skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils"
2 |
3 | function Skeleton({
4 | className,
5 | ...props
6 | }: React.HTMLAttributes) {
7 | return (
8 |
12 | )
13 | }
14 |
15 | export { Skeleton }
16 |
--------------------------------------------------------------------------------
/app/(auth)/layout.tsx:
--------------------------------------------------------------------------------
1 | import { Logo } from "./_components/logo";
2 |
3 | const AuthLayout = ({ children }: { children: React.ReactNode }) => {
4 | return (
5 |
6 |
7 | {children}
8 |
9 | );
10 | };
11 |
12 | export default AuthLayout;
13 |
--------------------------------------------------------------------------------
/app/(browse)/(home)/page.tsx:
--------------------------------------------------------------------------------
1 | import { Suspense } from "react";
2 |
3 | import { Results, ResultsSkeleton } from "./_components/results";
4 |
5 | export default function Page() {
6 | return (
7 |
8 | }>
9 |
10 |
11 |
12 | );
13 | };
--------------------------------------------------------------------------------
/app/(dashboard)/u/[username]/_components/navbar/index.tsx:
--------------------------------------------------------------------------------
1 | import { Actions } from "./actions";
2 | import { Logo } from "./logo";
3 |
4 | export const Navbar = () => {
5 | return (
6 |
10 | );
11 | };
12 |
--------------------------------------------------------------------------------
/components/theme-provider.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import { ThemeProvider as NextThemesProvider } from "next-themes";
5 | import { type ThemeProviderProps } from "next-themes/dist/types";
6 |
7 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
8 | return {children};
9 | }
10 |
--------------------------------------------------------------------------------
/store/use-sidebar.ts:
--------------------------------------------------------------------------------
1 | import { create } from "zustand";
2 |
3 | interface SidebarStore {
4 | collapsed: boolean;
5 | onExpand: () => void;
6 | onCollapse: () => void;
7 | }
8 |
9 | export const useSidebar = create((set) => ({
10 | collapsed: false,
11 | onExpand: () => set(() => ({ collapsed: false })),
12 | onCollapse: () => set(() => ({ collapsed: true })),
13 | }));
14 |
--------------------------------------------------------------------------------
/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": "neutral",
10 | "cssVariables": true
11 | },
12 | "aliases": {
13 | "components": "@/components",
14 | "utils": "@/lib/utils"
15 | }
16 | }
--------------------------------------------------------------------------------
/store/use-creator-sidebar.ts:
--------------------------------------------------------------------------------
1 | import { create } from "zustand";
2 |
3 | interface CreatorSidebarStore {
4 | collapsed: boolean;
5 | onExpand: () => void;
6 | onCollapse: () => void;
7 | }
8 |
9 | export const useCreatorSidebar = create((set) => ({
10 | collapsed: false,
11 | onExpand: () => set(() => ({ collapsed: false })),
12 | onCollapse: () => set(() => ({ collapsed: true })),
13 | }));
14 |
--------------------------------------------------------------------------------
/app/(browse)/_components/navbar/index.tsx:
--------------------------------------------------------------------------------
1 | import { Actions } from "./actions";
2 | import { Logo } from "./logo";
3 | import { Search } from "./search";
4 |
5 | export const Navbar = () => {
6 | return (
7 |
12 | );
13 | };
14 |
--------------------------------------------------------------------------------
/components/live-badge.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils";
2 |
3 | interface LiveBadgeProps {
4 | className?: string;
5 | }
6 |
7 | export const LiveBadge = ({ className }: LiveBadgeProps) => {
8 | return (
9 |
15 | Live
16 |
17 | );
18 | };
19 |
--------------------------------------------------------------------------------
/app/error.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { Button } from "@/components/ui/button";
3 | import Link from "next/link";
4 |
5 | const ErrorPage = () => {
6 | return (
7 |
8 |
Something went wrong
9 |
12 |
13 | );
14 | };
15 |
16 | export default ErrorPage;
17 |
--------------------------------------------------------------------------------
/components/stream-player/loading-video.tsx:
--------------------------------------------------------------------------------
1 | import { Loader } from "lucide-react";
2 |
3 | interface LoadingVideoProps {
4 | label: string;
5 | }
6 |
7 | export const LoadingVideo = ({ label }: LoadingVideoProps) => {
8 | return (
9 |
13 | );
14 | };
15 |
--------------------------------------------------------------------------------
/components/stream-player/offline-video.tsx:
--------------------------------------------------------------------------------
1 | import { WifiOff } from "lucide-react";
2 |
3 | interface OfflineVideoProps {
4 | username: string;
5 | }
6 |
7 | export const OfflineVideo = ({ username }: OfflineVideoProps) => {
8 | return (
9 |
10 |
11 |
{username} is offline
12 |
13 | );
14 | };
15 |
--------------------------------------------------------------------------------
/app/(browse)/[username]/error.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { Button } from "@/components/ui/button";
3 | import Link from "next/link";
4 |
5 | const ErrorPage = () => {
6 | return (
7 |
8 |
Something went wrong
9 |
12 |
13 | );
14 | };
15 |
16 | export default ErrorPage;
17 |
--------------------------------------------------------------------------------
/app/(dashboard)/u/[username]/chat/loading.tsx:
--------------------------------------------------------------------------------
1 | import { Skeleton } from "@/components/ui/skeleton";
2 | import { ToggleCardSkeleton } from "./_components/toggle-card";
3 |
4 | const ChatLoading = () => {
5 | return (
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 | );
15 | };
16 |
17 | export default ChatLoading;
18 |
--------------------------------------------------------------------------------
/app/not-found.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button";
2 | import Link from "next/link";
3 |
4 | const NotFoundPage = () => {
5 | return (
6 |
7 |
404
8 |
We couldn't find the page you were looking for.
9 |
12 |
13 | );
14 | };
15 |
16 | export default NotFoundPage;
17 |
--------------------------------------------------------------------------------
/.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 | .env
31 | # vercel
32 | .vercel
33 |
34 | # typescript
35 | *.tsbuildinfo
36 | next-env.d.ts
37 |
--------------------------------------------------------------------------------
/app/(browse)/[username]/not-found.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button";
2 | import Link from "next/link";
3 |
4 | const NotFoundPage = () => {
5 | return (
6 |
7 |
404
8 |
We couldn't find the user you were looking for.
9 |
12 |
13 | );
14 | };
15 |
16 | export default NotFoundPage;
17 |
--------------------------------------------------------------------------------
/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx";
2 | import { twMerge } from "tailwind-merge";
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs));
6 | }
7 |
8 | export const stringToColor = (str: string) => {
9 | let hash = 0;
10 | for (let i = 0; i < str.length; i++) {
11 | hash = str.charCodeAt(1) + ((hash << 5) - hash);
12 | }
13 |
14 | let color = "#";
15 | for (let i = 0; i < 3; i++) {
16 | const value = (hash >> (i * 8)) & 0xff;
17 | color += ("00" + value.toString(16)).substr(-2);
18 | }
19 | return color;
20 | };
21 |
--------------------------------------------------------------------------------
/middleware.ts:
--------------------------------------------------------------------------------
1 | import { authMiddleware } from "@clerk/nextjs";
2 |
3 | // This example protects all routes including api/trpc routes
4 | // Please edit this to allow other routes to be public as needed.
5 | // See https://clerk.com/docs/references/nextjs/auth-middleware for more information about configuring your Middleware
6 | export default authMiddleware({
7 | publicRoutes: [
8 | "/",
9 | "/api/webhooks(.*)",
10 | "/api/uploadthing",
11 | "/:username",
12 | "/search",
13 | ],
14 | });
15 |
16 | export const config = {
17 | matcher: ["/((?!.+\\.[\\w]+$|_next).*)", "/", "/(api|trpc)(.*)"],
18 | };
19 |
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/(browse)/layout.tsx:
--------------------------------------------------------------------------------
1 | import { Suspense } from "react";
2 | import { Container } from "./_components/container";
3 | import { Navbar } from "./_components/navbar";
4 | import { Sidebar, SidebarSkeleton } from "./_components/sidebar";
5 |
6 | const BrowseLayout = ({ children }: { children: React.ReactNode }) => {
7 | return (
8 | <>
9 |
10 |
11 | }>
12 |
13 |
14 | {children}
15 |
16 | >
17 | );
18 | };
19 |
20 | export default BrowseLayout;
21 |
--------------------------------------------------------------------------------
/actions/user.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { getSelf } from "@/lib/auth-service";
4 | import { db } from "@/lib/db";
5 | import { User } from "@prisma/client";
6 | import { revalidatePath } from "next/cache";
7 |
8 | export const updateUser = async (values: Partial) => {
9 | const self = await getSelf();
10 |
11 | const validData = {
12 | bio: values.bio,
13 | };
14 |
15 | const user = await db.user.update({
16 | where: {
17 | id: self.id,
18 | },
19 | data: {
20 | ...validData,
21 | },
22 | });
23 |
24 | revalidatePath(`/u/${self.username}`);
25 | revalidatePath(`/${self.username}`);
26 |
27 | return user;
28 | };
29 |
--------------------------------------------------------------------------------
/app/(dashboard)/u/[username]/_components/sidebar/wrapper.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { cn } from "@/lib/utils";
4 | import { useCreatorSidebar } from "@/store/use-creator-sidebar";
5 |
6 | interface WrapperProps {
7 | children: React.ReactNode;
8 | }
9 |
10 | export const Wrapper = ({ children }: WrapperProps) => {
11 | const { collapsed } = useCreatorSidebar((state) => state);
12 |
13 | return (
14 |
22 | );
23 | };
24 |
--------------------------------------------------------------------------------
/app/(browse)/search/page.tsx:
--------------------------------------------------------------------------------
1 | import { redirect } from "next/navigation";
2 | import { Results, ResultsSkeleton } from "./_components/results";
3 | import { Suspense } from "react";
4 |
5 | interface SearchPageProps {
6 | searchParams: {
7 | term?: string;
8 | };
9 | }
10 |
11 | const SearchPage = ({ searchParams }: SearchPageProps) => {
12 | if (!searchParams.term) {
13 | redirect("/");
14 | }
15 |
16 | return (
17 |
18 | }>
19 |
20 |
21 |
22 | );
23 | };
24 |
25 | export default SearchPage;
26 |
--------------------------------------------------------------------------------
/store/use-chat-sidebar.ts:
--------------------------------------------------------------------------------
1 | import { create } from "zustand";
2 |
3 | export enum ChatVariant {
4 | CHAT = "CHAT",
5 | COMMUNITY = "COMMUNITY",
6 | }
7 |
8 | interface ChatSidebarStore {
9 | collapsed: boolean;
10 | variant: ChatVariant;
11 | onExpand: () => void;
12 | onCollapse: () => void;
13 | onChangeVariant: (variant: ChatVariant) => void;
14 | }
15 |
16 | export const useChatSidebar = create((set) => ({
17 | collapsed: false,
18 | variant: ChatVariant.CHAT,
19 | onExpand: () => set(() => ({ collapsed: false })),
20 | onCollapse: () => set(() => ({ collapsed: true })),
21 | onChangeVariant: (variant: ChatVariant) => set(() => ({ variant })),
22 | }));
23 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "preserve",
15 | "incremental": true,
16 | "plugins": [
17 | {
18 | "name": "next"
19 | }
20 | ],
21 | "paths": {
22 | "@/*": ["./*"]
23 | }
24 | },
25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
26 | "exclude": ["node_modules"]
27 | }
28 |
--------------------------------------------------------------------------------
/app/(dashboard)/u/[username]/_components/navbar/actions.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button";
2 | import { UserButton } from "@clerk/nextjs";
3 | import { LogOut } from "lucide-react";
4 | import Link from "next/link";
5 |
6 | export const Actions = () => {
7 | return (
8 |
9 |
20 |
21 |
22 | );
23 | };
24 |
--------------------------------------------------------------------------------
/app/(dashboard)/u/[username]/keys/_components/url-card.tsx:
--------------------------------------------------------------------------------
1 | import { Input } from "@/components/ui/input";
2 | import { CopyButton } from "./copy-button";
3 |
4 | interface UrlCardProps {
5 | value: string | null;
6 | }
7 |
8 | export const UrlCard = ({ value }: UrlCardProps) => {
9 | return (
10 |
11 |
12 |
Server URL
13 |
19 |
20 |
21 | );
22 | };
23 |
--------------------------------------------------------------------------------
/app/(dashboard)/u/[username]/(home)/page.tsx:
--------------------------------------------------------------------------------
1 | import { StreamPlayer } from "@/components/stream-player";
2 | import { getUserByUsername } from "@/lib/user-service";
3 | import { currentUser } from "@clerk/nextjs";
4 |
5 | interface CreatorPageProps {
6 | params: {
7 | username: string;
8 | };
9 | }
10 |
11 | const CreatorPage = async ({ params }: CreatorPageProps) => {
12 | const externalUser = await currentUser();
13 | const user = await getUserByUsername(params.username);
14 |
15 | if (!user || user.externalUserId !== externalUser?.id || !user.stream) {
16 | throw new Error("Unauthorized");
17 | }
18 | return (
19 |
20 |
21 |
22 | );
23 | };
24 |
25 | export default CreatorPage;
26 |
--------------------------------------------------------------------------------
/app/(auth)/_components/logo.tsx:
--------------------------------------------------------------------------------
1 | import Image from "next/image";
2 | import { Poppins } from "next/font/google";
3 |
4 | import { cn } from "@/lib/utils";
5 |
6 | const font = Poppins({
7 | subsets: ["latin"],
8 | weight: ["200", "300", "400", "500", "600", "700", "800"],
9 | });
10 |
11 | export const Logo = () => {
12 | return (
13 |
14 |
15 |
16 |
17 |
18 |
NexStream
19 |
Let's play
20 |
21 |
22 | );
23 | };
24 |
--------------------------------------------------------------------------------
/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as LabelPrimitive from "@radix-ui/react-label"
5 | import { cva, type VariantProps } from "class-variance-authority"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const labelVariants = cva(
10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
11 | )
12 |
13 | const Label = React.forwardRef<
14 | React.ElementRef,
15 | React.ComponentPropsWithoutRef &
16 | VariantProps
17 | >(({ className, ...props }, ref) => (
18 |
23 | ))
24 | Label.displayName = LabelPrimitive.Root.displayName
25 |
26 | export { Label }
27 |
--------------------------------------------------------------------------------
/app/(browse)/_components/container.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useEffect } from "react";
4 | import { useMediaQuery } from "usehooks-ts";
5 |
6 | import { cn } from "@/lib/utils";
7 | import { useSidebar } from "@/store/use-sidebar";
8 |
9 | interface ContainerProps {
10 | children: React.ReactNode;
11 | }
12 | export const Container = ({ children }: ContainerProps) => {
13 | const matches = useMediaQuery("(max-width: 1024px)");
14 | const { collapsed, onCollapse, onExpand } = useSidebar((state) => state);
15 |
16 | useEffect(() => {
17 | if (matches) {
18 | onCollapse();
19 | } else {
20 | onExpand();
21 | }
22 | }, [matches, onCollapse, onExpand]);
23 | return (
24 |
27 | {children}
28 |
29 | );
30 | };
31 |
--------------------------------------------------------------------------------
/app/(dashboard)/u/[username]/layout.tsx:
--------------------------------------------------------------------------------
1 | import { redirect } from "next/navigation";
2 |
3 | import { getSelfByUsername } from "@/lib/auth-service";
4 |
5 | import { Navbar } from "./_components/navbar";
6 | import { Sidebar } from "./_components/sidebar";
7 | import { Container } from "./_components/container";
8 |
9 | interface CreatorLayoutProps {
10 | params: { username: string };
11 | children: React.ReactNode;
12 | }
13 |
14 | const CreatorLayout = async ({ params, children }: CreatorLayoutProps) => {
15 | const self = await getSelfByUsername(params.username);
16 |
17 | if (!self) {
18 | redirect("/");
19 | }
20 |
21 | return (
22 | <>
23 |
24 |
25 |
26 | {children}
27 |
28 | >
29 | );
30 | };
31 |
32 | export default CreatorLayout;
33 |
--------------------------------------------------------------------------------
/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | export interface TextareaProps
6 | extends React.TextareaHTMLAttributes {}
7 |
8 | const Textarea = React.forwardRef(
9 | ({ className, ...props }, ref) => {
10 | return (
11 |
19 | )
20 | }
21 | )
22 | Textarea.displayName = "Textarea"
23 |
24 | export { Textarea }
25 |
--------------------------------------------------------------------------------
/components/hint.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Tooltip,
3 | TooltipContent,
4 | TooltipProvider,
5 | TooltipTrigger,
6 | } from "@/components/ui/tooltip";
7 |
8 | interface HintProps {
9 | label: string;
10 | children: React.ReactNode;
11 | asChild?: boolean;
12 | side?: "top" | "bottom" | "left" | "right";
13 | align?: "start" | "center" | "end";
14 | }
15 |
16 | export const Hint = ({ label, children, asChild, align, side }: HintProps) => {
17 | return (
18 |
19 |
20 | {children}
21 |
26 | {label}
27 |
28 |
29 |
30 | );
31 | };
32 |
--------------------------------------------------------------------------------
/components/stream-player/fullscreen-control.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Maximize, Minimize } from "lucide-react";
4 | import { Hint } from "@/components/hint";
5 |
6 | interface FullscreenControlProps {
7 | isFullscreen: boolean;
8 | onToggle: () => void;
9 | }
10 |
11 | export const FullscreenControl = ({
12 | isFullscreen,
13 | onToggle,
14 | }: FullscreenControlProps) => {
15 | const Icon = isFullscreen ? Minimize : Maximize;
16 | const label = isFullscreen ? "Exit fullscreen" : "Enter fullscreen";
17 |
18 | return (
19 |
20 |
21 |
27 |
28 |
29 | );
30 | };
31 |
--------------------------------------------------------------------------------
/components/stream-player/chat-header.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Skeleton } from "../ui/skeleton";
4 | import { ChatToggle } from "./chat-toggle";
5 | import { VariantToggle } from "./variant-toggle";
6 |
7 | export const ChatHeader = () => {
8 | return (
9 |
10 |
11 |
12 |
13 |
Stream Chat
14 |
15 |
16 |
17 |
18 | );
19 | };
20 |
21 | export const ChatHeaderSkeleton = () => {
22 | return (
23 |
24 |
25 |
26 |
27 | );
28 | };
29 |
--------------------------------------------------------------------------------
/components/ui/separator.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SeparatorPrimitive from "@radix-ui/react-separator"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Separator = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(
12 | (
13 | { className, orientation = "horizontal", decorative = true, ...props },
14 | ref
15 | ) => (
16 |
27 | )
28 | )
29 | Separator.displayName = SeparatorPrimitive.Root.displayName
30 |
31 | export { Separator }
32 |
--------------------------------------------------------------------------------
/actions/follow.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { revalidatePath } from "next/cache";
4 |
5 | import { followUser, unfollowUser } from "@/lib/follow-service";
6 |
7 | export const onFollow = async (id: string) => {
8 | try {
9 | const followedUser = await followUser(id);
10 |
11 | revalidatePath("/");
12 |
13 | if (followedUser) {
14 | revalidatePath(`/${followedUser.following.username}`);
15 | }
16 |
17 | return followedUser;
18 | } catch (error) {
19 | throw new Error("Interal Error");
20 | }
21 | };
22 |
23 | export const onUnfollow = async (id: string) => {
24 | try {
25 | const unfollowedUser = await unfollowUser(id);
26 |
27 | revalidatePath("/");
28 |
29 | if (unfollowedUser) {
30 | revalidatePath(`/${unfollowedUser.following.username}`);
31 | }
32 |
33 | return unfollowedUser;
34 | } catch (error) {
35 | throw new Error("Internal Error");
36 | }
37 | };
38 |
--------------------------------------------------------------------------------
/app/(dashboard)/u/[username]/_components/container.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { useEffect } from "react";
3 | import { useMediaQuery } from "usehooks-ts";
4 |
5 | import { cn } from "@/lib/utils";
6 | import { useCreatorSidebar } from "@/store/use-creator-sidebar";
7 |
8 | interface ContainerProps {
9 | children: React.ReactNode;
10 | }
11 |
12 | export const Container = ({ children }: ContainerProps) => {
13 | const { collapsed, onCollapse, onExpand } = useCreatorSidebar(
14 | (state) => state
15 | );
16 |
17 | const matches = useMediaQuery(`(max-width: 1024px)`);
18 |
19 | useEffect(() => {
20 | if (matches) {
21 | onCollapse();
22 | } else {
23 | onExpand();
24 | }
25 | }, [matches, onCollapse, onExpand]);
26 |
27 | return (
28 |
31 | {children}
32 |
33 | );
34 | };
35 |
--------------------------------------------------------------------------------
/app/(dashboard)/u/[username]/keys/_components/copy-button.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Button } from "@/components/ui/button";
4 | import { CheckCheck, Copy } from "lucide-react";
5 | import { useState } from "react";
6 |
7 | interface CopyButtonProps {
8 | value?: string | null;
9 | }
10 |
11 | export const CopyButton = ({ value }: CopyButtonProps) => {
12 | const [isCopied, setIsCopied] = useState(false);
13 |
14 | const onCopy = () => {
15 | if (!value) return;
16 |
17 | setIsCopied(true);
18 | navigator.clipboard.writeText(value);
19 | setTimeout(() => {
20 | setIsCopied(false);
21 | }, 1000);
22 | };
23 |
24 | const Icon = isCopied ? CheckCheck : Copy;
25 | return (
26 |
34 | );
35 | };
36 |
--------------------------------------------------------------------------------
/app/(browse)/[username]/page.tsx:
--------------------------------------------------------------------------------
1 | import { StreamPlayer } from "@/components/stream-player";
2 | import { isBlockedByUser } from "@/lib/block-service";
3 | import { isFollowingUser } from "@/lib/follow-service";
4 | import { getUserByUsername } from "@/lib/user-service";
5 | import { notFound } from "next/navigation";
6 |
7 | interface UserPageProps {
8 | params: {
9 | username: string;
10 | };
11 | }
12 |
13 | const UserPage = async ({ params }: UserPageProps) => {
14 | const user = await getUserByUsername(params.username);
15 |
16 | if (!user || !user.stream) {
17 | notFound();
18 | }
19 |
20 | const isFollowing = await isFollowingUser(user.id);
21 | const isBlocked = await isBlockedByUser(user.id);
22 |
23 | if (isBlocked) {
24 | notFound();
25 | }
26 |
27 | return (
28 |
29 | );
30 | };
31 |
32 | export default UserPage;
33 |
--------------------------------------------------------------------------------
/app/(dashboard)/u/[username]/community/page.tsx:
--------------------------------------------------------------------------------
1 | import { getBlockedUsers } from "@/lib/block-service";
2 | import { columns } from "./_components/columns";
3 | import { DataTable } from "./_components/data-table";
4 | import { format } from "date-fns";
5 |
6 | const CommunityPage = async () => {
7 | const blockedUsers = await getBlockedUsers();
8 |
9 | const formattedData = blockedUsers.map((block) => ({
10 | ...block,
11 | userId: block.blocked.id,
12 | imageUrl: block.blocked.imageUrl,
13 | username: block.blocked.username,
14 | createdAt: format(new Date(block.blocked.createdAt), "dd/MM/yyyy"),
15 | }));
16 |
17 | return (
18 |
19 |
20 |
Community Settings
21 |
22 |
23 |
24 | );
25 | };
26 |
27 | export default CommunityPage;
28 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/app/(browse)/_components/navbar/logo.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 | import Image from "next/image";
3 | import { Poppins } from "next/font/google";
4 |
5 | import { cn } from "@/lib/utils";
6 |
7 | const font = Poppins({
8 | subsets: ["latin"],
9 | weight: ["200", "300", "400", "500", "600", "700", "800"],
10 | });
11 |
12 | export const Logo = () => {
13 | return (
14 |
15 |
16 |
17 |
18 |
19 |
20 |
NexStream
21 |
Let's play
22 |
23 |
24 |
25 | );
26 | };
27 |
--------------------------------------------------------------------------------
/app/(dashboard)/u/[username]/_components/navbar/logo.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 | import Image from "next/image";
3 | import { Poppins } from "next/font/google";
4 |
5 | import { cn } from "@/lib/utils";
6 |
7 | const font = Poppins({
8 | subsets: ["latin"],
9 | weight: ["200", "300", "400", "500", "600", "700", "800"],
10 | });
11 |
12 | export const Logo = () => {
13 | return (
14 |
15 |
16 |
17 |
18 |
19 |
20 |
NexStream
21 |
Creator dashboard
22 |
23 |
24 |
25 | );
26 | };
27 |
--------------------------------------------------------------------------------
/app/api/uploadthing/core.ts:
--------------------------------------------------------------------------------
1 | import { getSelf } from "@/lib/auth-service";
2 | import { db } from "@/lib/db";
3 | import { createUploadthing, type FileRouter } from "uploadthing/next";
4 | import { UploadThingError } from "uploadthing/server";
5 |
6 | const f = createUploadthing();
7 |
8 | export const ourFileRouter = {
9 | thumbnailUploader: f({
10 | image: {
11 | maxFileSize: "4MB",
12 | maxFileCount: 1,
13 | },
14 | })
15 | .middleware(async () => {
16 | const self = await getSelf();
17 |
18 | return { user: self };
19 | })
20 | .onUploadComplete(async ({ metadata, file }) => {
21 | await db.stream.update({
22 | where: {
23 | userId: metadata.user.id,
24 | },
25 | data: {
26 | thumbnailUrl: file.url,
27 | },
28 | });
29 |
30 | return { fileUrl: file.url };
31 | }),
32 | } satisfies FileRouter;
33 |
34 | export type OurFileRouter = typeof ourFileRouter;
35 |
--------------------------------------------------------------------------------
/app/(dashboard)/u/[username]/keys/page.tsx:
--------------------------------------------------------------------------------
1 | import { UrlCard } from "./_components/url-card";
2 | import { getSelf } from "@/lib/auth-service";
3 | import { getStreamByUserId } from "@/lib/stream-service";
4 | import { KeyCard } from "./_components/key-card";
5 | import { ConnectModal } from "./_components/connect-modal";
6 |
7 | const KeysPage = async () => {
8 | const self = await getSelf();
9 | const stream = await getStreamByUserId(self.id);
10 |
11 | if (!stream) {
12 | throw new Error("Stream not found");
13 | }
14 |
15 | return (
16 |
17 |
18 |
Keys & URLs
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | );
27 | };
28 |
29 | export default KeysPage;
30 |
--------------------------------------------------------------------------------
/lib/auth-service.ts:
--------------------------------------------------------------------------------
1 | import { currentUser } from "@clerk/nextjs";
2 | import { db } from "@/lib/db";
3 |
4 | export const getSelf = async () => {
5 | const self = await currentUser();
6 |
7 | if (!self || !self.username) {
8 | throw new Error("Unauthorized");
9 | }
10 |
11 | const user = await db.user.findUnique({
12 | where: {
13 | externalUserId: self.id,
14 | },
15 | });
16 |
17 | if (!user) {
18 | throw new Error("Not Found");
19 | }
20 |
21 | return user;
22 | };
23 |
24 | export const getSelfByUsername = async (username: string) => {
25 | const self = await currentUser();
26 |
27 | if (!self || !self.username) {
28 | throw new Error("Unauthorized");
29 | }
30 |
31 | const user = await db.user.findUnique({
32 | where: { username },
33 | });
34 |
35 | if (!user) {
36 | throw new Error("User not found");
37 | }
38 |
39 | if (self.username !== user.username) {
40 | throw new Error("Unauthorized");
41 | }
42 |
43 | return user;
44 | };
45 |
--------------------------------------------------------------------------------
/app/(dashboard)/u/[username]/community/_components/unblock-button.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { onUnblock } from "@/actions/block";
4 | import { Button } from "@/components/ui/button";
5 | import { useTransition } from "react";
6 | import { toast } from "sonner";
7 |
8 | interface UnblockButtonProps {
9 | userId: string;
10 | }
11 |
12 | export const UnblockButton = ({ userId }: UnblockButtonProps) => {
13 | const [isPending, startTransition] = useTransition();
14 |
15 | const onClick = () => {
16 | startTransition(() => {
17 | onUnblock(userId)
18 | .then((result) =>
19 | toast.success(`User ${result.blocked.username} unblocked`)
20 | )
21 | .catch(() => toast.error("Something went wrong"));
22 | });
23 | };
24 |
25 | return (
26 |
35 | );
36 | };
37 |
--------------------------------------------------------------------------------
/components/stream-player/chat-toggle.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { ArrowLeftFromLine, ArrowRightFromLine } from "lucide-react";
4 | import { Hint } from "../hint";
5 | import { Button } from "../ui/button";
6 | import { useChatSidebar } from "@/store/use-chat-sidebar";
7 |
8 | export const ChatToggle = () => {
9 | const { collapsed, onExpand, onCollapse } = useChatSidebar((state) => state);
10 |
11 | const Icon = collapsed ? ArrowLeftFromLine : ArrowRightFromLine;
12 |
13 | const onToggle = () => {
14 | if (collapsed) {
15 | onExpand();
16 | } else {
17 | onCollapse();
18 | }
19 | };
20 |
21 | const label = collapsed ? "Expand" : "Collapse";
22 |
23 | return (
24 |
25 |
32 |
33 | );
34 | };
35 |
--------------------------------------------------------------------------------
/components/stream-player/chat-message.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { format } from "date-fns";
4 | import { ReceivedChatMessage } from "@livekit/components-react";
5 |
6 | import { stringToColor } from "@/lib/utils";
7 |
8 | interface ChatMessageProps {
9 | data: ReceivedChatMessage;
10 | }
11 |
12 | export const ChatMessage = ({ data }: ChatMessageProps) => {
13 | const color = stringToColor(data.from?.name || "");
14 |
15 | return (
16 |
17 |
18 | {format(data.timestamp, "hh:mm a")}
19 |
20 |
21 |
22 |
23 | {data.from?.name}
24 |
25 | :
26 |
27 |
{data.message}
28 |
29 |
30 | );
31 | };
32 |
--------------------------------------------------------------------------------
/lib/user-service.ts:
--------------------------------------------------------------------------------
1 | import { db } from "@/lib/db";
2 |
3 | export const getUserByUsername = async (username: string) => {
4 | const user = await db.user.findUnique({
5 | where: {
6 | username,
7 | },
8 | select: {
9 | id: true,
10 | externalUserId: true,
11 | username: true,
12 | bio: true,
13 | imageUrl: true,
14 | stream: {
15 | select: {
16 | id: true,
17 | isLive: true,
18 | isChatDelayed: true,
19 | isChatEnabled: true,
20 | isChatFollowersOnly: true,
21 | thumbnailUrl: true,
22 | name: true,
23 | },
24 | },
25 | _count: {
26 | select: {
27 | followedBy: true,
28 | },
29 | },
30 | },
31 | });
32 | return user;
33 | };
34 |
35 | export const getUserById = async (id: string) => {
36 | const user = await db.user.findUnique({
37 | where: {
38 | id,
39 | },
40 | include: {
41 | stream: true,
42 | },
43 | });
44 |
45 | return user;
46 | };
47 |
--------------------------------------------------------------------------------
/components/stream-player/variant-toggle.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { MessageSquare, Users } from "lucide-react";
4 | import { Hint } from "../hint";
5 | import { Button } from "../ui/button";
6 | import { ChatVariant, useChatSidebar } from "@/store/use-chat-sidebar";
7 |
8 | export const VariantToggle = () => {
9 | const { variant, onChangeVariant } = useChatSidebar((state) => state);
10 |
11 | const isChat = variant === ChatVariant.CHAT;
12 |
13 | const Icon = isChat ? Users : MessageSquare;
14 |
15 | const onToggle = () => {
16 | const newVariant = isChat ? ChatVariant.COMMUNITY : ChatVariant.CHAT;
17 | onChangeVariant(newVariant);
18 | };
19 |
20 | const label = isChat ? "Community" : "Go back to chat";
21 |
22 | return (
23 |
24 |
31 |
32 | );
33 | };
34 |
--------------------------------------------------------------------------------
/app/(browse)/_components/sidebar/index.tsx:
--------------------------------------------------------------------------------
1 | import { getRecommended } from "@/lib/recommended-service";
2 | import { Recommended, RecommendedSkeleton } from "./recommended";
3 | import { ToggleSkeleton, Toogle } from "./toggle";
4 | import { Wrapper } from "./wrapper";
5 | import { getFollowedUsers } from "@/lib/follow-service";
6 | import { Following, FollowingSkeleton } from "./following";
7 |
8 | export const Sidebar = async () => {
9 | const recommended = await getRecommended();
10 | const following = await getFollowedUsers();
11 |
12 | return (
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | );
21 | };
22 |
23 | export const SidebarSkeleton = () => {
24 | return (
25 |
30 | );
31 | };
32 |
--------------------------------------------------------------------------------
/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 | import { Inter } from "next/font/google";
3 | import "./globals.css";
4 | import { ClerkProvider } from "@clerk/nextjs";
5 | import { dark } from "@clerk/themes";
6 | import { ThemeProvider } from "@/components/theme-provider";
7 | import { Toaster } from "sonner";
8 |
9 | const inter = Inter({ subsets: ["latin"] });
10 |
11 | export const metadata: Metadata = {
12 | title: "NexStream",
13 | description: "created by Yash Mewada",
14 | };
15 |
16 | export default function RootLayout({
17 | children,
18 | }: {
19 | children: React.ReactNode;
20 | }) {
21 | return (
22 |
23 |
24 |
25 |
30 |
31 | {children}
32 |
33 |
34 |
35 |
36 | );
37 | }
38 |
--------------------------------------------------------------------------------
/actions/block.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 | import { getSelf } from "@/lib/auth-service";
3 | import { blockUser, unblockUser } from "@/lib/block-service";
4 | import { RoomServiceClient } from "livekit-server-sdk";
5 | import { revalidatePath } from "next/cache";
6 |
7 | const roomService = new RoomServiceClient(
8 | process.env.LIVEKIT_API_URL!,
9 | process.env.LIVEKIT_API_KEY!,
10 | process.env.LIVEKIT_API_SECRET!
11 | );
12 |
13 | export const onBlock = async (id: string) => {
14 | const self = await getSelf();
15 |
16 | let blockedUser;
17 |
18 | try {
19 | blockedUser = await blockUser(id);
20 | } catch {
21 | //this means user is a guest
22 | }
23 |
24 | try {
25 | await roomService.removeParticipant(self.id, id);
26 | } catch {
27 | //this means user is not in the room
28 | }
29 |
30 | revalidatePath(`/u/${self.username}/community`);
31 |
32 | return blockedUser;
33 | };
34 |
35 | export const onUnblock = async (id: string) => {
36 | const self = await getSelf();
37 | const unBlockedUser = await unblockUser(id);
38 |
39 | revalidatePath(`/u/${self.username}/community`);
40 |
41 | return unBlockedUser;
42 | };
43 |
--------------------------------------------------------------------------------
/app/api/webhooks/livekit/route.ts:
--------------------------------------------------------------------------------
1 | import { headers } from "next/headers";
2 | import { WebhookReceiver } from "livekit-server-sdk";
3 |
4 | import { db } from "@/lib/db";
5 |
6 | const reciever = new WebhookReceiver(
7 | process.env.LIVEKIT_API_KEY!,
8 | process.env.LIVEKIT_API_SECRET!
9 | );
10 |
11 | export async function POST(req: Request) {
12 | const body = await req.text();
13 | const headerPayload = headers();
14 | const authorization = headerPayload.get("Authorization");
15 |
16 | if (!authorization) {
17 | return new Response("No authorization header", { status: 400 });
18 | }
19 |
20 | const event = reciever.receive(body, authorization);
21 |
22 | if (event.event === "ingress_started") {
23 | await db.stream.update({
24 | where: {
25 | ingressId: event.ingressInfo?.ingressId,
26 | },
27 | data: {
28 | isLive: true,
29 | },
30 | });
31 | }
32 |
33 | if (event.event === "ingress_ended") {
34 | await db.stream.update({
35 | where: {
36 | ingressId: event.ingressInfo?.ingressId,
37 | },
38 | data: {
39 | isLive: false,
40 | },
41 | });
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/components/stream-player/chat-list.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { ReceivedChatMessage } from "@livekit/components-react";
3 | import { ChatMessage } from "./chat-message";
4 | import { Skeleton } from "../ui/skeleton";
5 |
6 | interface ChatListProps {
7 | messages: ReceivedChatMessage[];
8 | isHidden: boolean;
9 | }
10 |
11 | export const ChatList = ({ messages, isHidden }: ChatListProps) => {
12 | if (isHidden || !messages || messages.length === 0) {
13 | return (
14 |
15 |
16 | {isHidden ? "Chat is disabled" : "Welcome to the chat"}
17 |
18 |
19 | );
20 | }
21 |
22 | return (
23 |
24 | {messages.map((message) => (
25 |
26 | ))}
27 |
28 | );
29 | };
30 |
31 | export const ChatListSkeleton = () => {
32 | return (
33 |
34 |
35 |
36 | );
37 | };
38 |
--------------------------------------------------------------------------------
/app/(dashboard)/u/[username]/chat/page.tsx:
--------------------------------------------------------------------------------
1 | import { getSelf } from "@/lib/auth-service";
2 | import { getStreamByUserId } from "@/lib/stream-service";
3 | import { ToggleCard } from "./_components/toggle-card";
4 |
5 | const ChatPage = async () => {
6 | const self = await getSelf();
7 | const stream = await getStreamByUserId(self.id);
8 |
9 | if (!stream) {
10 | throw new Error("Stream not found");
11 | }
12 |
13 | return (
14 |
15 |
16 |
Chat settings
17 |
18 |
19 |
24 |
29 |
34 |
35 |
36 | );
37 | };
38 |
39 | export default ChatPage;
40 |
--------------------------------------------------------------------------------
/app/(browse)/_components/sidebar/wrapper.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { useSidebar } from "@/store/use-sidebar";
3 | import { cn } from "@/lib/utils";
4 | import { useState, useEffect } from "react";
5 | import { ToggleSkeleton } from "./toggle";
6 | import { RecommendedSkeleton } from "./recommended";
7 | import { useIsClient } from "usehooks-ts";
8 | import { FollowingSkeleton } from "./following";
9 |
10 | interface WrapperProps {
11 | children: React.ReactNode;
12 | }
13 |
14 | export const Wrapper = ({ children }: WrapperProps) => {
15 | const isClient = useIsClient();
16 | const { collapsed } = useSidebar((state) => state);
17 |
18 | if (!isClient)
19 | return (
20 |
25 | );
26 |
27 | return (
28 |
36 | );
37 | };
38 |
--------------------------------------------------------------------------------
/components/ui/slider.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as SliderPrimitive from "@radix-ui/react-slider";
5 |
6 | import { cn } from "@/lib/utils";
7 |
8 | const Slider = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 |
21 |
22 |
23 |
24 |
25 | ));
26 | Slider.displayName = SliderPrimitive.Root.displayName;
27 |
28 | export { Slider };
29 |
--------------------------------------------------------------------------------
/app/(browse)/_components/navbar/actions.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button";
2 | import { SignInButton, UserButton, currentUser } from "@clerk/nextjs";
3 | import { Clapperboard } from "lucide-react";
4 | import Link from "next/link";
5 |
6 | export const Actions = async () => {
7 | const user = await currentUser();
8 | return (
9 |
10 | {!user && (
11 |
12 |
15 |
16 | )}
17 | {!!user && (
18 |
19 |
30 |
31 |
32 | )}
33 |
34 | );
35 | };
36 |
--------------------------------------------------------------------------------
/app/(dashboard)/u/[username]/keys/_components/key-card.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Input } from "@/components/ui/input";
4 | import { CopyButton } from "./copy-button";
5 | import { Button } from "@/components/ui/button";
6 | import { useState } from "react";
7 |
8 | interface KeyCardProps {
9 | value: string | null;
10 | }
11 |
12 | export const KeyCard = ({ value }: KeyCardProps) => {
13 | const [show, setShow] = useState(false);
14 |
15 | return (
16 |
17 |
18 |
Stream key
19 |
20 |
21 |
27 |
28 |
29 |
32 |
33 |
34 |
35 | );
36 | };
37 |
--------------------------------------------------------------------------------
/hooks/use-viewer-token.ts:
--------------------------------------------------------------------------------
1 | import { toast } from "sonner";
2 | import { useEffect, useState } from "react";
3 | import { JwtPayload, jwtDecode } from "jwt-decode";
4 | import { createViewerToken } from "@/actions/token";
5 |
6 | export const useViewerToken = (hostIdentity: string) => {
7 | const [token, setToken] = useState("");
8 | const [name, setName] = useState("");
9 | const [identity, setIdentity] = useState("");
10 |
11 | useEffect(() => {
12 | const createToken = async () => {
13 | try {
14 | const viewerToken = await createViewerToken(hostIdentity);
15 | setToken(viewerToken);
16 |
17 | const decodedToken = jwtDecode(viewerToken) as JwtPayload & {
18 | name?: string;
19 | };
20 |
21 | const name = decodedToken?.name;
22 | const identity = decodedToken.jti;
23 |
24 | if (identity) {
25 | setIdentity(identity);
26 | }
27 |
28 | if (name) {
29 | setName(name);
30 | }
31 | } catch {
32 | toast.error("Something went wrong");
33 | }
34 | };
35 | createToken();
36 | }, [hostIdentity]);
37 |
38 | return {
39 | token,
40 | name,
41 | identity,
42 | };
43 | };
44 |
--------------------------------------------------------------------------------
/actions/stream.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { revalidatePath } from "next/cache";
4 | import { db } from "@/lib/db";
5 | import { Stream } from "@prisma/client";
6 | import { getSelf } from "@/lib/auth-service";
7 |
8 | export const updateStream = async (values: Partial) => {
9 | try {
10 | const self = await getSelf();
11 | const selfStream = await db.stream.findUnique({
12 | where: {
13 | userId: self.id,
14 | },
15 | });
16 |
17 | if (!selfStream) {
18 | throw new Error("Stream not found");
19 | }
20 |
21 | const validData = {
22 | thumbnailUrl: values.thumbnailUrl,
23 | name: values.name,
24 | isChatEnabled: values.isChatEnabled,
25 | isChatFollowersOnly: values.isChatFollowersOnly,
26 | isChatDelayed: values.isChatDelayed,
27 | };
28 |
29 | const stream = await db.stream.update({
30 | where: {
31 | id: selfStream.id,
32 | },
33 | data: {
34 | ...validData,
35 | },
36 | });
37 |
38 | revalidatePath(`/u/${self.username}/chat`);
39 | revalidatePath(`/u/${self.username}`);
40 | revalidatePath(`/${self.username}`);
41 |
42 | return stream;
43 | } catch {
44 | throw new Error("Internal Error");
45 | }
46 | };
47 |
--------------------------------------------------------------------------------
/app/(browse)/search/_components/results.tsx:
--------------------------------------------------------------------------------
1 | import { getSearch } from "@/lib/search-service";
2 | import { ResultCard, ResultCardSkeleton } from "./result-card";
3 | import { Skeleton } from "@/components/ui/skeleton";
4 |
5 | interface ResultsProps {
6 | term?: string;
7 | }
8 |
9 | export const Results = async ({ term }: ResultsProps) => {
10 | const data = await getSearch(term);
11 |
12 | return (
13 |
14 |
15 | Results for term "{term}"
16 |
17 | {data.length === 0 && (
18 |
19 | No results found. Try searching for something else
20 |
21 | )}
22 |
23 | {data.map((result) => (
24 |
25 | ))}
26 |
27 |
28 | );
29 | };
30 |
31 | export const ResultsSkeleton = () => {
32 | return (
33 |
34 |
35 |
36 | {[...Array(4)].map((_, i) => (
37 |
38 | ))}
39 |
40 |
41 | );
42 | };
43 |
--------------------------------------------------------------------------------
/app/(browse)/_components/sidebar/recommended.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { useSidebar } from "@/store/use-sidebar";
3 | import { User } from "@prisma/client";
4 | import { UserItem, UserItemSkeleton } from "./user-item";
5 |
6 | interface RecommendedProps {
7 | data: (User & {
8 | stream: { isLive: boolean } | null;
9 | })[];
10 | }
11 |
12 | export const Recommended = ({ data }: RecommendedProps) => {
13 | const { collapsed } = useSidebar((state) => state);
14 | const showLabel = !collapsed && data.length > 0;
15 | return (
16 |
17 | {showLabel && (
18 |
21 | )}
22 |
23 | {data.map((user) => (
24 |
30 | ))}
31 |
32 |
33 | );
34 | };
35 |
36 | export const RecommendedSkeleton = () => {
37 | return (
38 |
39 | {[...Array(3)].map((_, i) => (
40 |
41 | ))}
42 |
43 | );
44 | };
45 |
--------------------------------------------------------------------------------
/components/ui/switch.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as SwitchPrimitives from "@radix-ui/react-switch";
5 |
6 | import { cn } from "@/lib/utils";
7 |
8 | const Switch = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 |
25 |
26 | ));
27 | Switch.displayName = SwitchPrimitives.Root.displayName;
28 |
29 | export { Switch };
30 |
--------------------------------------------------------------------------------
/components/ui/tooltip.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as TooltipPrimitive from "@radix-ui/react-tooltip"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const TooltipProvider = TooltipPrimitive.Provider
9 |
10 | const Tooltip = TooltipPrimitive.Root
11 |
12 | const TooltipTrigger = TooltipPrimitive.Trigger
13 |
14 | const TooltipContent = React.forwardRef<
15 | React.ElementRef,
16 | React.ComponentPropsWithoutRef
17 | >(({ className, sideOffset = 4, ...props }, ref) => (
18 |
27 | ))
28 | TooltipContent.displayName = TooltipPrimitive.Content.displayName
29 |
30 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
31 |
--------------------------------------------------------------------------------
/public/spooky.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/(browse)/(home)/_components/results.tsx:
--------------------------------------------------------------------------------
1 | import { getStreams } from "@/lib/feed-service";
2 | import { Skeleton } from "@/components/ui/skeleton";
3 |
4 | import { ResultCard, ResultCardSkeleton } from "./result-card";
5 |
6 | export const Results = async () => {
7 | const data = await getStreams();
8 |
9 | return (
10 |
11 |
12 | Streams we think you'll like
13 |
14 | {data.length === 0 && (
15 |
16 | No streams found.
17 |
18 | )}
19 |
20 | {data.map((result) => (
21 |
25 | ))}
26 |
27 |
28 | )
29 | }
30 |
31 | export const ResultsSkeleton = () => {
32 | return (
33 |
34 |
35 |
36 | {[...Array(4)].map((_, i) => (
37 |
38 | ))}
39 |
40 |
41 | );
42 | };
--------------------------------------------------------------------------------
/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/actions/token.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { v4 } from "uuid";
4 | import { AccessToken } from "livekit-server-sdk";
5 |
6 | import { getSelf } from "@/lib/auth-service";
7 | import { getUserById } from "@/lib/user-service";
8 | import { isBlockedByUser } from "@/lib/block-service";
9 |
10 | export const createViewerToken = async (hostIdentity: string) => {
11 | let self;
12 |
13 | try {
14 | self = await getSelf();
15 | } catch {
16 | const id = v4();
17 | const username = `guest#${Math.floor(Math.random() * 1000)}`;
18 | self = { id, username };
19 | }
20 |
21 | const host = await getUserById(hostIdentity);
22 |
23 | if (!host) {
24 | throw new Error("User not found");
25 | }
26 |
27 | const isBlocked = await isBlockedByUser(host.id);
28 |
29 | if (isBlocked) {
30 | throw new Error("User is blocked");
31 | }
32 |
33 | const isHost = self.id === host.id;
34 |
35 | const token = new AccessToken(
36 | process.env.LIVEKIT_API_KEY!,
37 | process.env.LIVEKIT_API_SECRET!,
38 | {
39 | identity: isHost ? `host-${self.id}` : self.id,
40 | name: self.username,
41 | }
42 | );
43 |
44 | token.addGrant({
45 | room: host.id,
46 | roomJoin: true,
47 | canPublish: false,
48 | canPublishData: true,
49 | });
50 |
51 | return await Promise.resolve(token.toJwt());
52 | };
53 |
--------------------------------------------------------------------------------
/components/stream-player/volume-control.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Volume1, Volume2, VolumeX } from "lucide-react";
4 | import { Hint } from "@/components/hint";
5 | import { Slider } from "@/components/ui/slider";
6 |
7 | interface VolumeControlProps {
8 | onToggle: () => void;
9 | onChange: (value: number) => void;
10 | value: number;
11 | }
12 |
13 | export const VolumeControl = ({
14 | onToggle,
15 | onChange,
16 | value,
17 | }: VolumeControlProps) => {
18 | const isMuted = value === 0;
19 | const isAboveHalf = value > 50;
20 |
21 | let Icon = Volume1;
22 |
23 | if (isMuted) {
24 | Icon = VolumeX;
25 | } else if (isAboveHalf) {
26 | Icon = Volume2;
27 | }
28 |
29 | const label = isMuted ? "Unmute" : "Mute";
30 |
31 | const handleChange = (value: number[]) => {
32 | onChange(value[0]);
33 | };
34 |
35 | return (
36 |
37 |
38 |
44 |
45 |
52 |
53 | );
54 | };
55 |
--------------------------------------------------------------------------------
/app/(browse)/_components/sidebar/following.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useSidebar } from "@/store/use-sidebar";
4 | import { Follow, User } from "@prisma/client";
5 | import { UserItem, UserItemSkeleton } from "./user-item";
6 |
7 | interface FollowingProps {
8 | data: (Follow & {
9 | following: User & {
10 | stream: { isLive: boolean } | null;
11 | };
12 | })[];
13 | }
14 |
15 | export const Following = ({ data }: FollowingProps) => {
16 | const { collapsed } = useSidebar((state) => state);
17 |
18 | if (!data.length) {
19 | return null;
20 | }
21 | return (
22 |
23 | {!collapsed && (
24 |
27 | )}
28 |
29 | {data.map((follow) => (
30 |
36 | ))}
37 |
38 |
39 | );
40 | };
41 |
42 | export const FollowingSkeleton = () => {
43 | return (
44 |
45 | {[...Array(3)].map((_, i) => (
46 |
47 | ))}
48 |
49 | );
50 | };
51 |
--------------------------------------------------------------------------------
/lib/feed-service.ts:
--------------------------------------------------------------------------------
1 | import { db } from "@/lib/db";
2 | import { getSelf } from "@/lib/auth-service";
3 |
4 | export const getStreams = async () => {
5 | let userId;
6 |
7 | try {
8 | const self = await getSelf();
9 | userId = self.id;
10 | } catch {
11 | userId = null;
12 | }
13 |
14 | let streams = [];
15 |
16 | if (userId) {
17 | streams = await db.stream.findMany({
18 | where: {
19 | user: {
20 | NOT: {
21 | blocking: {
22 | some: {
23 | blockedId: userId,
24 | },
25 | },
26 | },
27 | },
28 | },
29 | select: {
30 | id: true,
31 | user: true,
32 | isLive: true,
33 | name: true,
34 | thumbnailUrl: true,
35 | },
36 | orderBy: [
37 | {
38 | isLive: "desc",
39 | },
40 | {
41 | updatedAt: "desc",
42 | },
43 | ],
44 | });
45 | } else {
46 | streams = await db.stream.findMany({
47 | select: {
48 | id: true,
49 | user: true,
50 | isLive: true,
51 | name: true,
52 | thumbnailUrl: true,
53 | },
54 | orderBy: [
55 | {
56 | isLive: "desc",
57 | },
58 | {
59 | updatedAt: "desc",
60 | },
61 | ],
62 | });
63 | }
64 |
65 | return streams;
66 | };
67 |
--------------------------------------------------------------------------------
/components/stream-player/about-card.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { VerifiedMark } from "../verified-mark";
4 | import { BioModal } from "./bio-modal";
5 |
6 | interface AboutCardProps {
7 | hostName: string;
8 | hostIdentity: string;
9 | viewerIdentity: string;
10 | bio: string | null;
11 | followedByCount: number;
12 | }
13 |
14 | export const AboutCard = ({
15 | hostIdentity,
16 | hostName,
17 | viewerIdentity,
18 | bio,
19 | followedByCount,
20 | }: AboutCardProps) => {
21 | const hostAsViewer = `host-${hostIdentity}`;
22 | const isHost = viewerIdentity === hostAsViewer;
23 |
24 | const followedByLabel = followedByCount === 1 ? "follower" : "followers";
25 |
26 | return (
27 |
28 |
29 |
30 |
31 | About {hostName}
32 |
33 | {isHost &&
}
34 |
35 |
36 | {followedByCount}{" "}
37 | {followedByLabel}
38 |
39 |
40 | {bio || "This user prefers to keep an air of mystery about them."}
41 |
42 |
43 |
44 | );
45 | };
46 |
--------------------------------------------------------------------------------
/app/(dashboard)/u/[username]/_components/sidebar/toggle.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { ArrowLeftFromLine, ArrowRightFromLine } from "lucide-react";
4 |
5 | import { Hint } from "@/components/hint";
6 | import { Button } from "@/components/ui/button";
7 | import { useCreatorSidebar } from "@/store/use-creator-sidebar";
8 |
9 | export const Toggle = () => {
10 | const { collapsed, onExpand, onCollapse } = useCreatorSidebar(
11 | (state) => state
12 | );
13 |
14 | const label = collapsed ? "Expand" : "Collapse";
15 |
16 | return (
17 | <>
18 | {collapsed && (
19 |
20 |
21 |
24 |
25 |
26 | )}
27 | {!collapsed && (
28 |
29 |
Dashboard
30 |
31 |
38 |
39 |
40 | )}
41 | >
42 | );
43 | };
44 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/app/(dashboard)/u/[username]/_components/sidebar/navigation.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useUser } from "@clerk/nextjs";
4 | import { usePathname } from "next/navigation";
5 | import { Fullscreen, KeyRound, MessageSquare, Users } from "lucide-react";
6 |
7 | import { NavItem, NavItemSkeleton } from "./nav-item";
8 |
9 | export const Navigation = () => {
10 | const pathname = usePathname();
11 | const { user } = useUser();
12 |
13 | const routes = [
14 | {
15 | label: "Stream",
16 | href: `/u/${user?.username}`,
17 | icon: Fullscreen,
18 | },
19 | {
20 | label: "Keys",
21 | href: `/u/${user?.username}/keys`,
22 | icon: KeyRound,
23 | },
24 | {
25 | label: "Chat",
26 | href: `/u/${user?.username}/chat`,
27 | icon: MessageSquare,
28 | },
29 | {
30 | label: "Community",
31 | href: `/u/${user?.username}/community`,
32 | icon: Users,
33 | },
34 | ];
35 |
36 | if (!user?.username) {
37 | return (
38 |
39 | {[...Array(4)].map((_, i) => (
40 |
41 | ))}
42 |
43 | );
44 | }
45 |
46 | return (
47 |
48 | {routes.map((route) => (
49 |
56 | ))}
57 |
58 | );
59 | };
60 |
--------------------------------------------------------------------------------
/components/stream-player/video.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { ConnectionState, Track } from "livekit-client";
4 | import {
5 | useConnectionState,
6 | useRemoteParticipant,
7 | useTracks,
8 | } from "@livekit/components-react";
9 | import { OfflineVideo } from "./offline-video";
10 | import { LoadingVideo } from "./loading-video";
11 | import { LiveVideo } from "./live-video";
12 | import { Skeleton } from "../ui/skeleton";
13 |
14 | interface VideoProps {
15 | hostName: string;
16 | hostIdentity: string;
17 | }
18 |
19 | export const Video = ({ hostName, hostIdentity }: VideoProps) => {
20 | const connectionState = useConnectionState();
21 | const participant = useRemoteParticipant(hostIdentity);
22 | const tracks = useTracks([
23 | Track.Source.Camera,
24 | Track.Source.Microphone,
25 | ]).filter((track) => track.participant.identity === hostIdentity);
26 |
27 | let content;
28 |
29 | if (!participant && connectionState === ConnectionState.Connected) {
30 | content = ;
31 | } else if (!participant || tracks.length === 0) {
32 | content = ;
33 | } else {
34 | content = ;
35 | }
36 |
37 | return {content}
;
38 | };
39 |
40 | export const VideoSkeleton = () => {
41 | return (
42 |
43 |
44 |
45 | );
46 | };
47 |
--------------------------------------------------------------------------------
/app/(dashboard)/u/[username]/_components/sidebar/nav-item.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Button } from "@/components/ui/button";
4 | import { Skeleton } from "@/components/ui/skeleton";
5 | import { cn } from "@/lib/utils";
6 | import { useCreatorSidebar } from "@/store/use-creator-sidebar";
7 | import { LucideIcon } from "lucide-react";
8 | import Link from "next/link";
9 |
10 | interface NavItemProps {
11 | icon: LucideIcon;
12 | label: string;
13 | href: string;
14 | isActive: boolean;
15 | }
16 |
17 | export const NavItem = ({
18 | icon: Icon,
19 | label,
20 | href,
21 | isActive,
22 | }: NavItemProps) => {
23 | const { collapsed } = useCreatorSidebar((state) => state);
24 |
25 | return (
26 |
42 | );
43 | };
44 |
45 | export const NavItemSkeleton = () => {
46 | return (
47 |
48 |
49 |
50 |
51 |
52 |
53 | );
54 | };
55 |
--------------------------------------------------------------------------------
/lib/recommended-service.ts:
--------------------------------------------------------------------------------
1 | import { db } from "@/lib/db";
2 | import { getSelf } from "@/lib/auth-service";
3 |
4 | export const getRecommended = async () => {
5 | let userId;
6 |
7 | try {
8 | const self = await getSelf();
9 | userId = self.id;
10 | } catch {
11 | userId = null;
12 | }
13 |
14 | let users = [];
15 |
16 | if (userId) {
17 | users = await db.user.findMany({
18 | where: {
19 | AND: [
20 | {
21 | NOT: {
22 | id: userId,
23 | },
24 | },
25 | {
26 | NOT: {
27 | followedBy: {
28 | some: {
29 | followerId: userId,
30 | },
31 | },
32 | },
33 | },
34 | {
35 | NOT: {
36 | blocking: {
37 | some: {
38 | blockedId: userId,
39 | },
40 | },
41 | },
42 | },
43 | ],
44 | },
45 | include: {
46 | stream: {
47 | select: {
48 | isLive: true,
49 | },
50 | },
51 | },
52 | orderBy: {
53 | createdAt: "desc",
54 | },
55 | });
56 | } else {
57 | users = await db.user.findMany({
58 | include: {
59 | stream: {
60 | select: {
61 | isLive: true,
62 | },
63 | },
64 | },
65 | orderBy: {
66 | createdAt: "desc",
67 | },
68 | });
69 | }
70 |
71 | return users;
72 | };
73 |
--------------------------------------------------------------------------------
/app/(dashboard)/u/[username]/chat/_components/toggle-card.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Switch } from "@/components/ui/switch";
4 | import { toast } from "sonner";
5 | import { useTransition } from "react";
6 | import { updateStream } from "@/actions/stream";
7 | import { Skeleton } from "@/components/ui/skeleton";
8 |
9 | type FieldTypes = "isChatEnabled" | "isChatDelayed" | "isChatFollowersOnly";
10 |
11 | interface ToggleCardProps {
12 | label: string;
13 | value: boolean;
14 | field: FieldTypes;
15 | }
16 |
17 | export const ToggleCard = ({
18 | label,
19 | value = false,
20 | field,
21 | }: ToggleCardProps) => {
22 | const [isPending, startTransition] = useTransition();
23 |
24 | const onChange = () => {
25 | startTransition(() => {
26 | updateStream({ [field]: !value })
27 | .then(() => toast.success("Chat settings updated"))
28 | .catch(() => toast.error("Something went wrong"));
29 | });
30 | };
31 |
32 | return (
33 |
34 |
35 |
{label}
36 |
37 |
42 | {value ? "On" : "Off"}
43 |
44 |
45 |
46 |
47 | );
48 | };
49 |
50 | export const ToggleCardSkeleton = () => {
51 | return ;
52 | };
53 |
--------------------------------------------------------------------------------
/components/stream-player/chat-info.tsx:
--------------------------------------------------------------------------------
1 | import { useMemo } from "react";
2 | import { Info } from "lucide-react";
3 |
4 | import { Hint } from "../hint";
5 |
6 | interface ChatInfoProps {
7 | isDelayed: boolean;
8 | isFollowersOnly: boolean;
9 | }
10 |
11 | export const ChatInfo = ({ isDelayed, isFollowersOnly }: ChatInfoProps) => {
12 | const hint = useMemo(() => {
13 | if (isFollowersOnly && !isDelayed) {
14 | return "Only followers can chat";
15 | }
16 |
17 | if (isDelayed && !isFollowersOnly) {
18 | return "Messages are delayed by 3 seconds";
19 | }
20 |
21 | if (isDelayed && isFollowersOnly) {
22 | return "Only followers can chat. Messages are delayed by 3 seconds";
23 | }
24 |
25 | return "";
26 | }, [isDelayed, isFollowersOnly]);
27 |
28 | const label = useMemo(() => {
29 | if (isFollowersOnly && !isDelayed) {
30 | return "Followers only";
31 | }
32 |
33 | if (isDelayed && !isFollowersOnly) {
34 | return "Slow mode";
35 | }
36 |
37 | if (isDelayed && isFollowersOnly) {
38 | return "Followers only and slow mode";
39 | }
40 |
41 | return "";
42 | }, [isDelayed, isFollowersOnly]);
43 |
44 | if (!isDelayed && !isFollowersOnly) {
45 | return null;
46 | }
47 |
48 | return (
49 |
50 |
51 |
52 |
53 |
{label}
54 |
55 | );
56 | };
57 |
--------------------------------------------------------------------------------
/components/ui/avatar.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as AvatarPrimitive from "@radix-ui/react-avatar"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Avatar = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 | ))
21 | Avatar.displayName = AvatarPrimitive.Root.displayName
22 |
23 | const AvatarImage = React.forwardRef<
24 | React.ElementRef,
25 | React.ComponentPropsWithoutRef
26 | >(({ className, ...props }, ref) => (
27 |
32 | ))
33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName
34 |
35 | const AvatarFallback = React.forwardRef<
36 | React.ElementRef,
37 | React.ComponentPropsWithoutRef
38 | >(({ className, ...props }, ref) => (
39 |
47 | ))
48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
49 |
50 | export { Avatar, AvatarImage, AvatarFallback }
51 |
--------------------------------------------------------------------------------
/app/(dashboard)/u/[username]/community/_components/columns.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Button } from "@/components/ui/button";
4 | import { UserAvatar } from "@/components/user-avatar";
5 | import { ColumnDef } from "@tanstack/react-table";
6 | import { ArrowUpDown } from "lucide-react";
7 | import { UnblockButton } from "./unblock-button";
8 |
9 | export type BlockedUser = {
10 | id: string;
11 | userId: string;
12 | imageUrl: string;
13 | username: string;
14 | createdAt: string;
15 | };
16 |
17 | export const columns: ColumnDef[] = [
18 | {
19 | accessorKey: "username",
20 | header: ({ column }) => (
21 |
28 | ),
29 | cell: ({ row }) => (
30 |
31 |
35 | {row.original.username}
36 |
37 | ),
38 | },
39 | {
40 | accessorKey: "createdAt",
41 | header: ({ column }) => (
42 |
49 | ),
50 | },
51 | {
52 | id: "actions",
53 | cell: ({ row }) => ,
54 | },
55 | ];
56 |
--------------------------------------------------------------------------------
/app/(browse)/_components/navbar/search.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import qs from "query-string";
4 | import { useState } from "react";
5 | import { SearchIcon, X } from "lucide-react";
6 | import { useRouter } from "next/navigation";
7 | import { Input } from "@/components/ui/input";
8 | import { Button } from "@/components/ui/button";
9 |
10 | export const Search = () => {
11 | const router = useRouter();
12 | const [value, setValue] = useState("");
13 |
14 | const onSubmit = (e: React.FormEvent) => {
15 | e.preventDefault();
16 |
17 | if (!value) return;
18 |
19 | const url = qs.stringifyUrl(
20 | {
21 | url: "/search",
22 | query: { term: value },
23 | },
24 | { skipEmptyString: true }
25 | );
26 |
27 | router.push(url);
28 | };
29 |
30 | const onClear = () => {
31 | setValue("");
32 | };
33 |
34 | return (
35 |
60 | );
61 | };
62 |
--------------------------------------------------------------------------------
/app/(browse)/_components/sidebar/toggle.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Button } from "@/components/ui/button";
4 | import { useSidebar } from "@/store/use-sidebar";
5 | import { ArrowLeftFromLine, ArrowRightFromLine } from "lucide-react";
6 | import { Hint } from "@/components/hint";
7 | import { Skeleton } from "@/components/ui/skeleton";
8 |
9 | export const Toogle = () => {
10 | const { collapsed, onExpand, onCollapse } = useSidebar((state) => state);
11 |
12 | const label = collapsed ? "Expand" : "Collapse";
13 |
14 | return (
15 | <>
16 | {collapsed && (
17 |
18 |
19 |
22 |
23 |
24 | )}
25 | {!collapsed && (
26 |
27 |
For you
28 |
29 |
36 |
37 |
38 | )}
39 | >
40 | );
41 | };
42 |
43 | export const ToggleSkeleton = () => {
44 | return (
45 |
46 |
47 |
48 |
49 | );
50 | };
51 |
--------------------------------------------------------------------------------
/components/user-avatar.tsx:
--------------------------------------------------------------------------------
1 | import { cva, type VariantProps } from "class-variance-authority";
2 | import { cn } from "@/lib/utils";
3 | import { Skeleton } from "./ui/skeleton";
4 | import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
5 | import { LiveBadge } from "./live-badge";
6 |
7 | const avatarSizes = cva("", {
8 | variants: {
9 | size: {
10 | default: "h-8 w-8",
11 | lg: "h-14 w-14",
12 | },
13 | },
14 | defaultVariants: {
15 | size: "default",
16 | },
17 | });
18 |
19 | interface UserAvatarProps extends VariantProps {
20 | username: string;
21 | imageUrl: string;
22 | isLive?: boolean;
23 | showBadge?: boolean;
24 | }
25 |
26 | export const UserAvatar = ({
27 | username,
28 | imageUrl,
29 | isLive,
30 | showBadge,
31 | size,
32 | }: UserAvatarProps) => {
33 | const canShowBadge = showBadge && isLive;
34 | return (
35 |
36 |
42 |
43 |
44 | {username[0]}
45 | {username[username.length - 1]}
46 |
47 |
48 | {canShowBadge && (
49 |
50 |
51 |
52 | )}
53 |
54 | );
55 | };
56 |
57 | interface UserAvatarSkeletonProps extends VariantProps {}
58 |
59 | export const UserAvatarSkeleton = ({ size }: UserAvatarSkeletonProps) => {
60 | return ;
61 | };
62 |
--------------------------------------------------------------------------------
/components/ui/alert.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { cva, type VariantProps } from "class-variance-authority"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const alertVariants = cva(
7 | "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
8 | {
9 | variants: {
10 | variant: {
11 | default: "bg-background text-foreground",
12 | destructive:
13 | "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
14 | },
15 | },
16 | defaultVariants: {
17 | variant: "default",
18 | },
19 | }
20 | )
21 |
22 | const Alert = React.forwardRef<
23 | HTMLDivElement,
24 | React.HTMLAttributes & VariantProps
25 | >(({ className, variant, ...props }, ref) => (
26 |
32 | ))
33 | Alert.displayName = "Alert"
34 |
35 | const AlertTitle = React.forwardRef<
36 | HTMLParagraphElement,
37 | React.HTMLAttributes
38 | >(({ className, ...props }, ref) => (
39 |
44 | ))
45 | AlertTitle.displayName = "AlertTitle"
46 |
47 | const AlertDescription = React.forwardRef<
48 | HTMLParagraphElement,
49 | React.HTMLAttributes
50 | >(({ className, ...props }, ref) => (
51 |
56 | ))
57 | AlertDescription.displayName = "AlertDescription"
58 |
59 | export { Alert, AlertTitle, AlertDescription }
60 |
--------------------------------------------------------------------------------
/components/ui/scroll-area.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const ScrollArea = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, children, ...props }, ref) => (
12 |
17 |
18 | {children}
19 |
20 |
21 |
22 |
23 | ))
24 | ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
25 |
26 | const ScrollBar = React.forwardRef<
27 | React.ElementRef,
28 | React.ComponentPropsWithoutRef
29 | >(({ className, orientation = "vertical", ...props }, ref) => (
30 |
43 |
44 |
45 | ))
46 | ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
47 |
48 | export { ScrollArea, ScrollBar }
49 |
--------------------------------------------------------------------------------
/app/(browse)/(home)/_components/result-card.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 | import { Stream, User } from "@prisma/client";
3 |
4 | import { Thumbnail, ThumbnailSkeleton } from "@/components/thumbnail";
5 | import { Skeleton } from "@/components/ui/skeleton";
6 | import { UserAvatar, UserAvatarSkeleton } from "@/components/user-avatar";
7 |
8 | interface ResultCardProps {
9 | data: {
10 | user: User;
11 | isLive: boolean;
12 | name: string;
13 | thumbnailUrl: string | null;
14 | };
15 | }
16 |
17 | export const ResultCard = ({ data }: ResultCardProps) => {
18 | return (
19 |
20 |
21 |
27 |
28 |
33 |
34 |
35 | {data.name}
36 |
37 |
{data.user.username}
38 |
39 |
40 |
41 |
42 | );
43 | };
44 |
45 | export const ResultCardSkeleton = () => {
46 | return (
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 | );
58 | };
59 |
--------------------------------------------------------------------------------
/app/(browse)/[username]/_components/actions.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { onBlock, onUnblock } from "@/actions/block";
3 | import { onFollow, onUnfollow } from "@/actions/follow";
4 | import { Button } from "@/components/ui/button";
5 | import { useTransition } from "react";
6 | import { toast } from "sonner";
7 |
8 | interface ActionProps {
9 | isFollowing: boolean;
10 | userId: string;
11 | }
12 |
13 | export const Actions = ({ isFollowing, userId }: ActionProps) => {
14 | const [isPending, startTransition] = useTransition();
15 |
16 | const handleFollow = () => {
17 | startTransition(() => {
18 | onFollow(userId)
19 | .then((data) =>
20 | toast.success(`You are now following ${data.following.username}`)
21 | )
22 | .catch(() => toast.error("Something went wrong"));
23 | });
24 | };
25 |
26 | const handleUnfollow = () => {
27 | startTransition(() => {
28 | onUnfollow(userId)
29 | .then((data) =>
30 | toast.success(`You have unfollowed ${data.following.username}`)
31 | )
32 | .catch(() => toast.error("Something went wrong"));
33 | });
34 | };
35 |
36 | const onClick = () => {
37 | if (isFollowing) {
38 | handleUnfollow();
39 | } else {
40 | handleFollow();
41 | }
42 | };
43 |
44 | const handleBlock = () => {
45 | startTransition(() => {
46 | onUnblock(userId)
47 | .then((data) =>
48 | toast.success(`Unblocked the user ${data.blocked.username}`)
49 | )
50 | .catch(() => toast.error("Something went wrong"));
51 | });
52 | };
53 |
54 | return (
55 | <>
56 |
59 |
62 | >
63 | );
64 | };
65 |
--------------------------------------------------------------------------------
/app/(browse)/_components/sidebar/user-item.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { usePathname } from "next/navigation";
3 | import { cn } from "@/lib/utils";
4 | import { Button } from "@/components/ui/button";
5 | import { useSidebar } from "@/store/use-sidebar";
6 | import { Skeleton } from "@/components/ui/skeleton";
7 | import Link from "next/link";
8 | import { UserAvatar } from "@/components/user-avatar";
9 | import { LiveBadge } from "@/components/live-badge";
10 |
11 | interface UserItemProps {
12 | username: string;
13 | imageUrl: string;
14 | isLive?: boolean;
15 | }
16 |
17 | export const UserItem = ({ username, imageUrl, isLive }: UserItemProps) => {
18 | const pathname = usePathname();
19 |
20 | const { collapsed } = useSidebar((state) => state);
21 |
22 | const href = `/${username}`;
23 | const isActive = pathname === href;
24 |
25 | return (
26 |
48 | );
49 | };
50 |
51 | export const UserItemSkeleton = () => {
52 | return (
53 |
54 |
55 |
56 |
57 |
58 |
59 | );
60 | };
61 |
--------------------------------------------------------------------------------
/components/thumbnail.tsx:
--------------------------------------------------------------------------------
1 | import Image from "next/image";
2 |
3 | import { Skeleton } from "@/components/ui/skeleton";
4 | import { LiveBadge } from "@/components/live-badge";
5 | import { UserAvatar } from "@/components/user-avatar";
6 |
7 | interface ThumbnailProps {
8 | src: string | null;
9 | fallback: string;
10 | isLive: boolean;
11 | username: string;
12 | };
13 |
14 | export const Thumbnail = ({
15 | src,
16 | fallback,
17 | isLive,
18 | username,
19 | }: ThumbnailProps) => {
20 | let content;
21 |
22 | if (!src) {
23 | content = (
24 |
25 |
32 |
33 | )
34 | } else {
35 | content = (
36 |
42 | )
43 | }
44 |
45 | return (
46 |
47 |
48 | {content}
49 | {isLive && src && (
50 |
51 |
52 |
53 | )}
54 |
55 | );
56 | };
57 |
58 | export const ThumbnailSkeleton = () => {
59 | return (
60 |
61 |
62 |
63 | );
64 | };
--------------------------------------------------------------------------------
/components/stream-player/community-item.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { toast } from "sonner";
3 | import { startTransition, useTransition } from "react";
4 | import { MinusCircle } from "lucide-react";
5 |
6 | import { Hint } from "../hint";
7 | import { onBlock } from "@/actions/block";
8 | import { cn, stringToColor } from "@/lib/utils";
9 | import { Button } from "../ui/button";
10 |
11 | interface CommunityItemProps {
12 | hostName: string;
13 | viewerName: string;
14 | participantName?: string;
15 | participantIdentity: string;
16 | }
17 |
18 | export const CommunityItem = ({
19 | hostName,
20 | viewerName,
21 | participantName,
22 | participantIdentity,
23 | }: CommunityItemProps) => {
24 | const [isPending, startTransition] = useTransition();
25 |
26 | const color = stringToColor(participantName || "");
27 | const isSelf = participantName === viewerName;
28 | const isHost = viewerName === hostName;
29 |
30 | const handleBlock = () => {
31 | if (!participantName || isSelf || !isHost) return;
32 |
33 | startTransition(() => {
34 | onBlock(participantIdentity)
35 | .then(() => toast.success(`Blocked ${participantName}`))
36 | .catch(() => toast.error("Something went wrong"));
37 | });
38 | };
39 |
40 | return (
41 |
47 |
{participantName}
48 | {isHost && !isSelf && (
49 |
50 |
58 |
59 | )}
60 |
61 | );
62 | };
63 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import { withUt } from "uploadthing/tw";
2 | /** @type {import('tailwindcss').Config} */
3 | module.exports = withUt({
4 | darkMode: ["class"],
5 | content: [
6 | "./pages/**/*.{ts,tsx}",
7 | "./components/**/*.{ts,tsx}",
8 | "./app/**/*.{ts,tsx}",
9 | "./src/**/*.{ts,tsx}",
10 | ],
11 | theme: {
12 | container: {
13 | center: true,
14 | padding: "2rem",
15 | screens: {
16 | "2xl": "1400px",
17 | },
18 | },
19 | extend: {
20 | colors: {
21 | border: "hsl(var(--border))",
22 | input: "hsl(var(--input))",
23 | ring: "hsl(var(--ring))",
24 | background: "hsl(var(--background))",
25 | foreground: "hsl(var(--foreground))",
26 | primary: {
27 | DEFAULT: "hsl(var(--primary))",
28 | foreground: "hsl(var(--primary-foreground))",
29 | },
30 | secondary: {
31 | DEFAULT: "hsl(var(--secondary))",
32 | foreground: "hsl(var(--secondary-foreground))",
33 | },
34 | destructive: {
35 | DEFAULT: "hsl(var(--destructive))",
36 | foreground: "hsl(var(--destructive-foreground))",
37 | },
38 | muted: {
39 | DEFAULT: "hsl(var(--muted))",
40 | foreground: "hsl(var(--muted-foreground))",
41 | },
42 | accent: {
43 | DEFAULT: "hsl(var(--accent))",
44 | foreground: "hsl(var(--accent-foreground))",
45 | },
46 | popover: {
47 | DEFAULT: "hsl(var(--popover))",
48 | foreground: "hsl(var(--popover-foreground))",
49 | },
50 | card: {
51 | DEFAULT: "hsl(var(--card))",
52 | foreground: "hsl(var(--card-foreground))",
53 | },
54 | },
55 | borderRadius: {
56 | lg: "var(--radius)",
57 | md: "calc(var(--radius) - 2px)",
58 | sm: "calc(var(--radius) - 4px)",
59 | },
60 | },
61 | },
62 | plugins: [require("tailwindcss-animate")],
63 | });
64 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "streaming",
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 | "postinstall": "prisma generate"
11 | },
12 | "dependencies": {
13 | "@clerk/nextjs": "^4.28.1",
14 | "@clerk/themes": "^1.7.9",
15 | "@livekit/components-react": "^1.5.3",
16 | "@prisma/client": "^5.7.1",
17 | "@radix-ui/react-avatar": "^1.0.4",
18 | "@radix-ui/react-dialog": "^1.0.5",
19 | "@radix-ui/react-label": "^2.0.2",
20 | "@radix-ui/react-scroll-area": "^1.0.5",
21 | "@radix-ui/react-select": "^2.0.0",
22 | "@radix-ui/react-separator": "^1.0.3",
23 | "@radix-ui/react-slider": "^1.1.2",
24 | "@radix-ui/react-slot": "^1.0.2",
25 | "@radix-ui/react-switch": "^1.0.3",
26 | "@radix-ui/react-tooltip": "^1.0.7",
27 | "@tanstack/react-table": "^8.12.0",
28 | "@uploadthing/react": "^6.2.4",
29 | "class-variance-authority": "^0.7.0",
30 | "clsx": "^2.0.0",
31 | "date-fns": "^3.3.1",
32 | "jwt-decode": "^4.0.0",
33 | "livekit-client": "^1.15.11",
34 | "livekit-server-sdk": "^1.2.7",
35 | "lucide-react": "^0.298.0",
36 | "next": "14.0.4",
37 | "next-themes": "^0.2.1",
38 | "query-string": "^8.1.0",
39 | "react": "^18",
40 | "react-dom": "^18",
41 | "sonner": "^1.3.1",
42 | "svix": "^1.15.0",
43 | "tailwind-merge": "^2.1.0",
44 | "tailwindcss-animate": "^1.0.7",
45 | "uploadthing": "^6.4.1",
46 | "usehooks-ts": "^2.9.1",
47 | "uuid": "^9.0.1",
48 | "zustand": "^4.4.7"
49 | },
50 | "devDependencies": {
51 | "@types/node": "^20",
52 | "@types/react": "^18",
53 | "@types/react-dom": "^18",
54 | "@types/uuid": "^9.0.8",
55 | "autoprefixer": "^10.0.1",
56 | "eslint": "^8",
57 | "eslint-config-next": "14.0.4",
58 | "postcss": "^8",
59 | "prisma": "^5.7.1",
60 | "tailwindcss": "^3.3.0",
61 | "typescript": "^5"
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/app/(browse)/search/_components/result-card.tsx:
--------------------------------------------------------------------------------
1 | import { Thumbnail, ThumbnailSkeleton } from "@/components/thumbnail";
2 | import { VerifiedMark } from "@/components/verified-mark";
3 | import { User } from "@prisma/client";
4 | import Link from "next/link";
5 | import { formatDistanceToNow } from "date-fns";
6 | import { Skeleton } from "@/components/ui/skeleton";
7 |
8 | interface ResultCardProps {
9 | data: {
10 | id: string;
11 | name: string;
12 | thumbnailUrl: string | null;
13 | isLive: boolean;
14 | updatedAt: Date;
15 | user: User;
16 | };
17 | }
18 |
19 | export const ResultCard = ({ data }: ResultCardProps) => {
20 | return (
21 |
22 |
23 |
24 |
30 |
31 |
32 |
33 |
34 | {data.user.username}
35 |
36 |
37 |
38 |
{data.name}
39 |
40 | {formatDistanceToNow(new Date(data.updatedAt), {
41 | addSuffix: true,
42 | })}
43 |
44 |
45 |
46 |
47 | );
48 | };
49 |
50 | export const ResultCardSkeleton = () => {
51 | return (
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 | );
63 | };
64 |
--------------------------------------------------------------------------------
/lib/search-service.ts:
--------------------------------------------------------------------------------
1 | import { db } from "@/lib/db";
2 | import { getSelf } from "@/lib/auth-service";
3 |
4 | export const getSearch = async (term?: string) => {
5 | let userId;
6 |
7 | try {
8 | const self = await getSelf();
9 | userId = self.id;
10 | } catch {
11 | userId = null;
12 | }
13 |
14 | let streams = [];
15 |
16 | if (userId) {
17 | streams = await db.stream.findMany({
18 | where: {
19 | user: {
20 | NOT: {
21 | blocking: {
22 | some: {
23 | blockedId: userId,
24 | },
25 | },
26 | },
27 | },
28 | OR: [
29 | {
30 | name: {
31 | contains: term,
32 | },
33 | },
34 | {
35 | user: {
36 | username: {
37 | contains: term,
38 | },
39 | },
40 | },
41 | ],
42 | },
43 | select: {
44 | user: true,
45 | id: true,
46 | name: true,
47 | isLive: true,
48 | thumbnailUrl: true,
49 | updatedAt: true,
50 | },
51 | orderBy: [
52 | {
53 | isLive: "desc",
54 | },
55 | {
56 | updatedAt: "desc",
57 | },
58 | ],
59 | });
60 | } else {
61 | streams = await db.stream.findMany({
62 | where: {
63 | OR: [
64 | {
65 | name: {
66 | contains: term,
67 | },
68 | },
69 | {
70 | user: {
71 | username: {
72 | contains: term,
73 | },
74 | },
75 | },
76 | ],
77 | },
78 | select: {
79 | user: true,
80 | id: true,
81 | name: true,
82 | isLive: true,
83 | thumbnailUrl: true,
84 | updatedAt: true,
85 | },
86 | orderBy: [
87 | {
88 | isLive: "desc",
89 | },
90 | {
91 | updatedAt: "desc",
92 | },
93 | ],
94 | });
95 | }
96 |
97 | return streams;
98 | };
99 |
--------------------------------------------------------------------------------
/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Slot } from "@radix-ui/react-slot";
3 | import { cva, type VariantProps } from "class-variance-authority";
4 |
5 | import { cn } from "@/lib/utils";
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
9 | {
10 | variants: {
11 | variant: {
12 | default: "bg-primary text-primary-foreground hover:bg-primary/90",
13 | destructive:
14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90",
15 | outline:
16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
17 | secondary:
18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80",
19 | ghost: "hover:bg-accent hover:text-accent-foreground",
20 | link: "text-primary underline-offset-4 hover:underline",
21 | primary: "text-primary bg-blue-600 hover:bg-blue-600/80",
22 | },
23 | size: {
24 | default: "h-10 px-4 py-2",
25 | sm: "h-9 rounded-md px-3",
26 | lg: "h-11 rounded-md px-8",
27 | icon: "h-10 w-10",
28 | },
29 | },
30 | defaultVariants: {
31 | variant: "default",
32 | size: "default",
33 | },
34 | }
35 | );
36 |
37 | export interface ButtonProps
38 | extends React.ButtonHTMLAttributes,
39 | VariantProps {
40 | asChild?: boolean;
41 | }
42 |
43 | const Button = React.forwardRef(
44 | ({ className, variant, size, asChild = false, ...props }, ref) => {
45 | const Comp = asChild ? Slot : "button";
46 | return (
47 |
52 | );
53 | }
54 | );
55 | Button.displayName = "Button";
56 |
57 | export { Button, buttonVariants };
58 |
--------------------------------------------------------------------------------
/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | html,
6 | body,
7 | :root {
8 | height: 100%;
9 | @apply bg-neutral-900/80;
10 | }
11 |
12 | .hidden-scrollbar {
13 | -ms-overflow-style: none;
14 | scrollbar-width: none;
15 | }
16 |
17 | .hidden-scrollbar::-webkit-scrollbar {
18 | display: none;
19 | }
20 |
21 | @layer base {
22 | :root {
23 | --background: 0 0% 100%;
24 | --foreground: 222.2 84% 4.9%;
25 |
26 | --card: 0 0% 100%;
27 | --card-foreground: 222.2 84% 4.9%;
28 |
29 | --popover: 0 0% 100%;
30 | --popover-foreground: 222.2 84% 4.9%;
31 |
32 | --primary: 222.2 47.4% 11.2%;
33 | --primary-foreground: 210 40% 98%;
34 |
35 | --secondary: 210 40% 96.1%;
36 | --secondary-foreground: 222.2 47.4% 11.2%;
37 |
38 | --muted: 210 40% 96.1%;
39 | --muted-foreground: 215.4 16.3% 46.9%;
40 |
41 | --accent: 210 40% 96.1%;
42 | --accent-foreground: 222.2 47.4% 11.2%;
43 |
44 | --destructive: 0 84.2% 60.2%;
45 | --destructive-foreground: 210 40% 98%;
46 |
47 | --border: 214.3 31.8% 91.4%;
48 | --input: 214.3 31.8% 91.4%;
49 | --ring: 222.2 84% 4.9%;
50 |
51 | --radius: 0.5rem;
52 | }
53 |
54 | .dark {
55 | --background: 226.7 12.7% 13.9%;
56 | --foreground: 210 40% 98%;
57 |
58 | --card: 226.7 12.7% 13.9%;
59 | --card-foreground: 210 40% 98%;
60 |
61 | --popover: 226.7 12.7% 13.9%;
62 | --popover-foreground: 210 40% 98%;
63 |
64 | --primary: 210 40% 98%;
65 | --primary-foreground: 222.2 47.4% 11.2%;
66 |
67 | --secondary: 226.7 12.7% 17.5%;
68 | --secondary-foreground: 210 40% 98%;
69 |
70 | --muted: 226.7 12.7% 17.5%;
71 | --muted-foreground: 215 20.2% 65.1%;
72 |
73 | --accent: 226.7 12.7% 17.5%;
74 | --accent-foreground: 210 40% 98%;
75 |
76 | --destructive: 0 62.8% 30.6%;
77 | --destructive-foreground: 210 40% 98%;
78 |
79 | --border: 226.7 12.7% 17.5%;
80 | --input: 226.7 12.7% 17.5%;
81 | --ring: 212.7 26.8% 83.9%;
82 | }
83 | }
84 |
85 | @layer base {
86 | * {
87 | @apply border-border;
88 | }
89 | body {
90 | @apply bg-background text-foreground;
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/components/stream-player/actions.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useRouter } from "next/navigation";
4 | import { useAuth } from "@clerk/nextjs";
5 | import { Button } from "../ui/button";
6 | import { Heart } from "lucide-react";
7 | import { cn } from "@/lib/utils";
8 | import { onFollow, onUnfollow } from "@/actions/follow";
9 | import { useTransition } from "react";
10 | import { toast } from "sonner";
11 | import { Skeleton } from "../ui/skeleton";
12 |
13 | interface ActionsProps {
14 | hostIdentity: string;
15 | isFollowing: boolean;
16 | isHost: boolean;
17 | }
18 |
19 | export const Actions = ({
20 | hostIdentity,
21 | isFollowing,
22 | isHost,
23 | }: ActionsProps) => {
24 | const [isPending, startTransition] = useTransition();
25 | const router = useRouter();
26 | const { userId } = useAuth();
27 |
28 | const handleFollow = () => {
29 | startTransition(() => {
30 | onFollow(hostIdentity)
31 | .then((data) =>
32 | toast.success(`You are now following ${data.following.username}`)
33 | )
34 | .catch(() => toast.error("Something went wrong"));
35 | });
36 | };
37 |
38 | const handleUnfollow = () => {
39 | startTransition(() => {
40 | onUnfollow(hostIdentity)
41 | .then((data) =>
42 | toast.success(`You have unfollowed ${data.following.username}`)
43 | )
44 | .catch(() => toast.error("Something went wrong"));
45 | });
46 | };
47 |
48 | const toggleFollow = () => {
49 | if (!userId) {
50 | return router.push("/sign-in");
51 | }
52 |
53 | if (isHost) return;
54 |
55 | if (isFollowing) {
56 | handleUnfollow();
57 | } else {
58 | handleFollow();
59 | }
60 | };
61 |
62 | return (
63 |
74 | );
75 | };
76 |
77 | export const ActionsSkeleton = () => {
78 | return ;
79 | };
80 |
--------------------------------------------------------------------------------
/components/stream-player/info-card.tsx:
--------------------------------------------------------------------------------
1 | "use-client";
2 |
3 | import { Pencil } from "lucide-react";
4 | import { Separator } from "../ui/separator";
5 | import Image from "next/image";
6 | import { InfoModal } from "./info-modal";
7 |
8 | interface InfoCardProps {
9 | name: string;
10 | thumbnailUrl: string | null;
11 | hostIdentity: string;
12 | viewerIdentity: string;
13 | }
14 |
15 | export const InfoCard = ({
16 | name,
17 | thumbnailUrl,
18 | hostIdentity,
19 | viewerIdentity,
20 | }: InfoCardProps) => {
21 | const hostAsViewer = `host-${hostIdentity}`;
22 | const isHost = viewerIdentity === hostAsViewer;
23 |
24 | if (!isHost) return null;
25 |
26 | return (
27 |
28 |
29 |
30 |
33 |
34 |
35 | Edit your stream info
36 |
37 |
38 | Maximize your visibility
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
Name
47 |
{name}
48 |
49 |
50 |
Thumbnail
51 | {thumbnailUrl && (
52 |
53 |
59 |
60 | )}
61 |
62 |
63 |
64 |
65 | );
66 | };
67 |
--------------------------------------------------------------------------------
/components/stream-player/bio-modal.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import {
4 | Dialog,
5 | DialogClose,
6 | DialogContent,
7 | DialogHeader,
8 | DialogTitle,
9 | DialogTrigger,
10 | } from "@/components/ui/dialog";
11 | import { Button } from "@/components/ui/button";
12 | import { Textarea } from "../ui/textarea";
13 | import { useState, useTransition, useRef, ElementRef } from "react";
14 | import { updateUser } from "@/actions/user";
15 | import { toast } from "sonner";
16 |
17 | interface BioModalProps {
18 | initialValue: string | null;
19 | }
20 |
21 | export const BioModal = ({ initialValue }: BioModalProps) => {
22 | const closeRef = useRef>(null);
23 | const [isPending, startTransition] = useTransition();
24 | const [value, setValue] = useState(initialValue || "");
25 |
26 | const onSubmit = (e: React.FormEvent) => {
27 | e.preventDefault();
28 |
29 | startTransition(() => {
30 | updateUser({ bio: value })
31 | .then(() => {
32 | toast.success("User bio updated");
33 | closeRef?.current?.click();
34 | })
35 | .catch(() => toast.error("Something went wrong"));
36 | });
37 | };
38 |
39 | return (
40 |
71 | );
72 | };
73 |
--------------------------------------------------------------------------------
/prisma/schema.prisma:
--------------------------------------------------------------------------------
1 | // This is your Prisma schema file,
2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema
3 |
4 | generator client {
5 | provider = "prisma-client-js"
6 | previewFeatures = ["fullTextSearch", "fullTextIndex"]
7 | }
8 |
9 | datasource db {
10 | provider = "mysql"
11 | url = env("DATABASE_URL")
12 | relationMode = "prisma"
13 | }
14 |
15 | model User {
16 | id String @id @default(uuid())
17 | username String @unique
18 | imageUrl String @db.Text
19 | externalUserId String @unique
20 | bio String? @db.Text
21 |
22 | following Follow[] @relation("Following")
23 | followedBy Follow[] @relation("FollowedBy")
24 |
25 | blocking Block[] @relation("Blocking")
26 | blockedBy Block[] @relation("BlockedBy")
27 |
28 | stream Stream?
29 |
30 | createdAt DateTime @default(now())
31 | updatedAt DateTime @updatedAt
32 | }
33 |
34 | model Stream {
35 | id String @id @default(uuid())
36 | name String @db.Text
37 | thumbnailUrl String? @db.Text
38 |
39 | ingressId String? @unique
40 | serverUrl String? @db.Text
41 | streamKey String? @db.Text
42 |
43 | isLive Boolean @default(false)
44 | isChatEnabled Boolean @default(true)
45 | isChatDelayed Boolean @default(false)
46 | isChatFollowersOnly Boolean @default(false)
47 |
48 | userId String @unique
49 | user User @relation(fields: [userId], references: [id], onDelete: Cascade)
50 |
51 | createdAt DateTime @default(now())
52 | updatedAt DateTime @updatedAt
53 |
54 | @@index([userId])
55 | @@index([ingressId])
56 | @@fulltext([name])
57 | }
58 |
59 | model Follow {
60 | id String @id @default(uuid())
61 | followerId String
62 | followingId String
63 |
64 | follower User @relation(name: "Following", fields: [followerId], references: [id], onDelete: Cascade)
65 | following User @relation(name: "FollowedBy", fields: [followingId], references: [id], onDelete: Cascade)
66 |
67 | createdAt DateTime @default(now())
68 | updatedAt DateTime @updatedAt
69 |
70 | @@unique([followerId, followingId])
71 | @@index([followerId])
72 | @@index([followingId])
73 | }
74 |
75 | model Block {
76 | id String @id @default(uuid())
77 | blockerId String
78 | blockedId String
79 |
80 | blocker User @relation(name: "Blocking", fields: [blockerId], references: [id], onDelete: Cascade)
81 | blocked User @relation(name: "BlockedBy", fields: [blockedId], references: [id], onDelete: Cascade)
82 |
83 | @@unique([blockerId,blockedId])
84 | @@index([blockerId])
85 | @@index([blockedId])
86 | }
--------------------------------------------------------------------------------
/actions/ingress.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import {
4 | IngressAudioEncodingPreset,
5 | IngressInput,
6 | IngressClient,
7 | IngressVideoEncodingPreset,
8 | RoomServiceClient,
9 | type CreateIngressOptions,
10 | } from "livekit-server-sdk";
11 |
12 | import { db } from "@/lib/db";
13 | import { getSelf } from "@/lib/auth-service";
14 | import { TrackSource } from "livekit-server-sdk/dist/proto/livekit_models";
15 | import { revalidatePath } from "next/cache";
16 |
17 | const roomService = new RoomServiceClient(
18 | process.env.LIVEKIT_API_URL!,
19 | process.env.LIVEKIT_API_KEY!,
20 | process.env.LIVEKIT_API_SECRET!
21 | );
22 |
23 | const ingressClient = new IngressClient(process.env.LIVEKIT_API_URL!);
24 |
25 | export const resetIngresses = async (hostIdentity: string) => {
26 | const ingresses = await ingressClient.listIngress({
27 | roomName: hostIdentity,
28 | });
29 |
30 | const rooms = await roomService.listRooms([hostIdentity]);
31 |
32 | for (const room of rooms) {
33 | await roomService.deleteRoom(room.name);
34 | }
35 |
36 | for (const ingress of ingresses) {
37 | if (ingress.ingressId) {
38 | await ingressClient.deleteIngress(ingress.ingressId);
39 | }
40 | }
41 | };
42 |
43 | export const createIngress = async (ingressType: IngressInput) => {
44 | const self = await getSelf();
45 |
46 | await resetIngresses(self.id);
47 |
48 | const options: CreateIngressOptions = {
49 | name: self.username,
50 | roomName: self.id,
51 | participantName: self.username,
52 | participantIdentity: self.id,
53 | };
54 |
55 | if (ingressType === IngressInput.WHIP_INPUT) {
56 | options.bypassTranscoding = true;
57 | } else {
58 | options.video = {
59 | source: TrackSource.CAMERA,
60 | preset: IngressVideoEncodingPreset.H264_1080P_30FPS_3_LAYERS,
61 | };
62 | options.audio = {
63 | source: TrackSource.MICROPHONE,
64 | preset: IngressAudioEncodingPreset.OPUS_STEREO_96KBPS,
65 | };
66 | }
67 |
68 | const ingress = await ingressClient.createIngress(ingressType, options);
69 |
70 | if (!ingress || !ingress.url || !ingress.streamKey) {
71 | throw new Error("Failed to create ingress");
72 | }
73 |
74 | await db.stream.update({
75 | where: {
76 | userId: self.id,
77 | },
78 | data: {
79 | ingressId: ingress.ingressId,
80 | serverUrl: ingress.url,
81 | streamKey: ingress.streamKey,
82 | },
83 | });
84 |
85 | revalidatePath(`/u/${self.username}/keys`);
86 | return ingress;
87 | };
88 |
--------------------------------------------------------------------------------
/components/stream-player/chat-community.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useParticipants } from "@livekit/components-react";
4 | import { useMemo, useState } from "react";
5 | import { useDebounce } from "usehooks-ts";
6 | import { Input } from "../ui/input";
7 | import { ScrollArea } from "../ui/scroll-area";
8 | import { CommunityItem } from "./community-item";
9 | import { LocalParticipant, RemoteParticipant } from "livekit-client";
10 |
11 | interface ChatCommunityProps {
12 | hostName: string;
13 | viewerName: string;
14 | isHidden: boolean;
15 | }
16 |
17 | export const ChatCommunity = ({
18 | hostName,
19 | viewerName,
20 | isHidden,
21 | }: ChatCommunityProps) => {
22 | const [value, setValue] = useState("");
23 | const debouncedValue = useDebounce(value, 500);
24 |
25 | const participants = useParticipants();
26 |
27 | const onChange = (newValue: string) => {
28 | setValue(newValue);
29 | };
30 |
31 | const filteredParticipants = useMemo(() => {
32 | const deduped = participants.reduce((acc, participant) => {
33 | const hostAsViewer = `host-${participant.identity}`;
34 |
35 | if (!acc.some((p) => p.identity === hostAsViewer)) {
36 | acc.push(participant);
37 | }
38 | return acc;
39 | }, [] as (RemoteParticipant | LocalParticipant)[]);
40 |
41 | return deduped.filter((participant) => {
42 | return participant.name
43 | ?.toLowerCase()
44 | .includes(debouncedValue.toLowerCase());
45 | });
46 | }, [participants, debouncedValue]);
47 |
48 | if (isHidden) {
49 | return (
50 |
51 |
Community is disabled
52 |
53 | );
54 | }
55 |
56 | return (
57 |
58 |
onChange(e.target.value)}
60 | placeholder="Search community"
61 | className="border-white/10"
62 | />
63 |
64 |
65 | No results
66 |
67 | {filteredParticipants.map((participant) => (
68 |
75 | ))}
76 |
77 |
78 | );
79 | };
80 |
--------------------------------------------------------------------------------
/components/stream-player/chat-form.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useState } from "react";
4 |
5 | import { cn } from "@/lib/utils";
6 | import { Input } from "../ui/input";
7 | import { Button } from "../ui/button";
8 | import { Skeleton } from "../ui/skeleton";
9 | import { ChatInfo } from "./chat-info";
10 |
11 | interface ChatFormProps {
12 | onSubmit: () => void;
13 | value: string;
14 | onChange: (value: string) => void;
15 | isHidden: boolean;
16 | isFollowersOnly: boolean;
17 | isDelayed: boolean;
18 | isEnabled: boolean;
19 | isFollowing: boolean;
20 | }
21 |
22 | export const ChatForm = ({
23 | onSubmit,
24 | value,
25 | onChange,
26 | isHidden,
27 | isFollowersOnly,
28 | isDelayed,
29 | isEnabled,
30 | isFollowing,
31 | }: ChatFormProps) => {
32 | const [isDelayBlocked, setIsDelayBlocked] = useState(false);
33 |
34 | const isFollowersOnlyAndNotFollowing = isFollowersOnly && !isFollowing;
35 | const isDisabled =
36 | isHidden || isDelayBlocked || isFollowersOnlyAndNotFollowing;
37 |
38 | const handleSubmit = (e: React.FormEvent) => {
39 | e.preventDefault();
40 | e.stopPropagation();
41 |
42 | if (!value || isDisabled) return;
43 |
44 | if (isDelayed && !isDelayBlocked) {
45 | setIsDelayBlocked(true);
46 | setTimeout(() => {
47 | setIsDelayBlocked(false);
48 | onSubmit();
49 | }, 3000);
50 | } else {
51 | onSubmit();
52 | }
53 | };
54 |
55 | if (isHidden) {
56 | return null;
57 | }
58 |
59 | return (
60 |
83 | );
84 | };
85 |
86 | export const ChatFormSkeleton = () => {
87 | return (
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 | );
96 | };
97 |
--------------------------------------------------------------------------------
/app/api/webhooks/clerk/route.ts:
--------------------------------------------------------------------------------
1 | import { Webhook } from "svix";
2 | import { headers } from "next/headers";
3 | import { WebhookEvent } from "@clerk/nextjs/server";
4 | import { db } from "@/lib/db";
5 | import { resetIngresses } from "@/actions/ingress";
6 |
7 | export async function POST(req: Request) {
8 | // You can find this in the Clerk Dashboard -> Webhooks -> choose the webhook
9 | const WEBHOOK_SECRET = process.env.CLERK_WEBHOOK_SECRET;
10 |
11 | if (!WEBHOOK_SECRET) {
12 | throw new Error(
13 | "Please add WEBHOOK_SECRET from Clerk Dashboard to .env or .env.local"
14 | );
15 | }
16 |
17 | // Get the headers
18 | const headerPayload = headers();
19 | const svix_id = headerPayload.get("svix-id");
20 | const svix_timestamp = headerPayload.get("svix-timestamp");
21 | const svix_signature = headerPayload.get("svix-signature");
22 |
23 | // If there are no headers, error out
24 | if (!svix_id || !svix_timestamp || !svix_signature) {
25 | return new Response("Error occured -- no svix headers", {
26 | status: 400,
27 | });
28 | }
29 |
30 | // Get the body
31 | const payload = await req.json();
32 | const body = JSON.stringify(payload);
33 |
34 | // Create a new Svix instance with your secret.
35 | const wh = new Webhook(WEBHOOK_SECRET);
36 |
37 | let evt: WebhookEvent;
38 |
39 | // Verify the payload with the headers
40 | try {
41 | evt = wh.verify(body, {
42 | "svix-id": svix_id,
43 | "svix-timestamp": svix_timestamp,
44 | "svix-signature": svix_signature,
45 | }) as WebhookEvent;
46 | } catch (err) {
47 | console.error("Error verifying webhook:", err);
48 | return new Response("Error occured", {
49 | status: 400,
50 | });
51 | }
52 |
53 | const eventType = evt.type;
54 |
55 | if (eventType === "user.created") {
56 | await db.user.create({
57 | data: {
58 | externalUserId: payload.data.id,
59 | username: payload.data.username,
60 | imageUrl: payload.data.image_url,
61 | stream: {
62 | create: {
63 | name: `${payload.data.username}'s stream`,
64 | },
65 | },
66 | },
67 | });
68 | }
69 |
70 | if (eventType === "user.updated") {
71 | await db.user.update({
72 | where: {
73 | externalUserId: payload.data.id,
74 | },
75 | data: {
76 | username: payload.data.username,
77 | imageUrl: payload.data.image_url,
78 | },
79 | });
80 | }
81 |
82 | if (eventType === "user.deleted") {
83 | await resetIngresses(payload.data.id);
84 |
85 | await db.user.delete({
86 | where: {
87 | externalUserId: payload.data.id,
88 | },
89 | });
90 | }
91 | return new Response("", { status: 200 });
92 | }
93 |
--------------------------------------------------------------------------------
/components/stream-player/live-video.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Participant, Track } from "livekit-client";
4 | import { useRef, useState, useEffect } from "react";
5 | import { useTracks } from "@livekit/components-react";
6 | import { FullscreenControl } from "./fullscreen-control";
7 | import { useEventListener } from "usehooks-ts";
8 | import { VolumeControl } from "./volume-control";
9 |
10 | interface LiveVideoProps {
11 | participant: Participant;
12 | }
13 |
14 | export const LiveVideo = ({ participant }: LiveVideoProps) => {
15 | const videoRef = useRef(null);
16 | const wrapperRef = useRef(null);
17 |
18 | const [isFullscreen, setIsFullscreen] = useState(false);
19 | const [volume, setVolume] = useState(0);
20 |
21 | const onVolumeChange = (value: number) => {
22 | setVolume(+value);
23 | if (videoRef?.current) {
24 | videoRef.current.muted = value === 0;
25 | videoRef.current.volume = +value * 0.01;
26 | }
27 | };
28 |
29 | const toggleMute = () => {
30 | const isMuted = volume === 0;
31 |
32 | setVolume(isMuted ? 50 : 0);
33 |
34 | if (videoRef.current) {
35 | videoRef.current.muted = !isMuted;
36 | videoRef.current.volume = isMuted ? 0.5 : 0;
37 | }
38 | };
39 |
40 | useEffect(() => {
41 | onVolumeChange(0);
42 | }, []);
43 |
44 | const toggleFullScreen = () => {
45 | if (isFullscreen) {
46 | document.exitFullscreen();
47 | } else if (wrapperRef?.current) {
48 | wrapperRef.current.requestFullscreen();
49 | }
50 | };
51 |
52 | const handleFullscreenChange = () => {
53 | const isCurrentlyFullscreen = document.fullscreenElement !== null;
54 | setIsFullscreen(isCurrentlyFullscreen);
55 | };
56 |
57 | useEventListener("fullscreenchange", handleFullscreenChange, wrapperRef);
58 |
59 | useTracks([Track.Source.Camera, Track.Source.Microphone])
60 | .filter((track) => track.participant.identity === participant.identity)
61 | .forEach((track) => {
62 | if (videoRef.current) {
63 | track.publication.track?.attach(videoRef.current);
64 | }
65 | });
66 |
67 | return (
68 |
84 | );
85 | };
86 |
--------------------------------------------------------------------------------
/components/stream-player/header.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import {
4 | useParticipants,
5 | useRemoteParticipant,
6 | } from "@livekit/components-react";
7 | import { UserAvatar, UserAvatarSkeleton } from "../user-avatar";
8 | import { VerifiedMark } from "../verified-mark";
9 | import { UserIcon } from "lucide-react";
10 | import { Actions, ActionsSkeleton } from "./actions";
11 | import { Skeleton } from "../ui/skeleton";
12 |
13 | interface HeaderProps {
14 | imageUrl: string;
15 | hostName: string;
16 | hostIdentity: string;
17 | viewerIdentity: string;
18 | isFollowing: boolean;
19 | name: string;
20 | }
21 |
22 | export const Header = ({
23 | imageUrl,
24 | hostName,
25 | hostIdentity,
26 | viewerIdentity,
27 | isFollowing,
28 | name,
29 | }: HeaderProps) => {
30 | const participants = useParticipants();
31 | const participant = useRemoteParticipant(hostIdentity);
32 |
33 | const isLive = !!participant;
34 | const participantCount = participants.length - 1;
35 |
36 | const hostAsViewer = `host-${hostIdentity}`;
37 | const isHost = viewerIdentity === hostAsViewer;
38 |
39 | return (
40 |
41 |
42 |
49 |
50 |
51 |
{hostName}
52 |
53 |
54 |
{name}
55 | {isLive ? (
56 |
57 |
58 |
59 | {participantCount}{" "}
60 | {participantCount === 1 ? "viewer" : "viewers"}
61 |
62 |
63 | ) : (
64 |
65 | Offline
66 |
67 | )}
68 |
69 |
70 |
75 |
76 | );
77 | };
78 |
79 | export const HeaderSkeleton = () => {
80 | return (
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 | );
92 | };
93 |
--------------------------------------------------------------------------------
/app/(dashboard)/u/[username]/keys/_components/connect-modal.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { IngressInput } from "livekit-server-sdk";
4 | import { Button } from "@/components/ui/button";
5 | import {
6 | Dialog,
7 | DialogClose,
8 | DialogContent,
9 | DialogHeader,
10 | DialogTitle,
11 | DialogTrigger,
12 | } from "@/components/ui/dialog";
13 | import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
14 | import {
15 | Select,
16 | SelectContent,
17 | SelectItem,
18 | SelectTrigger,
19 | SelectValue,
20 | } from "@/components/ui/select";
21 | import { AlertTriangle } from "lucide-react";
22 | import { useState, useTransition, useRef, ElementRef } from "react";
23 | import { createIngress } from "@/actions/ingress";
24 | import { toast } from "sonner";
25 |
26 | const RTMP = String(IngressInput.RTMP_INPUT);
27 | const WHIP = String(IngressInput.WHIP_INPUT);
28 |
29 | type IngressType = typeof RTMP | typeof WHIP;
30 |
31 | export const ConnectModal = () => {
32 | const closeRef = useRef>(null);
33 | const [isPending, startTransition] = useTransition();
34 | const [ingressType, setIngressType] = useState(RTMP);
35 |
36 | const onSubmit = () => {
37 | startTransition(() => {
38 | createIngress(parseInt(ingressType))
39 | .then(() => {
40 | toast.success("Ingress created");
41 | closeRef?.current?.click();
42 | })
43 | .catch(() => toast.error("Something went wrong"));
44 | });
45 | };
46 |
47 | return (
48 |
87 | );
88 | };
89 |
--------------------------------------------------------------------------------
/lib/block-service.ts:
--------------------------------------------------------------------------------
1 | import { db } from "@/lib/db";
2 | import { getSelf } from "@/lib/auth-service";
3 |
4 | export const isBlockedByUser = async (id: string) => {
5 | try {
6 | const self = await getSelf();
7 |
8 | const otherUser = await db.user.findUnique({
9 | where: { id },
10 | });
11 |
12 | if (!otherUser) {
13 | throw new Error("User not found");
14 | }
15 |
16 | if (otherUser.id === self.id) {
17 | return false;
18 | }
19 |
20 | const existingBlock = await db.block.findUnique({
21 | where: {
22 | blockerId_blockedId: {
23 | blockerId: otherUser.id,
24 | blockedId: self.id,
25 | },
26 | },
27 | });
28 |
29 | return !!existingBlock;
30 | } catch {
31 | return false;
32 | }
33 | };
34 |
35 | export const blockUser = async (id: string) => {
36 | const self = await getSelf();
37 |
38 | if (self.id === id) {
39 | throw new Error("Cannot block yourself");
40 | }
41 |
42 | const otherUser = await db.user.findUnique({
43 | where: {
44 | id,
45 | },
46 | });
47 |
48 | if (!otherUser) {
49 | throw new Error("User not found");
50 | }
51 |
52 | const existingBlock = await db.block.findUnique({
53 | where: {
54 | blockerId_blockedId: {
55 | blockerId: self.id,
56 | blockedId: otherUser.id,
57 | },
58 | },
59 | });
60 |
61 | if (existingBlock) {
62 | throw new Error("Already blocked");
63 | }
64 |
65 | const block = await db.block.create({
66 | data: {
67 | blockerId: self.id,
68 | blockedId: otherUser.id,
69 | },
70 | include: {
71 | blocked: true,
72 | },
73 | });
74 |
75 | return block;
76 | };
77 |
78 | export const unblockUser = async (id: string) => {
79 | const self = await getSelf();
80 |
81 | if (self.id === id) {
82 | throw new Error("Cannot unblock yourself");
83 | }
84 |
85 | const otherUser = await db.user.findUnique({
86 | where: { id },
87 | });
88 |
89 | if (!otherUser) {
90 | throw new Error("User not found");
91 | }
92 |
93 | const existingBlock = await db.block.findUnique({
94 | where: {
95 | blockerId_blockedId: {
96 | blockerId: self.id,
97 | blockedId: otherUser.id,
98 | },
99 | },
100 | });
101 |
102 | if (!existingBlock) {
103 | throw new Error("Not blocked");
104 | }
105 |
106 | const unblock = await db.block.delete({
107 | where: {
108 | id: existingBlock.id,
109 | },
110 | include: {
111 | blocked: true,
112 | },
113 | });
114 |
115 | return unblock;
116 | };
117 |
118 | export const getBlockedUsers = async () => {
119 | const self = await getSelf();
120 |
121 | const blockedUsers = await db.block.findMany({
122 | where: {
123 | blockerId: self.id,
124 | },
125 | include: {
126 | blocked: true,
127 | },
128 | });
129 |
130 | return blockedUsers;
131 | };
132 |
--------------------------------------------------------------------------------
/components/ui/table.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | const Table = React.forwardRef<
6 | HTMLTableElement,
7 | React.HTMLAttributes
8 | >(({ className, ...props }, ref) => (
9 |
16 | ))
17 | Table.displayName = "Table"
18 |
19 | const TableHeader = React.forwardRef<
20 | HTMLTableSectionElement,
21 | React.HTMLAttributes
22 | >(({ className, ...props }, ref) => (
23 |
24 | ))
25 | TableHeader.displayName = "TableHeader"
26 |
27 | const TableBody = React.forwardRef<
28 | HTMLTableSectionElement,
29 | React.HTMLAttributes
30 | >(({ className, ...props }, ref) => (
31 |
36 | ))
37 | TableBody.displayName = "TableBody"
38 |
39 | const TableFooter = React.forwardRef<
40 | HTMLTableSectionElement,
41 | React.HTMLAttributes
42 | >(({ className, ...props }, ref) => (
43 | tr]:last:border-b-0",
47 | className
48 | )}
49 | {...props}
50 | />
51 | ))
52 | TableFooter.displayName = "TableFooter"
53 |
54 | const TableRow = React.forwardRef<
55 | HTMLTableRowElement,
56 | React.HTMLAttributes
57 | >(({ className, ...props }, ref) => (
58 |
66 | ))
67 | TableRow.displayName = "TableRow"
68 |
69 | const TableHead = React.forwardRef<
70 | HTMLTableCellElement,
71 | React.ThHTMLAttributes
72 | >(({ className, ...props }, ref) => (
73 | |
81 | ))
82 | TableHead.displayName = "TableHead"
83 |
84 | const TableCell = React.forwardRef<
85 | HTMLTableCellElement,
86 | React.TdHTMLAttributes
87 | >(({ className, ...props }, ref) => (
88 | |
93 | ))
94 | TableCell.displayName = "TableCell"
95 |
96 | const TableCaption = React.forwardRef<
97 | HTMLTableCaptionElement,
98 | React.HTMLAttributes
99 | >(({ className, ...props }, ref) => (
100 |
105 | ))
106 | TableCaption.displayName = "TableCaption"
107 |
108 | export {
109 | Table,
110 | TableHeader,
111 | TableBody,
112 | TableFooter,
113 | TableHead,
114 | TableRow,
115 | TableCell,
116 | TableCaption,
117 | }
118 |
--------------------------------------------------------------------------------
/components/stream-player/chat.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { ChatVariant, useChatSidebar } from "@/store/use-chat-sidebar";
4 | import { ConnectionState } from "livekit-client";
5 | import {
6 | useChat,
7 | useConnectionState,
8 | useRemoteParticipant,
9 | } from "@livekit/components-react";
10 | import { useMediaQuery } from "usehooks-ts";
11 | import { useEffect, useMemo, useState } from "react";
12 | import { ChatHeader, ChatHeaderSkeleton } from "./chat-header";
13 | import { ChatForm, ChatFormSkeleton } from "./chat-form";
14 | import { ChatList, ChatListSkeleton } from "./chat-list";
15 | import { ChatCommunity } from "./chat-community";
16 |
17 | interface ChatProps {
18 | hostName: string;
19 | hostIdentity: string;
20 | viewerName: string;
21 | isFollowing: boolean;
22 | isChatEnabled: boolean;
23 | isChatDelayed: boolean;
24 | isChatFollowersOnly: boolean;
25 | }
26 |
27 | export const Chat = ({
28 | hostName,
29 | hostIdentity,
30 | viewerName,
31 | isFollowing,
32 | isChatEnabled,
33 | isChatDelayed,
34 | isChatFollowersOnly,
35 | }: ChatProps) => {
36 | const matches = useMediaQuery("(max-width: 1024px)");
37 | const { variant, onExpand } = useChatSidebar((state) => state);
38 | const connectionState = useConnectionState();
39 | const participant = useRemoteParticipant(hostIdentity);
40 |
41 | const isOnline = participant && connectionState === ConnectionState.Connected;
42 |
43 | const isHidden = !isChatEnabled || !isOnline;
44 |
45 | const [value, setValue] = useState("");
46 | const { chatMessages: messages, send } = useChat();
47 |
48 | useEffect(() => {
49 | if (matches) {
50 | onExpand();
51 | }
52 | }, [matches, onExpand]);
53 |
54 | const reversedMessages = useMemo(() => {
55 | return messages.sort((a, b) => b.timestamp - a.timestamp);
56 | }, [messages]);
57 |
58 | const onSubmit = () => {
59 | if (!send) return;
60 |
61 | send(value);
62 | setValue("");
63 | };
64 |
65 | const onChange = (value: string) => {
66 | setValue(value);
67 | };
68 |
69 | return (
70 |
71 |
72 | {variant === ChatVariant.CHAT && (
73 | <>
74 |
75 |
85 | >
86 | )}
87 | {variant === ChatVariant.COMMUNITY && (
88 | <>
89 |
94 | >
95 | )}
96 |
97 | );
98 | };
99 |
100 | export const ChatSkeleton = () => {
101 | return (
102 |
103 |
104 |
105 |
106 |
107 | );
108 | };
109 |
--------------------------------------------------------------------------------
/lib/follow-service.ts:
--------------------------------------------------------------------------------
1 | import { db } from "./db";
2 | import { getSelf } from "./auth-service";
3 |
4 | export const getFollowedUsers = async () => {
5 | try {
6 | const self = await getSelf();
7 |
8 | const followedUsers = db.follow.findMany({
9 | where: {
10 | followerId: self.id,
11 | following: {
12 | blocking: {
13 | none: {
14 | blockedId: self.id,
15 | },
16 | },
17 | },
18 | },
19 | include: {
20 | following: {
21 | include: {
22 | stream: {
23 | select: {
24 | isLive: true,
25 | },
26 | },
27 | },
28 | },
29 | },
30 | });
31 |
32 | return followedUsers;
33 | } catch {
34 | return [];
35 | }
36 | };
37 |
38 | export const isFollowingUser = async (id: string) => {
39 | try {
40 | const self = await getSelf();
41 |
42 | const otherUser = await db.user.findUnique({
43 | where: {
44 | id,
45 | },
46 | });
47 |
48 | if (!otherUser) {
49 | throw new Error("User not found");
50 | }
51 |
52 | if (otherUser.id === self.id) {
53 | return true;
54 | }
55 |
56 | const existingFollow = await db.follow.findFirst({
57 | where: {
58 | followerId: self.id,
59 | followingId: otherUser.id,
60 | },
61 | });
62 |
63 | return !!existingFollow;
64 | } catch {
65 | return false;
66 | }
67 | };
68 |
69 | export const followUser = async (id: string) => {
70 | const self = await getSelf();
71 |
72 | const otherUser = await db.user.findUnique({
73 | where: {
74 | id,
75 | },
76 | });
77 |
78 | if (!otherUser) {
79 | throw new Error("User not found");
80 | }
81 |
82 | if (otherUser.id === self.id) {
83 | throw new Error("Cannot follow yourself");
84 | }
85 |
86 | const existingFollow = await db.follow.findFirst({
87 | where: {
88 | followerId: self.id,
89 | followingId: otherUser.id,
90 | },
91 | });
92 |
93 | if (existingFollow) {
94 | throw new Error("Already following");
95 | }
96 |
97 | const follow = await db.follow.create({
98 | data: {
99 | followerId: self.id,
100 | followingId: otherUser.id,
101 | },
102 | include: {
103 | following: true,
104 | follower: true,
105 | },
106 | });
107 |
108 | return follow;
109 | };
110 |
111 | export const unfollowUser = async (id: string) => {
112 | const self = await getSelf();
113 | const otherUser = await db.user.findUnique({
114 | where: {
115 | id,
116 | },
117 | });
118 |
119 | if (!otherUser) {
120 | throw new Error("User not found");
121 | }
122 |
123 | if (otherUser.id === self.id) {
124 | throw new Error("Cannot unfollow yourself");
125 | }
126 |
127 | const existingFollow = await db.follow.findFirst({
128 | where: {
129 | followerId: self.id,
130 | followingId: otherUser.id,
131 | },
132 | });
133 |
134 | if (!existingFollow) {
135 | throw new Error("Not following");
136 | }
137 |
138 | const follow = await db.follow.delete({
139 | where: {
140 | id: existingFollow.id,
141 | },
142 | include: {
143 | following: true,
144 | },
145 | });
146 |
147 | return follow;
148 | };
149 |
--------------------------------------------------------------------------------
/components/stream-player/index.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useViewerToken } from "@/hooks/use-viewer-token";
4 | import { Stream, User } from "@prisma/client";
5 | import { LiveKitRoom } from "@livekit/components-react";
6 | import { useChatSidebar } from "@/store/use-chat-sidebar";
7 | import { cn } from "@/lib/utils";
8 | import { Video, VideoSkeleton } from "./video";
9 | import { Chat, ChatSkeleton } from "./chat";
10 | import { ChatToggle } from "./chat-toggle";
11 | import { Header, HeaderSkeleton } from "./header";
12 | import { InfoCard } from "./info-card";
13 | import { AboutCard } from "./about-card";
14 |
15 | type CustomStream = {
16 | id: string;
17 | isChatEnabled: boolean;
18 | isChatDelayed: boolean;
19 | isChatFollowersOnly: boolean;
20 | isLive: boolean;
21 | thumbnailUrl: string | null;
22 | name: string;
23 | };
24 |
25 | type CustomUser = {
26 | id: string;
27 | username: string;
28 | bio: string | null;
29 | stream: CustomStream | null;
30 | imageUrl: string;
31 | _count: { followedBy: number };
32 | };
33 |
34 | interface StreamPlayerProps {
35 | user: CustomUser;
36 | stream: CustomStream;
37 | isFollowing: boolean;
38 | }
39 |
40 | export const StreamPlayer = ({
41 | user,
42 | stream,
43 | isFollowing,
44 | }: StreamPlayerProps) => {
45 | const { token, name, identity } = useViewerToken(user.id);
46 |
47 | const { collapsed } = useChatSidebar((state) => state);
48 |
49 | if (!token || !name || !identity) {
50 | return ;
51 | }
52 | return (
53 | <>
54 | {collapsed && (
55 |
56 |
57 |
58 | )}
59 |
67 |
91 |
92 |
101 |
102 |
103 | >
104 | );
105 | };
106 |
107 | export const StreamPlayerSkeleton = () => {
108 | return (
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 | );
119 | };
120 |
--------------------------------------------------------------------------------
/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as DialogPrimitive from "@radix-ui/react-dialog"
5 | import { X } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const Dialog = DialogPrimitive.Root
10 |
11 | const DialogTrigger = DialogPrimitive.Trigger
12 |
13 | const DialogPortal = DialogPrimitive.Portal
14 |
15 | const DialogClose = DialogPrimitive.Close
16 |
17 | const DialogOverlay = React.forwardRef<
18 | React.ElementRef,
19 | React.ComponentPropsWithoutRef
20 | >(({ className, ...props }, ref) => (
21 |
29 | ))
30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
31 |
32 | const DialogContent = React.forwardRef<
33 | React.ElementRef,
34 | React.ComponentPropsWithoutRef
35 | >(({ className, children, ...props }, ref) => (
36 |
37 |
38 |
46 | {children}
47 |
48 |
49 | Close
50 |
51 |
52 |
53 | ))
54 | DialogContent.displayName = DialogPrimitive.Content.displayName
55 |
56 | const DialogHeader = ({
57 | className,
58 | ...props
59 | }: React.HTMLAttributes) => (
60 |
67 | )
68 | DialogHeader.displayName = "DialogHeader"
69 |
70 | const DialogFooter = ({
71 | className,
72 | ...props
73 | }: React.HTMLAttributes) => (
74 |
81 | )
82 | DialogFooter.displayName = "DialogFooter"
83 |
84 | const DialogTitle = React.forwardRef<
85 | React.ElementRef,
86 | React.ComponentPropsWithoutRef
87 | >(({ className, ...props }, ref) => (
88 |
96 | ))
97 | DialogTitle.displayName = DialogPrimitive.Title.displayName
98 |
99 | const DialogDescription = React.forwardRef<
100 | React.ElementRef,
101 | React.ComponentPropsWithoutRef
102 | >(({ className, ...props }, ref) => (
103 |
108 | ))
109 | DialogDescription.displayName = DialogPrimitive.Description.displayName
110 |
111 | export {
112 | Dialog,
113 | DialogPortal,
114 | DialogOverlay,
115 | DialogClose,
116 | DialogTrigger,
117 | DialogContent,
118 | DialogHeader,
119 | DialogFooter,
120 | DialogTitle,
121 | DialogDescription,
122 | }
123 |
--------------------------------------------------------------------------------
/app/(dashboard)/u/[username]/community/_components/data-table.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import { Button } from "@/components/ui/button";
5 | import { Input } from "@/components/ui/input";
6 | import {
7 | ColumnDef,
8 | ColumnFiltersState,
9 | SortingState,
10 | flexRender,
11 | getCoreRowModel,
12 | getFilteredRowModel,
13 | getPaginationRowModel,
14 | getSortedRowModel,
15 | useReactTable,
16 | } from "@tanstack/react-table";
17 |
18 | import {
19 | Table,
20 | TableBody,
21 | TableCell,
22 | TableHead,
23 | TableHeader,
24 | TableRow,
25 | } from "@/components/ui/table";
26 |
27 | interface DataTableProps {
28 | columns: ColumnDef[];
29 | data: TData[];
30 | }
31 |
32 | export function DataTable({
33 | columns,
34 | data,
35 | }: DataTableProps) {
36 | const [sorting, setSorting] = React.useState([]);
37 | const [columnFilters, setColumnFilters] = React.useState(
38 | []
39 | );
40 |
41 | const table = useReactTable({
42 | data,
43 | columns,
44 | getCoreRowModel: getCoreRowModel(),
45 | getPaginationRowModel: getPaginationRowModel(),
46 | onSortingChange: setSorting,
47 | getSortedRowModel: getSortedRowModel(),
48 | onColumnFiltersChange: setColumnFilters,
49 | getFilteredRowModel: getFilteredRowModel(),
50 | state: {
51 | sorting,
52 | columnFilters,
53 | },
54 | });
55 |
56 | return (
57 |
58 |
59 |
65 | table.getColumn("username")?.setFilterValue(event.target.value)
66 | }
67 | className="max-w-sm"
68 | />
69 |
70 |
71 |
72 |
73 | {table.getHeaderGroups().map((headerGroup) => (
74 |
75 | {headerGroup.headers.map((header) => {
76 | return (
77 |
78 | {header.isPlaceholder
79 | ? null
80 | : flexRender(
81 | header.column.columnDef.header,
82 | header.getContext()
83 | )}
84 |
85 | );
86 | })}
87 |
88 | ))}
89 |
90 |
91 | {table.getRowModel().rows?.length ? (
92 | table.getRowModel().rows.map((row) => (
93 |
97 | {row.getVisibleCells().map((cell) => (
98 |
99 | {flexRender(
100 | cell.column.columnDef.cell,
101 | cell.getContext()
102 | )}
103 |
104 | ))}
105 |
106 | ))
107 | ) : (
108 |
109 |
113 | No results.
114 |
115 |
116 | )}
117 |
118 |
119 |
120 |
121 |
129 |
137 |
138 |
139 | );
140 | }
141 |
--------------------------------------------------------------------------------
/components/stream-player/info-modal.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { toast } from "sonner";
4 | import { useState, useTransition, useRef, ElementRef } from "react";
5 | import { useRouter } from "next/navigation";
6 | import { Trash } from "lucide-react";
7 | import Image from "next/image";
8 |
9 | import {
10 | Dialog,
11 | DialogClose,
12 | DialogContent,
13 | DialogHeader,
14 | DialogTitle,
15 | DialogTrigger,
16 | } from "@/components/ui/dialog";
17 | import { Hint } from "@/components/hint";
18 | import { Label } from "@/components/ui/label";
19 | import { Input } from "@/components/ui/input";
20 | import { Button } from "@/components/ui/button";
21 | import { updateStream } from "@/actions/stream";
22 | import { UploadDropzone } from "@/lib/uploadthing";
23 |
24 | interface InfoModalProps {
25 | initialName: string;
26 | initialThumbnailUrl: string | null;
27 | }
28 |
29 | export const InfoModal = ({
30 | initialName,
31 | initialThumbnailUrl,
32 | }: InfoModalProps) => {
33 | const router = useRouter();
34 | const closeRef = useRef>(null);
35 | const [isPending, startTransition] = useTransition();
36 |
37 | const [name, setName] = useState(initialName);
38 | const [thumbnailUrl, setThumbnailUrl] = useState(initialThumbnailUrl);
39 |
40 | const onRemove = () => {
41 | startTransition(() => {
42 | updateStream({ thumbnailUrl: null })
43 | .then(() => {
44 | toast.success("Thumbnail removed");
45 | setThumbnailUrl("");
46 | closeRef?.current?.click();
47 | })
48 | .catch(() => toast.error("Something went wrong"));
49 | });
50 | };
51 |
52 | const onSubmit = (e: React.FormEvent) => {
53 | e.preventDefault();
54 |
55 | startTransition(() => {
56 | updateStream({ name: name })
57 | .then(() => {
58 | toast.success("Stream updated");
59 | closeRef?.current?.click();
60 | })
61 | .catch(() => toast.error("Something went wrong"));
62 | });
63 | };
64 |
65 | const onChange = (e: React.ChangeEvent) => {
66 | setName(e.target.value);
67 | };
68 |
69 | return (
70 |
147 | );
148 | };
149 |
--------------------------------------------------------------------------------
/components/ui/select.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as SelectPrimitive from "@radix-ui/react-select"
5 | import { Check, ChevronDown, ChevronUp } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const Select = SelectPrimitive.Root
10 |
11 | const SelectGroup = SelectPrimitive.Group
12 |
13 | const SelectValue = SelectPrimitive.Value
14 |
15 | const SelectTrigger = React.forwardRef<
16 | React.ElementRef,
17 | React.ComponentPropsWithoutRef
18 | >(({ className, children, ...props }, ref) => (
19 | span]:line-clamp-1",
23 | className
24 | )}
25 | {...props}
26 | >
27 | {children}
28 |
29 |
30 |
31 |
32 | ))
33 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
34 |
35 | const SelectScrollUpButton = React.forwardRef<
36 | React.ElementRef,
37 | React.ComponentPropsWithoutRef
38 | >(({ className, ...props }, ref) => (
39 |
47 |
48 |
49 | ))
50 | SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
51 |
52 | const SelectScrollDownButton = React.forwardRef<
53 | React.ElementRef,
54 | React.ComponentPropsWithoutRef
55 | >(({ className, ...props }, ref) => (
56 |
64 |
65 |
66 | ))
67 | SelectScrollDownButton.displayName =
68 | SelectPrimitive.ScrollDownButton.displayName
69 |
70 | const SelectContent = React.forwardRef<
71 | React.ElementRef,
72 | React.ComponentPropsWithoutRef
73 | >(({ className, children, position = "popper", ...props }, ref) => (
74 |
75 |
86 |
87 |
94 | {children}
95 |
96 |
97 |
98 |
99 | ))
100 | SelectContent.displayName = SelectPrimitive.Content.displayName
101 |
102 | const SelectLabel = React.forwardRef<
103 | React.ElementRef,
104 | React.ComponentPropsWithoutRef
105 | >(({ className, ...props }, ref) => (
106 |
111 | ))
112 | SelectLabel.displayName = SelectPrimitive.Label.displayName
113 |
114 | const SelectItem = React.forwardRef<
115 | React.ElementRef,
116 | React.ComponentPropsWithoutRef
117 | >(({ className, children, ...props }, ref) => (
118 |
126 |
127 |
128 |
129 |
130 |
131 |
132 | {children}
133 |
134 | ))
135 | SelectItem.displayName = SelectPrimitive.Item.displayName
136 |
137 | const SelectSeparator = React.forwardRef<
138 | React.ElementRef,
139 | React.ComponentPropsWithoutRef
140 | >(({ className, ...props }, ref) => (
141 |
146 | ))
147 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName
148 |
149 | export {
150 | Select,
151 | SelectGroup,
152 | SelectValue,
153 | SelectTrigger,
154 | SelectContent,
155 | SelectLabel,
156 | SelectItem,
157 | SelectSeparator,
158 | SelectScrollUpButton,
159 | SelectScrollDownButton,
160 | }
161 |
--------------------------------------------------------------------------------