Close #2
This commit is contained in:
@ -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>
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
if (useDatabase) {
|
||||||
|
// Try to fetch from database with source info first
|
||||||
|
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}`)
|
const response = await fetch(`/api/episode/${episodeId}`)
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = await response.json()
|
const errorData = await response.json()
|
||||||
throw new Error(errorData.error || 'エピソードの取得に失敗しました')
|
throw new Error(errorData.error || 'エピソードの取得に失敗しました')
|
||||||
}
|
}
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
setEpisode(data.episode)
|
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)
|
||||||
|
}
|
||||||
} 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'
|
||||||
}}>
|
}}>
|
||||||
|
{episode.feedTitle && (
|
||||||
<div style={{ marginBottom: '10px' }}>
|
<div style={{ marginBottom: '10px' }}>
|
||||||
<strong>ファイルサイズ:</strong> {formatFileSize(episode.audioLength)}
|
<strong>ソースフィード:</strong>
|
||||||
|
<Link to={`/feeds/${episode.feedId}`} style={{ marginLeft: '5px', color: '#007bff' }}>
|
||||||
|
{episode.feedTitle}
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
{episode.guid && (
|
)}
|
||||||
|
{episode.feedUrl && (
|
||||||
<div style={{ marginBottom: '10px' }}>
|
<div style={{ marginBottom: '10px' }}>
|
||||||
<strong>エピソードID:</strong> {episode.guid}
|
<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>
|
||||||
|
@ -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,19 +12,48 @@ 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)
|
||||||
|
|
||||||
|
if (useDatabase) {
|
||||||
|
// Try to fetch from database first
|
||||||
|
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')
|
const response = await fetch('/api/episodes-from-xml')
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = await response.json()
|
const errorData = await response.json()
|
||||||
@ -31,9 +61,33 @@ function EpisodeList() {
|
|||||||
}
|
}
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
console.log('Fetched episodes from XML:', data)
|
console.log('Fetched episodes from XML:', data)
|
||||||
setEpisodes(data.episodes || [])
|
|
||||||
|
// 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) {
|
} 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>
|
||||||
|
282
frontend/src/components/FeedDetail.tsx
Normal file
282
frontend/src/components/FeedDetail.tsx
Normal 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
|
188
frontend/src/components/FeedList.tsx
Normal file
188
frontend/src/components/FeedList.tsx
Normal 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
|
68
server.ts
68
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) => {
|
app.post("/api/feed-requests", async (c) => {
|
||||||
try {
|
try {
|
||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
|
@ -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(
|
||||||
|
Reference in New Issue
Block a user