This commit is contained in:
2025-06-09 08:44:59 +09:00
parent abe4f18273
commit 27004f0cef
3 changed files with 279 additions and 21 deletions

View File

@ -44,35 +44,63 @@ function EpisodeList() {
const [error, setError] = useState<string | null>(null);
const [currentAudio, setCurrentAudio] = useState<string | null>(null);
const [useDatabase, setUseDatabase] = useState(true);
// Pagination state
const [currentPage, setCurrentPage] = useState<number>(1);
const [pageSize, setPageSize] = useState<number>(20);
const [totalPages, setTotalPages] = useState<number>(1);
const [totalEpisodes, setTotalEpisodes] = useState<number>(0);
useEffect(() => {
fetchEpisodes();
fetchCategories();
}, [useDatabase]);
}, [useDatabase, currentPage, pageSize]);
useEffect(() => {
if (searchQuery.trim()) {
performSearch();
} else {
} else if (!useDatabase) {
// Only filter locally if using XML data (non-paginated)
filterEpisodesByCategory();
}
}, [episodes, selectedCategory, searchQuery]);
// Reset to page 1 when category changes (but don't trigger if already on page 1)
useEffect(() => {
if (currentPage !== 1) {
setCurrentPage(1);
} else {
// If already on page 1, still need to refetch with new category
fetchEpisodes();
}
}, [selectedCategory]);
const fetchEpisodes = async () => {
try {
setLoading(true);
setError(null);
if (useDatabase) {
// Try to fetch from database first
const response = await fetch("/api/episodes-with-feed-info");
// Try to fetch from database first with pagination
const searchParams = new URLSearchParams({
page: currentPage.toString(),
limit: pageSize.toString(),
});
if (selectedCategory) {
searchParams.append("category", selectedCategory);
}
const response = await fetch(`/api/episodes-with-feed-info?${searchParams}`);
if (!response.ok) {
throw new Error("データベースからの取得に失敗しました");
}
const data = await response.json();
// Handle paginated response
if (data.episodes !== undefined) {
const dbEpisodes = data.episodes || [];
if (dbEpisodes.length === 0) {
if (dbEpisodes.length === 0 && data.total === 0) {
// Database is empty, fallback to XML
console.log("Database is empty, falling back to XML parsing...");
setUseDatabase(false);
@ -80,6 +108,19 @@ function EpisodeList() {
}
setEpisodes(dbEpisodes);
setTotalEpisodes(data.total || 0);
setTotalPages(data.totalPages || 1);
setFilteredEpisodes(dbEpisodes); // For paginated data, episodes are already filtered
} else {
// Fallback to non-paginated response
const dbEpisodes = data.episodes || [];
if (dbEpisodes.length === 0) {
console.log("Database is empty, falling back to XML parsing...");
setUseDatabase(false);
return;
}
setEpisodes(dbEpisodes);
}
} else {
// Use XML parsing as primary source
const response = await fetch("/api/episodes-from-xml");
@ -321,6 +362,8 @@ function EpisodeList() {
(
{searchQuery
? `検索結果: ${filteredEpisodes.length}`
: useDatabase
? `${totalEpisodes}件中 ${Math.min((currentPage - 1) * pageSize + 1, totalEpisodes)}-${Math.min(currentPage * pageSize, totalEpisodes)}件を表示 (${currentPage}/${totalPages}ページ)`
: selectedCategory
? `${filteredEpisodes.length}/${episodes.length}`
: `${episodes.length}`}
@ -547,6 +590,108 @@ function EpisodeList() {
))}
</tbody>
</table>
{/* Pagination Controls - only show for database mode */}
{useDatabase && totalPages > 1 && (
<div
style={{
marginTop: "20px",
display: "flex",
justifyContent: "center",
alignItems: "center",
gap: "10px",
flexWrap: "wrap",
}}
>
<button
className="btn btn-secondary"
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
>
</button>
{/* Page numbers */}
<div style={{ display: "flex", gap: "5px", alignItems: "center" }}>
{/* First page */}
{currentPage > 3 && (
<>
<button
className={`btn ${currentPage === 1 ? "btn-primary" : "btn-secondary"}`}
onClick={() => setCurrentPage(1)}
>
1
</button>
{currentPage > 4 && <span>...</span>}
</>
)}
{/* Current page and nearby pages */}
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
const pageNum = Math.max(1, currentPage - 2) + i;
if (pageNum > totalPages) return null;
if (pageNum < Math.max(1, currentPage - 2)) return null;
return (
<button
key={pageNum}
className={`btn ${currentPage === pageNum ? "btn-primary" : "btn-secondary"}`}
onClick={() => setCurrentPage(pageNum)}
style={{
minWidth: "40px",
}}
>
{pageNum}
</button>
);
})}
{/* Last page */}
{currentPage < totalPages - 2 && (
<>
{currentPage < totalPages - 3 && <span>...</span>}
<button
className={`btn ${currentPage === totalPages ? "btn-primary" : "btn-secondary"}`}
onClick={() => setCurrentPage(totalPages)}
>
{totalPages}
</button>
</>
)}
</div>
<button
className="btn btn-secondary"
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
>
</button>
{/* Page size selector */}
<div style={{ marginLeft: "20px", display: "flex", alignItems: "center", gap: "5px" }}>
<span style={{ fontSize: "14px" }}>:</span>
<select
value={pageSize}
onChange={(e) => {
setPageSize(Number.parseInt(e.target.value, 10));
setCurrentPage(1); // Reset to first page when changing page size
}}
style={{
padding: "4px 8px",
fontSize: "14px",
border: "1px solid #ccc",
borderRadius: "4px",
}}
>
<option value={10}>10</option>
<option value={20}>20</option>
<option value={50}>50</option>
<option value={100}>100</option>
</select>
</div>
</div>
)}
</div>
);
}

View File

@ -627,11 +627,32 @@ app.get("/api/episode-with-source/:episodeId", async (c) => {
app.get("/api/episodes-with-feed-info", async (c) => {
try {
const { fetchEpisodesWithFeedInfo } = await import(
"./services/database.js"
);
const page = c.req.query("page");
const limit = c.req.query("limit");
const category = c.req.query("category");
// If pagination parameters are provided, use paginated endpoint
if (page || limit) {
const { fetchEpisodesWithFeedInfoPaginated } = await import("./services/database.js");
const pageNum = page ? Number.parseInt(page, 10) : 1;
const limitNum = limit ? Number.parseInt(limit, 10) : 20;
// Validate pagination parameters
if (Number.isNaN(pageNum) || pageNum < 1) {
return c.json({ error: "Invalid page number" }, 400);
}
if (Number.isNaN(limitNum) || limitNum < 1 || limitNum > 100) {
return c.json({ error: "Invalid limit (must be between 1-100)" }, 400);
}
const result = await fetchEpisodesWithFeedInfoPaginated(pageNum, limitNum, category || undefined);
return c.json(result);
} else {
// Original behavior for backward compatibility
const { fetchEpisodesWithFeedInfo } = await import("./services/database.js");
const episodes = await fetchEpisodesWithFeedInfo();
return c.json({ episodes });
}
} catch (error) {
console.error("Error fetching episodes with feed info:", error);
return c.json({ error: "Failed to fetch episodes with feed info" }, 500);

View File

@ -454,6 +454,98 @@ export async function fetchEpisodesWithFeedInfo(): Promise<
}
}
// Get episodes with feed information for enhanced display (paginated)
export async function fetchEpisodesWithFeedInfoPaginated(
page: number = 1,
limit: number = 10,
category?: string
): Promise<{ episodes: EpisodeWithFeedInfo[]; total: number; page: number; limit: number; totalPages: number }> {
try {
const offset = (page - 1) * limit;
// Build query conditions
let whereCondition = "WHERE f.active = 1";
const params: any[] = [];
if (category) {
whereCondition += " AND e.category = ?";
params.push(category);
}
// Get total count
const countStmt = db.prepare(`
SELECT COUNT(*) as count
FROM episodes e
JOIN articles a ON e.article_id = a.id
JOIN feeds f ON a.feed_id = f.id
${whereCondition}
`);
const countResult = countStmt.get(...params) as { count: number };
const total = countResult.count;
// Get paginated episodes
const episodesStmt = db.prepare(`
SELECT
e.id,
e.title,
e.description,
e.audio_path as audioPath,
e.duration,
e.file_size as fileSize,
e.category,
e.created_at as createdAt,
e.article_id as articleId,
a.title as articleTitle,
a.link as articleLink,
a.pub_date as articlePubDate,
f.id as feedId,
f.title as feedTitle,
f.url as feedUrl,
f.category as feedCategory
FROM episodes e
JOIN articles a ON e.article_id = a.id
JOIN feeds f ON a.feed_id = f.id
${whereCondition}
ORDER BY e.created_at DESC
LIMIT ? OFFSET ?
`);
const rows = episodesStmt.all(...params, limit, offset) as any[];
const episodes = rows.map((row) => ({
id: row.id,
title: row.title,
description: row.description,
audioPath: row.audioPath,
duration: row.duration,
fileSize: row.fileSize,
category: row.category,
createdAt: row.createdAt,
articleId: row.articleId,
articleTitle: row.articleTitle,
articleLink: row.articleLink,
articlePubDate: row.articlePubDate,
feedId: row.feedId,
feedTitle: row.feedTitle,
feedUrl: row.feedUrl,
feedCategory: row.feedCategory,
}));
const totalPages = Math.ceil(total / limit);
return {
episodes,
total,
page,
limit,
totalPages
};
} catch (error) {
console.error("Error fetching paginated episodes with feed info:", error);
throw error;
}
}
// Get episodes by feed ID
export async function fetchEpisodesByFeedId(
feedId: string,