Skip to content

Discordクローン作成 5. サーバー作成用モーダル、サイドバー作成

公開日

表紙

10. サーバ作成モーダル

+ボタンを押すとサーバ作成モーダルが表示されるようにする。

zustandの導入

GitHub - pmndrs/zustand: 🐻 Bear necessities for state management in React
🐻 Bear necessities for state management in React. Contribute to pmndrs/zustand development by creating an account on GitHub.
GitHub - pmndrs/zustand: 🐻 Bear necessities for state management in React favicon https://github.com/pmndrs/zustand
GitHub - pmndrs/zustand: 🐻 Bear necessities for state management in React

状態管理ライブラリとしてzustandを導入する。

zustandのインストール

terminal
pnpm add zustand

hooksの追加

基本的な使い方はこちら

sample
import { create } from 'zustand'
 
const useBearStore = create((set) => ({
  bears: 0,
  increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
  removeAllBears: () => set({ bears: 0 }),
}))

今回はモーダルのストアを作成し、開閉状態と種類を管理。 モーダルタイプを持たせることで一元管理として、モーダルが複数起動するような状態を防ぐ(はず)。今回はまだcreateServerのみ。

hooks\use-modal-store.ts
import { create } from "zustand";
 
export type ModalType = "createServer";
 
interface ModalStore {
  type: ModalType | null;
  isOpen: boolean;
  onOpen: (type: ModalType) => void;
  onClose: () => void;
}
 
export const useModal = create<ModalStore>((set) => ({
  type: null,
  isOpen: false,
  onOpen: (type) => set({ isOpen: true, type: type }),
  onClose: () => set({ isOpen: false, type: null }),
}));
 
 

サーバ作成モーダル

基本構造は components\modals\initial-modal.tsx と同様。変更点のみハイライト。zustandのhooksで状態管理を行う形に修正している。

モーダル本体の作成

components\Modal\CreateServerModal.tsx
"use client";
 
import { useModal } from "@/hooks/use-modal-store";
 
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 = () => {
    onClose();
    form.reset();
  };
 
  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">
            サーバー設定のカスタマイズ
          </DialogTitle>
          <DialogDescription className="text-center text-zinc-500">
            サーバー名とイメージ画像を選択します。これらは後で変更できます。
          </DialogDescription>
        </DialogHeader>
        <Form {...form}>
          <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
{/**(省略)**/}
          </form>
        </Form>
      </DialogContent>
    </Dialog>
  );
};
 

プロバイダを作成

クライアントサイドで動くコンポーネントがサーバサイドレンダリング時に動くとハイドレーションエラーが発生する。プロバイダを作成し、サーバサイドレンダリングが完了したのちに開くようにしていく(同様の問題が発生する場合は、基本的にこの書き方で解消していく)

components\providers\modal-provider.tsx
"use client";
 
import { CreateServerModal } from "@/components/modals/create-server-modal";
import { useEffect, useState } from "react";
 
export const ModalProvider = () => {
  const [isOpen, setIsOpen] = useState(false);
 
  useEffect(() => {
    setIsOpen(true);
  }, []);
 
  if (!isOpen) {
    return null;
  }
 
  return (
    <>
      <CreateServerModal />
    </>
  );
};
 

ルートレイアウトにプロバイダを追加

app\layout.tsx
import { ModalProvider } from "@/components/providers/modal-provider";
 
export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <ClerkProvider>
      <html lang="ja" suppressHydrationWarning>
        <body className={cn(font.className, "bg-white dark:bg-[#313338]")}>
          <ThemeProvider
            attribute="class"
            defaultTheme="dark"
            enableColorScheme={false}
            storageKey="discord-theme"
          >
            <ModalProvider />
            {children}
          </ThemeProvider>
        </body>
      </html>
    </ClerkProvider>
  );
}
 

+ボタンでモーダルを開く

zustandのonOpenを使ってモーダルを開くようにする。これで新しいサーバを追加できるようになる。

components\navigation\navigation-action.tsx
"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="サーバーを追加する">
        <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-hidden 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>
  );
};
 

11. サーバサイドバー

Server Page側を作成。

11-1

レイアウトを作成する。

params からはパスルーティングしているserverIdを取得できるので、自分が所属しているサーバの場合に表示するように制御。

app/(main)/(routes)/servers/[serverId]/layout.tsx
import { currentProfile } from "@/lib/current-profile";
import { db } from "@/lib/db";
import { redirectToSignIn } from "@clerk/nextjs";
import { redirect } from "next/navigation";
 
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,
        },
      },
    },
  });
 
  //所属していないサーバーの場合はトップページにリダイレクトする。
  //所属サーバがある場合は1件目が表示される。
  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"></div>
      <main className="h-full md:pl-60">{children}</main>
    </div>
  );
};
 
export default ServerIdLayout;
 

レイアウトは以下のようになる。

app/(main)/(routes)/servers/[serverId]/index.tsx
  return (
    <div className="h-full">
      <div className="hidden md:flex h-full w-60  z-20 flex-col fixed inset-y-0"></div>
      <main className="h-full md:pl-60">{children}</main>
    </div>
  );

11-2

このサーバサイドバーを実装していく。

サイドバーコンポーネントの実装

サーバ情報の取得

  • プロフィールを取得し、サーバの情報を取得するのは同一。
  • ただしサイドバーコンポーネントでは「表示可能なサーバ」であるチェックは行われている前提なので、ここではwhere句に含めていない。
  • サーバのチャンネルと所属メンバーを取得する。
components/server/server-sidebar.tsx
import { currentProfile } from "@/lib/current-profile";
import { db } from "@/lib/db";
import { redirect } from "next/navigation";
 
interface ServerSidebarProps {
  serverId: string;
}
 
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",
        },
      },
    },
  });
 
  return <div>Server Sidebar Component</div>;
};
 

チャンネルとメンバー取得

サーバ取得した後、チャンネルとメンバーを取得する。

components/server/server-sidebar.tsx
import { ChannelType } from "@prisma/client";
 
export const ServerSidebar = async ({ serverId }: ServerSidebarProps) => {
 
  //===(省略)===
 
  if (!server) {
    return redirect("/");
  }
 
  //チャンネルとメンバーを取得する。
  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
  );
 
  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]">
      Server Sidebar Component
    </div>
  );
};

この時点でレイアウトは以下のようになる。

11-3

サーバヘッダーコンポーネントを作成

サーバヘッダにはServerを渡す。 include で取得しているmember, profileを使うので、型定義を追加する。

components\server\server-sidebar.tsx
  const server = await db.server.findUnique({
    where: {
      id: serverId,
    },
    include: {
      channels: {
        orderBy: {
          createdAt: "asc",
        },
      },
      members: {
        include: {
          profile: true,
        },
        orderBy: {
          role: "asc",
        },
      },
    },
  });
types.ts
import { Member, Profile, Server } from "@prisma/client";
 
export type ServerWithMembersWithProfiles = Server & {
  members: (Member & { profile: Profile })[];
};

この型定義の結果は以下となる。

ServerWithMembersWithProfiles
type ServerWithMembersWithProfiles = {
    id: string;
    name: string;
    imageUrl: string;
    inviteCode: string;
    profileId: string;
    createdAt: Date;
    updatedAt: Date;
} & {
    members: (Member & {
        profile: Profile;
    })[];
}
 

これをサーバヘッダのコンポーネントに適用する。

components\server\server-header.tsx
"use client";
 
import { ServerWithMembersWithProfiles } from "@/types";
import { MemberRole } from "@prisma/client";
 
interface ServerHeaderProps {
  server: ServerWithMembersWithProfiles;
  role?: MemberRole;
}
 
export const ServerHeader = ({ server, role }: ServerHeaderProps) => {
  return <div>Server Header</div>;
};
 
components\server\server-header.tsx
  return (
    <div className="flex flex-col h-full text-primary w-full dark:bg-[#2B2D31] bg-[#F2F3F5]">
      <ServerHeader server={server} role={role} />
    </div>
  );

ドロップダウンメニューの追加

ログイン中のサーバを表示し、ドロップダウンからメンバーの招待・サーバー設定を行えるようにする。

components\server\server-header.tsx
"use client";
 
import { ServerWithMembersWithProfiles } from "@/types";
import { MemberRole } from "@prisma/client";
import {
  DropdownMenu,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ChevronDown } from "lucide-react";
 
interface ServerHeaderProps {
  server: ServerWithMembersWithProfiles;
  role?: MemberRole;
}
 
export const ServerHeader = ({ server, role }: ServerHeaderProps) => {
  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-neu dark:border-neutral-800 border-b-2 hover:bg-zinc-700/10 dark:hover:bg-zinc-700/50 transition">
          {server.name}
          <ChevronDown className="h-5 w-5 ml-auto" />
        </button>
      </DropdownMenuTrigger>
    </DropdownMenu>
  );
};
 

11-4

[Tips] モデレータ

共同管理者のこと。管理者によって任意の操作権限を与える。

ドロップダウンメニューを作成する

  • ユーザーを招待する(モデレータ以上)
  • サーバー設定(管理者のみ)
  • メンバーの管理(管理者のみ)
  • チャンネルの作成(モデレータ以上)
  • サーバーを削除する(管理者のみ)
  • サーバーから退出する(管理者以外)
components\server\server-header.tsx
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
  ChevronDown,
  LogOut,
  PlusCircle,
  Settings,
  Trash,
  UserPlus,
  Users,
} from "lucide-react";
 
export const ServerHeader = ({ server, role }: ServerHeaderProps) => {
  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-neu dark:border-neutral-800 border-b-2 hover:bg-zinc-700/10 dark:hover:bg-zinc-700/50 transition">
          {server.name}
          <ChevronDown className="h-5 w-5 ml-auto" />
        </button>
      </DropdownMenuTrigger>
      <DropdownMenuContent className="w-56 text-xs font-medium text-black dark:text-neutral-400 space-y-[2px]">
        {isModerator && (
          <DropdownMenuItem className="text-indigo-600 dark:text-indigo-400 px-3 py-2 text-sm cursor-pointer">
            ユーザーを招待する
            <UserPlus className="h-4 w-4 ml-auto" />
          </DropdownMenuItem>
        )}
        {isAdmin && (
          <DropdownMenuItem className="px-3 py-2 text-sm cursor-pointer">
            サーバー設定
            <Settings className="h-4 w-4 ml-auto" />
          </DropdownMenuItem>
        )}
        {isAdmin && (
          <DropdownMenuItem className="px-3 py-2 text-sm cursor-pointer">
            メンバーの管理
            <Users className="h-4 w-4 ml-auto" />
          </DropdownMenuItem>
        )}
        {isModerator && (
          <DropdownMenuItem className="px-3 py-2 text-sm cursor-pointer">
            チャンネルの作成
            <PlusCircle className="h-4 w-4 ml-auto" />
          </DropdownMenuItem>
        )}
        {isModerator && <DropdownMenuSeparator />}
        {isAdmin && (
          <DropdownMenuItem className="text-rose-500 px-3 py-2 text-sm cursor-pointer">
            サーバーを削除する
            <Trash className="h-4 w-4 ml-auto" />
          </DropdownMenuItem>
        )}
        {!isAdmin && (
          <DropdownMenuItem className="text-rose-500 px-3 py-2 text-sm cursor-pointer">
            サーバーから退出する
            <LogOut className="h-4 w-4 ml-auto" />
          </DropdownMenuItem>
        )}
      </DropdownMenuContent>
    </DropdownMenu>
  );
};
 

11-5