Fix episode player and episode list
This commit is contained in:
		@@ -1,9 +1,19 @@
 | 
			
		||||
import { useState } from 'react'
 | 
			
		||||
import { Routes, Route, Link, useLocation } from 'react-router-dom'
 | 
			
		||||
import EpisodeList from './components/EpisodeList'
 | 
			
		||||
import FeedManager from './components/FeedManager'
 | 
			
		||||
import EpisodeDetail from './components/EpisodeDetail'
 | 
			
		||||
 | 
			
		||||
function App() {
 | 
			
		||||
  const [activeTab, setActiveTab] = useState<'episodes' | 'feeds'>('episodes')
 | 
			
		||||
  const location = useLocation()
 | 
			
		||||
  const isEpisodeDetail = location.pathname.startsWith('/episode/')
 | 
			
		||||
 | 
			
		||||
  if (isEpisodeDetail) {
 | 
			
		||||
    return (
 | 
			
		||||
      <Routes>
 | 
			
		||||
        <Route path="/episode/:episodeId" element={<EpisodeDetail />} />
 | 
			
		||||
      </Routes>
 | 
			
		||||
    )
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="container">
 | 
			
		||||
@@ -13,23 +23,25 @@ function App() {
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <div className="tabs">
 | 
			
		||||
        <button
 | 
			
		||||
          className={`tab ${activeTab === 'episodes' ? 'active' : ''}`}
 | 
			
		||||
          onClick={() => setActiveTab('episodes')}
 | 
			
		||||
        <Link
 | 
			
		||||
          to="/"
 | 
			
		||||
          className={`tab ${location.pathname === '/' ? 'active' : ''}`}
 | 
			
		||||
        >
 | 
			
		||||
          エピソード一覧
 | 
			
		||||
        </button>
 | 
			
		||||
        <button
 | 
			
		||||
          className={`tab ${activeTab === 'feeds' ? 'active' : ''}`}
 | 
			
		||||
          onClick={() => setActiveTab('feeds')}
 | 
			
		||||
        </Link>
 | 
			
		||||
        <Link
 | 
			
		||||
          to="/feeds"
 | 
			
		||||
          className={`tab ${location.pathname === '/feeds' ? 'active' : ''}`}
 | 
			
		||||
        >
 | 
			
		||||
          フィードリクエスト
 | 
			
		||||
        </button>
 | 
			
		||||
        </Link>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <div className="content">
 | 
			
		||||
        {activeTab === 'episodes' && <EpisodeList />}
 | 
			
		||||
        {activeTab === 'feeds' && <FeedManager />}
 | 
			
		||||
        <Routes>
 | 
			
		||||
          <Route path="/" element={<EpisodeList />} />
 | 
			
		||||
          <Route path="/feeds" element={<FeedManager />} />
 | 
			
		||||
        </Routes>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										200
									
								
								frontend/src/components/EpisodeDetail.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										200
									
								
								frontend/src/components/EpisodeDetail.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,200 @@
 | 
			
		||||
import { useState, useEffect } from 'react'
 | 
			
		||||
import { useParams, Link } from 'react-router-dom'
 | 
			
		||||
 | 
			
		||||
interface Episode {
 | 
			
		||||
  id: string
 | 
			
		||||
  title: string
 | 
			
		||||
  description: string
 | 
			
		||||
  pubDate: string
 | 
			
		||||
  audioUrl: string
 | 
			
		||||
  audioLength: string
 | 
			
		||||
  guid: string
 | 
			
		||||
  link: string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function EpisodeDetail() {
 | 
			
		||||
  const { episodeId } = useParams<{ episodeId: string }>()
 | 
			
		||||
  const [episode, setEpisode] = useState<Episode | null>(null)
 | 
			
		||||
  const [loading, setLoading] = useState(true)
 | 
			
		||||
  const [error, setError] = useState<string | null>(null)
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    fetchEpisode()
 | 
			
		||||
  }, [episodeId])
 | 
			
		||||
 | 
			
		||||
  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 || 'エピソードの取得に失敗しました')
 | 
			
		||||
      }
 | 
			
		||||
      const data = await response.json()
 | 
			
		||||
      setEpisode(data.episode)
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      console.error('Episode fetch error:', err)
 | 
			
		||||
      setError(err instanceof Error ? err.message : 'エラーが発生しました')
 | 
			
		||||
    } finally {
 | 
			
		||||
      setLoading(false)
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const shareEpisode = () => {
 | 
			
		||||
    const shareUrl = window.location.href
 | 
			
		||||
    navigator.clipboard.writeText(shareUrl).then(() => {
 | 
			
		||||
      alert('エピソードリンクをクリップボードにコピーしました')
 | 
			
		||||
    }).catch(() => {
 | 
			
		||||
      // Fallback for older browsers
 | 
			
		||||
      const textArea = document.createElement('textarea')
 | 
			
		||||
      textArea.value = shareUrl
 | 
			
		||||
      document.body.appendChild(textArea)
 | 
			
		||||
      textArea.select()
 | 
			
		||||
      document.execCommand('copy')
 | 
			
		||||
      document.body.removeChild(textArea)
 | 
			
		||||
      alert('エピソードリンクをクリップボードにコピーしました')
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const formatDate = (dateString: string) => {
 | 
			
		||||
    return new Date(dateString).toLocaleString('ja-JP')
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const formatFileSize = (bytes: string) => {
 | 
			
		||||
    const size = parseInt(bytes)
 | 
			
		||||
    if (isNaN(size)) return ''
 | 
			
		||||
    
 | 
			
		||||
    const units = ['B', 'KB', 'MB', 'GB']
 | 
			
		||||
    let unitIndex = 0
 | 
			
		||||
    let fileSize = size
 | 
			
		||||
 | 
			
		||||
    while (fileSize >= 1024 && unitIndex < units.length - 1) {
 | 
			
		||||
      fileSize /= 1024
 | 
			
		||||
      unitIndex++
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return `${fileSize.toFixed(1)} ${units[unitIndex]}`
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (loading) {
 | 
			
		||||
    return (
 | 
			
		||||
      <div className="container">
 | 
			
		||||
        <div className="loading">読み込み中...</div>
 | 
			
		||||
      </div>
 | 
			
		||||
    )
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (error) {
 | 
			
		||||
    return (
 | 
			
		||||
      <div className="container">
 | 
			
		||||
        <div className="error">{error}</div>
 | 
			
		||||
        <Link to="/" className="btn btn-secondary" style={{ marginTop: '20px' }}>
 | 
			
		||||
          エピソード一覧に戻る
 | 
			
		||||
        </Link>
 | 
			
		||||
      </div>
 | 
			
		||||
    )
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (!episode) {
 | 
			
		||||
    return (
 | 
			
		||||
      <div className="container">
 | 
			
		||||
        <div className="error">エピソードが見つかりません</div>
 | 
			
		||||
        <Link to="/" className="btn btn-secondary" style={{ marginTop: '20px' }}>
 | 
			
		||||
          エピソード一覧に戻る
 | 
			
		||||
        </Link>
 | 
			
		||||
      </div>
 | 
			
		||||
    )
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="container">
 | 
			
		||||
      <div className="header">
 | 
			
		||||
        <Link to="/" className="btn btn-secondary" style={{ marginBottom: '20px' }}>
 | 
			
		||||
          ← エピソード一覧に戻る
 | 
			
		||||
        </Link>
 | 
			
		||||
        <div className="title" style={{ fontSize: '28px', marginBottom: '10px' }}>
 | 
			
		||||
          {episode.title}
 | 
			
		||||
        </div>
 | 
			
		||||
        <div className="subtitle" style={{ color: '#666', marginBottom: '20px' }}>
 | 
			
		||||
          公開日: {formatDate(episode.pubDate)}
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <div className="content">
 | 
			
		||||
        <div style={{ marginBottom: '30px' }}>
 | 
			
		||||
          <audio
 | 
			
		||||
            controls
 | 
			
		||||
            className="audio-player"
 | 
			
		||||
            src={episode.audioUrl}
 | 
			
		||||
            style={{ width: '100%', height: '60px' }}
 | 
			
		||||
          >
 | 
			
		||||
            お使いのブラウザは音声の再生に対応していません。
 | 
			
		||||
          </audio>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <div style={{ display: 'flex', gap: '15px', marginBottom: '30px' }}>
 | 
			
		||||
          <button 
 | 
			
		||||
            className="btn btn-primary"
 | 
			
		||||
            onClick={shareEpisode}
 | 
			
		||||
          >
 | 
			
		||||
            このエピソードを共有
 | 
			
		||||
          </button>
 | 
			
		||||
          {episode.link && (
 | 
			
		||||
            <a 
 | 
			
		||||
              href={episode.link} 
 | 
			
		||||
              target="_blank" 
 | 
			
		||||
              rel="noopener noreferrer"
 | 
			
		||||
              className="btn btn-secondary"
 | 
			
		||||
            >
 | 
			
		||||
              元記事を見る
 | 
			
		||||
            </a>
 | 
			
		||||
          )}
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <div style={{ marginBottom: '30px' }}>
 | 
			
		||||
          <h3 style={{ marginBottom: '15px' }}>エピソード情報</h3>
 | 
			
		||||
          <div style={{ 
 | 
			
		||||
            backgroundColor: '#f8f9fa', 
 | 
			
		||||
            padding: '20px', 
 | 
			
		||||
            borderRadius: '8px',
 | 
			
		||||
            fontSize: '14px'
 | 
			
		||||
          }}>
 | 
			
		||||
            <div style={{ marginBottom: '10px' }}>
 | 
			
		||||
              <strong>ファイルサイズ:</strong> {formatFileSize(episode.audioLength)}
 | 
			
		||||
            </div>
 | 
			
		||||
            {episode.guid && (
 | 
			
		||||
              <div style={{ marginBottom: '10px' }}>
 | 
			
		||||
                <strong>エピソードID:</strong> {episode.guid}
 | 
			
		||||
              </div>
 | 
			
		||||
            )}
 | 
			
		||||
            <div>
 | 
			
		||||
              <strong>音声URL:</strong> 
 | 
			
		||||
              <a href={episode.audioUrl} target="_blank" rel="noopener noreferrer" style={{ marginLeft: '5px' }}>
 | 
			
		||||
                直接ダウンロード
 | 
			
		||||
              </a>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        {episode.description && (
 | 
			
		||||
          <div>
 | 
			
		||||
            <h3 style={{ marginBottom: '15px' }}>エピソード詳細</h3>
 | 
			
		||||
            <div style={{ 
 | 
			
		||||
              backgroundColor: '#fff', 
 | 
			
		||||
              padding: '20px', 
 | 
			
		||||
              border: '1px solid #e9ecef',
 | 
			
		||||
              borderRadius: '8px',
 | 
			
		||||
              lineHeight: '1.6'
 | 
			
		||||
            }}>
 | 
			
		||||
              {episode.description}
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        )}
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default EpisodeDetail
 | 
			
		||||
@@ -3,14 +3,12 @@ import { useState, useEffect } from 'react'
 | 
			
		||||
interface Episode {
 | 
			
		||||
  id: string
 | 
			
		||||
  title: string
 | 
			
		||||
  audioPath: string
 | 
			
		||||
  createdAt: string
 | 
			
		||||
  article?: {
 | 
			
		||||
    link: string
 | 
			
		||||
  }
 | 
			
		||||
  feed?: {
 | 
			
		||||
    title: string
 | 
			
		||||
  }
 | 
			
		||||
  description: string
 | 
			
		||||
  pubDate: string
 | 
			
		||||
  audioUrl: string
 | 
			
		||||
  audioLength: string
 | 
			
		||||
  guid: string
 | 
			
		||||
  link: string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function EpisodeList() {
 | 
			
		||||
@@ -26,14 +24,14 @@ function EpisodeList() {
 | 
			
		||||
  const fetchEpisodes = async () => {
 | 
			
		||||
    try {
 | 
			
		||||
      setLoading(true)
 | 
			
		||||
      const response = await fetch('/api/episodes')
 | 
			
		||||
      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:', data)
 | 
			
		||||
      setEpisodes(data)
 | 
			
		||||
      console.log('Fetched episodes from XML:', data)
 | 
			
		||||
      setEpisodes(data.episodes || [])
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      console.error('Episode fetch error:', err)
 | 
			
		||||
      setError(err instanceof Error ? err.message : 'エラーが発生しました')
 | 
			
		||||
@@ -46,7 +44,7 @@ function EpisodeList() {
 | 
			
		||||
    return new Date(dateString).toLocaleString('ja-JP')
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const playAudio = (audioPath: string) => {
 | 
			
		||||
  const playAudio = (audioUrl: string) => {
 | 
			
		||||
    if (currentAudio) {
 | 
			
		||||
      const currentPlayer = document.getElementById(currentAudio) as HTMLAudioElement
 | 
			
		||||
      if (currentPlayer) {
 | 
			
		||||
@@ -54,7 +52,23 @@ function EpisodeList() {
 | 
			
		||||
        currentPlayer.currentTime = 0
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    setCurrentAudio(audioPath)
 | 
			
		||||
    setCurrentAudio(audioUrl)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const shareEpisode = (episode: Episode) => {
 | 
			
		||||
    const shareUrl = `${window.location.origin}/episode/${episode.id}`
 | 
			
		||||
    navigator.clipboard.writeText(shareUrl).then(() => {
 | 
			
		||||
      alert('エピソードリンクをクリップボードにコピーしました')
 | 
			
		||||
    }).catch(() => {
 | 
			
		||||
      // Fallback for older browsers
 | 
			
		||||
      const textArea = document.createElement('textarea')
 | 
			
		||||
      textArea.value = shareUrl
 | 
			
		||||
      document.body.appendChild(textArea)
 | 
			
		||||
      textArea.select()
 | 
			
		||||
      document.execCommand('copy')
 | 
			
		||||
      document.body.removeChild(textArea)
 | 
			
		||||
      alert('エピソードリンクをクリップボードにコピーしました')
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (loading) {
 | 
			
		||||
@@ -93,10 +107,10 @@ function EpisodeList() {
 | 
			
		||||
      <table className="table">
 | 
			
		||||
        <thead>
 | 
			
		||||
          <tr>
 | 
			
		||||
            <th style={{ width: '40%' }}>タイトル</th>
 | 
			
		||||
            <th style={{ width: '20%' }}>フィード</th>
 | 
			
		||||
            <th style={{ width: '20%' }}>作成日時</th>
 | 
			
		||||
            <th style={{ width: '20%' }}>操作</th>
 | 
			
		||||
            <th style={{ width: '35%' }}>タイトル</th>
 | 
			
		||||
            <th style={{ width: '25%' }}>説明</th>
 | 
			
		||||
            <th style={{ width: '15%' }}>公開日</th>
 | 
			
		||||
            <th style={{ width: '25%' }}>操作</th>
 | 
			
		||||
          </tr>
 | 
			
		||||
        </thead>
 | 
			
		||||
        <tbody>
 | 
			
		||||
@@ -106,9 +120,9 @@ function EpisodeList() {
 | 
			
		||||
                <div style={{ marginBottom: '8px' }}>
 | 
			
		||||
                  <strong>{episode.title}</strong>
 | 
			
		||||
                </div>
 | 
			
		||||
                {episode.article?.link && (
 | 
			
		||||
                {episode.link && (
 | 
			
		||||
                  <a 
 | 
			
		||||
                    href={episode.article.link} 
 | 
			
		||||
                    href={episode.link} 
 | 
			
		||||
                    target="_blank" 
 | 
			
		||||
                    rel="noopener noreferrer"
 | 
			
		||||
                    style={{ fontSize: '12px', color: '#666' }}
 | 
			
		||||
@@ -117,27 +131,46 @@ function EpisodeList() {
 | 
			
		||||
                  </a>
 | 
			
		||||
                )}
 | 
			
		||||
              </td>
 | 
			
		||||
              <td>{episode.feed?.title || '不明'}</td>
 | 
			
		||||
              <td>{formatDate(episode.createdAt)}</td>
 | 
			
		||||
              <td>
 | 
			
		||||
                <button 
 | 
			
		||||
                  className="btn btn-primary"
 | 
			
		||||
                  onClick={() => playAudio(episode.audioPath)}
 | 
			
		||||
                  style={{ marginBottom: '8px' }}
 | 
			
		||||
                >
 | 
			
		||||
                  再生
 | 
			
		||||
                </button>
 | 
			
		||||
                {currentAudio === episode.audioPath && (
 | 
			
		||||
                  <div>
 | 
			
		||||
                    <audio
 | 
			
		||||
                      id={episode.audioPath}
 | 
			
		||||
                      controls
 | 
			
		||||
                      className="audio-player"
 | 
			
		||||
                      src={`/podcast_audio/${episode.audioPath}`}
 | 
			
		||||
                      onEnded={() => setCurrentAudio(null)}
 | 
			
		||||
                    />
 | 
			
		||||
                <div style={{ 
 | 
			
		||||
                  fontSize: '14px', 
 | 
			
		||||
                  maxWidth: '200px', 
 | 
			
		||||
                  overflow: 'hidden', 
 | 
			
		||||
                  textOverflow: 'ellipsis',
 | 
			
		||||
                  whiteSpace: 'nowrap'
 | 
			
		||||
                }}>
 | 
			
		||||
                  {episode.description || 'No description'}
 | 
			
		||||
                </div>
 | 
			
		||||
              </td>
 | 
			
		||||
              <td>{formatDate(episode.pubDate)}</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)}
 | 
			
		||||
                    >
 | 
			
		||||
                      再生
 | 
			
		||||
                    </button>
 | 
			
		||||
                    <button 
 | 
			
		||||
                      className="btn btn-secondary"
 | 
			
		||||
                      onClick={() => shareEpisode(episode)}
 | 
			
		||||
                    >
 | 
			
		||||
                      共有
 | 
			
		||||
                    </button>
 | 
			
		||||
                  </div>
 | 
			
		||||
                )}
 | 
			
		||||
                  {currentAudio === episode.audioUrl && (
 | 
			
		||||
                    <div>
 | 
			
		||||
                      <audio
 | 
			
		||||
                        id={episode.audioUrl}
 | 
			
		||||
                        controls
 | 
			
		||||
                        className="audio-player"
 | 
			
		||||
                        src={episode.audioUrl}
 | 
			
		||||
                        onEnded={() => setCurrentAudio(null)}
 | 
			
		||||
                      />
 | 
			
		||||
                    </div>
 | 
			
		||||
                  )}
 | 
			
		||||
                </div>
 | 
			
		||||
              </td>
 | 
			
		||||
            </tr>
 | 
			
		||||
          ))}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,10 +1,13 @@
 | 
			
		||||
import { StrictMode } from 'react'
 | 
			
		||||
import { createRoot } from 'react-dom/client'
 | 
			
		||||
import { BrowserRouter } from 'react-router-dom'
 | 
			
		||||
import App from './App.tsx'
 | 
			
		||||
import './styles.css'
 | 
			
		||||
 | 
			
		||||
createRoot(document.getElementById('root')!).render(
 | 
			
		||||
  <StrictMode>
 | 
			
		||||
    <App />
 | 
			
		||||
    <BrowserRouter>
 | 
			
		||||
      <App />
 | 
			
		||||
    </BrowserRouter>
 | 
			
		||||
  </StrictMode>,
 | 
			
		||||
)
 | 
			
		||||
		Reference in New Issue
	
	Block a user