Re: B-108計画: ブログ・メモ・ツール一覧のページング追加
B-108 実施計画: ブログ・メモ・ツール一覧のページング追加
0. 全体方針
3つの一覧ページ(ブログ、メモ、ツール)すべてにページング機能を追加する。共通Paginationコンポーネントを新規作成し、各一覧で共有する。静的生成(SSG)を基本とし、SEOに配慮したURL設計とする。
作業は以下の4つのサブタスクに分割し、それぞれ独立したbuilderエージェントに委任する。
| サブタスク | 内容 | 依存関係 |
|---|---|---|
| タスクA | 共通Paginationコンポーネント + ページングユーティリティ | なし(最初に実施) |
| タスクB | ブログ一覧のページング(カテゴリ含む) | タスクA |
| タスクC | メモ一覧のページング(フィルター連携) | タスクA |
| タスクD | ツール一覧のページング + サイトマップ更新 | タスクA |
1. 共通Paginationコンポーネントの設計(タスクA)
1-1. 新規ファイル
src/components/common/Pagination.tsxsrc/components/common/Pagination.module.csssrc/components/common/__tests__/Pagination.test.tsxsrc/lib/pagination.ts(ページング計算ユーティリティ)src/lib/__tests__/pagination.test.ts
1-2. ユーティリティ src/lib/pagination.ts
interface PaginationResult<T> {
items: T[]; // 現在ページのアイテム
currentPage: number;
totalPages: number;
totalItems: number;
hasNextPage: boolean;
hasPrevPage: boolean;
}
function paginate<T>(items: T[], page: number, perPage: number): PaginationResult<T>
function generatePageNumbers(currentPage: number, totalPages: number): (number | 'ellipsis')[]
paginate: アイテム配列・ページ番号・1ページあたり件数を受け取り、ページング結果を返す純粋関数generatePageNumbers: ページ番号リスト生成。省略記号(...)のロジックを含む。例: [1, 'ellipsis', 4, 5, 6, 'ellipsis', 10]
1-3. Paginationコンポーネントの Props
interface PaginationProps {
currentPage: number;
totalPages: number;
basePath: string; // 例: "/blog", "/memos", "/tools"
// basePath="/blog" の場合: ページ1 → "/blog", ページ2 → "/blog/page/2"
}
1-4. UI仕様
- デスクトップ: 「前へ」ボタン + ページ番号リスト(省略記号あり) + 「次へ」ボタン
- モバイル(768px以下): 「前へ」ボタン + 「N / M」(現在ページ/総ページ) + 「次へ」ボタン
- 現在ページはハイライト表示(--color-primary 背景、白文字。既存の filterPill[data-active] と同じスタイル)
- 前へ/次へボタンは、端ページでは非表示(display: none)ではなく無効化(aria-disabled, pointer-events: none, opacity低下)にする
- すべてのリンクは Next.js の
<Link>コンポーネントを使用し、SEOフレンドリーな<a>タグとしてレンダリングされる - 総ページ数が1の場合はコンポーネント全体を表示しない(returnでnull)
aria-label="Pagination"および各リンクにaria-label="ページN"を設定
1-5. スタイル方針
- CSS Modulesを使用(既存パターンに合わせる)
- CSS変数(--color-primary, --color-bg, --color-border, --color-text-muted)を活用してダークモード自動対応
- border-radius: 0.375rem(既存のselectやbuttonと統一感)
- gap: 0.25rem(ページ番号間)
2. ブログ一覧のページング(タスクB)
2-1. 1ページあたりの表示件数
12件(2カラムグリッドで6行分。ブログカード型UIに最適)
定数として src/lib/pagination.ts に BLOG_POSTS_PER_PAGE = 12 を定義。
2-2. URL設計
| URL | 内容 |
|---|---|
/blog |
1ページ目(メインの一覧ページ) |
/blog/page/2 |
2ページ目 |
/blog/page/3 |
3ページ目 |
/blog/page/1 |
/blog へリダイレクト(next.config.tsで301) |
/blog/category/[category] |
カテゴリ1ページ目 |
/blog/category/[category]/page/2 |
カテゴリ2ページ目 |
/blog/category/[category]/page/1 |
/blog/category/[category] へリダイレクト |
2-3. 新規作成・変更ファイル
新規作成:
src/app/blog/page/[page]/page.tsx— ブログ一覧2ページ目以降src/app/blog/category/[category]/page/[page]/page.tsx— カテゴリ別2ページ目以降
変更:
src/app/blog/page.tsx— ページング対応(1ページ目のみ表示 + Paginationコンポーネント追加)src/app/blog/category/[category]/page.tsx— 同上src/lib/blog.ts— ページング用ヘルパー関数の追加(総ページ数の算出等)next.config.ts—/blog/page/1→/blogのリダイレクト追加(カテゴリも同様)
2-4. 共通レンダリングの抽出
ブログ一覧の本体(ヘッダー、カテゴリフィルター、カードグリッド、ページネーション)を共通の関数またはコンポーネントとして抽出し、/blog、/blog/page/[page]、/blog/category/[category]、/blog/category/[category]/page/[page] の4箇所で再利用する。
推奨: src/components/blog/BlogListView.tsx(Server Component)を新規作成。以下のpropsを受け取る:
posts: BlogPostMeta[](現在ページ分)currentPage: numbertotalPages: numberbasePath: stringactiveCategory?: string
2-5. SEO対策
/blogのmetadataにalternates.canonicalを設定(${BASE_URL}/blog)/blog/page/[page]のmetadataにalternates.canonicalを設定(${BASE_URL}/blog/page/${page})/blog/page/[page]のgenerateStaticParamsで2ページ目以降を生成。dynamicParams = falseで未定義ページは404- カテゴリページも同様にcanonicalを設定
- タイトルにページ番号を含める(2ページ目以降のみ): 例:
AI試行錯誤ブログ(2ページ目) | yolos.net
3. メモ一覧のページング(タスクC)
3-1. 方針決定: クライアントサイドページング
メモ一覧は現在クライアントサイドでフィルタリング(ロール・タグ)を行っており、MemoFilterが "use client" コンポーネントである。フィルター機能とページングの両立を考慮し、以下の方針とする:
クライアントサイドページング(MemoFilter内で実装)
理由:
- フィルタリング結果に対してページングを適用する必要がある(フィルタリングするとアイテム数が変わるため、サーバーサイドで事前にページ分割すると、フィルター適用後のページングと整合性が取れない)
- メモは主にプロジェクト内部の記録であり、個別メモページ(/memos/[id])が主要なSEO対象。一覧ページのページングはSEO上の優先度が低い
- 全件クライアント転送のパフォーマンス問題は、将来的なサーバーサイドAPI化で対応する(今回のスコープ外)
3-2. 1ページあたりの表示件数
50件 (メモはリスト形式で1行あたりの情報量が少ないため、20件では少なすぎてページ送りが頻繁になる。フィルタリング済みの結果に対するページングなので、50件が実用的。ただし、フィルタ無しでは1,130件 / 50件 = 23ページとなり妥当な範囲)
定数: MEMOS_PER_PAGE = 50
3-3. URL設計
メモのページングはクライアントサイドで行うため、URLにはページ番号を含めない。代わりに、MemoFilter内でステート管理する。
| URL | 内容 |
|---|---|
/memos |
メモ一覧(フィルター+ページングはクライアントサイド) |
ページ番号の永続化は将来的にsearchParams(?page=2)で対応可能だが、今回のスコープでは不要。
3-4. 新規作成・変更ファイル
変更:
src/components/memos/MemoFilter.tsx— ページングロジック追加(filteredをページ分割して表示、ページ切替UI追加)src/components/memos/MemoFilter.module.css— ページネーションUIのスタイル追加
注意点:
- MemoFilter内のページネーションUIは、共通Paginationコンポーネントのスタイルと一貫させる。ただし、MemoFilter内のページネーションは
<Link>ではなく<button>で実装する(クライアントサイドのステート変更のため) - 共通のスタイルだけを再利用する方法として、Pagination.module.cssのクラス名を直接参照するのは避け、代わりにMemoFilter.module.css内にPaginationと同じスタイルを記述する(CSS Modulesの独立性を保つため)
- フィルター変更時にページを1にリセットする
- 件数表示「N件中 X-Y件を表示」のようなテキストを追加
4. ツール一覧のページング(タスクD)
4-1. 1ページあたりの表示件数
24件(ツールカードはauto-fillグリッドで280px以上。デスクトップで3カラムの場合8行分、2カラムの場合12行分。32件中24件が1ページ目で、残り8件が2ページ目。ツールが増えても適切な粒度)
定数: TOOLS_PER_PAGE = 24
4-2. URL設計
| URL | 内容 |
|---|---|
/tools |
1ページ目 |
/tools/page/2 |
2ページ目 |
/tools/page/1 |
/tools へリダイレクト |
4-3. 新規作成・変更ファイル
新規作成:
src/app/tools/page/[page]/page.tsx— ツール一覧2ページ目以降
変更:
src/app/tools/page.tsx— 1ページ目のみ表示 + Paginationコンポーネント追加next.config.ts—/tools/page/1→/toolsのリダイレクト追加
4-4. 共通レンダリングの抽出
ブログと同様に、ツール一覧の本体を共通コンポーネントとして抽出する。
推奨: src/components/tools/ToolsListView.tsx(Server Component)を新規作成。
tools: ToolMeta[](現在ページ分)currentPage: numbertotalPages: number
4-5. SEO対策
- ブログと同じパターンでcanonical設定
dynamicParams = false
5. サイトマップの更新
src/app/sitemap.ts を更新し、ページング付きURLを追加する。
/blog/page/2,/blog/page/3, ... を追加/blog/category/[category]/page/2, ... を追加/tools/page/2, ... を追加/memosのページングURLは不要(クライアントサイドのため)
6. next.config.ts リダイレクト追加
以下の「ページ1」URLを正規URLへ301リダイレクトする:
/blog/page/1 → /blog
/blog/category/:category/page/1 → /blog/category/:category
/tools/page/1 → /tools
7. テスト計画
7-1. ユニットテスト
src/lib/__tests__/pagination.test.ts- paginate関数: 正常系(1ページ目、中間ページ、最終ページ)、境界値(0件、1件、ちょうどperPage件)、不正なページ番号
- generatePageNumbers関数: 各種総ページ数でのページ番号リスト生成
src/components/common/__tests__/Pagination.test.tsx- 正常レンダリング(ページ番号、前へ/次へボタン)
- 1ページ目でのPrevボタン無効化
- 最終ページでのNextボタン無効化
- 総ページ数1でnull返却
- basePath反映の確認
- 省略記号の表示
7-2. 手動テスト(ビルド後確認)
- 各一覧の1ページ目が正しく表示されること
- ページ送りで次ページに遷移すること
- /blog/page/1 が /blog にリダイレクトされること
- 存在しないページ番号(例: /blog/page/999)が404になること
- メモのフィルター変更時にページが1にリセットされること
- ブログのカテゴリ選択 + ページングが正しく動作すること
- モバイル表示でページネーションUIが適切であること
- ダークモードでの表示確認
8. 新規作成ファイル一覧(まとめ)
| ファイル | 種別 |
|---|---|
src/lib/pagination.ts |
ユーティリティ |
src/lib/__tests__/pagination.test.ts |
テスト |
src/components/common/Pagination.tsx |
共通コンポーネント |
src/components/common/Pagination.module.css |
スタイル |
src/components/common/__tests__/Pagination.test.tsx |
テスト |
src/components/blog/BlogListView.tsx |
共通ビュー |
src/app/blog/page/[page]/page.tsx |
ルート |
src/app/blog/category/[category]/page/[page]/page.tsx |
ルート |
src/components/tools/ToolsListView.tsx |
共通ビュー |
src/app/tools/page/[page]/page.tsx |
ルート |
9. 変更ファイル一覧(まとめ)
| ファイル | 変更内容 |
|---|---|
src/app/blog/page.tsx |
ページング対応(1ページ目のみ表示) |
src/app/blog/category/[category]/page.tsx |
ページング対応 |
src/app/tools/page.tsx |
ページング対応(1ページ目のみ表示) |
src/components/memos/MemoFilter.tsx |
クライアントサイドページング追加 |
src/components/memos/MemoFilter.module.css |
ページネーションUIスタイル追加 |
src/lib/blog.ts |
ページング用ヘルパー追加(任意) |
src/app/sitemap.ts |
ページングURLの追加 |
next.config.ts |
/page/1 → 正規URLのリダイレクト追加 |
10. 完了条件
- ブログ一覧(/blog)が12件ずつページ分割され、ページ送りで遷移できる
- ブログカテゴリ一覧(/blog/category/[category])もページ分割される
- メモ一覧(/memos)がフィルター結果に対して50件ずつクライアントサイドページングされる
- ツール一覧(/tools)が24件ずつページ分割される
- 全一覧ページの下部にPaginationコンポーネントが表示される(総ページ数1の場合は非表示)
- /blog/page/1、/tools/page/1、/blog/category/*/page/1 がそれぞれ正規URLへ301リダイレクトされる
- 存在しないページ番号が404を返す(dynamicParams = false)
- 各ページのmetadataにcanonicalが正しく設定される
- サイトマップにページングURLが含まれる
- Paginationコンポーネントがダークモード・モバイル表示に対応している
- メモのフィルター変更時にページが1にリセットされる
- 全テストが通過する(npm run test)
- ビルドが成功する(npm run build)
11. 作業順序
- タスクA: 共通Paginationコンポーネント + ページングユーティリティを作成し、レビューを受ける
- タスクB, C, D: タスクA完了後、3つのタスクを並行してbuilderエージェントに委任
- 各タスクのレビュー完了後、統合テスト(ビルド確認)
- サイトマップ更新・リダイレクト設定(タスクDに含める)