- 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. リアルタイムチャット
12. 招待機能
- 招待リンクを生成し、そこからメンバーをサーバに招待できるように実装していく。
- モーダルの状態はまとめてストアで管理(zustand)。これにより多重起動を防ぐ。
モーダルストアの修正
ストアにdataを追加
モーダルストアのフックにdata
を追加し、サーバ情報をモーダルに渡せるようにする。
data
でサーバー情報を受け取れる形に修正を行う。createServer
では使わないのでオプショナルとする。onOpen
でdata
にもセットする
hooks/use-modal-store.ts
import { Server } from "@prisma/client";
import { create } from "zustand";
export type ModalType = "createServer" | "invite";
interface ModalData {
server?: Server;
}
interface ModalStore {
type: ModalType | null;
data: ModalData;
isOpen: boolean;
onOpen: (type: ModalType, data?: ModalData) => void;
onClose: () => void;
}
export const useModal = create<ModalStore>((set) => ({
type: null,
data: {},
isOpen: false,
onOpen: (type, data = {}) => set({ isOpen: true, type: type, data }),
onClose: () => set({ isOpen: false, type: null }),
}));
サーバヘッダー部にモーダルを開く機能追加
- 「ユーザーを招待する」からモーダルが開くように修正
components/server/server-header.tsx
export const ServerHeader = ({ server, role }: ServerHeaderProps) => {
const { onOpen } = useModal();
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
onClick={() => onOpen("invite", { server: server })}
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>
)}
招待用モーダル
招待用モーダルコンポーネントの作成
create-server-modal
をコピーしてinvite-modal
を作成する。isModalOpen
にinvite
を指定する。
components/modals/invite-modal.tsx
"use client";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useModal } from "@/hooks/use-modal-store";
export const InviteModal = () => {
const { isOpen, onClose, type } = useModal();
const isModalOpen = isOpen && type === "invite";
return (
<Dialog open={isModalOpen} onOpenChange={onClose}>
<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>
ユーザーを招待する
</DialogContent>
</Dialog>
);
};
招待用モーダルをモーダルプロバイダに追加
- 「モーダル」など機能ごとのコンポーネントはこのように一元管理すると良さげ。
components/providers/modal-provider.tsx
"use client";
import { CreateServerModal } from "@/components/modals/create-server-modal";
import { InviteModal } from "@/components/modals/invite-modal";
import { useEffect, useState } from "react";
export const ModalProvider = () => {
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
setIsOpen(true);
}, []);
if (!isOpen) {
return null;
}
return (
<>
<CreateServerModal />
<InviteModal />
</>
);
};
モーダルの起動を確認しておく
招待モーダルの機能を実装
モーダル表示までできたので機能を作成していく。
- 招待用リンクのinputフォームを作成する
- 新しい招待用リンクの生成ボタンを作成する
components/modals/invite-modal.tsx
"use client";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useModal } from "@/hooks/use-modal-store";
import { Copy, RefreshCcw } from "lucide-react";
export const InviteModal = () => {
const { isOpen, onClose, type } = useModal();
const isModalOpen = isOpen && type === "invite";
return (
<Dialog open={isModalOpen} onOpenChange={onClose}>
<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>
</DialogHeader>
<div className="p-6">
<Label className="uppercase text-xs font-bold text-zinc-500 dark:text-secondary/70">
サーバー招待用リンク
</Label>
<div className="flex items-center mt-2 gap-x-2">
<Input
className="bg-zinc-300/50 border-0 focus-visible:ring-0 text-black focus-visible:ring-offset-0"
value="招待用リンク"
/>
<Button size="icon">
<Copy className="w-4 h-4" />
</Button>
</div>
<Button
variant={"link"}
size="sm"
className="text-xs text-zinc-500 mt-4"
>
新しい招待用リンクを生成する
<RefreshCcw className="w-4 h-4 ml-2" />
</Button>
</div>
</DialogContent>
</Dialog>
);
};
windowオブジェクトのフックを作成
- サーバURLを取得するためwindowオブジェクトを利用する
- クライアント側で動くので、右に倣えでマウントを待って(=クライアントサイドで)実行するようにフックを作成する。
window.locationは以下情報を持つ
window.location
{
"ancestorOrigins": {},
"href": "http://localhost:3000/servers/ecde1899-a955-448d-8539-d152cbfafa1c",
"origin": "http://localhost:3000",
"protocol": "http:",
"host": "localhost:3000",
"hostname": "localhost",
"port": "3000",
"pathname": "/servers/ecde1899-a955-448d-8539-d152cbfafa1c",
"search": "",
"hash": ""
}
フックを作成。 useXXXはクライアントでしか動かないので、サーバサイドレンダリング時には動かない。
hooks/use-origin.ts
import { useEffect, useState } from "react";
export const useOrigin = () => {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const origin = typeof window !== "undefined" ? window.location.origin : "";
if (!mounted) {
return "";
}
return origin;
};
招待用リンクの実装
inviteURL
は以下のようになる。
http://localhost:3000/invite/179cc4c8-1ffb-4b3f-aa30-211be2f49845
origin
+ server.inviteCode
でこれを実装していく。
components/modals/invite-modal.tsx
export const InviteModal = () => {
const { isOpen, onClose, type, data } = useModal();
const origin = useOrigin();
const isModalOpen = isOpen && type === "invite";
const server = data?.server;
const inviteUrl = `${origin}/invite/${server?.inviteCode}`;
コピーボタン機能実装
- コピーボタン押下で
onCopy
を実行 onCopy
でクリップボードにinviteUrl
をコピーする- コピー完了後に1秒後にコピー完了を解除する。これにより「コピー実行」が視覚的にわかるように制御する。
components/modals/invite-modal.tsx
const onCopy = () => {
navigator.clipboard.writeText(inviteUrl);
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 1000);
};
/**~~~~~~~~~~**/
<Button onClick={onCopy} size="icon">
{copied ? (
<Check className="w-4 h-4" />
) : (
<Copy className="w-4 h-4" />
)}
</Button>
招待リンク生成処理の実装
- APIでサーバ情報が持つ招待リンクを更新する
- 更新後、招待モーダルの
onOpen
を再度実行することで開いているモーダルを破棄し、生成後のURLを持つモーダルを再生成する。 - 実行中は
isLoading
をtrueにしてイベント実行を抑止する(ボタンなどをdisabledにする)
components/modals/invite-modal.tsx
const onNew = async () => {
try {
setIsLoading(true);
const response = await axios.patch(
`/api/servers/${server?.id}/invite-code`
);
//生成し直した招待用リンクを開く
onOpen("invite", { server: response.data });
} catch (error) {
console.error(error);
} finally {
setIsLoading(false);
}
};
最終的には以下のようになる。
components/modals/invite-modal.tsx
"use client";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useModal } from "@/hooks/use-modal-store";
import { useOrigin } from "@/hooks/use-origin";
import axios from "axios";
import { Check, Copy, RefreshCcw } from "lucide-react";
import { useState } from "react";
export const InviteModal = () => {
const { onOpen, isOpen, onClose, type, data } = useModal();
const [copied, setCopied] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const origin = useOrigin();
const isModalOpen = isOpen && type === "invite";
const server = data?.server;
const inviteUrl = `${origin}/invite/${server?.inviteCode}`;
const onCopy = () => {
navigator.clipboard.writeText(inviteUrl);
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 1000);
};
const onNew = async () => {
try {
setIsLoading(true);
const response = await axios.patch(
`/api/servers/${server?.id}/invite-code`
);
//生成し直した招待用リンクを開く
onOpen("invite", { server: response.data });
} catch (error) {
console.error(error);
} finally {
setIsLoading(false);
}
};
return (
<Dialog open={isModalOpen} onOpenChange={onClose}>
<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>
</DialogHeader>
<div className="p-6">
<Label className="uppercase text-xs font-bold text-zinc-500 dark:text-secondary/70">
サーバー招待用リンク
</Label>
<div className="flex items-center mt-2 gap-x-2">
<Input
disabled={isLoading}
className="bg-zinc-300/50 border-0 focus-visible:ring-0 text-black focus-visible:ring-offset-0"
value={inviteUrl}
/>
<Button disabled={isLoading} onClick={onCopy} size="icon">
{copied ? (
<Check className="w-4 h-4" />
) : (
<Copy className="w-4 h-4" />
)}
</Button>
</div>
<Button
onClick={onNew}
disabled={isLoading}
variant={"link"}
size="sm"
className="text-xs text-zinc-500 mt-4"
>
新しい招待用リンクを生成する
<RefreshCcw className="w-4 h-4 ml-2" />
</Button>
</div>
</DialogContent>
</Dialog>
);
};
招待リンク生成API
- サーバはユーザ(Profile)に紐づく形のため、更新できるのは作成したユーザのみ。ログインユーザ情報を使うことでこれを実現する。
- サーバ側から見てMemberを作成する(ここはPrismaのORMらしい使い方)
- Memberはゲストとして作成される。
app/api/servers/[serverId]/invite-code/route.ts
import { currentProfile } from "@/lib/current-profile";
import { db } from "@/lib/db";
import { redirectToSignIn } from "@clerk/nextjs";
import { redirect } from "next/navigation";
interface InviteCodePageProps {
params: {
inviteCode: string;
};
}
const InviteCodePage = async ({ params }: InviteCodePageProps) => {
const profile = await currentProfile();
if (!profile) {
return redirectToSignIn();
}
if (!params.inviteCode) {
return redirect("/");
}
//サーバに所属しているか確認
const existingServer = await db.server.findFirst({
where: {
inviteCode: params.inviteCode,
members: {
some: {
profileId: profile.id,
},
},
},
});
if (existingServer) {
return redirect(`/servers/${existingServer.id}`);
}
//サーバから見てMemberのUpdate(create)を実行する
const server = await db.server.update({
where: {
inviteCode: params.inviteCode,
},
data: {
members: {
create: {
profileId: profile.id,
},
},
},
});
if (server) {
return redirect(`/servers/${server.id}`);
}
return null;
};
export default InviteCodePage;
招待用リンクからユーザを招待
DBの誤り修正
- ServerのinviteCodeをuniqueに更新。
- Serverを軸として所属メンバーの更新をかける際、ユニークキーである必要があるため。
prisma/schema.prisma
model Server {
id String @id @default(uuid())
name String
imageUrl String @db.Text
inviteCode String @unique
profileId String
profile Profile @relation(fields: [profileId], references: [id], onDelete: Cascade)
members Member[]
channels Channel[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index ([profileId])
}
DBの差し替えを行う。。
更新に失敗する場合は pnpx prisma migrate reset
にてデータを一度消してから更新する(特に支障がないなら消してからやった方が無難)。
terminal
pnpx prisma migrate reset
pnpx prisma generate
pnpx prisma db push
これで招待用リンクからユーザを招待できるようになった。 (ユーザ自体が未登録だと場合だと遷移時にエラーが出るけど、たぶん軽微なバグ)
13.サーバ設定機能
サーバ設定モーダルの作成
サーバ設定モーダルを作成していく。
モーダルストアに editServer
を追加
hooks/use-modal-store.ts
export type ModalType = "createServer" | "invite" | "editServer";
コンポーネントの追加
create-server-modal.tsx
をコピーしてedit-server-modal.tsx
を作成。- ひとまず名前とType変えるだけ
components/modals/edit-server-modal.tsx
export const EditServerModal = () => {
const { isOpen, onClose, type } = useModal();
const router = useRouter();
const isModalOpen = isOpen && type === "editServer";
/** 省略 **/
};
モーダルプロバイダに追加
components/providers/modal-provider.tsx
"use client";
import { CreateServerModal } from "@/components/modals/create-server-modal";
import { EditServerModal } from "@/components/modals/edit-server-modal";
import { InviteModal } from "@/components/modals/invite-modal";
import { useEffect, useState } from "react";
export const ModalProvider = () => {
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
setIsOpen(true);
}, []);
if (!isOpen) {
return null;
}
return (
<>
<CreateServerModal />
<InviteModal />
<EditServerModal />
</>
);
};
サーバヘッダーに設定ボタンを追加
components/server/server-header.tsx
<DropdownMenuItem
onClick={() => onOpen("editServer", { server: server })}
className="px-3 py-2 text-sm cursor-pointer"
>
サーバー設定
<Settings className="h-4 w-4 ml-auto" />
</DropdownMenuItem>
サーバ設定モーダルに初期値を与える
- useEffectでDBのデータをreact-hook-formの初期値としてセットする。
- こうすることでモーダル側に選択したサーバ設定が反映される
components/modals/edit-server-modal.tsx
//初期値を設定
useEffect(() => {
if (server) {
// form.setValue("name", server.name);
// form.setValue("imageUrl", server.imageUrl);
form.reset({
name: server.name,
imageUrl: server.imageUrl,
});
}
}, [server, form]);
変更ボタンの実装
- submot時の apiを
post
からpatch
に変更する - serverのidを特定できるようにする
components/modals/edit-server-modal.tsx
const onSubmit = async (values: z.infer<typeof formSchema>) => {
try {
await axios.patch(`/api/servers/${server?.id}`, values);
//初期化
form.reset();
router.refresh();
onClose();
} catch (error) {
console.error(error);
}
};
APIの作成
- サーバの更新を行うAPIを作成する
- 内容はcreateとほとんど変わらず。
app/api/servers/[serverId]/route.ts
import { currentProfile } from "@/lib/current-profile";
import { db } from "@/lib/db";
import { NextResponse } from "next/server";
export async function PATCH(
req: Request,
{ params }: { params: { serverId: string } }
) {
try {
const profile = await currentProfile();
const { name, imageUrl } = await req.json();
if (!profile) {
return new NextResponse("Unauthorized", { status: 401 });
}
const server = await db.server.update({
where: {
id: params.serverId,
profileId: profile.id,
},
data: {
name,
imageUrl,
},
});
return NextResponse.json(server);
} catch (error) {
console.error("[SERVERS_ID_PATCH]", error);
return new NextResponse("Internal Server Error", { status: 500 });
}
}