- 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. リアルタイムチャット
17. サーチサーバモーダル
サイドバーにサーバ検索用のモーダルを作成する。
サーバサイドバーに追加
アイコンマップを作成
[ChannelType.TEXT]
のキーは計算プロパティ名参照。
オブジェクト初期化子 - JavaScript | MDN
オブジェクト初期化子 (object initializer) は、オブジェクトのプロパティ名と関連する値の 0 個以上のペアを中括弧 ({}) で囲んだカンマ区切りのリストです。オブジェクトは Object.create() や new 演算子でコンストラクター関数を呼び出して使用することもできます。

components/server/server-sidebar.tsx
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 ml-2 text-indigo-500" />
),
[MemberRole.ADMIN]: <ShieldAlert className="h-4 w-4 ml-2 text-indigo-500" />,
};
サーバサイドバーにスクロールエリアを追加
- テキストチャンネル
- 音声チャンネル
- 映像チャンネル
- メンバー が表示されるようにする。
components/server/server-sidebar.tsx
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">
<ServerSearch
data={[
{
label: "テキストチャンネル",
type: "channel",
data: textChannels.map((channel) => ({
icon: iconMap[channel.type],
name: channel.name,
id: channel.id,
})),
},
{
label: "音声チャンネル",
type: "channel",
data: audioChannels.map((channel) => ({
icon: iconMap[channel.type],
name: channel.name,
id: channel.id,
})),
},
{
label: "映像チャンネル",
type: "channel",
data: videoChannels.map((channel) => ({
icon: iconMap[channel.type],
name: channel.name,
id: channel.id,
})),
},
{
label: "メンバー",
type: "member",
data: members.map((member) => ({
icon: roleIconMap[member.role],
name: member.profile.name,
id: member.id,
})),
},
]}
/>
</ScrollArea>
</div>
);
サーバサーチコンポーネント
- 検索はボタンで作成し、ショートカットキーを表示する(いったん、ここではmac)
- group指定でhover時のスタイルを変更する
<kbd>
は使ったことがなかった
<kbd>: キーボード入力要素 - HTML: ハイパーテキストマークアップ言語 | MDN
<kbd> は HTML の要素で、キーボード、音声入力、その他の入力端末からのユーザーによる文字入力を表す行内の文字列の区間を表します。慣習的に、ユーザーエージェントは既定で <kbd> 要素の中身を等幅フォントで表示しますが、 HTML 標準で規定されているものではありません。

components/server/server-search.tsx
"use client";
import { Search } from "lucide-react";
interface ServerSearchProps {
data: {
label: string;
type: "channel" | "member";
data:
| {
icon: React.ReactNode;
name: string;
id: string;
}[]
| undefined;
}[];
}
export const ServerSearch = ({ data }: ServerSearchProps) => {
return (
<>
<button className="group px-2 py-2 rounded-md flex items-center gap-x-2 w-full hover:bg-zinc-700/10 dark:bg-zinc-700/50 transition">
<Search className="w-4 h-4 text-zinc-500 dark:text-zinc-400" />
<p className="font-semibold text-sm text-zinc-500 dark:text-zinc-400 group-hover:text-zinc-600 dark:group-hover:text-zinc-300 transition">
検索
</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-foreground ml-auto">
<span className="text-xs">⌘</span>K
</kbd>
</button>
</>
);
};
コマンドダイアログの実装
shadcn/uiからcommandを追加
コマンドからオープンできるようにする。shadcn/uiからcommandを追加する。
Command
Fast, composable, unstyled command menu for React.

ダイアログを追加
components/server/server-search.tsx
export const ServerSearch = ({ data }: ServerSearchProps) => {
const [open, setOpen] = useState(false);
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:bg-zinc-700/50 transition"
>
<Search className="w-4 h-4 text-zinc-500 dark:text-zinc-400" />
<p className="font-semibold text-sm text-zinc-500 dark:text-zinc-400 group-hover:text-zinc-600 dark:group-hover:text-zinc-300 transition">
検索
</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-foreground ml-auto">
<span className="text-xs">⌘</span>K
</kbd>
</button>
<CommandDialog open={open} onOpenChange={setOpen}>
<CommandInput placeholder="全てのチャンネル、メンバーを検索する" />
<CommandList>
<CommandEmpty>
<p>検索結果がありません</p>
</CommandEmpty>
{data.map(({ label, type, data }) => {
if (!data) return null;
return (
<CommandGroup key={label} heading={label}>
{data?.map(({ id, icon, name }) => {
return (
<CommandItem key={id}>
{icon}
<span>{name}</span>
</CommandItem>
);
})}
</CommandGroup>
);
})}
</CommandList>
</CommandDialog>
</>
);
};
この段階で検索を行うことが可能。
コマンド機能の追加
- コマンドは
useEffect
でキーボードイベントを追加することで実現する - return でクリーンアップを行う
クリーンアップコードは公式ドキュメント参考
useEffect – React
The library for web and native user interfaces

components\server\server-search.tsx
export const ServerSearch = ({ data }: ServerSearchProps) => {
const [open, setOpen] = useState(false);
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);
}, []);
これでctrl+k
(もしくは⌘K
) で検索ができるようになる。
検索結果から遷移できるようにする
components\server\server-search.tsx
const onClick = ({
id,
type,
}: {
id: string;
type: "channel" | "member";
}) => {
setOpen(false);
if (type === "member") {
router.push(`/servers/${params?.serverId}/conversations/${id}`);
}
if (type === "channel") {
router.push(`/servers/${params?.serverId}/channels/${id}`);
}
};
CommandItem
に onSelect
を追加する。
components\server\server-search.tsx
<CommandGroup key={label} heading={label}>
{data?.map(({ id, icon, name }) => {
return (
<CommandItem
key={id}
onSelect={() => onClick({ id, type })}
>
{icon}
<span>{name}</span>
</CommandItem>
);
})}
</CommandGroup>
ここまでで検索結果からチャンネル・メンバーに遷移できるようになる。
18. チャンネルリスト
チャンネルリストの作成
テキストチャンネル(ラベル部)の追加
- スクロールエリアの中に配置する
- サーバセクションを作成する(server-section.tsx)
- チャンネル作成の+ボタンを追加する
components/server/server-sidebar.tsx
<Separator className="bg-zinc-200 dark:bg-zinc-700 rounded-md my-2 " />
{!!textChannels.length && (
<div className="mb-2">
<ServerSection
label={"テキストチャンネル"}
role={role}
sectionType="channel"
channelType={ChannelType.TEXT}
/>
</div>
)}
components/server/server-section.tsx
"use client";
import { ActionTooltip } from "@/components/action-tooltip";
import { useModal } from "@/hooks/use-modal-store";
import { ServerWithMembersWithProfiles } from "@/types";
import { ChannelType, MemberRole } from "@prisma/client";
import { Plus } from "lucide-react";
interface ServerSectionProps {
label: string;
role?: MemberRole;
sectionType: "channel" | "member";
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 === "channel" && (
<ActionTooltip label="チャンネル作成" side="top">
<button
onClick={() => onOpen("createChannel")}
className="text-zinc-500 hover:text-zinc-600 dark:text-zinc-400 dark:hover:text-zinc-300 transition"
>
<Plus className="h-4 w-4" />
</button>
</ActionTooltip>
)}
</div>
);
};
テキストチャンネルリストの作成
components/server/server-sidebar.tsx
{!!textChannels.length && (
<div className="mb-2">
<ServerSection
label={"テキストチャンネル"}
role={role}
sectionType="channel"
channelType={ChannelType.TEXT}
/>
{textChannels.map((channel) => (
<ServerChannel
key={channel.id}
channel={channel}
role={role}
server={server}
/>
))}
</div>
)}
Icon
でReactNodeとして扱えるようにしている- 後述のmember側は
icon
を追加する
// チャンネルのアイコンマップ
const iconMap = {
[ChannelType.TEXT]: Hash,
[ChannelType.AUDIO]: Mic,
[ChannelType.VIDEO]: Video,
};
const Icon = iconMap[channel.type];
// メンバーのアイコンマップ
const roleIconMap = {
GUEST: null,
MODERATOR: <ShieldCheck className="mr-2 h-4 w-4 text-indigo-500" />,
ADMIN: <ShieldAlert className="mr-2 h-4 w-4 text-rose-500" />,
};
const icon = roleIconMap[member.role];
components/server/server-channel.tsx
"use client";
import { cn } from "@/lib/utils";
import { Channel, ChannelType, MemberRole, Server } from "@prisma/client";
import { Hash, Mic, Video } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
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 router = useRouter();
const params = useParams();
const Icon = iconMap[channel.type];
return (
<button
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>
</button>
);
};
チャンネルの編集アイコン追加
- generalチャンネル以外は編集可能なので、hover時に編集・削除できるようにする
- generalチャンネルはロックアイコンを表示する
components/server/server-channel.tsx
export const ServerChannel = ({
channel,
server,
role,
}: ServerChannelProps) => {
const router = useRouter();
const params = useParams();
const Icon = iconMap[channel.type];
return (
<button
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 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 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>
);
};
音声・ビデオチャンネル追加
作りはテキストチャンネルと同様。
{
!!audioChannels.length && (
<div className="mb-2">
<ServerSection
label={"音声チャンネル"}
role={role}
sectionType="channel"
channelType={ChannelType.AUDIO}
/>
{audioChannels.map(channel => (
<ServerChannel
key={channel.id}
channel={channel}
role={role}
server={server}
/>
))}
</div>
);
}
{
!!videoChannels.length && (
<div className="mb-2">
<ServerSection
label={"映像チャンネル"}
role={role}
sectionType="channel"
channelType={ChannelType.VIDEO}
/>
{videoChannels.map(channel => (
<ServerChannel
key={channel.id}
channel={channel}
role={role}
server={server}
/>
))}
</div>
);
}
メンバーリストの作成
components/server/server-member.tsx
"use client";
import { UserAvatar } from "@/components/user-avatar";
import { cn } from "@/lib/utils";
import { Member, Profile, Server } from "@prisma/client";
import { ShieldAlert, ShieldCheck } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
interface ServerMemberProps {
member: Member & { profile: Profile };
server: Server;
}
const roleIconMap = {
GUEST: null,
MODERATOR: <ShieldCheck className="h-4 w-4 mr-2 text-indigo-500" />,
ADMIN: <ShieldAlert className="h-4 w-4 mr-2 text-rose-500" />,
};
export const ServerMember = ({ member, server }: ServerMemberProps) => {
const params = useParams();
const router = useRouter();
const icon = roleIconMap[member.role];
return (
<button
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 &&
"text-primary dark:text-zinc-200 dark:group-hover:text-white"
)}
>
{member.profile.name}
</p>
{icon}
</button>
);
};
モーダルに固定値を持たせる
各チャンネルから作成する際にチャンネルタイプを固定する。 モーダルストアにチャンネルタイプを追加する。
hooks/use-modal-store.ts
interface ModalData {
server?: Server;
channelType?: ChannelType;
}
components/server/server-section.tsx
{role !== MemberRole.GUEST && sectionType === "channel" && (
<ActionTooltip label="チャンネル作成" side="top">
<button
onClick={() => onOpen("createChannel", { channelType })}
className="text-zinc-500 hover:text-zinc-600 dark:text-zinc-400 dark:hover:text-zinc-300 transition"
>
<Plus className="h-4 w-4" />
</button>
</ActionTooltip>
)}
components/modals/create-channel-modal.tsx
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]);
これで各チャンネルから作成する際にチャンネルタイプを固定できるようになる。
19. チャンネル機能作成
- リストができたので各機能を作成していく。
- 作成するものは基本一緒。
チャンネル削除
チャンネル削除API
app/api/channels/[channelId]/route.ts
import { currentProfile } from "@/lib/current-profile";
import { db } from "@/lib/db";
import { NextResponse } from "next/server";
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: ["ADMIN", "MODERATOR"],
},
},
},
},
data: {
channels: {
delete: {
id: params.channelId,
name: {
not: "general",
},
},
},
},
});
return NextResponse.json(server);
} catch (error) {
console.error("[CHANNELS_DELETE]", error);
return new NextResponse("Internal Server Error", { status: 500 });
}
}
チャンネル編集API
app/api/channels/[channelId]/route.ts
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("Cannot edit general channel", { status: 400 });
}
const server = await db.server.update({
where: {
id: serverId,
members: {
some: {
profileId: profile.id,
role: {
in: ["ADMIN", "MODERATOR"],
},
},
},
},
data: {
channels: {
update: {
where: {
id: params.channelId,
NOT: {
name: "general",
},
},
data: {
name,
type,
},
},
},
},
});
return NextResponse.json(server);
} catch (error) {
console.error("[CHANNELS_PATCH]", error);
return new NextResponse("Internal Server Error", { status: 500 });
}
}