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