- 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. リアルタイムチャット
10. サーバ作成モーダル
+ボタンを押すとサーバ作成モーダルが表示されるようにする。
zustandの導入
状態管理ライブラリとしてzustandを導入する。
zustandのインストール
pnpm add zustand
hooksの追加
基本的な使い方はこちら
import { create } from 'zustand'
const useBearStore = create((set) => ({
bears: 0,
increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
removeAllBears: () => set({ bears: 0 }),
}))
今回はモーダルのストアを作成し、開閉状態と種類を管理。 モーダルタイプを持たせることで一元管理として、モーダルが複数起動するような状態を防ぐ(はず)。今回はまだcreateServerのみ。
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で状態管理を行う形に修正している。
モーダル本体の作成
"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>
);
};
プロバイダを作成
クライアントサイドで動くコンポーネントがサーバサイドレンダリング時に動くとハイドレーションエラーが発生する。プロバイダを作成し、サーバサイドレンダリングが完了したのちに開くようにしていく(同様の問題が発生する場合は、基本的にこの書き方で解消していく)
"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 />
</>
);
};
ルートレイアウトにプロバイダを追加
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を使ってモーダルを開くようにする。これで新しいサーバを追加できるようになる。
"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側を作成。
レイアウトを作成する。
params
からはパスルーティングしているserverIdを取得できるので、自分が所属しているサーバの場合に表示するように制御。
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;
レイアウトは以下のようになる。
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>
);
このサーバサイドバーを実装していく。
サイドバーコンポーネントの実装
サーバ情報の取得
- プロフィールを取得し、サーバの情報を取得するのは同一。
- ただしサイドバーコンポーネントでは「表示可能なサーバ」であるチェックは行われている前提なので、ここではwhere句に含めていない。
- サーバのチャンネルと所属メンバーを取得する。
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>;
};
チャンネルとメンバー取得
サーバ取得した後、チャンネルとメンバーを取得する。
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>
);
};
この時点でレイアウトは以下のようになる。
サーバヘッダーコンポーネントを作成
サーバヘッダにはServerを渡す。
include
で取得しているmember, profileを使うので、型定義を追加する。
const server = await db.server.findUnique({
where: {
id: serverId,
},
include: {
channels: {
orderBy: {
createdAt: "asc",
},
},
members: {
include: {
profile: true,
},
orderBy: {
role: "asc",
},
},
},
});
import { Member, Profile, Server } from "@prisma/client";
export type ServerWithMembersWithProfiles = Server & {
members: (Member & { profile: Profile })[];
};
この型定義の結果は以下となる。
type ServerWithMembersWithProfiles = {
id: string;
name: string;
imageUrl: string;
inviteCode: string;
profileId: string;
createdAt: Date;
updatedAt: Date;
} & {
members: (Member & {
profile: Profile;
})[];
}
これをサーバヘッダのコンポーネントに適用する。
"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>;
};
return (
<div className="flex flex-col h-full text-primary w-full dark:bg-[#2B2D31] bg-[#F2F3F5]">
<ServerHeader server={server} role={role} />
</div>
);
ドロップダウンメニューの追加
ログイン中のサーバを表示し、ドロップダウンからメンバーの招待・サーバー設定を行えるようにする。
"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>
);
};
[Tips] モデレータ
共同管理者のこと。管理者によって任意の操作権限を与える。
ドロップダウンメニューを作成する
- ユーザーを招待する(モデレータ以上)
- サーバー設定(管理者のみ)
- メンバーの管理(管理者のみ)
- チャンネルの作成(モデレータ以上)
- サーバーを削除する(管理者のみ)
- サーバーから退出する(管理者以外)
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>
);
};