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
|
Reference in New Issue
Block a user