From b7f3ca6a27bad6124d093599707006ae43454ef7 Mon Sep 17 00:00:00 2001 From: Satsuki Akiba Date: Sun, 8 Jun 2025 21:53:45 +0900 Subject: [PATCH] Add searching feature --- admin-panel/src/App.tsx | 140 ++++++++++++---- admin-server.ts | 16 +- frontend/src/App.tsx | 10 +- frontend/src/components/CategoryList.tsx | 8 +- frontend/src/components/EpisodeDetail.tsx | 20 ++- frontend/src/components/EpisodeList.tsx | 193 ++++++++++++++++++---- frontend/src/components/FeedList.tsx | 12 +- frontend/src/components/RSSEndpoints.tsx | 19 ++- frontend/src/styles.css | 18 +- scripts/fetch_and_generate.ts | 9 +- server.ts | 132 +++++++++------ services/batch-scheduler.ts | 14 +- services/content-extractor.ts | 6 +- services/database.ts | 100 ++++++++++- services/llm.ts | 11 +- services/podcast.ts | 50 +++--- 16 files changed, 564 insertions(+), 194 deletions(-) diff --git a/admin-panel/src/App.tsx b/admin-panel/src/App.tsx index b7458f4..f4bdc29 100644 --- a/admin-panel/src/App.tsx +++ b/admin-panel/src/App.tsx @@ -91,25 +91,33 @@ function App() { const loadData = async () => { setLoading(true); try { - const [feedsRes, statsRes, envRes, requestsRes, episodesRes] = await Promise.all([ - fetch("/api/admin/feeds"), - fetch("/api/admin/stats"), - fetch("/api/admin/env"), - fetch("/api/admin/feed-requests"), - fetch("/api/admin/episodes"), - ]); + const [feedsRes, statsRes, envRes, requestsRes, episodesRes] = + await Promise.all([ + fetch("/api/admin/feeds"), + fetch("/api/admin/stats"), + fetch("/api/admin/env"), + fetch("/api/admin/feed-requests"), + fetch("/api/admin/episodes"), + ]); - if (!feedsRes.ok || !statsRes.ok || !envRes.ok || !requestsRes.ok || !episodesRes.ok) { + if ( + !feedsRes.ok || + !statsRes.ok || + !envRes.ok || + !requestsRes.ok || + !episodesRes.ok + ) { throw new Error("Failed to load data"); } - const [feedsData, statsData, envData, requestsData, episodesData] = await Promise.all([ - feedsRes.json(), - statsRes.json(), - envRes.json(), - requestsRes.json(), - episodesRes.json(), - ]); + const [feedsData, statsData, envData, requestsData, episodesData] = + await Promise.all([ + feedsRes.json(), + statsRes.json(), + envRes.json(), + requestsRes.json(), + episodesRes.json(), + ]); setFeeds(feedsData); setStats(statsData); @@ -580,20 +588,36 @@ function App() {

-
+
{episodes.length}
総エピソード数
- {episodes.filter(ep => ep.feedCategory).map(ep => ep.feedCategory).filter((category, index, arr) => arr.indexOf(category) === index).length} + { + episodes + .filter((ep) => ep.feedCategory) + .map((ep) => ep.feedCategory) + .filter( + (category, index, arr) => + arr.indexOf(category) === index, + ).length + }
カテゴリー数
- {episodes.reduce((acc, ep) => acc + (ep.fileSize || 0), 0) / (1024 * 1024) > 1 + {episodes.reduce( + (acc, ep) => acc + (ep.fileSize || 0), + 0, + ) / + (1024 * 1024) > + 1 ? `${Math.round(episodes.reduce((acc, ep) => acc + (ep.fileSize || 0), 0) / (1024 * 1024))}MB` : `${Math.round(episodes.reduce((acc, ep) => acc + (ep.fileSize || 0), 0) / 1024)}KB`}
@@ -620,37 +644,79 @@ function App() {
  • {episode.title}

    -
    +
    フィード: {episode.feedTitle || episode.feedUrl} {episode.feedCategory && ( - + {episode.feedCategory} )}
    -
    - 記事: {episode.articleTitle} + {episode.description && ( -
    - {episode.description.length > 100 - ? episode.description.substring(0, 100) + "..." +
    + {episode.description.length > 100 + ? episode.description.substring(0, 100) + "..." : episode.description}
    )} -
    - 作成日: {new Date(episode.createdAt).toLocaleString("ja-JP")} +
    + + 作成日:{" "} + {new Date(episode.createdAt).toLocaleString( + "ja-JP", + )} + {episode.duration && ( <> | - 再生時間: {Math.round(episode.duration / 60)}分 + + 再生時間: {Math.round(episode.duration / 60)} + 分 + )} {episode.fileSize && ( <> | - ファイルサイズ: {episode.fileSize > 1024 * 1024 + ファイルサイズ:{" "} + {episode.fileSize > 1024 * 1024 ? `${Math.round(episode.fileSize / (1024 * 1024))}MB` : `${Math.round(episode.fileSize / 1024)}KB`} @@ -658,14 +724,14 @@ function App() { )}
    - 🎵 音声ファイルを再生 @@ -675,7 +741,9 @@ function App() {
    diff --git a/admin-server.ts b/admin-server.ts index 36af725..9e006ce 100644 --- a/admin-server.ts +++ b/admin-server.ts @@ -8,8 +8,8 @@ import { batchScheduler } from "./services/batch-scheduler.js"; import { config, validateConfig } from "./services/config.js"; import { closeBrowser } from "./services/content-extractor.js"; import { - deleteFeed, deleteEpisode, + deleteFeed, fetchAllEpisodes, fetchEpisodesWithArticles, getAllCategories, @@ -412,9 +412,9 @@ app.get("/api/admin/db-diagnostic", async (c) => { } catch (error) { console.error("Error running database diagnostic:", error); return c.json( - { - error: "Failed to run database diagnostic", - details: error instanceof Error ? error.message : String(error) + { + error: "Failed to run database diagnostic", + details: error instanceof Error ? error.message : String(error), }, 500, ); @@ -702,14 +702,14 @@ app.get("/index.html", serveAdminIndex); app.get("*", serveAdminIndex); // Graceful shutdown -process.on('SIGINT', async () => { - console.log('\n🛑 Received SIGINT. Graceful shutdown...'); +process.on("SIGINT", async () => { + console.log("\n🛑 Received SIGINT. Graceful shutdown..."); await closeBrowser(); process.exit(0); }); -process.on('SIGTERM', async () => { - console.log('\n🛑 Received SIGTERM. Graceful shutdown...'); +process.on("SIGTERM", async () => { + console.log("\n🛑 Received SIGTERM. Graceful shutdown..."); await closeBrowser(); process.exit(0); }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index bd2cceb..9d01031 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,9 +9,13 @@ import RSSEndpoints from "./components/RSSEndpoints"; function App() { const location = useLocation(); - const isMainPage = ["/", "/feeds", "/categories", "/feed-requests", "/rss-endpoints"].includes( - location.pathname, - ); + const isMainPage = [ + "/", + "/feeds", + "/categories", + "/feed-requests", + "/rss-endpoints", + ].includes(location.pathname); return (
    diff --git a/frontend/src/components/CategoryList.tsx b/frontend/src/components/CategoryList.tsx index de634a8..b6b5e8a 100644 --- a/frontend/src/components/CategoryList.tsx +++ b/frontend/src/components/CategoryList.tsx @@ -70,7 +70,6 @@ function CategoryList() { return groupedFeeds[category]?.length || 0; }; - if (loading) { return
    読み込み中...
    ; } @@ -138,7 +137,10 @@ function CategoryList() {
    {groupedFeeds[category]?.slice(0, 3).map((feed) => (
    - + {feed.title || feed.url}
    @@ -387,4 +389,4 @@ function CategoryList() { ); } -export default CategoryList; \ No newline at end of file +export default CategoryList; diff --git a/frontend/src/components/EpisodeDetail.tsx b/frontend/src/components/EpisodeDetail.tsx index 168006d..abd46ac 100644 --- a/frontend/src/components/EpisodeDetail.tsx +++ b/frontend/src/components/EpisodeDetail.tsx @@ -182,11 +182,17 @@ function EpisodeDetail() { {/* Image metadata */} - + - + {/* Twitter Card metadata */} @@ -195,8 +201,14 @@ function EpisodeDetail() { name="twitter:description" content={episode.description || `${episode.title}のエピソード詳細`} /> - - + + {/* Audio-specific metadata */} ([]); - const [filteredEpisodes, setFilteredEpisodes] = 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); @@ -46,8 +50,12 @@ function EpisodeList() { }, [useDatabase]); useEffect(() => { - filterEpisodesByCategory(); - }, [episodes, selectedCategory]); + if (searchQuery.trim()) { + performSearch(); + } else { + filterEpisodesByCategory(); + } + }, [episodes, selectedCategory, searchQuery]); const fetchEpisodes = async () => { try { @@ -127,12 +135,41 @@ function EpisodeList() { } }; + 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(episode => - episode.feedCategory === selectedCategory + const filtered = episodes.filter( + (episode) => episode.feedCategory === selectedCategory, ); setFilteredEpisodes(filtered); } @@ -197,19 +234,53 @@ function EpisodeList() { return
    {error}
    ; } - if (filteredEpisodes.length === 0 && episodes.length > 0 && selectedCategory) { - return ( -
    -

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

    - -
    - ); + if (filteredEpisodes.length === 0 && episodes.length > 0) { + if (searchQuery.trim()) { + return ( +
    +

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

    + {selectedCategory && ( +

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

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

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

    + +
    + ); + } } if (episodes.length === 0) { @@ -235,22 +306,79 @@ function EpisodeList() {
    -

    エピソード一覧 ({selectedCategory ? `${filteredEpisodes.length}/${episodes.length}` : episodes.length}件)

    -
    +
    +

    + エピソード一覧 ( + {searchQuery + ? `検索結果: ${filteredEpisodes.length}件` + : selectedCategory + ? `${filteredEpisodes.length}/${episodes.length}件` + : `${episodes.length}件`} + ) +

    +
    + + データソース: {useDatabase ? "データベース" : "XML"} + + +
    +
    + +
    + setSearchQuery(e.target.value)} + style={{ + flex: "1", + minWidth: "200px", + padding: "8px 12px", + fontSize: "14px", + border: "1px solid #ccc", + borderRadius: "4px", + }} + /> + {searchQuery && ( + + )} {categories.length > 0 && ( )} - - データソース: {useDatabase ? "データベース" : "XML"} - - + {isSearching && ( + 検索中... + )}
    @@ -309,7 +434,13 @@ function EpisodeList() { {episode.feedTitle} {episode.feedCategory && ( - + ({episode.feedCategory}) )} diff --git a/frontend/src/components/FeedList.tsx b/frontend/src/components/FeedList.tsx index 133726b..e538b77 100644 --- a/frontend/src/components/FeedList.tsx +++ b/frontend/src/components/FeedList.tsx @@ -63,8 +63,8 @@ function FeedList() { if (!selectedCategory) { setFilteredFeeds(feeds); } else { - const filtered = feeds.filter(feed => - feed.category === selectedCategory + const filtered = feeds.filter( + (feed) => feed.category === selectedCategory, ); setFilteredFeeds(filtered); } @@ -125,7 +125,13 @@ function FeedList() { alignItems: "center", }} > -

    フィード一覧 ({selectedCategory ? `${filteredFeeds.length}/${feeds.length}` : feeds.length}件)

    +

    + フィード一覧 ( + {selectedCategory + ? `${filteredFeeds.length}/${feeds.length}` + : feeds.length} + 件) +

    {categories.length > 0 && (