Files
VoiceRSSSummary/frontend/src/components/EpisodeList.tsx
2025-06-11 23:03:40 +09:00

622 lines
20 KiB
TypeScript

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<EpisodeWithFeedInfo[]>([]);
const [filteredEpisodes, setFilteredEpisodes] = useState<
EpisodeWithFeedInfo[]
>([]);
const [categories, setCategories] = useState<string[]>([]);
const [selectedCategory, setSelectedCategory] = useState<string>("");
const [searchQuery, setSearchQuery] = useState<string>("");
const [isSearching, setIsSearching] = useState(false);
const [loading, setLoading] = useState(true);
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, 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 <div className="loading">...</div>;
}
if (error) {
return <div className="error">{error}</div>;
}
if (filteredEpisodes.length === 0 && episodes.length > 0) {
if (searchQuery.trim()) {
return (
<div className="empty-state">
<p>{searchQuery}</p>
{selectedCategory && (
<p>{selectedCategory}</p>
)}
<div
style={{
marginTop: "10px",
display: "flex",
gap: "10px",
justifyContent: "center",
}}
>
<button
className="btn btn-secondary"
onClick={() => setSearchQuery("")}
>
</button>
{selectedCategory && (
<button
className="btn btn-secondary"
onClick={() => setSelectedCategory("")}
>
</button>
)}
</div>
</div>
);
} else if (selectedCategory) {
return (
<div className="empty-state">
<p>{selectedCategory}</p>
<button
className="btn btn-secondary"
onClick={() => setSelectedCategory("")}
style={{ marginTop: "10px" }}
>
</button>
</div>
);
}
}
if (episodes.length === 0) {
return (
<div className="empty-state">
<p></p>
<p>
RSSフィードをリクエストするか
</p>
<button
className="btn btn-secondary"
onClick={fetchEpisodes}
style={{ marginTop: "10px" }}
>
</button>
</div>
);
}
return (
<div>
<div style={{ marginBottom: "20px" }}>
<div className="episode-header">
<h2 className="episode-title">
(
{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}`}
)
</h2>
<div className="episode-meta">
<span className="episode-meta-text">
: {useDatabase ? "データベース" : "XML"}
</span>
<button className="btn btn-secondary" onClick={fetchEpisodes}>
</button>
</div>
</div>
<div className="episode-search-bar">
<input
type="text"
placeholder="エピソードを検索..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="episode-search-input"
/>
{searchQuery && (
<button
className="btn btn-secondary"
onClick={() => setSearchQuery("")}
style={{
padding: "8px 12px",
fontSize: "14px",
}}
>
</button>
)}
{categories.length > 0 && (
<select
value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value)}
className="episode-category-select"
>
<option value=""></option>
{categories.map((category) => (
<option key={category} value={category}>
{category}
</option>
))}
</select>
)}
{isSearching && <span className="episode-meta-text">...</span>}
</div>
</div>
<table className="table">
<thead>
<tr>
<th style={{ width: "35%" }}></th>
<th style={{ width: "25%" }}></th>
<th style={{ width: "15%" }}></th>
<th style={{ width: "25%" }}></th>
</tr>
</thead>
<tbody>
{filteredEpisodes.map((episode) => (
<tr key={episode.id}>
<td>
<div style={{ marginBottom: "8px" }}>
<strong>
<Link
to={`/episode/${episode.id}`}
className="episode-link"
>
{episode.title}
</Link>
</strong>
</div>
{episode.feedTitle && (
<div className="episode-feed-info">
:{" "}
<Link
to={`/feeds/${episode.feedId}`}
className="episode-link"
>
{episode.feedTitle}
</Link>
{episode.feedCategory && (
<span
style={{
marginLeft: "8px",
color: "var(--text-muted)",
fontSize: "11px",
}}
>
({episode.feedCategory})
</span>
)}
</div>
)}
{episode.articleTitle &&
episode.articleTitle !== episode.title && (
<div className="episode-article-info">
: <strong>{episode.articleTitle}</strong>
</div>
)}
{episode.articleLink && (
<a
href={episode.articleLink}
target="_blank"
rel="noopener noreferrer"
className="episode-article-link"
>
</a>
)}
</td>
<td>
<div className="episode-description">
{episode.description || "No description"}
</div>
{episode.fileSize && (
<div className="episode-file-size">
{formatFileSize(episode.fileSize)}
</div>
)}
</td>
<td>{formatDate(episode.createdAt)}</td>
<td>
<div className="episode-actions">
<div className="episode-action-buttons">
<button
className="btn btn-primary"
onClick={() =>
playAudio(
episode.audioPath.startsWith("/")
? episode.audioPath
: `/podcast_audio/${episode.audioPath}`,
)
}
>
</button>
<button
className="btn btn-secondary"
onClick={() => shareEpisode(episode)}
>
</button>
</div>
{currentAudio ===
(episode.audioPath.startsWith("/")
? episode.audioPath
: `/podcast_audio/${episode.audioPath}`) && (
<div>
<audio
id={episode.audioPath}
controls
className="audio-player"
src={
episode.audioPath.startsWith("/")
? episode.audioPath
: `/podcast_audio/${episode.audioPath}`
}
onEnded={() => setCurrentAudio(null)}
/>
</div>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
{/* Pagination Controls - only show for database mode */}
{useDatabase && totalPages > 1 && (
<div className="pagination-container">
<button
className="btn btn-secondary"
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
>
</button>
{/* Page numbers */}
<div className="pagination-pages">
{/* 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 pagination-page-btn ${currentPage === pageNum ? "btn-primary" : "btn-secondary"}`}
onClick={() => setCurrentPage(pageNum)}
>
{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 className="pagination-size-selector">
<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
}}
className="pagination-size-select"
>
<option value={10}>10</option>
<option value={20}>20</option>
<option value={50}>50</option>
<option value={100}>100</option>
</select>
</div>
</div>
)}
</div>
);
}
export default EpisodeList;