Skip to content

Discordクローン作成 7. メンバーの管理

公開日

表紙

14. メンバーの管理

サーバに所属するメンバーを管理できるようにしていく。

  • 権限の変更
  • サーバからのキック

モーダル呼び出し部分の実装

ストア、プロバイダにタイプを追加

  • ストアにmembersを追加
  • モーダル本体を作成(招待モーダルコピー)
  • プロバイダにタイプを追加
  • サーバヘッダから呼び出し
hooks/use-modal-store.ts
export type ModalType = "createServer" | "invite" | "editServer" | "members";
hooks/use-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 { MembersModal } from "@/components/modals/members-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 />
      <MembersModal />
    </>
  );
};
components/servers/server-header.tsx
{isAdmin && (
  <DropdownMenuItem
    onClick={() => onOpen("members", { server: server })}
    className="px-3 py-2 text-sm cursor-pointer"
  >
    メンバーの管理
    <Users className="h-4 w-4 ml-auto" />
  </DropdownMenuItem>
)}

この時点でモーダルは呼び出せるので、中身を実装していく

メンバー管理モーダル実装

モーダルの実装

  • DialogDescription でメンバー数を表示
  • ScrollArea でメンバーを表示
  • UserAvatar でアバターを表示 (ここは次に実装)
components/modals/members-modal.tsx
"use client";
 
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogTitle,
} from "@/components/ui/dialog";
 
import { ScrollArea } from "@/components/ui/scroll-area";
import { UserAvatar } from "@/components/user-avatar";
import { useModal } from "@/hooks/use-modal-store";
import { useOrigin } from "@/hooks/use-origin";
import { ServerWithMembersWithProfiles } from "@/types";
 
export const MembersModal = () => {
  const { onOpen, isOpen, onClose, type, data } = useModal();
 
  const origin = useOrigin();
 
  const isModalOpen = isOpen && type === "members";
 
  const { server } = data as { server: ServerWithMembersWithProfiles };
 
  return (
    <Dialog open={isModalOpen} onOpenChange={onClose}>
      <DialogContent className="bg-white text-black overflow-hidden">
        <DialogHeader className="pt-8 px-6">
          <DialogTitle className="text-2xl text-center font-bold">
            メンバーの管理
          </DialogTitle>
          <DialogDescription className="text-center text-zinc-500">
            {server?.members?.length} Members
          </DialogDescription>
        </DialogHeader>
        <ScrollArea className="mt-8 max-h-[420px] pr-6">
          {server?.members?.map((member) => (
            <div
              key={member.id}
              className="flex items-center gap-x-2 mb-6"
            >
              <UserAvatar />
            </div>
          ))}
        </ScrollArea>
      </DialogContent>
    </Dialog>
  );
};

[Tips] 型を明示する

  • モーダルストアではインタフェース上「Server」しか指定していないため、その中にmembersを持っていることを知らない。

14-1

  • 取得処理でas XXXXX で型定義を上書きする。
  • インタフェース側を修正すればいいのでは?とも思うが、そうすると汎用性がなくなるので、こういう方法もある。
const { server } = data as { server: ServerWithMembersWithProfiles };

取得元はサーバサイドバーの以下

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

ユーザーアバターの実装

shadcn/ui のアバターコンポーネントを追加する。

Avatar
An image element with a fallback for representing the user.
Avatar favicon https://ui.shadcn.com/docs/components/avatar
Avatar
terminal
pnpm dlx shadcn-ui@latest add avatar

ユーザーアバターの実装

アバターはclassNameを受け取れるコンポーネントとして作成しておく。

components/user-avatar.tsx
import { Avatar, AvatarImage } from "@/components/ui/avatar";
import { cn } from "@/lib/utils";
 
interface UserAvatarProps {
  src?: string;
  className?: string;
}
 
export const UserAvatar = ({ src, className }: UserAvatarProps) => {
  return (
    <Avatar className={cn("h-7 w-7 md:h-10 md:w-10", className)}>
      <AvatarImage src={src} />
    </Avatar>
  );
};

ロールのアイコン定義

以下の定義を作成し、ユーザー名の横にアイコンを表示する

components/modals/members-modal.tsx
const roleIconMap = {
  GUEST: null,
  MODERATOR: <ShieldCheck className="h-4 w-4  text-indigo-500" />,
  ADMIN: <ShieldAlert className="h-4 w-4  text-rose-500" />,
};

スクロールエリアにアバターが表示される形に更新

components/modals/members-modal.tsx
<ScrollArea className="mt-8 max-h-[420px] pr-6">
  {server?.members?.map((member) => (
    <div key={member.id} className="flex items-center gap-x-2 mb-6">
      <UserAvatar src={member.profile.imageUrl} />
      <div className="flex flex-col gap-y-1">
        <div className="text-xs font-semibold flex items-center gap-x-1">
          {member.profile.name}
          {roleIconMap[member.role]}
        </div>
        <p className="text-xs text-zinc-500">{member.profile.email}</p>
      </div>
    </div>
  ))}
</ScrollArea>

14-2

メンバーへのアクション追加

管理者(自分)は、メンバーに対するアクションを行える。

components/modals/members-modal.tsx
const [loadingId, setLoadingId] = useState("");
 
<ScrollArea className="mt-8 max-h-[420px] pr-6">
  {server?.members?.map((member) => (
    <div key={member.id} className="flex items-center gap-x-2 mb-6">
      <UserAvatar src={member.profile.imageUrl} />
      <div className="flex flex-col gap-y-1">
        <div className="text-xs font-semibold flex items-center gap-x-1">
          {member.profile.name}
          {roleIconMap[member.role]}
        </div>
        <p className="text-xs text-zinc-500">{member.profile.email}</p>
      </div>
      {server.profileId !== member.profileId &&
        loadingId !== member.id && (
          <div className="ml-auto">Actions</div>
        )}
    </div>
  ))}
</ScrollArea>

ここからActionsの内容を実装していく。

権限エリア

  • ドロップダウンメニューからそのユーザーに対して実行できる操作を選択できるようにする。
  • loadingId !== member.id であるときのみ、ドロップダウンが表示されるようにする
  • 後続処理で loadingId === member.id でスピナー表示を行う。
components/modals/members-modal.tsx
<ScrollArea className="mt-8 max-h-[420px] pr-6">
  {server?.members?.map((member) => (
    <div key={member.id} className="flex items-center gap-x-2 mb-6">
      <UserAvatar src={member.profile.imageUrl} />
      <div className="flex flex-col gap-y-1">
        <div className="text-xs font-semibold flex items-center gap-x-1">
          {member.profile.name}
          {roleIconMap[member.role]}
        </div>
        <p className="text-xs text-zinc-500">{member.profile.email}</p>
      </div>
      {server.profileId !== member.profileId &&
        loadingId !== member.id && (
          <div className="ml-auto">
            <DropdownMenu>
              <DropdownMenuTrigger>
                <MoreVertical className="h-4 w-4 text-zinc-500" />
              </DropdownMenuTrigger>
              <DropdownMenuContent side="left">
                <DropdownMenuSub>
                  <DropdownMenuSubTrigger className="flex items-center">
                    <ShieldQuestion className="w-4 h-4 mr-2" />
                    <span>権限</span>
                  </DropdownMenuSubTrigger>
                  <DropdownMenuPortal>
                    <DropdownMenuSubContent>
                      <DropdownMenuItem>
                        <Shield className="w-4 h-4 mr-2" />
                        ゲスト
                        {member.role === "GUEST" && (
                          <Check className="w-4 h-4 ml-auto" />
                        )}
                      </DropdownMenuItem>
                      <DropdownMenuItem>
                        <ShieldCheck className="w-4 h-4 mr-2" />
                        モデレータ
                        {member.role === "MODERATOR" && (
                          <Check className="w-4 h-4 ml-auto" />
                        )}
                      </DropdownMenuItem>
                    </DropdownMenuSubContent>
                  </DropdownMenuPortal>
                </DropdownMenuSub>
                <DropdownMenuSeparator />
              </DropdownMenuContent>
            </DropdownMenu>
          </div>
        )}
    </div>
  ))}
</ScrollArea>

この時点でドロップダウンメニューの権限部分が表示できる。

14-3

query-stringの導入

権限更新ではクエリパラメータを用いるため、query-stringを導入する。

GitHub - sindresorhus/query-string: Parse and stringify URL query strings
Parse and stringify URL query strings. Contribute to sindresorhus/query-string development by creating an account on GitHub.
GitHub - sindresorhus/query-string: Parse and stringify URL query strings favicon https://github.com/sindresorhus/query-string
GitHub - sindresorhus/query-string: Parse and stringify URL query strings
terminal
pnpm add query-string

権限変更イベント

  • メンバーに対する権限であるので、メンバーAPIを利用する。
  • サーバIDはクエリパラメータで渡す形とする(APIの形を汚さないため?)。
  • axiosから変更対象のロールを送信する。
  • router.refresh() でページをリフレッシュ
  • onOpen("members", { server: response.data }) でモーダルをリロード
components/modals/members-modal.tsx
const onRoleChange = async (memberId: string, role: MemberRole) => {
  try {
    setLoadingId(memberId);
    const url = qs.stringifyUrl({
      url: `/api/members/${memberId}`,
      query: {
        serverId: server.id,
      },
    });
 
    const response = await axios.patch(url, { role });
 
    router.refresh();
    onOpen("members", { server: response.data });
  } catch (error) {
    console.error(error);
  } finally {
    setLoadingId("");
  }
};

権限変更APIの追加

  • パラメータを元に権限を変更する。
  • 変更したあとのサーバ情報を返却する(というのもあってServerを軸にしている)
  • 更新対象のメンバーのprofileIdが自分のものでないことを保障することで、管理者が意図せず更新されてしまうことを防いでいる。

APIのパラメータ取得方法はRoute Handlersの説明を参照

Routing: Route Handlers | Next.js
Create custom request handlers for a given route using the Web's Request and Response APIs.
Routing: Route Handlers | Next.js favicon https://nextjs.org/docs/app/building-your-application/routing/route-handlers#dynamic-route-segments
Routing: Route Handlers | Next.js
app/api/members/[memberId]/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: { memberId: string } }
) {
  try {
    const profile = await currentProfile();
    const { searchParams } = new URL(req.url);
    const { role } = await req.json();
 
    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.memberId) {
      return new NextResponse("Member ID Missing", { status: 400 });
    }
 
    const server = await db.server.update({
      where: {
        id: serverId,
        profileId: profile.id,
      },
      data: {
        members: {
          update: {
            where: {
              id: params.memberId,
              profileId: {
                not: profile.id,
              },
            },
            data: {
              role: role,
            },
          },
        },
      },
      include: {
        members: {
          include: {
            profile: true,
          },
          orderBy: {
            role: "asc",
          },
        },
      },
    });
 
    return NextResponse.json(server);
  } catch (error) {
    console.error("[MEMBERS_ID_PATCH]", error);
    return new NextResponse("Internal Server Error", { status: 500 });
  }
}
 

キック用APIの追加

app/api/members/[memberId]/route.ts
export async function DELETE(
  req: Request,
  { params }: { params: { memberId: 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.memberId) {
      return new NextResponse("Member ID Missing", { status: 400 });
    }
 
    const server = await db.server.update({
      where: {
        id: serverId,
        profileId: profile.id,
      },
      data: {
        members: {
          delete: {
            id: params.memberId,
            profileId: {
              not: profile.id,
            },
          },
        },
      },
      include: {
        members: {
          include: {
            profile: true,
          },
          orderBy: {
            role: "asc",
          },
        },
      },
    });
 
    return NextResponse.json(server);
  } catch (error) {
    console.error("[MEMBERS_ID_DELETE]", error);
    return new NextResponse("Internal Server Error", { status: 500 });
  }
}