- Discordクローン作成 1. 環境構築〜Clerkの設定
- Discordクローン作成 2. ダークモード〜Prisma, PlanetScale
- Discordクローン作成 3. モーダルUI, 画像(uploadthing)
- Discordクローン作成 4. サーバ作成, ナビゲーション
- Discordクローン作成 5. サーバー作成用モーダル、サイドバー作成
- Discordクローン作成 6. 招待、サーバ設定
- Discordクローン作成 7. メンバーの管理
- Discordクローン作成 8. チャンネル作成、サーバ削除・退出
- Discordクローン作成 9. サーバ検索、チャンネルリスト
- Discordクローン作成 10. チャンネルページ作成 👈ココ
- Discordクローン作成 11. 会話ページ作成
- Discordクローン作成 12. メッセージ送信
- Discordクローン作成 13. リアルタイムチャット
20. ページの作成
右側のチャンネルページを作成していく。
チャンネルページへの遷移
onClickの追加
ServerChannel
コンポーネントにonClick
を追加する。
このとき、以下のままだとbutton
のchildrenにまでonClickが伝搬してしまう。
components/server/server-channel.tsx
export const ServerChannel = ({
channel,
server,
role,
}: ServerChannelProps) => {
const onClick = () => {
router.push(`/servers/${server.id}/channels/${channel.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?.channelId === channel.id && "bg-zinc-700/20 dark:bg-zinc-700"
)}
>
<Icon className="flex-shrink-0 w-5 h-5 text-zinc-500 dark:text-zinc-400" />
<p
className={cn(
"line-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:white"
)}
>
{channel.name}
</p>
{channel.name !== "general" && role !== MemberRole.GUEST && (
<div className="ml-auto flex items-center gap-x-2">
<ActionTooltip label="編集">
<Edit
onClick={() => onOpen("editChannel", { server, channel })}
className="hidden group-hover:block w-4 h-4 text-zinc-500 hover:text-zinc-600 dark:text-zinc-400 dark:hover:text-zinc-300 transition "
/>
</ActionTooltip>
<ActionTooltip label="削除">
<Trash
onClick={() => onOpen("deleteChannel", { server, channel })}
className="hidden group-hover:block w-4 h-4 text-zinc-500 hover:text-zinc-600 dark:text-zinc-400 dark:hover: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>
);
};
stopPropagation
を使って伝搬を止める。React.MouseEvent
で取得可能。
Event: stopPropagation() メソッド - Web API | MDN
stopPropagation() は Event インターフェイスのメソッドで、キャプチャおよびバブリング段階において現在のイベントのさらなる伝播を阻止します。しかし、これは既定の動作の発生を妨げるものではありません。例えば、リンクのクリックはまだ処理されます。これらの動作を止めたい場合は、preventDefault() メソッドを参照してください。また、現在の要素における他のイベントハンドラーへの伝搬も防げません。それらを止めたい場合は、stopImmediatePropagation() を参照してください。

components/server/server-channel.tsx
export const ServerChannel = ({
channel,
server,
role,
}: ServerChannelProps) => {
const { onOpen } = useModal();
const router = useRouter();
const params = useParams();
const Icon = iconMap[channel.type];
const onClick = () => {
router.push(`/servers/${server.id}/channels/${channel.id}`);
};
const onAction = (e: React.MouseEvent, action: ModalType) => {
e.stopPropagation();
onOpen(action, { server, channel });
};
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/20 dark:bg-zinc-700"
)}
>
<Icon className="flex-shrink-0 w-5 h-5 text-zinc-500 dark:text-zinc-400" />
<p
className={cn(
"line-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:white"
)}
>
{channel.name}
</p>
{channel.name !== "general" && role !== MemberRole.GUEST && (
<div className="ml-auto flex items-center gap-x-2">
<ActionTooltip label="編集">
<Edit
onClick={(e) => onAction(e, "editChannel")}
className="hidden group-hover:block w-4 h-4 text-zinc-500 hover:text-zinc-600 dark:text-zinc-400 dark:hover:text-zinc-300 transition "
/>
</ActionTooltip>
<ActionTooltip label="削除">
<Trash
onClick={(e) => onAction(e, "deleteChannel")}
className="hidden group-hover:block w-4 h-4 text-zinc-500 hover:text-zinc-600 dark:text-zinc-400 dark:hover: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>
);
会話ページへの遷移
メンバー一覧のonClickも追加する。
components/server/server-member.tsx
export const ServerMember = ({ member, server }: ServerMemberProps) => {
const params = useParams();
const router = useRouter();
const icon = roleIconMap[member.role];
const onClick = () => {
router.push(`/servers/${server.id}/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"
)}
>
http://localhost:3000/servers/8657074d-22d5-4d0e-8564-99c2a3e4e0b0/conversations/e5f9024e-4388-4910-addf-5a490be02e5b
のようなURLになる。
ページ作成
ガワを作製
- チャンネルページ
app/(main)/(routes)/servers/[serverId]/channels/[channelId]/page.tsx
- 会話ページ
app/(main)/(routes)/servers/[serverId]/conversations/[memberId]/page.tsx
サーバ選択時にgeneralを表示
generalを表示するようにサーバのpageを修正する。
app/(main)/(routes)/servers/[serverId]/page.tsx
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 initialChannel = server?.channels[0];
if (initialChannel?.name !== "general") {
return null;
}
return redirect(`/servers/${params.serverId}/channels/${initialChannel.id}`);
};
export default ServerIdPage;
21. チャットヘッダ
チャットヘッダはレスポンシブ対応でメニューを作製する。
チャットヘッダのレイアウト作製
コンポーネント作成
ヘッダ部を作成する
components/chat/chat-header.tsx
import { Hash, Menu } from "lucide-react";
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">
<Menu />
{type === "channel" && (
<Hash className="w-5 h-5 text-zinc-500 dark:text-zinc-400 mr-2" />
)}
<p className="font-semibold text-md text-black dark:text-white">{name}</p>
</div>
);
};
ページ側。
/channels/[channelId]/page.tsx
import { ChatHeader } from "@/components/chat/chat-header";
import { currentProfile } from "@/lib/current-profile";
import { db } from "@/lib/db";
import { redirectToSignIn } from "@clerk/nextjs";
import { redirect } from "next/navigation";
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: {
profileId: profile.id,
serverId: params.serverId,
},
});
if (!channel || !member) {
return redirect("/");
}
return (
<div className="bg-white dark:bg-[#313338] flex flex-col h-full">
<ChatHeader
serverId={params.serverId}
name={channel.name}
type="channel"
/>
</div>
);
};
export default ChannelIdPage;
ここからヘッダの実装を行っていく。
ハンバーガーメニューの作成
メニューではshadcn/uiのSheetを使う
Sheet
Extends the Dialog component to display content that complements the main content of the screen.

ここからmobile-toggle.tsx
を作成
components/mobile-toggle.tsx
import { NavigationSidebar } from "@/components/navigation/navigation-sidebar";
import { ServerSidebar } from "@/components/server/server-sidebar";
import { Button } from "@/components/ui/button";
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
import { Menu } from "lucide-react";
export const MobileToggle = ({ serverId }: { serverId: string }) => {
return (
<Sheet>
<SheetTrigger asChild>
<Button className="md:hidden" variant={"ghost"} size={"icon"}>
<Menu />
</Button>
</SheetTrigger>
<SheetContent side={"left"} className="p-0 flex gap-0">
<div className="w-[72px]">
<NavigationSidebar />
</div>
<ServerSidebar serverId={serverId} />
</SheetContent>
</Sheet>
);
};
components/chat/chat-header.tsx
import { MobileToggle } from "@/components/mobile-toggle";
import { Hash } from "lucide-react";
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" />
)}
<p className="font-semibold text-md text-black dark:text-white">{name}</p>
</div>
);
};
✖アイコンがプルダウンに被るのが気になるので暫定でコメントアウト(この時点では手順にはない)
components/ui/sheet.tsx
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>
));
Prismaの修正
- チャット機能を利用する上で必要なDB情報を追加する。
- もともとあったテーブルは省略
Message
db/schema.prisma
model Message {
id String @id @default(uuid())
content String @db.Text
fileUrl String @db.Text
memberId String
member Member @relation(fields: [memberId], references: [id], onDelete: Cascade)
channelId String
channel Channel @relation(fields: [channelId], references: [id], onDelete: Cascade)
deleted Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([memberId])
@@index([channelId])
}
Conversation
- memberOneId, memberTwoIdでメンバーを管理する。
@@unique([memberOneId, memberTwoId])
指定をすることで、同じメンバーでの会話が重複しないようにする@@index([memberOneId])
がなくて良い理由は、@@unique([memberOneId, memberTwoId])
があるため。特に記述が見つからないが、配列1件目にはindexが作成されるっぽい(インデックスなしの警告が出ない)
db/schema.prisma
model Conversation {
id String @id @default(uuid())
memberOneId String
memberOne Member @relation("MemberOne",fields: [memberOneId], references: [id], onDelete: Cascade)
memberTwoId String
memberTwo Member @relation("MemberTwo",fields: [memberTwoId], references: [id], onDelete: Cascade)
directMessages DirectMessage[]
@@index([memberTwoId])
@@unique([memberOneId, memberTwoId])
}
- Memberテーブル側には
conversationInitiated
とconversationReceived
を追加する。
db/schema.prisma
model Member {
id String @id @default(uuid())
role MemberRole @default(GUEST)
profileId String
profile Profile @relation(fields: [profileId], references: [id], onDelete: Cascade)
serverId String
server Server @relation(fields: [serverId], references: [id], onDelete: Cascade)
messages Message[]
directMessages DirectMessage[]
conversationInitiated Conversation[] @relation("MemberOne")
conversationReceived Conversation[] @relation("MemberTwo")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([profileId])
@@index([serverId])
}
DirectMessage
db/schema.prisma
model DirectMessage {
id String @id @default(uuid())
content String @db.Text
fileUrl String @db.Text
conversationId String
conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
memberId String
member Member @relation(fields: [memberId], references: [id], onDelete: Cascade)
deleted Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([conversationId])
@@index([memberId])
}
Prisma Generate
terminal
pnpx prisma generate
pnpx prisma db push