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)
 | 
				
			||||||
      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>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -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>
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										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