Skip to content

Discordクローン作成 10. チャンネルページ作成

公開日

表紙

20. ページの作成

右側のチャンネルページを作成していく。

20-1

チャンネルページへの遷移

onClickの追加

ServerChannelコンポーネントにonClickを追加する。

このとき、以下のままだとbutton のchildrenにまでonClickが伝搬してしまう。

components/server/server-channel.tsx
export const ServerChannel = ({
  channel,
  server,
  role,
}: ServerChannelProps) => {
 
  const onClick = () => {
    router.push(`/servers/${server.id}/channels/${channel.id}`);
  };
 
  return (
    <button
      onClick={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
              onClick={() => onOpen("editChannel", { server, channel })}
              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
              onClick={() => onOpen("deleteChannel", { server, channel })}
              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>
  );
};
 
  • stopPropagation を使って伝搬を止める。
  • React.MouseEventで取得可能。
Event: stopPropagation() メソッド - Web API | MDN
stopPropagation() は Event インターフェイスのメソッドで、キャプチャおよびバブリング段階において現在のイベントのさらなる伝播を阻止します。しかし、これは既定の動作の発生を妨げるものではありません。例えば、リンクのクリックはまだ処理されます。これらの動作を止めたい場合は、preventDefault() メソッドを参照してください。また、現在の要素における他のイベントハンドラーへの伝搬も防げません。それらを止めたい場合は、stopImmediatePropagation() を参照してください。
Event: stopPropagation() メソッド - Web API | MDN favicon https://developer.mozilla.org/ja/docs/Web/API/Event/stopPropagation
Event: stopPropagation() メソッド - Web API | MDN
components/server/server-channel.tsx
export const ServerChannel = ({
  channel,
  server,
  role,
}: ServerChannelProps) => {
  const { onOpen } = useModal();
  const router = useRouter();
  const params = useParams();
 
  const Icon = iconMap[channel.type];
 
  const onClick = () => {
    router.push(`/servers/${server.id}/channels/${channel.id}`);
  };
 
  const onAction = (e: React.MouseEvent, action: ModalType) => {
    e.stopPropagation();
    onOpen(action, { server, channel });
  };
 
  return (
    <button
      onClick={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
              onClick={(e) => onAction(e, "editChannel")}
              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
              onClick={(e) => onAction(e, "deleteChannel")}
              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>
  );

会話ページへの遷移

メンバー一覧のonClickも追加する。

components/server/server-member.tsx
export const ServerMember = ({ member, server }: ServerMemberProps) => {
  const params = useParams();
  const router = useRouter();
 
  const icon = roleIconMap[member.role];
 
  const onClick = () => {
    router.push(`/servers/${server.id}/conversations/${member.id}`);
  };
 
  return (
    <button
      onClick={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?.memberId === member.id && "bg-zinc-700/20 dark:bg-zinc-700"
      )}
    >

http://localhost:3000/servers/8657074d-22d5-4d0e-8564-99c2a3e4e0b0/conversations/e5f9024e-4388-4910-addf-5a490be02e5bのようなURLになる。

ページ作成

ガワを作製

  • チャンネルページ
    • app/(main)/(routes)/servers/[serverId]/channels/[channelId]/page.tsx
  • 会話ページ
    • app/(main)/(routes)/servers/[serverId]/conversations/[memberId]/page.tsx

サーバ選択時にgeneralを表示

generalを表示するようにサーバのpageを修正する。

app/(main)/(routes)/servers/[serverId]/page.tsx
import { currentProfile } from "@/lib/current-profile";
import { db } from "@/lib/db";
import { redirectToSignIn } from "@clerk/nextjs";
import { redirect } from "next/navigation";
 
interface ServerIdPageProps {
  params: {
    serverId: string;
  };
}
 
const ServerIdPage = async ({ params }: ServerIdPageProps) => {
  const profile = await currentProfile();
  if (!profile) {
    return redirectToSignIn();
  }
 
  const server = await db.server.findUnique({
    where: {
      id: params.serverId,
      members: {
        some: {
          profileId: profile.id,
        },
      },
    },
    include: {
      channels: {
        where: {
          name: "general",
        },
        orderBy: {
          createdAt: "asc",
        },
      },
    },
  });
  const initialChannel = server?.channels[0];
 
  if (initialChannel?.name !== "general") {
    return null;
  }
 
  return redirect(`/servers/${params.serverId}/channels/${initialChannel.id}`);
};
 
export default ServerIdPage;
 

20-2

21. チャットヘッダ

チャットヘッダはレスポンシブ対応でメニューを作製する。

チャットヘッダのレイアウト作製

コンポーネント作成

ヘッダ部を作成する

components/chat/chat-header.tsx
import { Hash, Menu } from "lucide-react";
 
interface ChatHeaderProps {
  serverId: string;
  name: string;
  type: "channel" | "conversation";
  imageUrl?: string;
}
 
export const ChatHeader = ({
  serverId,
  name,
  type,
  imageUrl,
}: ChatHeaderProps) => {
  return (
    <div className="text-md font-semibold px-3 flex items-center h-12 border-neutral-200 dark:border-neutral-800 border-b-2">
      <Menu />
      {type === "channel" && (
        <Hash className="w-5 h-5 text-zinc-500 dark:text-zinc-400 mr-2" />
      )}
      <p className="font-semibold text-md text-black dark:text-white">{name}</p>
    </div>
  );
};
 

ページ側。

/channels/[channelId]/page.tsx
import { ChatHeader } from "@/components/chat/chat-header";
import { currentProfile } from "@/lib/current-profile";
import { db } from "@/lib/db";
import { redirectToSignIn } from "@clerk/nextjs";
import { redirect } from "next/navigation";
 
interface ChannelIdPageProps {
  params: {
    serverId: string;
    channelId: string;
  };
}
 
const ChannelIdPage = async ({ params }: ChannelIdPageProps) => {
  const profile = await currentProfile();
  if (!profile) {
    return redirectToSignIn();
  }
 
  const channel = await db.channel.findUnique({
    where: {
      id: params.channelId,
    },
  });
 
  const member = await db.member.findFirst({
    where: {
      profileId: profile.id,
      serverId: params.serverId,
    },
  });
 
  if (!channel || !member) {
    return redirect("/");
  }
 
  return (
    <div className="bg-white dark:bg-[#313338] flex flex-col h-full">
      <ChatHeader
        serverId={params.serverId}
        name={channel.name}
        type="channel"
      />
    </div>
  );
};
 
export default ChannelIdPage;

20-3

ここからヘッダの実装を行っていく。

ハンバーガーメニューの作成

メニューではshadcn/uiのSheetを使う

Sheet
Extends the Dialog component to display content that complements the main content of the screen.
Sheet favicon https://ui.shadcn.com/docs/components/sheet
Sheet

ここからmobile-toggle.tsxを作成

components/mobile-toggle.tsx
import { NavigationSidebar } from "@/components/navigation/navigation-sidebar";
import { ServerSidebar } from "@/components/server/server-sidebar";
import { Button } from "@/components/ui/button";
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
import { Menu } from "lucide-react";
 
export const MobileToggle = ({ serverId }: { serverId: string }) => {
  return (
    <Sheet>
      <SheetTrigger asChild>
        <Button className="md:hidden" variant={"ghost"} size={"icon"}>
          <Menu />
        </Button>
      </SheetTrigger>
      <SheetContent side={"left"} className="p-0 flex gap-0">
        <div className="w-[72px]">
          <NavigationSidebar />
        </div>
        <ServerSidebar serverId={serverId} />
      </SheetContent>
    </Sheet>
  );
};
 
components/chat/chat-header.tsx
import { MobileToggle } from "@/components/mobile-toggle";
import { Hash } from "lucide-react";
 
interface ChatHeaderProps {
  serverId: string;
  name: string;
  type: "channel" | "conversation";
  imageUrl?: string;
}
 
export const ChatHeader = ({
  serverId,
  name,
  type,
  imageUrl,
}: ChatHeaderProps) => {
  return (
    <div className="text-md font-semibold px-3 flex items-center h-12 border-neutral-200 dark:border-neutral-800 border-b-2">
      <MobileToggle serverId={serverId} />
      {type === "channel" && (
        <Hash className="w-5 h-5 text-zinc-500 dark:text-zinc-400 mr-2" />
      )}
      <p className="font-semibold text-md text-black dark:text-white">{name}</p>
    </div>
  );
};
 

20-4

✖アイコンがプルダウンに被るのが気になるので暫定でコメントアウト(この時点では手順にはない)

components/ui/sheet.tsx
const SheetContent = React.forwardRef<
  React.ElementRef<typeof SheetPrimitive.Content>,
  SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
  <SheetPortal>
    <SheetOverlay />
    <SheetPrimitive.Content
      ref={ref}
      className={cn(sheetVariants({ side }), className)}
      {...props}
    >
      {children}
      {/* <SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
        <X className="h-4 w-4" />
        <span className="sr-only">Close</span>
      </SheetPrimitive.Close> */}
    </SheetPrimitive.Content>
  </SheetPortal>
));

Prismaの修正

  • チャット機能を利用する上で必要なDB情報を追加する。
  • もともとあったテーブルは省略

Message

db/schema.prisma
 
model Message {
  id String @id @default(uuid())
  content String @db.Text
  fileUrl String @db.Text
 
  memberId String
  member Member @relation(fields: [memberId], references: [id], onDelete: Cascade)
 
  channelId String
  channel Channel @relation(fields: [channelId], references: [id], onDelete: Cascade)
 
  deleted Boolean @default(false)
 
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
 
  @@index([memberId])
  @@index([channelId])
 
}

Conversation

  • memberOneId, memberTwoIdでメンバーを管理する。
  • @@unique([memberOneId, memberTwoId]) 指定をすることで、同じメンバーでの会話が重複しないようにする
  • @@index([memberOneId])がなくて良い理由は、@@unique([memberOneId, memberTwoId])があるため。特に記述が見つからないが、配列1件目にはindexが作成されるっぽい(インデックスなしの警告が出ない)
db/schema.prisma
model Conversation {
  id String @id @default(uuid())
 
  memberOneId String
  memberOne Member @relation("MemberOne",fields: [memberOneId], references: [id], onDelete: Cascade)
 
  memberTwoId String
  memberTwo Member @relation("MemberTwo",fields: [memberTwoId], references: [id], onDelete: Cascade)
 
  directMessages DirectMessage[]
 
  @@index([memberTwoId])
 
  @@unique([memberOneId, memberTwoId])
}
  • Memberテーブル側にはconversationInitiatedconversationReceivedを追加する。
db/schema.prisma
model Member {
  id String @id @default(uuid())
  role MemberRole @default(GUEST)
 
  profileId String
  profile Profile @relation(fields: [profileId], references: [id], onDelete: Cascade)
 
  serverId String
  server Server @relation(fields: [serverId], references: [id], onDelete: Cascade)
 
  messages Message[]
  directMessages DirectMessage[]
 
  conversationInitiated Conversation[] @relation("MemberOne")
  conversationReceived Conversation[] @relation("MemberTwo")
 
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
 
  @@index([profileId])
  @@index([serverId])
 
}

DirectMessage

db/schema.prisma
model DirectMessage {
  id String @id @default(uuid())
  content String @db.Text
  fileUrl String @db.Text
 
  conversationId String
  conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
 
  memberId String
  member Member @relation(fields: [memberId], references: [id], onDelete: Cascade)
 
  deleted Boolean @default(false)
 
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
 
  @@index([conversationId])
  @@index([memberId])
}

Prisma Generate

terminal
pnpx prisma generate
pnpx prisma db push