Zenn
🔥

Next.js+TSでフロント・バックを完結させる個人開発 ②ホーム画面〜ログイン処理

2025/02/12に公開

はじめに

前回の続きとして、今回はホーム画面とログイン処理周りについてまとめます。
Next.js の App Router を利用して、ホーム画面に動画を表示したり、Google Oauth2.0 を使ったログインを実装しています。

ディレクトリ構成

プロジェクトのルートは以下のようになっています。(変更があったファイルのみ抜粋)

/project-root
├── /app
│   ├── /(pages)
│   │   ├ layout.tsx          # (pages)配下に適用するレイアウト
│   │   ├ want/page.tsx       # 今欲しいものを載せるページ
│   │   ├ portfolio/page.tsx  # 自己紹介ページ
│   │   └ memo/page.tsx       # メモを載せるページ
│   ├── /components
│   │   ├── /ui               # 必要なUIコンポーネント
│   │   └── google-login.tsx  # ログインロジック
│   ├── page.tsx              # ホームページ
│   ├── layout.tsx            # プロジェクト全体に適用するレイアウト
├── /public                   # 動画, favicon置き場
├── .env.local                # Google Oauth用のCLIENT ID保存
├── env.ts                    # envファイルで型のundefined回避
├── components.json           # shadcn/uiの設定ファイル
...

shadcn/ui のコンポーネント管理

components.json

shadcn/ui の設定は主にこの components.json で行います。
エイリアスの設定をすることで、コンポーネントのインストール先や import 時のパスを自由に切り替えることが可能です。

components.json
{
  "$schema": "https://ui.shadcn.com/schema.json",
  "style": "default",
  "rsc": true,
  "tsx": true,
  "tailwind": {
    "config": "tailwind.config.ts",
    "css": "app/globals.css",
    "baseColor": "neutral",
    "cssVariables": true,
    "prefix": ""
  },
  "aliases": {
    "components": "@/app/components",
    "utils": "@/app/lib/utils",
    "ui": "@/app/components/ui",
    "lib": "@/app/lib",
    "hooks": "@/app/hooks"
  },
  "iconLibrary": "lucide"
}

ポイント

  • shadcn/uicomponents.json で各種設定が管理される
  • aliases@/app/components/ui@/app/lib といったパスを指定できる
  • これによりコンポーネントのインストール先を、/ui ディレクトリにまとめるなど柔軟に設定できる

ホームページ (app/page.tsx)

ホーム画面では背景動画をループさせて表示しつつ、ボタンにホバーすると対応した動画が流れる仕様を実装しています。さらに動画が終了すると、自動的に次の動画に切り替わるようにしています。

app/page.tsx
"use client";

import { useEffect, useRef, useState } from "react";
import { Button } from "@/app/components/ui/button";
import Link from "next/link";

export default function Component() {
  const [currentVideoIndex, setCurrentVideoIndex] = useState(0);
  const videoRef = useRef<HTMLVideoElement>(null);

  const videos = ["/video1.mp4", "/video2.mp4", "/video3.mp4"];

  useEffect(() => {
    const videoElement = videoRef.current;
    if (!videoElement) return;

    const handleVideoEnd = () => {
      setCurrentVideoIndex((prevIndex) => (prevIndex + 1) % videos.length);
    };

    videoElement.addEventListener("ended", handleVideoEnd);

    return () => {
      videoElement.removeEventListener("ended", handleVideoEnd);
    };
  }, []);

  const getButtonText = (index: number) => {
    return index === 0 ? "want" : index === 1 ? "portfolio" : "memo";
  };

  const getButtonLink = (index: number) => {
    return `/${getButtonText(index)}`;
  };

  return (
    <div className="relative h-screen w-full overflow-hidden font-zilla-slab">
      <video
        ref={videoRef}
        className="absolute top-0 left-0 min-h-full min-w-full object-cover"
        src={videos[currentVideoIndex]}
        autoPlay
        muted
      />
      <div className="relative z-10 flex flex-col items-center justify-center h-full">
        <h1 className="text-3xl text-black mb-8">luck storage</h1>
        <div className="flex space-x-4">
          {[0, 1, 2].map((index) => (
            <Link key={index} href={getButtonLink(index)}>
              <Button
                className={`px-6 py-2 text-black transition-colors
                ${
                  index === currentVideoIndex
                    ? "bg-custom-button-hover"
                    : "bg-custom-button hover:bg-custom-button-hover"
                }`}
                onMouseEnter={() => setCurrentVideoIndex(index)}
              >
                {getButtonText(index)}
              </Button>
            </Link>
          ))}
        </div>
      </div>
    </div>
  );
}

ポイント

  1. 動画の切り替え
    useEffect で動画の終了(ended)イベントを監視し、currentVideoIndexを更新して次の動画へ。
  2. ボタンのホバーで動画切り替え
    onMouseEnter で動画インデックスを変更して、対応する動画に即切り替え。

グローバルレイアウト (app/layout.tsx)

Next.js 13 App Router では app/layout.tsx がプロジェクト全体のレイアウトとなります。
ここで Google フォントやグローバルなメタ情報、favicon の設定を行っています。

app/layout.tsx
import type { Metadata } from "next";
import "./globals.css";
import { Zilla_Slab } from "next/font/google";

const zillaSlabFont = Zilla_Slab({
  subsets: ["latin"],
  weight: ["400", "700"],
});

export const metadata: Metadata = {
  title: "luck Storage",
  description: "luckの個人的なメモアプリです",
  icons: {
    icon: "/favicon.ico",
  },
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body className={zillaSlabFont.className}>
        {children}
      </body>
    </html>
  );
}

ポイント

  • Google Fonts の適用:
    next/font/google を利用して Zilla_Slab をロードし、classNamebody に適用
  • favicon へのパス設定:
    ルートの public 配下に favicon.ico を配置したため、/favicon.ico で読み込む

ページ配下レイアウト (app/(pages)/layout.tsx)

次に /(pages) 配下だけに適用したい共通レイアウトを定義しています。
例えば、ヘッダーにログインボタンを設置したい場合などはここに実装します。
なお、ホームページ(/) にはログインボタンを表示させないようにする設計です。

app/(pages)/layout.tsx
"use client";

import Link from "next/link";
import { Button } from "@/app/components/ui/button";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/app/components/ui/dropdown-menu";
import { UserIcon } from "lucide-react";
import React from "react";
import LoginButton from "../components/google-login";

export default function PagesLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="min-h-screen flex flex-col">
      <header className="w-full p-4 bg-background border-b">
        <div className="w-full flex justify-between items-center">
          <div className="w-12">{/* Empty div for balance */}</div>
          <Link
            href="/"
            className="text-2xl text-center hover:text-primary transition-colors"
          >
            luck storage
          </Link>

          <div className="w-12 flex justify-end">
            <DropdownMenu>
              <DropdownMenuTrigger asChild>
                <Button variant="ghost" size="icon">
                  <UserIcon className="h-5 w-5" />
                </Button>
              </DropdownMenuTrigger>
              <DropdownMenuContent align="end">
                <DropdownMenuItem>
                  <LoginButton />
                </DropdownMenuItem>
                <DropdownMenuItem>admin</DropdownMenuItem>
              </DropdownMenuContent>
            </DropdownMenu>
          </div>
        </div>
      </header>
      <main className="flex-grow">{children}</main>
    </div>
  );
}

ポイント

  • / にはログインボタンを表示しないかわりに、/(pages) 配下のページからヘッダーにログインボタンを表示
  • 将来的に admin 項目から編集画面に遷移させる予定

Google ログイン (app/components/google-login.tsx)

最後に、Google Oauth2.0 を使ったログイン処理の実装例です。
react-oauth/google を利用し、GoogleOAuthProvider でラップしています。
また、.env.localNEXT_PUBLIC_GOOGLE_CLIENT_ID を設定したうえで、
env.ts から GOOGLE_CLIENT_ID を読み込むようにして、undefined の可能性を排除しています。

env.ts
export const env = {
  GOOGLE_CLIENT_ID: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID ?? "",
};
app/components/google-login.tsx
import { GoogleOAuthProvider, useGoogleLogin } from "@react-oauth/google";
import { env } from "@/env";

function GoogleAuthComponentLogin() {
  const login = useGoogleLogin({
    flow: "implicit", // TODO: PKCEの利用へ切り替え予定
    onSuccess: (tokenResponse) => {
      console.log(tokenResponse);
    },
    onError: () => {
      console.error("Login failed");
    },
    prompt: "select_account",
  });

  return (
    <button
      style={{ width: "-webkit-fill-available", textAlign: "left" }}
      onClick={() => login()}
    >
      login
    </button>
  );
}

export default function LoginButton() {
  return (
    <GoogleOAuthProvider clientId={env.GOOGLE_CLIENT_ID}>
      <GoogleAuthComponentLogin />
    </GoogleOAuthProvider>
  );
}

ポイント

  • 環境変数を使う際に env.ts を挟み、env.GOOGLE_CLIENT_ID で呼び出す
  • react-oauth/google ライブラリを利用し、Implicit flow で動作確認中
  • 今後 API 実装時に PKCE + Token exchange に切り替える予定

 
 
 
 

これでひとまず「ホーム画面」と「ログイン処理」周りの実装が形になりました。
現在、Want ページでは、amazon のリンクなどを貼ってそっから商品画像を抽出してくれる仕組みがあれば便利だなと考えています。次回はその実現性の調査から入りたいと思います。

以上です。

KA projects

Discussion

ログインするとコメントできます