|
import { useState, useMemo, useEffect } from 'react' |
|
import { useSearchParams, useRouter } from 'next/navigation' |
|
import articlesData from './data.json' |
|
import type { Article, PaginationInfo, AdjacentArticles } from './type' |
|
|
|
/** |
|
* ページング情報を計算する関数 |
|
* |
|
* @param currentPage - 現在のページ番号(1から始まる) |
|
* @param totalItems - 総データ件数 |
|
* @param itemsPerPage - 1ページあたりの表示件数 |
|
* @returns ページング情報オブジェクト |
|
*/ |
|
export const calculatePaginationInfo = ( |
|
currentPage: number, |
|
totalItems: number, |
|
itemsPerPage: number |
|
): PaginationInfo => { |
|
// 総ページ数を計算(小数点以下は切り上げ) |
|
// 例: 100件のデータを3件ずつ表示 → Math.ceil(100/3) = 34ページ |
|
const totalPages = Math.ceil(totalItems / itemsPerPage) |
|
|
|
// 現在のページ番号を有効な範囲内に収める(1以上、総ページ数以下) |
|
// ユーザーが無効なページ番号を指定した場合の安全対策 |
|
const validCurrentPage = Math.max(1, Math.min(currentPage, totalPages)) |
|
|
|
// 表示開始位置のインデックスを計算(0から始まる) |
|
// 例: 5ページ目なら (5-1) × 3 = 12(配列の12番目から開始) |
|
const startIndex = (validCurrentPage - 1) * itemsPerPage |
|
|
|
// 表示終了位置のインデックスを計算(総データ数を超えないように調整) |
|
// 例: startIndex=12, itemsPerPage=3なら 12+3=15だが、総データ数100を超えない |
|
const endIndex = Math.min(startIndex + itemsPerPage, totalItems) |
|
|
|
// 次のページがあるかどうか判定 |
|
const hasNext = validCurrentPage < totalPages |
|
|
|
// 前のページがあるかどうか判定 |
|
const hasPrev = validCurrentPage > 1 |
|
|
|
return { |
|
currentPage: validCurrentPage, |
|
totalPages, |
|
totalItems, |
|
itemsPerPage, |
|
hasNext, |
|
hasPrev, |
|
// 次のページ番号(なければnull) |
|
nextPage: hasNext ? validCurrentPage + 1 : null, |
|
// 前のページ番号(なければnull) |
|
prevPage: hasPrev ? validCurrentPage - 1 : null, |
|
startIndex, |
|
endIndex |
|
} |
|
} |
|
|
|
/** |
|
* 指定された記事の前後の記事を取得する関数 |
|
* |
|
* @param articles - 全記事の配列 |
|
* @param currentArticleId - 現在の記事のID |
|
* @returns 前後の記事オブジェクト |
|
*/ |
|
export const getAdjacentArticles = ( |
|
articles: Article[], |
|
currentArticleId: number |
|
): AdjacentArticles => { |
|
// 現在の記事が配列の何番目にあるかを検索 |
|
// findIndexは見つからない場合-1を返す |
|
const currentIndex = articles.findIndex(article => article.id === currentArticleId) |
|
|
|
return { |
|
// 次の記事: 現在のインデックスが最後でなければ存在 |
|
next: currentIndex < articles.length - 1 ? articles[currentIndex + 1] : null, |
|
// 前の記事: 現在のインデックスが0より大きければ存在 |
|
prev: currentIndex > 0 ? articles[currentIndex - 1] : null |
|
} |
|
} |
|
|
|
/** |
|
* 省略記号付きのページ番号リストを生成する関数 |
|
* 例: [1, "...", 5, 6, 7, "...", 20] のような形式 |
|
* |
|
* @param currentPage - 現在のページ番号 |
|
* @param totalPages - 総ページ数 |
|
* @param delta - 現在のページの前後に表示するページ数(デフォルト: 2) |
|
* @returns ページ番号と省略記号の配列 |
|
*/ |
|
export const getPageNumbers = ( |
|
currentPage: number, |
|
totalPages: number, |
|
delta: number = 2 |
|
): (number | string)[] => { |
|
const pageNumbers: (number | string)[] = [] |
|
|
|
// 総ページ数が7以下の場合は全ページを表示 |
|
// 省略の必要がないため |
|
if (totalPages <= 7) { |
|
for (let i = 1; i <= totalPages; i++) { |
|
pageNumbers.push(i) |
|
} |
|
} else { |
|
// 複雑なページング表示のロジック |
|
|
|
// 常に最初のページ(1)を表示 |
|
pageNumbers.push(1) |
|
|
|
// 現在のページが最初の方から離れている場合、省略記号を表示 |
|
// delta + 2 = 2 + 2 = 4より大きい場合(5ページ目以降) |
|
if (currentPage > delta + 2) { |
|
pageNumbers.push('...') |
|
} |
|
|
|
// 現在のページの前後delta個分のページを表示 |
|
// ただし、2ページ目以降、最後から2ページ目まで |
|
for (let i = Math.max(2, currentPage - delta); i <= Math.min(totalPages - 1, currentPage + delta); i++) { |
|
pageNumbers.push(i) |
|
} |
|
|
|
// 現在のページが最後の方から離れている場合、省略記号を表示 |
|
if (currentPage < totalPages - delta - 1) { |
|
pageNumbers.push('...') |
|
} |
|
|
|
// 常に最後のページを表示 |
|
pageNumbers.push(totalPages) |
|
} |
|
|
|
return pageNumbers |
|
} |
|
|
|
/** |
|
* 全記事データを取得する関数 |
|
* |
|
* @returns 記事の配列 |
|
*/ |
|
export const getArticles = (): Article[] => { |
|
return articlesData |
|
} |
|
|
|
/** |
|
* 指定された範囲の記事を切り出す関数 |
|
* |
|
* @param articles - 全記事の配列 |
|
* @param startIndex - 開始インデックス |
|
* @param endIndex - 終了インデックス |
|
* @returns 指定範囲の記事配列 |
|
*/ |
|
export const getPaginatedArticles = ( |
|
articles: Article[], |
|
startIndex: number, |
|
endIndex: number |
|
): Article[] => { |
|
// Array.slice()で指定範囲の要素を取得 |
|
// startIndex以上endIndex未満の要素を返す |
|
return articles.slice(startIndex, endIndex) |
|
} |
|
|
|
/** |
|
* usePaginationカスタムフックの戻り値の型定義 |
|
*/ |
|
export type UsePaginationResult = { |
|
currentPage: number // 現在のページ番号 |
|
selectedArticleId: number | null // 選択中の記事ID |
|
paginationInfo: PaginationInfo // ページング情報 |
|
currentPageArticles: Article[] // 現在のページの記事配列 |
|
adjacentArticles: AdjacentArticles | null // 選択記事の前後の記事 |
|
pageNumbers: (number | string)[] // ページ番号リスト(省略記号含む) |
|
handlePageChange: (page: number) => void // ページ変更ハンドラー |
|
handleArticleSelect: (articleId: number) => void // 記事選択ハンドラー |
|
} |
|
|
|
/** |
|
* ページング機能を提供するカスタムフック(URL同期版) |
|
* |
|
* このフックは以下の機能を提供します: |
|
* - ページング状態の管理 |
|
* - URLパラメータとの同期 |
|
* - ページング情報の計算 |
|
* - 現在のページの記事取得 |
|
* - 選択記事の前後記事取得 |
|
* - ページ番号リストの生成 |
|
* - イベントハンドラーの提供 |
|
* |
|
* URLパラメータ: |
|
* - ?page=N: ページ番号(例: ?page=5) |
|
* - ?article=ID: 選択記事ID(例: ?article=10) |
|
* |
|
* @param itemsPerPage - 1ページあたりの表示件数(デフォルト: 5) |
|
* @returns ページング関連の状態と関数 |
|
*/ |
|
export const usePagination = (itemsPerPage: number = 5): UsePaginationResult => { |
|
// Next.jsのルーティング機能 |
|
const router = useRouter() |
|
const searchParams = useSearchParams() |
|
|
|
// URLパラメータから初期値を取得 |
|
const initialPage = parseInt(searchParams.get('page') || '1', 10) |
|
const initialArticleId = searchParams.get('article') ? parseInt(searchParams.get('article') || '0', 10) : null |
|
|
|
// 現在のページ番号の状態管理(URLパラメータから初期化) |
|
const [currentPage, setCurrentPage] = useState(initialPage) |
|
|
|
// 選択中の記事IDの状態管理(URLパラメータから初期化) |
|
const [selectedArticleId, setSelectedArticleId] = useState<number | null>(initialArticleId) |
|
|
|
// 全記事データを取得 |
|
const articles = getArticles() |
|
|
|
// URLパラメータが変更された時に状態を同期 |
|
useEffect(() => { |
|
const page = parseInt(searchParams.get('page') || '1', 10) |
|
const articleId = searchParams.get('article') ? parseInt(searchParams.get('article') || '0', 10) : null |
|
|
|
setCurrentPage(page) |
|
setSelectedArticleId(articleId) |
|
}, [searchParams]) |
|
|
|
// ページング情報をメモ化して計算 |
|
// currentPage, articles.length, itemsPerPageが変更された時のみ再計算 |
|
const paginationInfo = useMemo(() => { |
|
return calculatePaginationInfo(currentPage, articles.length, itemsPerPage) |
|
}, [currentPage, articles.length, itemsPerPage]) |
|
|
|
// 現在のページの記事配列をメモ化して計算 |
|
// articles, startIndex, endIndexが変更された時のみ再計算 |
|
const currentPageArticles = useMemo(() => { |
|
return getPaginatedArticles(articles, paginationInfo.startIndex, paginationInfo.endIndex) |
|
}, [articles, paginationInfo.startIndex, paginationInfo.endIndex]) |
|
|
|
// 選択記事の前後記事をメモ化して計算 |
|
// articles, selectedArticleIdが変更された時のみ再計算 |
|
const adjacentArticles = useMemo(() => { |
|
if (selectedArticleId) { |
|
return getAdjacentArticles(articles, selectedArticleId) |
|
} else { |
|
return null |
|
} |
|
}, [articles, selectedArticleId]) |
|
|
|
// ページ番号リストをメモ化して計算 |
|
// currentPage, totalPagesが変更された時のみ再計算 |
|
const pageNumbers = useMemo(() => { |
|
return getPageNumbers(paginationInfo.currentPage, paginationInfo.totalPages) |
|
}, [paginationInfo.currentPage, paginationInfo.totalPages]) |
|
|
|
/** |
|
* URLパラメータを更新する関数 |
|
* |
|
* @param newPage - 新しいページ番号 |
|
* @param newArticleId - 新しい記事ID(nullの場合はパラメータを削除) |
|
*/ |
|
const updateUrl = (newPage: number, newArticleId: number | null = null) => { |
|
const params = new URLSearchParams() |
|
|
|
// ページ番号をパラメータに追加(1ページ目以外) |
|
if (newPage > 1) { |
|
params.set('page', newPage.toString()) |
|
} |
|
|
|
// 記事IDをパラメータに追加(選択されている場合) |
|
if (newArticleId) { |
|
params.set('article', newArticleId.toString()) |
|
} |
|
|
|
// URLを更新(ブラウザの履歴に追加) |
|
const queryString = params.toString() |
|
const newUrl = queryString ? `?${queryString}` : '/benkyo/paging' |
|
router.push(newUrl) |
|
} |
|
|
|
/** |
|
* ページ変更時のハンドラー(URL同期版) |
|
* |
|
* @param page - 変更先のページ番号 |
|
*/ |
|
const handlePageChange = (page: number) => { |
|
// URLを更新(記事選択は解除) |
|
updateUrl(page, null) |
|
} |
|
|
|
/** |
|
* 記事選択時のハンドラー(URL同期版) |
|
* |
|
* @param articleId - 選択する記事のID |
|
*/ |
|
const handleArticleSelect = (articleId: number) => { |
|
// URLを更新(現在のページを維持、記事IDを追加) |
|
updateUrl(currentPage, articleId) |
|
} |
|
|
|
// カスタムフックの戻り値 |
|
return { |
|
currentPage, |
|
selectedArticleId, |
|
paginationInfo, |
|
currentPageArticles, |
|
adjacentArticles, |
|
pageNumbers, |
|
handlePageChange, |
|
handleArticleSelect |
|
} |
|
} |