Compare commits

...

83 Commits

Author SHA1 Message Date
ad16705a5f fixed typo in file names 2023-11-13 00:29:05 -08:00
bbd7bf482d reordered imports for messages 2023-11-13 00:15:02 -08:00
7414dff177 fixed messages typo 2023-11-13 00:12:04 -08:00
b1f4019622 fixed more typos 2023-11-13 00:11:52 -08:00
64eb438da7 fixed status of loading in chat messages 2023-11-13 00:08:46 -08:00
f86cc88e0b fixed chat item 2023-11-13 00:08:24 -08:00
b650affe09 fixed version 2023-11-13 00:08:11 -08:00
b83fbc5f77 added live video. seem broken without ssl 2023-11-12 23:30:59 -08:00
a168a094d1 added delete and edit direct messages broke id und 2023-11-12 21:19:17 -08:00
4448cecdda final prisma for video 2023-11-12 21:15:07 -08:00
e7397aad68 fixed code organization in channels page 2023-11-12 21:14:40 -08:00
01b41845b3 fixt another memberOne and Two type 2023-11-12 17:45:20 -08:00
a57859aef0 fixed type on prisma schema and others 2023-11-12 17:31:24 -08:00
ca3057c895 added chat scroll hook 2023-10-29 17:23:13 -07:00
a105e6180e hooked the socket to messages and applyed a fix 2023-10-29 16:28:03 -07:00
5da047a274 add chat item component and edit delete message 2023-10-28 00:31:56 -07:00
952f93cab0 added datefns to project 2023-10-28 00:30:44 -07:00
70141f46fd added chat messages component and query provider 2023-10-27 22:45:31 -07:00
55ad18e04a added tanstack to the package 2023-10-27 22:44:45 -07:00
350b24f8ea added emoji picker 2023-10-26 20:24:26 -07:00
484a8f4107 added emoji mart and popover component 2023-10-26 20:24:09 -07:00
fa641dc3a1 added message file upload for image and pdf 2023-10-26 19:48:25 -07:00
e0162c2d5e added messages api and socket workaround 2023-10-26 18:44:20 -07:00
16ce64ba61 added chat input 2023-10-23 00:05:37 -07:00
c517a1f354 ADDED SOCKET PROVIDER fixed a typo in file names 2023-10-22 22:26:46 -07:00
adea34b31b added badge and socket.io to the package 2023-10-22 22:24:27 -07:00
7d48962a96 added member chatheader and conversation page 2023-10-21 00:21:44 -07:00
ad78831d7e added messages conversations and dm to prisma 2023-10-20 22:44:49 -07:00
3c586ddc99 added mobile mode button and sidebar 2023-10-20 22:44:03 -07:00
d148623a0d created channelid and memberid page 2023-10-20 19:42:52 -07:00
725c67d6f7 fixed member button classname 2023-10-20 19:38:37 -07:00
24bc54ef6e fixed useRouter import 2023-10-20 19:36:58 -07:00
6b4b41bfc8 fixed typo 2023-10-20 17:41:39 -07:00
d71588b8ac added delete and edit channel modals 2023-10-18 22:55:19 -07:00
bccc53942d fixed lightmode sidebar bg color 2023-10-18 22:19:21 -07:00
d3cdd24d80 added channel list and members to server sidebar 2023-10-18 14:56:26 -07:00
f8f4f0f94a fixed a typo in serverheader 2023-10-18 14:54:54 -07:00
fca82def36 added search 2023-10-15 20:29:24 -07:00
0a891f065d added cmdk to project 2023-10-15 20:29:08 -07:00
1784e9ceb8 added delete and leave functionality 2023-10-15 18:12:54 -07:00
12032e868c added create channel modal and route 2023-10-15 16:11:40 -07:00
7e6004fc87 added shadcn select to the project 2023-10-15 16:11:10 -07:00
835b9a23e2 added members modal and route 2023-10-14 21:05:11 -07:00
ce96a85e55 fixed a typo 2023-10-14 21:04:43 -07:00
a0534eab12 added on open event for manage members 2023-10-14 21:04:14 -07:00
6a26bff3cd added UserAvatar 2023-10-14 21:03:42 -07:00
b7220c216c added quary-string and avatar to project 2023-10-14 21:03:01 -07:00
a05031374c cleaner for edit server 2023-10-14 17:09:23 -07:00
601126e317 added edit sever settings 2023-10-14 17:05:01 -07:00
e3075627a4 added invitation popup 2023-10-14 00:06:14 -07:00
2c29ae11e7 changed inviteCode to unique 2023-10-14 00:04:30 -07:00
c0df5a4df0 fixed final button in server header dropdown 2023-10-13 00:43:55 -07:00
868021049c created server sidebar header and dropdown menu 2023-10-11 00:12:02 -07:00
65d90df42a added create server modal baseed on initial modal 2023-10-10 21:34:38 -07:00
784e24e8f3 implemented modal providor modal store 2023-10-10 21:33:43 -07:00
266cb715b0 added zustand to the project 2023-10-10 21:30:19 -07:00
58097695f3 added main layout and server page and navigation 2023-10-10 17:49:42 -07:00
47bcb9757c transparent mode toggle 2023-10-10 17:46:39 -07:00
7d9c652d3f added action-tooltip and transparent mode-toggle 2023-10-10 17:45:30 -07:00
f41829fa41 added scroll-area separator and tooltip 2023-10-10 17:41:57 -07:00
4763519f35 added current profile, create a server 2023-10-08 18:18:17 -07:00
6ed6687800 added axios to the project 2023-10-08 18:17:11 -07:00
02c42c93e1 fixed type in modal 2023-10-08 17:30:03 -07:00
f05ddd3236 added uploadthing and updated initial modal 2023-10-08 17:25:08 -07:00
b577450c02 updated allow domains in next config 2023-10-08 17:23:35 -07:00
8367231424 added initial modal and more components 2023-10-08 02:33:48 -07:00
e493f48458 added resolvers 2023-10-08 02:33:09 -07:00
c7b61d05cb added prisma schema, db and initial profile 2023-10-07 23:19:53 -07:00
db7c2350c5 added prisma and prisma client 2023-10-07 23:17:51 -07:00
a252c3769b removed main and moved layout back to app 2023-10-07 23:16:49 -07:00
c7a7740c7a added mode toggle 2023-10-07 13:34:15 -07:00
766d3131b1 added theme providor and updated the main layout 2023-10-07 13:34:04 -07:00
b6276aaa06 installed next themes and dropdown menu 2023-10-07 13:32:50 -07:00
e46ff98377 added userbutton for logout and profile 2023-10-07 13:15:12 -07:00
11ef6daf23 added clerk providor 2023-10-07 13:14:50 -07:00
9dd01e1291 added signup and signin pages 2023-10-07 13:14:34 -07:00
39e2caf39a added middleware for clerks 2023-10-07 13:14:06 -07:00
33136e797f added clerk to package 2023-10-07 12:43:16 -07:00
e09e609a1e updated git ignore for .env file 2023-10-07 12:42:18 -07:00
c70041cfed updated page layout and folders 2023-10-07 12:41:57 -07:00
ad3b195a38 added button component and button to home page. 2023-10-07 11:28:16 -07:00
cd3f757f0f added react-slot 2023-10-07 11:27:43 -07:00
0dcb937515 fixed typo in globals 2023-10-07 11:26:45 -07:00
102 changed files with 9784 additions and 99 deletions

1
.gitignore vendored
View File

@ -26,6 +26,7 @@ yarn-error.log*
# local env files
.env*.local
.env
# vercel
.vercel

View File

@ -0,0 +1,5 @@
import { SignIn } from "@clerk/nextjs";
export default function Page() {
return <SignIn />;
}

View File

@ -0,0 +1,5 @@
import { SignUp } from "@clerk/nextjs";
export default function Page() {
return <SignUp />;
}

9
app/(auth)/layout.tsx Normal file
View File

@ -0,0 +1,9 @@
const AuthLayout = ({ children }: { children: React.ReactNode}) => {
return (
<div className="h-full flex items-center justify-center">
{children}
</div>
);
}
export default AuthLayout;

View File

@ -0,0 +1,62 @@
import { currentProfile } from "@/lib/current-profile";
import { redirectToSignIn } from "@clerk/nextjs";
import { redirect } from "next/navigation";
import { db } from "@/lib/db";
interface InviteCodePageProps {
params: {
inviteCode: string;
};
};
const InviteCodePage = async ({
params
}: InviteCodePageProps) => {
const profile = await currentProfile();
if (!profile) {
return redirectToSignIn();
}
if (!params?.inviteCode) {
return redirect("/");
}
const existingServer = await db.server.findFirst({
where: {
inviteCode: params.inviteCode,
members: {
some: {
profileId: profile.id,
},
},
},
});
if (existingServer) {
return redirect(`/servers/${existingServer.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
}
export default InviteCodePage;

View File

@ -0,0 +1,97 @@
import { currentProfile } from "@/lib/current-profile";
import { redirectToSignIn } from "@clerk/nextjs";
import { db } from "@/lib/db";
import { redirect } from "next/navigation";
import { ChatHeader } from "@/components/chat/chat-header";
import { ChatInput } from "@/components/chat/chat-input";
import { ChatMessages } from "@/components/chat/chat-messages";
import { ChannelType } from "@prisma/client";
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 (
<div className="bg-white dark:bg-[#313338] flex flex-col h-full">
<ChatHeader
name={channel.name}
serverId={channel.serverId}
type={"channel"}
/>
{channel.type === ChannelType.TEXT && (
<>
<ChatMessages
member={member}
name={channel.name}
chatId={channel.id}
type="channel"
apiUrl="/api/messages"
socketUrl="/api/socket/messages"
socketQuery={{
channelId: channel.id,
serverId: channel.serverId,
}}
paramKey="channelId"
paramValue={channel.id}
/>
<ChatInput name={channel.name}
type="channel"
apiUrl="/api/socket/messages"
query={{
channelId: channel.id,
serverId: channel.serverId,
}}
/>
</>
)}
{channel.type === ChannelType.AUDIO && (
<MediaRoom
chatId={channel.id}
video={false}
audio={true}
/>
)}
{channel.type === ChannelType.VIDEO && (
<MediaRoom
chatId={channel.id}
video={true}
audio={true}
/>
)}
</div>
)
}
export default ChannelIdPage

View File

@ -0,0 +1,99 @@
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";
import { getOrCreateConversation } from "@/lib/conversation";
import { currentProfile } from "@/lib/current-profile";
import { db } from "@/lib/db";
import { redirectToSignIn } from "@clerk/nextjs";
import { redirect } from "next/navigation";
interface MemberIdPageProps{
params: {
serverId: string;
memberId: 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 (
<div className="bg-white dark:bg-[#313338] flex flex-col h-full">
<ChatHeader
imageUrl={otherMember.profile.imageUrl}
name={otherMember.profile.name}
serverId={params.serverId}
type={"conversation"}
/>
{searchParams.video && (
<MediaRoom
chatId={conversation.id}
video={true}
audio={true}
/>
)}
{!searchParams.video && (
<>
<ChatMessages
member={currentMember}
name={otherMember.profile.name}
chatId={conversation.id}
type="conversation"
apiUrl="/api/direct-messages"
paramKey="conversationId"
paramValue={conversation.id}
socketUrl="/api/socket/direct-messages"
socketQuery={{
conversationId: conversation.id,
}}
/>
<ChatInput
name={otherMember.profile.name}
type="conversation"
apiUrl="/api/socket/direct-messages"
query={{
conversationId: conversation.id,
}}
/>
</>
)}
</div>
)
}
export default MemberIdPage

View File

@ -0,0 +1,51 @@
import { redirectToSignIn } from "@clerk/nextjs";
import { redirect } from "next/navigation";
import { currentProfile } from "@/lib/current-profile";
import { db } from "@/lib/db";
import { ServerSidebar } from "@/components/server/server-sidebar";
const ServerIdLayout = async ({
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) {
return redirect("/");
}
return (
<div className="h-full">
<div
className="hidden md:flex h-full w-60 z-20 flex-col fixed inset-y-0">
<ServerSidebar serverId={params.serverId} />
</div>
<main className="h-full md:pl-60">
{children}
</main>
</div>
);
}
export default ServerIdLayout;

View File

@ -0,0 +1,50 @@
import { currentProfile } from "@/lib/current-profile";
import { db } from "@/lib/db";
import { redirectToSignIn } from "@clerk/nextjs";
import { redirect } from "next/navigation";
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 inistailChannel = server?.channels[0];
if (inistailChannel?.name !== "general") {
return null;
}
return redirect(`/servers/${params?.serverId}/channels/${inistailChannel?.id}`);
}
export default ServerIdPage;

20
app/(main)/layout.tsx Normal file
View File

@ -0,0 +1,20 @@
import { NavigationSidebar } from "@/components/navigation/navigation-sidebar";
const MainLayout = async ({
children
}: {
children: React.ReactNode;
}) => {
return (
<div className="h-full">
<div className="hidden md:flex h-full w-[72px] z-30 flex-col fixed inset-y-0">
<NavigationSidebar/>
</div>
<main className="md:pl-[72px] h-full">
{children}
</main>
</div>
);
}
export default MainLayout;

27
app/(setup)/page.tsx Normal file
View File

@ -0,0 +1,27 @@
import { redirect } from "next/navigation";
import { db } from "@/lib/db";
import { initialProfile } from "@/lib/initial-profile";
import { InitialModal } from "@/components/modals/initial-modal";
const SetupPage = async () => {
const profile = await initialProfile();
const server = await db.server.findFirst({
where: {
members: {
some: {
profileId: profile.id,
},
},
}
});
if (server) {
return redirect(`/servers/${server.id}`);
}
return <InitialModal/>;
}
export default SetupPage;

View File

@ -0,0 +1,123 @@
import { currentProfile } from "@/lib/current-profile";
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
import { MemberRole } from "@prisma/client";
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 PATCH(
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("Channel 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,
name: {
not: "general"
}
},
data: {
name,
type,
}
}
}
},
});
return NextResponse.json(server);
}
catch (error) {
console.log("[CHANNEL_ID_PATCH]", error);
return new NextResponse("Internal Error", { status: 500 });
}
}

65
app/api/channels/route.ts Normal file
View File

@ -0,0 +1,65 @@
import { NextResponse } from "next/server";
import { MemberRole } from "@prisma/client";
import { currentProfile } from "@/lib/current-profile";
import { db } from "@/lib/db";
export async function POST(req: Request)
{
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 (name === "general")
{
return new NextResponse("Channel 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: {
create: [
{
profileId: profile.id,
name,
type,
}
]
}
},
});
return NextResponse.json(server);
}
catch (error)
{
console.log("[CHANNEL_POST]", error);
return new NextResponse("Internal Error", { status: 500 });
}
}

View File

@ -0,0 +1,83 @@
import { NextResponse } from "next/server";
import { DirectMessage } from "@prisma/client";
import { currentProfile } from "@/lib/current-profile";
import { db } from "@/lib/db";
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 });
}
}

35
app/api/livekit/route.ts Normal file
View File

@ -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: at.toJwt() });
}

View File

@ -0,0 +1,128 @@
import { currentProfile } from "@/lib/current-profile";
import { db } from "@/lib/db";
import { NextResponse } from "next/server";
export async function DELETE(req: Request, { params }: { params: { memberId: 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.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 (error)
{
console.log("[MEMBER_ID_DELETE]", error);
return new NextResponse("Internal Error", { status: 500});
}
}
export async function PATCH(req: Request, { params }: { params: { memberId: string }})
{
try
{
const profile = await currentProfile();
const { searchParams } = new URL(req.url);
const { role } = await req.json();
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.memberId)
{
return new NextResponse("Member ID Missing", { 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 (error)
{
console.log("[MEMBER_ID_PATCH]", error);
return new NextResponse("Internal Error", { status: 500});
}
}

83
app/api/messages/route.ts Normal file
View File

@ -0,0 +1,83 @@
import { NextResponse } from "next/server";
import { Message } from "@prisma/client";
import { currentProfile } from "@/lib/current-profile";
import { db } from "@/lib/db";
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 });
}
}

View File

@ -0,0 +1,38 @@
import { v4 as uuidv4 } from "uuid";
import { NextResponse } from "next/server";
import { currentProfile } from "@/lib/current-profile";
import { db } from "@/lib/db";
export async function PATCH(
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: profile.id,
},
data: {
inviteCode: uuidv4(),
}
});
return NextResponse.json(server)
} catch (error) {
console.log("[SERVER_ID]", error);
return new NextResponse("Internal Error", { status: 500});
}
}

View File

@ -0,0 +1,57 @@
import { currentProfile } from "@/lib/current-profile";
import { db } from "@/lib/db";
import { NextResponse } from "next/server";
export async function PATCH(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 (error)
{
console.log("[SERVER_ID_LEAVE]", error);
return new NextResponse("Internal Error", { status: 500});
}
}

View File

@ -0,0 +1,65 @@
import { currentProfile } from "@/lib/current-profile";
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
export async function DELETE(req: Request, { params }: { params: { serverId: string }})
{
try
{
const profile = await currentProfile();
if (!profile)
{
return new NextResponse("Unauthorized", { status: 401 });
}
const server = await db.server.delete
({
where:
{
id: params.serverId,
profileId: profile.id,
},
});
return NextResponse.json(server);
}
catch (error)
{
console.log("[SERVER_ID_DELETE]", error);
return new NextResponse("Internal Error", { status: 500});
}
}
export async function PATCH(req: Request, { params }: { params: { serverId: string }})
{
try
{
const profile = await currentProfile();
const { name, imageUrl } = await req.json();
if (!profile)
{
return new NextResponse("Unauthorized", { status: 401 });
}
const server = await db.server.update
({
where:
{
id: params.serverId,
profileId: profile.id,
},
data:
{
name,
imageUrl,
}
});
return NextResponse.json(server);
}
catch (error)
{
console.log("[SERVER_ID_PATCH]", error);
return new NextResponse("Internal Error", { status: 500});
}
}

42
app/api/servers/route.ts Normal file
View File

@ -0,0 +1,42 @@
import { v4 as uuidv4 } from "uuid";
import { NextResponse } from "next/server";
import { MemberRole } from "@prisma/client";
import { currentProfile } from "@/lib/current-profile";
import { db } from "@/lib/db";
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 }
]
}
}
})
return NextResponse.json({ server });
} catch (error) {
console.log("[SERVER POST]", error);
return new NextResponse("Internal Error", { status: 500 });
}
}

View File

@ -0,0 +1,21 @@
import { auth } from "@clerk/nextjs";
import { createUploadthing, type FileRouter } from "uploadthing/next";
const f = createUploadthing();
const handleAuth = () => {
const { userId } = auth();
if (!userId) throw new Error("Unauthorized");
return { userId: userId };
}
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;

View File

@ -0,0 +1,8 @@
import { createNextRouteHandler } from "uploadthing/next";
import { ourFileRouter } from "./core";
// Export routes for Next App Router
export const { GET, POST } = createNextRouteHandler({
router: ourFileRouter,
});

View File

@ -3,7 +3,7 @@
@tailwind utilities;
html,
boby,
body,
:root {
height: 100%;
}

View File

@ -1,8 +1,15 @@
import './globals.css'
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import { Open_Sans } from 'next/font/google'
import { ClerkProvider } from '@clerk/nextjs'
const inter = Inter({ subsets: ['latin'] })
import { cn } from '@/lib/utils'
import { ThemeProvider } from '@/components/providers/theme-provider'
import { ModalProvider } from '@/components/providers/modal-provider'
import { SocketProvider } from '@/components/providers/socket-provider'
import { QueryProvider } from '@/components/providers/query-provider'
const font = Open_Sans({ subsets: ['latin'] })
export const metadata: Metadata = {
title: 'Create Next App',
@ -15,8 +22,27 @@ export default function RootLayout({
children: React.ReactNode
}) {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
</html>
<ClerkProvider>
<html lang="en" suppressHydrationWarning>
<body className={cn (
font.className,
"bg-white dark:bg-[#313338]"
)}>
<ThemeProvider
attribute="class"
defaultTheme="dark"
enableSystem={false}
storageKey="discord-theme"
>
<SocketProvider>
<ModalProvider/>
<QueryProvider>
{children}
</QueryProvider>
</SocketProvider>
</ThemeProvider>
</body>
</html>
</ClerkProvider>
)
}

View File

@ -1,8 +0,0 @@
export default function Home() {
return (
<p className='text-3xl font-bold text-indigo-500'>
Hello Discord Clone
</p>
)
}

View File

@ -0,0 +1,37 @@
"user client"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
interface ActionTooltipProps {
label: string;
children: React.ReactNode;
side?: "top" | "right" | "bottom" | "left";
align?: "start" | "center" | "end";
}
export const ActionTooltip = ({
label,
children,
side,
align,
}: ActionTooltipProps) => {
return (
<TooltipProvider>
<Tooltip delayDuration={50}>
<TooltipTrigger asChild>
{children}
</TooltipTrigger>
<TooltipContent side={side} align={align}>
<p className="font-semibold text-sm capitalize">
{label.toLowerCase()}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}

View File

@ -0,0 +1,44 @@
import { Hash, Menu } from "lucide-react"
import { MobileToggle } from "@/components/mobile-toggle";
import { UserAvatar } from "@/components/user-avatar";
import { SocketIndicator } from "@/components/socket-indicator";
import { ChatVideoButton } from "./chat-video-button";
interface ChatHeaderProps {
serverId: string;
name: string;
type: "channel" | "conversation";
imageUrl?: string;
}
export const ChatHeader = ({
serverId,
name,
type,
imageUrl
}: ChatHeaderProps) => {
return (
<div className="text-md font-semibold px-3 flex items-center h-12 border-neutral-200 dark:border-neutral-800 border-b-2">
<MobileToggle serverId={serverId} />
{type === "channel" && (
<Hash className="w-5 h-5 text-zinc-500 dark:text-zinc-400 mr-2"/>
)}
{type === "conversation" && (
<UserAvatar
src={imageUrl}
className="w-8 h-8 md:h-8 md:w-8 mr-2"
/>
)}
<p className="font-semibold text-md text-black dark:text-white">
{name}
</p>
<div className="ml-auto flex items-center">
{type === "conversation" && (
<ChatVideoButton/>
)}
<SocketIndicator/>
</div>
</div>
)
}

View File

@ -0,0 +1,102 @@
"use client"
import { useForm } from "react-hook-form";
import * as z from "zod";
import axios from "axios";
import qs from "query-string";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Form,
FormControl,
FormField,
FormItem,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Plus } from "lucide-react";
import { useModal } from "@/hooks/use-modal-store";
import { EmojiPicker } from "@/components/emoji-picker";
import { useRouter } from "next/navigation";
interface ChatInputProps {
apiUrl: string;
query: Record<string, any>;
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<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
content: "",
}
});
const isLoading = form.formState.isSubmitting;
const onSubmit = async (values: z.infer<typeof formSchema>) => {
try{
const url = qs.stringifyUrl({
url: apiUrl,
query,
});
await axios.post(url, values);
form.reset();
router.refresh();
}catch(error){
console.log(error);
}
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="content"
render={({ field }) => (
<FormItem>
<FormControl>
<div className="relative p-4 pb-6">
<button
type="button"
onClick={() => onOpen("messageFile", { apiUrl, query})}
className="absolute top-7 left-8 h-[24px] w-[24px] bg-zinc-500 dark:bg-zinc-400 hover:bg-zinc-600 dark:hover:bg-zinc-300 transition rounded-full p-1 flex items-center justify-center"
>
<Plus className="text-white dark:text-[#313338]"/>
</button>
<Input
disabled={isLoading}
className="px-14 py-6 bg-zinc-200/90 dark:bg-zinc-700/75 border-none border-0 focus-visible:ring-0 focus-visible:ring-offset-0 text-zinc-600 dark:text-zinc-200"
placeholder={`Message ${type === "conversation" ? name : "#" + name}`}
{...field}
/>
<div className="absolute top-7 right-8">
<EmojiPicker
onChange={(emoji: string) => field.onChange(`${field.value} ${emoji}`)}
/>
</div>
</div>
</FormControl>
</FormItem>
)}
/>
</form>
</Form>
)
}

View File

@ -0,0 +1,252 @@
"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 { UserAvatar } from "@/components/user-avatar";
import { ActionTooltip } from "@/components/action-tooltip";
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";
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<string, string>;
}
const roleIconMap = {
"GUEST": null,
"MODERATOR": <ShieldCheck className="h-4 w-4 ml-2 text-indigo-500"/>,
"ADMIN": <ShieldAlert className="h-4 w-4 ml-2 text-rose-500"/>,
}
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: KeyboardEvent) => {
if (event.key === "Escape" || event.keyCode === 27) {
setIsEditing(false);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, []);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
content: content
}
});
const isLoading = form.formState.isSubmitting;
const onSubmit = async (values: z.infer<typeof formSchema>) => {
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]);
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 (
<div className="relative group flex items-center hover:bg-black/5 p-4 transition w-full">
<div className="group flex gap-x-2 items-start w-full">
<div onClick={onMemberClick} className="cursor-pointer hover:drop-shadow-md transition">
<UserAvatar src={member.profile.imageUrl}/>
</div>
<div className="flex flex-col w-full">
<div className="flex items-center gap-x-2">
<div className="flex items-center">
<p onClick={onMemberClick} className="font-semibold text-sm hover:underline cursor-pointer">
{member.profile.name}
</p>
<ActionTooltip label={member.role}>
{roleIconMap[member.role]}
</ActionTooltip>
</div>
<span className="text-xs text-zinc-500 dark:text-zinc-400">
{timestamp}
</span>
</div>
{isImage && (
<a
href={fileUrl}
target="_blank"
rel="noopener noreferrer"
className="relative aspect-square rounded-md mt-2 overflow-hidden border flex items-center bg-secondary h-48 w-48"
>
<Image
src={fileUrl}
alt={content}
fill
className="object-cover"
/>
</a>
)}
{isPDF && (
<div className="relative flex items-center p-2 mt-2 rounded-md bg-background/10">
<FileIcon className="h-10 w-10 fill-indigo-200 stroke-indigo-400 "/>
<a
href={fileUrl}
target="_blank"
rel="noopener noreferrer"
className="ml-2 text-sm text-indigo-500 dark:text-indigo-400 hover:underline"
>
PDF File
</a>
</div>
)}
{!fileUrl && !isEditing && (
<p className={cn(
"text-sm text-zinc-600 dark:text-zinc-300",
deleted && "italic text-zinc-500 dark:text-zinc-400 text-xs mt-1"
)}>
{content}
{isUpdated && (
<span className="text-[10px] ml-2 text-zinc-500 dark:text-zinc-400">
(edited)
</span>
)}
</p>
)}
{!fileUrl && isEditing && (
<Form {...form}>
<form
className="flex items-center w-full gap-x-2 pt-2"
onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="content"
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<div className="relative w-full">
<Input
disabled={isLoading}
className="p-2 bg-zinc-200/90 dark:bg-zinc-700/75 border-0 focus-visible:ring-0 focus-visible:ring-offset-0 text-zinc-600 dark:text-zinc-200"
placeholder="Edited message"
{...field}
/>
</div>
</FormControl>
</FormItem>
)}
/>
<Button disabled={isLoading} size="sm" variant="primary">
Save
</Button>
</form>
<span className="text-[10px] mt-1 text-zinc-400">
Press escape to cancel, enter to save
</span>
</Form>
)}
</div>
</div>
{canDeleteMessage && (
<div className="hidden group-hover:flex items-center gap-x-2 absolute p-1 -top-2 right-5 bg-white dark:bg-zinc-800 border rounded-sm">
{canEditMessage && (
<ActionTooltip label="Edit">
<Edit
onClick={() => setIsEditing(true)}
className="cursor-pointer ml-auto w-4 h-4 text-zinc-500 hover:text-zinc-600 dark:hove:text-zinc-300 transition"
/>
</ActionTooltip>
)}
<ActionTooltip label="Delete">
<Trash
onClick={() => onOpen("deleteMessage", {
apiUrl: `${socketUrl}/${id}`,
query: socketQuery,
})}
className="cursor-pointer ml-auto w-4 h-4 text-zinc-500 hover:text-zinc-600 dark:hove:text-zinc-300 transition"
/>
</ActionTooltip>
</div>
)}
</div>
)
}

View File

@ -0,0 +1,144 @@
"use client";
import { Fragment, useRef, ElementRef, use } from "react";
import { format } from "date-fns"
import { Member, Message, Profile } from "@prisma/client"
import { ChatWelcome } from "./chat-welcome";
import { useChatQuery } from "@/hooks/use-chat-query";
import { Loader2, ServerCrash } from "lucide-react";
import { ChatItem } from "./chat-item";
import { useChatSocket } from "@/hooks/use-chat-socket";
import { useChatScroll } from "@/hooks/use-chat-scroll";
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<string, string>;
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<ElementRef<"div">>(null);
const bottomRef = useRef<ElementRef<"div">>(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 === "loading") {
return (
<div className="flex flex-col flex-1 justify-center items-center">
<Loader2 className="h-7 w-7 text-zinc-500 animate-spin my-4"/>
<p className="text-xs text-zinc-500 dark:text-zinc-400">
Loading Messages...
</p>
</div>
)
}
if (status === "error") {
return (
<div className="flex flex-col flex-1 justify-center items-center">
<ServerCrash className="h-7 w-7 text-zinc-500 my-4"/>
<p className="text-xs text-zinc-500 dark:text-zinc-400">
Something went wrong!
</p>
</div>
)
}
return (
<div ref={chatRef} className="flex-1 flex flex-col py-4 overflow-y-auto">
{!hasNextPage && <div className="flex-1"/>}
{!hasNextPage &&(
<ChatWelcome
type={type}
name={name}
/>
)}
{hasNextPage && (
<div className="flex justify-center">
{isFetchingNextPage ? (
<Loader2 className="h-6 w-6 text-zinc-500 animate-spin my-4"/>
): (
<button
onClick={() => fetchNextPage()}
className="text-zinc-500 hover:text-zinc-600 dark:text-zinc-400 text-xs dark:hover:text-zinc-300 transition"
>
Load previous messages
</button>
)}
</div>
)}
<div className="flex flex-col-reverse mt-auto">
{data?.pages.map((group, i) => (
<Fragment key={i}>
{group.items.map((message: MessageWithMemberWithProfile) => (
<ChatItem
key={message.id}
id={message.id}
currentMember={member}
member={message.member}
content={message.content}
fileUrl={message.fileUrl}
deleted={message.deleted}
timestamp={format(new Date(message.createdAt), DATE_FORMAT)}
isUpdated={message.updatedAt !== message.createdAt}
socketUrl={socketUrl}
socketQuery={socketQuery}
/>
))}
</Fragment>
))}
</div>
<div ref={bottomRef}/>
</div>
)
}

View File

@ -0,0 +1,39 @@
"use client";
import qs from "query-string";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Video, VideoOff } from "lucide-react";
import { ActionTooltip } from "@/components/action-tooltip";
export const ChatVideoButton = () => {
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
const isVideo = searchParams?.get("video");
const onClick = () => {
const url = qs.stringifyUrl({
url: pathname || "",
query: {
video: isVideo ? undefined : true,
}
}, { skipNull: true});
router.push(url);
}
const Icon = isVideo ? VideoOff : Video;
const tooltiplabel = isVideo ? "End video call" : "Start video call";
return (
<ActionTooltip side="bottom" label={tooltiplabel}>
<button onClick={onClick} className="hover:opacity=75 transition mr-4">
<Icon className="h-6 w-6 text-zinc-500 dark:text-zinc-400"/>
</button>
</ActionTooltip>
)
}

View File

@ -0,0 +1,28 @@
import { Hash } from "lucide-react";
interface ChatWelcomeProps {
name: string;
type: "channel" | "conversation";
}
export const ChatWelcome = ({
name,
type,
}: ChatWelcomeProps) => {
return (
<div className="space-y-2 px-4 mb-4">
{type === "channel" && (
<div className="h-[75px] w-[75px] rounded-full bg-zinc-500 dark:bg-zinc-700 flex items-center justify-center">
<Hash className="h-12 w-12 text-white"/>
</div>
)}
<p className="text-xl md:text-3l font-bold">
{type === "channel" ? "Welcome to #" : ""}{name}
</p>
<p className="text-zinc-600 dark:text-zinc-400 text-sm">
{type === "channel" ? `This is the start of the #${name} channel.` : `This is the start of your conversation with ${name}`}
</p>
</div>
)
}

View File

@ -0,0 +1,42 @@
"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,
PopoverTrigger,
PopoverContent,
} from "@/components/ui/popover";
interface EmojiPickerProps {
onChange: (value: string) => void;
}
export const EmojiPicker = ({
onChange,
}: EmojiPickerProps) => {
const { resolvedTheme } = useTheme();
return (
<Popover>
<PopoverTrigger>
<Smile className="text-zinc-500 dark:text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300 transition"/>
</PopoverTrigger>
<PopoverContent
side="right"
sideOffset={40}
className="bg-transparent border-none shadow-none drop-shadow-none mb-16"
>
<Picker
theme={resolvedTheme}
data={data}
onEmojiSelect={(emoji: any) => onChange(emoji.native)}
/>
</PopoverContent>
</Popover>
)
}

View File

@ -0,0 +1,77 @@
"use client";
import { FileIcon, X } from "lucide-react";
import Image from "next/image";
import { UploadDropzone } from "@/lib/uploadthing";
import "@uploadthing/react/styles.css";
interface FileUploadProps {
onChange: (url?: string) => void;
value: string;
endpoint: "messageFile" | "serverImage";
}
export const FileUpload = ({
onChange,
value,
endpoint,
}: FileUploadProps) => {
const fileType = value?.split(".").pop();
if (value && fileType !== "pdf") {
return (
<div className="relative h-20 w-20">
<Image
fill
src={value}
alt="Upload"
className="rounded-full"
/>
<button
onClick={() => onChange("")}
className="bg-rose-500 text-white p-1 rounded-full absolute top-0 right-0 shadow-sm"
type="button"
>
<X className="h-4 w-4" />
</button>
</div>
)
}
if (value && fileType === "pdf") {
return (
<div className="relative flex items-center p-2 mt-2 rounded-md bg-background/10">
<FileIcon className="h-10 w-10 fill-indigo-200 stroke-indigo-400 "/>
<a
href={value}
target="_blank"
rel="noopener noreferrer"
className="ml-2 text-sm text-indigo-500 dark:text-indigo-400 hover:underline"
>
{value}
</a>
<button
onClick={() => onChange("")}
className="bg-rose-500 text-white p-1 rounded-full absolute -top-2 -right-2 shadow-sm"
type="button"
>
<X className="h-4 w-4" />
</button>
</div>
)
}
return (
<UploadDropzone
endpoint={endpoint}
onClientUploadComplete={(res) => {
onChange(res?.[0].url);
}}
onUploadError={(error: Error) => {
console.error(error);
}}
/>
)
}

67
components/media-room.tsx Normal file
View File

@ -0,0 +1,67 @@
"use client";
import { useEffect, useState } from "react";
import { LiveKitRoom, VideoConference} from "@livekit/components-react"
import "@livekit/components-styles"
import { Channel } from "@prisma/client";
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 (
<div className="flex flex-col flex-1 justify-center items-center">
<Loader2
className="h-7 w-7 text-zinc-500 animate-spin my-4"
/>
<p className="text-xs text-zinc-500 dark:text-zinc-400">
Loading...
</p>
</div>
)
}
return (
<LiveKitRoom
data-lk-theme="default"
serverUrl={process.env.NEXT_PUBLIC_LIVEKIT_URL}
token={token}
connect={true}
video={video}
audio={audio}
>
<VideoConference />
</LiveKitRoom>
)
}

View File

@ -0,0 +1,32 @@
import { Menu } from "lucide-react"
import {
Sheet,
SheetContent,
SheetTrigger
} from "@/components/ui/sheet";
import { Button } from "./ui/button";
import { NavigationSidebar } from "@/components/navigation/navigation-sidebar";
import { ServerSidebar } from "@/components/server/server-sidebar";
export const MobileToggle = ({
serverId
}: {
serverId: string;
}) => {
return (
<Sheet>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" className="md:hidden">
<Menu/>
</Button>
</SheetTrigger>
<SheetContent side="left" className="p-0 flex gap-0">
<div className="w-[72px]">
<NavigationSidebar/>
</div>
<ServerSidebar serverId={serverId}/>
</SheetContent>
</Sheet>
)
}

View File

@ -0,0 +1,176 @@
"use client";
import qs from "query-string";
import axios from "axios";
import * as z from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { ChannelType } from "@prisma/client";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import{
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { useParams, useRouter } from "next/navigation";
import { useModal } from "@/hooks/use-modal-store";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@/components/ui/select";
import { useEffect } from "react";
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 const CreateChannelModal = () => {
const { isOpen, onClose, type, data } = useModal();
const router = useRouter();
const params = useParams();
const isModalOpen = isOpen && type === "createChannel";
const { channelType } = data;
const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
type: channelType || ChannelType.TEXT,
}
});
useEffect(() => {
if (channelType) {
form.setValue("type", channelType);
}
else{
form.setValue("type", ChannelType.TEXT);
}
}, [channelType, form])
const isLoading = form.formState.isSubmitting;
const onSubmit = async (values: z.infer<typeof formSchema>) => {
try {
const url = qs.stringifyUrl({
url: "/api/channels",
query: {
serverId: params?.serverId
}
});
await axios.post(url, values);
form.reset();
router.refresh();
onClose();
} catch (error) {
console.error(error);
}
}
const handleClose = () => {
form.reset();
onClose();
}
return (
<Dialog open={isModalOpen} onOpenChange={handleClose}>
<DialogContent className="bg-white text-black p-0 overflow-hidden">
<DialogHeader className="pt-8 px-6">
<DialogTitle className="text-2xl text-center">
Create Channel
</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<div className="space-y-8 px-6">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel
className="uppercase text-xs font-bold text-zinc-500 dark:text-secondary/70"
>
Channel name
</FormLabel>
<FormControl>
<Input
disabled={isLoading}
className="bg-zinc-300/50 border-0
focus-visible:ring-0 text-black
focus-visible:ring-offset-0"
placeholder="Enter channel name"
{...field}
/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>Channel Type</FormLabel>
<Select
disabled={isLoading}
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger
className="bg-zinc-300/50 border-0 focus:ring-0 text-black ring-offset-0 focus:ring-offset-0 capitalize outline-none"
>
<SelectValue placeholder="Select a channel type"/>
</SelectTrigger>
</FormControl>
<SelectContent>
{Object.values(ChannelType).map((type) => (
<SelectItem key={type} value={type} className="capitalize">
{type.toLocaleLowerCase()}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<DialogFooter className="bg-gray-100 px-6 py-4">
<Button variant={"primary"} disabled={isLoading}>
Create
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,138 @@
"use client";
import axios from "axios";
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,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { FileUpload } from "@/components/file-upload";
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 const CreateServerModal = () => {
const { isOpen, onClose, type } = useModal();
const router = useRouter();
const isModalOpen = isOpen && type === "createServer";
const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
imageUrl: "",
}
});
const isLoading = form.formState.isSubmitting;
const onSubmit = async (values: z.infer<typeof formSchema>) => {
try {
await axios.post("/api/servers", values);
form.reset();
router.refresh();
onClose();
} catch (error) {
console.error(error);
}
}
const handleClose = () => {
form.reset();
onClose();
}
return (
<Dialog open={isModalOpen} onOpenChange={handleClose}>
<DialogContent className="bg-white text-black p-0 overflow-hidden">
<DialogHeader className="pt-8 px-6">
<DialogTitle className="text-2xl text-center">
Customize your server
</DialogTitle>
<DialogDescription className="text-center text-zinc-500">
Give your server a personality with a name and an image. You can always change it later.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<div className="space-y-8 px-6">
<div className="flex items-center justify-center text-center">
<FormField
control={form.control}
name="imageUrl"
render={({ field }) => (
<FormItem>
<FormControl>
<FileUpload
endpoint="serverImage"
value={field.value}
onChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel
className="uppercase text-xs font-bold text-zinc-500 dark:text-secondary/70"
>
Server name
</FormLabel>
<FormControl>
<Input
disabled={isLoading}
className="bg-zinc-300/50 border-0
focus-visible:ring-0 text-black
focus-visible:ring-offset-0"
placeholder="Enter server name"
{...field}
/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
</div>
<DialogFooter className="bg-gray-100 px-6 py-4">
<Button variant={"primary"} disabled={isLoading}>
Create
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,89 @@
"use client";
import qs from "query-string";
import axios from "axios";
import { useState } from "react";
import { useRouter } from "next/navigation";
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 DeleteChannelModal = () => {
const { isOpen, onClose, type, data } = useModal();
const router = useRouter();
const isModalOpen = isOpen && type === "deleteChannel";
const { server, channel } = data;
const [isLoading, setIsLoading] = useState(false);
const onClick = async () =>
{
try
{
setIsLoading(true);
const url = qs.stringifyUrl({
url: `/api/channels/${channel?.id}`,
query: {
serverId: server?.id
},
})
await axios.delete(url);
onClose();
router.refresh();
router.push(`/servers/${server?.id}`);
}
catch (error)
{
console.log(error);
}
finally
{
setIsLoading(false);
onClose();
}
}
return (
<Dialog open={isModalOpen} onOpenChange={onClose}>
<DialogContent className="bg-white text-black p-0 overflow-hidden">
<DialogHeader className="pt-8 px-6">
<DialogTitle className="text-2xl text-center font-bold">
Delete Channel
</DialogTitle>
<DialogDescription className="text-center text-zinc-500">
Are you sure you want to do this? <br/>
<span className="font-semibold text-indigo-500" >
#{channel?.name}
</span> will be permenantly deleted.
</DialogDescription>
</DialogHeader>
<DialogFooter className="bg-gray-100 px-6 py-4">
<div className="flex items-center justify-between w-full">
<Button disabled={isLoading} onClick={onClose} variant="ghost">
Cancel
</Button>
<Button disabled={isLoading} onClick={onClick} variant="primary">
Confirm
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,79 @@
"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 DeleteMessageModal = () => {
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);
onClose();
}
}
return (
<Dialog open={isModalOpen} onOpenChange={onClose}>
<DialogContent className="bg-white text-black p-0 overflow-hidden">
<DialogHeader className="pt-8 px-6">
<DialogTitle className="text-2xl text-center font-bold">
Delete Message
</DialogTitle>
<DialogDescription className="text-center text-zinc-500">
Are you sure you want to do this? <br/>
The message will be permenantly deleted.
</DialogDescription>
</DialogHeader>
<DialogFooter className="bg-gray-100 px-6 py-4">
<div className="flex items-center justify-between w-full">
<Button disabled={isLoading} onClick={onClose} variant="ghost">
Cancel
</Button>
<Button disabled={isLoading} onClick={onClick} variant="primary">
Confirm
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,81 @@
"use client";
import axios from "axios";
import { useState } from "react";
import { useRouter } from "next/navigation";
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 DeleteServerModal = () => {
const { isOpen, onClose, type, data } = useModal();
const router = useRouter();
const isModalOpen = isOpen && type === "deleteServer";
const { server } = data;
const [isLoading, setIsLoading] = useState(false);
const onClick = async () =>
{
try
{
setIsLoading(true);
await axios.delete(`/api/servers/${server?.id}`);
onClose();
router.refresh();
router.push("/");
}
catch (error)
{
console.log(error);
}
finally
{
setIsLoading(false);
onClose();
}
}
return (
<Dialog open={isModalOpen} onOpenChange={onClose}>
<DialogContent className="bg-white text-black p-0 overflow-hidden">
<DialogHeader className="pt-8 px-6">
<DialogTitle className="text-2xl text-center font-bold">
Delete Server
</DialogTitle>
<DialogDescription className="text-center text-zinc-500">
Are you sure you want to do this? <br/>
<span className="font-semibold text-indigo-500" >
{server?.name}
</span> will be permenantly deleted.
</DialogDescription>
</DialogHeader>
<DialogFooter className="bg-gray-100 px-6 py-4">
<div className="flex items-center justify-between w-full">
<Button disabled={isLoading} onClick={onClose} variant="ghost">
Cancel
</Button>
<Button disabled={isLoading} onClick={onClick} variant="primary">
Confirm
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,173 @@
"use client";
import qs from "query-string";
import axios from "axios";
import * as z from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { ChannelType } from "@prisma/client";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@/components/ui/select";
import { useEffect } from "react";
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 const EditChannelModal = () => {
const { isOpen, onClose, type, data } = useModal();
const router = useRouter();
const isModalOpen = isOpen && type === "editChannel";
const { channel, server } = data;
const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
type: channel?.type || ChannelType.TEXT,
}
});
useEffect(() => {
if (channel) {
form.setValue("name", channel.name),
form.setValue("type", channel.type)
}
}, [form, channel])
const isLoading = form.formState.isSubmitting;
const onSubmit = async (values: z.infer<typeof formSchema>) => {
try {
const url = qs.stringifyUrl({
url: `/api/channels/${channel?.id}`,
query: {
serverId: server?.id
}
});
await axios.patch(url, values);
form.reset();
router.refresh();
onClose();
} catch (error) {
console.error(error);
}
}
const handleClose = () => {
form.reset();
onClose();
}
return (
<Dialog open={isModalOpen} onOpenChange={handleClose}>
<DialogContent className="bg-white text-black p-0 overflow-hidden">
<DialogHeader className="pt-8 px-6">
<DialogTitle className="text-2xl text-center">
Edit Channel
</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<div className="space-y-8 px-6">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel
className="uppercase text-xs font-bold text-zinc-500 dark:text-secondary/70"
>
Channel name
</FormLabel>
<FormControl>
<Input
disabled={isLoading}
className="bg-zinc-300/50 border-0
focus-visible:ring-0 text-black
focus-visible:ring-offset-0"
placeholder="Enter channel name"
{...field}
/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>Channel Type</FormLabel>
<Select
disabled={isLoading}
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger
className="bg-zinc-300/50 border-0 focus:ring-0 text-black ring-offset-0 focus:ring-offset-0 capitalize outline-none"
>
<SelectValue placeholder="Select a channel type"/>
</SelectTrigger>
</FormControl>
<SelectContent>
{Object.values(ChannelType).map((type) => (
<SelectItem key={type} value={type} className="capitalize">
{type.toLocaleLowerCase()}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<DialogFooter className="bg-gray-100 px-6 py-4">
<Button variant={"primary"} disabled={isLoading}>
Save
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,149 @@
"use client";
import axios from "axios";
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,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { FileUpload } from "@/components/file-upload";
import { useRouter } from "next/navigation";
import { useModal } from "@/hooks/use-modal-store";
import { useEffect } from "react";
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 const EditServerModal = () => {
const { isOpen, onClose, type, data } = useModal();
const router = useRouter();
const isModalOpen = isOpen && type === "editServer";
const { server } = data;
const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
imageUrl: "",
}
});
useEffect(() => {
if (server) {
form.setValue("name", server.name),
form.setValue("imageUrl", server.imageUrl)
}
}, [server, form]);
const isLoading = form.formState.isSubmitting;
const onSubmit = async (values: z.infer<typeof formSchema>) => {
try {
await axios.patch(`/api/servers/${server?.id}`, values);
form.reset();
router.refresh();
onClose();
} catch (error) {
console.error(error);
}
}
const handleClose = () => {
form.reset();
onClose();
}
return (
<Dialog open={isModalOpen} onOpenChange={handleClose}>
<DialogContent className="bg-white text-black p-0 overflow-hidden">
<DialogHeader className="pt-8 px-6">
<DialogTitle className="text-2xl text-center">
Customize your server
</DialogTitle>
<DialogDescription className="text-center text-zinc-500">
Give your server a personality with a name and an image. You can always change it later.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<div className="space-y-8 px-6">
<div className="flex items-center justify-center text-center">
<FormField
control={form.control}
name="imageUrl"
render={({ field }) => (
<FormItem>
<FormControl>
<FileUpload
endpoint="serverImage"
value={field.value}
onChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel
className="uppercase text-xs font-bold text-zinc-500 dark:text-secondary/70"
>
Server name
</FormLabel>
<FormControl>
<Input
disabled={isLoading}
className="bg-zinc-300/50 border-0
focus-visible:ring-0 text-black
focus-visible:ring-offset-0"
placeholder="Enter server name"
{...field}
/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
</div>
<DialogFooter className="bg-gray-100 px-6 py-4">
<Button variant={"primary"} disabled={isLoading}>
Save
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,140 @@
"use client";
import axios from "axios";
import * as z from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { useEffect, useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import{
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { FileUpload } from "@/components/file-upload";
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 const InitialModal = () => {
const [isMounted, setIsMounted] = useState(false);
const router = useRouter();
useEffect(() => {
setIsMounted(true);
}, []);
const form = useForm({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
imageUrl: "",
}
});
const isLoading = form.formState.isSubmitting;
const onSubmit = async (values: z.infer<typeof formSchema>) => {
try {
await axios.post("/api/servers", values);
form.reset();
router.refresh();
window.location.reload();
} catch (error) {
console.error(error);
}
}
if (!isMounted) {
return null;
}
return (
<Dialog open>
<DialogContent className="bg-white text-black p-0 overflow-hidden">
<DialogHeader className="pt-8 px-6">
<DialogTitle className="text-2xl text-center">
Customize your server
</DialogTitle>
<DialogDescription className="text-center text-zinc-500">
Give your server a personality with a name and an image. You can always change it later.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<div className="space-y-8 px-6">
<div className="flex items-center justify-center text-center">
<FormField
control={form.control}
name="imageUrl"
render={({ field }) => (
<FormItem>
<FormControl>
<FileUpload
endpoint="serverImage"
value={field.value}
onChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel
className="uppercase text-xs font-bold text-zinc-500 dark:text-secondary/70"
>
Server name
</FormLabel>
<FormControl>
<Input
disabled={isLoading}
className="bg-zinc-300/50 border-0
focus-visible:ring-0 text-black
focus-visible:ring-offset-0"
placeholder="Enter server name"
{...field}
/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
</div>
<DialogFooter className="bg-gray-100 px-6 py-4">
<Button variant={"primary"} disabled={isLoading}>
Create
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,90 @@
"use client";
import axios from "axios";
import { useState } from "react";
import { Check, Copy, RefreshCw } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useModal } from "@/hooks/use-modal-store";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { useOrigin } from "@/hooks/user-origin";
export const InviteModal = () => {
const { onOpen, isOpen, onClose, type, data } = useModal();
const origin = useOrigin();
const isModalOpen = isOpen && type === "invite";
const { server } = data;
const [copied, setCopied] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const inviteUrl = `${origin}/invite/${server?.inviteCode}`;
const onCopy = () => {
navigator.clipboard.writeText(inviteUrl);
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 1000);
}
const onNew = async () => {
try {
setIsLoading(true);
const response = await axios.patch(`/api/servers/${server?.id}/invite-code`);
onOpen("invite", {server: response.data});
} catch (error) {
console.log(error);
} finally{
setIsLoading(false);
}
}
return (
<Dialog open={isModalOpen} onOpenChange={onClose}>
<DialogContent className="bg-white text-black p-0 overflow-hidden">
<DialogHeader className="pt-8 px-6">
<DialogTitle className="text-2xl text-center font-bold">
Invite Friends
</DialogTitle>
</DialogHeader>
<div className="p-6">
<Label className="uppercase text-xs font-bold text-zinc-500 dark:text-secondary/70">
Server invite link
</Label>
<div className="flex items-center mt-2 gap-x-2">
<Input className="bg-zinc-300/50 border-0 focus-visible:ring-0 text-black focus-visible:ring-offset-0 "
disabled={isLoading}
value={inviteUrl}
/>
<Button disabled={isLoading} onClick={onCopy} size="icon">
{copied ? <Check className="w-4 h-4"/> : <Copy className="w-4 h-4"/>}
</Button>
</div>
<Button
onClick={onNew}
disabled={isLoading}
variant="link"
size="sm"
className="text-xs text-zinc-500 mt-4"
>
Generate a new link
<RefreshCw className="w-4 h-4 ml-2"/>
</Button>
</div>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,81 @@
"use client";
import axios from "axios";
import { useState } from "react";
import { useRouter } from "next/navigation";
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 LeaveServerModal = () => {
const { isOpen, onClose, type, data } = useModal();
const router = useRouter();
const isModalOpen = isOpen && type === "leaveServer";
const { server } = data;
const [isLoading, setIsLoading] = useState(false);
const onClick = async () =>
{
try
{
setIsLoading(true);
await axios.patch(`/api/servers/${server?.id}/leave`);
onClose();
router.refresh();
router.push("/");
}
catch (error)
{
console.log(error);
}
finally
{
setIsLoading(false);
onClose();
}
}
return (
<Dialog open={isModalOpen} onOpenChange={onClose}>
<DialogContent className="bg-white text-black p-0 overflow-hidden">
<DialogHeader className="pt-8 px-6">
<DialogTitle className="text-2xl text-center font-bold">
Leave Server
</DialogTitle>
<DialogDescription className="text-center text-zinc-500">
Are you sure you wan to leave
<span className="font-semibold text-indigo-500" >
{server?.name}
</span>
</DialogDescription>
</DialogHeader>
<DialogFooter className="bg-gray-100 px-6 py-4">
<div className="flex items-center justify-between w-full">
<Button disabled={isLoading} onClick={onClose} variant="ghost">
Cancel
</Button>
<Button disabled={isLoading} onClick={onClick} variant="primary">
Confirm
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,164 @@
"use client";
import axios from "axios";
import qs from "query-string";
import { Check, Gavel, Loader2, MoreVertical, Shield, ShieldAlert, ShieldCheck, ShieldQuestion } from "lucide-react";
import { useState } from "react";
import { MemberRole } from "@prisma/client";
import { useRouter } from "next/navigation";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useModal } from "@/hooks/use-modal-store";
import { ServerWithMembersWithProfiles } from "@/types";
import { ScrollArea } from "@/components/ui/scroll-area";
import { UserAvatar } from "@/components/user-avatar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuTrigger,
DropdownMenuSubTrigger,
} from "@/components/ui/dropdown-menu";
const roleIconMap = {
"GUEST": null,
"MODERATOR": <ShieldCheck className="w-4 h-4 ml-2 text-indigo-500"/>,
"ADMIN": <ShieldAlert className="w-4 h-4 text-rose-500"/>
}
export const MembersModal = () => {
const router = useRouter();
const { onOpen, isOpen, onClose, type, data } = useModal();
const [loadingId, setloadingId] = useState("");
const isModalOpen = isOpen && type === "members";
const { server } = data as { server: ServerWithMembersWithProfiles };
const onKick = async (memberId: string) => {
try {
setloadingId(memberId);
const url = qs.stringifyUrl({
url: `/api/members/${memberId}`,
query: {
serverId: server?.id,
}
});
const repsponse = await axios.delete(url);
router.refresh();
onOpen("members", {server: repsponse.data});
} catch (error) {
console.log(error);
} finally {
setloadingId("");
}
}
const onRoleChange = async (memberId: string, role: MemberRole) => {
try {
setloadingId(memberId);
const url = qs.stringifyUrl({
url: `/api/members/${memberId}`,
query: {
serverId: server?.id,
}
});
const response = await axios.patch(url, { role });
router.refresh();
onOpen("members", {server: response.data});
} catch (error) {
console.log(error);
} finally {
setloadingId("");
}
}
return (
<Dialog open={isModalOpen} onOpenChange={onClose}>
<DialogContent className="bg-white dark:bg-[#313338] text-black dark:text-white overflow-hidden">
<DialogHeader className="pt-8 px-6">
<DialogTitle className="text-2xl text-center font-bold">
Manage Members
</DialogTitle>
<DialogDescription className="text-center text-zinc-500 dark:text-zinc-300">
{server?.members?.length} Members
</DialogDescription>
</DialogHeader>
<ScrollArea className="mt-8 max-h-[420px] pr-6">
{server?.members?.map((member) => (
<div key={member.id} className="flex items-center gap-x-2 mb-6">
<UserAvatar src={member.profile.imageUrl}/>
<div className="flex flex-col gap-y-1">
<div className="text-xs font-semibold flex items-center gap-x-1">
{member.profile.name}
{roleIconMap[member.role]}
</div>
<p className="text-xs text-zinc-500 dark:text-zinc-300">
{member.profile.email}
</p>
</div>
{server.profileId !== member.profileId && loadingId !== member.id && (
<div className="ml-auto">
<DropdownMenu>
<DropdownMenuTrigger>
<MoreVertical className="h-4 w-4 text-zinc-500 dark:text-zinc-300">
</MoreVertical>
</DropdownMenuTrigger>
<DropdownMenuContent side="left">
<DropdownMenuSub>
<DropdownMenuSubTrigger className="flex items-center">
<ShieldQuestion className="w-4 h-4 mr-2"/>
<span>Role</span>
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
<DropdownMenuItem onClick={() => onRoleChange(member.id, "GUEST")}>
<Shield className="w-4 h-4 mr-2"/>
Guest
{member.role === "GUEST" && (
<Check className="w-4 h-4 ml-auto"/>
)}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onRoleChange(member.id, "MODERATOR")}>
<ShieldCheck className="w-4 h-4 mr-2"/>
Moderator
{member.role === "MODERATOR" && (
<Check className="w-4 h-4 ml-auto"/>
)}
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
<DropdownMenuSeparator/>
<DropdownMenuItem onClick={() => onKick(member.id)}>
<Gavel className="h-4 w-4 mr-2"/>
Kick
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
{loadingId === member.id && (
<Loader2 className="animate-spin text-zinc-500 ml-auto w-4 h-4"/>
)}
</div>
))}
</ScrollArea>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,117 @@
"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 { FileUpload } from "@/components/file-upload";
import { useRouter } from "next/navigation";
import { useModal } from "@/hooks/use-modal-store";
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<typeof formSchema>) => {
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 (
<Dialog open={isModalOpen} onOpenChange={handleClose}>
<DialogContent className="bg-white text-black p-0 overflow-hidden">
<DialogHeader className="pt-8 px-6">
<DialogTitle className="text-2xl text-center font-bold">
Add an attachment
</DialogTitle>
<DialogDescription className="text-center text-zinc-500">
Send a file as a message
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<div className="space-y-8 px-6">
<div className="flex items-center justify-center text-center">
<FormField
control={form.control}
name="fileUrl"
render={({ field }) => (
<FormItem>
<FormControl>
<FileUpload
endpoint="messageFile"
value={field.value}
onChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</div>
</div>
<DialogFooter className="bg-gray-100 px-6 py-4">
<Button variant="primary" disabled={isLoading}>
Send
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,40 @@
"use client"
import * as React from "react"
import { Moon, Sun } 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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="bg-transparrent border-0" variant="outline" size="icon">
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@ -0,0 +1,33 @@
"use client"
import { Plus } from "lucide-react"
import { ActionTooltip } from "@/components/action-tooltip"
import { useModal } from "@/hooks/use-modal-store"
export const NavigationAction = () => {
const { onOpen } = useModal();
return (
<div>
<ActionTooltip
side="right"
align="center"
label="Add a server"
>
<button
onClick={() => onOpen("createServer")}
className="group flex items-center"
>
<div className="flex mx-3 h-[48px] w-[48px] rounded-[24px] group-hover:rounded-[16px] transition-all overflow-hidding items-center justify-center bg-background dark:bg-neutral-700 group-hover:bg-emerald-500">
<Plus
className="group-hover:text-white transition text-emerald-500"
size={25}
/>
</div>
</button>
</ActionTooltip>
</div>
)
}

View File

@ -0,0 +1,54 @@
"use client"
import Image from "next/image"
import { useParams, useRouter } from "next/navigation"
import { cn } from "@/lib/utils"
import { ActionTooltip } from "@/components/action-tooltip"
interface NavigationItemProps {
id: string;
imageUrl: string;
name: string;
};
export const NavigationItem = ({
id,
imageUrl,
name
}: NavigationItemProps) => {
const params = useParams();
const router = useRouter();
const onClick = () => {
router.push(`/servers/${id}`);
}
return (
<ActionTooltip
side="right"
align="center"
label={name}
>
<button
onClick={onClick}
className="group relative flex items-center"
>
<div className={cn(
"absolute left-0 bg-primary rounded-r-full transition-all w-[4px]",
params?.serverId === id && "group-hover:h-[20px]",
params?.serverId === id ? "h-[36px]" : "h-[8px]"
)}/>
<div className={cn(
"relative group flex mx-3 h-[48px] w-[48px] rounded-[24px] group-hover:rounded-[16px] transition-all overflow-hidden",
params?.serverId === id && "bg-primary/10 text-primary rounded-[16px]"
)}>
<Image
fill
src={imageUrl}
alt="Channel"
/>
</div>
</button>
</ActionTooltip>
)
}

View File

@ -0,0 +1,62 @@
import { redirect } from "next/navigation";
import { ModeToggle } from "@/components/mode-toggle";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { currentProfile } from "@/lib/current-profile";
import { db } from "@/lib/db";
import { NavigationAction } from "./navigation-action";
import { NavigationItem } from "./navigation-item";
import { UserButton } from "@clerk/nextjs";
export const NavigationSidebar = async () => {
const profile = await currentProfile();
if (!profile) {
return redirect("/");
}
const servers = await db.server.findMany({
where: {
members: {
some: {
profileId: profile.id,
},
},
},
})
return (
<div className="space-y-4 flex flex-col items-center h-full text-primary w-full bg-[#E3E5E8] dark:bg-[#1E1F22] py-3"
>
<NavigationAction/>
<Separator
className="h-[2px] bg-zinc-300 dark:bg-zinc-700 rounded-md w-10 mx-auto"
/>
<ScrollArea className="flex-1 w-full">
{servers.map((server) => (
<div key={server.id} className="mb-4">
<NavigationItem
id={server.id}
name={server.name}
imageUrl={server.imageUrl}
/>
</div>
))}
</ScrollArea>
<div className="pb-3 mt-auto flex items-center flex-col gap-y-4">
<ModeToggle/>
<UserButton
afterSignOutUrl="/"
appearance={{
elements: {
avatarBox: "h-[48px] w-[48px]"
}
}}
/>
</div>
</div>
)
}

View File

@ -0,0 +1,43 @@
"use client"
import { useEffect, useState } from "react";
import { EditServerModal } from "@/components/modals/edit-server-modal";
import { CreateServerModal } from "@/components/modals/create-server-modal";
import { InviteModal } from "@/components/modals/invite-modal";
import { MembersModal } from "@/components/modals/members-modal";
import { CreateChannelModal } from "@/components/modals/create-channel-modal";
import { LeaveServerModal } from "@/components/modals/leave-server-modal";
import { DeleteServerModal } from "@/components/modals/delete-server-modal";
import { DeleteChannelModal } from "@/components/modals/delete-channel-modal";
import { EditChannelModal } from "@/components/modals/edit-channel-modal";
import { MessageFileModal } from "@/components/modals/message-file-modal.tsx";
import { DeleteMessageModal } from "@/components/modals/delete-message-modal";
export const ModalProvider = () => {
const [isMounted, setIsMounted] = useState(false);
useEffect (() => {
setIsMounted(true);
}, []);
if (!isMounted) {
return null;
}
return (
<>
<CreateServerModal />
<InviteModal />
<EditServerModal />
<MembersModal/>
<CreateChannelModal/>
<LeaveServerModal/>
<DeleteServerModal/>
<DeleteChannelModal/>
<EditChannelModal/>
<MessageFileModal/>
<DeleteMessageModal/>
</>
)
}

View File

@ -0,0 +1,21 @@
"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 (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
}

View File

@ -0,0 +1,59 @@
"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<SocketContextType>({
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 (
<SocketContext.Provider value={{ socket, isConnected }}>
{children}
</SocketContext.Provider>
)
}

View File

@ -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 <NextThemesProvider {...props}>{children}</NextThemesProvider>
}

View File

@ -0,0 +1,79 @@
"use client"
import { cn } from "@/lib/utils";
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 "@/components/action-tooltip";
import { ModalType, useModal } from "@/hooks/use-modal-store";
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 (
<button
onClick={onClick}
className={cn(
"group px-2 py-2 rounded-md flex items-center gap-x-2 w-full hover:bg-zinc-700/10 dark:hover:bg-zinc-700/50 transition mb-1",
params?.channelId === channel.id && "bg-zinc-700/10 dark:bg-zinc-700"
)}
>
<Icon className="flex-shrink-0 w-5 h-5 text-zinc-500 dark:text-zinc-400"/>
<p className={cn(
"link-clamp-1 font-semibold text-sm text-zinc-500 group-hover:text-zinc-600 dark:text-zinc-400 dark:group-hover:text-zinc-300 transition",
params?.channelId === channel.id && "text-primary dark:text-zinc-200 dark:group-hover:text-white"
)}>
{channel.name}
</p>
{channel.name !== "general" && role !== MemberRole.GUEST && (
<div className="ml-auto flex items-center gap-x-2">
<ActionTooltip label="Edit">
<Edit
onClick={(e) => onAction(e, "editChannel")}
className="hidden group-hover:block w-4 h-4 text-zinc-500 hove:text-zinc-600 dark:text-zinc-400 dark:hove:text-zinc-300 transition"
/>
</ActionTooltip>
<ActionTooltip label="Delete">
<Trash
onClick={(e) => onAction(e, "deleteChannel")}
className="hidden group-hover:block w-4 h-4 text-zinc-500 hove:text-zinc-600 dark:text-zinc-400 dark:hove:text-zinc-300 transition"
/>
</ActionTooltip>
</div>
)}
{channel.name === "general" && (
<Lock className="ml-auto w-4 h-4 text-zinc-500 dark:text-zinc-400"/>
)}
</button>
)
}

View File

@ -0,0 +1,94 @@
"use client";
import { ServerWithMembersWithProfiles } from "@/types";
import { MemberRole } from "@prisma/client";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { ChevronDown, LogOut, Plus, PlusCircle, Settings, Trash, Users } from "lucide-react";
import { useModal } from "@/hooks/use-modal-store";
interface ServerHeaderProps {
server: ServerWithMembersWithProfiles;
role?: MemberRole;
}
export const ServerHeader = ({server, role}: ServerHeaderProps) => {
const { onOpen } = useModal();
const isAdmin = role === MemberRole.ADMIN;
const isModerator = isAdmin || role === MemberRole.MODERATOR;
return (
<DropdownMenu>
<DropdownMenuTrigger
className="focus:outline-none"
asChild
>
<button className="w-full text-md font-semibold px-3 flex items-center h-12 border-neutral-2 dark:border-neutral-800 border-b-2 hover:bg-zinc-700/10 dark:hover:bg-zinc-700/50 transition"
>
{server.name}
<ChevronDown className="w-5 h-5 ml-auto"/>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56 text-xs font-medium text-black dark:text-neutral-400 space-y-[2px]"
>
{isModerator && (
<DropdownMenuItem
onClick={() => onOpen("invite", {server})}
className="text-indigo-600 dark:text-indigo-400 px-3 py-2 text-sm cursor-pointer"
>
Invite People
<Plus className="w-4 h-4 ml-auto"/>
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => onOpen("editServer", {server})}
className=" px-3 py-2 text-sm cursor-pointer"
>
Server Settings
<Settings className="w-4 h-4 ml-auto"/>
</DropdownMenuItem>
)}
{isModerator && (
<DropdownMenuItem
onClick={() => onOpen("members", {server})}
className=" px-3 py-2 text-sm cursor-pointer"
>
Manage Members
<Users className="w-4 h-4 ml-auto"/>
</DropdownMenuItem>
)}
{isModerator && (
<DropdownMenuItem
onClick={() => onOpen("createChannel")}
className=" px-3 py-2 text-sm cursor-pointer"
>
Create Channel
<PlusCircle className="w-4 h-4 ml-auto"/>
</DropdownMenuItem>
)}
{isModerator && (
<DropdownMenuSeparator/>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() => onOpen("deleteServer", {server})}
className="text-rose-500 px-3 py-2 text-sm cursor-pointer"
>
Delete Server
<Trash className="w-4 h-4 ml-auto"/>
</DropdownMenuItem>
)}
{!isAdmin && (
<DropdownMenuItem
onClick={() => onOpen("leaveServer", {server})}
className="text-rose-500 px-3 py-2 text-sm cursor-pointer"
>
Leave Server
<LogOut className="w-4 h-4 ml-auto"/>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@ -0,0 +1,58 @@
"use client"
import { cn } from "@/lib/utils";
import { Member, MemberRole, Profile, Server } from "@prisma/client"
import { ShieldAlert, ShieldCheck } from "lucide-react";
import { useParams } from "next/navigation";
import { useRouter } from "next/navigation";
import { UserAvatar } from "@/components/user-avatar";
interface ServerMemberProps {
member: Member & {
profile: Profile;
};
server: Server;
}
const roleIconMap = {
[MemberRole.GUEST]: null,
[MemberRole.MODERATOR]: <ShieldCheck className="h-4 w-4 ml-2 text-indigo-500" />,
[MemberRole.ADMIN]: <ShieldAlert className="w-4 h-4 ml-2 text-rose-500" />
}
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 (
<button
onClick={onClick}
className={cn(
"group px-2 py-2 rounded-md flex items-center gap-x-2 w-full hover:bg-zinc-700/10 dark:hover:bg-zinc-700/50 transition mb-1",
params?.memberId === member.id && "bg-zinc-700/20 dark:bg-zinc-700"
)}>
<UserAvatar src={member.profile.imageUrl}
className="h-8 w-8 md:h-8 md:w-8"
/>
<p
className={cn(
"font-semibold text-sm text-zinc-500 group-hover:text-zinc-600 dark:text-zinc-400 dark:group-hover:text-zinc-300 transition",
params?.memberId === member.id &&
"dark:text-zinc-200 dark:group-hove:text-white"
)}
>
{member.profile.name}
</p>
{icon}
</button>
)
}

View File

@ -0,0 +1,107 @@
"use client"
import { Search } from "lucide-react";
import React, { useEffect, useState } from "react";
import {
CommandDialog,
CommandInput,
CommandItem,
CommandEmpty,
CommandGroup,
CommandList
} from "@/components/ui/command";
import { useParams, useRouter } from "next/navigation";
interface ServerSearchProps {
data: {
label: string;
type: "channel" | "member";
data: {
icon: React.ReactNode;
name: string;
id: string;
}[] | undefined
}[];
}
export const 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 (
<>
<button
onClick={() => setOpen(true)}
className="group px-2 py-2 rounded-md flex items-center gap-x-2 w-full hover:bg-zinc-700/10 dark:hover:bg-zinc-700/50 transition">
<Search className="w-4 h-4 text-zinc-500 dark:text-zinc-400"/>
<p className="font-semiboldd text-sm text-zinc-500 dark:text-zinc-400 group-hover:text-zinc-600 dark:group-hover:text-zinc-300 transition">
Search
</p>
<kbd
className="pointer-events-none inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-forground ml-auto"
>
<span className="text-xs">CMD</span>K
</kbd>
</button>
<CommandDialog open={open} onOpenChange={setOpen}>
<CommandInput placeholder="Search all channels" />
<CommandList>
<CommandEmpty>
No Results Found
</CommandEmpty>
{data.map(({ label, type, data }) => {
if (!data?.length) return null;
return (
<CommandGroup key={label} heading={label}>
{data?.map(({ id, icon, name }) => {
return (
<CommandItem
onSelect={() => onClick({ id, type})}
key={id}>
{icon}
<span>{name}</span>
</CommandItem>
)
})}
</CommandGroup>
)
})}
</CommandList>
</CommandDialog>
</>
)
}

View File

@ -0,0 +1,47 @@
"use client"
import { ServerWithMembersWithProfiles } from "@/types";
import { ChannelType, MemberRole } from "@prisma/client";
import { ActionTooltip } from "../action-tooltip";
import { Plus, Settings } from "lucide-react";
import { useModal } from "@/hooks/use-modal-store";
interface ServerSectionProps {
label: string;
role?: MemberRole;
sectionType: "channels" | "members";
channelType?: ChannelType;
server?: ServerWithMembersWithProfiles;
}
export const ServerSection = ({
label,
role,
sectionType,
channelType,
server
}: ServerSectionProps)=> {
const {onOpen } = useModal();
return (
<div className="flex items-center justify-between py-2">
<p className="text-xs uppercase font-semibold text-zinc-500 dark:text-zinc-400">
{label}
</p>
{role !== MemberRole.GUEST && sectionType === "channels" && (
<ActionTooltip label="Create Channel" side="top">
<button onClick={() => onOpen("createChannel", {channelType})} className="text-zinc-500 hove:text-zinc-600 dark:text-zinc-400 dark:hover:text-zinc-300 transition">
<Plus className="h-4 w-4"/>
</button>
</ActionTooltip>
)}
{role === MemberRole.ADMIN && sectionType === "members" && (
<ActionTooltip label="Manage Members" side="top">
<button onClick={() => onOpen("members", {server})} className="text-zinc-500 hove:text-zinc-600 dark:text-zinc-400 dark:hover:text-zinc-300 transition">
<Settings className="h-4 w-4"/>
</button>
</ActionTooltip>
)}
</div>
)
}

View File

@ -0,0 +1,208 @@
import { ChannelType, MemberRole } from "@prisma/client";
import { redirect } from "next/navigation";
import { Hash, Mic, ShieldAlert, ShieldCheck, Video } from "lucide-react";
import { currentProfile } from "@/lib/current-profile";
import { db } from "@/lib/db";
import { Separator } from "@/components/ui/separator";
import { ScrollArea } from "@/components/ui/scroll-area";
import { ServerHeader } from "./server-header";
import { ServerSearch } from "./server-search";
import { ServerSection } from "./server-section";
import { ServerChannel } from "./server-channel";
import { ServerMember } from "./server-member";
interface ServerSidebarProps {
serverId: string;
}
const iconMap = {
[ChannelType.TEXT]: <Hash className="mr-2 h-4 w-4" />,
[ChannelType.AUDIO]: <Mic className="mr-2 h-4 w-4" />,
[ChannelType.VIDEO]: <Video className="mr-2 h-4 w-4" />
}
const roleIconMap = {
[MemberRole.GUEST]: null,
[MemberRole.MODERATOR]: <ShieldCheck className="h-4 w-4 mr-2 text-indigo-500" />,
[MemberRole.ADMIN]: <ShieldAlert className="w-4 h-4 mr-2 text-rose-500" />
}
export const ServerSidebar = async ({
serverId
}: ServerSidebarProps) => {
const profile = await currentProfile();
if (!profile) {
return redirect("/");
}
const server = await db.server.findUnique({
where: {
id: serverId,
},
include: {
channels: {
orderBy: {
createdAt: "asc",
},
},
members: {
include: {
profile: true,
},
orderBy: {
role: "asc",
}
},
}
});
const textChannels = server?.channels.filter((channel) => channel.type === ChannelType.TEXT)
const audioChannels = server?.channels.filter((channel) => channel.type === ChannelType.AUDIO)
const videoChannels = server?.channels.filter((channel) => channel.type === ChannelType.VIDEO)
const members = server?.members.filter((member) => member.profileId !== profile.id)
if (!server) {
return redirect("/");
}
const role = server.members.find((member) => member.profileId === profile.id)?.role;
return (
<div className="flex flex-col h-full text-primary w-full dark:bg-[#2B2D31] bg-[#F2F3F5]">
<ServerHeader
server={server}
role={role}
/>
<ScrollArea className="flex-1 px-3">
<div className="mt-2">
<ServerSearch
data={[
{
label: "Text Channels",
type: "channel",
data: textChannels?.map((channel) => ({
id: channel.id,
name: channel.name,
icon: iconMap[channel.type],
}))
},
{
label: "Voice Channels",
type: "channel",
data: audioChannels?.map((channel) => ({
id: channel.id,
name: channel.name,
icon: iconMap[channel.type],
}))
},
{
label: "Video Channels",
type: "channel",
data: videoChannels?.map((channel) => ({
id: channel.id,
name: channel.name,
icon: iconMap[channel.type],
}))
},
{
label: "Members",
type: "member",
data: members?.map((member) => ({
id: member.id,
name: member.profile.name,
icon: roleIconMap[member.role],
}))
},
]}
/>
</div>
<Separator className="bg-zinc-200 dark:bg-zinc-700 rounded-md my-2"/>
{!!textChannels?.length && (
<div className="mb-2">
<ServerSection
sectionType="channels"
channelType={ChannelType.TEXT}
role={role}
label="Text Channels"
/>
<div className="space-y-[2px]">
{textChannels?.map((channel) => (
<ServerChannel
key={channel.id}
channel={channel}
role={role}
server={server}
/>
))}
</div>
</div>
)}
{!!audioChannels?.length && (
<div className="mb-2">
<ServerSection
sectionType="channels"
channelType={ChannelType.AUDIO}
role={role}
label="Voice Channels"
/>
<div className="space-y-[2px]">
{audioChannels?.map((channel) => (
<ServerChannel
key={channel.id}
channel={channel}
role={role}
server={server}
/>
))}
</div>
</div>
)}
{!!videoChannels?.length && (
<div className="mb-2">
<ServerSection
sectionType="channels"
channelType={ChannelType.VIDEO}
role={role}
label="Video Channels"
/>
<div className="space-y-[2px]">
{videoChannels?.map((channel) => (
<ServerChannel
key={channel.id}
channel={channel}
role={role}
server={server}
/>
))}
</div>
</div>
)}
{!!members?.length && (
<div className="mb-2">
<ServerSection
sectionType="members"
channelType={ChannelType.VIDEO}
role={role}
label="Members"
server={server}
/>
<div className="space-y-[2px]">
{members?.map((member) => (
<ServerMember
key={member.id}
member={member}
server={server}
/>
))}
</div>
</div>
)}
</ScrollArea>
</div>
)
}

View File

@ -0,0 +1,28 @@
"use client";
import { useSocket } from "@/components/providers/socket-provider";
import { Badge } from "@/components/ui/badge";
export const SocketIndicator = () => {
const { isConnected } = useSocket();
if (!isConnected) {
return (
<Badge
variant="outline"
className="bg-yellow-600 text-white border-none"
>
Fallback: Polling every 1s
</Badge>
)
}
return (
<Badge
variant="outline"
className="bg-emerald-600 text-white border-none"
>
Live: Real-time Updates
</Badge>
)
}

50
components/ui/avatar.tsx Normal file
View File

@ -0,0 +1,50 @@
"use client"
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }

36
components/ui/badge.tsx Normal file
View File

@ -0,0 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

57
components/ui/button.tsx Normal file
View File

@ -0,0 +1,57 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center 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",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
primary: "bg-indigo-500 text-white hover:bg-indigo-500/90"
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

155
components/ui/command.tsx Normal file
View File

@ -0,0 +1,155 @@
"use client"
import * as React from "react"
import { DialogProps } from "@radix-ui/react-dialog"
import { Command as CommandPrimitive } from "cmdk"
import { Search } from "lucide-react"
import { cn } from "@/lib/utils"
import { Dialog, DialogContent } from "@/components/ui/dialog"
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
interface CommandDialogProps extends DialogProps {}
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
CommandShortcut.displayName = "CommandShortcut"
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

119
components/ui/dialog.tsx Normal file
View File

@ -0,0 +1,119 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg md:w-full",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@ -0,0 +1,200 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

176
components/ui/form.tsx Normal file
View File

@ -0,0 +1,176 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
ControllerProps,
FieldPath,
FieldValues,
FormProvider,
useFormContext,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState, formState } = useFormContext()
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
)
})
FormItem.displayName = "FormItem"
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField()
return (
<Label
ref={ref}
className={cn(error && "text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
})
FormLabel.displayName = "FormLabel"
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
})
FormControl.displayName = "FormControl"
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField()
return (
<p
ref={ref}
id={formDescriptionId}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
})
FormDescription.displayName = "FormDescription"
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message) : children
if (!body) {
return null
}
return (
<p
ref={ref}
id={formMessageId}
className={cn("text-sm font-medium text-destructive", className)}
{...props}
>
{body}
</p>
)
})
FormMessage.displayName = "FormMessage"
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

25
components/ui/input.tsx Normal file
View File

@ -0,0 +1,25 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

26
components/ui/label.tsx Normal file
View File

@ -0,0 +1,26 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

31
components/ui/popover.tsx Normal file
View File

@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent }

View File

@ -0,0 +1,53 @@
"use client"
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
className={cn(
"relative rounded-full bg-border",
orientation === "vertical" && "flex-1"
)}
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

121
components/ui/select.tsx Normal file
View File

@ -0,0 +1,121 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
}

View File

@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

140
components/ui/sheet.tsx Normal file
View File

@ -0,0 +1,140 @@
"use client"
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Sheet = SheetPrimitive.Root
const SheetTrigger = SheetPrimitive.Trigger
const SheetClose = SheetPrimitive.Close
const SheetPortal = SheetPrimitive.Portal
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
}
)
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
{children}
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
SheetHeader.displayName = "SheetHeader"
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
SheetFooter.displayName = "SheetFooter"
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

30
components/ui/tooltip.tsx Normal file
View File

@ -0,0 +1,30 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@ -0,0 +1,18 @@
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 (
<Avatar className={cn("h-7 w-7 md:h-10 md:w-10", className)}>
<AvatarImage src={src} />
</Avatar>
)
}

54
hooks/use-chat-query.ts Normal file
View File

@ -0,0 +1,54 @@
import qs from "query-string";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useSocket } from "@/components/providers/socket-provider";
interface ChatQueryProps {
queryKey: string;
apiUrl: string;
paramKey: "channelId" | "conversationId";
paramValue: string;
};
export const useChatQuery = ({
queryKey,
apiUrl,
paramKey,
paramValue
}: ChatQueryProps) => {
const { isConnected } = useSocket();
const fetchMessages = async ({ pageParam = undefined }) => {
const url = qs.stringifyUrl({
url: apiUrl,
query: {
cursor: pageParam,
[paramKey]: paramValue,
}
}, { skipNull: true });
const res = await fetch(url);
return res.json();
};
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
status,
} = useInfiniteQuery({
queryKey: [queryKey],
queryFn: fetchMessages,
getNextPageParam: (lastPage) => lastPage?.nextCursor,
refetchInterval: isConnected ? false : 1000,
});
return {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
status,
};
}

60
hooks/use-chat-scroll.ts Normal file
View File

@ -0,0 +1,60 @@
import { useEffect, useState } from "react";
type ChatScrollProps = {
chatRef: React.RefObject<HTMLDivElement>;
bottomRef: React.RefObject<HTMLDivElement>;
shouldLoadMore: boolean;
loadMore: () => void;
count: number;
}
export const useChatScroll = ({
chatRef,
bottomRef,
shouldLoadMore,
loadMore,
count,
}: ChatScrollProps) => {
const [hasInitialized, setHasInitialized] = useState(false);
useEffect(() => {
const topDiv = chatRef?.current;
const hadleScroll = () => {
const scrollTop = topDiv?.scrollTop;
if (scrollTop === 0 && shouldLoadMore) {
loadMore();
}
};
topDiv?.addEventListener("scroll", hadleScroll);
return () => {
topDiv?.removeEventListener("scroll", hadleScroll);
}
}, [shouldLoadMore, loadMore, chatRef]);
useEffect(() => {
const bottomDiv = bottomRef?.current;
const topDiv = chatRef?.current;
const shouldAutoScroll= () => {
if (!hasInitialized && bottomDiv){
setHasInitialized(true);
return true;
}
if (!topDiv) {
return false;
}
const distanceFromBottom = topDiv.scrollHeight - topDiv.clientHeight;
return distanceFromBottom <= 100;
}
if (shouldAutoScroll()) {
setTimeout(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, 100);
}
}, [bottomRef, chatRef, hasInitialized, count]);
}

80
hooks/use-chat-socket.ts Normal file
View File

@ -0,0 +1,80 @@
import { useSocket } from "@/components/providers/socket-provider";
import { Member, Message, Profile } from "@prisma/client";
import { useQueryClient } from "@tanstack/react-query";
import { useEffect } from "react";
type ChatSockerProps = {
addKey: string;
updateKey: string;
queryKey: string;
}
type MessageWithMemberWithProfile = Message & {
member: Member & {
profile: Profile;
}
}
export const useChatSocket = ({ addKey, updateKey, queryKey }: ChatSockerProps) => {
const { socket } = useSocket();
const queryClient = useQueryClient();
useEffect(() => {
if (!socket) return;
socket.on(addKey, (message: MessageWithMemberWithProfile) => {
queryClient.setQueryData([queryKey], ( oldData: any) => {
if (!oldData || !oldData.pages || oldData.pages.length === 0) {
return oldData;
}
const newData = oldData.pages.map((page: any) => {
return {
...page,
items: page.items.map((item: MessageWithMemberWithProfile ) => {
if (item.id === message.id) {
return message;
}
return item;
})
}
});
return {
...oldData,
pages: newData,
}
})
});
socket.on(addKey, (message: MessageWithMemberWithProfile) => {
queryClient.setQueryData([queryKey], (oldData: any) => {
if (!oldData || !oldData.pages || oldData.pages.length === 0) {
return {
pages: [{
items: [message],
}]
}
}
const newData = [...oldData.pages];
newData[0] = {
...newData[0],
items: [message, ...newData[0].items],
};
return {
...oldData,
pages: newData,
};
});
});
return () => {
socket.off(addKey);
socket.off(updateKey);
}
}, [queryClient, socket, addKey, updateKey, queryKey]);
}

29
hooks/use-modal-store.ts Normal file
View File

@ -0,0 +1,29 @@
import { Channel, ChannelType, Server } from "@prisma/client";
import { create } from "zustand";
export type ModalType = "createServer" | "invite" | "editServer" | "members" | "createChannel" | "leaveServer" | "deleteServer" | "deleteChannel" | "editChannel" | "messageFile" | "deleteMessage";
interface ModalData {
server?: Server;
channel?: Channel
channelType?: ChannelType;
apiUrl?: string;
query?: Record<string, any>;
}
interface ModalStore {
type: ModalType | null;
data: ModalData;
isOpen: boolean;
onOpen: (type: ModalType, data?:ModalData) => void;
onClose: () => void;
}
export const useModal = create<ModalStore>((set) => ({
type: null,
data: {},
isOpen: false,
onOpen: (type, data= {}) => set({ isOpen: true, type, data }),
onClose: () => set({ type: null, isOpen: false }),
}));

17
hooks/use-origin.ts Normal file
View File

@ -0,0 +1,17 @@
import { useEffect, useState } from "react"
export const useOrigin = () => {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const origin = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
if (!mounted) {
return "";
}
return origin;
}

72
lib/conversation.ts Normal file
View File

@ -0,0 +1,72 @@
import { db } from "@/lib/db";
export const getOrCreateConversation = async (memberOneId: string, memberTwoId: string) => {
let conversation = await findConversation(memberOneId, memberTwoId) || await findConversation(memberTwoId, memberOneId);
if (!conversation)
{
conversation = await createNewConversation(memberOneId, memberTwoId);
}
return conversation;
}
const findConversation = async (memberOneId: string, memberTwoId: string) => {
try
{
return await db.conversation.findFirst({
where: {
AND: [
{ memberOneId: memberOneId},
{ memberTwoId: memberTwoId},
],
},
include: {
memberOne: {
include: {
profile: true,
}
},
memberTwo: {
include: {
profile: true,
}
}
}
});
}
catch (error)
{
return null;
}
}
const createNewConversation = async (memberOneId: string, memberTwoId: string) => {
try
{
return await db.conversation.create({
data:{
memberOneId,
memberTwoId,
},
include: {
memberOne: {
include: {
profile: true,
}
},
memberTwo: {
include: {
profile: true,
}
}
},
});
}
catch (error)
{
return null;
}
}

View File

@ -0,0 +1,20 @@
import { getAuth } from "@clerk/nextjs/server";
import { db } from "@/lib/db";
import { NextApiRequest } from "next";
export const currentProfilePages = async (req: NextApiRequest) => {
const { userId } = getAuth(req);
if (!userId) {
return null;
}
const profile = await db.profile.findUnique({
where: {
userId
},
});
return profile;
}

19
lib/current-profile.ts Normal file
View File

@ -0,0 +1,19 @@
import { auth } from "@clerk/nextjs";
import { db } from "@/lib/db";
export const currentProfile = async () => {
const { userId } = auth();
if (!userId) {
return null;
}
const profile = await db.profile.findUnique({
where: {
userId
},
});
return profile;
}

9
lib/db.ts Normal file
View File

@ -0,0 +1,9 @@
import { PrismaClient } from '@prisma/client'
declare global {
var prisma: PrismaClient | undefined;
};
export const db = globalThis.prisma || new PrismaClient()
if (process.env.NODE_ENV !== 'production') globalThis.prisma = db

32
lib/initial-profile.ts Normal file
View File

@ -0,0 +1,32 @@
import { currentUser, redirectToSignIn } from "@clerk/nextjs";
import { db } from "@/lib/db";
export const initialProfile = async () => {
const user = await currentUser();
if (!user) {
return redirectToSignIn();
}
const profile = await db.profile.findUnique({
where: {
userId: user.id,
},
});
if (profile) {
return profile;
}
const newProfile = await db.profile.create({
data: {
userId: user.id,
name: `${user.firstName} ${user.lastName}`,
imageUrl : user.imageUrl,
email: user.emailAddresses[0].emailAddress,
},
});
return newProfile;
}

6
lib/uploadthing.ts Normal file
View File

@ -0,0 +1,6 @@
import { generateComponents } from "@uploadthing/react";
import type { OurFileRouter } from "@/app/api/uploadthing/core";
export const { UploadButton, UploadDropzone, Uploader } =
generateComponents<OurFileRouter>();

13
middleware.ts Normal file
View File

@ -0,0 +1,13 @@
import { authMiddleware } from "@clerk/nextjs";
// This example protects all routes including api/trpc routes
// Please edit this to allow other routes to be public as needed.
// See https://clerk.com/docs/references/nextjs/auth-middleware for more information about configuring your middleware
export default authMiddleware({
publicRoutes: ["/api/uploadthing"]
});
export const config = {
matcher: ['/((?!.+\\.[\\w]+$|_next).*)', '/', '/(api|trpc)(.*)'],
};

View File

@ -1,4 +1,19 @@
/** @type {import('next').NextConfig} */
const nextConfig = {}
const nextConfig = {
webpack: (config) => {
config.externals.push({
"utf-8-validate": "utf-8-validate",
"bufferutil": "commonjs bufferutil"
})
return config;
},
images: {
domains: [
"uploadthing.com",
"utfs.io"
]
}
}
module.exports = nextConfig

2967
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -9,22 +9,61 @@
"lint": "next lint"
},
"dependencies": {
"@clerk/nextjs": "^4.25.3",
"@emoji-mart/data": "^1.1.2",
"@emoji-mart/react": "^1.1.1",
"@hookform/resolvers": "^3.3.1",
"@livekit/components-react": "^1.4.0",
"@livekit/components-styles": "^1.0.7",
"@prisma/client": "^5.4.1",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-scroll-area": "^1.0.5",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-separator": "^1.0.3",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-tooltip": "^1.0.7",
"@tanstack/react-query": "^4.33.0",
"@types/node": "20.5.9",
"@types/react": "18.2.21",
"@types/react-dom": "18.2.7",
"@uploadthing/react": "^5.6.2",
"autoprefixer": "10.4.15",
"axios": "^1.5.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"cmdk": "^0.2.0",
"date-fns": "^2.30.0",
"emoji-mart": "^5.5.2",
"eslint": "8.48.0",
"eslint-config-next": "13.4.19",
"livekit-client": "^1.14.4",
"livekit-server-sdk": "^1.2.7",
"lucide-react": "^0.274.0",
"next": "13.4.19",
"postcss": "8.4.29",
"next": "^13.5.4",
"next-themes": "^0.2.1",
"postcss": "^8.4.31",
"query-string": "^8.1.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-dropzone": "^14.2.3",
"react-hook-form": "^7.47.0",
"socket.io": "^4.7.2",
"socket.io-client": "^4.7.2",
"tailwind-merge": "^1.14.0",
"tailwindcss": "3.3.3",
"tailwindcss-animate": "^1.0.7",
"typescript": "5.2.2"
"typescript": "5.2.2",
"uploadthing": "^5.7.1",
"uuid": "^9.0.1",
"zod": "^3.22.4",
"zustand": "^4.4.3"
},
"devDependencies": {
"@types/uuid": "^9.0.5",
"prisma": "^5.4.1"
}
}

View File

@ -0,0 +1,145 @@
import { currentProfilePages } from "@/lib/current-profile-pages";
import { db } from "@/lib/db";
import { NextApiResponseServerIo } from "@/types";
import { MemberRole } from "@prisma/client";
import { NextApiRequest } from "next";
export default async function handler(req: NextApiRequest, res: NextApiResponseServerIo) {
if (req.method !== "DELETE" && req.method !== "PATCH") {
return res.status(405).json({ error: "Method not allowed" });
}
try {
const profile = await currentProfilePages(req);
const { directMessageId, conversationId } = req.query;
const { content } = req.body;
if (!profile) {
return res.status(401).json({ error: "Unauthorized" });
}
if (!conversationId) {
return res.status(400).json({ error: "Conversation ID Missing" });
}
const conversation = await db.conversation.findFirst({
where: {
id: conversationId as string,
OR: [
{
memberOne: {
profileId: profile.id
}
},
{
memberTwo: {
profileId: profile.id
}
},
]
},
include: {
memberOne: {
include: {
profile: true
}
},
memberTwo: {
include: {
profile: true
}
}
}
});
if (!conversation) {
return res.status(404).json({ error: "Conversation not found" });
}
const member = conversation.memberOne.profileId === profile.id ?
conversation.memberOne : conversation.memberTwo;
if (!member) {
return res.status(404).json({ error: "Member not found" });
}
let directMessage = await db.directMessage.findFirst({
where: {
id: directMessageId as string,
conversationId: conversationId as string,
},
include: {
member: {
include: {
profile: true,
}
}
}
});
if (!directMessage || directMessage.deleted) {
return res.status(404).json({ error: "Message not found" });
}
const isMessageOwner = directMessage.memberId === member.id;
const isAdmin = member.role === MemberRole.ADMIN;
const isModerator = member.role === MemberRole.MODERATOR;
const canModify = isMessageOwner || isAdmin || isModerator;
if (!canModify) {
return res.status(401).json({ error: "Unauthorized" });
}
if (req.method === "DELETE") {
directMessage = await db.directMessage.update({
where: {
id: directMessageId as string,
},
data: {
fileUrl: null,
content: "This message has been deleted.",
deleted: true,
},
include: {
member: {
include: {
profile: true,
}
}
}
});
}
if (req.method === "PATCH") {
if (!isMessageOwner) {
return res.status(401).json({ error: "Unauthorized" });
}
directMessage = await db.directMessage.update({
where: {
id: directMessageId as string,
},
data: {
content,
},
include: {
member: {
include: {
profile: true,
}
}
}
});
}
const updateKey = `chat:${conversationId}:messages:update`;
res?.socket?.server?.io?.to(updateKey).emit(updateKey, directMessage);
return res.status(200).json(directMessage);
} catch (error) {
console.error(error);
return res.status(500).json({ error: "Internal Error" });
}
}

View File

@ -0,0 +1,95 @@
import { currentProfilePages } from "@/lib/current-profile-pages";
import { NextApiResponseServerIo } from "@/types";
import { NextApiRequest } from "next";
import { db } from "@/lib/db";
export default async function handler(req: NextApiRequest, res: NextApiResponseServerIo) {
if (req.method !== "POST") {
return res.status(405).json({ error: "Method not allowed" });
}
try {
const profile = await currentProfilePages(req);
const { content, fileUrl } = req.body;
const { conversationId } = req.query;
if (!profile) {
return res.status(401).json({ error: "Unauthorized" });
}
if (!conversationId) {
return res.status(400).json({ error: "Conversation ID Missing" });
}
if (!content) {
return res.status(400).json({ error: "Content Missing" });
}
const conversation = await db.conversation.findFirst({
where: {
id: conversationId as string,
OR: [
{
memberOne: {
profileId: profile.id
}
},
{
memberTwo: {
profileId: profile.id
}
},
]
},
include: {
memberOne: {
include: {
profile: true
}
},
memberTwo: {
include: {
profile: true
}
}
}
});
if (!conversation) {
return res.status(404).json({ error: "Conversation not found" });
}
const member = conversation.memberOne.profileId === profile.id ?
conversation.memberOne : conversation.memberTwo;
if (!member) {
return res.status(401).json({ error: "Member not found" });
}
const message = await db.directMessage.create({
data: {
content,
fileUrl,
conversationId: conversationId as string,
memberId: member.id,
},
include: {
member: {
include: {
profile: true
}
}
}
});
const channelKey = `chat:${conversationId}:messages`;
res?.socket?.server?.io?.emit(channelKey, message);
return res.status(200).json({ message });
}
catch (error) {
console.log("[DIRECT_MESSAGES_POST]", error);
return res.status(500).json({ message: "Internal Error" });
}
}

26
pages/api/socket/io.ts Normal file
View File

@ -0,0 +1,26 @@
import { Server as NetServer } from 'http';
import { NextApiRequest } from 'next';
import { Server as ServerIO } from 'socket.io';
import { NextApiResponseServerIo } from '@/types';
export const config = {
api: {
bodyParser: false,
}
}
const ioHandler = (req: NextApiRequest, res: NextApiResponseServerIo) => {
if (!res.socket.server.io) {
const path = "/api/socket/io"
const httpServer: NetServer = res.socket.server as any;
const io = new ServerIO(httpServer, {
path: path,
addTrailingSlash: false,
});
res.socket.server.io = io;
}
res.end();
}
export default ioHandler;

View File

@ -0,0 +1,143 @@
import { currentProfilePages } from "@/lib/current-profile-pages";
import { db } from "@/lib/db";
import { NextApiResponseServerIo } from "@/types";
import { MemberRole } from "@prisma/client";
import { NextApiRequest } from "next";
export default async function handler(req: NextApiRequest, res: NextApiResponseServerIo) {
if (req.method !== "DELETE" && req.method !== "PATCH") {
return res.status(405).json({ error: "Method not allowed" });
}
try {
const profile = await currentProfilePages(req);
const { serverId, channelId, messageId } = req.query;
const { content } = req.body;
if (!profile) {
return res.status(401).json({ error: "Unauthorized" });
}
if (!serverId) {
return res.status(400).json({ error: "Server ID Missing" });
}
if (!channelId) {
return res.status(400).json({ error: "Channel ID Missing" });
}
const server= await db.server.findFirst({
where: {
id: serverId as string,
members: {
some: {
profileId: profile.id
}
},
},
include: {
members: true
}
});
if (!server) {
return res.status(404).json({ error: "Server not found" });
}
const channel = await db.channel.findFirst({
where: {
id: channelId as string,
serverId: server.id,
},
});
if (!channel) {
return res.status(404).json({ error: "Channel not found" });
}
const member = server.members.find((member) => member.profileId === profile.id);
if (!member) {
return res.status(404).json({ error: "Member not found" });
}
let message = await db.message.findFirst({
where: {
id: messageId as string,
channelId: channel.id as string,
},
include: {
member: {
include: {
profile: true,
}
}
}
});
if (!message || message.deleted) {
return res.status(404).json({ error: "Message not found" });
}
const isMessageOwner = message.memberId === member.id;
const isAdmin = member.role === MemberRole.ADMIN;
const isModerator = member.role === MemberRole.MODERATOR;
const canModify = isMessageOwner || isAdmin || isModerator;
if (!canModify) {
return res.status(401).json({ error: "Unauthorized" });
}
if (req.method === "DELETE") {
message = await db.message.update({
where: {
id: messageId as string,
},
data: {
fileUrl: null,
content: "This message has been deleted.",
deleted: true,
},
include: {
member: {
include: {
profile: true,
}
}
}
});
}
if (req.method === "PATCH") {
if (!isMessageOwner) {
return res.status(401).json({ error: "Unauthorized" });
}
message = await db.message.update({
where: {
id: messageId as string,
},
data: {
content,
},
include: {
member: {
include: {
profile: true,
}
}
}
});
}
const updateKey = `chat:${channelId}:messages:update`;
res?.socket?.server?.io?.to(updateKey).emit(updateKey, message);
return res.status(200).json(message);
} catch (error) {
console.error(error);
return res.status(500).json({ error: "Internal Error" });
}
}

View File

@ -0,0 +1,93 @@
import { currentProfilePages } from "@/lib/current-profile-pages";
import { NextApiResponseServerIo } from "@/types";
import { NextApiRequest } from "next";
import { db } from "@/lib/db";
export default async function handler(req: NextApiRequest, res: NextApiResponseServerIo) {
if (req.method !== "POST") {
return res.status(405).json({ error: "Method not allowed" });
}
try {
const profile = await currentProfilePages(req);
const { content, fileUrl } = req.body;
const { serverId, channelId } = req.query;
if (!profile) {
return res.status(401).json({ error: "Unauthorized" });
}
if (!serverId) {
return res.status(400).json({ error: "Server ID Missing" });
}
if (!channelId) {
return res.status(400).json({ error: "Channel ID Missing" });
}
if (!content) {
return res.status(400).json({ error: "Content Missing" });
}
const server= await db.server.findFirst({
where: {
id: serverId as string,
members: {
some: {
profileId: profile.id
}
},
},
include: {
members: true
}
});
if (!server) {
return res.status(404).json({ error: "Server not found" });
}
const channel = await db.channel.findFirst({
where: {
id: channelId as string,
serverId: server.id,
},
});
if (!channel) {
return res.status(404).json({ error: "Channel not found" });
}
const member = server.members.find((member) => member.profileId === profile.id);
if (!member) {
return res.status(401).json({ error: "Member not found" });
}
const message = await db.message.create({
data: {
content,
fileUrl,
channelId: channelId as string,
memberId: member.id,
},
include: {
member: {
include: {
profile: true
}
}
}
});
const channelKey = `chat:${channelId}:messages`;
res?.socket?.server?.io?.emit(channelKey, message);
return res.status(200).json({ message });
}
catch (error) {
console.log("[MESSAGES_POST]", error);
return res.status(500).json({ message: "Internal Error" });
}
}

Some files were not shown because too many files have changed in this diff Show More