This commit is contained in:
2025-06-07 19:30:37 +09:00
parent b413162033
commit 353843871a
7 changed files with 949 additions and 51 deletions

View File

@ -1,11 +1,14 @@
import { Routes, Route, Link, useLocation } from 'react-router-dom' import { Routes, Route, Link, useLocation } from 'react-router-dom'
import EpisodeList from './components/EpisodeList' import EpisodeList from './components/EpisodeList'
import FeedManager from './components/FeedManager' import FeedManager from './components/FeedManager'
import FeedList from './components/FeedList'
import FeedDetail from './components/FeedDetail'
import EpisodeDetail from './components/EpisodeDetail' import EpisodeDetail from './components/EpisodeDetail'
function App() { function App() {
const location = useLocation() const location = useLocation()
const isEpisodeDetail = location.pathname.startsWith('/episode/') const isEpisodeDetail = location.pathname.startsWith('/episode/')
const isFeedDetail = location.pathname.startsWith('/feeds/') && location.pathname.split('/').length === 3
if (isEpisodeDetail) { if (isEpisodeDetail) {
return ( return (
@ -15,6 +18,14 @@ function App() {
) )
} }
if (isFeedDetail) {
return (
<Routes>
<Route path="/feeds/:feedId" element={<FeedDetail />} />
</Routes>
)
}
return ( return (
<div className="container"> <div className="container">
<div className="header"> <div className="header">
@ -32,6 +43,12 @@ function App() {
<Link <Link
to="/feeds" to="/feeds"
className={`tab ${location.pathname === '/feeds' ? 'active' : ''}`} className={`tab ${location.pathname === '/feeds' ? 'active' : ''}`}
>
</Link>
<Link
to="/feed-requests"
className={`tab ${location.pathname === '/feed-requests' ? 'active' : ''}`}
> >
</Link> </Link>
@ -40,7 +57,8 @@ function App() {
<div className="content"> <div className="content">
<Routes> <Routes>
<Route path="/" element={<EpisodeList />} /> <Route path="/" element={<EpisodeList />} />
<Route path="/feeds" element={<FeedManager />} /> <Route path="/feeds" element={<FeedList />} />
<Route path="/feed-requests" element={<FeedManager />} />
</Routes> </Routes>
</div> </div>
</div> </div>

View File

@ -1,41 +1,84 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useParams, Link } from 'react-router-dom' import { useParams, Link } from 'react-router-dom'
interface Episode {
interface EpisodeWithFeedInfo {
id: string id: string
title: string title: string
description: string description?: string
pubDate: string audioPath: string
audioUrl: string duration?: number
audioLength: string fileSize?: number
guid: string createdAt: string
link: string articleId: string
articleTitle: string
articleLink: string
articlePubDate: string
feedId: string
feedTitle?: string
feedUrl: string
} }
function EpisodeDetail() { function EpisodeDetail() {
const { episodeId } = useParams<{ episodeId: string }>() const { episodeId } = useParams<{ episodeId: string }>()
const [episode, setEpisode] = useState<Episode | null>(null) const [episode, setEpisode] = useState<EpisodeWithFeedInfo | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [useDatabase, setUseDatabase] = useState(true)
useEffect(() => { useEffect(() => {
fetchEpisode() fetchEpisode()
}, [episodeId]) }, [episodeId, useDatabase])
const fetchEpisode = async () => { const fetchEpisode = async () => {
if (!episodeId) return if (!episodeId) return
try { try {
setLoading(true) setLoading(true)
const response = await fetch(`/api/episode/${episodeId}`)
if (!response.ok) { if (useDatabase) {
const errorData = await response.json() // Try to fetch from database with source info first
throw new Error(errorData.error || 'エピソードの取得に失敗しました') const response = await fetch(`/api/episode-with-source/${episodeId}`)
if (!response.ok) {
throw new Error('データベースからの取得に失敗しました')
}
const data = await response.json()
setEpisode(data.episode)
} else {
// Fallback to XML parsing (existing functionality)
const response = await fetch(`/api/episode/${episodeId}`)
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'エピソードの取得に失敗しました')
}
const data = await response.json()
const xmlEpisode = data.episode
// Convert XML episode to EpisodeWithFeedInfo format
const convertedEpisode: EpisodeWithFeedInfo = {
id: xmlEpisode.id,
title: xmlEpisode.title,
description: xmlEpisode.description,
audioPath: xmlEpisode.audioUrl,
createdAt: xmlEpisode.pubDate,
articleId: xmlEpisode.guid,
articleTitle: xmlEpisode.title,
articleLink: xmlEpisode.link,
articlePubDate: xmlEpisode.pubDate,
feedId: '',
feedTitle: 'RSS Feed',
feedUrl: ''
}
setEpisode(convertedEpisode)
} }
const data = await response.json()
setEpisode(data.episode)
} catch (err) { } catch (err) {
console.error('Episode fetch error:', 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 : 'エラーが発生しました') setError(err instanceof Error ? err.message : 'エラーが発生しました')
} finally { } finally {
setLoading(false) setLoading(false)
@ -62,13 +105,12 @@ function EpisodeDetail() {
return new Date(dateString).toLocaleString('ja-JP') return new Date(dateString).toLocaleString('ja-JP')
} }
const formatFileSize = (bytes: string) => { const formatFileSize = (bytes?: number) => {
const size = parseInt(bytes) if (!bytes) return ''
if (isNaN(size)) return ''
const units = ['B', 'KB', 'MB', 'GB'] const units = ['B', 'KB', 'MB', 'GB']
let unitIndex = 0 let unitIndex = 0
let fileSize = size let fileSize = bytes
while (fileSize >= 1024 && unitIndex < units.length - 1) { while (fileSize >= 1024 && unitIndex < units.length - 1) {
fileSize /= 1024 fileSize /= 1024
@ -118,7 +160,7 @@ function EpisodeDetail() {
{episode.title} {episode.title}
</div> </div>
<div className="subtitle" style={{ color: '#666', marginBottom: '20px' }}> <div className="subtitle" style={{ color: '#666', marginBottom: '20px' }}>
: {formatDate(episode.pubDate)} : {formatDate(episode.createdAt)}
</div> </div>
</div> </div>
@ -127,7 +169,7 @@ function EpisodeDetail() {
<audio <audio
controls controls
className="audio-player" className="audio-player"
src={episode.audioUrl} src={episode.audioPath}
style={{ width: '100%', height: '60px' }} style={{ width: '100%', height: '60px' }}
> >
使 使
@ -141,9 +183,9 @@ function EpisodeDetail() {
> >
</button> </button>
{episode.link && ( {episode.articleLink && (
<a <a
href={episode.link} href={episode.articleLink}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="btn btn-secondary" className="btn btn-secondary"
@ -151,6 +193,14 @@ function EpisodeDetail() {
</a> </a>
)} )}
{episode.feedId && (
<Link
to={`/feeds/${episode.feedId}`}
className="btn btn-secondary"
>
</Link>
)}
</div> </div>
<div style={{ marginBottom: '30px' }}> <div style={{ marginBottom: '30px' }}>
@ -161,17 +211,45 @@ function EpisodeDetail() {
borderRadius: '8px', borderRadius: '8px',
fontSize: '14px' fontSize: '14px'
}}> }}>
<div style={{ marginBottom: '10px' }}> {episode.feedTitle && (
<strong>:</strong> {formatFileSize(episode.audioLength)}
</div>
{episode.guid && (
<div style={{ marginBottom: '10px' }}> <div style={{ marginBottom: '10px' }}>
<strong>ID:</strong> {episode.guid} <strong>:</strong>
<Link to={`/feeds/${episode.feedId}`} style={{ marginLeft: '5px', color: '#007bff' }}>
{episode.feedTitle}
</Link>
</div>
)}
{episode.feedUrl && (
<div style={{ marginBottom: '10px' }}>
<strong>URL:</strong>
<a href={episode.feedUrl} target="_blank" rel="noopener noreferrer" style={{ marginLeft: '5px' }}>
{episode.feedUrl}
</a>
</div>
)}
{episode.articleTitle && episode.articleTitle !== episode.title && (
<div style={{ marginBottom: '10px' }}>
<strong>:</strong> {episode.articleTitle}
</div>
)}
{episode.articlePubDate && (
<div style={{ marginBottom: '10px' }}>
<strong>:</strong> {formatDate(episode.articlePubDate)}
</div>
)}
{episode.fileSize && (
<div style={{ marginBottom: '10px' }}>
<strong>:</strong> {formatFileSize(episode.fileSize)}
</div>
)}
{episode.duration && (
<div style={{ marginBottom: '10px' }}>
<strong>:</strong> {Math.floor(episode.duration / 60)}{episode.duration % 60}
</div> </div>
)} )}
<div> <div>
<strong>URL:</strong> <strong>URL:</strong>
<a href={episode.audioUrl} target="_blank" rel="noopener noreferrer" style={{ marginLeft: '5px' }}> <a href={episode.audioPath} target="_blank" rel="noopener noreferrer" style={{ marginLeft: '5px' }}>
</a> </a>
</div> </div>

View File

@ -1,4 +1,5 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
interface Episode { interface Episode {
id: string id: string
@ -11,29 +12,82 @@ interface Episode {
link: 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
}
function EpisodeList() { function EpisodeList() {
const [episodes, setEpisodes] = useState<Episode[]>([]) const [episodes, setEpisodes] = useState<EpisodeWithFeedInfo[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [currentAudio, setCurrentAudio] = useState<string | null>(null) const [currentAudio, setCurrentAudio] = useState<string | null>(null)
const [useDatabase, setUseDatabase] = useState(true)
useEffect(() => { useEffect(() => {
fetchEpisodes() fetchEpisodes()
}, []) }, [useDatabase])
const fetchEpisodes = async () => { const fetchEpisodes = async () => {
try { try {
setLoading(true) setLoading(true)
const response = await fetch('/api/episodes-from-xml')
if (!response.ok) { if (useDatabase) {
const errorData = await response.json() // Try to fetch from database first
throw new Error(errorData.error || 'エピソードの取得に失敗しました') const response = await fetch('/api/episodes-with-feed-info')
if (!response.ok) {
throw new Error('データベースからの取得に失敗しました')
}
const data = await response.json()
setEpisodes(data.episodes || [])
} else {
// Fallback to XML parsing (existing functionality)
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)
} }
const data = await response.json()
console.log('Fetched episodes from XML:', data)
setEpisodes(data.episodes || [])
} catch (err) { } catch (err) {
console.error('Episode fetch error:', 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 : 'エラーが発生しました') setError(err instanceof Error ? err.message : 'エラーが発生しました')
} finally { } finally {
setLoading(false) setLoading(false)
@ -44,7 +98,7 @@ function EpisodeList() {
return new Date(dateString).toLocaleString('ja-JP') return new Date(dateString).toLocaleString('ja-JP')
} }
const playAudio = (audioUrl: string) => { const playAudio = (audioPath: string) => {
if (currentAudio) { if (currentAudio) {
const currentPlayer = document.getElementById(currentAudio) as HTMLAudioElement const currentPlayer = document.getElementById(currentAudio) as HTMLAudioElement
if (currentPlayer) { if (currentPlayer) {
@ -52,10 +106,10 @@ function EpisodeList() {
currentPlayer.currentTime = 0 currentPlayer.currentTime = 0
} }
} }
setCurrentAudio(audioUrl) setCurrentAudio(audioPath)
} }
const shareEpisode = (episode: Episode) => { const shareEpisode = (episode: EpisodeWithFeedInfo) => {
const shareUrl = `${window.location.origin}/episode/${episode.id}` const shareUrl = `${window.location.origin}/episode/${episode.id}`
navigator.clipboard.writeText(shareUrl).then(() => { navigator.clipboard.writeText(shareUrl).then(() => {
alert('エピソードリンクをクリップボードにコピーしました') alert('エピソードリンクをクリップボードにコピーしました')
@ -71,6 +125,21 @@ function EpisodeList() {
}) })
} }
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) { if (loading) {
return <div className="loading">...</div> return <div className="loading">...</div>
} }
@ -109,7 +178,7 @@ function EpisodeList() {
<tr> <tr>
<th style={{ width: '35%' }}></th> <th style={{ width: '35%' }}></th>
<th style={{ width: '25%' }}></th> <th style={{ width: '25%' }}></th>
<th style={{ width: '15%' }}></th> <th style={{ width: '15%' }}></th>
<th style={{ width: '25%' }}></th> <th style={{ width: '25%' }}></th>
</tr> </tr>
</thead> </thead>
@ -118,11 +187,28 @@ function EpisodeList() {
<tr key={episode.id}> <tr key={episode.id}>
<td> <td>
<div style={{ marginBottom: '8px' }}> <div style={{ marginBottom: '8px' }}>
<strong>{episode.title}</strong> <strong>
<Link
to={`/episode/${episode.id}`}
style={{ textDecoration: 'none', color: '#007bff' }}
>
{episode.title}
</Link>
</strong>
</div> </div>
{episode.link && ( {episode.feedTitle && (
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>
: <Link to={`/feeds/${episode.feedId}`} style={{ color: '#007bff' }}>{episode.feedTitle}</Link>
</div>
)}
{episode.articleTitle && episode.articleTitle !== episode.title && (
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>
: <strong>{episode.articleTitle}</strong>
</div>
)}
{episode.articleLink && (
<a <a
href={episode.link} href={episode.articleLink}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
style={{ fontSize: '12px', color: '#666' }} style={{ fontSize: '12px', color: '#666' }}
@ -141,14 +227,19 @@ function EpisodeList() {
}}> }}>
{episode.description || 'No description'} {episode.description || 'No description'}
</div> </div>
{episode.fileSize && (
<div style={{ fontSize: '12px', color: '#666', marginTop: '4px' }}>
{formatFileSize(episode.fileSize)}
</div>
)}
</td> </td>
<td>{formatDate(episode.pubDate)}</td> <td>{formatDate(episode.createdAt)}</td>
<td> <td>
<div style={{ display: 'flex', gap: '8px', flexDirection: 'column' }}> <div style={{ display: 'flex', gap: '8px', flexDirection: 'column' }}>
<div style={{ display: 'flex', gap: '8px' }}> <div style={{ display: 'flex', gap: '8px' }}>
<button <button
className="btn btn-primary" className="btn btn-primary"
onClick={() => playAudio(episode.audioUrl)} onClick={() => playAudio(episode.audioPath)}
> >
</button> </button>
@ -159,13 +250,13 @@ function EpisodeList() {
</button> </button>
</div> </div>
{currentAudio === episode.audioUrl && ( {currentAudio === episode.audioPath && (
<div> <div>
<audio <audio
id={episode.audioUrl} id={episode.audioPath}
controls controls
className="audio-player" className="audio-player"
src={episode.audioUrl} src={episode.audioPath}
onEnded={() => setCurrentAudio(null)} onEnded={() => setCurrentAudio(null)}
/> />
</div> </div>

View File

@ -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<Feed | null>(null)
const [episodes, setEpisodes] = useState<EpisodeWithFeedInfo[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [currentAudio, setCurrentAudio] = useState<string | null>(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 <div className="loading">...</div>
}
if (error) {
return (
<div className="error">
{error}
<Link to="/feeds" className="btn btn-secondary" style={{ marginTop: '20px', display: 'block' }}>
</Link>
</div>
)
}
if (!feed) {
return (
<div className="error">
<Link to="/feeds" className="btn btn-secondary" style={{ marginTop: '20px', display: 'block' }}>
</Link>
</div>
)
}
return (
<div>
<div style={{ marginBottom: '20px' }}>
<Link to="/feeds" className="btn btn-secondary">
</Link>
</div>
<div className="feed-header" style={{ marginBottom: '30px' }}>
<h1 style={{ marginBottom: '10px' }}>
{feed.title || feed.url}
</h1>
<div style={{ color: '#666', marginBottom: '10px' }}>
<a href={feed.url} target="_blank" rel="noopener noreferrer">
{feed.url}
</a>
</div>
{feed.description && (
<div style={{ marginBottom: '15px', color: '#333' }}>
{feed.description}
</div>
)}
<div style={{ fontSize: '14px', color: '#666' }}>
: {formatDate(feed.createdAt)}
{feed.lastUpdated && ` | 最終更新: ${formatDate(feed.lastUpdated)}`}
</div>
</div>
<div style={{ marginBottom: '20px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2> ({episodes.length})</h2>
<button className="btn btn-secondary" onClick={fetchFeedAndEpisodes}>
</button>
</div>
{episodes.length === 0 ? (
<div className="empty-state">
<p></p>
<p></p>
</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>
{episodes.map((episode) => (
<tr key={episode.id}>
<td>
<div style={{ marginBottom: '8px' }}>
<strong>
<Link
to={`/episode/${episode.id}`}
style={{ textDecoration: 'none', color: '#007bff' }}
>
{episode.title}
</Link>
</strong>
</div>
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>
: <strong>{episode.articleTitle}</strong>
</div>
{episode.articleLink && (
<a
href={episode.articleLink}
target="_blank"
rel="noopener noreferrer"
style={{ fontSize: '12px', color: '#666' }}
>
</a>
)}
</td>
<td>
<div style={{
fontSize: '14px',
maxWidth: '200px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}>
{episode.description || 'No description'}
</div>
{episode.fileSize && (
<div style={{ fontSize: '12px', color: '#666', marginTop: '4px' }}>
{formatFileSize(episode.fileSize)}
</div>
)}
</td>
<td>{formatDate(episode.createdAt)}</td>
<td>
<div style={{ display: 'flex', gap: '8px', flexDirection: 'column' }}>
<div style={{ display: 'flex', gap: '8px' }}>
<button
className="btn btn-primary"
onClick={() => playAudio(episode.audioPath)}
>
</button>
<button
className="btn btn-secondary"
onClick={() => shareEpisode(episode)}
>
</button>
</div>
{currentAudio === episode.audioPath && (
<div>
<audio
id={episode.audioPath}
controls
className="audio-player"
src={episode.audioPath}
onEnded={() => setCurrentAudio(null)}
/>
</div>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)
}
export default FeedDetail

View File

@ -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<Feed[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(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 <div className="loading">...</div>
}
if (error) {
return <div className="error">{error}</div>
}
if (feeds.length === 0) {
return (
<div className="empty-state">
<p></p>
<p>RSSフィードをリクエストするか</p>
<button
className="btn btn-secondary"
onClick={fetchFeeds}
style={{ marginTop: '10px' }}
>
</button>
</div>
)
}
return (
<div>
<div style={{ marginBottom: '20px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2> ({feeds.length})</h2>
<button className="btn btn-secondary" onClick={fetchFeeds}>
</button>
</div>
<div className="feed-grid">
{feeds.map((feed) => (
<div key={feed.id} className="feed-card">
<div className="feed-card-header">
<h3 className="feed-title">
<Link to={`/feeds/${feed.id}`} className="feed-link">
{feed.title || feed.url}
</Link>
</h3>
<div className="feed-url">
<a href={feed.url} target="_blank" rel="noopener noreferrer">
{feed.url}
</a>
</div>
</div>
{feed.description && (
<div className="feed-description">
{feed.description}
</div>
)}
<div className="feed-meta">
<div>: {formatDate(feed.createdAt)}</div>
{feed.lastUpdated && (
<div>: {formatDate(feed.lastUpdated)}</div>
)}
</div>
<div className="feed-actions">
<Link to={`/feeds/${feed.id}`} className="btn btn-primary">
</Link>
</div>
</div>
))}
</div>
<style>{`
.feed-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
}
.feed-card {
border: 1px solid #e9ecef;
border-radius: 8px;
padding: 20px;
background: white;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.feed-card-header {
margin-bottom: 15px;
}
.feed-title {
margin: 0 0 8px 0;
font-size: 18px;
}
.feed-link {
text-decoration: none;
color: #007bff;
}
.feed-link:hover {
text-decoration: underline;
}
.feed-url {
font-size: 12px;
color: #666;
word-break: break-all;
}
.feed-url a {
color: #666;
text-decoration: none;
}
.feed-url a:hover {
color: #007bff;
}
.feed-description {
margin-bottom: 15px;
color: #333;
line-height: 1.5;
}
.feed-meta {
margin-bottom: 15px;
font-size: 12px;
color: #666;
}
.feed-meta div {
margin-bottom: 4px;
}
.feed-actions {
text-align: right;
}
`}</style>
</div>
)
}
export default FeedList

View File

@ -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) => { app.post("/api/feed-requests", async (c) => {
try { try {
const body = await c.req.json(); const body = await c.req.json();

View File

@ -145,6 +145,24 @@ export interface LegacyEpisode {
sourceLink: string; 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 // Feed management functions
export async function saveFeed( export async function saveFeed(
feed: Omit<Feed, "id" | "createdAt">, feed: Omit<Feed, "id" | "createdAt">,
@ -236,6 +254,161 @@ export async function getAllFeeds(): Promise<Feed[]> {
} }
} }
// Get active feeds for user display
export async function fetchActiveFeeds(): Promise<Feed[]> {
return getAllFeeds();
}
// Get episodes with feed information for enhanced display
export async function fetchEpisodesWithFeedInfo(): Promise<EpisodeWithFeedInfo[]> {
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<EpisodeWithFeedInfo[]> {
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<EpisodeWithFeedInfo | null> {
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<Feed[]> { export async function getAllFeedsIncludingInactive(): Promise<Feed[]> {
try { try {
const stmt = db.prepare( const stmt = db.prepare(