Next.js+TSでフロント・バックを完結させる個人開発 ②ホーム画面〜ログイン処理
はじめに
前回の続きとして、今回はホーム画面とログイン処理周りについてまとめます。
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 時のパスを自由に切り替えることが可能です。
{
"$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/ui は
components.json
で各種設定が管理される -
aliases で
@/app/components/ui
や@/app/lib
といったパスを指定できる - これによりコンポーネントのインストール先を、
/ui
ディレクトリにまとめるなど柔軟に設定できる
ホームページ (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>
);
}
ポイント
-
動画の切り替え
useEffect
で動画の終了(ended
)イベントを監視し、currentVideoIndex
を更新して次の動画へ。 -
ボタンのホバーで動画切り替え
onMouseEnter
で動画インデックスを変更して、対応する動画に即切り替え。
グローバルレイアウト (app/layout.tsx)
Next.js 13 App Router では app/layout.tsx
がプロジェクト全体のレイアウトとなります。
ここで Google フォントやグローバルなメタ情報、favicon
の設定を行っています。
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
をロードし、className
をbody
に適用 -
favicon へのパス設定:
ルートの public 配下にfavicon.ico
を配置したため、/favicon.ico
で読み込む
ページ配下レイアウト (app/(pages)/layout.tsx)
次に /(pages)
配下だけに適用したい共通レイアウトを定義しています。
例えば、ヘッダーにログインボタンを設置したい場合などはここに実装します。
なお、ホームページ(/) にはログインボタンを表示させないようにする設計です。
"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.local
に NEXT_PUBLIC_GOOGLE_CLIENT_ID
を設定したうえで、
env.ts
から GOOGLE_CLIENT_ID
を読み込むようにして、undefined の可能性を排除しています。
export const env = {
GOOGLE_CLIENT_ID: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID ?? "",
};
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 のリンクなどを貼ってそっから商品画像を抽出してくれる仕組みがあれば便利だなと考えています。次回はその実現性の調査から入りたいと思います。
以上です。
Discussion