Skip to content

Discordクローン作成 9. サーバ検索、チャンネルリスト

公開日

表紙

17. サーチサーバモーダル

サイドバーにサーバ検索用のモーダルを作成する。

サーバサイドバーに追加

アイコンマップを作成

[ChannelType.TEXT] のキーは計算プロパティ名参照。

オブジェクト初期化子 - JavaScript | MDN
オブジェクト初期化子 (object initializer) は、オブジェクトのプロパティ名と関連する値の 0 個以上のペアを中括弧 ({}) で囲んだカンマ区切りのリストです。オブジェクトは Object.create() や new 演算子でコンストラクター関数を呼び出して使用することもできます。
オブジェクト初期化子 - JavaScript | MDN favicon https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Operators/Object_initializer#計算プロパティ名
オブジェクト初期化子 - JavaScript | MDN
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 標準で規定されているものではありません。
<kbd>: キーボード入力要素 - HTML: ハイパーテキストマークアップ言語 | MDN favicon https://developer.mozilla.org/ja/docs/Web/HTML/Element/kbd
<kbd>: キーボード入力要素 - HTML: ハイパーテキストマークアップ言語 | MDN
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>
    </>
  );
};

17-1

コマンドダイアログの実装

shadcn/uiからcommandを追加

コマンドからオープンできるようにする。shadcn/uiからcommandを追加する。

Command
Fast, composable, unstyled command menu for React.
Command favicon https://ui.shadcn.com/docs/components/command
Command

ダイアログを追加

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>
    </>
  );
};

この段階で検索を行うことが可能。

17-2

コマンド機能の追加

  • コマンドはuseEffectでキーボードイベントを追加することで実現する
  • return でクリーンアップを行う

クリーンアップコードは公式ドキュメント参考

useEffect – React
The library for web and native user interfaces
useEffect – React favicon https://ja.react.dev/reference/react/useEffect#connecting-to-an-external-system
useEffect – React
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}`);
  }
};

CommandItemonSelect を追加する。

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>
  );
};
 

18-1

テキストチャンネルリストの作成

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>
  );
};
 

18-2

チャンネルの編集アイコン追加

  • 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>
  );
};
 

18-3

音声・ビデオチャンネル追加

作りはテキストチャンネルと同様。

{
  !!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>
  );
}

18-4

メンバーリストの作成

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. チャンネル機能作成

  • リストができたので各機能を作成していく。
  • 作成するものは基本一緒。

19-1

チャンネル削除

チャンネル削除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 });
  }
}