Compare commits
83 Commits
Kylas-disc
...
master
Author | SHA1 | Date | |
---|---|---|---|
ad16705a5f | |||
bbd7bf482d | |||
7414dff177 | |||
b1f4019622 | |||
64eb438da7 | |||
f86cc88e0b | |||
b650affe09 | |||
b83fbc5f77 | |||
a168a094d1 | |||
4448cecdda | |||
e7397aad68 | |||
01b41845b3 | |||
a57859aef0 | |||
ca3057c895 | |||
a105e6180e | |||
5da047a274 | |||
952f93cab0 | |||
70141f46fd | |||
55ad18e04a | |||
350b24f8ea | |||
484a8f4107 | |||
fa641dc3a1 | |||
e0162c2d5e | |||
16ce64ba61 | |||
c517a1f354 | |||
adea34b31b | |||
7d48962a96 | |||
ad78831d7e | |||
3c586ddc99 | |||
d148623a0d | |||
725c67d6f7 | |||
24bc54ef6e | |||
6b4b41bfc8 | |||
d71588b8ac | |||
bccc53942d | |||
d3cdd24d80 | |||
f8f4f0f94a | |||
fca82def36 | |||
0a891f065d | |||
1784e9ceb8 | |||
12032e868c | |||
7e6004fc87 | |||
835b9a23e2 | |||
ce96a85e55 | |||
a0534eab12 | |||
6a26bff3cd | |||
b7220c216c | |||
a05031374c | |||
601126e317 | |||
e3075627a4 | |||
2c29ae11e7 | |||
c0df5a4df0 | |||
868021049c | |||
65d90df42a | |||
784e24e8f3 | |||
266cb715b0 | |||
58097695f3 | |||
47bcb9757c | |||
7d9c652d3f | |||
f41829fa41 | |||
4763519f35 | |||
6ed6687800 | |||
02c42c93e1 | |||
f05ddd3236 | |||
b577450c02 | |||
8367231424 | |||
e493f48458 | |||
c7b61d05cb | |||
db7c2350c5 | |||
a252c3769b | |||
c7a7740c7a | |||
766d3131b1 | |||
b6276aaa06 | |||
e46ff98377 | |||
11ef6daf23 | |||
9dd01e1291 | |||
39e2caf39a | |||
33136e797f | |||
e09e609a1e | |||
c70041cfed | |||
ad3b195a38 | |||
cd3f757f0f | |||
0dcb937515 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -26,6 +26,7 @@ yarn-error.log*
|
|||||||
|
|
||||||
# local env files
|
# local env files
|
||||||
.env*.local
|
.env*.local
|
||||||
|
.env
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
5
app/(auth)/(routes)/sign-in/[[...sign-in]]/page.tsx
Normal file
5
app/(auth)/(routes)/sign-in/[[...sign-in]]/page.tsx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { SignIn } from "@clerk/nextjs";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <SignIn />;
|
||||||
|
}
|
5
app/(auth)/(routes)/sign-up/[[...sign-up]]/page.tsx
Normal file
5
app/(auth)/(routes)/sign-up/[[...sign-up]]/page.tsx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { SignUp } from "@clerk/nextjs";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <SignUp />;
|
||||||
|
}
|
9
app/(auth)/layout.tsx
Normal file
9
app/(auth)/layout.tsx
Normal 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;
|
62
app/(invite)/(routes)/invite/[inviteCode]/page.tsx
Normal file
62
app/(invite)/(routes)/invite/[inviteCode]/page.tsx
Normal 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;
|
@ -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
|
@ -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
|
51
app/(main)/(routes)/servers/[serverId]/layout.tsx
Normal file
51
app/(main)/(routes)/servers/[serverId]/layout.tsx
Normal 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;
|
50
app/(main)/(routes)/servers/[serverId]/page.tsx
Normal file
50
app/(main)/(routes)/servers/[serverId]/page.tsx
Normal 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
20
app/(main)/layout.tsx
Normal 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
27
app/(setup)/page.tsx
Normal 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;
|
123
app/api/channels/[channelId]/route.ts
Normal file
123
app/api/channels/[channelId]/route.ts
Normal 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
65
app/api/channels/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
83
app/api/direct-messages/route.ts
Normal file
83
app/api/direct-messages/route.ts
Normal 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
35
app/api/livekit/route.ts
Normal 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() });
|
||||||
|
}
|
128
app/api/members/[memberId]/route.ts
Normal file
128
app/api/members/[memberId]/route.ts
Normal 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
83
app/api/messages/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
38
app/api/servers/[serverId]/invite-code/route.ts
Normal file
38
app/api/servers/[serverId]/invite-code/route.ts
Normal 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});
|
||||||
|
}
|
||||||
|
}
|
57
app/api/servers/[serverId]/leave/route.ts
Normal file
57
app/api/servers/[serverId]/leave/route.ts
Normal 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});
|
||||||
|
}
|
||||||
|
}
|
65
app/api/servers/[serverId]/route.ts
Normal file
65
app/api/servers/[serverId]/route.ts
Normal 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
42
app/api/servers/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
21
app/api/uploadthing/core.ts
Normal file
21
app/api/uploadthing/core.ts
Normal 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;
|
8
app/api/uploadthing/route.ts
Normal file
8
app/api/uploadthing/route.ts
Normal 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,
|
||||||
|
});
|
@ -3,7 +3,7 @@
|
|||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
html,
|
html,
|
||||||
boby,
|
body,
|
||||||
:root {
|
:root {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,15 @@
|
|||||||
import './globals.css'
|
import './globals.css'
|
||||||
import type { Metadata } from 'next'
|
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 = {
|
export const metadata: Metadata = {
|
||||||
title: 'Create Next App',
|
title: 'Create Next App',
|
||||||
@ -15,8 +22,27 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<ClerkProvider>
|
||||||
<body className={inter.className}>{children}</body>
|
<html lang="en" suppressHydrationWarning>
|
||||||
</html>
|
<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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -1,8 +0,0 @@
|
|||||||
|
|
||||||
export default function Home() {
|
|
||||||
return (
|
|
||||||
<p className='text-3xl font-bold text-indigo-500'>
|
|
||||||
Hello Discord Clone
|
|
||||||
</p>
|
|
||||||
)
|
|
||||||
}
|
|
37
components/action-tooltip.tsx
Normal file
37
components/action-tooltip.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
44
components/chat/chat-header.tsx
Normal file
44
components/chat/chat-header.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
102
components/chat/chat-input.tsx
Normal file
102
components/chat/chat-input.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
252
components/chat/chat-item.tsx
Normal file
252
components/chat/chat-item.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
144
components/chat/chat-messages.tsx
Normal file
144
components/chat/chat-messages.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
39
components/chat/chat-video-button.tsx
Normal file
39
components/chat/chat-video-button.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
|
||||||
|
}
|
28
components/chat/chat-welcome.tsx
Normal file
28
components/chat/chat-welcome.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
42
components/emoji-picker.tsx
Normal file
42
components/emoji-picker.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
77
components/file-upload.tsx
Normal file
77
components/file-upload.tsx
Normal 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
67
components/media-room.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
32
components/mobile-toggle.tsx
Normal file
32
components/mobile-toggle.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
176
components/modals/create-channel-modal.tsx
Normal file
176
components/modals/create-channel-modal.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
138
components/modals/create-server-modal.tsx
Normal file
138
components/modals/create-server-modal.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
89
components/modals/delete-channel-modal.tsx
Normal file
89
components/modals/delete-channel-modal.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
79
components/modals/delete-message-modal.tsx
Normal file
79
components/modals/delete-message-modal.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
81
components/modals/delete-server-modal.tsx
Normal file
81
components/modals/delete-server-modal.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
173
components/modals/edit-channel-modal.tsx
Normal file
173
components/modals/edit-channel-modal.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
149
components/modals/edit-server-modal.tsx
Normal file
149
components/modals/edit-server-modal.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
140
components/modals/initial-modal.tsx
Normal file
140
components/modals/initial-modal.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
90
components/modals/invite-modal.tsx
Normal file
90
components/modals/invite-modal.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
81
components/modals/leave-server-modal.tsx
Normal file
81
components/modals/leave-server-modal.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
164
components/modals/members-modal.tsx
Normal file
164
components/modals/members-modal.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
117
components/modals/message-file-modal.tsx
Normal file
117
components/modals/message-file-modal.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
40
components/mode-toggle.tsx
Normal file
40
components/mode-toggle.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
33
components/navigation/navigation-action.tsx
Normal file
33
components/navigation/navigation-action.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
54
components/navigation/navigation-item.tsx
Normal file
54
components/navigation/navigation-item.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
62
components/navigation/navigation-sidebar.tsx
Normal file
62
components/navigation/navigation-sidebar.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
43
components/providers/modal-provider.tsx
Normal file
43
components/providers/modal-provider.tsx
Normal 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/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
21
components/providers/query-provider.tsx
Normal file
21
components/providers/query-provider.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
59
components/providers/socket-provider.tsx
Normal file
59
components/providers/socket-provider.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
9
components/providers/theme-provider.tsx
Normal file
9
components/providers/theme-provider.tsx
Normal 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>
|
||||||
|
}
|
79
components/server/server-channel.tsx
Normal file
79
components/server/server-channel.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
94
components/server/server-header.tsx
Normal file
94
components/server/server-header.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
58
components/server/server-member.tsx
Normal file
58
components/server/server-member.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
107
components/server/server-search.tsx
Normal file
107
components/server/server-search.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
47
components/server/server-section.tsx
Normal file
47
components/server/server-section.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
208
components/server/server-sidebar.tsx
Normal file
208
components/server/server-sidebar.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
28
components/socket-indicator.tsx
Normal file
28
components/socket-indicator.tsx
Normal 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
50
components/ui/avatar.tsx
Normal 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
36
components/ui/badge.tsx
Normal 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
57
components/ui/button.tsx
Normal 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
155
components/ui/command.tsx
Normal 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
119
components/ui/dialog.tsx
Normal 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,
|
||||||
|
}
|
200
components/ui/dropdown-menu.tsx
Normal file
200
components/ui/dropdown-menu.tsx
Normal 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
176
components/ui/form.tsx
Normal 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
25
components/ui/input.tsx
Normal 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
26
components/ui/label.tsx
Normal 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
31
components/ui/popover.tsx
Normal 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 }
|
53
components/ui/scroll-area.tsx
Normal file
53
components/ui/scroll-area.tsx
Normal 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
121
components/ui/select.tsx
Normal 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,
|
||||||
|
}
|
31
components/ui/separator.tsx
Normal file
31
components/ui/separator.tsx
Normal 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
140
components/ui/sheet.tsx
Normal 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
30
components/ui/tooltip.tsx
Normal 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 }
|
18
components/user-avatar.tsx
Normal file
18
components/user-avatar.tsx
Normal 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
54
hooks/use-chat-query.ts
Normal 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
60
hooks/use-chat-scroll.ts
Normal 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
80
hooks/use-chat-socket.ts
Normal 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
29
hooks/use-modal-store.ts
Normal 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
17
hooks/use-origin.ts
Normal 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
72
lib/conversation.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
20
lib/current-profile-pages.ts
Normal file
20
lib/current-profile-pages.ts
Normal 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
19
lib/current-profile.ts
Normal 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
9
lib/db.ts
Normal 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
32
lib/initial-profile.ts
Normal 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
6
lib/uploadthing.ts
Normal 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
13
middleware.ts
Normal 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)(.*)'],
|
||||||
|
};
|
||||||
|
|
@ -1,4 +1,19 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @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
|
module.exports = nextConfig
|
||||||
|
2967
package-lock.json
generated
2967
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
45
package.json
45
package.json
@ -9,22 +9,61 @@
|
|||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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/node": "20.5.9",
|
||||||
"@types/react": "18.2.21",
|
"@types/react": "18.2.21",
|
||||||
"@types/react-dom": "18.2.7",
|
"@types/react-dom": "18.2.7",
|
||||||
|
"@uploadthing/react": "^5.6.2",
|
||||||
"autoprefixer": "10.4.15",
|
"autoprefixer": "10.4.15",
|
||||||
|
"axios": "^1.5.1",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
|
"cmdk": "^0.2.0",
|
||||||
|
"date-fns": "^2.30.0",
|
||||||
|
"emoji-mart": "^5.5.2",
|
||||||
"eslint": "8.48.0",
|
"eslint": "8.48.0",
|
||||||
"eslint-config-next": "13.4.19",
|
"eslint-config-next": "13.4.19",
|
||||||
|
"livekit-client": "^1.14.4",
|
||||||
|
"livekit-server-sdk": "^1.2.7",
|
||||||
"lucide-react": "^0.274.0",
|
"lucide-react": "^0.274.0",
|
||||||
"next": "13.4.19",
|
"next": "^13.5.4",
|
||||||
"postcss": "8.4.29",
|
"next-themes": "^0.2.1",
|
||||||
|
"postcss": "^8.4.31",
|
||||||
|
"query-string": "^8.1.0",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "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",
|
"tailwind-merge": "^1.14.0",
|
||||||
"tailwindcss": "3.3.3",
|
"tailwindcss": "3.3.3",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
145
pages/api/socket/direct-messages/[directmessageId].ts
Normal file
145
pages/api/socket/direct-messages/[directmessageId].ts
Normal 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" });
|
||||||
|
}
|
||||||
|
}
|
95
pages/api/socket/direct-messages/index.ts
Normal file
95
pages/api/socket/direct-messages/index.ts
Normal 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
26
pages/api/socket/io.ts
Normal 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;
|
143
pages/api/socket/messages/[messageId].ts
Normal file
143
pages/api/socket/messages/[messageId].ts
Normal 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" });
|
||||||
|
}
|
||||||
|
}
|
93
pages/api/socket/messages/index.ts
Normal file
93
pages/api/socket/messages/index.ts
Normal 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
Loading…
x
Reference in New Issue
Block a user