Next.js の独習<その1>

`Next.js` というのが何なのか分からない。独習してみる。

先にまとめ

  • Next.JS は恐らく、現代の開発者に エスケープ・シーケンス を覚えさせるのが大変だと思っているのか、 エスケープ・シーケンス を書かさせない仕組みになるよう コーディング文化を変更しようとしているのだと思う。それは 既存の構文を破壊してでも、あるいはむしろ積極的に行われている

  • Next.JS は恐らく、従来は[WebブラウザーでWebサイトを閲覧する]という仕組みだったものを機能不足だと思っていて、[Webブラウザーの上で動くアプリケーション]にしたいのだと思う。そのためには恐らく内部的には Java Script のコードが見たくもないようなすごいことになってると思う。内部を理解するより、ブラックボックスにしておいて、インターフェースを覚えろ、ということなのだろう。だからか、もとからある専門用語を変えて、別の用語を使っていることがある。読み替える必要があるケースがある

  • 総じて、昔に低級言語と高級言語が出てきたように、この度も、Web という文書閲覧から始まった仕組みに対して、デスクトップ・アプリのようにあれも欲しい、これも欲しいと人間側が要求してきたことを、結局はプログラマーの誰もが同じような土台のライブラリーを作っていたようなことに対して Meta社が肩代わりしてやってくれているんだと思う。そんな大変なことを利益を上げながらやってくれているのだから、ありがたく勉強して使わせてもらう感じ

  • これから勉強することは、コンピューターの方を向いてなくて、人間の方を向いていると考えると合ってると思う

ここから本文

インターネット上の記事を読むと、
Next.js は Node.js の上で動く Web フレームワークだとか、
Next.js は React の上で動くだとか、
クライアントサイド用だった React を、サーバーサイドでも利用できるように機能拡張するものだとか、
そういった説明が出てくるか、本当かどうかわからない

👆 React を使うか、 React をラッピングした Next.js を使うか、と言いたいらしい

だったら Next.js ⊂ React ⊂ Node.js のような包含関係がありそう

👆 React はクライアントサイドでページを生成するので、
サーバーサイドでページを生成する必要があるなら Next.js、
そうでなければ React
という分け方があるようだ。

名前が似ている Nuxt.js は Vue 用だから完全に別物のようだ

何を教材に Next.js を独習するべき?

👆 何を教材に Next.js を独習すべきか、情報がない。とりあえず公式を覗いてみる

公式の教材は やけに難しいことがあるが、開けてみないことには分からない。
入門者を歓迎しているテクノロジーの配布サイトでは、だいたい `Learn なんとか` というボタンが置いてあるのでそれをクリックする

Next.js は、フルスタック Web アプリケーション(※)を作れるのが自慢らしい。

※フルスタック Web アプリケーション … だいたい、クライアント・サイドと、サーバー・サイドの両方を開発している、ということを開発者が自慢しているぐらいの意味のもので、インターネット上にアクセスして使うアプリみたいなやつのこと

公式のチュートリアルでは、財務のダッシュボードを作ろう、という教材だそうだ。
注意書きとして React ぐらい使えるようになってから読めよ、といった趣旨のことが書いてある

Next.js の公式チュートリアルを進めるためのシステムの要件も つらつら書いてあるが、
わたしは Windows 11 のパソコンを持っていて WSL をインストールしており、WSL には Ubuntu 22.04 が入っていて、
そこに入っている node のバージョンは v20.16.0 だ。
これなら 行けそう

そして Git Hub アカウントと Vercel アカウント も持ってろよ、といった趣旨のことが書いてある。
Git Hub アカウントは持っているが、 Vercel アカウントとは何だろうか?
調べてみよう

Vercel アカウントを取得しよう

👆 Vercel というのは Next.js を開発した会社だそうだ。
とりあえず Vercel アカウントを開設した

pnpm というパッケージ・マネージャーをインストールしよう

公式のチュートリアルは、Webページの下の方まで読んで次へ進んでいけばいいようだ。
pnpm をインストールしろ、と書いてある

muzudho@Takahashi-PC:~$ npm install -g pnpm

added 1 package in 2s

1 package is looking for funding
  run `npm fund` for details

👆 インストールした

Next.js アプリを作れ

muzudho@Takahashi-PC:~$ mkdir nextjs_practice
muzudho@Takahashi-PC:~$ cd nextjs_practice
muzudho@Takahashi-PC:~/nextjs_practice$

👆 なんか分らんが とりあえずフォルダーを作っておく

muzudho@Takahashi-PC:~/nextjs_practice$ npx create-next-app@latest nextjs-dashboard --example "https://github.com/vercel/next-learn/tree/main/dashboard/starter-example" --use-pnpm

👆 なんか分らんが、コマンドを打鍵した

~前略~

Done in 7.7s

Success! Created nextjs-dashboard at /home/muzudho/nextjs_practice/nextjs-dashboard
Inside that directory, you can run several commands:

  pnpm run dev
    Starts the development server.

  pnpm run build
    Builds the app for production.

  pnpm start
    Runs the built app in production mode.

We suggest that you begin by typing:

  cd nextjs-dashboard
  pnpm run dev

👆 なんか分らんが、何かインストールされた

muzudho@Takahashi-PC:~/nextjs_practice$ cd nextjs-dashboard
muzudho@Takahashi-PC:~/nextjs_practice/nextjs-dashboard$

👆 指示されるがまま、コマンドを打鍵した

muzudho@Takahashi-PC:~/nextjs_practice/nextjs-dashboard$ ls
README.md  next-env.d.ts    node_modules  pnpm-lock.yaml     public              tsconfig.json
app        next.config.mjs  package.json  postcss.config.js  tailwind.config.ts

👆 多分、Webアプリケーションのソース一式が入っているディレクトリーなのだと思う

📁 app
    📁 lib
    📁 ui
📁 public
📁 scripts
📄 next.config.js
...

👆 公式チュートリアルにディレクトリーの説明がある

Web アプリケーションのディレクトリーを Visual Studio Code で開いてみよう

muzudho@Takahashi-PC:~/nextjs_practice/nextjs-dashboard$ code .

👆 Windows 11 の WSL の中で動いている Ubuntu 22.04 のターミナルで、
`code .` と打鍵すると、外側の方のホストの Windows 11 で Visual Studio Code (以下、VSCode と略記)が開いて、
WSL の中の Ubuntu 22.04 のファイルを編集できるようになる

なんだか分からないが 内容物のテキスト・ファイルを開いて中身を眺めていく

`*.ts` ファイルが Type Script だろうか?
`*.tsx` ファイルは、Type Script の中に XMLタグのようなものが入ってる?

プレースホルダー・データとは

わたしが「ダミー・データ」とか勝手に呼んでるものは、「プレースホルダー・データ」(Placeholder data)と呼ばれているらしい

`nextjs-dashboard/app/lib/placeholder-data.ts` ファイルにプレースホルダー・データが入ってる

// This file contains placeholder data that you'll be replacing with real data in the Data Fetching chapter:
// https://nextjs.org/learn/dashboard-app/fetching-data
const users = [
  {
    id: '410544b2-4001-4271-9855-fec4b6a6442a',
    name: 'User',
    email: 'user@nextmail.com',
    password: '123456',
  },
];

const customers = [
  {
    id: 'd6e15727-9fe1-4961-8c5b-ea44a9bd81aa',
    name: 'Evil Rabbit',
    email: 'evil@rabbit.com',
    image_url: '/customers/evil-rabbit.png',
  },
  {
    id: '3958dc9e-712f-4377-85e9-fec4b6a6442a',
    name: 'Delba de Oliveira',
    email: 'delba@oliveira.com',
    image_url: '/customers/delba-de-oliveira.png',
  },

~以下略~

データベースのテーブルの設定のようなものは、 `app/lib/definitions.ts` ファイルに書いてる

// This file contains type definitions for your data.
// It describes the shape of the data, and what data type each property should accept.
// For simplicity of teaching, we're manually defining these types.
// However, these types are generated automatically if you're using an ORM such as Prisma.
export type User = {
  id: string;
  name: string;
  email: string;
  password: string;
};

export type Customer = {
  id: string;
  name: string;
  email: string;
  image_url: string;
};

~以下略~

👆 テーブル名と、列名と、列の型だろうか?

実践的には、手動で definitions.ts を書くのではなく、
PrismaDrizzle を利用して自動でそれを作るのらしい。
なんのこっちゃ

開発サーバーを走らせろ

muzudho@Takahashi-PC:~/nextjs_practice/nextjs-dashboard$ pnpm i
Lockfile is up to date, resolution step is skipped
Packages: +227
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Progress: resolved 227, reused 227, downloaded 0, added 0, done
Done in 2.8s

👆 なんか分らんが、指示のままにコマンドを叩いた

muzudho@Takahashi-PC:~/nextjs_practice/nextjs-dashboard$ pnpm dev

> @ dev /home/muzudho/nextjs_practice/nextjs-dashboard
> next dev

  ▲ Next.js 15.0.0-canary.56
  - Local:        http://localhost:3000

 ✓ Starting...
Attention: Next.js now collects completely anonymous telemetry regarding usage.
This information is used to shape Next.js' roadmap and prioritize features.
You can learn more, including how to opt-out if you'd not like to participate in this anonymous program, by visiting the following URL:
https://nextjs.org/telemetry

 ✓ Ready in 1722ms

👆 上記のコマンドを打鍵すると、開発モードでサーバーが走る?

http://localhost:3000 をWebブラウザで開いてみる

Welcome to Acme. This is the example for the Next.js Learn Course, brought to you by Vercel.

Log in

👆 なんか Webページが出た。
Log in のリンクはまだ 404 でページが見つからないエラーのようだ

チュートリアルによると、 これは CSS でスタイルを設定するところがまだできていないので、直しましょう、という勉強をするステップのようだ

チャプター2

👆 `app/ui/global.css` ファイルを見ろ、とある。
そのファイルは Webサイトの全体の設定なのか? Webサイト構成の最上位の `*.tsx` ファイルでインポートしろと推奨されてる

import '@/app/ui/global.css';

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

👆 `app/layout.tsx` ファイルの1行目で、 `app/ui/global.css` ファイルをインポートしろ、という指示があったので、そうする

👇 (ここで、執筆の日が変わったので)WSL を起動する復習

C:\Users\muzud>wsl -d Ubuntu

muzudho@Takahashi-PC:/mnt/c/Users/muzud$ cd ~/nextjs_practice/nextjs-dashboard

muzudho@Takahashi-PC:~/nextjs_practice/nextjs-dashboard$ pnpm dev

 http://localhost:3000 へアクセス

http://localhost:3000 のWebページでは、CSS のレイアウトが適用されているようだ

`/app/ui/global.css` ファイルの `@tailwind` ディレクティブ

テイルワインドは CSS の書き方を変える仕掛け

<h1 className="text-blue-500">I'm blue!</h1>

👆 テイルワインドで、 `className` アトリビュートを機能追加している

`/app/page.tsx` ファイルを見てもらえば、テイルワインドの使用例が分かるとのこと

import AcmeLogo from '@/app/ui/acme-logo';
import { ArrowRightIcon } from '@heroicons/react/24/outline';
import Link from 'next/link';

export default function Page() {
  return (
    <main className="flex min-h-screen flex-col p-6">
      <div className="flex h-20 shrink-0 items-end rounded-lg bg-blue-500 p-4 md:h-52">
        {/* <AcmeLogo /> */}
      </div>
      <div className="mt-4 flex grow flex-col gap-4 md:flex-row">
        <div className="flex flex-col justify-center gap-6 rounded-lg bg-gray-50 px-6 py-10 md:w-2/5 md:px-20">
          <p className={`text-xl text-gray-800 md:text-3xl md:leading-normal`}>
            <strong>Welcome to Acme.</strong> This is the example for the{' '}
            <a href="https://nextjs.org/learn/" className="text-blue-500">
              Next.js Learn Course
            </a>
            , brought to you by Vercel.
          </p>
          <Link
            href="/login"
            className="flex items-center gap-5 self-start rounded-lg bg-blue-500 px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-blue-400 md:text-base"
          >
            <span>Log in</span> <ArrowRightIcon className="w-5 md:w-6" />
          </Link>
        </div>
        <div className="flex items-center justify-center p-6 md:w-3/5 md:px-28 md:py-12">
          {/* Add Hero Images Here */}
        </div>
      </div>
    </main>
  );
}

👆 className アトリビュートを使って、スタイルを設定しているようだ

<div
  className="relative w-0 h-0 border-l-[15px] border-r-[15px] border-b-[26px] border-l-transparent border-r-transparent border-b-black"
/>

👆 上記のコードを `<p>` タグの上に挿入しろ、と指示がある。
そのようにしてファイルを保存した

自動更新されて、微妙に文章の縦位置が動いただけに見える。
しかしそれでは、ページ末尾のクイズの回答に答えられない

そこで Web ブラウザーで `[Ctrl] + [F5]` を打鍵してスーパー・リロードすると、
黒塗りの三角形が表示された

CSS モジュール

テイルワインドを用いず、従来の CSS を `*.tsx` ファイル内で用いる方法の説明もある

以下のファイルを `/app/ui/home.module.css` という名前で保存する

.shape {
  height: 0;
  width: 0;
  border-bottom: 30px solid black;
  border-left: 20px solid transparent;
  border-right: 20px solid transparent;
}

👆 上記の CSS ファイルを …

import AcmeLogo from '@/app/ui/acme-logo';
import { ArrowRightIcon } from '@heroicons/react/24/outline';
import Link from 'next/link';
import styles from '@/app/ui/home.module.css';


export default function Page() {
  return (
    <main className="flex min-h-screen flex-col p-6">
      <div className="flex h-20 shrink-0 items-end rounded-lg bg-blue-500 p-4 md:h-52">
        {/* <AcmeLogo /> */}
      </div>
      <div className="mt-4 flex grow flex-col gap-4 md:flex-row">
        <div className="flex flex-col justify-center gap-6 rounded-lg bg-gray-50 px-6 py-10 md:w-2/5 md:px-20">
          {/*
          <div
            className="relative w-0 h-0 border-l-[15px] border-r-[15px] border-b-[26px] border-l-transparent border-r-transparent border-b-black"
          />
          */}
          <div
            className="{styles.shape}"
          />
          <p className={`text-xl text-gray-800 md:text-3xl md:leading-normal`}>
            <strong>Welcome to Acme.</strong> This is the example for the{' '}
            <a href="https://nextjs.org/learn/" className="text-blue-500">
              Next.js Learn Course
            </a>
            , brought to you by Vercel.
          </p>
          <Link
            href="/login"
            className="flex items-center gap-5 self-start rounded-lg bg-blue-500 px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-blue-400 md:text-base"
          >
            <span>Log in</span> <ArrowRightIcon className="w-5 md:w-6" />
          </Link>
        </div>
        <div className="flex items-center justify-center p-6 md:w-3/5 md:px-28 md:py-12">
          {/* Add Hero Images Here */}
        </div>
      </div>
    </main>
  );
}

👆 `/app/page.tsx` ファイルでインポートする

<div className={styles.shape} />

👆 スタイルシートの設定方法も変わっていることに注意

`clsx` ライブラリー

`clsx` は、条件付きで、適用するスタイルのクラスを切り替える仕組みのようだ

👇 `/app/ui/invoices/status.tsx` ファイル

import { CheckIcon, ClockIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';

export default function InvoiceStatus({ status }: { status: string }) {
  return (
    <span
      className={clsx(
        'inline-flex items-center rounded-full px-2 py-1 text-xs',
        {
          'bg-gray-100 text-gray-500': status === 'pending',
          'bg-green-500 text-white': status === 'paid',
        },
      )}
    >
      {status === 'pending' ? (
        <>
          Pending
          <ClockIcon className="ml-1 w-4 text-gray-500" />
        </>
      ) : null}
      {status === 'paid' ? (
        <>
          Paid
          <CheckIcon className="ml-1 w-4 text-white" />
        </>
      ) : null}
    </span>
  );
}

👆 className アトリビュートの値の中で …

'bg-gray-100 text-gray-500': status === 'pending',
'bg-green-500 text-white': status === 'paid',

👆 status 値に応じて、どちらのスタイルを取るか指定できるような書き方をしている

チャプター3

👆 google フォントを使ってみる例がある

👇 `/app/ui` フォルダーに `fonts.ts` ファイルを作成する

import { Inter } from 'next/font/google';
 
export const inter = Inter({ subsets: ['latin'] });

👇 次に `/app/layout.tsx` ファイルで inter を利用する

import '@/app/ui/global.css';
import { inter } from '@/app/ui/fonts';

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

練習問題も付いている。
`Lusitana` フォントをインポートして `/app/page.tsx` の `<p>` 要素に設定しろという指示だ

👆 Google Fonts というWebサイトには `Lusitana` というフォントがあるようだ。
じゃあ、Google Fonts には `Inter` フォントもあるのか?

👆 Inter フォントもあった

じゃあ …

import { Inter, Lusitana } from 'next/font/google';
 
export const inter = Inter({ subsets: ['latin'] });
export const lusitana = Lusitana({ subsets: ['latin'] });

👆 フォントの規格に直交性があるなら、上記のように書けば
Lusitana フォントが使えるのではないか?

`app/page.tsx` では、以下のように書いたら Lusitana フォントが使えるか?

import AcmeLogo from '@/app/ui/acme-logo';
import { ArrowRightIcon } from '@heroicons/react/24/outline';
import Link from 'next/link';
/*
import styles from '@/app/ui/home.module.css';
*/
import { lusitana } from '@/app/ui/fonts';


export default function Page() {
  return (
    <main className="flex min-h-screen flex-col p-6">
      <div className="flex h-20 shrink-0 items-end rounded-lg bg-blue-500 p-4 md:h-52">
        {/* <AcmeLogo /> */}
      </div>
      <div className="mt-4 flex grow flex-col gap-4 md:flex-row">
        <div className="flex flex-col justify-center gap-6 rounded-lg bg-gray-50 px-6 py-10 md:w-2/5 md:px-20">
          <div
            className="relative w-0 h-0 border-l-[15px] border-r-[15px] border-b-[26px] border-l-transparent border-r-transparent border-b-black"
          />
          {/*
          <div
            className="{styles.shape}"
          />
           */}
          <p className={`text-xl text-gray-800 md:text-3xl md:leading-normal ${lusitana.className} antialiased`}>
            <strong>Welcome to Acme.</strong> This is the example for the{' '}
            <a href="https://nextjs.org/learn/" className="text-blue-500">
              Next.js Learn Course
            </a>
            , brought to you by Vercel.
          </p>
          <Link
            href="/login"
            className="flex items-center gap-5 self-start rounded-lg bg-blue-500 px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-blue-400 md:text-base"
          >
            <span>Log in</span> <ArrowRightIcon className="w-5 md:w-6" />
          </Link>
        </div>
        <div className="flex items-center justify-center p-6 md:w-3/5 md:px-28 md:py-12">
          {/* Add Hero Images Here */}
        </div>
      </div>
    </main>
  );
}

localhost:3000 のWebページを開くと、エラーが表示されている

Next.js (15.0.0-canary.56) is outdated (learn more)

Build Error

Failed to compile

app/ui/fonts.ts

`next/font` error
Missing weight for font `Lusitana`.
Available weights: `400`, `700`

👆 フォントの太さがどうのこうの

👆 weight を明示的に指定しなければならないのか?

`app/ui/fonts.ts` を以下のように修正

import { Inter, Lusitana } from 'next/font/google';
 
export const inter = Inter({ subsets: ['latin'] });
export const lusitana = Lusitana({
    subsets: ['latin'],
    weight: ['400', '700'],
 });

👆 これでエラーは消えた。フォントも適用されている気分はする

`/app/page.tsx` に `<AcmeLogo>` を追加

      <div className="flex h-20 shrink-0 items-end rounded-lg bg-blue-500 p-4 md:h-52">
        <AcmeLogo />
      </div>

👆 なんだか分からないが指示されたとおりにすると、フォント付きの画像のようなものが出てきた。このフォントが Lusitana なのだろう

画像表示

Next.JS は最上位の `/public` フォルダーの下に、画像のような静的アセットを置いておけるそうだ

Next.JS では画像をデバイスに合わせて引き延ばしたりを自動化しているらしく、その他にもいろいろ工夫を入れており、これらを 画像の最適化 と呼んでいるようだ

Next.JS では、画像を表示するときは、HTML の `<img>` タグを拡張した `<Image>` コンポーネントを使うのだそうだ

Webサイトをデスクトップで見たときと、モバイルで見たときで表示する画像を変えるといったことができるようだ

画像ファイルは
`/public/hero-desktop.png` と
`/public/hero-mobile.png` の2つが予め用意されている

`/app/page.tsx` ファイルに手を加える

import Image from 'next/image';
      {/* Add Hero Images Here */}
      <Image
        src="/hero-desktop.png"
        width={1000}
        height={760}
        className="hidden md:block"
        alt="Screenshots of the dashboard project showing desktop version"
      />

👆 以上は、デスクトップで表示される画像のようだ

モバイル用の `<Image>` タグは自分で書け、という演習問題になっている。

          <Image
            src="/hero-mobile.png"
            width={560}
            height={620}
            className="block md:hidden"
            alt="Screenshot of the dashboard project showing mobile version"
          />

👆 答えを見て書く。2つの `<Image>` タグが並ぶように書く

デスクトップとモバイルの両方で確認するよう指示があるが、
localhost をモバイルで確認する方法を作れないので、
ブラウザの開発者モードにモバイル表示がなかったか調べる

👆 Google Chrome で `[F12]` キーを押して開発者モードにして、
スマホ画面にするアイコンがあったんで それをクリックして、
ドロップダウンリストから機種も選べるので選んで、
一応 画像が差し変わることが確認できた

読んでおくといい記事へのリンクも一覧されていた

チャプター4

👆 この章では、レイアウトとページの作成を学ぶのだそうだ

👇 ルート・セグメント(Root Segment) というのは以下のようなものだそうだ

📁 app                    # (1) Root Segment
    📁 dashboard          # (2) Segment
        📁 invoices       # (3) Leaf Segment

👇 URLセグメント(URL segment) というのは以下のようなものだそうだ

acme.com /   dashboard / invoices
         -   ---------   --------
         (1) (2)         (3)

         ------------------------
         URL Path

👆 上記の ルート・セグメントと、URLセグメントをマッピング(対応付け)することを ネステッド・ルーティング(Nested routing)と呼んでいるのだろうか?

`layout.tsx` ファイルと、 `page.tsx` ファイルが重要なのだそうだ

`app` フォルダーの下に、 `dashboard` フォルダーを作成する。
さらに `dashboard` フォルダーの下に `page.tsx` テキストファイルを作成する

`app/dashboard/page.tsx`

👆 こう配置すると、ダッシュボード・アプリケーションの `page.tsx` ファイルという見方をすればいいのか?
内容は以下のように記入する

export default function Page() {
  return <p>Dashboard Page</p>;
}

次に http://localhost:3000/dashboard へアクセスする

これで、 `Dashboard` とだけ書かれたページが表示される

演習問題で2つのページを追加するよう指示があるのでやっておく

複数ページに跨るナビゲーションを作る

`/app/dashboard/layout.tsx` ファイルを作成する。
内容は以下のように書く

import SideNav from '@/app/ui/dashboard/sidenav';
 
export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <div className="flex h-screen flex-col md:flex-row md:overflow-hidden">
      <div className="w-full flex-none md:w-64">
        <SideNav />
      </div>
      <div className="flex-grow p-6 md:overflow-y-auto md:p-12">{children}</div>
    </div>
  );
}

👆 これでサイド・メニュー(サイド・ナビゲーション)が付く

<SideNav />
<div className="flex-grow p-6 md:overflow-y-auto md:p-12">{children}</div>

👆 `<SideNav /> や `{children}` に仕掛けがあるんだろうか?

Next.js では、ページの移動時は、ページの部分だけ再描画されて、
ナビゲーションは再描画されないのが自慢のようだ

`/app/layout.tsx` は、ルート・レイアウトと呼ぶ

ルート・レイアウトは必ず必要とのこと

ルート・レイアウトには、HTMLで必要な `<html>` と `<body>` タグが含まれている

チャプター5

👆 ナビゲーションを作っていく?

従来の HTML の `<a>` タグでは、ページ全体が再描画(レンダリング)されてしまう。 Node.JS では、再描画が必要なところだけを再描画する 部分レンダリング を行えるのが自慢らしい

`<Link>` コンポーネント

`/app/ui/dashboard/nav-links.tsx` ファイルを開けとの指示

import Link from 'next/link';

👆 公式のチュートリアルを見ながら上記の行を追加して、 `<a>` タグを `<Link>` タグへ差し替える

これで、画面遷移時に、部分レンダリングされるようになる

Next.JS では、ナビゲーションで画面遷移する体験を向上させる工夫を内蔵している。
アプリケーションをルート・セグメント(route segments)で分割して持っているから、それができる。
そして ルート・セグメント ごとにコードが分離されているから、
1つのルート・セグメントでエラーが起こっても、他のルート・セグメントは続行できる

Next.JS は、運用環境では、リンクがビューポートに表示されたタイミングで、ルート・セグメントのコードをプリフェッチ(prefetches;先読み)する。
ユーザーがクリックするタイミングでは、宛先のページはすでにバックグラウンドで読み込んでいて、すぐに表示される

アクティブ・リンクとは

アクティブ・リンクとは、ユーザーが現在どのページにいるか分かるように目立たせたリンクのこと

Next.JS には usePathname() というフックがある

👇 `/app/ui/dashboard/nav-links.tsx` を開き、以下の2行を埋め込む

'use client';
import { usePathname } from 'next/navigation';

また …

export default function NavLinks() {
  const pathname = usePathname();
  // ...
}

👆 pathname 定数を作っておく

import clsx from 'clsx';

👆 clsx もインポート

            className={clsx(
              'flex h-[48px] grow items-center justify-center gap-2 rounded-md bg-gray-50 p-3 text-sm font-medium hover:bg-sky-100 hover:text-blue-600 md:flex-none md:justify-start md:p-2 md:px-3',
              {
                'bg-sky-100 text-blue-600': pathname === link.href,
              },
            )}

👆 className は、clsx を使って書き直し

http://localhost:3000/dashboard を Webブラウザーで開けてみるとエラーが出ている

1 of 1 error    Next.js (15.0.0-canary.56) is outdated (learn more)

Unhandled Runtime Error

Error: Unsupported Server Component type: undefined

Call Stack
Next.js
~以下略~

👆 このエラーメッセージでは よくわからん

'use client';

👆 この書き方は、シングルクォーテーションの位置がおかしくないか?

use 'client';

👆 use が予約語で、 client は文字列なのでは?
と思ったが この書き方はエラーになった

しかし `'use client';` を書いたタイミングでこのエラーが出ているようだ

👆 調べる。
`use client` は、次の意味ではない。
「このコンポーネントは Client Component だよ」
`use client` は、次の意味だ。
「このコンポーネントと子どもたちは Client Component だよ」

それでも分からない

https://www.reddit.com/r/nextjs/comments/1e06oeo/nextjs_chapter_5_tutorial_issue_error_unsupported/

👆 似たような問題に当たっている記事を探す。
サーバーの再起動をすると勝手に直るらしい。
WSL のターミナルで `[Ctrl] + [C]` を打鍵

pnpm dev

👆 サーバーをまた起動

それだけでは直らない。 Google Chrome で `[Ctrl] + [F5]` を打鍵しスーパーリロード。
WSL のターミナルにログが出た

 ○ Compiling /dashboard ...
 ✓ Compiled /dashboard in 4.8s (562 modules)
 ✓ Compiled in 412ms (276 modules)
 GET /dashboard 200 in 5260ms
 GET /dashboard 200 in 96ms

👆 コンパイルが走った。
これで アクティブ・リンクが表示された

チャプター6

👆 データベースを設定する章だろうか?
公式チュートリアルでは PostgreSQL を使うそうだ

また、Git Hub でソースコードを管理することもこの章でやるらしい。

👆 もうやってるからそれはスキップする

また、 Vercel アカウントを作っておけと指示があるが、
それも もうやってるから スキップする

Vercel に、 GitHub にある nextjs-dashboard リポジトリをデプロイする

Vercel にログインして、最初のプロジェクトをデプロイしろ、という指示がある。
なんだかわからないが、とりあえず Git Hub に上げている nextjs-dashboard リポジトリを Vercel にデプロイしてみる。
インストールしたり、インポートしたり、デプロイしたり、指示通りやるが、何やってるかさっぱり分からない

どうも、レンタルサーバーにインスタンスが立ち上がって、
Web サーバーが立ったのだろうか?

`Visit` ボタンを押してみた

👆 サーバーが立ったっぽい?

デプロイに少しタイムラグがあるが、特に操作なく、
GitHub にソースをアップロードすると、
Vercel にデプロイされた Web アプリケーションも更新されるようだ。
これは楽だ。最近の開発環境はエコシステムが進んでいる

Vercel に PostgreSQL データベース・サーバーを立てる?

昔はデータベース・サーバーといえば、ローカルPCに立てて練習するか、
レンタルサーバーを借りてインストールするところから始めたものだが、
今では クラウドに借りて しかも無料で練習できるんだろうか?

とりあえず、(練習は次の日に跨ったので)わたしの Vercel アカウントにログインするところからやり直す

👆 メインメニューの `Storage` をクリック

さらに `Postgres` の `Create` ボタンをクリック

自分が住んでるとこに近いリージョン(地域)を選べとのこと

👆 わたしにはシンガポールが良さそう

`.env.local` タブをクリックする

POSTGRES_URL="************"
POSTGRES_PRISMA_URL="************"
POSTGRES_URL_NO_SSL="************"
POSTGRES_URL_NON_POOLING="************"
POSTGRES_USER="************"
POSTGRES_HOST="************"
POSTGRES_PASSWORD="************"
POSTGRES_DATABASE="************"

👆 何も設定されていない秘密のデータが出てくる

VSCode に戻る

👇 `.env.example` ファイルを開く

# Copy from .env.local on the Vercel dashboard
# https://nextjs.org/learn/dashboard-app/setting-up-your-database#create-a-postgres-database
POSTGRES_URL=
POSTGRES_PRISMA_URL=
POSTGRES_URL_NON_POOLING=
POSTGRES_USER=
POSTGRES_HOST=
POSTGRES_PASSWORD=
POSTGRES_DATABASE=

# `openssl rand -base64 32`
AUTH_SECRET=
AUTH_URL=http://localhost:3000/api/auth

`.env.example` ファイルをコピーして貼り付けし、 `.env` にリネームする。
`.env.local` の内容をコピー(上書き?)する

# Copy from .env.local on the Vercel dashboard
# https://nextjs.org/learn/dashboard-app/setting-up-your-database#create-a-postgres-database
POSTGRES_URL="************"
POSTGRES_PRISMA_URL="************"
POSTGRES_URL_NO_SSL="************"
POSTGRES_URL_NON_POOLING="************"
POSTGRES_USER="************"
POSTGRES_HOST="************"
POSTGRES_PASSWORD="************"
POSTGRES_DATABASE="************"

# `openssl rand -base64 32`
AUTH_SECRET=
AUTH_URL=http://localhost:3000/api/auth

👆 `POSTGRES_URL_NO_SSL` という項目が1つ増えた

また …

# local env files
.env*.local
.env

# vercel
.vercel

👆 `.gitignore` ファイルに `.env` が含まれていることを確認する。
これで秘密ファイルの `.env` ファイルをリポジトリーにアップロードすることを防ぐ

muzudho@Takahashi-PC:~/nextjs_practice/nextjs-dashboard$ pnpm i @vercel/postgres

👆 ローカルPCで、もう1つ WSL を走らせるターミナルを開いて、
上記のコマンドを打鍵する

 WARN  6 deprecated subdependencies found: are-we-there-yet@2.0.0, gauge@3.0.2, glob@7.2.3, inflight@1.0.6, npmlog@5.0.1, rimraf@3.0.2
Packages: +2 -2
++--
Progress: resolved 251, reused 227, downloaded 0, added 2, done
 WARN  Issues with peer dependencies found
.
└─┬ next 15.0.0-canary.56
  ├── ✕ unmet peer react@19.0.0-rc.0: found 19.0.0-rc-f38c22b244-20240704
  └── ✕ unmet peer react-dom@19.0.0-rc.0: found 19.0.0-rc-f38c22b244-20240704

Done in 4.8s

👆 古くなっているパッケージを含んでいるようだが、無視する。
これで Vercel Postgres SDK がインストールされた?

データベースをシードする

データベースに初期データを入れることを シード(Seed) って呼んでいるのか? 使われている言葉がよくわからない。
あるいは ファイルに元データをハードコーディングしていて、それをデータベースに入れようとしているのか?

`/app/seed/route.ts` というファイルがあり、その中にコメントアウトされているコードがあるので、コメントアウトを外す

export async function GET() {
  // return Response.json({
  //   message:
  //     'Uncomment this file and remove this line. You can delete this file when you are finished.',
  // });
  try {
    await client.sql`BEGIN`;
    await seedUsers();
    await seedCustomers();
    await seedInvoices();
    await seedRevenue();
    await client.sql`COMMIT`;

    return Response.json({ message: 'Database seeded successfully' });
  } catch (error) {
    await client.sql`ROLLBACK`;
    return Response.json({ error }, { status: 500 });
  }
}

👆 ソースコードを読むと、 `GET()` 関数では コメントアウトしているところと、そうでないところを逆さにしろということではないか

テーブル作ったり、データを挿入したりしている

localhost:3000/seed にアクセスすれば、データベースの作成が始まるということだろう。アクセスしてみる

1 of 1 error    Next.js (15.0.0-canary.56) is outdated (learn more)

VercelPostgresError: VercelPostgresError - 'invalid_connection_string': This connection string is meant to be used with a direct connection. Make sure to use a pooled connection string or try `createClient()` instead.

This error happened while generating the page. Any console logs will be displayed in the terminal window.

Source

app/seed/route.ts (5:25) @ connect

  3| import { invoices, customers, revenue, users } from '../lib/placeholder-data';
  4|
> 5| const client = await db.connect();
  6|
  7| async function seedUsers() {

Call Stack

~以下略~

👆 `.env` ファイルでデータベース接続先が `****` みたいになってるのがダメなのでは?

じゃあ、あのファイル、自分で設定しないといけないのか?

Make sure you reveal the secrets before copying them.

https://nextjs.org/learn/dashboard-app/setting-up-your-database

👆 なんか流暢なフレーズ出てきたな、と読み流していたが、シークレットの部分を自分で設定しろ、と指示していたのか

POSTGRES_URL="************"

ポストグレスのURLって何だ? Versal の Storage にデプロイされてると思うんで、そのURLを探したらいいのか?

いろいろ悩んでいると `Show secret` というリンクを見つけたのでクリックしたら、情報が開示された。
これを `.env` にコピーしたら、
Webブラウザーで開けていた `http://localhost:3000/seed` がリロードされて

{"message":"Database seeded successfully"}

と表示されていた。データベースのシードは完了だ。
じゃあもう `/app/seed/route.js` ファイルは削除してよいとのこと。
あとでどこかで失敗してやり直すかもしれないのでとりあえず残しておく

注意書きとして、この公式チュートリアルは練習用なんで、運用に使う前にはよく考えろといった感じの説明がある

Versal サイトの Storage メインメニューから、サイドメニューの Data へ

サイドメニューの Data から、データベースの中身を見てみろ、と指示があるので見てみる

`/app/lib/placeholder-data.ts` ファイルの内容がデータベースに入っていることを確認する。
users テーブルの password 列の値は暗号化されていることを確認する

クエリーを実行する

同じ Data のページの `Query` タブをクリックすると、
クエリーを入力できるテキストボックスが出てくる

SELECT invoices.amount, customers.name
FROM invoices
JOIN customers ON invoices.customer_id = customers.id
WHERE invoices.amount = 666;

👆 上記をコピー&ペーストして `Run Query` ボタンをクリック

クエリーの結果が同じ画面に表示された

チャプター7

👆 データベースの構築が終わったので、次はデータベースを使ってデータを画面に表示したりする方法などを教えてくれるそうだ

データベースへのアクセスには SQL や ORM を使うそうだ。 ORM って何だ?

👆 SQL を文字列連結すると SQLインジェクション の脆弱性があるから、 ORM 使えるんだったら ORM 使いたいな

とりあえず指示通り `/app/lib/data.ts` ファイルのソースコードを眺めてみる

import { sql } from '@vercel/postgres';

👆 SQL を利用できるように、 sql オブジェクトをインポートしている

データベースへのアクセスは全部 `/app/lib/data.ts` ファイルの中に書いてしまおう、という感じだそうだ

とりあえず指示通り `/app/dashboard/page.tsx` ファイルを開きソースコードを眺めてみる

export default function Page() {
    return <p>Dashboard Page</p>;
}

👆 前に自分で作ったファイルだ

👇 これを以下のように書き換える。どこを変えるというより、全部をコピー貼り付けで上書きだ

import { Card } from '@/app/ui/dashboard/cards';
import RevenueChart from '@/app/ui/dashboard/revenue-chart';
import LatestInvoices from '@/app/ui/dashboard/latest-invoices';
import { lusitana } from '@/app/ui/fonts';
 
export default async function Page() {
  return (
    <main>
      <h1 className={`${lusitana.className} mb-4 text-xl md:text-2xl`}>
        Dashboard
      </h1>
      <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
        {/* <Card title="Collected" value={totalPaidInvoices} type="collected" /> */}
        {/* <Card title="Pending" value={totalPendingInvoices} type="pending" /> */}
        {/* <Card title="Total Invoices" value={numberOfInvoices} type="invoices" /> */}
        {/* <Card
          title="Total Customers"
          value={numberOfCustomers}
          type="customers"
        /> */}
      </div>
      <div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-4 lg:grid-cols-8">
        {/* <RevenueChart revenue={revenue}  /> */}
        {/* <LatestInvoices latestInvoices={latestInvoices} /> */}
      </div>
    </main>
  );
}

👆 ほとんどコメントアウトされているので、Webページには `Dashboard` と表示されるだけだ

指示通りコードを追加したり、
`/app/ui/dashboard/revenue-chart.tsx` を開いてみたり、
アンコメントしたり、
いろいろやる

そうすると Dashboard と書かれていたページに棒グラフが表示された

同様に `<LatestInvoices>` タグも有効になるように改造する

また、 `<Card> ` タグを自力実装する演習問題も解く

リクエスト・ウォーターフォール

リクエストA、B、Cがあるとき、リクエストAが終わってからでないとリクエストBが始まらず、リクエストBが終わってからでないとリクエストCが始まらないことを リクエスト・ウォーターフォール と呼んでいるようだ

const revenue = await fetchRevenue();
const latestInvoices = await fetchLatestInvoices(); // wait for fetchRevenue() to finish

👆 `fetchRevenue();` が終わってからでないと `fetchLatestInvoices();` を実行してはいけないということを説明したいようだ

パラレル・データ・フェッチング

リクエストA、B、Cを並列に処理できるところでは、並列に処理すれば、速度面で改善できるのでは、という話し

    const data = await Promise.all([
      invoiceCountPromise,
      customerCountPromise,
      invoiceStatusPromise,
    ]);

👆 Java Script を使って並列処理している箇所

チャプター8

👆 Next.JS では、Webページは読み込み時にすべてのデータを読み込むので、サーバー側がデータを更新しても、Webページを読んでいる人にはその更新は反映されないという話と、そして、静的レンダリングと、動的レンダリングを説明してくれるそうだ

チャプター7で残っていた宿題の話が書いてある。
並列処理でデータを取得するとき、そのうちの1つが、他のデータ取得の全部の時間より長くかかるようだったらどうなるか?

演習をやってみると、画面がフリーズしたように変化せず、待っていると画面が描画された。
このようなケースでは プログレスバーなど、何かユーザーには いつまで待たせるのか見積を表示するものが欲しいところだ

動的レンダリングで並列データ・フェッチングを行うと、その処理速度は1番遅い処理ぐらいになるという説明のようだ

チャプター9

👆 ストリーミング というのは、ルートをより細かな単位 チャンク に分割し、送り付けるもののようだ

たとえば ページ丸ごと1個送るのではなく、ページ内の項目を1つずつ送るようにし、届けられた分から順次表示するとか。そうすればページ全体が固まって止まっているようりはマシだ

ローディング・スケルトン

`/app/dashboard/loading.tsx` ファイルを新規作成する

export default function Loading() {
  return <div>Loading...</div>;
}

👆 ページが読み込み完了するまで、勝手にこのページが出てくれる

`/app/dashboard/loading.tsx` を書き直す

import DashboardSkeleton from '@/app/ui/skeletons';
 
export default function Loading() {
  return <DashboardSkeleton />;
}

👆 こう書くと Dashboard ページをグレーアウトしたような画面が自動生成されていて表示される

ディレクトリー構成はツリー構造になっているので、
`/app/dashboard/invoices/` のような下位ディレクトリーにも ローディング・スケルトンが自動で用意される

これを避けるには `/dashboard/(overview)` というディレクトリーを用意する

その中に `/app/dashboard/loading.tsx` ファイルと `/app/dashboard/page.tsx` を移動する

これで ローディング・スケルトンは Dashboard ページにだけ適用される

ディレクトリー名に丸括弧を使うと、そのディレクトリーは URLパスには含まれないそうだ。
`/dashboard/(overview)/page.tsx` ファイルへのパスに対応づくURLは `/dashboard` になる

コンポーネントのストリーミング

import { Suspense } from 'react';
import { RevenueChartSkeleton } from '@/app/ui/skeletons';

👆 `Suspense` を追加。 `RevenueChartSkeleton` も追加

        {/* <RevenueChart revenue={revenue}  /> */}
        <Suspense fallback={<RevenueChartSkeleton />}>
          <RevenueChart />
        </Suspense>

👆 タグを変更する

👇 するとWebブラウザーにエラーが出てきた

1 of 1 error    Next.js (15.0.0-canary.56) is outdated (learn more)

Unhandled Runtime Error

Error: Cannot read properties of undefined (reading 'map')

Source
app/lib/utils.ts (28:45) @ map
  26 |   // based on highest record and in 1000s
  27 |   const yAxisLabels = [];
> 28 |   const highestRecord = Math.max(...revenue.map((month) => month.revenue));
     |                                             ^
  29 |   const topLabel = Math.ceil(highestRecord / 1000) * 1000;
  30 |
  31 |   for (let i = topLabel; i >= 0; i -= 1000) {

👆 自分が触ったことによって、他のところで起こったエラー。ワケワカラン

👇 ターミナルにもエラーが出ている

 ⨯ app/lib/utils.ts (28:45) @ map
 ⨯ TypeError: Cannot read properties of undefined (reading 'map')
    at generateYAxis (./app/lib/utils.ts:28:47)
    at RevenueChart (./app/ui/dashboard/revenue-chart.tsx:22:100)
    at AsyncLocalStorage.run (node:async_hooks:346:14)
    at stringify (<anonymous>)
digest: "1785807660"
  26 |   // based on highest record and in 1000s
  27 |   const yAxisLabels = [];
> 28 |   const highestRecord = Math.max(...revenue.map((month) => month.revenue));
     |                                             ^
  29 |   const topLabel = Math.ceil(highestRecord / 1000) * 1000;
  30 |
  31 |   for (let i = topLabel; i >= 0; i -= 1000) {
 GET /dashboard 200 in 206ms

👆 ワケワカラン

ターミナル側で `[Ctrl] + [C]` を打鍵して強制終了させ …

muzudho@Takahashi-PC:~/nextjs_practice/nextjs-dashboard$ pnpm dev

👆 Webサーバーを再起動

エラーは直らず

👆 調べてみると `revenue.map()` と書いてある部分を `revenue?.map()` に変えてもらうと何か変わるかもしれないが、自分のコントロールできるところではないので 仕方ない

章には続きがあった。 `/app/ui/dashboard/revenue-chart.tsx` を編集する

//import { Revenue } from '@/app/lib/definitions';
import { fetchRevenue } from '@/app/lib/data';
// export default async function RevenueChart({
//   revenue,
// }: {
//   revenue: Revenue[];
// }) {
export default async function RevenueChart() { // Make component async, remove the props
  const revenue = await fetchRevenue(); // Fetch data inside the component

👆 ワケワカランが指示通りに変更する

エラーが取れた。読み込みが遅いグラフだけが遅れて表示され、それ以外は先に表示されるようになった

実習問題として、 `<LatestInvoices>` を同様に自力実装する問題がある

コンポーネントのグループ化

ポップ・エフェクト(popping effect)の話が書いてあるが、なんのことだか分からない

AI の Copilot に聴くと ポップ・エフェクトという名前のものは3つあるので、どのポップ・エフェクトか調べてから質問してほしいとのことだ。

おそらく、ポッピング効果というのは、アラートが表示されることなのかもしれない

千鳥効果(staggered effect)という言葉も出てくるが、これも分からない。
段違いの何かだろうか?

`/app/dashboard/page.tsx` ファイルを編集するよう指示がある

//import { Card } from '@/app/ui/dashboard/cards';
import CardWrapper from '@/app/ui/dashboard/cards';
//import { fetchCardData } from '@/app/lib/data';
import {
  RevenueChartSkeleton,
  LatestInvoicesSkeleton,
  CardsSkeleton, // 追加
} from '@/app/ui/skeletons';
  // const {
  //   numberOfCustomers,
  //   numberOfInvoices,
  //   totalPaidInvoices,
  //   totalPendingInvoices,
  // } = await fetchCardData();

👇 次に `/app/ui/dashboard/cards.tsx` ファイルの編集

import { fetchCardData } from '@/app/lib/data';
export default async function CardWrapper() {

  // 追加
  const {
    numberOfInvoices,
    numberOfCustomers,
    totalPaidInvoices,
    totalPendingInvoices,
  } = await fetchCardData();

また、コメントアウトされているコードをアンコメントする

これで、カード状のコンポーネントがローディング・スケルトン付きで動的に読み込まれた

チャプター 10

👆 Next.JS のバージョン14から実装されている実験的な機能の部分プリレンダリング(Partial Prerendereing;PPR)の説明がある

従来、ツリー構造で 動的なノードから見て枝側の方のノードは全部動的だったところを、
部分プリレンダリングというのは 動的なノードから見て枝側の方のノードにも 静的なノードを置けるという仕組みなのだろうか?
静的コンテンツに穴を空けて 動的コンテンツがその穴から覗いている雰囲気なのらしい

`next.config.mjs` ファイルを編集する

/** @type {import('next').NextConfig} */

const nextConfig = {

  // 追加 
  experimental: {
    ppr: 'incremental',
  },

};

export default nextConfig;

`/app/dashboard/layout.tsx` も編集する

// 追加
export const experimental_ppr = true;

チャプター 11

👆 検索とページネーションの説明だそうだ

`/app/dashboard/invoices/page.tsx` ファイルを開いてコードを張り付けろとの指示

import Pagination from '@/app/ui/invoices/pagination';
import Search from '@/app/ui/search';
import Table from '@/app/ui/invoices/table';
import { CreateInvoice } from '@/app/ui/invoices/buttons';
import { lusitana } from '@/app/ui/fonts';
import { InvoicesTableSkeleton } from '@/app/ui/skeletons';
import { Suspense } from 'react';
 
export default async function Page() {
  return (
    <div className="w-full">
      <div className="flex w-full items-center justify-between">
        <h1 className={`${lusitana.className} text-2xl`}>Invoices</h1>
      </div>
      <div className="mt-4 flex items-center justify-between gap-2 md:mt-8">
        <Search placeholder="Search invoices..." />
        <CreateInvoice />
      </div>
      {/*  <Suspense key={query + currentPage} fallback={<InvoicesTableSkeleton />}>
        <Table query={query} currentPage={currentPage} />
      </Suspense> */}
      <div className="mt-5 flex w-full justify-center">
        {/* <Pagination totalPages={totalPages} /> */}
      </div>
    </div>
  );
}

`useSearchParams` というフックを使うと、 `/dashboard/invoices?page=1&query=pending` のような URL から、`{page: '1', query: 'pending'}` という感じのデータを取れるらしい。何のことだか分からないが

`usePathname` というフックを使うと、 `/dashboard/invoices` のような URL から `/dashboard/invoices` という感じの文字列を取れるらしい。何のことだか分からないが

`useRouter` というフックは、クライアント・コンポーネント内のルート間のナビゲーションを有効にできるらしい。何のことだか分からないが

`/app/ui/search.tsx` ファイルを見ろとの指示。 `<Search>` タグのソースになっているのか?

ファイル冒頭の `'use client';` は、このオブジェクトがクライアント・コンポーネントであることを示すらしい。
クライアント・コンポーネントは、イベント・リスナーやフックを使えるらしい

`<input>` は、検索の入力欄だそうだ

`<input>` 要素の `onChangeLister` というイベントハンドラを設定する方法を説明してくれるそうだ

そこで、 `handleSearch` という関数を新規作成しておいて、その関数を `onChangeLister` イベントハンドラで呼び出す説明だそうだ

`/app/ui/search.tsx` :

  function handleSearch(term: string) {
    console.log(term);
  }
onChange={(e) => {
    handleSearch(e.target.value);
}}

👆 検索欄に文字を入力しても、WSLを表示しているターミナルにログが出力される様子はなかった。

ブラウザで `[F12]` キーを押して開発者モードにし、コンソールを開くと ログが出力されていた。Ok

検索パラメータでURLを更新する

`/app/ui/search.tsx` に以下のコードを追加

import { useSearchParams } from 'next/navigation';
const searchParams = useSearchParams();

`handleSearch(…)` 関数に以下のコードを追加すればいいのか?

    // URLの引数を扱うオブジェクト
    const params = new URLSearchParams(searchParams);

    // 検索欄に入力されている文字列を、URLの引数に追加する
    if (term) {
      params.set('query', term);
    // 検索欄に入力されている文字列がなければ、URLの引数から削除する
    } else {
      params.delete('query');
    }

👆 このコードを追加して 検索テキストボックスに文字列を入力しても、目に見える変化は無かった

指示に従って `/app/ui/search.tsx` にさらにコードを追加

import { useSearchParams, usePathname, useRouter } from 'next/navigation';
  const pathname = usePathname();
  const { replace } = useRouter();
    // replace(...) 関数はURLを置き換える。ユーザーが Lee と入力すると、例えば以下のコードの場合 `/dashboard/invoices?query=lee` といった感じになる
    // `${pathname}` は現在のパス。この場合 "/dashboard/invoices"
    // `params.toString()` は、URLの引数に使える書式になるのか?
    replace(`${pathname}?${params.toString()}`);

👆 上記のコードを追加することで、検索ボックスに文字列を入力すると、URLも動的に変更されるようになった

フック]という用語は、イベントハンドラーのことのようだが、よくわからない

入力フィールドと、URLの引数を同期させる

URLの引数を、入力フィールドの初期値として入れておく方法の解説があるようだ。
以下の属性を `/app/ui/search.tsx` の `<input>` タグに追加する

defaultValue={searchParams.get('query')?.toString()}

👆 `value` ではなく `defaultValue` を使うことに注意

こうすると、Webブラウザーのアドレス入力欄のURLの引数を変更して Enter キーを押すと、検索テキストボックスの内容も更新された

テーブルの更新

指示に従って `/app/dashboard/invoices/page.tsx` を改造する

export default async function Page({
  searchParams,
}: {
  searchParams?: {
    query?: string;
    page?: string;
  };
}) {
  const query = searchParams?.query || '';
  const currentPage = Number(searchParams?.page) || 1;
      <Suspense key={query + currentPage} fallback={<InvoicesTableSkeleton />}>
        <Table query={query} currentPage={currentPage} />
      </Suspense>

👆 `<Table>` コンポーネントに `query` を渡している。
`/app/lib/data.ts` ファイルで定義している `fetchFilteredInvoices()` 関数に `query` と `currentPage` を渡しているのと同じことだそうだ

👇 `fetchFilteredInvoices( )` 関数は、 `/app/ui/invoices/table.tsx` ファイルの中で使用されている

// ...
export default async function InvoicesTable({
  query,
  currentPage,
}: {
  query: string;
  currentPage: number;
}) {
  const invoices = await fetchFilteredInvoices(query, currentPage);
  // ...
}

検索テキストボックスに `Lee` と入力すると、
検索テキストボックスの下に Lee さんのデータが表示されるようになった

👇 なんだかよく分からないが説明がある。今のところ、何がなんだか分からない

  • <Search>はクライアント・コンポーネントなので、クライアントからパラメータにアクセスするためにuseSearchParams()フックを使用しました

  • <Table>はそれ自身のデータを取得するサーバー・コンポーネントなので、ページからコンポーネントにsearchParams プロパティを渡すことができます。
    一般的なルールとして、クライアントからパラメータを読み込みたい場合、useSearchParams()フックを使用します

デバウンシング(Debouncing)

`/app/ui/search.tsx` でのコンソール・ログ出力の文言を変更

console.log(`Searching... ${term}`);

👆 1文字入力するたびに、データベースへのアクセスが行われていて、サーバーへの負荷が高いという説明

デバウンシング(Debouncing)とは、関数が起動する速度を制限する手法の名前だそうだ。1文字ずつの入力ごとに関数を呼び出すのではなく、ユーザーが入力を止めたときに関数を呼び出すなど

デバウンシングは、ライブラリーを使って行うそうだ

👇 サーバーを動かしているターミナルとは別に、ターミナルをもう1つ開いて、ライブラリーをインストールする

muzudho@Takahashi-PC:~/nextjs_practice/nextjs-dashboard$ pnpm i use-debounce

説明がいう `<Search>` コンポーネントというのは `/app/ui/search.tsx` ファイル自体を指しているようだが、そこに下記のように `useDebouncedCallback` を[インストール]する

  // Inside the Search Component...
  const handleSearch = useDebouncedCallback((term: string) => {
    console.log(`Searching... ${term}`);
  
    // URLの引数を扱うオブジェクト
    const params = new URLSearchParams(searchParams);

    // 検索欄に入力されている文字列を、URLの引数に追加する
    if (term) {
      params.set('query', term);
    // 検索欄に入力されている文字列がなければ、URLの引数から削除する
    } else {
      params.delete('query');
    }

    // replace(...) 関数はURLを置き換える。ユーザーが Lee と入力すると、例えば以下のコードの場合 `/dashboard/invoices?query=lee` といった感じになる
    // `${pathname}` は現在のパス。この場合 "/dashboard/invoices"
    // `params.toString()` は、URLの引数に使える書式になるのか?
    replace(`${pathname}?${params.toString()}`);
  }, 300);

👆 既存の `handleSearch` 関数定義文とは差し替える?
「この関数は、handleSearch の内容をラップし、ユーザーが入力を止めてから特定の時間(300ミリ秒)後にのみコードを実行する」そうだ

検索テキストボックスに文字入力してみると、反応が遅くなった。
わざと反応を遅くするというテクニックなようだ

ページネーションの追加

`<Pagenation>` コンポーネントはクライアント・コンポーネントなので、ここでデータを取得するとそのコードがクライアント側に見えるので 避けたい

データはサーバー側で取得し、 `prop` (HTMLタグのアトリビュートのことか?)を使ってデータを渡すのがよいそうだ

👇 指示通り `/app/dashboard/invoices/page.tsx` へ以下のコードを追記

import { fetchInvoicesPages } from '@/app/lib/data';
  // fetchInvoicesPages は、検索クエリに基づくページの総数を返します。
  // 例えば、検索クエリにマッチする請求書が12件あり、各ページに6件の請求書が表示される場合、総ページ数は2ページとなります。
  const totalPages = await fetchInvoicesPages(query);
<Pagination totalPages={totalPages} />

👇 指示通り `/app/ui/invoices/pagination.tsx` へ以下のコードを追記

import { usePathname, useSearchParams } from 'next/navigation';
  const pathname = usePathname();
  const searchParams = useSearchParams();
  const currentPage = Number(searchParams.get('page')) || 1;

👇 さらに、コメントアウトされている箇所をアンコメントする

  // NOTE: Uncomment this code in Chapter 11

  const allPages = generatePagination(currentPage, totalPages);

  return (
    <>
      {/*  NOTE: Uncomment this code in Chapter 11 */}

      <div className="inline-flex">
        <PaginationArrow
          direction="left"
          href={createPageURL(currentPage - 1)}
          isDisabled={currentPage <= 1}
        />

        <div className="flex -space-x-px">
          {allPages.map((page, index) => {
            let position: 'first' | 'last' | 'single' | 'middle' | undefined;

            if (index === 0) position = 'first';
            if (index === allPages.length - 1) position = 'last';
            if (allPages.length === 1) position = 'single';
            if (page === '...') position = 'middle';

            return (
              <PaginationNumber
                key={page}
                href={createPageURL(page)}
                page={page}
                position={position}
                isActive={currentPage === page}
              />
            );
          })}
        </div>

        <PaginationArrow
          direction="right"
          href={createPageURL(currentPage + 1)}
          isDisabled={currentPage >= totalPages}
        />
      </div>
    </>
  );

👇 次に、`<Pagenation>` コンポーネントのソースもいじる

`/app/ui/invoices/pagination.tsx`

  const createPageURL = (pageNumber: number | string) => {
    const params = new URLSearchParams(searchParams);
    params.set('page', pageNumber.toString());
    return `${pathname}?${params.toString()}`;
  };

👆 `createPageURL` は、 HTMLのタグの `href` 属性の値を作るのに使われているようだ

👇 `/app/ui/search.tsx` にコード追加

// ページ番号を1にリセットする
params.set('page', '1');

これで、検索ボックスに Lee や Delba と人名を入れたり、消したりすると、ページ番号や、次のページをめくるボタン付きで検索結果が出ている。Ok

チャプター12

👆 いわゆる CRUD を扱うのだろうか?

サーバーアクションとは何か?

説明を読んでも分からない。多分、サーバー側にプログラムを書いて、それをサーバー側に置いておいて、クライアント側からはアクセスすることだけができるようなやつだと思う

サーバーアクションを使えば、いくつかのセキュリティ対策もやってくれているそうだ

サーバーアクションをフォームで使う

`<Form>` タグの `action` 属性でサーバーアクションを呼び出せるそうだ

progressive enhancement が特徴らしい。よくわからない

サーバーアクションは、キャッシングもしているらしい。よくわからない

請求書を作成する

その手順が説明される

1.新しいルートとフォームを作成する

👇 `/app/dashboard/invoices/create/page.tsx` ファイルを新規作成する

import Form from '@/app/ui/invoices/create-form';
import Breadcrumbs from '@/app/ui/invoices/breadcrumbs';
import { fetchCustomers } from '@/app/lib/data';
 
export default async function Page() {
  const customers = await fetchCustomers();
 
  return (
    <main>
      <Breadcrumbs
        breadcrumbs={[
          { label: 'Invoices', href: '/dashboard/invoices' },
          {
            label: 'Create Invoice',
            href: '/dashboard/invoices/create',
            active: true,
          },
        ]}
      />
      <Form customers={customers} />
    </main>
  );
}

`<Form>` コンポーネントはあらかじめ用意してくれておいたそうだ

http://localhost:3000/dashboard/invoices/create へアクセスする。
請求書の作成ページがある

2.サーバーアクション作成

`/app/lib/actions.ts` ファイルを新規作成し、以下のコードを追加

'use server';

👆 `'use server';` を書いておくと、このファイル内で export されるすべての関数は、サーバー・アクションだとマーク付けされるそうだ

👇 続けてコード追加

export async function createInvoice(formData: FormData) {}

👇 `/app/ui/invoices/create-form.tsx` ファイルを編集

import { createInvoice } from '@/app/lib/actions';
<form action={createInvoice}>

👆 `<form>` に action 属性を追加

API エンドポイント の説明があった。 action 属性で指定する先を API エンドポイントと呼んでいるのか?

fromData からデータを抽出する

👇 `/app/lib/actions.ts` のコード編集

'use server';
 
export async function createInvoice(formData: FormData) {
  const rawFormData = {
    customerId: formData.get('customerId'),
    amount: formData.get('amount'),
    status: formData.get('status'),
  };
  // Test it out:
  console.log(rawFormData);
}

👇 フォームのデータをまとめて全部取る場合

const rawFormData = Object.fromEntries(formData.entries())

これで、`[F12]` キーを押したときの開発者モードのコンソールにはフォームの内容がログ出力される。 Ok

4.データをバリデーションする

👇 `/app/lib/definitions.ts` ソースを見るだけ

export type Invoice = {
  id: string; // Will be created on the database
  customer_id: string;
  amount: number; // Stored in cents
  date: string;
  // In TypeScript, this is called a string union type.
  // It means that the "status" property can only be one of the two strings: 'pending' or 'paid'.
  status: 'pending' | 'paid';
};

👇 型を調べるコンソール・ログの取り方。 `/app/lib/actions.ts`

    console.log(`amount の型:${typeof rawFormData.amount}`);

👆 `amount` の型は数字ではなく文字列だそうだ

型の検証ライブラリには Zod があるとのこと

👇 `/app/lib/actions.ts` に以下のコードを追加

import { z } from 'zod';
 
const FormSchema = z.object({
  id: z.string(),
  customerId: z.string(),
  amount: z.coerce.number(),
  status: z.enum(['pending', 'paid']),
  date: z.string(),
});
 
const CreateInvoice = FormSchema.omit({ id: true, date: true });

👇 既存コードの差し替え

export async function createInvoice(formData: FormDat a) {
  const { customerId, amount, status } = CreateInvoice.parse({
    customerId: formData.get('customerId'),
    amount: formData.get('amount'),
    status: formData.get('status'),
  });

  const amountInCents = amount * 100;
  const date = new Date().toISOString().split('T')[0];
}

👇 追加

import { sql } from '@vercel/postgres';
await sql`
  INSERT INTO invoices (customer_id, amount, status, date)
  VALUES (${customerId}, ${amountInCents}, ${status}, ${date})
`;

👆 SQLインジェクションの脆弱性がないか心配だが、とりあえず指示通りコードを書く

リバリデートとリダイレクト

クライアント側に持たせるキャッシュの仕組みだそうだ

👇 `/app/lib/actions.ts` に以下のコードを追加

import { revalidatePath } from 'next/cache';
revalidatePath('/dashboard/invoices');

👇 リダイレクト機能も `/app/lib/actions.ts` ファイルに追加

import { redirect } from 'next/navigation';

これで請求書を作成すると、 http://localhost:3000/dashboard/invoices に自動でページ遷移するようになった

請求書の更新

次は既存の請求書データを更新する話し。既存の請求書の Id が必要になる

1.請求書IDを使用して動的ルートセグメントを作成する

角括弧で文字列を囲むと、動的にルートセグメントを作れるそうだ。
例: `invoices/[id]/edit/page.tsx`

👇 指示に従って `/app/ui/invoices/table.tsx` ファイルのコードの解説

<UpdateInvoice id={invoice.id} />

👇 指示に従って `/app/ui/invoices/buttons.tsx` ファイルの既存コードを編集

変更前
    <Link
      href="/dashboard/invoices"
      className="rounded-md border p-2 hover:bg-gray-100"
    >
変更後
    <Link
      href={`/dashboard/invoices/${id}/edit`}
      className="rounded-md border p-2 hover:bg-gray-100"
    >

2.ページパラメータから請求書IDを読み取る

👇 `/app/dashboard/invoices/[id]/edit/page.tsx` ファイルを新規作成して、下記のコードを貼り付け

import Form from '@/app/ui/invoices/edit-form';
import Breadcrumbs from '@/app/ui/invoices/breadcrumbs';
import { fetchCustomers } from '@/app/lib/data';
 
export default async function Page() {
  return (
    <main>
      <Breadcrumbs
        breadcrumbs={[
          { label: 'Invoices', href: '/dashboard/invoices' },
          {
            label: 'Edit Invoice',
            href: `/dashboard/invoices/${id}/edit`,
            active: true,
          },
        ]}
      />
      <Form invoice={invoice} customers={customers} />
    </main>
  );
}

👇 さらに関数に引数を付ける

export default async function Page({ params }: { params: { id: string } }) {
  const id = params.id;

3.特定の請求書を取得する

👇 指示通り `/dashboard/invoices/[id]/edit/page.tsx` を編集

import { fetchInvoiceById, fetchCustomers } from '@/app/lib/data';
  const [invoice, customers] = await Promise.all([
    fetchInvoiceById(id),
    fetchCustomers(),
  ]);

http://localhost:3000/dashboard/invoices へアクセスし、鉛筆ボタンをクリックしてほしい。請求書の編集画面に遷移する

次は URL も `http://localhost:3000/dashboard/invoice/uuid/edit` のように更新したい

4.Id をサーバーアクションに渡す

Id をサーバーアクションに渡すときは、エンコードするよう説明があった

👇 指示通り `/app/ui/invoices/edit-form.tsx` ファイルを編集する

import { updateInvoice } from '@/app/lib/actions';
const updateInvoiceWithId = updateInvoice.bind(null, invoice.id);
<form action={updateInvoiceWithId}>

👇 指示通り `/app/lib/actions.ts` ファイルを編集する

// Use Zod to update the expected types
const UpdateInvoice = FormSchema.omit({ id: true, date: true });
export async function updateInvoice(id: string, formData: FormData) {
  const { customerId, amount, status } = UpdateInvoice.parse({
    customerId: formData.get('customerId'),
    amount: formData.get('amount'),
    status: formData.get('status'),
  });
 
  const amountInCents = amount * 100;
 
  await sql`
    UPDATE invoices
    SET customer_id = ${customerId}, amount = ${amountInCents}, status = ${status}
    WHERE id = ${id}
  `;
 
  revalidatePath('/dashboard/invoices');
  redirect('/dashboard/invoices');
}

請求書の削除

👇 指示通り `/app/ui/invoices/buttons.tsx` ファイルをコード追加・編集する

import { deleteInvoice } from '@/app/lib/actions';
export function DeleteInvoice({ id }: { id: string }) {
  const deleteInvoiceWithId = deleteInvoice.bind(null, id);
 
  return (
    <form action={deleteInvoiceWithId}>
      <button type="submit" className="rounded-md border p-2 hover:bg-gray-100">
        <span className="sr-only">Delete</span>
        <TrashIcon className="w-4" />
      </button>
    </form>
  );
}

👇 指示通り `/app/lib/actions.ts` ファイルをコード編集する

export async function deleteInvoice(id: string) {
  await sql`DELETE FROM invoices WHERE id = ${id}`;
  revalidatePath('/dashboard/invoices');
}

これで、 `http://localhost:3000/dashboard/invoices` ページのごみバケツのボタンで、レコードを削除できるようになった

チャプター13

👆 エラーハンドリングについて説明してくれるそうだ

👇 指示に従って `/app/lib/actions.ts` ファイルを編集する

// createInvoice()関数

  try {
    // ここに await sql` ~ ` の文を入れる

  } catch (error) {
    return {
      message: 'Database Error: Failed to Create Invoice.',
    };
  }

// updateInvoice()関数も同様。メッセージは以下の通り
    { message: 'Database Error: Failed to Update Invoice.', }

// deleteInvoice()関数は、全体を try catch で囲む感じ
export async function deleteInvoice(id: string) {
  try {
    await sql`DELETE FROM invoices WHERE id = ${id}`;
    revalidatePath('/dashboard/invoices');
    return { message: 'Deleted Invoice.' };
  } catch (error) {
    return { message: 'Database Error: Failed to Delete Invoice.' };
  }
}

👇 わざと例外を投げて、エラー処理を見る例

export async function deleteInvoice(id: string) {
  throw new Error('Failed to Delete Invoice');

👆 コーディングをミスしたときと同じフォーマットのエラー画面が出てくる

error.tsx を使ってすべてのエラーを処理する

👇 `/dashboard/invoices/error.tsx` ファイルを新規作成して、以下のコードを貼り付ける

'use client';
 
import { useEffect } from 'react';
 
export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  useEffect(() => {
    // Optionally log the error to an error reporting service
    console.error(error);
  }, [error]);
 
  return (
    <main className="flex h-full flex-col items-center justify-center">
      <h2 className="text-center">Something went wrong!</h2>
      <button
        className="mt-4 rounded-md bg-blue-500 px-4 py-2 text-sm text-white transition-colors hover:bg-blue-400"
        onClick={
          // Attempt to recover by trying to re-render the invoices route
          () => reset()
        }
      >
        Try again
      </button>
    </main>
  );
}

👆 `error.tsx` は、クライアント・コンポーネントである必要がある

`error` は、 Java Script ネイティブの Error オブジェクトのインスタンスだ

`reset()` は、エラーをリセットするのか?

これで、エラーのときは `error.tsx` で出力した HTML が表示されてる。Ok

notFound()関数を使って、404エラーに対応する

http://localhost:3000/dashboard/invoices/2e94d1ed-d220-449f-9f11-f0bbceed9645/edit にアクセスする

これはフェイクの UUID なので、データベースには存在しない

Webページが無いかどうかは、別のエラーとして分けたい。
👇 指示通り `/app/lib/data.ts` ファイルを編集する

// fetchInvoiceById()関数

console.log(invoice); // Invoice is an empty array []

👇 指示通り `/dashboard/invoices/[id]/edit/page.tsx` ファイルを編集する

import { notFound } from 'next/navigation';
  if (!invoice) {
    notFound();
  }

これで、存在しない UUID でアクセスしたとき、 404 用のページが表示された。Ok

ただし、全画面に表示されてしまっている

👇 指示通り `/app/dashboard/invoices/[id]/edit/not-found.tsx` ファイルを新規作成し、以下のコードを貼り付ける

import Link from 'next/link';
import { FaceFrownIcon } from '@heroicons/react/24/outline';
 
export default function NotFound() {
  return (
    <main className="flex h-full flex-col items-center justify-center gap-2">
      <FaceFrownIcon className="w-10 text-gray-400" />
      <h2 className="text-xl font-semibold">404 Not Found</h2>
      <p>Could not find the requested invoice.</p>
      <Link
        href="/dashboard/invoices"
        className="mt-4 rounded-md bg-blue-500 px-4 py-2 text-sm text-white transition-colors hover:bg-blue-400"
      >
        Go Back
      </Link>
    </main>
  );
}

👆 これで、ナビゲーションを残したまま、 404 用のページが表示された。Ok

さらに詳細な記事へのリンクも並んでいた

チャプター14

👆 サーバー側でバリデーションする説明だろうか? それに加えて 障害が持った人にも使えるようにすることを アクセシビリティ と呼んでいるそうだ

ESLint の説明がある。画像に alt 属性が付いているかなどを検知してくれるそうだ

👇 `/package.json` にコードを追加

"scripts": {
    "build": "next build",
    "dev": "next dev",
    "start": "next start",
    "lint": "next lint" ※これを追加 
},

👇 そして、ターミナルで以下のコマンドを打鍵する

muzudho@Takahashi-PC:~/nextjs_practice/nextjs-dashboard$ pnpm lint

👆 質問が出てくるので、答えていく

👇 エラーメッセージが出た

We created the .eslintrc.json file for you and included your selected configuration.
 ▲ ESLint has successfully been configured. Run next lint again to view warnings and errors.
 ELIFECYCLE  Command failed with exit code 1.

👆 よくわからない

`/app/ui/invoices/table.tsx` ファイルにある `<Image>` タグから、 `alt` 属性を削除したら、 ESLint がそれを見つけるか試してみよう、という演習がある

muzudho@Takahashi-PC:~/nextjs_practice/nextjs-dashboard$ pnpm lint

> @ lint /home/muzudho/nextjs_practice/nextjs-dashboard
> next lint

Failed to load config "next/typescript" to extend from.
Referenced from: /home/muzudho/nextjs_practice/nextjs-dashboard/.eslintrc.json
 ELIFECYCLE  Command failed with exit code 1.

👆 エラーが出てきた。 ESLint のインストールに失敗しているのかもしれない

👇 `.eslintrc.json` を見てみる

変更前

{
  "extends": [
    "next/core-web-vitals",
    "next/typescript"
  ]
}

👇 以下のように書き換えてみた

変更後

{
  "extends": ["eslint:recommended", "next"]
}

これで `pnpm lint` が動くようになった。正しいか分からないが

./app/ui/invoices/table.tsx
88:23  Warning: Image elements must have an alt prop, either with meaningful text, or an empty string for decorative images.  jsx-a11y/alt-text

👆 `<image>` タグに `alt` 属性が無いことを検知してくれた。
他にもいくつかエラーを検知してくれたが よくわからない

フォーム バリデーション

http://localhost:3000/dashboard/invoices/create のページでフォームが空欄のまま送信ボタンを押すとエラー画面が出てくる

クライアント-サイド バリデーション

👇 指示通り `/app/ui/invoices/create-form.tsx` ファイルを編集する

<input
  id="amount"
  name="amount"
  type="number"
  placeholder="Enter USD amount"
  className="peer block w-full rounded-md border border-gray-200 py-2 pl-10 text-sm outline-2 placeholder:text-gray-500"
  required ※👈この行を追加する 
/>

👆 これで、空欄のまま送信ボタンを押すと Web ブラウザー上に警告のポップアップが出るようになった。これがクライアント側のバリデーションだ

`required` をまた消して、次の演習に進む

サーバー-サイド バリデーション

👇 指示通り `/app/ui/invoices/create-form.tsx` ファイルを編集する

'use client';
import { useActionState } from 'react';

👆 `useActionState` フックを使いたいから、クライアント コンポーネントにした?

Form コンポーネント内の userActionState フックの仕様:
引数:
  action
  initialState
返却値:
  [state, - フォームの状態
   formAction - フォームの送信時に呼び出される関数
  ]

createInvoice アクションを useActionState の引数として渡す。
`<form action={}>` 属性内で formAction を呼び出す。

👇 指示通り `/app/ui/invoices/create-form.tsx` ファイルを編集する

const [state, formAction] = useActionState(createInvoice, initialState);
※変更前
<form action={createInvoice}>...</form>;

※変更後
return <form action={formAction}>...</form>;

👇 指示通り `/app/ui/invoices/create-form.tsx` ファイルを編集する

import { createInvoice, State } from '@/app/lib/actions';
※ Form()関数

const initialState: State = { message: null, errors: {} };

👆 initialState 変数には任意のメンバー変数を定義できるそうだ。
State 型は `/app/lib/actions.ts` ファイルからインポートしているのか? よくわからない。あとで追加する?

👇 指示通り `/app/lib/actions.ts` ファイルを編集する

  // 文字列を期待している。空だった場合のエラーメッセージを渡す
  customerId: z.string({
    invalid_type_error: 'Please select a customer.',
  }),
  // 文字列型を数値型に変換している。空欄ならデフォルトで 0 に変換される。 gt は常に大きい量を要求している?
  amount: z.coerce
    .number()
    .gt(0, { message: 'Please enter an amount greater than $0.' }),
  // 'pending' でも 'paid' でもないときエラー。そのエラーメッセージを指定する
  status: z.enum(['pending', 'paid'], {
    invalid_type_error: 'Please select an invoice status.',
  }),
export type State = {
  errors?: {
    customerId?: string[];
    amount?: string[];
    status?: string[];
  };
  message?: string | null;
};

// 引数を追加している
// prevState 引数は useActionState フックから渡される。この例では使用しませんが、必須
export async function createInvoice(prevState: State, formData: FormData) {
  // ...
}
※ createInvoice()関数で
※ 変更前
    const { customerId, amount, status } = CreateInvoice.parse({

※ 変更後
    // safeParse() は、success か error フィールドを持つオブジェクトを返す
    const validatedFields = CreateInvoice.safeParse({
  // If form validation fails, return errors early. Otherwise, continue.
  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors,
      message: 'Missing Fields. Failed to Create Invoice.',
    };
  }
  // Prepare data for insertion into the database
  const { customerId, amount, status } = validatedFields.data;

👇 指示通り `/app/ui/invoices/create-form.tsx` ファイルを編集する

※ <select> タグの属性
aria-describedby="customer-error"
      <div id="customer-error" aria-live="polite" aria-atomic="true">
        {state.errors?.customerId &&
          state.errors.customerId.map((error: string) => (
            <p className="mt-2 text-sm text-red-500" key={error}>
              {error}
            </p>
          ))}
      </div>

👆 `aria-describedby="customer-error"` は、エラー時に表示するものを指定しているのか?

`aria-live="polite"` の説明はよくわからない

演習: aria ラベルを追加する

エラーが出たときに、テキストボックスなどの下部に赤色の字でメッセージを出すことか?

`pnpm lint` を使って正しく書けているか確認する

👇 演習だが答えを見て `/app/ui/invoices/edit-form.tsx` ファイルを編集する

// ...
import { updateInvoice, State } from '@/app/lib/actions';
import { useActionState } from 'react';
 
export default function EditInvoiceForm({
  invoice,
  customers,
}: {
  invoice: InvoiceForm;
  customers: CustomerField[];
}) {
  const initialState: State = { message: null, errors: {} };
  const updateInvoiceWithId = updateInvoice.bind(null, invoice.id);
  const [state, formAction] = useActionState(updateInvoiceWithId, initialState);
 
  return <form action={formAction}></form>;
}

👇 `/app/lib/actions.ts` ファイルを編集する

export async function updateInvoice(
  id: string,
  prevState: State,
  formData: FormData,
) {
  const validatedFields = UpdateInvoice.safeParse({
    customerId: formData.get('customerId'),
    amount: formData.get('amount'),
    status: formData.get('status'),
  });
 
  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors,
      message: 'Missing Fields. Failed to Update Invoice.',
    };
  }
 
  const { customerId, amount, status } = validatedFields.data;
  const amountInCents = amount * 100;
 
  try {
    await sql`
      UPDATE invoices
      SET customer_id = ${customerId}, amount = ${amountInCents}, status = ${status}
      WHERE id = ${id}
    `;
  } catch (error) {
    return { message: 'Database Error: Failed to Update Invoice.' };
  }
 
  revalidatePath('/dashboard/invoices');
  redirect('/dashboard/invoices');
}

チャプター15

👆 ユーザー認証を追加するのか?

認証(authentication)とは?

ユーザーが本人かどうかシステムで確認することだそうだ

認証(Authentication)と認可(Authorization)の違い

  • 認証(Authentication)とは、ユーザーが本人であることを確認すること。ユーザー名やパスワードなど、自分が持っているもので自分の身元を証明する。

  • 認可(Authorization)は、認証の次のステップだ。ユーザーの身元が確認されると、認可によってアプリケーションのどの部分の使用が許可されるかが決定される。

ログイン・ルートの作成

👇 指示通り `/app/login/page.tsx` ファイルに以下のコードを貼り付ける

import AcmeLogo from '@/app/ui/acme-logo';
import LoginForm from '@/app/ui/login-form';
 
export default function LoginPage() {
  return (
    <main className="flex items-center justify-center md:h-screen">
      <div className="relative mx-auto flex w-full max-w-[400px] flex-col space-y-2.5 p-4 md:-mt-32">
        <div className="flex h-20 w-full items-end rounded-lg bg-blue-500 p-3 md:h-36">
          <div className="w-32 text-white md:w-36">
            <AcmeLogo />
          </div>
        </div>
        <LoginForm />
      </div>
    </main>
  );
}

NextAuth.js

Next.js での認証のソリューション

👇 インストールするため、以下のコマンドを打鍵

pnpm i next-auth@beta

これでインストール完了

👇 次に秘密鍵を作る

openssl rand -base64 32

これで 秘密鍵の文字列が出てくる

👇 指示通り `.env` ファイルを編集する

AUTH_SECRET=your-secret-key

👆 Vercel 環境で AUTH_SECRET を指定する方法はこれを読めばいい?

ページオプションの追加

ページオプションというのは、認証のための設定のためにやるのか?

👇 指示通り `/auth.config.ts` ファイルを新規作成して以下のコードを貼り付ける

import type { NextAuthConfig } from 'next-auth';
 
export const authConfig = {
  pages: {
    // サイン・イン ページは '/login' に遷移させるのか?
    signIn: '/login',
  },
} satisfies NextAuthConfig;

👆 サイン・インのページを `/login` に設定しているのか?

Next.JS ミドルウェアでルート(routes)を保護する

ログインしていないと、ページにアクセスできないようにする、というのを 保護(protect) と呼んでいる?

  pages: { ... },
  callbacks: {
    // 認可。リクエストが完了する前に呼び出される?
    authorized({ auth, request: { nextUrl } }) {
      const isLoggedIn = !!auth?.user;
      const isOnDashboard = nextUrl.pathname.startsWith('/dashboard');
      if (isOnDashboard) {
        if (isLoggedIn) return true;

        // ログインしていなければ、認可しない
        return false; // Redirect unauthenticated users to login page

      } else if (isLoggedIn) {
        return Response.redirect(new URL('/dashboard', nextUrl));
      }
      return true;
    },
  },
  providers: [], // Add providers with an empty array for now
} satisfies NextAuthConfig; 

👆 プロバイダー・オプションの説明は略

👇 指示通り `/middleware.ts` ファイルを新規作成し、以下のコードを貼り付ける

import NextAuth from 'next-auth';
import { authConfig } from './auth.config';
 
export default NextAuth(authConfig).auth;
 
export const config = {
  // `matcher` オプションを利用して、特定のパスでだけ認証を行うようにしているのか?
  // https://nextjs.org/docs/app/building-your-application/routing/middleware#matcher
  matcher: ['/((?!api|_next/static|_next/image|.*\\.png$).*)'],
};

👆 ミドルウェアが認証を検証するまでルート(route)のレンダリングを開始しないので、セキュリティとパフォーマンスの両方が向上するそうだ

パスワードのハッシュ化(Password hashing)

パスワードのハッシュ化とは、パスワードをランダムな文字列に見えるようにすることだそうだ

👇 指示に従って `/auth.ts` ファイルを新規作成し、以下のコードを貼り付ける

import NextAuth from 'next-auth';
import { authConfig } from './auth.config';
 
export const { auth, signIn, signOut } = NextAuth({
  ...authConfig,
});

資格情報プロバイダー(Credentials provider)の追加

資格情報プロバイダーとは、グーグルとか、GitHub とか、ログインするときに使うやつのことか?
資格情報プロバイダーを使うと、ユーザーはユーザー名とパスワードを入力することでログインできるそうだ

👇 指示に従って `/auth.ts` ファイルにコードを追加する

import Credentials from 'next-auth/providers/credentials';
  ...authConfig,
  providers: [Credentials({})],

一般的には、資格情報プロバイダーを使うよりは、 OAuth プロバイダーや、電子メール・プロバイダーを使った方がいい?

サインイン機能の追加

👇 指示に従って `/auth.ts` ファイルにコードを追加する

    Credentials({
      async authorize(credentials) {
        // バリデーション 
        const parsedCredentials = z
          .object({ email: z.string().email(), password: z.string().min(6) })
          .safeParse(credentials);
      },
    }),

👇 データベースからユーザーのデータを取得する関数を作る。
指示に従って `/auth.ts` ファイルにコードを追加する

import { sql } from '@vercel/postgres';
import type { User } from '@/app/lib/definitions';
import bcrypt from 'bcrypt';
async function getUser(email: string): Promise<User | undefined> {
  try {
    const user = await sql<User>`SELECT * FROM users WHERE email=${email}`;
    return user.rows[0];
  } catch (error) {
    console.error('Failed to fetch user:', error);
    throw new Error('Failed to fetch user.');
  }
}
        if (parsedCredentials.success) {
          const { email, password } = parsedCredentials.data;
          const user = await getUser(email);
          if (!user) return null;
        }

        return null;

👇 パスワードの確認を追加。
指示に従って `/auth.ts` ファイルにコードを追加する

          const passwordsMatch = await bcrypt.compare(password, user.password);
          if (passwordsMatch) return user;
        console.log('Invalid credentials');
        return null;

👆 パスワードが一致するならユーザーを返し、そうでなければヌルを返す

ログインフォームの更新

👇 指示に従って `/app/lib/actions.ts` ファイルを編集する

'use server';
 
import { signIn } from '@/auth';
import { AuthError } from 'next-auth';
 
// ...
 
export async function authenticate(
  prevState: string | undefined,
  formData: FormData,
) {
  try {
    await signIn('credentials', formData);
  } catch (error) {
    if (error instanceof AuthError) {
      switch (error.type) {
        case 'CredentialsSignin':
          return 'Invalid credentials.';
        default:
          return 'Something went wrong.';
      }
    }
    throw error;
  }
}

👇 指示に従って `app/ui/login-form.tsx` ファイルを編集する

'use client';
import { useActionState } from 'react';
import { authenticate } from '@/app/lib/actions';
  // LoginForm関数
  const [errorMessage, formAction, isPending] = useActionState(
    authenticate,
    undefined,
  );
<form action={formAction} className="space-y-3">
        <Button className="mt-4 w-full" aria-disabled={isPending}>
          Log in <ArrowRightIcon className="ml-auto h-5 w-5 text-gray-50" />
        </Button>
          {errorMessage && (
            <>
              <ExclamationCircleIcon className="h-5 w-5 text-red-500" />
              <p className="text-sm text-red-500">{errorMessage}</p>
            </>
          )}

ログアウト機能の追加

ナビゲーションにログアウト機能を追加する

👇 指示通り `/ui/dashboard/sidenav.tsx` ファイルを編集する

import { signOut } from '@/auth';
        <form
          action={async () => {
            'use server';
            await signOut();
          }}
        >

これでユーザー認証ページができた。存在しないメールアドレスとパスワードを入力すると正しく弾かれた。Ok

👇 デフォルトのアカウントは以下の通りらしい

  • Email: user@nextmail.com

  • Password: 123456

チャプター16

👆 メタデータの追加? SEOに重要らしい

メタデータとは何か?

ユーザーに見せるデータではなくて、ページには付いている情報。`<head>` タグに含まれている

メタデータの種類

  • `<title>` - Webブラウザーのタブに表示されるタイトル。検索エンジンもこれを見る

  • デスクリプション メタデータ。👇 検索結果に出てくる

<meta name="description" content="A brief description of the page content." />
  • キーワード メタデータ。👇 検索エンジンがインデックスを作るのに役に立つ

<meta name="keywords" content="keyword1, keyword2, keyword3" />
  • オープングラフ メタデータ。👇 ソーシャルメディアでの表現を強化する

<meta property="og:title" content="Title Here" />
<meta property="og:description" content="Description Here" />
<meta property="og:image" content="image_url_here" />
  • ファビコン メタデータ。👇 ブラウザに表示される小さなアイコン

<link rel="icon" href="path/to/favicon.ico" />

メタデータの追加

Next.JS でメタデータを追加する方法は2つある

  • 構成ベース(Config-based)

  • ファイルベース(File-based)

    • `favicon.ico`, `apple-icon.jpg`, `icon.jpg`

    • `opengraph-image.jpg`, `twitter-image.jpg`

    • `robots.txt`

    • `sitemap.xml`

これらを設定すると、Next.JS が `<head>` ファイルを適切に出力してくれる?

ファビコンとオープングラフの画像

`/public` フォルダーの下に `favicon.ico` ファイルと `opengraph-mage.png` ファイルを置いてある

この2つのファイルを `/app` フォルダーのルートに移動する

ブラウザの `[F12]` キーを押して開発者モードにすると、HTMLのソースに `<head>` ファイルが追加されていた。Ok

動的にOG(opengraph)画像を作れるという言及もあった

ページデータとデスクリプション

👇 指示通り `/app/layout.tsx` ファイルを編集する

import { Metadata } from 'next';

// Next.JS が HTML の <head> の中の <meta> 要素を自動で作ってくれる
export const metadata: Metadata = {
  title: 'Acme Dashboard',
  description: 'The official Next.js Course Dashboard, built with App Router.',
  metadataBase: new URL('https://next-learn-dashboard.vercel.sh'),
};

👇 個別のページごとに ページデータを設定するには、
まず メンテナンス性が悪い例から。
指示通り `/app/dashboard/invoices/page.tsx` ファイルを編集する

import { Metadata } from 'next';
 
export const metadata: Metadata = {
  title: 'Invoices | Acme Dashboard',
};

👆 これでも機能するそうだが、Webページのタイトルが変わったとき、すべてのこのように設定しているページで変更しないといけないくなる

👇 代わりの方法。指示通り `/app/layout.tsx` ファイルを編集する

※変更前
export const metadata: Metadata = {
  title: 'Acme Dashboard',
  ※中略
};

※変更後
export const metadata: Metadata = {
  title: {
    // %s はページのタイトルに置き換えられる
    template: '%s | Acme Dashboard',
    default: 'Acme Dashboard',
  },
  ※中略
};

👇 ページ個別のタイトルの設定の仕方
指示通り `/app/dashboard/invoices/page.tsx` ファイルを編集する

export const metadata: Metadata = {
  // ページのタイトル
  title: 'Invoices',
};

👇 これで、Webブラウザーで `[F12]` キーを押して開発者モードにして HTML のソースを見ると、 `<head>` 要素の中の `<meta>` 要素のタイトルのところには以下のようになっている。 Ok

<meta name="twitter:title" content="Invoices | Acme Dashboard">

👇 そのほか、演習問題がある。
例えば `/app/login/page.tsx` ページにも以下のように追加する

import { Metadata } from 'next';

export const metadata: Metadata = {
  // ページのタイトル
  title: 'Login',
};

チャプター17

次のステップへの記事へのいろいろなリンクが貼られている。
これで コースはおわり

この記事が気に入ったらサポートをしてみませんか?