Skip to content

Discordクローン作成 2. ダークモード〜Prisma, PlanetScale

公開日

表紙

4. ダークモード

ダークモード対応

shadcn/uiを利用してダークモードを実装する。手順は公式にしたがう。

Dark Mode
Adding dark mode to your site.
Dark Mode favicon https://ui.shadcn.com/docs/dark-mode
Dark Mode

next-themesのインストール

next-themesを利用してダークモードを実装する。 attributesの設定は以下参照。

GitHub - pacocoursey/next-themes: Perfect Next.js dark mode in 2 lines of code. Support System preference and any other theme with no flashing
Perfect Next.js dark mode in 2 lines of code. Support System preference and any other theme with no flashing - pacocoursey/next-themes
GitHub - pacocoursey/next-themes: Perfect Next.js dark mode in 2 lines of code. Support System preference and any other theme with no flashing favicon https://github.com/pacocoursey/next-themes
GitHub - pacocoursey/next-themes: Perfect Next.js dark mode in 2 lines of code. Support System preference and any other theme with no flashing
terminal
pnpm add next-themes

theme-providerの作成

ディレクトリはprovidersでまとめるようにしていく。

components/providers/theme-provider.tsx
"use client"
 
import * as React from "react"
import { ThemeProvider as NextThemesProvider } from "next-themes"
import { type ThemeProviderProps } from "next-themes/dist/types"
 
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
  return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}
 

rootのlayoutでThemeProviderを利用する。 ダークモードの背景色を変えるためにbg-whiteとdark:bg-[#313338]を利用する。

app/layout.tsx
<body className={cn(font.className, "bg-white dark:bg-[#313338]")}>
  <ThemeProvider
    attribute="class"
    defaultTheme="dark"
    enableColorScheme={false}
    storageKey="discord-theme"
  >
    {children}
  </ThemeProvider>
</body>

属性は以下の通り。attributeはNext.jsならclassを指定。

  • storageKey = ‘theme’: テーマ設定を localStorage に保存するために使用されるキー
  • defaultTheme = ‘system’: デフォルトのテーマ名
  • enableSystem = true dark:lightに基づいて切り替えるかどうか
  • enableColorScheme = true: 入力やボタンなどの組み込み UI にどの配色 (暗いか明るい) が使用されているかをブラウザーに示すかどうか
  • attribute = ‘data-theme’: アクティブなテーマに基づいて変更された HTML 属性, classおよびdata-*(任意のデータ属性、data-mode、data-colorなどを意味します)を受け入れます

suppressHydrationWarningの設定

Next.js 13の App Routerを利用する場合、ハイドレーションエラーが発生するのでhtmlタグにsuppressHydrationWarningを設定する。

app/layout.tsx
<html lang="ja" suppressHydrationWarning>
 
</html>

suppressHydrationWarning は React のプロパティで、サーバーサイドレンダリング(SSR)とクライアントサイドレンダリング(CSR)の間で発生する可能性のある警告を抑制するために使用されます。React では、サーバーサイドでレンダリングされたマークアップとクライアントサイドでレンダリングされたマークアップが一致しない場合、警告が発生します。これは、Reactがクライアントサイドで既存のマークアップを「再利用」しようとするプロセス(これを「hydration」と呼びます)が、予期しない違いによって中断される可能性があるためです。 suppressHydrationWarning プロパティを true に設定すると、この警告が抑制されます。ただし、このプロパティは基本的には開発者が意図的にマークアップの不一致を引き起こす場合にのみ使用するべきです。不一致が偶然に起こった場合、それは通常、バグの兆候であり、その警告は無視すべきではありません。

トグルの追加

components/mode-toggle.tsx
"use client"
 
import * as React from "react"
import { MoonIcon, SunIcon } from "@radix-ui/react-icons"
import { useTheme } from "next-themes"
 
import { Button } from "@/components/ui/button"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
 
export function ModeToggle() {
  const { setTheme } = useTheme()
 
  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button variant="outline" size="icon">
          <SunIcon className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
          <MoonIcon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
          <span className="sr-only">Toggle theme</span>
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent align="end">
        <DropdownMenuItem onClick={() => setTheme("light")}>
          Light
        </DropdownMenuItem>
        <DropdownMenuItem onClick={() => setTheme("dark")}>
          Dark
        </DropdownMenuItem>
        <DropdownMenuItem onClick={() => setTheme("system")}>
          System
        </DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  )
}
 

ドロップダウンメニューのコンポーネントがないので追加する

Dropdown Menu
Displays a menu to the user — such as a set of actions or functions — triggered by a button.
Dropdown Menu favicon https://ui.shadcn.com/docs/components/dropdown-menu
Dropdown Menu
terminal
pnpm dlx shadcn-ui@latest add dropdown-menu
 
pnpm add @radix-ui/react-icons

5. Prisma, PlanetScaleの設定

Prismaの設定

Prismaのインストール

terminal
pnpm add -D prisma

Prismaの初期化

terminal
pnpx prisma init

prisma/schema.prisma にファイルが追加されている。 .envにもDATABASE_URLが追加されている。

.env
DATABASE_URL="postgresql://johndoe:randompassword@localhost:5432/mydb?schema=public"

Next steps:

  1. Set the DATABASE_URL in the .env file to point to your existing database. If your database has no tables yet, read https://pris.ly/d/getting-started
  2. Set the provider of the datasource block in schema.prisma to match your database: postgresql, mysql, sqlite, sqlserver, mongodb or cockroachdb.
  3. Run prisma db pull to turn your database schema into a Prisma schema.
  4. Run prisma generate to generate the Prisma Client. You can then start querying your database.

PlanetScaleを使うので、DATABASE_URLはあとから書き換える。

PlanetScaleの設定

Hobbyプランでも十分使えるPlanetScale。プロジェクトが1つしか作れないのが難点。 前はなかった気がするけど、東京リージョンがあるのは◯。 Supabaseはプロジェクト複数作れるので、そちらも検討して良い。小規模ならDockerで十分。 今やるとクレジットカード登録が必要(勝手に課金はない)。

プロジェクト作成

The ultimate MySQL database platform — PlanetScale
PlanetScale is the world’s most advanced, fully-managed MySQL database platform. Scale, performance, and reliability, powered by Vitess.
The ultimate MySQL database platform — PlanetScale favicon https://planetscale.com/
The ultimate MySQL database platform — PlanetScale

planetscale-1

Connect を選択して接続用のパスワードを作成する。 ロールはAdminのまま。

planetscale-1

Prismaに反映

作成すると Connect with から Prismaを選択することで、接続用のURLが表示される。 DATABASE_URL をコピーして .env に貼り付ける。

.env
DATABASE_URL="postgresql://<username>:<password>@<cluster-name>.planetscale.com:5432/<database-name>?schema=public"

また、同じようにschema.prisma側もコピペで書き換える。

Prisma スキーマ定義作成

PlanetScaleも準備ができたのでスキーマ定義を作成する。

テーブル定義

  • Profile, Server, Member, Channelの4つのテーブルを作成する。
  • ProfileがServer, Member, Channelを持っており、ServerがMember, Channelを持っている。
  • Delete時にCascadeを指定している。
  • MySQLの場合、@db.Textを指定しないと VARCHAR(191) となる(なぜこんなに中途半端?) (https://www.prisma.io/docs/orm/overview/databases/mysql)
schema.prisma
generator client {
  provider = "prisma-client-js"
}
 
datasource db {
  provider = "mysql"
  url = env("DATABASE_URL")
  relationMode = "prisma"
}
 
model Profile {
  id String @id @default(uuid())
  userId String @unique
  name String
  imageUrl String @db.Text
  email String @db.Text
 
  servers Server[]
  members Member[]
  channels Channel[]
 
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}
model Server {
  id String @id @default(uuid())
  name String
  imageUrl String @db.Text
  inviteCode String @db.Text
 
  profileId String
  profile Profile @relation(fields: [profileId], references: [id], onDelete: Cascade)
 
  members Member[]
  channels Channel[]
 
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
 
  @@index ([profileId])
}
 
enum MemberRole {
  ADMIN
  MODERATOR
  GUEST
}
 
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)
 
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
 
  @@index([profileId])
  @@index([serverId])
 
}
 
enum ChannelType {
  TEXT
  AUDIO
  VIDEO
}
 
model Channel {
  id String @id @default(uuid())
  name String
  type ChannelType @default(TEXT)
 
  profileId String
  profile Profile @relation(fields: [profileId], references: [id], onDelete: Cascade)
 
  serverId String
  server Server @relation(fields: [serverId], references: [id], onDelete: Cascade)
 
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
 
  @@index([profileId])
  @@index([serverId])
}
 

DBのマイグレーション

terminal
pnpx prisma generate
pnpx prisma db push

Prisma Clientの追加

terminal
pnpm add @prisma/client

Prisma Clientの設定

ホットリロード対策を入れた形で設定(公式どおり)

lib/db.ts
import { PrismaClient } from "@prisma/client";
 
declare global {
  var prisma: PrismaClient | undefined;
}
 
export const db = global.prisma || new PrismaClient();
 
if (process.env.NODE_ENV !== "production") {
  global.prisma = db;
}
 

プロフィール作成機能

プロフィール初期化ライブラリ

ログインユーザを元にプロフィールを作成するライブラリ。 存在する場合はログインユーザのプロフィールを返し、存在しない場合は新規作成する。

lib/initial-profile.ts
import { currentUser, redirectToSignIn } from "@clerk/nextjs";
 
import { db } from "@/lib/db";
 
export const initialProfile = async () => {
  const user = await currentUser();
  if (!user) {
    return redirectToSignIn();
  }
 
  const profile = await db.profile.findUnique({
    where: { userId: user.id },
  });
 
  if (profile) {
    return profile;
  }
 
  const newProfile = await db.profile.create({
    data: {
      userId: user.id,
      name: `${user.firstName} ${user.lastName}`,
      imageUrl: user.imageUrl,
      email: user.emailAddresses[0].emailAddress,
    },
  });
 
  return newProfile;
};
 

(main) を削除し、(setup)を追加

セットアップページではサーバを作成を行う。 (main)のpage.tsxが削除されたので、rootアクセス時に以下が表示される状態。

app/(setup)/page.tsx
import { redirect } from "next/navigation";
 
import { db } from "@/lib/db";
import { initialProfile } from "@/lib/initial-profile";
 
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 <div>Create a Server</div>;
};
 
export default SetupPage;
 

Prisma Studioで確認

ユーザが実際に作成されているのかを確認するためにPrisma Studioを利用する。

terminal
pnpx prisma studio