622 lines
20 KiB
TypeScript
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;
|