import { useEffect, useState } from "react"; import { Link } from "react-router-dom"; interface Episode { id: string; title: string; description: string; pubDate: string; audioUrl: string; audioLength: string; guid: string; link: string; } interface EpisodeWithFeedInfo { id: string; title: string; description?: string; audioPath: string; duration?: number; fileSize?: number; createdAt: string; articleId: string; articleTitle: string; articleLink: string; articlePubDate: string; feedId: string; feedTitle?: string; feedUrl: string; feedCategory?: string; category?: string; } function EpisodeList() { const [episodes, setEpisodes] = useState([]); const [filteredEpisodes, setFilteredEpisodes] = useState< EpisodeWithFeedInfo[] >([]); const [categories, setCategories] = useState([]); const [selectedCategory, setSelectedCategory] = useState(""); const [searchQuery, setSearchQuery] = useState(""); const [isSearching, setIsSearching] = useState(false); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [currentAudio, setCurrentAudio] = useState(null); const [useDatabase, setUseDatabase] = useState(true); // Pagination state const [currentPage, setCurrentPage] = useState(1); const [pageSize, setPageSize] = useState(20); const [totalPages, setTotalPages] = useState(1); const [totalEpisodes, setTotalEpisodes] = useState(0); useEffect(() => { fetchEpisodes(); fetchCategories(); }, [useDatabase, currentPage, pageSize]); useEffect(() => { if (searchQuery.trim()) { performSearch(); } 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 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 && data.total === 0) { // Database is empty, fallback to XML console.log("Database is empty, falling back to XML parsing..."); setUseDatabase(false); return; } 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"); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.error || "エピソードの取得に失敗しました"); } const data = await response.json(); console.log("Fetched episodes from XML:", data); // Convert XML episodes to EpisodeWithFeedInfo format const xmlEpisodes = data.episodes || []; const convertedEpisodes: EpisodeWithFeedInfo[] = xmlEpisodes.map( (episode: Episode) => ({ id: episode.id, title: episode.title, description: episode.description, audioPath: episode.audioUrl, createdAt: episode.pubDate, articleId: episode.guid, articleTitle: episode.title, articleLink: episode.link, articlePubDate: episode.pubDate, feedId: "", feedTitle: "RSS Feed", feedUrl: "", }), ); setEpisodes(convertedEpisodes); } } catch (err) { console.error("Episode fetch error:", err); if (useDatabase) { // Fallback to XML if database fails console.log("Falling back to XML parsing..."); setUseDatabase(false); return; } setError(err instanceof Error ? err.message : "エラーが発生しました"); } finally { setLoading(false); } }; const fetchCategories = async () => { try { const response = await fetch("/api/episode-categories"); if (response.ok) { const data = await response.json(); setCategories(data.categories || []); } } catch (err) { console.error("Error fetching categories:", err); } }; const performSearch = async () => { try { setIsSearching(true); setError(null); const searchParams = new URLSearchParams({ q: searchQuery.trim(), }); if (selectedCategory) { searchParams.append("category", selectedCategory); } const response = await fetch(`/api/episodes/search?${searchParams}`); if (!response.ok) { throw new Error("検索に失敗しました"); } const data = await response.json(); setFilteredEpisodes(data.episodes || []); } catch (err) { console.error("Search error:", err); setError(err instanceof Error ? err.message : "検索エラーが発生しました"); setFilteredEpisodes([]); } finally { setIsSearching(false); } }; const filterEpisodesByCategory = () => { if (!selectedCategory) { setFilteredEpisodes(episodes); } else { const filtered = episodes.filter( (ep) => ep.category === selectedCategory, ); setFilteredEpisodes(filtered); } }; const formatDate = (dateString: string) => { return new Date(dateString).toLocaleString("ja-JP"); }; const playAudio = (audioPath: string) => { if (currentAudio) { const currentPlayer = document.getElementById( currentAudio, ) as HTMLAudioElement; if (currentPlayer) { currentPlayer.pause(); currentPlayer.currentTime = 0; } } setCurrentAudio(audioPath); }; const shareEpisode = (episode: EpisodeWithFeedInfo) => { const shareUrl = `${window.location.origin}/episode/${episode.id}`; navigator.clipboard .writeText(shareUrl) .then(() => { alert("エピソードリンクをクリップボードにコピーしました"); }) .catch(() => { // Fallback for older browsers const textArea = document.createElement("textarea"); textArea.value = shareUrl; document.body.appendChild(textArea); textArea.select(); document.execCommand("copy"); document.body.removeChild(textArea); alert("エピソードリンクをクリップボードにコピーしました"); }); }; const formatFileSize = (bytes?: number) => { if (!bytes) return ""; const units = ["B", "KB", "MB", "GB"]; let unitIndex = 0; let fileSize = bytes; while (fileSize >= 1024 && unitIndex < units.length - 1) { fileSize /= 1024; unitIndex++; } return `${fileSize.toFixed(1)} ${units[unitIndex]}`; }; if (loading) { return
読み込み中...
; } if (error) { return
{error}
; } if (filteredEpisodes.length === 0 && episodes.length > 0) { if (searchQuery.trim()) { return (

「{searchQuery}」の検索結果がありません

{selectedCategory && (

カテゴリ「{selectedCategory}」内で検索しています

)}
{selectedCategory && ( )}
); } else if (selectedCategory) { return (

カテゴリ「{selectedCategory}」のエピソードがありません

); } } if (episodes.length === 0) { return (

エピソードがありません

フィードリクエストでRSSフィードをリクエストするか、管理者にバッチ処理の実行を依頼してください

); } return (

エピソード一覧 ( {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}件`} )

データソース: {useDatabase ? "データベース" : "XML"}
setSearchQuery(e.target.value)} className="episode-search-input" /> {searchQuery && ( )} {categories.length > 0 && ( )} {isSearching && 検索中...}
{filteredEpisodes.map((episode) => ( ))}
タイトル 説明 作成日 操作
{episode.title}
{episode.feedTitle && (
フィード:{" "} {episode.feedTitle} {episode.feedCategory && ( ({episode.feedCategory}) )}
)} {episode.articleTitle && episode.articleTitle !== episode.title && (
元記事: {episode.articleTitle}
)} {episode.articleLink && ( 元記事を見る )}
{episode.description || "No description"}
{episode.fileSize && (
{formatFileSize(episode.fileSize)}
)}
{formatDate(episode.createdAt)}
{currentAudio === (episode.audioPath.startsWith("/") ? episode.audioPath : `/podcast_audio/${episode.audioPath}`) && (
)}
{/* Pagination Controls - only show for database mode */} {useDatabase && totalPages > 1 && (
{/* Page numbers */}
{/* First page */} {currentPage > 3 && ( <> {currentPage > 4 && ...} )} {/* 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 ( ); })} {/* Last page */} {currentPage < totalPages - 2 && ( <> {currentPage < totalPages - 3 && ...} )}
{/* Page size selector */}
表示件数:
)}
); } export default EpisodeList;