Add pagination to feed list
This commit is contained in:
		@@ -20,25 +20,62 @@ function FeedList() {
 | 
			
		||||
  const [loading, setLoading] = useState(true);
 | 
			
		||||
  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(() => {
 | 
			
		||||
    fetchFeeds();
 | 
			
		||||
    fetchCategories();
 | 
			
		||||
  }, []);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    filterFeedsByCategory();
 | 
			
		||||
  }, [feeds, selectedCategory]);
 | 
			
		||||
    // Reset to page 1 when category changes
 | 
			
		||||
    setCurrentPage(1);
 | 
			
		||||
    fetchFeeds();
 | 
			
		||||
  }, [selectedCategory]);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    // Fetch feeds when page changes
 | 
			
		||||
    fetchFeeds();
 | 
			
		||||
  }, [currentPage, pageSize]);
 | 
			
		||||
 | 
			
		||||
  const fetchFeeds = async () => {
 | 
			
		||||
    try {
 | 
			
		||||
      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) {
 | 
			
		||||
        const errorData = await response.json();
 | 
			
		||||
        throw new Error(errorData.error || "フィードの取得に失敗しました");
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      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 || []);
 | 
			
		||||
        setFilteredFeeds(data.feeds || []);
 | 
			
		||||
        setTotalFeeds(data.feeds?.length || 0);
 | 
			
		||||
        setTotalPages(1);
 | 
			
		||||
      }
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      console.error("Feed fetch error:", err);
 | 
			
		||||
      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) => {
 | 
			
		||||
    return new Date(dateString).toLocaleString("ja-JP");
 | 
			
		||||
@@ -128,9 +155,9 @@ function FeedList() {
 | 
			
		||||
        <h2>
 | 
			
		||||
          フィード一覧 (
 | 
			
		||||
          {selectedCategory
 | 
			
		||||
            ? `${filteredFeeds.length}/${feeds.length}`
 | 
			
		||||
            : feeds.length}
 | 
			
		||||
          件)
 | 
			
		||||
            ? `${totalFeeds}件 - カテゴリ: ${selectedCategory}`
 | 
			
		||||
            : `${totalFeeds}件`}
 | 
			
		||||
          )
 | 
			
		||||
        </h2>
 | 
			
		||||
        <div style={{ display: "flex", gap: "10px", alignItems: "center" }}>
 | 
			
		||||
          {categories.length > 0 && (
 | 
			
		||||
@@ -152,7 +179,25 @@ function FeedList() {
 | 
			
		||||
              ))}
 | 
			
		||||
            </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>
 | 
			
		||||
        </div>
 | 
			
		||||
@@ -200,6 +245,77 @@ function FeedList() {
 | 
			
		||||
        ))}
 | 
			
		||||
      </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>{`
 | 
			
		||||
        .feed-grid {
 | 
			
		||||
          display: grid;
 | 
			
		||||
@@ -281,6 +397,55 @@ function FeedList() {
 | 
			
		||||
          font-size: 11px;
 | 
			
		||||
          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>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										23
									
								
								server.ts
									
									
									
									
									
								
							
							
						
						
									
										23
									
								
								server.ts
									
									
									
									
									
								
							@@ -509,9 +509,32 @@ app.get("/api/episode/:episodeId", async (c) => {
 | 
			
		||||
 | 
			
		||||
app.get("/api/feeds", async (c) => {
 | 
			
		||||
  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 feeds = await fetchActiveFeeds();
 | 
			
		||||
      return c.json({ feeds });
 | 
			
		||||
    }
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error("Error fetching feeds:", error);
 | 
			
		||||
    return c.json({ error: "Failed to fetch feeds" }, 500);
 | 
			
		||||
 
 | 
			
		||||
@@ -339,6 +339,65 @@ export async function fetchActiveFeeds(): Promise<Feed[]> {
 | 
			
		||||
  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
 | 
			
		||||
export async function fetchEpisodesWithFeedInfo(): Promise<
 | 
			
		||||
  EpisodeWithFeedInfo[]
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user