Add pagination to feed list
This commit is contained in:
@ -20,25 +20,62 @@ function FeedList() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Pagination state
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(12);
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
const [totalFeeds, setTotalFeeds] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchFeeds();
|
fetchFeeds();
|
||||||
fetchCategories();
|
fetchCategories();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
filterFeedsByCategory();
|
// Reset to page 1 when category changes
|
||||||
}, [feeds, selectedCategory]);
|
setCurrentPage(1);
|
||||||
|
fetchFeeds();
|
||||||
|
}, [selectedCategory]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Fetch feeds when page changes
|
||||||
|
fetchFeeds();
|
||||||
|
}, [currentPage, pageSize]);
|
||||||
|
|
||||||
const fetchFeeds = async () => {
|
const fetchFeeds = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const response = await fetch("/api/feeds");
|
setError(null);
|
||||||
|
|
||||||
|
// Build query parameters
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.append("page", currentPage.toString());
|
||||||
|
params.append("limit", pageSize.toString());
|
||||||
|
if (selectedCategory) {
|
||||||
|
params.append("category", selectedCategory);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`/api/feeds?${params.toString()}`);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = await response.json();
|
const errorData = await response.json();
|
||||||
throw new Error(errorData.error || "フィードの取得に失敗しました");
|
throw new Error(errorData.error || "フィードの取得に失敗しました");
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Handle paginated response
|
||||||
|
if (data.feeds && typeof data.total !== 'undefined') {
|
||||||
|
setFeeds(data.feeds);
|
||||||
|
setFilteredFeeds(data.feeds);
|
||||||
|
setTotalFeeds(data.total);
|
||||||
|
setTotalPages(data.totalPages);
|
||||||
|
} else {
|
||||||
|
// Fallback for non-paginated response (backward compatibility)
|
||||||
setFeeds(data.feeds || []);
|
setFeeds(data.feeds || []);
|
||||||
|
setFilteredFeeds(data.feeds || []);
|
||||||
|
setTotalFeeds(data.feeds?.length || 0);
|
||||||
|
setTotalPages(1);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Feed fetch error:", err);
|
console.error("Feed fetch error:", err);
|
||||||
setError(err instanceof Error ? err.message : "エラーが発生しました");
|
setError(err instanceof Error ? err.message : "エラーが発生しました");
|
||||||
@ -59,16 +96,6 @@ function FeedList() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const filterFeedsByCategory = () => {
|
|
||||||
if (!selectedCategory) {
|
|
||||||
setFilteredFeeds(feeds);
|
|
||||||
} else {
|
|
||||||
const filtered = feeds.filter(
|
|
||||||
(feed) => feed.category === selectedCategory,
|
|
||||||
);
|
|
||||||
setFilteredFeeds(filtered);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
const formatDate = (dateString: string) => {
|
||||||
return new Date(dateString).toLocaleString("ja-JP");
|
return new Date(dateString).toLocaleString("ja-JP");
|
||||||
@ -128,9 +155,9 @@ function FeedList() {
|
|||||||
<h2>
|
<h2>
|
||||||
フィード一覧 (
|
フィード一覧 (
|
||||||
{selectedCategory
|
{selectedCategory
|
||||||
? `${filteredFeeds.length}/${feeds.length}`
|
? `${totalFeeds}件 - カテゴリ: ${selectedCategory}`
|
||||||
: feeds.length}
|
: `${totalFeeds}件`}
|
||||||
件)
|
)
|
||||||
</h2>
|
</h2>
|
||||||
<div style={{ display: "flex", gap: "10px", alignItems: "center" }}>
|
<div style={{ display: "flex", gap: "10px", alignItems: "center" }}>
|
||||||
{categories.length > 0 && (
|
{categories.length > 0 && (
|
||||||
@ -152,7 +179,25 @@ function FeedList() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
)}
|
)}
|
||||||
<button className="btn btn-secondary" onClick={fetchFeeds}>
|
<select
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(e) => {
|
||||||
|
setPageSize(Number.parseInt(e.target.value));
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: "5px 10px",
|
||||||
|
fontSize: "14px",
|
||||||
|
border: "1px solid #ccc",
|
||||||
|
borderRadius: "4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value={6}>6件表示</option>
|
||||||
|
<option value={12}>12件表示</option>
|
||||||
|
<option value={24}>24件表示</option>
|
||||||
|
<option value={48}>48件表示</option>
|
||||||
|
</select>
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={fetchFeeds}>
|
||||||
更新
|
更新
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -200,6 +245,77 @@ function FeedList() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination Controls */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="pagination-container">
|
||||||
|
<div className="pagination-info">
|
||||||
|
ページ {currentPage} / {totalPages} (全 {totalFeeds} 件)
|
||||||
|
</div>
|
||||||
|
<div className="pagination-controls">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => setCurrentPage(1)}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
最初
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => setCurrentPage(currentPage - 1)}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
前へ
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Page numbers */}
|
||||||
|
<div className="page-numbers">
|
||||||
|
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||||
|
let pageNum;
|
||||||
|
if (totalPages <= 5) {
|
||||||
|
pageNum = i + 1;
|
||||||
|
} else if (currentPage <= 3) {
|
||||||
|
pageNum = i + 1;
|
||||||
|
} else if (currentPage >= totalPages - 2) {
|
||||||
|
pageNum = totalPages - 4 + i;
|
||||||
|
} else {
|
||||||
|
pageNum = currentPage - 2 + i;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={pageNum}
|
||||||
|
className={`btn ${currentPage === pageNum ? 'btn-primary' : 'btn-secondary'}`}
|
||||||
|
onClick={() => setCurrentPage(pageNum)}
|
||||||
|
>
|
||||||
|
{pageNum}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => setCurrentPage(currentPage + 1)}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
>
|
||||||
|
次へ
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => setCurrentPage(totalPages)}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
>
|
||||||
|
最後
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<style>{`
|
<style>{`
|
||||||
.feed-grid {
|
.feed-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
@ -281,6 +397,55 @@ function FeedList() {
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pagination-container {
|
||||||
|
margin-top: 30px;
|
||||||
|
padding: 20px 0;
|
||||||
|
border-top: 1px solid #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-info {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-controls {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-numbers {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
margin: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-controls button {
|
||||||
|
min-width: 40px;
|
||||||
|
height: 36px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-controls button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.pagination-controls {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-numbers {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
23
server.ts
23
server.ts
@ -509,9 +509,32 @@ app.get("/api/episode/:episodeId", async (c) => {
|
|||||||
|
|
||||||
app.get("/api/feeds", async (c) => {
|
app.get("/api/feeds", async (c) => {
|
||||||
try {
|
try {
|
||||||
|
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 { fetchActiveFeedsPaginated } = await import("./services/database.js");
|
||||||
|
const pageNum = page ? Number.parseInt(page, 10) : 1;
|
||||||
|
const limitNum = limit ? Number.parseInt(limit, 10) : 10;
|
||||||
|
|
||||||
|
// 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 fetchActiveFeedsPaginated(pageNum, limitNum, category || undefined);
|
||||||
|
return c.json(result);
|
||||||
|
} else {
|
||||||
|
// Original behavior for backward compatibility
|
||||||
const { fetchActiveFeeds } = await import("./services/database.js");
|
const { fetchActiveFeeds } = await import("./services/database.js");
|
||||||
const feeds = await fetchActiveFeeds();
|
const feeds = await fetchActiveFeeds();
|
||||||
return c.json({ feeds });
|
return c.json({ feeds });
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching feeds:", error);
|
console.error("Error fetching feeds:", error);
|
||||||
return c.json({ error: "Failed to fetch feeds" }, 500);
|
return c.json({ error: "Failed to fetch feeds" }, 500);
|
||||||
|
@ -339,6 +339,65 @@ export async function fetchActiveFeeds(): Promise<Feed[]> {
|
|||||||
return getAllFeeds();
|
return getAllFeeds();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get paginated active feeds with total count
|
||||||
|
export async function fetchActiveFeedsPaginated(
|
||||||
|
page: number = 1,
|
||||||
|
limit: number = 10,
|
||||||
|
category?: string
|
||||||
|
): Promise<{ feeds: Feed[]; total: number; page: number; limit: number; totalPages: number }> {
|
||||||
|
try {
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
// Build query conditions
|
||||||
|
let whereCondition = "WHERE active = 1";
|
||||||
|
const params: any[] = [];
|
||||||
|
|
||||||
|
if (category) {
|
||||||
|
whereCondition += " AND category = ?";
|
||||||
|
params.push(category);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get total count
|
||||||
|
const countStmt = db.prepare(`SELECT COUNT(*) as count FROM feeds ${whereCondition}`);
|
||||||
|
const countResult = countStmt.get(...params) as { count: number };
|
||||||
|
const total = countResult.count;
|
||||||
|
|
||||||
|
// Get paginated feeds
|
||||||
|
const feedsStmt = db.prepare(`
|
||||||
|
SELECT * FROM feeds
|
||||||
|
${whereCondition}
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
const rows = feedsStmt.all(...params, limit, offset) as any[];
|
||||||
|
|
||||||
|
const feeds = rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
url: row.url,
|
||||||
|
title: row.title,
|
||||||
|
description: row.description,
|
||||||
|
category: row.category,
|
||||||
|
lastUpdated: row.last_updated,
|
||||||
|
createdAt: row.created_at,
|
||||||
|
active: Boolean(row.active),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(total / limit);
|
||||||
|
|
||||||
|
return {
|
||||||
|
feeds,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
totalPages
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error getting paginated feeds:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Get episodes with feed information for enhanced display
|
// Get episodes with feed information for enhanced display
|
||||||
export async function fetchEpisodesWithFeedInfo(): Promise<
|
export async function fetchEpisodesWithFeedInfo(): Promise<
|
||||||
EpisodeWithFeedInfo[]
|
EpisodeWithFeedInfo[]
|
||||||
|
Reference in New Issue
Block a user