diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..bffb357 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "next/core-web-vitals" +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..00bba9b --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local +.env + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/README.md b/README.md new file mode 100644 index 0000000..c403366 --- /dev/null +++ b/README.md @@ -0,0 +1,36 @@ +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). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! + +## Deploy on Vercel + +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. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. diff --git a/app/(auth)/(routes)/sign-in/[[...sign-in]]/page.tsx b/app/(auth)/(routes)/sign-in/[[...sign-in]]/page.tsx new file mode 100644 index 0000000..0e8f69c --- /dev/null +++ b/app/(auth)/(routes)/sign-in/[[...sign-in]]/page.tsx @@ -0,0 +1,4 @@ +import { SignIn } from '@clerk/nextjs'; +export default function Page() { + return ; +} diff --git a/app/(auth)/(routes)/sign-up/[[...sign-up]]/page.tsx b/app/(auth)/(routes)/sign-up/[[...sign-up]]/page.tsx new file mode 100644 index 0000000..5590276 --- /dev/null +++ b/app/(auth)/(routes)/sign-up/[[...sign-up]]/page.tsx @@ -0,0 +1,4 @@ +import { SignUp } from '@clerk/nextjs'; +export default function Page() { + return ; +} diff --git a/app/(auth)/layout.tsx b/app/(auth)/layout.tsx new file mode 100644 index 0000000..6d1a296 --- /dev/null +++ b/app/(auth)/layout.tsx @@ -0,0 +1,9 @@ +export default function authLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
{children}
+ ); +} diff --git a/app/(invite)/(routes)/invite/[inviteCode]/page.tsx b/app/(invite)/(routes)/invite/[inviteCode]/page.tsx new file mode 100644 index 0000000..19edf44 --- /dev/null +++ b/app/(invite)/(routes)/invite/[inviteCode]/page.tsx @@ -0,0 +1,47 @@ +import { currentProfile } from '@/lib/currentProfile'; +import { db } from '@/lib/db'; +import { redirect } from 'next/navigation'; + +export default async function InviteCodePage({ + params, +}: { + params: { inviteCode: string }; +}) { + const profile = await currentProfile(); + if (!profile) { + return redirect(`${process.env.NEXT_PUBLIC_APP_URL}/sign-in`); + } + + if (!params.inviteCode) return redirect('/'); + + const existingUser = await db.server.findFirst({ + where: { + inviteCode: params.inviteCode, + members: { + some: { + profileId: profile.id, + }, + }, + }, + }); + + if (existingUser) return redirect(`/servers/${existingUser.id}`); + + const server = await db.server.update({ + where: { + inviteCode: params.inviteCode, + }, + data: { + members: { + create: [ + { + profileId: profile.id, + }, + ], + }, + }, + }); + + if (server) return redirect(`/servers/${server.id}`); + return null; +} diff --git a/app/(main)/(routes)/servers/[serverId]/channels/[channelId]/page.tsx b/app/(main)/(routes)/servers/[serverId]/channels/[channelId]/page.tsx new file mode 100644 index 0000000..b0717a0 --- /dev/null +++ b/app/(main)/(routes)/servers/[serverId]/channels/[channelId]/page.tsx @@ -0,0 +1,87 @@ +import { redirectToSignIn } from '@clerk/nextjs'; +import { redirect } from 'next/navigation'; +import { ChannelType } from '@prisma/client'; + +import { db } from '@/lib/db'; +import { currentProfile } from '@/lib/currentProfile'; +import ChatHeader from '@/components/chat/chat-header'; +import { ChatInput } from '@/components/chat/chat-input'; +import { ChatMessages } from '@/components/chat/chat-messages'; +import { MediaRoom } from '@/components/media-room'; + +interface ChannelIdPageProps { + params: { + serverId: string; + channelId: string; + }; +} + +const ChannelIdPage = async ({ params }: ChannelIdPageProps) => { + const profile = await currentProfile(); + + if (!profile) { + return redirectToSignIn(); + } + + const channel = await db.channel.findUnique({ + where: { + id: params.channelId, + }, + }); + + const member = await db.member.findFirst({ + where: { + serverId: params.serverId, + profileId: profile.id, + }, + }); + + if (!channel || !member) { + redirect('/'); + } + + return ( +
+ + {channel.type === ChannelType.TEXT && ( + <> + + + + )} + {channel.type === ChannelType.AUDIO && ( + + )} + {channel.type === ChannelType.VIDEO && ( + + )} +
+ ); +}; + +export default ChannelIdPage; diff --git a/app/(main)/(routes)/servers/[serverId]/conversations/[memberId]/page.tsx b/app/(main)/(routes)/servers/[serverId]/conversations/[memberId]/page.tsx new file mode 100644 index 0000000..5ee0cfe --- /dev/null +++ b/app/(main)/(routes)/servers/[serverId]/conversations/[memberId]/page.tsx @@ -0,0 +1,99 @@ +import { redirectToSignIn } from '@clerk/nextjs'; +import { redirect } from 'next/navigation'; + +import { db } from '@/lib/db'; +import { getOrCreateConversation } from '@/lib/conversation'; +import { ChatMessages } from '@/components/chat/chat-messages'; +import { ChatInput } from '@/components/chat/chat-input'; +import { currentProfile } from '@/lib/currentProfile'; +import ChatHeader from '@/components/chat/chat-header'; +import { MediaRoom } from '@/components/media-room'; + +interface MemberIdPageProps { + params: { + memberId: string; + serverId: string; + }; + searchParams: { + video?: boolean; + }; +} + +const MemberIdPage = async ({ params, searchParams }: MemberIdPageProps) => { + const profile = await currentProfile(); + + if (!profile) { + return redirectToSignIn(); + } + + const currentMember = await db.member.findFirst({ + where: { + serverId: params.serverId, + profileId: profile.id, + }, + include: { + profile: true, + }, + }); + + if (!currentMember) { + return redirect('/'); + } + + const conversation = await getOrCreateConversation( + currentMember.id, + params.memberId + ); + + if (!conversation) { + return redirect(`/servers/${params.serverId}`); + } + + const { memberOne, memberTwo } = conversation; + + const otherMember = + memberOne.profileId === profile.id ? memberTwo : memberOne; + + return ( +
+ + + {searchParams.video && ( + + )} + + {!searchParams.video && ( + <> + + + + )} +
+ ); +}; + +export default MemberIdPage; diff --git a/app/(main)/(routes)/servers/[serverId]/layout.tsx b/app/(main)/(routes)/servers/[serverId]/layout.tsx new file mode 100644 index 0000000..37836a1 --- /dev/null +++ b/app/(main)/(routes)/servers/[serverId]/layout.tsx @@ -0,0 +1,35 @@ +import ServerSidebar from '@/components/server/serverSidebar'; +import { currentProfile } from '@/lib/currentProfile'; +import { db } from '@/lib/db'; +import { redirectToSignIn } from '@clerk/nextjs'; +import { redirect } from 'next/navigation'; + +export default async function MainLayout({ + children, + params, +}: { + children: React.ReactNode; + params: { serverId: string }; +}) { + const profile = await currentProfile(); + if (!profile) return redirectToSignIn(); + const server = await db.server.findUnique({ + where: { + id: params.serverId, + members: { + some: { + profileId: profile.id, + }, + }, + }, + }); + if (!server) redirect('/'); + return ( +
+
+ +
+
{children}
+
+ ); +} diff --git a/app/(main)/(routes)/servers/[serverId]/page.tsx b/app/(main)/(routes)/servers/[serverId]/page.tsx new file mode 100644 index 0000000..5c7cdea --- /dev/null +++ b/app/(main)/(routes)/servers/[serverId]/page.tsx @@ -0,0 +1,50 @@ +import { redirectToSignIn } from '@clerk/nextjs'; +import { redirect } from 'next/navigation'; + +import { db } from '@/lib/db'; +import { currentProfile } from '@/lib/currentProfile'; + +interface ServerIdPageProps { + params: { + serverId: string; + }; +} + +const ServerIdPage = async ({ params }: ServerIdPageProps) => { + const profile = await currentProfile(); + + if (!profile) { + return redirectToSignIn(); + } + + const server = await db.server.findUnique({ + where: { + id: params.serverId, + members: { + some: { + profileId: profile.id, + }, + }, + }, + include: { + channels: { + where: { + name: 'general', + }, + orderBy: { + createdAt: 'asc', + }, + }, + }, + }); + + const initialChannel = server?.channels[0]; + + if (initialChannel?.name !== 'general') { + return null; + } + + return redirect(`/servers/${params.serverId}/channels/${initialChannel?.id}`); +}; + +export default ServerIdPage; diff --git a/app/(main)/layout.tsx b/app/(main)/layout.tsx new file mode 100644 index 0000000..1695131 --- /dev/null +++ b/app/(main)/layout.tsx @@ -0,0 +1,16 @@ +import NavigationSidebar from '@/components/navigations/NavigationSidebar'; + +export default function MainLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+
+ +
+
{children}
+
+ ); +} diff --git a/app/(setup)/page.tsx b/app/(setup)/page.tsx new file mode 100644 index 0000000..936ce80 --- /dev/null +++ b/app/(setup)/page.tsx @@ -0,0 +1,21 @@ +import InitialModal from '@/components/modals/initial-modal'; +import { db } from '@/lib/db'; +import { initialProfile } from '@/lib/initial-profile'; +import { redirect } from 'next/navigation'; + +export default async function Home() { + const profile = await initialProfile(); + const server = await db.server.findFirst({ + where: { + members: { + some: { + profileId: profile.id, + }, + }, + }, + }); + if (server) { + return redirect(`/servers/${server.id}`); + } + return ; +} diff --git a/app/api/channel/[channelId]/route.ts b/app/api/channel/[channelId]/route.ts new file mode 100644 index 0000000..52119eb --- /dev/null +++ b/app/api/channel/[channelId]/route.ts @@ -0,0 +1,122 @@ +import { NextResponse } from 'next/server'; +import { MemberRole } from '@prisma/client'; + +import { db } from '@/lib/db'; +import { currentProfile } from '@/lib/currentProfile'; + +export async function DELETE( + req: Request, + { params }: { params: { channelId: string } } +) { + try { + const profile = await currentProfile(); + const { searchParams } = new URL(req.url); + + const serverId = searchParams.get('serverId'); + + if (!profile) { + return new NextResponse('Unauthorized', { status: 401 }); + } + + if (!serverId) { + return new NextResponse('Server ID missing', { status: 400 }); + } + + if (!params.channelId) { + return new NextResponse('Channel ID missing', { status: 400 }); + } + + const server = await db.server.update({ + where: { + id: serverId, + members: { + some: { + profileId: profile.id, + role: { + in: [MemberRole.ADMIN, MemberRole.MODERATOR], + }, + }, + }, + }, + data: { + channels: { + delete: { + id: params.channelId, + name: { + not: 'general', + }, + }, + }, + }, + }); + + return NextResponse.json(server); + } catch (error) { + console.log('[CHANNEL_ID_DELETE]', error); + return new NextResponse('Internal Error', { status: 500 }); + } +} + +export async function PUT( + req: Request, + { params }: { params: { channelId: string } } +) { + try { + const profile = await currentProfile(); + const { name, type } = await req.json(); + const { searchParams } = new URL(req.url); + + const serverId = searchParams.get('serverId'); + + if (!profile) { + return new NextResponse('Unauthorized', { status: 401 }); + } + + if (!serverId) { + return new NextResponse('Server ID missing', { status: 400 }); + } + + if (!params.channelId) { + return new NextResponse('Channel ID missing', { status: 400 }); + } + + if (name === 'general') { + return new NextResponse("Name cannot be 'general'", { status: 400 }); + } + + const server = await db.server.update({ + where: { + id: serverId, + members: { + some: { + profileId: profile.id, + role: { + in: [MemberRole.ADMIN, MemberRole.MODERATOR], + }, + }, + }, + }, + data: { + channels: { + update: { + where: { + id: params.channelId, + NOT: { + name: 'general', + }, + }, + data: { + name, + type, + }, + }, + }, + }, + }); + + return NextResponse.json(server); + } catch (error) { + console.log('[CHANNEL_ID_PATCH]', error); + return new NextResponse('Internal Error', { status: 500 }); + } +} diff --git a/app/api/channel/route.ts b/app/api/channel/route.ts new file mode 100644 index 0000000..a67b265 --- /dev/null +++ b/app/api/channel/route.ts @@ -0,0 +1,45 @@ +import { currentProfile } from '@/lib/currentProfile'; +import { db } from '@/lib/db'; +import { MemberRole } from '@prisma/client'; +import { NextResponse } from 'next/server'; +export async function POST(req: Request) { + try { + const profile = await currentProfile(); + if (!profile) { + return new NextResponse('Unauthorized', { status: 401 }); + } + const { name, type } = await req.json(); + const { searchParams } = new URL(req.url); + const serverId = searchParams.get('serverId'); + if (!serverId) { + return new NextResponse('server Id missing', { status: 400 }); + } + + const server = await db.server.update({ + where: { + id: serverId, + members: { + some: { + profileId: profile.id, + role: { + in: [MemberRole.ADMIN, MemberRole.MODERATOR], + }, + }, + }, + }, + data: { + channels: { + create: { + profileId: profile.id, + name, + type, + }, + }, + }, + }); + return NextResponse.json(server); + } catch (err: any) { + console.log('[API-MEMBER_PATCH]', err); + return new NextResponse('Internal error', { status: 500 }); + } +} diff --git a/app/api/direct-messages/route.ts b/app/api/direct-messages/route.ts new file mode 100644 index 0000000..68d7b2d --- /dev/null +++ b/app/api/direct-messages/route.ts @@ -0,0 +1,81 @@ +import { NextResponse } from 'next/server'; +import { DirectMessage } from '@prisma/client'; + +import { db } from '@/lib/db'; +import { currentProfile } from '@/lib/currentProfile'; + +const MESSAGES_BATCH = 10; + +export async function GET(req: Request) { + try { + const profile = await currentProfile(); + const { searchParams } = new URL(req.url); + + const cursor = searchParams.get('cursor'); + const conversationId = searchParams.get('conversationId'); + + if (!profile) { + return new NextResponse('Unauthorized', { status: 401 }); + } + + if (!conversationId) { + return new NextResponse('Conversation ID missing', { status: 400 }); + } + + let messages: DirectMessage[] = []; + + if (cursor) { + messages = await db.directMessage.findMany({ + take: MESSAGES_BATCH, + skip: 1, + cursor: { + id: cursor, + }, + where: { + conversationId, + }, + include: { + member: { + include: { + profile: true, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }); + } else { + messages = await db.directMessage.findMany({ + take: MESSAGES_BATCH, + where: { + conversationId, + }, + include: { + member: { + include: { + profile: true, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }); + } + + let nextCursor = null; + + if (messages.length === MESSAGES_BATCH) { + nextCursor = messages[MESSAGES_BATCH - 1].id; + } + + return NextResponse.json({ + items: messages, + nextCursor, + }); + } catch (error) { + console.log('[DIRECT_MESSAGES_GET]', error); + return new NextResponse('Internal Error', { status: 500 }); + } +} diff --git a/app/api/livekit/route.ts b/app/api/livekit/route.ts new file mode 100644 index 0000000..f38c237 --- /dev/null +++ b/app/api/livekit/route.ts @@ -0,0 +1,35 @@ +import { AccessToken } from 'livekit-server-sdk'; +import { NextRequest, NextResponse } from 'next/server'; + +export async function GET(req: NextRequest) { + const room = req.nextUrl.searchParams.get('room'); + const username = req.nextUrl.searchParams.get('username'); + if (!room) { + return NextResponse.json( + { error: 'Missing "room" query parameter' }, + { status: 400 } + ); + } else if (!username) { + return NextResponse.json( + { error: 'Missing "username" query parameter' }, + { status: 400 } + ); + } + + const apiKey = process.env.LIVEKIT_API_KEY; + const apiSecret = process.env.LIVEKIT_API_SECRET; + const wsUrl = process.env.NEXT_PUBLIC_LIVEKIT_URL; + + if (!apiKey || !apiSecret || !wsUrl) { + return NextResponse.json( + { error: 'Server misconfigured' }, + { status: 500 } + ); + } + + const at = new AccessToken(apiKey, apiSecret, { identity: username }); + + at.addGrant({ room, roomJoin: true, canPublish: true, canSubscribe: true }); + + return NextResponse.json({ token: await at.toJwt() }); +} diff --git a/app/api/member/[memberId]/route.ts b/app/api/member/[memberId]/route.ts new file mode 100644 index 0000000..dd4aad7 --- /dev/null +++ b/app/api/member/[memberId]/route.ts @@ -0,0 +1,103 @@ +import { currentProfile } from '@/lib/currentProfile'; +import { db } from '@/lib/db'; +import { NextResponse } from 'next/server'; +export async function PATCH( + req: Request, + { params }: { params: { memberId: string } } +) { + try { + const profile = await currentProfile(); + if (!profile) { + return new NextResponse('Unauthorized', { status: 401 }); + } + const { role, serverId } = await req.json(); + if (!serverId) { + return new NextResponse('Server Id is Required', { status: 400 }); + } + const server = await db.server.update({ + where: { + id: serverId, + profileId: profile.id, + }, + data: { + members: { + update: { + where: { + id: params.memberId, + profileId: { + not: profile.id, + }, + }, + data: { + role, + }, + }, + }, + }, + include: { + members: { + include: { + profile: true, + }, + orderBy: { + role: 'asc', + }, + }, + }, + }); + return NextResponse.json(server); + } catch (err: any) { + console.log('[API-MEMBER_PATCH]', err); + return new NextResponse('Internal error', { status: 500 }); + } +} + +export async function DELETE( + req: Request, + { params }: { params: { memberId: string } } +) { + try { + const profile = await currentProfile(); + if (!profile) { + return new NextResponse('Unauthorized', { status: 401 }); + } + const { searchParams } = new URL(req.url); + const serverId = searchParams.get('serverId'); + if (!serverId) { + return new NextResponse('server Id missing', { status: 400 }); + } + if (!params.memberId) { + return new NextResponse('Member Id missing', { status: 400 }); + } + const server = await db.server.update({ + where: { + id: serverId, + profileId: profile.id, + }, + data: { + members: { + deleteMany: { + id: params.memberId, + profileId: { + not: profile.id, + }, + }, + }, + }, + include: { + members: { + include: { + profile: true, + }, + orderBy: { + role: 'asc', + }, + }, + }, + }); + return NextResponse.json(server); + } catch (err) { + console.log('[API-MEMBER_DELETE]', err); + return new NextResponse('Internal error', { status: 500 }); + } +} diff --git a/app/api/messages/route.ts b/app/api/messages/route.ts new file mode 100644 index 0000000..73e4ae0 --- /dev/null +++ b/app/api/messages/route.ts @@ -0,0 +1,81 @@ +import { NextResponse } from 'next/server'; +import { Message } from '@prisma/client'; + +import { db } from '@/lib/db'; +import { currentProfile } from '@/lib/currentProfile'; + +const MESSAGES_BATCH = 10; + +export async function GET(req: Request) { + try { + const profile = await currentProfile(); + const { searchParams } = new URL(req.url); + + const cursor = searchParams.get('cursor'); + const channelId = searchParams.get('channelId'); + + if (!profile) { + return new NextResponse('Unauthorized', { status: 401 }); + } + + if (!channelId) { + return new NextResponse('Channel ID missing', { status: 400 }); + } + + let messages: Message[] = []; + + if (cursor) { + messages = await db.message.findMany({ + take: MESSAGES_BATCH, + skip: 1, + cursor: { + id: cursor, + }, + where: { + channelId, + }, + include: { + member: { + include: { + profile: true, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }); + } else { + messages = await db.message.findMany({ + take: MESSAGES_BATCH, + where: { + channelId, + }, + include: { + member: { + include: { + profile: true, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }); + } + + let nextCursor = null; + + if (messages.length === MESSAGES_BATCH) { + nextCursor = messages[MESSAGES_BATCH - 1].id; + } + + return NextResponse.json({ + items: messages, + nextCursor, + }); + } catch (error) { + console.log('[MESSAGES_GET]', error); + return new NextResponse('Internal Error', { status: 500 }); + } +} diff --git a/app/api/servers/[serverId]/leave/route.ts b/app/api/servers/[serverId]/leave/route.ts new file mode 100644 index 0000000..12a411a --- /dev/null +++ b/app/api/servers/[serverId]/leave/route.ts @@ -0,0 +1,66 @@ +import { currentProfile } from '@/lib/currentProfile'; +import { db } from '@/lib/db'; +import { NextResponse } from 'next/server'; +export async function POST( + req: Request, + { params }: { params: { serverId: string } } +) { + try { + const profile = await currentProfile(); + if (!profile) { + return new NextResponse('Unauthorized', { status: 401 }); + } + if (!params.serverId) { + return new NextResponse('Server Id missing', { status: 400 }); + } + const server = await db.server.update({ + where: { + id: params.serverId, + profileId: { + not: profile.id, + }, + members: { + some: { + profileId: profile.id, + }, + }, + }, + data: { + members: { + deleteMany: { + profileId: profile.id, + }, + }, + }, + }); + return NextResponse.json(server); + } catch (err: any) { + console.log('[API-SERVERS_POST]', err); + return new NextResponse('Internal error', { status: 500 }); + } +} + +export async function DELETE( + req: Request, + { params }: { params: { serverId: string } } +) { + try { + const profile = await currentProfile(); + if (!profile) { + return new NextResponse('Unauthorized', { status: 401 }); + } + if (!params.serverId) { + return new NextResponse('Server Id missing', { status: 400 }); + } + const server = await db.server.delete({ + where: { + id: params.serverId, + profileId: profile.id, + }, + }); + return NextResponse.json(server); + } catch (err: any) { + console.log('[SERVER_ID_DELETE]', err); + return new NextResponse('Internal error', { status: 500 }); + } +} diff --git a/app/api/servers/[serverId]/route.ts b/app/api/servers/[serverId]/route.ts new file mode 100644 index 0000000..9d36e27 --- /dev/null +++ b/app/api/servers/[serverId]/route.ts @@ -0,0 +1,54 @@ +import { currentProfile } from '@/lib/currentProfile'; +import { db } from '@/lib/db'; +import { NextResponse } from 'next/server'; +import { v4 as uuidv4 } from 'uuid'; +export async function PATCH( + req: Request, + { params }: { params: { serverId: string } } +) { + try { + const profile = await currentProfile(); + if (!profile) { + return new NextResponse('Unauthorized', { status: 401 }); + } + const server = await db.server.update({ + where: { + id: params.serverId, + profileId: profile.id, + }, + data: { + inviteCode: uuidv4(), + }, + }); + return NextResponse.json(server); + } catch (err: any) { + console.log('[API-SERVERS_POST]', err); + return new NextResponse('Internal error', { status: 500 }); + } +} + +export async function PUT( + req: Request, + { params }: { params: { serverId: string } } +) { + try { + const { name, imageUrl } = await req.json(); + const profile = await currentProfile(); + if (!profile) { + return new NextResponse('Unauthorized', { status: 401 }); + } + const server = await db.server.update({ + where: { + id: params.serverId, + }, + data: { + name, + imageUrl, + }, + }); + return NextResponse.json(server); + } catch (err: any) { + console.log('[API-SERVERS_POST]', err); + return new NextResponse('Internal error', { status: 500 }); + } +} diff --git a/app/api/servers/route.ts b/app/api/servers/route.ts new file mode 100644 index 0000000..c679fc6 --- /dev/null +++ b/app/api/servers/route.ts @@ -0,0 +1,38 @@ +import { currentProfile } from '@/lib/currentProfile'; +import { db } from '@/lib/db'; +import { MemberRole } from '@prisma/client'; +import { NextResponse } from 'next/server'; +import { v4 as uuidv4 } from 'uuid'; +export async function POST(req: Request) { + try { + const { name, imageUrl } = await req.json(); + const profile = await currentProfile(); + if (!profile) { + return new NextResponse('Unauthorized', { status: 401 }); + } + const server = await db.server.create({ + data: { + profileId: profile.id, + name, + imageUrl, + inviteCode: uuidv4(), + channels: { + create: [ + { + name: 'general', + profileId: profile.id, + }, + ], + }, + members: { + create: [{ profileId: profile.id, role: MemberRole.ADMIN }], + }, + }, + }); + console.log(server); + return NextResponse.json(server); + } catch (err: any) { + console.log('[API-SERVERS_POST]', err); + return new NextResponse('Internal error', { status: 500 }); + } +} diff --git a/app/api/uploadthing/core.ts b/app/api/uploadthing/core.ts new file mode 100644 index 0000000..58b361b --- /dev/null +++ b/app/api/uploadthing/core.ts @@ -0,0 +1,22 @@ +import { createUploadthing, type FileRouter } from 'uploadthing/next'; +import { auth } from '@clerk/nextjs'; +import { UploadThingError } from 'uploadthing/server'; +const handleAuth = () => { + const { userId } = auth(); + if (!userId) throw new Error('Unauthorized'); + return { userId }; +}; + +const f = createUploadthing(); + +// FileRouter for your app, can contain multiple FileRoutes +export const ourFileRouter = { + serverImage: f({ image: { maxFileSize: '4MB', maxFileCount: 1 } }) + .middleware(handleAuth) + .onUploadComplete(() => {}), + messageFile: f(['image', 'pdf']) + .middleware(handleAuth) + .onUploadComplete(() => {}), +} satisfies FileRouter; + +export type OurFileRouter = typeof ourFileRouter; diff --git a/app/api/uploadthing/route.ts b/app/api/uploadthing/route.ts new file mode 100644 index 0000000..371e838 --- /dev/null +++ b/app/api/uploadthing/route.ts @@ -0,0 +1,11 @@ +import { createRouteHandler } from 'uploadthing/next'; + +import { ourFileRouter } from './core'; + +// Export routes for Next App Router +export const { GET, POST } = createRouteHandler({ + router: ourFileRouter, + + // Apply an (optional) custom config: + // config: { ... }, +}); diff --git a/app/favicon.ico b/app/favicon.ico new file mode 100644 index 0000000..916e1fc Binary files /dev/null and b/app/favicon.ico differ diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..cb8a82e --- /dev/null +++ b/app/globals.css @@ -0,0 +1,82 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +html, +body, +:root { + height: 100%; +} + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 20 14.3% 4.1%; + + --card: 0 0% 100%; + --card-foreground: 20 14.3% 4.1%; + + --popover: 0 0% 100%; + --popover-foreground: 20 14.3% 4.1%; + + --primary: 24 9.8% 10%; + --primary-foreground: 60 9.1% 97.8%; + + --secondary: 60 4.8% 95.9%; + --secondary-foreground: 24 9.8% 10%; + + --muted: 60 4.8% 95.9%; + --muted-foreground: 25 5.3% 44.7%; + + --accent: 60 4.8% 95.9%; + --accent-foreground: 24 9.8% 10%; + + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 60 9.1% 97.8%; + + --border: 20 5.9% 90%; + --input: 20 5.9% 90%; + --ring: 20 14.3% 4.1%; + + --radius: 0.5rem; + } + + .dark { + --background: 20 14.3% 4.1%; + --foreground: 60 9.1% 97.8%; + + --card: 20 14.3% 4.1%; + --card-foreground: 60 9.1% 97.8%; + + --popover: 20 14.3% 4.1%; + --popover-foreground: 60 9.1% 97.8%; + + --primary: 60 9.1% 97.8%; + --primary-foreground: 24 9.8% 10%; + + --secondary: 12 6.5% 15.1%; + --secondary-foreground: 60 9.1% 97.8%; + + --muted: 12 6.5% 15.1%; + --muted-foreground: 24 5.4% 63.9%; + + --accent: 12 6.5% 15.1%; + --accent-foreground: 60 9.1% 97.8%; + + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 60 9.1% 97.8%; + + --border: 12 6.5% 15.1%; + --input: 12 6.5% 15.1%; + --ring: 24 5.7% 82.9%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..25c1632 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,42 @@ +import type { Metadata } from 'next'; +import { Open_Sans } from 'next/font/google'; +import { ClerkProvider } from '@clerk/nextjs'; +import './globals.css'; +import { ThemeProvider } from '@/components/provider/theme-provider'; +import { cn } from '@/lib/utils'; +import { ModalProvider } from '@/components/provider/modal-provider'; +import { SocketProvider } from '@/components/provider/socket-provider'; +import { QueryProvider } from '@/components/provider/query-provider'; + +const font = Open_Sans({ subsets: ['latin'] }); + +export const metadata: Metadata = { + title: 'Discord - Group Chat That’s All Fun & Games', + description: 'Generated by create next app', +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + + + + + {children} + + + + + + ); +} diff --git a/components.json b/components.json new file mode 100644 index 0000000..a25c94c --- /dev/null +++ b/components.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "app/globals.css", + "baseColor": "stone", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils" + } +} \ No newline at end of file diff --git a/components/Action-tooltip.tsx b/components/Action-tooltip.tsx new file mode 100644 index 0000000..0b48800 --- /dev/null +++ b/components/Action-tooltip.tsx @@ -0,0 +1,32 @@ +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import React from 'react'; + +export default function ActionTooltip({ + label, + children, + side, + align, +}: { + label: string; + children: React.ReactNode; + side?: 'top' | 'right' | 'bottom' | 'left'; + align?: 'start' | 'center' | 'end'; +}) { + return ( + + + {children} + +

+ {label.toLowerCase()} +

+
+
+
+ ); +} diff --git a/components/avatar.tsx b/components/avatar.tsx new file mode 100644 index 0000000..195d9f1 --- /dev/null +++ b/components/avatar.tsx @@ -0,0 +1,15 @@ +import { Avatar, AvatarImage } from '@/components/ui/avatar'; +import { cn } from '@/lib/utils'; + +interface UserAvatarProps { + src?: string; + className?: string; +} + +export const UserAvatar = ({ src, className }: UserAvatarProps) => { + return ( + + + + ); +}; diff --git a/components/chat/chat-header.tsx b/components/chat/chat-header.tsx new file mode 100644 index 0000000..ccb78e9 --- /dev/null +++ b/components/chat/chat-header.tsx @@ -0,0 +1,35 @@ +import { Hash } from 'lucide-react'; +import { UserAvatar } from '../avatar'; +import MobileToggle from '../mobile-toggle'; +import { SocketIndicator } from '../socket-indicator'; + +interface ChatHeaderProps { + name: string; + serverId: string; + type: string; + imageUrl?: string; +} + +export default function ChatHeader({ + name, + serverId, + type, + imageUrl, +}: ChatHeaderProps) { + return ( +
+ + {type === 'channel' && ( + + )} + {type === 'conversation' && ( + + )} +

{name}

+
+ {/* {type === 'conversation' && } */} + +
+
+ ); +} diff --git a/components/chat/chat-input.tsx b/components/chat/chat-input.tsx new file mode 100644 index 0000000..d79961f --- /dev/null +++ b/components/chat/chat-input.tsx @@ -0,0 +1,97 @@ +'use client'; + +import * as z from 'zod'; +import axios from 'axios'; +import qs from 'query-string'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Plus } from 'lucide-react'; +import { useRouter } from 'next/navigation'; + +import { Form, FormControl, FormField, FormItem } from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { useModal } from '@/hooks/use-modal-store'; +import { EmojiPicker } from '../emojiPicker'; +// import { EmojiPicker } from '@/components/emoji-picker'; + +interface ChatInputProps { + apiUrl: string; + query: Record; + name: string; + type: 'conversation' | 'channel'; +} + +const formSchema = z.object({ + content: z.string().min(1), +}); + +export const ChatInput = ({ apiUrl, query, name, type }: ChatInputProps) => { + const { onOpen } = useModal(); + const router = useRouter(); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + content: '', + }, + }); + + const isLoading = form.formState.isSubmitting; + + const onSubmit = async (values: z.infer) => { + try { + const url = qs.stringifyUrl({ + url: apiUrl, + query, + }); + + await axios.post(url, values); + + form.reset(); + router.refresh(); + } catch (error) { + console.log(error); + } + }; + + return ( +
+ + ( + + +
+ + +
+ + field.onChange(`${field.value} ${emoji}`) + } + /> +
+
+
+
+ )} + /> + + + ); +}; diff --git a/components/chat/chat-item.tsx b/components/chat/chat-item.tsx new file mode 100644 index 0000000..d623579 --- /dev/null +++ b/components/chat/chat-item.tsx @@ -0,0 +1,259 @@ +'use client'; + +import * as z from 'zod'; +import axios from 'axios'; +import qs from 'query-string'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Member, MemberRole, Profile } from '@prisma/client'; +import { Edit, FileIcon, ShieldAlert, ShieldCheck, Trash } from 'lucide-react'; +import Image from 'next/image'; +import { useEffect, useState } from 'react'; +import { useRouter, useParams } from 'next/navigation'; + +import { cn } from '@/lib/utils'; +import { Form, FormControl, FormField, FormItem } from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { useModal } from '@/hooks/use-modal-store'; +import { UserAvatar } from '../avatar'; +import ActionTooltip from '../Action-tooltip'; + +interface ChatItemProps { + id: string; + content: string; + member: Member & { + profile: Profile; + }; + timestamp: string; + fileUrl: string | null; + deleted: boolean; + currentMember: Member; + isUpdated: boolean; + socketUrl: string; + socketQuery: Record; +} + +const roleIconMap = { + GUEST: null, + MODERATOR: , + ADMIN: , +}; + +const formSchema = z.object({ + content: z.string().min(1), +}); + +export const ChatItem = ({ + id, + content, + member, + timestamp, + fileUrl, + deleted, + currentMember, + isUpdated, + socketUrl, + socketQuery, +}: ChatItemProps) => { + const [isEditing, setIsEditing] = useState(false); + const { onOpen } = useModal(); + const params = useParams(); + const router = useRouter(); + + const onMemberClick = () => { + if (member.id === currentMember.id) { + return; + } + + router.push(`/servers/${params?.serverId}/conversations/${member.id}`); + }; + + useEffect(() => { + const handleKeyDown = (event: any) => { + if (event.key === 'Escape' || event.keyCode === 27) { + setIsEditing(false); + } + }; + + window.addEventListener('keydown', handleKeyDown); + + return () => window.removeEventListener('keyDown', handleKeyDown); + }, []); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + content: content, + }, + }); + + const isLoading = form.formState.isSubmitting; + + const onSubmit = async (values: z.infer) => { + try { + const url = qs.stringifyUrl({ + url: `${socketUrl}/${id}`, + query: socketQuery, + }); + + await axios.patch(url, values); + + form.reset(); + setIsEditing(false); + } catch (error) { + console.log(error); + } + }; + + useEffect(() => { + form.reset({ + content: content, + }); + }, [content, form]); + + const fileType = fileUrl?.split('.').pop(); + + const isAdmin = currentMember.role === MemberRole.ADMIN; + const isModerator = currentMember.role === MemberRole.MODERATOR; + const isOwner = currentMember.id === member.id; + const canDeleteMessage = !deleted && (isAdmin || isModerator || isOwner); + const canEditMessage = !deleted && isOwner && !fileUrl; + const isPDF = fileType === 'pdf' && fileUrl; + const isImage = !isPDF && fileUrl; + + return ( +
+
+
+ +
+
+
+
+

+ {member.profile.name} +

+ + {roleIconMap[member.role]} + +
+ + {timestamp} + +
+ {isImage && ( + + {content} + + )} + {isPDF && ( + + )} + {!fileUrl && !isEditing && ( +

+ {content} + {isUpdated && !deleted && ( + + (edited) + + )} +

+ )} + {!fileUrl && isEditing && ( +
+ + ( + + +
+ +
+
+
+ )} + /> + + + + Press escape to cancel, enter to save + + + )} +
+
+ {canDeleteMessage && ( +
+ {canEditMessage && ( + + setIsEditing(true)} + className="cursor-pointer ml-auto w-4 h-4 text-zinc-500 hover:text-zinc-600 dark:hover:text-zinc-300 transition" + /> + + )} + + + onOpen('deleteMessage', { + apiUrl: `${socketUrl}/${id}`, + query: socketQuery, + }) + } + className="cursor-pointer ml-auto w-4 h-4 text-zinc-500 hover:text-zinc-600 dark:hover:text-zinc-300 transition" + /> + +
+ )} +
+ ); +}; diff --git a/components/chat/chat-messages.tsx b/components/chat/chat-messages.tsx new file mode 100644 index 0000000..ae8f7c4 --- /dev/null +++ b/components/chat/chat-messages.tsx @@ -0,0 +1,132 @@ +'use client'; + +import { Fragment, useRef, ElementRef } from 'react'; +import { format } from 'date-fns'; +import { Member, Message, Profile } from '@prisma/client'; +import { Loader2, ServerCrash } from 'lucide-react'; + +import { ChatWelcome } from './chat-welcome'; +import { useChatQuery } from '@/hooks/use-chat-query'; +import { useChatSocket } from '@/hooks/use-chat-socket'; +import { useChatScroll } from '@/hooks/use-chat-scroll'; +import { ChatItem } from './chat-item'; + +const DATE_FORMAT = 'd MMM yyyy, HH:mm'; + +type MessageWithMemberWithProfile = Message & { + member: Member & { + profile: Profile; + }; +}; + +interface ChatMessagesProps { + name: string; + member: Member; + chatId: string; + apiUrl: string; + socketUrl: string; + socketQuery: Record; + paramKey: 'channelId' | 'conversationId'; + paramValue: string; + type: 'channel' | 'conversation'; +} + +export const ChatMessages = ({ + name, + member, + chatId, + apiUrl, + socketUrl, + socketQuery, + paramKey, + paramValue, + type, +}: ChatMessagesProps) => { + const queryKey = `chat:${chatId}`; + const addKey = `chat:${chatId}:messages`; + const updateKey = `chat:${chatId}:messages:update`; + + const chatRef = useRef>(null); + const bottomRef = useRef>(null); + + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, status } = + useChatQuery({ + queryKey, + apiUrl, + paramKey, + paramValue, + }); + useChatSocket({ queryKey, addKey, updateKey }); + useChatScroll({ + chatRef, + bottomRef, + loadMore: fetchNextPage, + shouldLoadMore: !isFetchingNextPage && !!hasNextPage, + count: data?.pages?.[0]?.items?.length ?? 0, + }); + + if (status === 'pending') { + return ( +
+ +

+ Loading messages... +

+
+ ); + } + + if (status === 'error') { + return ( +
+ +

+ Something went wrong! +

+
+ ); + } + + return ( +
+ {!hasNextPage &&
} + {!hasNextPage && } + {hasNextPage && ( +
+ {isFetchingNextPage ? ( + + ) : ( + + )} +
+ )} +
+ {data?.pages?.map((group, i) => ( + + {group.items.map((message: MessageWithMemberWithProfile) => ( + + ))} + + ))} +
+
+
+ ); +}; diff --git a/components/chat/chat-welcome.tsx b/components/chat/chat-welcome.tsx new file mode 100644 index 0000000..4a18f1e --- /dev/null +++ b/components/chat/chat-welcome.tsx @@ -0,0 +1,27 @@ +import { Hash } from 'lucide-react'; + +interface ChatWelcomeProps { + name: string; + type: 'channel' | 'conversation'; +} + +export const ChatWelcome = ({ name, type }: ChatWelcomeProps) => { + return ( +
+ {type === 'channel' && ( +
+ +
+ )} +

+ {type === 'channel' ? 'Welcome to #' : ''} + {name} +

+

+ {type === 'channel' + ? `This is the start of the #${name} channel.` + : `This is the start of your conversation with ${name}`} +

+
+ ); +}; diff --git a/components/emojiPicker.tsx b/components/emojiPicker.tsx new file mode 100644 index 0000000..291e66b --- /dev/null +++ b/components/emojiPicker.tsx @@ -0,0 +1,39 @@ +'use client'; + +import { Smile } from 'lucide-react'; +import Picker from '@emoji-mart/react'; +import data from '@emoji-mart/data'; +import { useTheme } from 'next-themes'; + +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; + +interface EmojiPickerProps { + onChange: (value: string) => void; +} + +export const EmojiPicker = ({ onChange }: EmojiPickerProps) => { + const { resolvedTheme } = useTheme(); + + return ( + + + + + + onChange(emoji.native)} + /> + + + ); +}; diff --git a/components/fileUpload.tsx b/components/fileUpload.tsx new file mode 100644 index 0000000..88282ee --- /dev/null +++ b/components/fileUpload.tsx @@ -0,0 +1,72 @@ +import { UploadButton } from '@/lib/uploadThing'; +import Image from 'next/image'; +import '@uploadthing/react/styles.css'; +import { Button } from './ui/button'; +import { FileIcon, X } from 'lucide-react'; + +export default function FileUpload({ + endpoint, + onChange, + value, +}: { + endpoint: 'serverImage' | 'messageFile'; + value: string; + onChange: (url: string) => void; +}) { + const filetype = value?.split('.').pop(); + if (value && filetype != 'pdf') { + return ( +
+ upload + +
+ ); + } + if (value && filetype === 'pdf') { + return ( +
+ + + {value} + + {' '} +
+ ); + } + return ( + <> + { + onChange(res[0].url); + }} + onUploadError={(error: Error) => { + console.log(error); + }} + /> + + ); +} diff --git a/components/media-room.tsx b/components/media-room.tsx new file mode 100644 index 0000000..8ad2e49 --- /dev/null +++ b/components/media-room.tsx @@ -0,0 +1,59 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { LiveKitRoom, VideoConference } from '@livekit/components-react'; +import '@livekit/components-styles'; +import { useUser } from '@clerk/nextjs'; +import { Loader2 } from 'lucide-react'; + +interface MediaRoomProps { + chatId: string; + video: boolean; + audio: boolean; +} + +export const MediaRoom = ({ chatId, video, audio }: MediaRoomProps) => { + const { user } = useUser(); + const [token, setToken] = useState(''); + + useEffect(() => { + if (!user?.firstName || !user?.lastName) return; + + const name = `${user.firstName} ${user.lastName}`; + + (async () => { + try { + const resp = await fetch( + `/api/livekit?room=${chatId}&username=${name}` + ); + const data = await resp.json(); + setToken(data.token); + } catch (e) { + console.log(e); + } + })(); + }, [user?.firstName, user?.lastName, chatId]); + + if (token === '') { + return ( +
+ +

Loading...

+
+ ); + } + + return ( + + + + ); +}; diff --git a/components/mobile-toggle.tsx b/components/mobile-toggle.tsx new file mode 100644 index 0000000..c43da63 --- /dev/null +++ b/components/mobile-toggle.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet'; +import { Button } from './ui/button'; +import { Menu } from 'lucide-react'; +import ServerSidebar from './server/serverSidebar'; +import NavigationSidebar from './navigations/NavigationSidebar'; + +interface MobileToggleProps { + serverId: string; +} +export default function MobileToggle({ serverId }: MobileToggleProps) { + return ( + + + + + +
+ +
+ +
+
+ ); +} diff --git a/components/modals/category-modal.tsx b/components/modals/category-modal.tsx new file mode 100644 index 0000000..f9cb49b --- /dev/null +++ b/components/modals/category-modal.tsx @@ -0,0 +1,121 @@ +'use client'; +import React, { useEffect, useState } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; +import axios from 'axios'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; + +import { useRouter } from 'next/navigation'; +import { useModal } from '@/hooks/use-modal-store'; +import { ChannelType } from '@prisma/client'; + +const formSchema = z.object({ + name: z + .string() + .min(1, { + message: 'category name is required', + }) + .refine((name) => name.toLocaleLowerCase() !== 'text', { + message: 'Channel name cannot be "text"', + }) + .refine((name) => name.toLocaleLowerCase() !== 'audio', { + message: 'Channel name cannot be "audio"', + }) + .refine((name) => name.toLocaleLowerCase() !== 'video', { + message: 'Channel name cannot be "video"', + }), +}); + +export default function CategoryModal() { + const { isOpen, onClose, type, data } = useModal(); + const isModalOpen = isOpen && type === 'category'; + const handleClose = () => { + form.reset(); + onClose(); + }; + const router = useRouter(); + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + name: '', + }, + }); + + const isloading = form.formState.isSubmitting; + async function onSubmit(values: z.infer) { + try { + // const res = await axios.post( + // `/api/category?serverId=${data.server?.id}`, + // values + // ); + form.reset(); + router.refresh(); + onClose(); + } catch (error) { + console.log(error); + } + } + return ( + + + + + Create a Category + + +
+ + ( + + CATEGORY NAME + + + + + + )} + /> + + + + + + +
+
+ ); +} diff --git a/components/modals/channel-modal.tsx b/components/modals/channel-modal.tsx new file mode 100644 index 0000000..1cd7f60 --- /dev/null +++ b/components/modals/channel-modal.tsx @@ -0,0 +1,157 @@ +'use client'; +import React, { use, useEffect, useState } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; +import axios from 'axios'; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import Link from 'next/link'; +import { UploadButton } from '@/lib/uploadThing'; +import FileUpload from '../fileUpload'; +import { useRouter } from 'next/navigation'; +import { useModal } from '@/hooks/use-modal-store'; +import { ChannelType } from '@prisma/client'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +const formSchema = z.object({ + name: z + .string() + .min(1, { + message: 'Channel name is required', + }) + .refine((name) => name !== 'general', { + message: 'Channel name cannot be "general"', + }), + type: z.nativeEnum(ChannelType), +}); + +export default function ChannelModal() { + const { isOpen, onClose, type, data } = useModal(); + const isModalOpen = isOpen && type === 'channel'; + const handleClose = () => { + form.reset(); + onClose(); + }; + const router = useRouter(); + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + name: '', + type: ChannelType.TEXT, + }, + }); + + useEffect(() => { + if (data.channelType) { + form.setValue('type', data.channelType); + } else { + form.setValue('type', ChannelType.TEXT); + } + }, [data.channelType, form]); + + const isloading = form.formState.isSubmitting; + async function onSubmit(values: z.infer) { + try { + const res = await axios.post( + `/api/channel?serverId=${data.server?.id}`, + values + ); + form.reset(); + router.refresh(); + onClose(); + } catch (error) { + console.log(error); + } + } + return ( + + + + + Create Channel + + +
+ + ( + + CHANNEL NAME + + + + + + )} + /> + ( + + Channel Type + + + )} + /> + + + + + +
+
+ ); +} diff --git a/components/modals/create-server-modal.tsx b/components/modals/create-server-modal.tsx new file mode 100644 index 0000000..5743b75 --- /dev/null +++ b/components/modals/create-server-modal.tsx @@ -0,0 +1,141 @@ +'use client'; +import React, { useEffect, useState } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; +import axios from 'axios'; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import Link from 'next/link'; +import { UploadButton } from '@/lib/uploadThing'; +import FileUpload from '../fileUpload'; +import { useRouter } from 'next/navigation'; +import { useModal } from '@/hooks/use-modal-store'; + +const formSchema = z.object({ + name: z.string().min(1, { + message: 'Server name is Required', + }), + imageUrl: z.string().min(1, { + message: 'Server image is Required', + }), +}); + +export default function CreateServerModal() { + const { isOpen, onClose, type } = useModal(); + const isModalOpen = isOpen && type === 'createServer'; + const handleClose = () => { + form.reset(); + onClose(); + }; + const router = useRouter(); + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + name: '', + imageUrl: '', + }, + }); + + const isloading = form.formState.isSubmitting; + async function onSubmit(values: z.infer) { + try { + const res = await axios.post('/api/servers', values); + form.reset(); + router.refresh(); + onClose(); + } catch (error) { + console.log(error); + } + } + return ( + + + + + Customize Your Server + + + Give your new server a personality with a name and an icon. You can + always change it later + + +
+ +
+ ( + + + + + + )} + /> +
+ ( + + SERVER NAME + + + + + + By Creating a Server, you agree to Discord's{' '} + + Community Guidlines + + + + )} + /> + + + + + +
+
+ ); +} diff --git a/components/modals/delete-channel.tsx b/components/modals/delete-channel.tsx new file mode 100644 index 0000000..5207414 --- /dev/null +++ b/components/modals/delete-channel.tsx @@ -0,0 +1,67 @@ +'use client'; +import React, { useState } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import axios from 'axios'; +import { useRouter } from 'next/navigation'; +import { useModal } from '@/hooks/use-modal-store'; + +import { Loader2Icon } from 'lucide-react'; + +export default function DeleteChannel() { + const { isOpen, onClose, type, data } = useModal(); + const isModalOpen = isOpen && type === 'deleteChannel'; + const handleClose = () => { + onClose(); + }; + const router = useRouter(); + + const [isloading, setIsLoading] = useState(false); + + async function onLeave() { + try { + setIsLoading(true); + const res = await axios.delete( + `/api/channel/${data.channel?.id}?serverId=${data.server?.id}` + ); + onClose(); + router.refresh(); + setIsLoading(false); + } catch (err) { + console.log(err); + } finally { + router.push('/'); + } + } + return ( + + + + + Delete Channel + + + Are you sure you want to delete{' '} + + {data.channel?.name} + + ? + + + + + + + ); +} diff --git a/components/modals/delete-message-modal.tsx b/components/modals/delete-message-modal.tsx new file mode 100644 index 0000000..a2a7de6 --- /dev/null +++ b/components/modals/delete-message-modal.tsx @@ -0,0 +1,78 @@ +'use client'; + +import qs from 'query-string'; +import axios from 'axios'; +import { useState } from 'react'; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { useModal } from '@/hooks/use-modal-store'; +import { Button } from '@/components/ui/button'; + +export const DeleteMessage = () => { + const { isOpen, onClose, type, data } = useModal(); + + const isModalOpen = isOpen && type === 'deleteMessage'; + const { apiUrl, query } = data; + + const [isLoading, setIsLoading] = useState(false); + + const onClick = async () => { + try { + setIsLoading(true); + const url = qs.stringifyUrl({ + url: apiUrl || '', + query, + }); + + await axios.delete(url); + + onClose(); + } catch (error) { + console.log(error); + } finally { + setIsLoading(false); + } + }; + + return ( + + + + + Delete Message + + + Are you sure you want to do this?
+ The message will be permanently deleted. +
+
+ +
+ + +
+
+
+
+ ); +}; diff --git a/components/modals/delete-server.tsx b/components/modals/delete-server.tsx new file mode 100644 index 0000000..30da442 --- /dev/null +++ b/components/modals/delete-server.tsx @@ -0,0 +1,69 @@ +'use client'; +import React, { useState } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import axios from 'axios'; +import { useRouter } from 'next/navigation'; +import { useModal } from '@/hooks/use-modal-store'; + +import { Loader2Icon } from 'lucide-react'; + +export default function DeleteServer() { + const { isOpen, onClose, type, data } = useModal(); + const isModalOpen = isOpen && type === 'deleteServer'; + const handleClose = () => { + onClose(); + }; + const router = useRouter(); + + const [isloading, setIsLoading] = useState(false); + + async function onLeave() { + try { + setIsLoading(true); + const res = await axios.delete(`/api/servers/${data.server?.id}/leave`); + onClose(); + router.refresh(); + setIsLoading(false); + } catch (err) { + console.log(err); + } finally { + router.push('/'); + } + } + return ( + + + + + Tussi ja rhe ho + + + Are you sure you want to delete{' '} + + {data.server?.name} + + ? + + + + + + + ); +} diff --git a/components/modals/download-modal.tsx b/components/modals/download-modal.tsx new file mode 100644 index 0000000..51c3e28 --- /dev/null +++ b/components/modals/download-modal.tsx @@ -0,0 +1,79 @@ +'use client'; +import { Dialog, DialogContent } from '@/components/ui/dialog'; +import { useModal } from '@/hooks/use-modal-store'; +import DownloadItem from './downloadModalItem'; + +export default function DownloadModal() { + const { isOpen, onClose, type } = useModal(); + const isModalOpen = isOpen && type === 'downloadApps'; + + const handleClose = () => { + onClose(); + }; + + return ( + + +
+ + + +
+

or on the go

+
+ + +
+
+
+ ); +} diff --git a/components/modals/downloadModalItem.tsx b/components/modals/downloadModalItem.tsx new file mode 100644 index 0000000..9c59aca --- /dev/null +++ b/components/modals/downloadModalItem.tsx @@ -0,0 +1,43 @@ +import Image from 'next/image'; +import React from 'react'; +import { Button } from '../ui/button'; +import { redirect } from 'next/navigation'; + +export default function DownloadItem({ + name, + src, + buttons, +}: { + name: string; + src: string; + buttons: { + name: string; + url: string; + }[]; +}) { + return ( +
+ {name} +

{name}

+
+ {buttons.map((button, id) => { + return ( + + ); + })} +
+
+ ); +} diff --git a/components/modals/edit-channel.tsx b/components/modals/edit-channel.tsx new file mode 100644 index 0000000..49f62aa --- /dev/null +++ b/components/modals/edit-channel.tsx @@ -0,0 +1,156 @@ +'use client'; +import React, { use, useEffect, useState } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; +import axios from 'axios'; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import Link from 'next/link'; +import { UploadButton } from '@/lib/uploadThing'; +import FileUpload from '../fileUpload'; +import { useRouter } from 'next/navigation'; +import { useModal } from '@/hooks/use-modal-store'; +import { ChannelType } from '@prisma/client'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +const formSchema = z.object({ + name: z + .string() + .min(1, { + message: 'Channel name is required', + }) + .refine((name) => name !== 'general', { + message: 'Channel name cannot be "general"', + }), + type: z.nativeEnum(ChannelType), +}); + +export default function ChannelEdit() { + const { isOpen, onClose, type, data } = useModal(); + const isModalOpen = isOpen && type === 'editChannel'; + const handleClose = () => { + form.reset(); + onClose(); + }; + const router = useRouter(); + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + name: data.channel?.name, + type: data.channel?.type, + }, + }); + + useEffect(() => { + if (data.channel?.type) { + form.setValue('type', data.channel?.type); + } + if (data.channel?.name) { + form.setValue('name', data.channel?.name); + } + }, [form, data.channel]); + + const isloading = form.formState.isSubmitting; + async function onSubmit(values: z.infer) { + try { + const res = await axios.put( + `/api/channel/${data.channel?.id}?serverId=${data.server?.id}`, + values + ); + form.reset(); + router.refresh(); + onClose(); + } catch (error) { + console.log(error); + } + } + return ( + + + + Edit Channel + +
+ + ( + + CHANNEL NAME + + + + + + )} + /> + ( + + Channel Type + + + )} + /> + + + + + +
+
+ ); +} diff --git a/components/modals/edit-server-modal.tsx b/components/modals/edit-server-modal.tsx new file mode 100644 index 0000000..b0e08fb --- /dev/null +++ b/components/modals/edit-server-modal.tsx @@ -0,0 +1,146 @@ +'use client'; +import React, { useEffect } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; +import axios from 'axios'; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; + +import FileUpload from '../fileUpload'; +import { useRouter } from 'next/navigation'; +import { useModal } from '@/hooks/use-modal-store'; + +const formSchema = z.object({ + name: z.string().min(1, { + message: 'Server name is Required', + }), + imageUrl: z.string().min(1, { + message: 'Server image is Required', + }), +}); + +export default function EditServerModal() { + const { isOpen, onClose, type, data } = useModal(); + const isModalOpen = isOpen && type === 'editServer'; + const handleClose = () => { + form.reset(); + onClose(); + }; + + const router = useRouter(); + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + name: '', + imageUrl: '', + }, + }); + useEffect(() => { + if (data.server) { + form.setValue('name', data.server.name); + form.setValue('imageUrl', data.server.imageUrl); + } + }, [data.server, form]); + + const isloading = form.formState.isSubmitting; + async function onSubmit(values: z.infer) { + try { + const res = await axios.put(`/api/servers/${data.server?.id}`, values); + form.reset(); + router.refresh(); + onClose(); + } catch (error) { + console.log(error); + } + } + return ( + + + + + Edit Your Server + + + Give your new server a personality with a name and an icon. You can + always change it later + + +
+ +
+ ( + + + + + + )} + /> +
+ ( + + SERVER NAME + + + + + + By Creating a Server, you agree to Discord's{' '} + + Community Guidlines + + + + )} + /> + + + + + +
+
+ ); +} diff --git a/components/modals/event-modal.tsx b/components/modals/event-modal.tsx new file mode 100644 index 0000000..98b9dc6 --- /dev/null +++ b/components/modals/event-modal.tsx @@ -0,0 +1,55 @@ +'use client'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; + +import { useModal } from '@/hooks/use-modal-store'; + +import { CalendarRange } from 'lucide-react'; + +export default function EventModal() { + const { isOpen, onClose, type } = useModal(); + const isModalOpen = isOpen && type === 'events'; + + const handleClose = () => { + onClose(); + }; + + return ( + + + + +
+ + events +
+ +
+
+
+
+ +
+

+ There are no upcoming events. +

+

+ Schedule an event for any planned activity in your server +

+

+ You can give other people permission to create events in +

+ + Server Settings < Roles. + +
+
+
+ ); +} diff --git a/components/modals/initial-modal.tsx b/components/modals/initial-modal.tsx new file mode 100644 index 0000000..77482b6 --- /dev/null +++ b/components/modals/initial-modal.tsx @@ -0,0 +1,138 @@ +'use client'; +import React, { useEffect, useState } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; +import axios from 'axios'; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import FileUpload from '../fileUpload'; +import { useRouter } from 'next/navigation'; + +const formSchema = z.object({ + name: z.string().min(1, { + message: 'Server name is Required', + }), + imageUrl: z.string().min(1, { + message: 'Server image is Required', + }), +}); + +export default function InitialModal() { + const [isMounted, setIsMounted] = useState(false); + useEffect(() => { + setIsMounted(true); + }, []); + const router = useRouter(); + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + name: '', + imageUrl: '', + }, + }); + + const isloading = form.formState.isSubmitting; + async function onSubmit(values: z.infer) { + try { + const res = await axios.post('/api/servers', values); + form.reset(); + router.refresh(); + window.location.reload(); + } catch (error) { + console.log(error); + } + } + if (!isMounted) { + return null; + } + return ( + + + + + Customize Your Server + + + Give your new server a personality with a name and an icon. You can + always change it later + + +
+ +
+ ( + + + + + + )} + /> +
+ ( + + SERVER NAME + + + + + + By Creating a Server, you agree to Discord's{' '} + + Community Guidlines + + + + )} + /> + + + + + +
+
+ ); +} diff --git a/components/modals/invite-modal.tsx b/components/modals/invite-modal.tsx new file mode 100644 index 0000000..87d8f15 --- /dev/null +++ b/components/modals/invite-modal.tsx @@ -0,0 +1,115 @@ +'use client'; +import React, { useState } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import axios from 'axios'; +import { Button } from '@/components/ui/button'; +import { useModal } from '@/hooks/use-modal-store'; +import { Checkbox } from '@/components/ui/checkbox'; + +import { Input } from '../ui/input'; +import { RefreshCwIcon, Settings } from 'lucide-react'; +import { useOrigin } from '@/hooks/use-origin'; +import { cn } from '@/lib/utils'; + +export default function InviteModal() { + const { onOpen, isOpen, onClose, type, data } = useModal(); + const isModalOpen = isOpen && type === 'invite'; + + const handleClose = () => { + onClose(); + }; + + const handleCopy = () => { + navigator.clipboard.writeText(inviteUrl); + setIsCopied(true); + setTimeout(() => { + setIsCopied(false); + }, 1000); + }; + const origin = useOrigin(); + const [isChecked, setIsChecked] = useState(false); + const [isCopy, setIsCopied] = useState(false); + + const inviteUrl = `${origin}/invite/${data.server?.inviteCode}`; + const [isLoading, setisLoading] = useState(false); + const handleNew = async () => { + setisLoading(true); + try { + const res = await axios.patch(`/api/servers/${data.server?.id}`); + onOpen('invite', { server: res.data }); + } catch (err) { + console.log(err); + } finally { + setisLoading(false); + } + }; + + return ( + + + + + invite friends to {data.server?.name} + + + # General + + +
+ +
+ + +
+

+ Your invite link + {isChecked ? ' will never expire' : ' expires in 7 days'}. +

+
+
+
+ setIsChecked(!isChecked)} + disabled={isLoading} + /> + +
+ +
+
+
+ ); +} diff --git a/components/modals/leave-server.tsx b/components/modals/leave-server.tsx new file mode 100644 index 0000000..c0878ba --- /dev/null +++ b/components/modals/leave-server.tsx @@ -0,0 +1,69 @@ +'use client'; +import React, { useState } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import axios from 'axios'; +import { useRouter } from 'next/navigation'; +import { useModal } from '@/hooks/use-modal-store'; + +import { Loader2Icon } from 'lucide-react'; + +export default function LeaveServer() { + const { isOpen, onClose, type, data } = useModal(); + const isModalOpen = isOpen && type === 'leaveServer'; + const handleClose = () => { + onClose(); + }; + const router = useRouter(); + + const [isloading, setIsLoading] = useState(false); + + async function onLeave() { + try { + setIsLoading(true); + const res = await axios.post(`/api/servers/${data.server?.id}/leave`); + onClose(); + router.refresh(); + setIsLoading(false); + } catch (err) { + console.log(err); + } finally { + router.push('/'); + } + } + return ( + + + + + Tussi ja rhe ho + + + Are you sure you want to leave + + {data.server?.name} + + ? + + + + + + + ); +} diff --git a/components/modals/manage-members-modal.tsx b/components/modals/manage-members-modal.tsx new file mode 100644 index 0000000..9c00ab6 --- /dev/null +++ b/components/modals/manage-members-modal.tsx @@ -0,0 +1,183 @@ +'use client'; +import React, { useState } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import axios from 'axios'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; + +import { Button } from '@/components/ui/button'; + +import { useRouter } from 'next/navigation'; +import { useModal } from '@/hooks/use-modal-store'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; + +import { ScrollArea } from '@radix-ui/react-scroll-area'; +import { + Check, + GavelIcon, + Loader2, + MoreVertical, + Shield, + ShieldAlert, + ShieldCheck, + ShieldCheckIcon, + ShieldQuestionIcon, +} from 'lucide-react'; + +export default function ManageMemberModal() { + const { onOpen, isOpen, onClose, type, data } = useModal(); + const isModalOpen = isOpen && type === 'members'; + const handleClose = () => { + onClose(); + }; + const router = useRouter(); + + const roleIconMap = { + GUEST: null, + MODERATOR: , + ADMIN: , + }; + const [loadingId, setloadingId] = useState(''); + const onRoleChange = async (role: string, memberId: string) => { + try { + setloadingId(memberId); + const response = await axios.patch(`/api/member/${memberId}`, { + role: role, + serverId: data.server?.id, + }); + onOpen('members', { server: response.data }); + } catch (err) { + console.log(err); + } + setloadingId(''); + router.refresh(); + }; + const onKick = async (memberId: string) => { + try { + setloadingId(memberId); + const response = await axios.delete( + `/api/member/${memberId}?serverId=${data.server?.id}` + ); + onOpen('members', { server: response.data }); + } catch (err) { + console.log(err); + } + setloadingId(''); + router.refresh(); + }; + if (data.server?.members == undefined) return null; + return ( + + + + + Manage Members + + + {data.server?.members?.length <= 1 + ? `${data.server?.members?.length} Member` + : `${data.server?.members?.length} Members`} + + + + {data.server?.members?.map((member, id) => { + return ( +
+
+ + + CN + +
+
+

{member.profile.name}

+

{roleIconMap[member.role]}

+
+

+ {member.profile.email} +

+
+
+ {data.server?.profileId != member.profileId && + loadingId != member.id && ( + + + + + + + Role + + + { + onRoleChange(val, member.id); + }} + > + +
+ + Guest +
+ {member.role === 'GUEST' && ( + + )} +
+ +
+ + Moderator +
+ {member.role === 'MODERATOR' && ( + + )} +
+
+ + + onKick(member.id)} + > +
+ + Kick +
+
+
+
+
+ )} + {loadingId == member.id && ( + + )} +
+ ); + })} +
+
+
+ ); +} diff --git a/components/modals/message-file-modal.tsx b/components/modals/message-file-modal.tsx new file mode 100644 index 0000000..18196c4 --- /dev/null +++ b/components/modals/message-file-modal.tsx @@ -0,0 +1,115 @@ +'use client'; + +import axios from 'axios'; +import qs from 'query-string'; +import * as z from 'zod'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Form, FormControl, FormField, FormItem } from '@/components/ui/form'; +import { Button } from '@/components/ui/button'; +import { useRouter } from 'next/navigation'; +import { useModal } from '@/hooks/use-modal-store'; +import FileUpload from '../fileUpload'; + +const formSchema = z.object({ + fileUrl: z.string().min(1, { + message: 'Attachment is required.', + }), +}); + +export const MessageFileModal = () => { + const { isOpen, onClose, type, data } = useModal(); + const router = useRouter(); + + const isModalOpen = isOpen && type === 'messageFile'; + const { apiUrl, query } = data; + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + fileUrl: '', + }, + }); + + const handleClose = () => { + form.reset(); + onClose(); + }; + + const isLoading = form.formState.isSubmitting; + + const onSubmit = async (values: z.infer) => { + try { + const url = qs.stringifyUrl({ + url: apiUrl || '', + query, + }); + + await axios.post(url, { + ...values, + content: values.fileUrl, + }); + + form.reset(); + router.refresh(); + handleClose(); + } catch (error) { + console.log(error); + } + }; + + return ( + + + + + Add an attachment + + + Send a file as a message + + +
+ +
+
+ ( + + + + + + )} + /> +
+
+ + + +
+ +
+
+ ); +}; diff --git a/components/mode-toggle.tsx b/components/mode-toggle.tsx new file mode 100644 index 0000000..5b08611 --- /dev/null +++ b/components/mode-toggle.tsx @@ -0,0 +1,44 @@ +'use client'; + +import * as React from 'react'; +import { MoonIcon, SunIcon } from 'lucide-react'; +import { useTheme } from 'next-themes'; + +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; + +export function ModeToggle() { + const { setTheme } = useTheme(); + + return ( + + + + + + setTheme('light')}> + Light + + setTheme('dark')}> + Dark + + setTheme('system')}> + System + + + + ); +} diff --git a/components/navigations/Navigation-action.tsx b/components/navigations/Navigation-action.tsx new file mode 100644 index 0000000..2470b9f --- /dev/null +++ b/components/navigations/Navigation-action.tsx @@ -0,0 +1,25 @@ +'use client'; +import { Plus } from 'lucide-react'; +import ActionTooltip from '../Action-tooltip'; +import { useModal } from '@/hooks/use-modal-store'; + +export default function NavigationAction() { + const { onOpen } = useModal(); + return ( +
+ + + +
+ ); +} diff --git a/components/navigations/Navigation-item.tsx b/components/navigations/Navigation-item.tsx new file mode 100644 index 0000000..931f403 --- /dev/null +++ b/components/navigations/Navigation-item.tsx @@ -0,0 +1,45 @@ +'use client'; +import ActionTooltip from '../Action-tooltip'; +import Image from 'next/image'; +import { cn } from '@/lib/utils'; +import { useParams, useRouter } from 'next/navigation'; + +function NavigationItem({ + id, + imageUrl, + name, +}: { + id: string; + imageUrl: string; + name: string; +}) { + const router = useRouter(); + const onclick = () => { + router.push(`/servers/${id}`); + }; + const params = useParams(); + return ( + + + + ); +} + +export default NavigationItem; diff --git a/components/navigations/NavigationDownload.tsx b/components/navigations/NavigationDownload.tsx new file mode 100644 index 0000000..92e7247 --- /dev/null +++ b/components/navigations/NavigationDownload.tsx @@ -0,0 +1,25 @@ +'use client'; +import { Download } from 'lucide-react'; +import ActionTooltip from '../Action-tooltip'; +import { useModal } from '@/hooks/use-modal-store'; + +export default function NavigationDownload() { + const { onOpen } = useModal(); + return ( +
+ + + +
+ ); +} diff --git a/components/navigations/NavigationSidebar.tsx b/components/navigations/NavigationSidebar.tsx new file mode 100644 index 0000000..f13b877 --- /dev/null +++ b/components/navigations/NavigationSidebar.tsx @@ -0,0 +1,59 @@ +import { currentProfile } from '@/lib/currentProfile'; +import { db } from '@/lib/db'; +import { redirect } from 'next/navigation'; +import React from 'react'; +import NavigationAction from './Navigation-action'; +import { Separator } from '@/components/ui/separator'; +import { ScrollArea } from '../ui/scroll-area'; +import NavigationItem from './Navigation-item'; +import { ModeToggle } from '../mode-toggle'; +import { UserButton } from '@clerk/nextjs'; +import NavigationDownload from './NavigationDownload'; + +export default async function NavigationSidebar() { + const profile = await currentProfile(); + if (!profile) { + return redirect('/'); + } + const servers = await db.server.findMany({ + where: { + members: { + some: { + profileId: profile.id, + }, + }, + }, + }); + return ( +
+ + + + + {servers.map((server) => { + return ( +
+ +
+ ); + })} +
+ +
+ + +
+
+ ); +} diff --git a/components/provider/modal-provider.tsx b/components/provider/modal-provider.tsx new file mode 100644 index 0000000..6c902c8 --- /dev/null +++ b/components/provider/modal-provider.tsx @@ -0,0 +1,44 @@ +'use client'; +import { useEffect, useState } from 'react'; +import CreateServerModal from '../modals/create-server-modal'; +import InviteModal from '../modals/invite-modal'; +import EventModal from '../modals/event-modal'; +import EditServerModal from '../modals/edit-server-modal'; +import DownloadModal from '../modals/download-modal'; +import ManageMemberModal from '../modals/manage-members-modal'; +import ChannelModal from '../modals/channel-modal'; +import LeaveServer from '../modals/leave-server'; +import DeleteServer from '../modals/delete-server'; +import CategoryModal from '../modals/category-modal'; +import DeleteChannel from '../modals/delete-channel'; +import ChannelEdit from '../modals/edit-channel'; +import { MessageFileModal } from '../modals/message-file-modal'; +import { DeleteMessage } from '../modals/delete-message-modal'; + +export const ModalProvider = () => { + const [isMounted, setIsMounted] = useState(false); + useEffect(() => { + setIsMounted(true); + }, []); + if (!isMounted) { + return null; + } + return ( + <> + + + + + + + + + + + + + + + + ); +}; diff --git a/components/provider/query-provider.tsx b/components/provider/query-provider.tsx new file mode 100644 index 0000000..26af9d1 --- /dev/null +++ b/components/provider/query-provider.tsx @@ -0,0 +1,12 @@ +'use client'; + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { useState } from 'react'; + +export const QueryProvider = ({ children }: { children: React.ReactNode }) => { + const [queryClient] = useState(() => new QueryClient()); + + return ( + {children} + ); +}; diff --git a/components/provider/socket-provider.tsx b/components/provider/socket-provider.tsx new file mode 100644 index 0000000..c9656c8 --- /dev/null +++ b/components/provider/socket-provider.tsx @@ -0,0 +1,53 @@ +'use client'; + +import { createContext, useContext, useEffect, useState } from 'react'; +import { io as ClientIO } from 'socket.io-client'; + +type SocketContextType = { + socket: any | null; + isConnected: boolean; +}; + +const SocketContext = createContext({ + socket: null, + isConnected: false, +}); + +export const useSocket = () => { + return useContext(SocketContext); +}; + +export const SocketProvider = ({ children }: { children: React.ReactNode }) => { + const [socket, setSocket] = useState(null); + const [isConnected, setIsConnected] = useState(false); + + useEffect(() => { + const socketInstance = new (ClientIO as any)( + process.env.NEXT_PUBLIC_SITE_URL!, + { + path: '/api/socket/io', + addTrailingSlash: false, + } + ); + + socketInstance.on('connect', () => { + setIsConnected(true); + }); + + socketInstance.on('disconnect', () => { + setIsConnected(false); + }); + + setSocket(socketInstance); + + return () => { + socketInstance.disconnect(); + }; + }, []); + + return ( + + {children} + + ); +}; diff --git a/components/provider/theme-provider.tsx b/components/provider/theme-provider.tsx new file mode 100644 index 0000000..32a8453 --- /dev/null +++ b/components/provider/theme-provider.tsx @@ -0,0 +1,9 @@ +'use client'; + +import * as React from 'react'; +import { ThemeProvider as NextThemesProvider } from 'next-themes'; +import { type ThemeProviderProps } from 'next-themes/dist/types'; + +export function ThemeProvider({ children, ...props }: ThemeProviderProps) { + return {children}; +} diff --git a/components/server/serverChannel.tsx b/components/server/serverChannel.tsx new file mode 100644 index 0000000..3ee54a5 --- /dev/null +++ b/components/server/serverChannel.tsx @@ -0,0 +1,80 @@ +'use client'; +import { ModalType, useModal } from '@/hooks/use-modal-store'; +import { Channel, ChannelType, MemberRole, Server } from '@prisma/client'; +import { Edit, Hash, Lock, Mic, Trash, Video } from 'lucide-react'; +import { useParams, useRouter } from 'next/navigation'; +import ActionTooltip from '../Action-tooltip'; +import { cn } from '@/lib/utils'; + +interface ServerChannelProps { + channel: Channel; + server: Server; + role?: MemberRole; +} + +const iconMap = { + [ChannelType.TEXT]: Hash, + [ChannelType.AUDIO]: Mic, + [ChannelType.VIDEO]: Video, +}; + +export const ServerChannel = ({ + channel, + server, + role, +}: ServerChannelProps) => { + const { onOpen } = useModal(); + const params = useParams(); + const router = useRouter(); + + const Icon = iconMap[channel.type]; + + const onClick = () => { + router.push(`/servers/${params?.serverId}/channels/${channel.id}`); + }; + + const onAction = (e: React.MouseEvent, action: ModalType) => { + e.stopPropagation(); + onOpen(action, { channel, server }); + }; + + return ( + + ); +}; diff --git a/components/server/serverEventHeader.tsx b/components/server/serverEventHeader.tsx new file mode 100644 index 0000000..bc7341d --- /dev/null +++ b/components/server/serverEventHeader.tsx @@ -0,0 +1,20 @@ +'use client'; +import { useModal } from '@/hooks/use-modal-store'; +import { CalendarRange } from 'lucide-react'; +import React from 'react'; + +export default function ServerEventHeader() { + const { onOpen } = useModal(); + const handleClick = () => { + onOpen('events'); + }; + return ( + + ); +} diff --git a/components/server/serverHeader.tsx b/components/server/serverHeader.tsx new file mode 100644 index 0000000..30af4b7 --- /dev/null +++ b/components/server/serverHeader.tsx @@ -0,0 +1,109 @@ +'use client'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { + ChevronDown, + FolderClosedIcon, + LogOutIcon, + PlusCircle, + Settings, + Trash, + UserPlus, + Users, +} from 'lucide-react'; +import { Channel, Member, MemberRole, Profile, Server } from '@prisma/client'; +import { useModal } from '@/hooks/use-modal-store'; +import { ServerWithMembersWithProfiles } from '@/types'; +export default function ServerHeader({ + server, + role, +}: { + server: ServerWithMembersWithProfiles; + role?: MemberRole; +}) { + const { onOpen } = useModal(); + const isAdmin = role === MemberRole.ADMIN; + const isModerator = isAdmin || role === MemberRole.MODERATOR; + return ( + + + + + + {isModerator && ( + onOpen('invite', { server })} + className="flex flex-row items-center justify-between text-indigo-600 dark:text-indigo-400 px-3 py-2 text-sm cursor-pointer" + > + Invite People + + + )} + {isAdmin && ( + onOpen('editServer', { server })} + className="flex flex-row items-center justify-between px-3 py-2 text-sm cursor-pointer" + > + Server Settings + + + )} + {isAdmin && ( + onOpen('members', { server })} + className="flex flex-row items-center justify-between px-3 py-2 text-sm cursor-pointer" + > + Manage Members + + + )} + {isModerator && ( + onOpen('channel', { server })} + className="flex flex-row items-center justify-between px-3 py-2 text-sm cursor-pointer" + > + Create Channel + + + )} + {isModerator && ( + onOpen('category', { server })} + className="flex flex-row items-center justify-between px-3 py-2 text-sm cursor-pointer" + > + Create Category + + + )} + {isModerator && } + + {isAdmin && ( + onOpen('deleteServer', { server })} + className=" text-rose-500 flex flex-row items-center justify-between px-3 py-2 text-sm cursor-pointer" + > + Delete Server + + + )} + {!isAdmin && ( + onOpen('leaveServer', { server })} + className="text-rose-500 flex flex-row items-center justify-between px-3 py-2 text-sm cursor-pointer" + > + Leave Server + + + )} + + + ); +} diff --git a/components/server/serverMember.tsx b/components/server/serverMember.tsx new file mode 100644 index 0000000..0452dd3 --- /dev/null +++ b/components/server/serverMember.tsx @@ -0,0 +1,57 @@ +'use client'; + +import { Member, MemberRole, Profile, Server } from '@prisma/client'; +import { ShieldAlert, ShieldCheck } from 'lucide-react'; +import { useParams, useRouter } from 'next/navigation'; + +import { cn } from '@/lib/utils'; +import { UserAvatar } from '../avatar'; + +interface ServerMemberProps { + member: Member & { profile: Profile }; + server: Server; +} + +const roleIconMap = { + [MemberRole.GUEST]: null, + [MemberRole.MODERATOR]: ( + + ), + [MemberRole.ADMIN]: , +}; + +export const ServerMember = ({ member, server }: ServerMemberProps) => { + const params = useParams(); + const router = useRouter(); + + const icon = roleIconMap[member.role]; + + const onClick = () => { + router.push(`/servers/${params?.serverId}/conversations/${member.id}`); + }; + + return ( + + ); +}; diff --git a/components/server/serverSearch.tsx b/components/server/serverSearch.tsx new file mode 100644 index 0000000..e62963c --- /dev/null +++ b/components/server/serverSearch.tsx @@ -0,0 +1,105 @@ +'use client'; + +import { Search } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { useParams, useRouter } from 'next/navigation'; + +import { + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command'; +interface ServerSearchProps { + data: { + label: string; + type: 'channel' | 'member'; + data: + | { + icon: React.ReactNode; + name: string; + id: string; + }[] + | undefined; + }[]; +} + +export default function ServerSearch({ data }: ServerSearchProps) { + const [open, setOpen] = useState(false); + const router = useRouter(); + const params = useParams(); + + useEffect(() => { + const down = (e: KeyboardEvent) => { + if (e.key === 'k' && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + setOpen((open) => !open); + } + }; + + document.addEventListener('keydown', down); + return () => document.removeEventListener('keydown', down); + }, []); + + const onClick = ({ + id, + type, + }: { + id: string; + type: 'channel' | 'member'; + }) => { + setOpen(false); + + if (type === 'member') { + return router.push(`/servers/${params?.serverId}/conversations/${id}`); + } + + if (type === 'channel') { + return router.push(`/servers/${params?.serverId}/channels/${id}`); + } + }; + + return ( + <> + + + + + No Results found + {data.map(({ label, type, data }) => { + if (!data?.length) return null; + + return ( + + {data?.map(({ id, icon, name }) => { + return ( + onClick({ id, type })} + > + {icon} + {name} + + ); + })} + + ); + })} + + + + ); +} diff --git a/components/server/serverSection.tsx b/components/server/serverSection.tsx new file mode 100644 index 0000000..420a947 --- /dev/null +++ b/components/server/serverSection.tsx @@ -0,0 +1,52 @@ +'use client'; +import { useModal } from '@/hooks/use-modal-store'; +import { ServerWithMembersWithProfiles } from '@/types'; +import { ChannelType, MemberRole } from '@prisma/client'; +import ActionTooltip from '../Action-tooltip'; +import { Plus, Settings } from 'lucide-react'; + +interface ServerSectionProps { + label: string; + role?: MemberRole; + sectionType: 'channels' | 'members'; + channelType?: ChannelType; + server?: ServerWithMembersWithProfiles; +} + +export const ServerSection = ({ + label, + role, + sectionType, + channelType, + server, +}: ServerSectionProps) => { + const { onOpen, data } = useModal(); + + return ( +
+

+ {label} +

+ {role !== MemberRole.GUEST && sectionType === 'channels' && ( + + + + )} + {role === MemberRole.ADMIN && sectionType === 'members' && ( + + + + )} +
+ ); +}; diff --git a/components/server/serverSidebar.tsx b/components/server/serverSidebar.tsx new file mode 100644 index 0000000..5e5d49b --- /dev/null +++ b/components/server/serverSidebar.tsx @@ -0,0 +1,208 @@ +import React from 'react'; + +import ServerHeader from './serverHeader'; +import { db } from '@/lib/db'; +import { ChannelType, MemberRole } from '@prisma/client'; +import { redirectToSignIn } from '@clerk/nextjs'; +import { currentProfile } from '@/lib/currentProfile'; +import { redirect } from 'next/navigation'; +import ServerEventHeader from './serverEventHeader'; +import ServerSearch from './serverSearch'; +import { Hash, Mic, ShieldAlert, ShieldCheck, Video } from 'lucide-react'; +import { ServerSection } from './serverSection'; +import { ScrollArea } from '../ui/scroll-area'; +import { Separator } from '../ui/separator'; +import { ServerChannel } from './serverChannel'; +import { ServerMember } from './serverMember'; +interface ServerSidebarProps { + serverId: string; +} + +const iconMap = { + [ChannelType.TEXT]: , + [ChannelType.AUDIO]: , + [ChannelType.VIDEO]: