Fix episode player and episode list

This commit is contained in:
2025-06-07 14:24:33 +09:00
parent 53a408d074
commit 6830d06ed4
7 changed files with 419 additions and 53 deletions

View File

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

View 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

View File

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

View File

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