Close #2
This commit is contained in:
		@@ -1,11 +1,14 @@
 | 
			
		||||
import { Routes, Route, Link, useLocation } from 'react-router-dom'
 | 
			
		||||
import EpisodeList from './components/EpisodeList'
 | 
			
		||||
import FeedManager from './components/FeedManager'
 | 
			
		||||
import FeedList from './components/FeedList'
 | 
			
		||||
import FeedDetail from './components/FeedDetail'
 | 
			
		||||
import EpisodeDetail from './components/EpisodeDetail'
 | 
			
		||||
 | 
			
		||||
function App() {
 | 
			
		||||
  const location = useLocation()
 | 
			
		||||
  const isEpisodeDetail = location.pathname.startsWith('/episode/')
 | 
			
		||||
  const isFeedDetail = location.pathname.startsWith('/feeds/') && location.pathname.split('/').length === 3
 | 
			
		||||
 | 
			
		||||
  if (isEpisodeDetail) {
 | 
			
		||||
    return (
 | 
			
		||||
@@ -15,6 +18,14 @@ function App() {
 | 
			
		||||
    )
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (isFeedDetail) {
 | 
			
		||||
    return (
 | 
			
		||||
      <Routes>
 | 
			
		||||
        <Route path="/feeds/:feedId" element={<FeedDetail />} />
 | 
			
		||||
      </Routes>
 | 
			
		||||
    )
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="container">
 | 
			
		||||
      <div className="header">
 | 
			
		||||
@@ -32,6 +43,12 @@ function App() {
 | 
			
		||||
        <Link
 | 
			
		||||
          to="/feeds"
 | 
			
		||||
          className={`tab ${location.pathname === '/feeds' ? 'active' : ''}`}
 | 
			
		||||
        >
 | 
			
		||||
          フィード一覧
 | 
			
		||||
        </Link>
 | 
			
		||||
        <Link
 | 
			
		||||
          to="/feed-requests"
 | 
			
		||||
          className={`tab ${location.pathname === '/feed-requests' ? 'active' : ''}`}
 | 
			
		||||
        >
 | 
			
		||||
          フィードリクエスト
 | 
			
		||||
        </Link>
 | 
			
		||||
@@ -40,7 +57,8 @@ function App() {
 | 
			
		||||
      <div className="content">
 | 
			
		||||
        <Routes>
 | 
			
		||||
          <Route path="/" element={<EpisodeList />} />
 | 
			
		||||
          <Route path="/feeds" element={<FeedManager />} />
 | 
			
		||||
          <Route path="/feeds" element={<FeedList />} />
 | 
			
		||||
          <Route path="/feed-requests" element={<FeedManager />} />
 | 
			
		||||
        </Routes>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,41 +1,84 @@
 | 
			
		||||
import { useState, useEffect } from 'react'
 | 
			
		||||
import { useParams, Link } from 'react-router-dom'
 | 
			
		||||
 | 
			
		||||
interface Episode {
 | 
			
		||||
 | 
			
		||||
interface EpisodeWithFeedInfo {
 | 
			
		||||
  id: string
 | 
			
		||||
  title: string
 | 
			
		||||
  description: string
 | 
			
		||||
  pubDate: string
 | 
			
		||||
  audioUrl: string
 | 
			
		||||
  audioLength: string
 | 
			
		||||
  guid: string
 | 
			
		||||
  link: 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 EpisodeDetail() {
 | 
			
		||||
  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 [error, setError] = useState<string | null>(null)
 | 
			
		||||
  const [useDatabase, setUseDatabase] = useState(true)
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    fetchEpisode()
 | 
			
		||||
  }, [episodeId])
 | 
			
		||||
  }, [episodeId, useDatabase])
 | 
			
		||||
 | 
			
		||||
  const fetchEpisode = async () => {
 | 
			
		||||
    if (!episodeId) return
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      setLoading(true)
 | 
			
		||||
      const response = await fetch(`/api/episode/${episodeId}`)
 | 
			
		||||
      if (!response.ok) {
 | 
			
		||||
        const errorData = await response.json()
 | 
			
		||||
        throw new Error(errorData.error || 'エピソードの取得に失敗しました')
 | 
			
		||||
      
 | 
			
		||||
      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}`)
 | 
			
		||||
        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) {
 | 
			
		||||
      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 : 'エラーが発生しました')
 | 
			
		||||
    } finally {
 | 
			
		||||
      setLoading(false)
 | 
			
		||||
@@ -62,13 +105,12 @@ function EpisodeDetail() {
 | 
			
		||||
    return new Date(dateString).toLocaleString('ja-JP')
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const formatFileSize = (bytes: string) => {
 | 
			
		||||
    const size = parseInt(bytes)
 | 
			
		||||
    if (isNaN(size)) return ''
 | 
			
		||||
  const formatFileSize = (bytes?: number) => {
 | 
			
		||||
    if (!bytes) return ''
 | 
			
		||||
    
 | 
			
		||||
    const units = ['B', 'KB', 'MB', 'GB']
 | 
			
		||||
    let unitIndex = 0
 | 
			
		||||
    let fileSize = size
 | 
			
		||||
    let fileSize = bytes
 | 
			
		||||
 | 
			
		||||
    while (fileSize >= 1024 && unitIndex < units.length - 1) {
 | 
			
		||||
      fileSize /= 1024
 | 
			
		||||
@@ -118,7 +160,7 @@ function EpisodeDetail() {
 | 
			
		||||
          {episode.title}
 | 
			
		||||
        </div>
 | 
			
		||||
        <div className="subtitle" style={{ color: '#666', marginBottom: '20px' }}>
 | 
			
		||||
          公開日: {formatDate(episode.pubDate)}
 | 
			
		||||
          作成日: {formatDate(episode.createdAt)}
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
@@ -127,7 +169,7 @@ function EpisodeDetail() {
 | 
			
		||||
          <audio
 | 
			
		||||
            controls
 | 
			
		||||
            className="audio-player"
 | 
			
		||||
            src={episode.audioUrl}
 | 
			
		||||
            src={episode.audioPath}
 | 
			
		||||
            style={{ width: '100%', height: '60px' }}
 | 
			
		||||
          >
 | 
			
		||||
            お使いのブラウザは音声の再生に対応していません。
 | 
			
		||||
@@ -141,9 +183,9 @@ function EpisodeDetail() {
 | 
			
		||||
          >
 | 
			
		||||
            このエピソードを共有
 | 
			
		||||
          </button>
 | 
			
		||||
          {episode.link && (
 | 
			
		||||
          {episode.articleLink && (
 | 
			
		||||
            <a 
 | 
			
		||||
              href={episode.link} 
 | 
			
		||||
              href={episode.articleLink} 
 | 
			
		||||
              target="_blank" 
 | 
			
		||||
              rel="noopener noreferrer"
 | 
			
		||||
              className="btn btn-secondary"
 | 
			
		||||
@@ -151,6 +193,14 @@ function EpisodeDetail() {
 | 
			
		||||
              元記事を見る
 | 
			
		||||
            </a>
 | 
			
		||||
          )}
 | 
			
		||||
          {episode.feedId && (
 | 
			
		||||
            <Link 
 | 
			
		||||
              to={`/feeds/${episode.feedId}`}
 | 
			
		||||
              className="btn btn-secondary"
 | 
			
		||||
            >
 | 
			
		||||
              このフィードの他のエピソード
 | 
			
		||||
            </Link>
 | 
			
		||||
          )}
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <div style={{ marginBottom: '30px' }}>
 | 
			
		||||
@@ -161,17 +211,45 @@ function EpisodeDetail() {
 | 
			
		||||
            borderRadius: '8px',
 | 
			
		||||
            fontSize: '14px'
 | 
			
		||||
          }}>
 | 
			
		||||
            <div style={{ marginBottom: '10px' }}>
 | 
			
		||||
              <strong>ファイルサイズ:</strong> {formatFileSize(episode.audioLength)}
 | 
			
		||||
            </div>
 | 
			
		||||
            {episode.guid && (
 | 
			
		||||
            {episode.feedTitle && (
 | 
			
		||||
              <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>
 | 
			
		||||
              <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>
 | 
			
		||||
            </div>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,5 @@
 | 
			
		||||
import { useState, useEffect } from 'react'
 | 
			
		||||
import { Link } from 'react-router-dom'
 | 
			
		||||
 | 
			
		||||
interface Episode {
 | 
			
		||||
  id: string
 | 
			
		||||
@@ -11,29 +12,82 @@ interface Episode {
 | 
			
		||||
  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() {
 | 
			
		||||
  const [episodes, setEpisodes] = useState<Episode[]>([])
 | 
			
		||||
  const [episodes, setEpisodes] = useState<EpisodeWithFeedInfo[]>([])
 | 
			
		||||
  const [loading, setLoading] = useState(true)
 | 
			
		||||
  const [error, setError] = useState<string | null>(null)
 | 
			
		||||
  const [currentAudio, setCurrentAudio] = useState<string | null>(null)
 | 
			
		||||
  const [useDatabase, setUseDatabase] = useState(true)
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    fetchEpisodes()
 | 
			
		||||
  }, [])
 | 
			
		||||
  }, [useDatabase])
 | 
			
		||||
 | 
			
		||||
  const fetchEpisodes = async () => {
 | 
			
		||||
    try {
 | 
			
		||||
      setLoading(true)
 | 
			
		||||
      const response = await fetch('/api/episodes-from-xml')
 | 
			
		||||
      if (!response.ok) {
 | 
			
		||||
        const errorData = await response.json()
 | 
			
		||||
        throw new Error(errorData.error || 'エピソードの取得に失敗しました')
 | 
			
		||||
      
 | 
			
		||||
      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')
 | 
			
		||||
        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) {
 | 
			
		||||
      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 : 'エラーが発生しました')
 | 
			
		||||
    } finally {
 | 
			
		||||
      setLoading(false)
 | 
			
		||||
@@ -44,7 +98,7 @@ function EpisodeList() {
 | 
			
		||||
    return new Date(dateString).toLocaleString('ja-JP')
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const playAudio = (audioUrl: string) => {
 | 
			
		||||
  const playAudio = (audioPath: string) => {
 | 
			
		||||
    if (currentAudio) {
 | 
			
		||||
      const currentPlayer = document.getElementById(currentAudio) as HTMLAudioElement
 | 
			
		||||
      if (currentPlayer) {
 | 
			
		||||
@@ -52,10 +106,10 @@ function EpisodeList() {
 | 
			
		||||
        currentPlayer.currentTime = 0
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    setCurrentAudio(audioUrl)
 | 
			
		||||
    setCurrentAudio(audioPath)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const shareEpisode = (episode: Episode) => {
 | 
			
		||||
  const shareEpisode = (episode: EpisodeWithFeedInfo) => {
 | 
			
		||||
    const shareUrl = `${window.location.origin}/episode/${episode.id}`
 | 
			
		||||
    navigator.clipboard.writeText(shareUrl).then(() => {
 | 
			
		||||
      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) {
 | 
			
		||||
    return <div className="loading">読み込み中...</div>
 | 
			
		||||
  }
 | 
			
		||||
@@ -109,7 +178,7 @@ function EpisodeList() {
 | 
			
		||||
          <tr>
 | 
			
		||||
            <th style={{ width: '35%' }}>タイトル</th>
 | 
			
		||||
            <th style={{ width: '25%' }}>説明</th>
 | 
			
		||||
            <th style={{ width: '15%' }}>公開日</th>
 | 
			
		||||
            <th style={{ width: '15%' }}>作成日</th>
 | 
			
		||||
            <th style={{ width: '25%' }}>操作</th>
 | 
			
		||||
          </tr>
 | 
			
		||||
        </thead>
 | 
			
		||||
@@ -118,11 +187,28 @@ function EpisodeList() {
 | 
			
		||||
            <tr key={episode.id}>
 | 
			
		||||
              <td>
 | 
			
		||||
                <div style={{ marginBottom: '8px' }}>
 | 
			
		||||
                  <strong>{episode.title}</strong>
 | 
			
		||||
                  <strong>
 | 
			
		||||
                    <Link 
 | 
			
		||||
                      to={`/episode/${episode.id}`}
 | 
			
		||||
                      style={{ textDecoration: 'none', color: '#007bff' }}
 | 
			
		||||
                    >
 | 
			
		||||
                      {episode.title}
 | 
			
		||||
                    </Link>
 | 
			
		||||
                  </strong>
 | 
			
		||||
                </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 
 | 
			
		||||
                    href={episode.link} 
 | 
			
		||||
                    href={episode.articleLink} 
 | 
			
		||||
                    target="_blank" 
 | 
			
		||||
                    rel="noopener noreferrer"
 | 
			
		||||
                    style={{ fontSize: '12px', color: '#666' }}
 | 
			
		||||
@@ -141,14 +227,19 @@ function EpisodeList() {
 | 
			
		||||
                }}>
 | 
			
		||||
                  {episode.description || 'No description'}
 | 
			
		||||
                </div>
 | 
			
		||||
                {episode.fileSize && (
 | 
			
		||||
                  <div style={{ fontSize: '12px', color: '#666', marginTop: '4px' }}>
 | 
			
		||||
                    {formatFileSize(episode.fileSize)}
 | 
			
		||||
                  </div>
 | 
			
		||||
                )}
 | 
			
		||||
              </td>
 | 
			
		||||
              <td>{formatDate(episode.pubDate)}</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.audioUrl)}
 | 
			
		||||
                      onClick={() => playAudio(episode.audioPath)}
 | 
			
		||||
                    >
 | 
			
		||||
                      再生
 | 
			
		||||
                    </button>
 | 
			
		||||
@@ -159,13 +250,13 @@ function EpisodeList() {
 | 
			
		||||
                      共有
 | 
			
		||||
                    </button>
 | 
			
		||||
                  </div>
 | 
			
		||||
                  {currentAudio === episode.audioUrl && (
 | 
			
		||||
                  {currentAudio === episode.audioPath && (
 | 
			
		||||
                    <div>
 | 
			
		||||
                      <audio
 | 
			
		||||
                        id={episode.audioUrl}
 | 
			
		||||
                        id={episode.audioPath}
 | 
			
		||||
                        controls
 | 
			
		||||
                        className="audio-player"
 | 
			
		||||
                        src={episode.audioUrl}
 | 
			
		||||
                        src={episode.audioPath}
 | 
			
		||||
                        onEnded={() => setCurrentAudio(null)}
 | 
			
		||||
                      />
 | 
			
		||||
                    </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) => {
 | 
			
		||||
  try {
 | 
			
		||||
    const body = await c.req.json();
 | 
			
		||||
 
 | 
			
		||||
@@ -145,6 +145,24 @@ export interface LegacyEpisode {
 | 
			
		||||
  sourceLink: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Extended interfaces for frontend display
 | 
			
		||||
export interface EpisodeWithFeedInfo {
 | 
			
		||||
  id: string;
 | 
			
		||||
  title: string;
 | 
			
		||||
  description?: string;
 | 
			
		||||
  audioPath: string;
 | 
			
		||||
  duration?: number;
 | 
			
		||||
  fileSize?: number;
 | 
			
		||||
  createdAt: string;
 | 
			
		||||
  articleId: string;
 | 
			
		||||
  articleTitle: string;
 | 
			
		||||
  articleLink: string;
 | 
			
		||||
  articlePubDate: string;
 | 
			
		||||
  feedId: string;
 | 
			
		||||
  feedTitle?: string;
 | 
			
		||||
  feedUrl: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Feed management functions
 | 
			
		||||
export async function saveFeed(
 | 
			
		||||
  feed: Omit<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[]> {
 | 
			
		||||
  try {
 | 
			
		||||
    const stmt = db.prepare(
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user