This commit is contained in:
2025-06-07 19:30:37 +09:00
parent b413162033
commit 353843871a
7 changed files with 949 additions and 51 deletions

View File

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

View File

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

View File

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

View 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

View 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