Skip to content

Discordクローン作成 4. サーバ作成, ナビゲーション

公開日|更新日

表紙

8. サーバの作成

7-7 ここのcreateの処理を作成していく

サーバ作成API呼び出し実装

どこかでfetchの方がいいと見かけた記憶があるのだけど、パフォーマンス上問題ないならaxiosの方が楽(なはず)。

fetch APIとaxiosについて - Qiita
fetch APIとは?fetch APIは、ブラウザに標準で実装されたWeb APIであり、HTTPリクエストを簡単に処理することができます。fetch APIは低レベルなAPIであり、リクエ…
fetch APIとaxiosについて - Qiita favicon https://qiita.com/manzoku_bukuro/items/3e5bb0a678ebe7a2d2c2
fetch APIとaxiosについて - Qiita

Axiosのインストール

terminal
pnpm add axios

Submit処理の実装

以下のonSubmit処理をAxiosを利用して実装していく

components/modals/initial-modal.tsx
  const onSubmit = async (values: z.infer<typeof formSchema>) => {
    console.log(values);
  };
components/modals/initial-modal.tsx
"use client";
 
import axios from "axios";
import { useRouter } from "next/navigation";
 
~~~
 
export const InitialModal = () => {
  const [isMounted, setIsMounted] = useState(false);
  const router = useRouter();
 
  useEffect(() => {
    setIsMounted(true);
  }, []);
 
  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();
      window.location.reload();
    } catch (error) {
      console.error(error);
    }
  };
 
~~~

[Tips]useRouterのrefreshとwindow.location.reload

refreshだけでいいのでは?と思ったので聞いてみるも、正確なところはわからず。refreshすることで「サーバがある状態」でデータ更新をするので、作成したサーバに入った状態とできるはず。

  1. router.refresh();:これはNext.jsのルーターを使用して現在のページをリフレッシュします。これにより、サーバーサイドで生成されるデータが更新されます。
  2. window.location.reload();:これはブラウザのJavaScript APIを使用してページ全体をリロードします。これにより、クライアントサイドの状態がリセットされ、ページ全体が再読み込みされます。

ただし、これら2つの行は似た動作をするため、通常はどちらか一方だけを使用します。両方を使用すると、ページが2回リロードされる可能性があります。

追記

サーバ削除処理においてwindow.location.reloadをつけないとリフレッシュが効かなかった。router.refreshだけだと効かないパターンがあるらしい(ここはまだ理解が浅い)

[Tips]react-hook-formのreset

必ず空にする、というわけではないので関数の使い方には注意が必要

reset
Performant, flexible and extensible forms with easy-to-use validation.
reset favicon https://react-hook-form.com/docs/useform/reset
reset

サーバ作成API

プロフィール取得ライブラリの作成

ログインユーザの情報を取得するためのライブラリを作成する。

lib/current-profile.ts
import { db } from "@/lib/db";
import { auth } from "@clerk/nextjs";
 
export const currentProfile = async () => {
  const { userId } = auth();
  if (!userId) {
    return null;
  }
  const profile = await db.profile.findUnique({
    where: {
      userId,
    },
  });
 
  return profile;
};
 

uuidのインストール

serverテーブルのidを作成するためにuuidをインストールする。

terminal
pnpm add uuid
 
pnpm add -D @types/uuid

[Tips] プライマリキーをUUIDにする場合の注意点

普通に使ってる分には問題なさそうだけど、SNSのPOSTなどでUUIDを使う場合は速度面への考慮が必要。以下が詳しそう(全部読めていない)。

MySQLでプライマリキーをUUIDにする前に知っておいて欲しいこと | Raccoon Tech Blog [株式会社ラクーンホールディングス 技術戦略部ブログ]
今回は MySQL のプライマリキーに UUID を採用する場合に起きるパフォーマンスの問題を仕組みから解説します。MySQL(InnoDB) & UUID のパフォーマンスについては各所でさんざん議論・検証されていますが、論理的に解説した記事が少なかったり一部には誤解を招くようなものもあるため、しっかりと理由から理解するための情報として役立つことができればと思っています。UUID と比較...
MySQLでプライマリキーをUUIDにする前に知っておいて欲しいこと | Raccoon Tech Blog [株式会社ラクーンホールディングス 技術戦略部ブログ] favicon https://techblog.raccoon.ne.jp/archives/1627262796.html
MySQLでプライマリキーをUUIDにする前に知っておいて欲しいこと | Raccoon Tech Blog [株式会社ラクーンホールディングス 技術戦略部ブログ]

UUIDには種類があって、UUID v7から時系列に基づいたUUIDが作成される。これによって上記のパフォーマンス問題が解消される(らしい)。まだドラフト?

UUIDとULIDの違いと種類を解説【ULID=ソート可能なUUID?】|東京のWEB制作会社・ホームページ制作会社|株式会社GIG
UUIDとULIDの違いと種類を解説【ULID=ソート可能なUUID?】|東京のWEB制作会社・ホームページ制作会社|株式会社GIG favicon https://giginc.co.jp/blog/giglab/uuid-ulid
UUIDとULIDの違いと種類を解説【ULID=ソート可能なUUID?】|東京のWEB制作会社・ホームページ制作会社|株式会社GIG

サーバ作成APIの実装

  • uuidはv4。
  • サーバ作成時にgeneralチャンネルと管理者(自分)を追加する。
app/api/servers/route.ts
import { v4 as uuidv4 } from "uuid";
 
import { currentProfile } from "@/lib/current-profile";
import { db } from "@/lib/db";
import { MemberRole } from "@prisma/client";
import { NextResponse } from "next/server";
 
export async function POST(req: Request) {
  try {
    const { name, imageUrl } = await req.json();
    const profile = await currentProfile();
 
    if (!profile) {
      return new NextResponse("Unauthorized", { status: 401 });
    }
 
    const server = await db.server.create({
      data: {
        profileId: profile.id,
        name,
        imageUrl,
        inviteCode: uuidv4(),
        channels: {
          create: [
            {
              name: "general",
              profileId: profile.id,
            },
          ],
        },
        members: {
          create: {
            profileId: profile.id,
            role: MemberRole.ADMIN,
          },
        },
      },
    });
 
    return NextResponse.json(server);
  } catch (error) {
    console.error("[SERVERS_POST]", error);
    return new NextResponse("Internal Server Error", { status: 500 });
  }
}
 

8-1

作成時にmemberとchannelに追加されることが確認できる。

8-2

リダイレクト

router.refresh()を行うとサーバが作成された状態となるので、サーバページにリダイレクトする(この時点では未作成なので404エラー)

app/(setup)/page.tsx
import { redirect } from "next/navigation";
 
import { db } from "@/lib/db";
import { initialProfile } from "@/lib/initial-profile";
import { InitialModal } from "@/components/modals/initial-modal";
 
const SetupPage = async () => {
  //プロフィールを取得する
  const profile = await initialProfile();
 
  //自分が所属するサーバから1件目を取得する
  const server = await db.server.findFirst({
    where: {
      members: {
        some: {
          profileId: profile.id,
        },
      },
    },
  });
  //サーバが存在する場合はそのサーバにリダイレクトする
  if (server) {
    return redirect(`/servers/${server.id}`);
  }
 
  return <InitialModal />;
};
 
export default SetupPage;
 

9. ナビゲーション

ナビゲーションバーを作成する

コンポーネントの作成

components/navigation/navigation-sidebar.tsx
export const NavigationSidebar = () => {
  return <div>Navigation Sidebar</div>;
};

レイアウトの設定

  • md以上の場合に表示されるようにする
  • サイドバーの幅は72px
  • inset-y-0 は左側固定。tailwindのレイアウト指定は以下の通り(inset-y-0なら04のレイアウト。)

9-1 https://tailwindcss.com/docs/top-right-bottom-left

app/(main)/layout.tsx
import { NavigationSidebar } from "@/components/navigation/navigation-sidebar";
 
const MainLayout = async ({ children }: { children: React.ReactNode }) => {
  return (
    <div className="h-full">
      <div className="hidden md:flex h-full w-[72px] z-30 flex-col fixed inset-y-0">
        <NavigationSidebar />
      </div>
      <main className="md:pl-[72px] h-full">{children}</main>
    </div>
  );
};
 
export default MainLayout;
 

ナビゲーションバーの中身を実装

ツールチップとセパレータの導入

shadcn/uiから導入 https://ui.shadcn.com/docs/components/tooltip

terminal
pnpm dlx shadcn-ui@latest add tooltip
pnpm dlx shadcn-ui@latest add separator
 

ナビゲーションアクションコンポーネント

クライアントサイドのアクションがあるので分割して作成していく。

  • navigation-sidebar
    • profileserversを取得する
    • NavigationActionコンポーネントを表示する
  • navigation-action
    • プラスボタンを表示する。
    • hover時のアクションはgroup指定で実施。
components/navigation/navigation-sidebar.tsx
import { currentProfile } from "@/lib/current-profile";
import { db } from "@/lib/db";
import { redirect } from "next/navigation";
 
export const NavigationSidebar = async () => {
  const profile = await currentProfile();
 
  if (!profile) {
    return redirect("/");
  }
 
  const servers = await db.server.findMany({
    where: {
      members: {
        some: {
          profileId: profile.id,
        },
      },
    },
  });
 
  return (
    <div className="space-y-4 flex flex-col items-center h-full text-primary w-full dark:bg-[#1E1F22] py-3">
      <NavigationAction />
    </div>
  );
};
 

group指定でhover時のアクションを実施する。group-hoverとすることによって複数要素を同じタイミングで変更できる。

Handling Hover, Focus, and Other States - Tailwind CSS
Using utilities to style elements on hover, focus, and more.
Handling Hover, Focus, and Other States - Tailwind CSS favicon https://tailwindcss.com/docs/hover-focus-and-other-states#styling-based-on-parent-state
Handling Hover, Focus, and Other States - Tailwind CSS
components/navigation/navigation-action.tsx
 
"use client";
 
import { Plus } from "lucide-react";
 
export const NavigationAction = () => {
  return (
    <div>
      <button 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>
    </div>
  );
};
 

こんなアニメーション。

9-2

[Tips]transition-allとtransitionの違い

transition-all を指定した箇所で transition を指定しても同じ動きをしている。cssで見ると一緒っぽい(transition-propertyに全種類入ってるのかは知らないけど……)

transition-all
.transition-all {
    transition-property: all;
    transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
    transition-duration: 150ms;
}
transition
.transition {
    transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter;
    transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
    transition-duration: 150ms;
}

アクション用ツールチップの作成

プラスボタンで表示されるツールチップを作成する。

components/action-tooltip.tsx
"use client";
 
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from "@/components/ui/tooltip";
 
interface ActionTooltipProps {
  children: React.ReactNode;
  label: string;
  side?: "top" | "bottom" | "left" | "right";
  align?: "start" | "center" | "end";
}
 
export const ActionTooltip = ({
  children,
  label,
  side,
  align,
}: ActionTooltipProps) => {
  return (
    <TooltipProvider>
      <Tooltip delayDuration={50}>
        <TooltipTrigger asChild>{children}</TooltipTrigger>
        <TooltipContent side={side} align={align}>
          <p className="font-semibold text-sm capitalize">
            {label.toLowerCase()}
          </p>
        </TooltipContent>
      </Tooltip>
    </TooltipProvider>
  );
};
 

asChild についてはchildrenに対して親コンポーネントの機能をつけることができる浅い理解。以下ブログが事の背景から非常に詳しい。

ReactコンポーネントでレンダリングされるHTML要素の種類を変更可能にするためのパターン
ReactのUIコンポーネントライブラリを使っていると、あるコンポーネントによってレンダリングされるHTML要素の種類を変更したくなる場面がある。たとえば、通常はbutton要素としてレンダリングされるButtonコンポーネントを使うときに、代わりにa要素を使ってレンダリングしたいというケース。
ReactコンポーネントでレンダリングされるHTML要素の種類を変更可能にするためのパターン favicon https://yuheiy.com/2023-06-03-react-changeable-element-type-patterns
ReactコンポーネントでレンダリングされるHTML要素の種類を変更可能にするためのパターン

ツールチップをナビゲーションアクションに追加

components/navigation/navigation-action.tsx
"use client";
 
import { Plus } from "lucide-react";
 
import { ActionTooltip } from "@/components/action-tooltip";
 
export const NavigationAction = () => {
  return (
    <div>
      <ActionTooltip side="right" align="center" label="サーバーを追加する">
        <button 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>
  );
};
 

hover時にツールチップが表示される。 9-3

ナビゲーションバーにサーバ一覧を表示する

アクションボタンの下にセパレータを追加してサーバ一覧を表示していく。ナビゲーションバー下部にはアカウント情報とダークモードの切り替えトグルも表示する。

セパレータ追加

components/navigation/navigation-sidebar.tsx
import { Separator } from "@/components/ui/separator";
 
export const NavigationSidebar = async () => {
~~~
  return (
    <div className="space-y-4 flex flex-col items-center h-full text-primary w-full dark:bg-[#1E1F22] py-3">
      <NavigationAction />
      <Separator className="h-[2px] bg-zinc-200 dark:bg-zinc-700 rounded-md w-10 mx-auto" />
    </div>
  );
};
 

スクロールエリア追加

Scroll-area
Augments native scroll functionality for custom, cross-browser styling.
Scroll-area favicon https://ui.shadcn.com/docs/components/scroll-area
Scroll-area
terminal
pnpm dlx shadcn-ui@latest add scroll-area

最終的には以下の形。 9-4

components/navigation/navigation-item.tsx
"use client";
 
import { ActionTooltip } from "@/components/action-tooltip";
 
import { useParams, useRouter } from "next/navigation";
 
import { cn } from "@/lib/utils";
import Image from "next/image";
 
interface NavigationItemProps {
  id: string;
  imageUrl: string;
  name: string;
}
 
export const NavigationItem = ({ id, imageUrl, name }: NavigationItemProps) => {
  const params = useParams();
  const router = useRouter();
 
  const onClick = () => {
    router.push(`/servers/${id}`);
  };
 
  return (
    <ActionTooltip side="right" align="center" label={name}>
      <button onClick={onClick} className="group relative flex items-center">
        <div
          className={cn(
            "absolute left-0 bg-primary rounded-r-full transition-all w-[4px]",
            params?.serverId !== id && "group-hover:h-[20px]",
            params?.serverId === id ? "h-[36px]" : "h-[8px]"
          )}
        />
        <div
          className={cn(
            " group flex mx-3 h-[48px] w-[48px] rounded-[24px] group-hover:rounded-[16px] transition-all overflow-hidden",
            params?.serverId === id &&
              "bg-primary/10 text-primary rounded-[16px]"
          )}
        >
          <Image alt="Channel" src={imageUrl} fill />
        </div>
      </button>
    </ActionTooltip>
  );
};
 

チャンネルアイコンの左部にあるバーを表示している。現在のサーバの場合は36px、それ以外は8pxで、hover時に20pxになるように制御。

<div
  className={cn(
    "bg-primary absolute left-0 w-[4px] rounded-r-full transition-all",
    params?.serverId !== id && "group-hover:h-[20px]",
    params?.serverId === id ? "h-[36px]" : "h-[8px]"
  )}
/>

サーバイメージのアイコン部。現在のサーバの場合は背景色を変更している(が、今のところ分からない)。relative, h-[48px] w-[48px] でアイコンのサイズが固定されるようにしている。(が、Imageコンポーネントでfillではなくwidth, heightを指定することでも実現できそう。何が違うんだろう。relative指定した上でサイズ指定する必要があるのが苦手)

<div
  className={cn(
    "group relative mx-3 flex h-[48px] w-[48px] overflow-hidden rounded-[24px] transition-all group-hover:rounded-[16px]",
    params?.serverId === id && "bg-primary/10 text-primary rounded-[16px]"
  )}
>
  <Image alt="Channel" src={imageUrl} fill />
</div>

チャンネルとダークモードのトグル。ユーザーアイコンを追加する。

components/navigation/navigation-sidebar.tsx
import { ModeToggle } from "@/components/mode-toggle";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { currentProfile } from "@/lib/current-profile";
import { db } from "@/lib/db";
import { UserButton } from "@clerk/nextjs";
import { redirect } from "next/navigation";
import { NavigationAction } from "./navigation-action";
import { NavigationItem } from "./navigation-item";
 
export const NavigationSidebar = async () => {
 
/* 省略*/
 
  return (
    <div className="space-y-4 flex flex-col items-center h-full text-primary w-full dark:bg-[#1E1F22] py-3">
      <NavigationAction />
      <Separator className="h-[2px] bg-zinc-200 dark:bg-zinc-700 rounded-md w-10 mx-auto" />
      <ScrollArea className="flex-1 w-full">
        {servers.map((server) => (
          <div key={server.id} className="mb-4">
            <NavigationItem
              id={server.id}
              name={server.name}
              imageUrl={server.imageUrl}
            />
          </div>
        ))}
      </ScrollArea>
      <div className="pb-3 mt-auto flex items-center flex-col gap-y-4">
        <ModeToggle />
        <UserButton
          afterSignOutUrl="/"
          appearance={{
            elements: {
              avatarBox: "h-[48px] w-[48px]",
            },
          }}
        />
      </div>
    </div>
  );
};