読み込み中...
}
@@ -109,7 +178,7 @@ function EpisodeList() {
- {episode.title}
+
+
+ {episode.title}
+
+
- {episode.link && (
+ {episode.feedTitle && (
+
+ フィード: {episode.feedTitle}
+
+ )}
+ {episode.articleTitle && episode.articleTitle !== episode.title && (
+
+ 元記事: {episode.articleTitle}
+
+ )}
+ {episode.articleLink && (
{episode.description || 'No description'}
+ {episode.fileSize && (
+
+ {formatFileSize(episode.fileSize)}
+
+ )}
|
- {formatDate(episode.pubDate)} |
+ {formatDate(episode.createdAt)} |
@@ -159,13 +250,13 @@ function EpisodeList() {
共有
- {currentAudio === episode.audioUrl && (
+ {currentAudio === episode.audioPath && (
diff --git a/frontend/src/components/FeedDetail.tsx b/frontend/src/components/FeedDetail.tsx
new file mode 100644
index 0000000..adc9ea2
--- /dev/null
+++ b/frontend/src/components/FeedDetail.tsx
@@ -0,0 +1,282 @@
+import { useState, useEffect } from 'react'
+import { useParams, Link } from 'react-router-dom'
+
+interface Feed {
+ id: string
+ url: string
+ title?: string
+ description?: string
+ lastUpdated?: string
+ createdAt: string
+ active: boolean
+}
+
+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
+}
+
+function FeedDetail() {
+ const { feedId } = useParams<{ feedId: string }>()
+ const [feed, setFeed] = useState (null)
+ const [episodes, setEpisodes] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+ const [currentAudio, setCurrentAudio] = useState(null)
+
+ useEffect(() => {
+ if (feedId) {
+ fetchFeedAndEpisodes()
+ }
+ }, [feedId])
+
+ const fetchFeedAndEpisodes = async () => {
+ try {
+ setLoading(true)
+
+ // Fetch feed info and episodes in parallel
+ const [feedResponse, episodesResponse] = await Promise.all([
+ fetch(`/api/feeds/${feedId}`),
+ fetch(`/api/feeds/${feedId}/episodes`)
+ ])
+
+ if (!feedResponse.ok) {
+ const errorData = await feedResponse.json()
+ throw new Error(errorData.error || 'フィード情報の取得に失敗しました')
+ }
+
+ if (!episodesResponse.ok) {
+ const errorData = await episodesResponse.json()
+ throw new Error(errorData.error || 'エピソードの取得に失敗しました')
+ }
+
+ const feedData = await feedResponse.json()
+ const episodesData = await episodesResponse.json()
+
+ setFeed(feedData.feed)
+ setEpisodes(episodesData.episodes || [])
+ } catch (err) {
+ console.error('Feed detail fetch error:', err)
+ setError(err instanceof Error ? err.message : 'エラーが発生しました')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const formatDate = (dateString: string) => {
+ return new Date(dateString).toLocaleString('ja-JP')
+ }
+
+ 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]}`
+ }
+
+ 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(() => {
+ const textArea = document.createElement('textarea')
+ textArea.value = shareUrl
+ document.body.appendChild(textArea)
+ textArea.select()
+ document.execCommand('copy')
+ document.body.removeChild(textArea)
+ alert('エピソードリンクをクリップボードにコピーしました')
+ })
+ }
+
+ if (loading) {
+ return 読み込み中...
+ }
+
+ if (error) {
+ return (
+
+ {error}
+
+ フィード一覧に戻る
+
+
+ )
+ }
+
+ if (!feed) {
+ return (
+
+ フィードが見つかりません
+
+ フィード一覧に戻る
+
+
+ )
+ }
+
+ return (
+
+
+
+ ← フィード一覧に戻る
+
+
+
+
+
+ {feed.title || feed.url}
+
+
+ {feed.description && (
+
+ {feed.description}
+
+ )}
+
+ 作成日: {formatDate(feed.createdAt)}
+ {feed.lastUpdated && ` | 最終更新: ${formatDate(feed.lastUpdated)}`}
+
+
+
+
+ エピソード一覧 ({episodes.length}件)
+
+
+
+ {episodes.length === 0 ? (
+
+ このフィードにはまだエピソードがありません
+ 管理者にバッチ処理の実行を依頼してください
+
+ ) : (
+
+
+
+ タイトル |
+ 説明 |
+ 作成日 |
+ 操作 |
+
+
+
+ {episodes.map((episode) => (
+
+
+
+
+
+ {episode.title}
+
+
+
+
+ 元記事: {episode.articleTitle}
+
+ {episode.articleLink && (
+
+ 元記事を見る
+
+ )}
+ |
+
+
+ {episode.description || 'No description'}
+
+ {episode.fileSize && (
+
+ {formatFileSize(episode.fileSize)}
+
+ )}
+ |
+ {formatDate(episode.createdAt)} |
+
+
+
+
+
+
+ {currentAudio === episode.audioPath && (
+
+
+ )}
+
+ |
+
+ ))}
+
+
+ )}
+
+ )
+}
+
+export default FeedDetail
\ No newline at end of file
diff --git a/frontend/src/components/FeedList.tsx b/frontend/src/components/FeedList.tsx
new file mode 100644
index 0000000..f29c719
--- /dev/null
+++ b/frontend/src/components/FeedList.tsx
@@ -0,0 +1,188 @@
+import { useState, useEffect } from 'react'
+import { Link } from 'react-router-dom'
+
+interface Feed {
+ id: string
+ url: string
+ title?: string
+ description?: string
+ lastUpdated?: string
+ createdAt: string
+ active: boolean
+}
+
+function FeedList() {
+ const [feeds, setFeeds] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+
+ useEffect(() => {
+ fetchFeeds()
+ }, [])
+
+ const fetchFeeds = async () => {
+ try {
+ setLoading(true)
+ const response = await fetch('/api/feeds')
+ if (!response.ok) {
+ const errorData = await response.json()
+ throw new Error(errorData.error || 'フィードの取得に失敗しました')
+ }
+ const data = await response.json()
+ setFeeds(data.feeds || [])
+ } catch (err) {
+ console.error('Feed fetch error:', err)
+ setError(err instanceof Error ? err.message : 'エラーが発生しました')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const formatDate = (dateString: string) => {
+ return new Date(dateString).toLocaleString('ja-JP')
+ }
+
+ if (loading) {
+ return 読み込み中...
+ }
+
+ if (error) {
+ return {error}
+ }
+
+ if (feeds.length === 0) {
+ return (
+
+ アクティブなフィードがありません
+ フィードリクエストでRSSフィードをリクエストするか、管理者にフィード追加を依頼してください
+
+
+ )
+ }
+
+ return (
+
+
+ フィード一覧 ({feeds.length}件)
+
+
+
+
+ {feeds.map((feed) => (
+
+
+
+
+ {feed.title || feed.url}
+
+
+
+
+
+ {feed.description && (
+
+ {feed.description}
+
+ )}
+
+
+ 作成日: {formatDate(feed.createdAt)}
+ {feed.lastUpdated && (
+ 最終更新: {formatDate(feed.lastUpdated)}
+ )}
+
+
+
+
+ エピソード一覧を見る
+
+
+
+ ))}
+
+
+
+
+ )
+}
+
+export default FeedList
\ No newline at end of file
diff --git a/server.ts b/server.ts
index 00bb681..9b31614 100644
--- a/server.ts
+++ b/server.ts
@@ -219,6 +219,74 @@ app.get("/api/episode/:episodeId", async (c) => {
}
});
+app.get("/api/feeds", async (c) => {
+ try {
+ 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);
+ }
+});
+
+app.get("/api/feeds/:feedId", async (c) => {
+ try {
+ const feedId = c.req.param("feedId");
+ const { getFeedById } = await import("./services/database.js");
+ const feed = await getFeedById(feedId);
+
+ if (!feed) {
+ return c.json({ error: "Feed not found" }, 404);
+ }
+
+ return c.json({ feed });
+ } catch (error) {
+ console.error("Error fetching feed:", error);
+ return c.json({ error: "Failed to fetch feed" }, 500);
+ }
+});
+
+app.get("/api/feeds/:feedId/episodes", async (c) => {
+ try {
+ const feedId = c.req.param("feedId");
+ const { fetchEpisodesByFeedId } = await import("./services/database.js");
+ const episodes = await fetchEpisodesByFeedId(feedId);
+ return c.json({ episodes });
+ } catch (error) {
+ console.error("Error fetching episodes by feed:", error);
+ return c.json({ error: "Failed to fetch episodes by feed" }, 500);
+ }
+});
+
+app.get("/api/episodes-with-feed-info", async (c) => {
+ try {
+ const { fetchEpisodesWithFeedInfo } = await import("./services/database.js");
+ const episodes = await fetchEpisodesWithFeedInfo();
+ return c.json({ episodes });
+ } catch (error) {
+ console.error("Error fetching episodes with feed info:", error);
+ return c.json({ error: "Failed to fetch episodes with feed info" }, 500);
+ }
+});
+
+app.get("/api/episode-with-source/:episodeId", async (c) => {
+ try {
+ const episodeId = c.req.param("episodeId");
+ const { fetchEpisodeWithSourceInfo } = await import("./services/database.js");
+ const episode = await fetchEpisodeWithSourceInfo(episodeId);
+
+ if (!episode) {
+ return c.json({ error: "Episode not found" }, 404);
+ }
+
+ return c.json({ episode });
+ } catch (error) {
+ console.error("Error fetching episode with source info:", error);
+ return c.json({ error: "Failed to fetch episode with source info" }, 500);
+ }
+});
+
app.post("/api/feed-requests", async (c) => {
try {
const body = await c.req.json();
diff --git a/services/database.ts b/services/database.ts
index 1597326..9ce4b0f 100644
--- a/services/database.ts
+++ b/services/database.ts
@@ -145,6 +145,24 @@ export interface LegacyEpisode {
sourceLink: string;
}
+// Extended interfaces for frontend display
+export 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;
+}
+
// Feed management functions
export async function saveFeed(
feed: Omit,
@@ -236,6 +254,161 @@ export async function getAllFeeds(): Promise {
}
}
+// Get active feeds for user display
+export async function fetchActiveFeeds(): Promise {
+ return getAllFeeds();
+}
+
+// Get episodes with feed information for enhanced display
+export async function fetchEpisodesWithFeedInfo(): Promise {
+ try {
+ const stmt = db.prepare(`
+ SELECT
+ e.id,
+ e.title,
+ e.description,
+ e.audio_path as audioPath,
+ e.duration,
+ e.file_size as fileSize,
+ e.created_at as createdAt,
+ e.article_id as articleId,
+ a.title as articleTitle,
+ a.link as articleLink,
+ a.pub_date as articlePubDate,
+ f.id as feedId,
+ f.title as feedTitle,
+ f.url as feedUrl
+ FROM episodes e
+ JOIN articles a ON e.article_id = a.id
+ JOIN feeds f ON a.feed_id = f.id
+ WHERE f.active = 1
+ ORDER BY e.created_at DESC
+ `);
+
+ const rows = stmt.all() as any[];
+
+ return rows.map((row) => ({
+ id: row.id,
+ title: row.title,
+ description: row.description,
+ audioPath: row.audioPath,
+ duration: row.duration,
+ fileSize: row.fileSize,
+ createdAt: row.createdAt,
+ articleId: row.articleId,
+ articleTitle: row.articleTitle,
+ articleLink: row.articleLink,
+ articlePubDate: row.articlePubDate,
+ feedId: row.feedId,
+ feedTitle: row.feedTitle,
+ feedUrl: row.feedUrl,
+ }));
+ } catch (error) {
+ console.error("Error fetching episodes with feed info:", error);
+ throw error;
+ }
+}
+
+// Get episodes by feed ID
+export async function fetchEpisodesByFeedId(feedId: string): Promise {
+ try {
+ const stmt = db.prepare(`
+ SELECT
+ e.id,
+ e.title,
+ e.description,
+ e.audio_path as audioPath,
+ e.duration,
+ e.file_size as fileSize,
+ e.created_at as createdAt,
+ e.article_id as articleId,
+ a.title as articleTitle,
+ a.link as articleLink,
+ a.pub_date as articlePubDate,
+ f.id as feedId,
+ f.title as feedTitle,
+ f.url as feedUrl
+ FROM episodes e
+ JOIN articles a ON e.article_id = a.id
+ JOIN feeds f ON a.feed_id = f.id
+ WHERE f.id = ? AND f.active = 1
+ ORDER BY e.created_at DESC
+ `);
+
+ const rows = stmt.all(feedId) as any[];
+
+ return rows.map((row) => ({
+ id: row.id,
+ title: row.title,
+ description: row.description,
+ audioPath: row.audioPath,
+ duration: row.duration,
+ fileSize: row.fileSize,
+ createdAt: row.createdAt,
+ articleId: row.articleId,
+ articleTitle: row.articleTitle,
+ articleLink: row.articleLink,
+ articlePubDate: row.articlePubDate,
+ feedId: row.feedId,
+ feedTitle: row.feedTitle,
+ feedUrl: row.feedUrl,
+ }));
+ } catch (error) {
+ console.error("Error fetching episodes by feed ID:", error);
+ throw error;
+ }
+}
+
+// Get single episode with source information
+export async function fetchEpisodeWithSourceInfo(episodeId: string): Promise {
+ try {
+ const stmt = db.prepare(`
+ SELECT
+ e.id,
+ e.title,
+ e.description,
+ e.audio_path as audioPath,
+ e.duration,
+ e.file_size as fileSize,
+ e.created_at as createdAt,
+ e.article_id as articleId,
+ a.title as articleTitle,
+ a.link as articleLink,
+ a.pub_date as articlePubDate,
+ f.id as feedId,
+ f.title as feedTitle,
+ f.url as feedUrl
+ FROM episodes e
+ JOIN articles a ON e.article_id = a.id
+ JOIN feeds f ON a.feed_id = f.id
+ WHERE e.id = ?
+ `);
+
+ const row = stmt.get(episodeId) as any;
+ if (!row) return null;
+
+ return {
+ id: row.id,
+ title: row.title,
+ description: row.description,
+ audioPath: row.audioPath,
+ duration: row.duration,
+ fileSize: row.fileSize,
+ createdAt: row.createdAt,
+ articleId: row.articleId,
+ articleTitle: row.articleTitle,
+ articleLink: row.articleLink,
+ articlePubDate: row.articlePubDate,
+ feedId: row.feedId,
+ feedTitle: row.feedTitle,
+ feedUrl: row.feedUrl,
+ };
+ } catch (error) {
+ console.error("Error fetching episode with source info:", error);
+ throw error;
+ }
+}
+
export async function getAllFeedsIncludingInactive(): Promise {
try {
const stmt = db.prepare(
|