はじめに
仕事のキャッチアップで急速錬成中。バックエンドばっかやってきたから急にフロントのフレームワークつかうことになるとつらーい!(でもチャンスだね)。今回はログイン画面と画面切り替えのあるものを作るぞ
ここまでできればかなりいろんなことができるようになるだろう。
Reference
Node
いままで node は exe をダウンロードしてきてインストールしていたが、コマンドラインでできるらしい。pythonの venv みたいな感じ。nvmという
(nvm-setup.exe をダウンロード。npmもインストールされる ※エディタの再起動必須)
nvm install 18.17.1
nvm use 18.17.1
nvm list
nvm uninstall 18.17.1
node -v
openSSL
https://slproweb.com/products/Win32OpenSSL.html
Win64 OpenSSL vX.X.X Light
C:\Program Files\OpenSSL-Win64\bin
を環境変数に登録
Getting Started
First, run the development server:
mkdir nextjs
cd nextjs
npx create-next-app@latest .
√ Would you like to use TypeScript? ... No / [Yes]
√ Would you like to use ESLint? ... No / [Yes]
√ Would you like to use Tailwind CSS? ... [No] / Yes
√ Would you like to use `src/` directory? ... No / [Yes]
√ Would you like to use App Router? (recommended) ... No / [Yes]
√ Would you like to customize the default import alias (@/*)? ... [No] / Yes
npm run dev
.env.example
-
.env.example
を作ったあとに、cp
コマンドで.env
を作成する -
NEXTAUTH_SECRET
は、openssl rand -base64 32
をコンソールで打ち込んで、出てきた文字をペースト
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET= # openssl rand -base64 32
BASE_URL=http://localhost:3000
cp .env.example .env
.gitignore
:
# local env files
+.env
.env*.local
package.json
好きにインストールして
{
"name": "nextjs",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
+ "format": "prettier --write ."
},
"dependencies": {
+ "@emotion/react": "^11.11.1",
+ "@emotion/styled": "^11.11.0",
+ "@mui/icons-material": "^5.14.16",
+ "@mui/material": "^5.14.17",
+ "@mui/x-data-grid": "^6.18.1",
+ "@prisma/client": "^5.5.2",
+ "dayjs": "^1.11.10",
- "next": "14.0.1"
+ "next": "^13.5.6",
+ "next-auth": "^4.24.5",
"react": "^18",
"react-dom": "^18",
+ "react-hook-form": "^7.48.2",
+ "ts-node": "^10.9.1",
+ "zod": "^3.22.4"
},
"devDependencies": {
+ "@next/eslint-plugin-next": "^14.0.3",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
+ "@typescript-eslint/eslint-plugin": "^6.13.1",
+ "@typescript-eslint/parser": "^6.13.1",
+ "dotenv": "^16.3.1",
"eslint": "^8",
"eslint-config-next": "14.0.2",
+ "eslint-config-prettier": "^9.0.0",
+ "jest": "^29.7.0",
+ "jest-environment-jsdom": "^29.7.0",
+ "prettier": "^3.1.0",
+ "prisma": "^5.5.2",
- "typescript": "^5"
+ "typescript": "^5.3.2"
}
}
prettier
/** @type {import('prettier').Config} */
module.exports = {
semi: false,
singleQuote: true,
printWidth: 100,
useTabs: false,
tabWidth: 2,
endOfLine: 'lf',
jsxSingleQuote: true,
}
prisma
npx prisma init --datasource-provider mysql
:
# prisma
/prisma/migrations/
/src/generated/
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
+ output = "../src/generated/prismaClient"
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
+ ここから下をコピーして追記
model Users {
id Int @id @default(autoincrement())
name String?
role String @default("user")
email String @unique
password String
passwordToken String? @map("password_token")
tokenExpiredAt DateTime? @map("token_expired_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
@@map("users")
}
DATABASE_URL="mysql://your-user-name:your-password@localhost:3306/nextjs_db"
npx prisma migrate dev --name init
Environment variables loaded from .env
Prisma schema loaded from prisma\schema.prisma
Datasource "db": MySQL database "nextjs_db" at "localhost:3306"
MySQL database nextjs_db created at localhost:3306
Applying migration `20231103015547_init`
The following migration(s) have been created and applied from new schema changes:
migrations/
└─ 20231103015547_init/
└─ migration.sql
Your database is now in sync with your schema.
✔ Generated Prisma Client (v5.5.2) to .\node_modules\@prisma\client in 197ms
prisma clientに反映
新規で schema.prisma
を作成した際、及び schema.prisma
を編集した際には、以下のコマンドを実行して、 prisma client
への反映を行う。このコマンドを実行してもgithub管理するコードの差分は発生しないが node_modules
が更新されている。prismaがモデル情報を取り込んでメソッドとして使えるようになる、、ような感じかな。Djangoとかと比べると片手落ちというかいまいちな印象だね。
dbスキーマに変更が出たら migrate
とセットで実行しろということ
npx prisma generate
seeder
npm install ts-node
:
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"format": "prettier --write .",
+ "seed": "ts-node --project tsconfig.seeds.json -r tsconfig-paths/register ./prisma/seeds/seeder.ts"
},
:
{
"compilerOptions": {
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "CommonJS",
"resolveJsonModule": true,
"isolatedModules": true,
"baseUrl": "./",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["**/*.ts"],
"exclude": ["node_modules"]
}
import { PrismaClient } from '@/generated/prismaClient'
async function main() {
const prisma = new PrismaClient()
await prisma.users.deleteMany()
await prisma.users.create({
data: {
name: '開発 太郎',
email: 'test@example.com',
password: 'hoge',
},
})
prisma.$disconnect()
}
main().then(() => console.log('seeding ok.'))
Route Handlers
App Router という仕組みがあり、フォルダ構造がそのままいわば Djangoでいう urls.py
のような役割になる
urlベースで作ったフォルダ構造の下に決まったファイルを作ると dashboard/page.tx
、http://localhost:3000/dashboard
でページが表示される
さらに、api
フォルダを噛ませてから、route.tsx
というファイルを作って、HTTPメソッドをオーバーライドしてあげると APIモード
になる。Djangoとかやってるやつにはかなり親和性が高い... ようになってきたのは実はつい最近らしい
import { NextRequest, NextResponse } from 'next/server'
export async function GET(req: NextRequest) {
return NextResponse.json({msg: "hello, api/users GET!"})
}
ちなみに、以下のようなファイルをつくると、Postmanの疎通テストみたいなのができる(PyCharmでやってるけどVSCodeにもあるよ)
GET http://localhost:3000/api/users
Accept: application/json
:
+ # .idea
+ /.idea/httpRequests/
# linuxだといわゆるこれ: rm -r /your/path/here
Remove-Item -Path "src/app/api/users" -Recurse -Force
ログイン機能とパスワード再設定
どばーっと行くけど、作ってみるとなるほどね!となるはず。
自動生成ファイルの調整
-
app
ディレクトリのlayout.tsx
を加工 -
app
ディレクトリのpage.tsx
を加工
import React from 'react'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang='en'>
<body>{children}</body>
</html>
)
}
import React from 'react'
export default function Page() {
return (
<div>
<p>保護されていないページ</p>
<p>This is Home</p>
<p>
<a href={'/admin/user-list'}>ユーザー管理ページにとぶ</a>
</p>
</div>
)
}
forgot-password(コンポーネント)
-
forgot-password
ディレクトリのlayout.tsx
page.tsx
を新規作成する -
(authenticated)
ディレクトリは単なるグループ分け用のフォルダ(言い換えると、マルカッコをつけることでappRouter
から除外されるので保護ページとそうじゃないページを見やすくわけている) - 同じような理由で先頭アンダーバー
_XXXX
ディレクトリはappRouter
から無視される
import { ReactNode } from 'react'
export default async function Layout({ children }: { children: ReactNode }) {
return <>{children}</>
}
import React from 'react'
import { ForgotPasswordPage } from './_components/ForgotPasswordPage'
export default function Page() {
// 無駄な処理に見えるけど、dbからデータをロードする必要があるときにまとめてデータを確保してコンポーネントに流し込むつくりにしよう
return <ForgotPasswordPage />
}
'use client'
import React, { useState } from 'react'
import { ForgotPasswordSent } from './ForgotPasswordSent'
import { ForgotPasswordEdit } from './ForgotPasswordEdit'
export function ForgotPasswordPage() {
const [isSent, setIsSent] = useState(false)
return isSent ? (
<ForgotPasswordSent />
) : (
<ForgotPasswordEdit onCompletion={() => setIsSent(true)} />
)
}
import React, { FormEvent } from 'react'
import { Box, Button, TextField, Typography } from '@mui/material'
type Props = {
onCompletion: () => void
}
export function ForgotPasswordEdit({ onCompletion }: Props) {
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
const formData = new FormData(event.currentTarget)
const data = {
email: formData.get('email'),
}
fetch('/api/admin/forgot-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ to: data.email }),
})
.then((response) => response.json())
.then((data) => {
console.log(`data: ${JSON.stringify(data)}`)
onCompletion()
})
.catch((error) => {
console.error('Fetch error:', error)
})
}
return (
<Box
component='main'
sx={{
flexGrow: 1,
height: '100vh',
overflow: 'auto',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Typography variant='h5' textAlign='center' sx={{ mb: 1 }}>
パスワードを忘れた場合
</Typography>
<Box
component='form'
sx={{ width: 345, display: 'flex', flexDirection: 'column', alignItems: 'center' }}
onSubmit={handleSubmit}
>
<TextField
id='email'
name='email'
label='メールアドレス'
variant='outlined'
required
sx={{ width: '100%' }}
/>
<Box sx={{ m: 1 }} />
<Button type='submit' variant='outlined'>
送信
</Button>
</Box>
</Box>
)
}
import React from 'react'
import { Box, Button, Typography } from '@mui/material'
export function ForgotPasswordSent() {
return (
<Box
component='main'
sx={{
flexGrow: 1,
height: '100vh',
overflow: 'auto',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Typography variant='h5' textAlign='center' sx={{ mb: 1 }}>
送信ボタンが押されました(画面2)
</Typography>
<Box sx={{ width: 345, display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<Typography sx={{ textAlign: 'center', width: 310, fontSize: '16px' }}>
送信ボタンが押された結果、画面が遷移しました。ブラウザの開発者コンソールを開いてURLをクリックしてください
</Typography>
<Box sx={{ m: 1 }} />
<Button variant='outlined' href='/admin/signin' sx={{ width: '100%' }}>
ログイン画面へ
</Button>
</Box>
</Box>
)
}
apiHandler
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
export async function validate<T>(req: NextRequest, schema: z.ZodType) {
const { data, success, error } = schema.safeParse(await req.json()) as {
data: JSON
success: boolean
error: { errors: z.ZodIssue[] }
}
if (!success) {
throw new Error(JSON.stringify({ message: 'Invalid request', errors: error.errors }))
}
return data as T
}
export function requestError(e: unknown): NextResponse {
if (e instanceof Error) {
return NextResponse.json({ error: JSON.parse(e.message) }, { status: 400 })
} else {
console.error('Unexpected error type:', e)
return NextResponse.json({ error: 'Unexpected error type' }, { status: 500 })
}
}
export function serverError(e: unknown): NextResponse {
if (e instanceof Error) {
return NextResponse.json({ error: JSON.parse(e.message) }, { status: 500 })
} else {
console.error('Unexpected error type:', e)
return NextResponse.json({ error: 'Unexpected error type' }, { status: 500 })
}
}
forgot-password(API)
import dayjs, { Dayjs, ManipulateType } from 'dayjs'
export function dateOffset(
n: number = 0,
unit: ManipulateType = 'minute',
base: Date = new Date(),
): Date {
let shiftedNow: Dayjs
if (n >= 0) {
shiftedNow = dayjs(base).add(n, unit)
} else {
shiftedNow = dayjs(base).subtract(Math.abs(n), unit)
}
return new Date(shiftedNow.format())
}
import { randomBytes } from 'crypto'
export function generateToken(): string {
const buffer: Buffer = randomBytes(32)
return buffer.toString('hex')
}
import { requestError, serverError, validate } from '@/util/apiHandler'
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { PrismaClient } from '@/generated/prismaClient'
import { generateToken } from '@/util/token'
import { dateOffset } from '@/util/date'
export async function POST(req: NextRequest) {
const schema = z.object({
to: z.string().email(),
})
type RequestType = z.infer<typeof schema>
let data: RequestType
// 送信先メールアドレスのuserを抽出
let userName: string
const prisma = new PrismaClient()
try {
data = await validate<RequestType>(req, schema)
const { name } = await prisma.users.findFirstOrThrow({ where: { email: data.to } })
userName = name || ''
} catch (error) {
return requestError(error)
}
// TODO: メール送信
const passwordToken = generateToken()
const tokenExpiredAt: Date = dateOffset(30, 'minute')
let message: object = {
to: data.to,
from: 'noreply@example.com',
subject: 'パスワード再設定用のURLをくれてやろう',
body: `${userName} さん、パスワード再設定用のURLです http://localhost:3000/admin/reset-password?token=${passwordToken}`,
}
try {
await prisma.users.update({
where: { email: data.to },
data: {
passwordToken,
tokenExpiredAt,
},
})
return NextResponse.json(message)
} catch (error) {
return serverError(error)
} finally {
await prisma.$disconnect()
}
}
POST http://localhost:3000/api/admin/forgot-password
Content-Type: application/json
{"to": "test@example.com"}
reset-password(コンポーネント)
import React from 'react'
import { ResetPasswordEdit } from './_components/ResetPasswordEdit'
export default function Page() {
// 無駄な処理に見えるけど、dbからデータをロードする必要があるときにまとめてデータを確保してコンポーネントに流し込むつくりにしよう
return <ResetPasswordEdit />
}
'use client'
import { useRouter, useSearchParams } from 'next/navigation'
import React, { FormEvent, useEffect } from 'react'
import { Box, Button, TextField, Typography } from '@mui/material'
type ResetPasswordEditForm = {
token: string
password: string
passwordConfirm: string
}
export function ResetPasswordEdit() {
const router = useRouter()
const forceRedirect = useSearchParams().size == 0
const token = useSearchParams().get('token')
// エフェクトは通常、コンポーネントを外部システム(=外部コンポーネント)と同期させるのに使います。
// see: https://ja.react.dev/learn/synchronizing-with-effects
useEffect(() => {
if (forceRedirect) {
router.push('/admin/signin')
}
}, [forceRedirect, router])
// クエリストリング(=token)がない状態でたどりついたらねずみ返し
if (forceRedirect) return <></>
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
const formData = new FormData(event.currentTarget)
const data = {
password: formData.get('password'),
passwordConfirm: formData.get('passwordConfirm'),
}
fetch('/api/admin/reset-password', {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ password: data.password, passwordConfirm: data.passwordConfirm }),
})
.then(() => {
router.push('/admin/signin')
})
.catch((error) => {
console.error('Fetch error:', error)
})
}
return (
<Box
component='main'
sx={{
flexGrow: 1,
height: '100vh',
overflow: 'auto',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Typography variant='h5' textAlign='center' sx={{ mb: 1 }}>
パスワード再設定
</Typography>
<Box
component='form'
sx={{ width: 345, display: 'flex', flexDirection: 'column', alignItems: 'center' }}
onSubmit={handleSubmit}
>
<TextField
id='password'
name='password'
type='password'
label='パスワード'
variant='outlined'
required
sx={{ width: '100%' }}
/>
<TextField
id='passwordConfirm'
name='passwordConfirm'
type='password'
label='パスワード再入力'
variant='outlined'
required
sx={{ width: '100%' }}
/>
<Box sx={{ m: 1 }} />
<Button type='submit' variant='outlined'>
送信
</Button>
</Box>
</Box>
)
}
reset-password(API)
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { PrismaClient } from '@/generated/prismaClient'
import { requestError, serverError, validate } from '@/util/apiHandler'
export async function PUT(req: NextRequest) {
const schema = z.object({
password: z.string(),
passwordConfirm: z.string(),
})
type RequestType = z.infer<typeof schema>
let data: RequestType
let userId: number
const prisma = new PrismaClient()
try {
data = await validate<RequestType>(req, schema)
let token = req.headers.get('Authorization')
if (token && token.startsWith('Bearer ')) {
token = token.slice(7)
} else {
throw new Error(JSON.stringify({ message: 'Invalid or missing Bearer token.' }))
}
const { id, tokenExpiredAt } = await prisma.users.findFirstOrThrow({
where: { passwordToken: token },
})
userId = id
if (tokenExpiredAt && new Date() > tokenExpiredAt) {
throw new Error(JSON.stringify({ message: 'Token has expired.' }))
}
if (data.password != data.passwordConfirm) {
throw new Error(
JSON.stringify({
message: 'The password and password confirmation do not match. Please try again.',
}),
)
}
} catch (error) {
return requestError(error)
}
try {
const { id } = await prisma.users.update({
where: { id: userId },
data: { password: data.password },
})
return NextResponse.json({ id })
} catch (error) {
return serverError(error)
}
}
### 200 OK
PUT http://localhost:3000/api/admin/reset-password
Content-Type: application/json
Authorization: Bearer 5698f13bcaa5b0208f031423a87f57a1229b6a8a31a390925a49927c28532b80
{
"password": "abc",
"passwordConfirm": "abc"
}
### 400 Bad Request: "Token has expired." or "The password and password confirmation do not match. Please try again."
PUT http://localhost:3000/api/admin/reset-password
Content-Type: application/json
Authorization: Bearer 5698f13bcaa5b0208f031423a87f57a1229b6a8a31a390925a49927c28532b80
{
"password": "a",
"passwordConfirm": "b"
}
### 400 Bad Request: Invalid or missing Bearer token.
PUT http://localhost:3000/api/admin/reset-password
Content-Type: application/json
{
"password": "a",
"passwordConfirm": "a"
}
NextAuth
import NextAuth from 'next-auth'
import options from '@/auth/authOptions'
const handler = NextAuth(options)
export { handler as GET, handler as POST }
import { requestError, validate } from '@/util/apiHandler'
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { PrismaClient } from '@/generated/prismaClient'
export async function GET(req: NextRequest) {
return NextResponse.json({ msg: 'hello, api/auth GET!' })
}
export async function POST(req: NextRequest) {
const schema = z.object({
email: z.string(),
password: z.string(),
})
type RequestType = z.infer<typeof schema>
let data: RequestType
try {
data = await validate<RequestType>(req, schema)
} catch (e: unknown) {
return requestError(e)
}
const prisma = new PrismaClient()
try {
const user = await prisma.users.findFirstOrThrow({
where: {
email: data.email,
password: data.password,
},
})
return NextResponse.json(user)
} catch (error) {
console.error(error)
} finally {
await prisma.$disconnect()
}
}
POST http://localhost:3000/api/auth
Content-Type: application/json
{
"email": "test@example.com",
"password": "hoge"
}
###
GET http://localhost:3000/api/auth
Accept: application/json
import { NextAuthOptions } from 'next-auth'
import CredentialsProvider from 'next-auth/providers/credentials'
import { UserSession } from '@/auth/UserSession'
const authOptions: NextAuthOptions = {
session: {
strategy: 'jwt',
},
providers: [
CredentialsProvider({
name: 'credentials',
credentials: {
email: {
label: 'Email',
type: 'email',
},
password: { label: 'Password', type: 'password' },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
return null
}
let userSession: UserSession
try {
const options = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email: credentials.email, password: credentials.password }),
}
const result = await fetch(`${process.env.BASE_URL}/api/auth`, options)
userSession = await result.json()
} catch (e) {
return null
}
const user: UserSession = {
id: userSession.id,
name: userSession.name,
email: userSession.email,
}
return user
},
}),
],
callbacks: {
jwt: ({ token, user, account }) => {
if (user) {
token.user = user
token.id = user.id
}
if (account) {
token.accessToken = account.access_token
}
return token
},
session: ({ session, token }) => {
const tokenInUser: UserSession = token.user as UserSession
return {
...session,
user: {
...tokenInUser,
...session.user,
},
}
},
},
}
export default authOptions
import { UserSession } from '@/auth/UserSession'
import options from '@/auth/authOptions'
import { getServerSession } from 'next-auth'
export async function getUserSession(): Promise<UserSession | null> {
const session = await getServerSession(options)
if (!session) return null
return session.user as UserSession
}
export type UserSession = {
id: string
name: string
email: string
}
'use client'
export { SessionProvider } from 'next-auth/react'
signin
import { redirect } from 'next/navigation'
import SigninForm from '@/app/admin/signin/_components/SigninForm'
import { getUserSession } from '@/auth/getUserSession'
import React from 'react'
export default async function Page() {
const userSession = await getUserSession()
if (userSession) {
redirect('/')
}
return <SigninForm />
}
'use client'
import React, { FormEvent } from 'react'
import { useRouter } from 'next/navigation'
import { signIn } from 'next-auth/react'
import { Box, Button, FormHelperText, Link, TextField, Typography } from '@mui/material'
export default function SigninForm() {
const [errorText, setErrorText] = React.useState('')
const router = useRouter()
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
const formData = new FormData(event.currentTarget)
const data = {
email: formData.get('email'),
password: formData.get('password'),
}
signIn('credentials', {
redirect: false,
...data,
})
.then((res) => {
if (!res || res?.error) {
setErrorText('Authentication failed.')
return
}
router.push('/')
})
.catch(() => {
setErrorText('System error.')
})
}
return (
<Box
component='main'
sx={{
flexGrow: 1,
height: '100vh',
overflow: 'auto',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Box sx={{ width: 345, display: 'flex', flexDirection: 'column' }}>
<Typography variant='h5' textAlign='center'>
ログイン
</Typography>
<Box sx={{ m: 2 }} />
<Box
component='form'
onSubmit={handleSubmit}
sx={{ display: 'flex', flexDirection: 'column' }}
>
<TextField id='email' name='email' label='メールアドレス' variant='outlined' />
<Box sx={{ m: 1 }} />
<TextField
id='password'
name='password'
label='パスワード'
type='password'
autoComplete='current-password'
/>
<FormHelperText error>{errorText}</FormHelperText>
<Box sx={{ m: 2 }} />
<Button type='submit' variant='outlined'>
サインイン
</Button>
<Link href={'/admin/forgot-password'}>パスワードを忘れました</Link>
</Box>
</Box>
</Box>
)
}
user-list(コンポーネント)
:
+ ここから下を追記
/**
* yyyy-mm-dd hh:mm:ss で出力
*/
export function dateFormatHelper(d: Date | null): string {
if (d === null) {
return ''
}
const options = {
year: 'numeric' as const,
month: '2-digit' as const,
day: '2-digit' as const,
hour: '2-digit' as const,
minute: '2-digit' as const,
second: '2-digit' as const,
hour12: false,
}
return new Intl.DateTimeFormat('ja-JP', options).format(d).replace(/\//g, '-')
}
import { getServerSession } from 'next-auth'
import { redirect } from 'next/navigation'
import { ReactNode } from 'react'
import options from '@/auth/authOptions'
export default async function Layout({ children }: { children: ReactNode }) {
const session = await getServerSession(options)
if (!session) redirect('/admin/signin')
return <>{children}</>
}
import React from 'react'
import { UserListPage } from './_components/UserListPage'
import { PrismaClient } from '@/generated/prismaClient'
export default async function Page() {
const prisma = new PrismaClient()
const props = {
users: await prisma.users.findMany(),
}
return <UserListPage {...props} />
}
'use client'
import { Users } from '@/generated/prismaClient'
import React from 'react'
import { Box, Typography } from '@mui/material'
import { DataGrid, GridColDef, GridRowsProp } from '@mui/x-data-grid'
import { dateFormatHelper } from '@/util/date'
type Props = {
users: Users[]
}
export function UserListPage({ users }: Props) {
if (!users || users.length === 0) {
return <Typography variant='h5'>No data available.</Typography>
}
const rows: GridRowsProp = users.map((row) => ({
id: row.id,
name: row.name,
role: row.role,
email: row.email,
password: row.password,
passwordToken: row.passwordToken,
tokenExpiredAt: dateFormatHelper(row.tokenExpiredAt),
createdAt: dateFormatHelper(row.createdAt),
updatedAt: dateFormatHelper(row.updatedAt),
}))
const columns: GridColDef[] = [
{ field: 'id', headerName: '#' },
{ field: 'name', headerName: '名前' },
{ field: 'role', headerName: '役割' },
{ field: 'email', headerName: 'メール' },
{ field: 'password', headerName: 'パスワード' },
{ field: 'passwordToken', headerName: 'トークン' },
{ field: 'tokenExpiredAt', headerName: 'トークン有効期限', width: 160 },
{ field: 'createdAt', headerName: '作成日時', width: 160 },
{ field: 'updatedAt', headerName: '更新日時', width: 160 },
]
return (
<>
<p>保護されたページ</p>
<Box width='100%'>
<Typography variant='h6'>Users</Typography>
<DataGrid columns={columns} rows={rows} />
</Box>
</>
)
}
Next.js のチュートリアル
prismaを使うなどのアレンジを入れながら。公式が使ってるTailwind を剥いだり大変だったけどいい訓練になったわ。基本、できあがりをベタ貼りするから差分はじぶんで調べてくれ
Chapter 2 CSS Styling
"dependencies": {
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
+ "@heroicons/react": "^2.0.18",
"@mui/icons-material": "^5.14.16",
:
},
import React from 'react'
import { Box, Button, Grid, Link, Typography } from '@mui/material'
import { ArrowRightIcon } from '@heroicons/react/24/outline'
export default function Page() {
return (
<Box display='flex' flexDirection='column'>
{/* 青い領域(ヘッダ) */}
<Grid item xs={12}>
<Box height={200} width='100%' bgcolor='primary.main'>
<Typography sx={{ color: 'white' }}>保護されていないページ</Typography>
<Typography variant='h5' sx={{ color: 'white', marginBottom: 2 }}>
This is Header
</Typography>
</Box>
</Grid>
{/* ライトグレーの領域(コンテンツ領域) */}
<Box display='flex' flexDirection='row'>
<Box
width={400}
height={500}
bgcolor='#f0f0f0'
display='flex'
flexDirection='column'
padding='30px'
justifyContent='center'
>
<Box display='flex' flexDirection='column'>
<Typography variant='h5' sx={{ color: 'text.primary', marginBottom: 2 }}>
<strong>Welcome to Acme.</strong> This is the example for the{' '}
<Link href='https://nextjs.org/learn' color='primary'>
Next.js Learn Course
</Link>
, brought to you by Vercel.
</Typography>
<Link href='/admin/signin' underline='none'>
<Button
variant='contained'
color='primary'
sx={{ width: '200px', borderRedius: 2, textTransform: 'none' }}
>
Log in
<ArrowRightIcon
className='text-white'
style={{ width: '1rem', height: '1rem', paddingLeft: '10px' }}
/>
</Button>
</Link>
</Box>
</Box>
{/* ホワイトの領域 */}
<Box display='flex' alignItems='center' justifyContent='center'>
{/* ヒーローイメージを追加*/}
</Box>
</Box>
</Box>
)
}
Chapter 3 Optimizing Fonts and Images
- 上記リンクから絵をダウンロードし、
public
に保存hero-desktop.png
hero-mobile.png
import { Inter, Lusitana } from 'next/font/google'
export const inter = Inter({ subsets: ['latin'] })
export const lusitana = Lusitana({
weight: ['400', '700'],
subsets: ['latin'],
})
import { Box, Typography } from '@mui/material'
import { Language } from '@mui/icons-material'
import { lusitana } from './fonts'
import React from 'react'
export default function AcmeLogo() {
return (
<Box sx={{ display: 'flex', flexDirection: 'row', alignItems: 'flex-end', color: 'white' }}>
<Language sx={{ fontSize: '3rem', transform: 'rotate(15deg)' }} />
<Typography
className={`${lusitana.className}`}
variant='h2'
sx={{ fontSize: '2.75rem', marginLeft: '8px' }}
>
Acme
</Typography>
</Box>
)
}
import React from 'react'
import { inter } from './_component/fonts'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang='en'>
<body className={`${inter.className} antialiasing`}>{children}</body>
</html>
)
}
import React from 'react'
import { Box, Button, Grid, Hidden, Link, Typography } from '@mui/material'
import { ArrowRightIcon } from '@heroicons/react/24/outline'
import AcmeLogo from './_component/acme-logo'
import { lusitana } from './_component/fonts'
import Image from 'next/image'
export default function Page() {
return (
<Box display='flex' flexDirection='column'>
{/* 青い領域(ヘッダ) */}
<Grid item xs={12}>
<Box height={200} width='100%' bgcolor='primary.main'>
<Typography sx={{ color: 'white' }}>保護されていないページ</Typography>
<Typography variant='h5' sx={{ color: 'white', marginBottom: 2 }}>
<AcmeLogo />
</Typography>
</Box>
</Grid>
{/* ライトグレーの領域(コンテンツ領域) */}
<Box display='flex' flexDirection='row'>
<Box
width={400}
height={760}
bgcolor='#f0f0f0'
display='flex'
flexDirection='column'
padding='30px'
justifyContent='center'
>
<Box display='flex' flexDirection='column'>
<Typography
className={`${lusitana.className}`}
variant='h5'
sx={{ color: 'text.primary', marginBottom: 2 }}
>
<strong>Welcome to Acme.</strong> This is the example for the{' '}
<Link href='https://nextjs.org/learn' color='primary'>
Next.js Learn Course
</Link>
, brought to you by Vercel.
</Typography>
<Link href='/admin/signin' underline='none'>
<Button
variant='contained'
color='primary'
sx={{ width: '200px', borderRedius: 2, textTransform: 'none' }}
>
Log in
<ArrowRightIcon
className='text-white'
style={{ width: '1rem', height: '1rem', paddingLeft: '10px' }}
/>
</Button>
</Link>
</Box>
</Box>
{/* ホワイトの領域 */}
<Box display='flex' alignItems='center' justifyContent='center'>
<Hidden mdDown>
<Image
src='/hero-desktop.png'
width={1000}
height={760}
className='hidden md:block'
alt='Screenshots of the dashboard project showing desktop version'
/>
</Hidden>
<Hidden mdUp>
<Image
src='/hero-mobile.png'
width={560}
height={620}
className='block md:hidden'
alt='Screenshots of the dashboard project showing mobile version'
/>
</Hidden>
</Box>
</Box>
</Box>
)
}
Chapter 4 Creating Layouts and Pages
'use client'
import { PresentationChartBarIcon, HomeIcon, UserGroupIcon } from '@heroicons/react/24/outline'
import { Box, Link, Typography } from '@mui/material'
import { usePathname } from 'next/navigation'
import React from 'react'
// いったん固定値で書くけど本来はdbから引っ張ってきてね
const links = [
{ name: 'Home', href: '/', icon: HomeIcon },
{ name: 'Dashboard', href: '/dashboard', icon: PresentationChartBarIcon },
{ name: 'Users', href: '/user-list', icon: UserGroupIcon },
]
export default function NavLinks() {
const pathname = usePathname()
return (
<>
{links.map((link) => {
const LinkIcon = link.icon
return (
<Link key={link.name} href={link.href}>
<Box display='flex' flexDirection='row' marginTop='20px'>
<LinkIcon style={{ width: '2.5rem', height: '2.5rem', marginRight: '10px' }} />
<Typography
variant='h6'
color={pathname === link.href ? 'primary.main' : 'text.primary'}
>
{link.name}
</Typography>
</Box>
</Link>
)
})}
</>
)
}
'use client'
import React, { FormEvent } from 'react'
import { Box, Link, Typography } from '@mui/material'
import { PowerIcon } from '@heroicons/react/24/outline'
import NavLinks from './nav-links'
import AcmeLogo from './acme-logo'
import { useRouter } from 'next/navigation'
export default function SideNav() {
const router = useRouter()
const handleLogout = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
router.push('/')
}
return (
<Box display='flex' height='100%' flexDirection='column'>
{/* ACMEロゴ */}
<Box bgcolor='primary.main' height='200px'>
<Link href='/'>
<AcmeLogo />
</Link>
</Box>
<div>
{/* Home, Invoices, Customers のメニュー */}
<Box height='600px'>
<NavLinks />
</Box>
{/* サインアウト */}
<form onSubmit={handleLogout}>
<button type='submit'>
<PowerIcon
style={{ width: '2.5rem', height: '2.5rem', marginRight: '10px' }}
></PowerIcon>
<Typography variant='h5'>Sign Out</Typography>
</button>
</form>
</div>
</Box>
)
}
import React from 'react'
import { inter } from './_component/fonts'
import { Box } from '@mui/material'
import SideNav from './_component/sidenav'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang='en'>
<body className={`${inter.className} antialiasing`}>
<Box display='flex' height='100%' flexDirection='row' overflow='hidden'>
{/* サイドバー表示部 */}
<Box width='300px' border='1px solid blue' padding='10px'>
<SideNav />
</Box>
{/* コンテンツ表示部 */}
<Box>{children}</Box>
</Box>
</body>
</html>
)
}
Chapter 5 Navigating Between Pages
'use client'
import { DocumentDuplicateIcon, HomeIcon, UserGroupIcon } from '@heroicons/react/24/outline'
import { Box, Link, Typography } from '@mui/material'
import { usePathname } from 'next/navigation'
import React from 'react'
// いったん固定値で書くけど本来はdbから引っ張ってきてね
const links = [
{ name: 'Home', href: '/', icon: HomeIcon },
{ name: 'Invoices', href: '/invoices', icon: DocumentDuplicateIcon },
{ name: 'Customers', href: '/customers', icon: UserGroupIcon },
]
export default function NavLinks() {
const pathname = usePathname()
return (
<>
{links.map((link) => {
const LinkIcon = link.icon
return (
<Link key={link.name} href={link.href}>
<Box display='flex' flexDirection='row' marginTop='20px'>
<LinkIcon style={{ width: '2.5rem', height: '2.5rem', marginRight: '10px' }} />
<Typography
variant='h6'
color={pathname === link.href ? 'primary.main' : 'text.primary'}
>
{link.name}
</Typography>
</Box>
</Link>
)
})}
</>
)
}
Chapter 7 Fetching Data
:
model Revenue {
id Int @id @default(autoincrement())
month String
revenue Int
@@map("revenue")
}
model Customer {
id Int @id @default(autoincrement())
invoices Invoice[]
name String
email String
imageUrl String @map("image_url")
@@map("customer")
}
model Invoice {
id Int @id @default(autoincrement())
customer Customer @relation(fields: [customerId], references: [id])
customerId Int @map("customer_id")
amount Int
date String
status String @default("pending")
@@map("invoice")
}
npx prisma migrate dev --name dashboard
npx prisma generate
export type Revenue = {
month: string
revenue: number
}
export type Invoice = {
id: number
amount: number
customer: {
name: string
imageUrl: string
email: string
}
}
'use client'
import React from 'react'
import { Box, Link, Typography } from '@mui/material'
import { DataGrid, GridColDef, GridRenderCellParams, GridRowsProp } from '@mui/x-data-grid'
import { Invoice } from '@/types/types'
export default function InvoiceDataTable({ invoice }: { invoice: Invoice[] }) {
if (!invoice || invoice.length === 0) {
return <Typography variant='h5'>No data available.</Typography>
}
const rows: GridRowsProp = invoice.map((row) => ({
id: row.id,
customerName: row.customer.name,
amount: row.amount,
}))
const columns: GridColDef[] = [
{
field: 'id',
headerName: '請求額No.',
width: 100,
renderCell: (params: GridRenderCellParams<any>) => (
<>
<Link href={`/invoices/${params.id}`}>{params.value}</Link>
</>
),
},
{ field: 'customerName', headerName: '名前', width: 150 },
{ field: 'amount', headerName: 'amount', width: 150 },
]
return (
<Box width='100%'>
<Typography variant='h6'>Recent Invoice</Typography>
<DataGrid rows={rows} columns={columns}></DataGrid>
</Box>
)
}
'use client'
import React from 'react'
import { Box, Typography } from '@mui/material'
import { DataGrid, GridColDef, GridRowsProp } from '@mui/x-data-grid'
import { Revenue } from '@/types/types'
export default function RevenueDataTable({ revenue }: { revenue: Revenue[] }) {
if (!revenue || revenue.length === 0) {
return <Typography variant='h5'>No data available.</Typography>
}
const rows: GridRowsProp = revenue
const columns: GridColDef[] = [
{
field: 'month',
headerName: '月',
width: 150,
},
{ field: 'revenue', headerName: '収益', width: 150 },
]
return (
<Box width='100%'>
<Typography variant='h6'>Recent Revenue</Typography>
<DataGrid rows={rows} columns={columns}></DataGrid>
</Box>
)
}
import React from 'react'
import { Box, Typography } from '@mui/material'
import InvoiceDataTable from './_component/InvoiceDataTable'
import RevenueDataTable from './_component/RevenueDataTable'
export default async function Page() {
const response = await fetch('http://localhost:3000/api/dashboard')
const { revenue, invoice } = await response.json()
return (
<Box display='flex' flexDirection='column'>
<Typography variant='h4'>Dashboard</Typography>
<Box>
<RevenueDataTable revenue={revenue}></RevenueDataTable>
</Box>
<Box>
<InvoiceDataTable invoice={invoice}></InvoiceDataTable>
</Box>
</Box>
)
}
import { PrismaClient } from '@/generated/prismaClient'
import { NextRequest, NextResponse } from 'next/server'
import { serverError } from '@/util/apiHandler'
export async function GET(req: NextRequest) {
const prisma = new PrismaClient()
try {
const invoice = await prisma.invoice.findMany({
select: {
amount: true,
customer: {
select: {
name: true,
imageUrl: true,
email: true,
},
},
id: true,
},
orderBy: {
date: 'desc',
},
take: 5,
})
const revenue = await prisma.revenue.findMany()
return NextResponse.json({ invoice, revenue })
} catch (error) {
return serverError(error)
} finally {
await prisma.$disconnect()
}
}
### 200 OK
GET http://localhost:3000/api/dashboard
Accept: application/json
import { PrismaClient } from '@/generated/prismaClient'
async function main() {
const prisma = new PrismaClient()
await prisma.users.deleteMany()
await prisma.users.create({
data: {
name: '開発 太郎',
email: 'test@example.com',
password: 'hoge',
},
})
await prisma.customer.createMany({
data: [
{
id: 1,
name: 'Delba de Oliveira',
email: 'delba@olivaira.com',
imageUrl: '/customers/delba-de-oliveira.png',
},
{
id: 2,
name: 'Lee Robinson',
email: 'lee@robinson.com',
imageUrl: '/customers/lee-robinson.png',
},
{
id: 3,
name: 'Hector Simpson',
email: 'hector@simpson.com',
imageUrl: '/customers/hector-simpson.png',
},
{
id: 4,
name: 'Steven Tey',
email: 'steven@tey.com',
imageUrl: '/customers/steven-tey.png',
},
{
id: 5,
name: 'Steph Dietz',
email: 'steph@dietz.com',
imageUrl: '/customers/steph-diets.png',
},
{
id: 6,
name: 'Michael Novotny',
email: 'michael@novotny',
imageUrl: '/customers/michael-novotny.png',
},
{
id: 7,
name: 'Evil Rabbit',
email: 'evil@rabbit.com',
imageUrl: '/customers/evil-rabbit.png',
},
{
id: 8,
name: 'Emil komalski',
email: 'emil@komalski.com',
imageUrl: '/customers/emil-komalski.png',
},
{
id: 9,
name: 'Amy Burns',
email: 'amy@burns.com',
imageUrl: '/customers/amy-burns.png',
},
{
id: 10,
name: 'Balazs Orban',
email: 'balazs@orban.com',
imageUrl: '/customers/balazs-orban.png',
},
],
})
await prisma.invoice.createMany({
data: [
{
customerId: 1,
amount: 15795,
date: '2022-11-14',
status: 'pending',
},
{
customerId: 2,
amount: 20348,
date: '2022-11-14',
status: 'pending',
},
{
customerId: 5,
amount: 3040,
date: '2022-10-29',
status: 'paid',
},
{
customerId: 4,
amount: 44000,
date: '2023-09-10',
status: 'paid',
},
{
customerId: 6,
amount: 34577,
date: '2023-09-10',
status: 'paid',
},
{
customerId: 8,
amount: 54246,
date: '2023-07-16',
status: 'pending',
},
{
customerId: 7,
amount: 666,
date: '2023-06-27',
status: 'pending',
},
{
customerId: 4,
amount: 32545,
date: '2023-06-09',
status: 'paid',
},
{
customerId: 5,
amount: 1250,
date: '2023-06-17',
status: 'paid',
},
{
customerId: 6,
amount: 8546,
date: '2023-06-07',
status: 'paid',
},
{
customerId: 2,
amount: 500,
date: '2023-08-19',
status: 'paid',
},
{
customerId: 6,
amount: 8945,
date: '2023-06-03',
status: 'paid',
},
{
customerId: 3,
amount: 8945,
date: '2023-06-18',
status: 'paid',
},
{
customerId: 1,
amount: 8945,
date: '2023-10-04',
status: 'paid',
},
{
customerId: 3,
amount: 1000,
date: '2022-06-05',
status: 'paid',
},
],
})
await prisma.revenue.createMany({
data: [
{
month: 'Jan',
revenue: 2000,
},
{
month: 'Feb',
revenue: 1800,
},
{
month: 'Mar',
revenue: 2200,
},
{
month: 'Apr',
revenue: 2500,
},
{
month: 'May',
revenue: 2300,
},
{
month: 'Jun',
revenue: 3200,
},
{
month: 'Jul',
revenue: 3500,
},
{
month: 'Aug',
revenue: 3700,
},
{
month: 'Sep',
revenue: 2500,
},
{
month: 'Oct',
revenue: 2800,
},
{
month: 'Nov',
revenue: 3000,
},
{
month: 'Dec',
revenue: 4800,
},
],
})
prisma.$disconnect()
}
main().then(() => console.log('seeding ok.'))
いったん休憩