feat: 記事ごとのポッドキャスト生成と新規記事検出システム、モダンUIの実装
- 新規記事検出システム: 記事の重複チェックと新規記事のみ処理 - 記事単位ポッドキャスト: フィード統合から記事個別エピソードに変更 - 6時間間隔バッチ処理: 自動定期実行スケジュールの改善 - 完全UIリニューアル: ダッシュボード・フィード管理・エピソード管理の3画面構成 - アクセシビリティ強化: ARIA属性、キーボードナビ、高コントラスト対応 - データベース刷新: feeds/articles/episodes階層構造への移行 - 中央集権設定管理: services/config.ts による設定統一 - エラーハンドリング改善: 全モジュールでの堅牢なエラー処理 - TypeScript型安全性向上: null安全性とインターフェース改善 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
255
frontend/src/components/Dashboard.tsx
Normal file
255
frontend/src/components/Dashboard.tsx
Normal file
@ -0,0 +1,255 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface Stats {
|
||||
totalFeeds: number;
|
||||
activeFeeds: number;
|
||||
totalEpisodes: number;
|
||||
lastUpdated: string;
|
||||
}
|
||||
|
||||
interface RecentEpisode {
|
||||
id: string;
|
||||
title: string;
|
||||
createdAt: string;
|
||||
article: {
|
||||
title: string;
|
||||
link: string;
|
||||
};
|
||||
feed: {
|
||||
title: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const [stats, setStats] = useState<Stats | null>(null);
|
||||
const [recentEpisodes, setRecentEpisodes] = useState<RecentEpisode[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDashboardData();
|
||||
}, []);
|
||||
|
||||
const fetchDashboardData = async () => {
|
||||
try {
|
||||
// Fetch stats and recent episodes in parallel
|
||||
const [statsResponse, episodesResponse] = await Promise.all([
|
||||
fetch("/api/stats"),
|
||||
fetch("/api/episodes")
|
||||
]);
|
||||
|
||||
if (!statsResponse.ok || !episodesResponse.ok) {
|
||||
throw new Error("Failed to fetch dashboard data");
|
||||
}
|
||||
|
||||
const statsData = await statsResponse.json();
|
||||
const episodesData = await episodesResponse.json();
|
||||
|
||||
setStats(statsData);
|
||||
setRecentEpisodes(episodesData.slice(0, 5)); // Show latest 5 episodes
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "エラーが発生しました");
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const triggerBatchProcess = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/batch/trigger", { method: "POST" });
|
||||
if (response.ok) {
|
||||
alert("バッチ処理を開始しました。新しいエピソードの生成には時間がかかる場合があります。");
|
||||
} else {
|
||||
alert("バッチ処理の開始に失敗しました。");
|
||||
}
|
||||
} catch (error) {
|
||||
alert("エラーが発生しました。");
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
|
||||
<span className="text-gray-600">読み込み中...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<div className="flex items-center">
|
||||
<div className="text-red-400">
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800">エラー</h3>
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div className="bg-white rounded-xl shadow-sm p-6 border border-gray-100">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<span role="img" aria-hidden="true" className="text-lg">📡</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-600">総フィード数</p>
|
||||
<p className="text-2xl font-semibold text-gray-900">{stats?.totalFeeds || 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm p-6 border border-gray-100">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-8 h-8 bg-green-100 rounded-lg flex items-center justify-center">
|
||||
<span role="img" aria-hidden="true" className="text-lg">✅</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-600">アクティブフィード</p>
|
||||
<p className="text-2xl font-semibold text-gray-900">{stats?.activeFeeds || 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm p-6 border border-gray-100">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-8 h-8 bg-purple-100 rounded-lg flex items-center justify-center">
|
||||
<span role="img" aria-hidden="true" className="text-lg">🎧</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-600">総エピソード数</p>
|
||||
<p className="text-2xl font-semibold text-gray-900">{stats?.totalEpisodes || 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm p-6 border border-gray-100">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-8 h-8 bg-yellow-100 rounded-lg flex items-center justify-center">
|
||||
<span role="img" aria-hidden="true" className="text-lg">🕒</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-600">最終更新</p>
|
||||
<p className="text-sm font-semibold text-gray-900">
|
||||
{stats?.lastUpdated ? new Date(stats.lastUpdated).toLocaleDateString('ja-JP') : '未取得'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="bg-gradient-to-r from-blue-500 to-purple-600 rounded-xl shadow-sm p-6 text-white">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">手動バッチ実行</h3>
|
||||
<p className="text-blue-100 text-sm mt-1">
|
||||
新しい記事をすぐにチェックしてポッドキャストを生成
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={triggerBatchProcess}
|
||||
className="bg-white bg-opacity-20 hover:bg-opacity-30 text-white font-medium py-2 px-4 rounded-lg transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-white focus:ring-opacity-50"
|
||||
aria-label="バッチ処理を手動実行"
|
||||
>
|
||||
実行
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm p-6 border border-gray-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">システム状態</h3>
|
||||
<div className="flex items-center space-x-2 mt-2">
|
||||
<div className="w-3 h-3 bg-green-400 rounded-full animate-pulse"></div>
|
||||
<span className="text-sm text-gray-600">自動バッチ処理 (6時間間隔)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-green-500">
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Episodes */}
|
||||
<div className="bg-white rounded-xl shadow-sm p-6 border border-gray-100">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900">最新エピソード</h3>
|
||||
<span className="text-sm text-gray-500">{recentEpisodes.length} エピソード</span>
|
||||
</div>
|
||||
|
||||
{recentEpisodes.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<span role="img" aria-hidden="true" className="text-2xl">🎧</span>
|
||||
</div>
|
||||
<p className="text-gray-500">まだエピソードがありません</p>
|
||||
<p className="text-sm text-gray-400 mt-1">フィードを追加してバッチ処理を実行してください</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{recentEpisodes.map((episode) => (
|
||||
<div
|
||||
key={episode.id}
|
||||
className="flex items-start space-x-4 p-4 rounded-lg border border-gray-100 hover:bg-gray-50 transition-colors duration-200"
|
||||
>
|
||||
<div className="w-10 h-10 bg-gradient-to-r from-blue-400 to-purple-500 rounded-lg flex items-center justify-center text-white font-semibold text-sm">
|
||||
<span role="img" aria-hidden="true">🎵</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="text-sm font-medium text-gray-900 truncate">
|
||||
{episode.title}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{episode.feed?.title} • {new Date(episode.createdAt).toLocaleDateString('ja-JP')}
|
||||
</p>
|
||||
{episode.article && (
|
||||
<a
|
||||
href={episode.article.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-blue-600 hover:text-blue-800 mt-1 inline-block"
|
||||
>
|
||||
元記事を見る →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
生成済み
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,24 +1,62 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
|
||||
interface Episode {
|
||||
id: string;
|
||||
title: string;
|
||||
pubDate: string;
|
||||
description?: string;
|
||||
audioPath: string;
|
||||
sourceLink: string;
|
||||
duration?: number;
|
||||
fileSize?: number;
|
||||
createdAt: string;
|
||||
article: {
|
||||
id: string;
|
||||
title: string;
|
||||
link: string;
|
||||
description?: string;
|
||||
pubDate: string;
|
||||
};
|
||||
feed: {
|
||||
id: string;
|
||||
title?: string;
|
||||
url: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function EpisodePlayer() {
|
||||
const [episodes, setEpisodes] = useState<Episode[]>([]);
|
||||
const [selectedEpisode, setSelectedEpisode] = useState<Episode | null>(null);
|
||||
const [audioUrl, setAudioUrl] = useState<string | null>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchEpisodes();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
|
||||
const updateTime = () => setCurrentTime(audio.currentTime);
|
||||
const updateDuration = () => setDuration(audio.duration);
|
||||
const handleEnded = () => setIsPlaying(false);
|
||||
|
||||
audio.addEventListener('timeupdate', updateTime);
|
||||
audio.addEventListener('loadedmetadata', updateDuration);
|
||||
audio.addEventListener('ended', handleEnded);
|
||||
|
||||
return () => {
|
||||
audio.removeEventListener('timeupdate', updateTime);
|
||||
audio.removeEventListener('loadedmetadata', updateDuration);
|
||||
audio.removeEventListener('ended', handleEnded);
|
||||
};
|
||||
}, [selectedEpisode]);
|
||||
|
||||
const fetchEpisodes = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/episodes");
|
||||
@ -35,45 +73,267 @@ export default function EpisodePlayer() {
|
||||
};
|
||||
|
||||
const handlePlay = (episode: Episode) => {
|
||||
setSelectedEpisode(episode);
|
||||
setAudioUrl(`/podcast_audio/${episode.audioPath}`);
|
||||
if (selectedEpisode?.id === episode.id) {
|
||||
// Toggle play/pause for the same episode
|
||||
if (isPlaying) {
|
||||
audioRef.current?.pause();
|
||||
setIsPlaying(false);
|
||||
} else {
|
||||
audioRef.current?.play();
|
||||
setIsPlaying(true);
|
||||
}
|
||||
} else {
|
||||
// Play new episode
|
||||
setSelectedEpisode(episode);
|
||||
setIsPlaying(true);
|
||||
setCurrentTime(0);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div>読み込み中...</div>;
|
||||
if (error) return <div className="text-red-500">エラー: {error}</div>;
|
||||
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const audio = audioRef.current;
|
||||
if (audio) {
|
||||
const newTime = parseFloat(e.target.value);
|
||||
audio.currentTime = newTime;
|
||||
setCurrentTime(newTime);
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (time: number) => {
|
||||
if (isNaN(time)) return "0:00";
|
||||
const minutes = Math.floor(time / 60);
|
||||
const seconds = Math.floor(time % 60);
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes?: number) => {
|
||||
if (!bytes) return "不明";
|
||||
const mb = bytes / (1024 * 1024);
|
||||
return `${mb.toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
const filteredEpisodes = episodes.filter(episode =>
|
||||
episode.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
episode.article.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
episode.feed.title?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-500"></div>
|
||||
<span className="text-gray-600">読み込み中...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<div className="flex items-center">
|
||||
<div className="text-red-400">
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800">エラー</h3>
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">最近のエピソード</h3>
|
||||
<div className="space-y-2">
|
||||
{episodes.map((episode) => (
|
||||
<div
|
||||
key={episode.id}
|
||||
className="flex justify-between items-center p-2 hover:bg-gray-50 rounded"
|
||||
>
|
||||
<span>{episode.title}</span>
|
||||
<button
|
||||
onClick={() => handlePlay(episode)}
|
||||
className="px-3 py-1 bg-blue-500 text-white rounded hover:bg-blue-600"
|
||||
>
|
||||
再生
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="space-y-6">
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg className="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="エピソードを検索..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Audio Player */}
|
||||
{selectedEpisode && (
|
||||
<div className="mt-6">
|
||||
<h4 className="text-md font-semibold mb-2">
|
||||
再生中: {selectedEpisode.title}
|
||||
</h4>
|
||||
{audioUrl ? (
|
||||
<audio src={audioUrl} controls className="w-full" />
|
||||
) : (
|
||||
<div>音声ファイルを読み込み中...</div>
|
||||
)}
|
||||
<div className="bg-gradient-to-r from-purple-50 to-blue-50 rounded-xl p-6 border border-purple-100">
|
||||
<div className="flex items-center space-x-4 mb-4">
|
||||
<div className="w-12 h-12 bg-gradient-to-r from-purple-500 to-blue-600 rounded-full flex items-center justify-center text-white">
|
||||
<span role="img" aria-hidden="true" className="text-xl">🎵</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-lg font-semibold text-gray-900 truncate">{selectedEpisode.title}</h3>
|
||||
<p className="text-sm text-gray-600">{selectedEpisode.feed.title}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handlePlay(selectedEpisode)}
|
||||
className="w-12 h-12 bg-white rounded-full shadow-md flex items-center justify-center hover:shadow-lg transition-shadow duration-200"
|
||||
aria-label={isPlaying ? "一時停止" : "再生"}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<svg className="w-6 h-6 text-gray-700" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zM7 8a1 1 0 012 0v4a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v4a1 1 0 102 0V8a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-6 h-6 text-gray-700 ml-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={duration || 0}
|
||||
value={currentTime}
|
||||
onChange={handleSeek}
|
||||
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer slider"
|
||||
aria-label="再生位置"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-500">
|
||||
<span>{formatTime(currentTime)}</span>
|
||||
<span>{formatTime(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Episode Info */}
|
||||
<div className="mt-4 space-y-2 text-sm">
|
||||
{selectedEpisode.description && (
|
||||
<p className="text-gray-700">{selectedEpisode.description}</p>
|
||||
)}
|
||||
<div className="flex items-center space-x-4 text-gray-500">
|
||||
<span>🗓️ {new Date(selectedEpisode.createdAt).toLocaleDateString('ja-JP')}</span>
|
||||
<span>💾 {formatFileSize(selectedEpisode.fileSize)}</span>
|
||||
{selectedEpisode.article.link && (
|
||||
<a
|
||||
href={selectedEpisode.article.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
📄 元記事
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={`/podcast_audio/${selectedEpisode.audioPath}`}
|
||||
onPlay={() => setIsPlaying(true)}
|
||||
onPause={() => setIsPlaying(false)}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Episodes List */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">エピソード一覧</h3>
|
||||
<span className="text-sm text-gray-500">{filteredEpisodes.length} エピソード</span>
|
||||
</div>
|
||||
|
||||
{filteredEpisodes.length === 0 ? (
|
||||
<div className="text-center py-12 bg-gray-50 rounded-xl">
|
||||
<div className="w-16 h-16 bg-gray-200 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<span role="img" aria-hidden="true" className="text-2xl">🎧</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
{searchTerm ? "検索結果がありません" : "エピソードがありません"}
|
||||
</h3>
|
||||
<p className="text-gray-500">
|
||||
{searchTerm ? "別のキーワードで検索してみてください" : "フィードを追加してバッチ処理を実行してください"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{filteredEpisodes.map((episode) => (
|
||||
<div
|
||||
key={episode.id}
|
||||
className={`border rounded-xl p-4 transition-all duration-200 cursor-pointer ${
|
||||
selectedEpisode?.id === episode.id
|
||||
? 'border-purple-300 bg-purple-50 shadow-md'
|
||||
: 'border-gray-200 bg-white hover:border-gray-300 hover:shadow-sm'
|
||||
}`}
|
||||
onClick={() => handlePlay(episode)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handlePlay(episode);
|
||||
}
|
||||
}}
|
||||
aria-label={`エピソード: ${episode.title}`}
|
||||
>
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className="flex-shrink-0">
|
||||
<div className={`w-12 h-12 rounded-full flex items-center justify-center ${
|
||||
selectedEpisode?.id === episode.id
|
||||
? 'bg-purple-500 text-white'
|
||||
: 'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{selectedEpisode?.id === episode.id && isPlaying ? (
|
||||
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zM7 8a1 1 0 012 0v4a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v4a1 1 0 102 0V8a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="text-base font-medium text-gray-900 mb-1 line-clamp-2">
|
||||
{episode.title}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600 mb-2 line-clamp-1">
|
||||
{episode.feed.title}
|
||||
</p>
|
||||
{episode.description && (
|
||||
<p className="text-sm text-gray-500 mb-2 line-clamp-2">
|
||||
{episode.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center space-x-4 text-xs text-gray-500">
|
||||
<span>📅 {new Date(episode.createdAt).toLocaleDateString('ja-JP')}</span>
|
||||
<span>💾 {formatFileSize(episode.fileSize)}</span>
|
||||
{episode.article.link && (
|
||||
<a
|
||||
href={episode.article.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-800"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
📄 元記事
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
243
frontend/src/components/FeedManager.tsx
Normal file
243
frontend/src/components/FeedManager.tsx
Normal file
@ -0,0 +1,243 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface Feed {
|
||||
id: string;
|
||||
url: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
lastUpdated?: string;
|
||||
createdAt: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export default function FeedManager() {
|
||||
const [feeds, setFeeds] = useState<Feed[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [newFeedUrl, setNewFeedUrl] = useState("");
|
||||
const [addingFeed, setAddingFeed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchFeeds();
|
||||
}, []);
|
||||
|
||||
const fetchFeeds = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch("/api/feeds");
|
||||
if (!response.ok) {
|
||||
throw new Error("フィードの取得に失敗しました");
|
||||
}
|
||||
const data = await response.json();
|
||||
setFeeds(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "エラーが発生しました");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addFeed = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!newFeedUrl.trim()) {
|
||||
alert("フィードURLを入力してください");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!newFeedUrl.startsWith('http')) {
|
||||
alert("有効なURLを入力してください");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setAddingFeed(true);
|
||||
const response = await fetch("/api/feeds", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ feedUrl: newFeedUrl }),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
if (result.result === "EXISTS") {
|
||||
alert("このフィードは既に登録されています");
|
||||
} else {
|
||||
alert("フィードが正常に追加されました");
|
||||
setNewFeedUrl("");
|
||||
fetchFeeds(); // Refresh the list
|
||||
}
|
||||
} else {
|
||||
alert(result.error || "フィードの追加に失敗しました");
|
||||
}
|
||||
} catch (err) {
|
||||
alert("エラーが発生しました");
|
||||
} finally {
|
||||
setAddingFeed(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
|
||||
<span className="text-gray-600">読み込み中...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Add New Feed Form */}
|
||||
<div className="bg-gradient-to-r from-blue-50 to-purple-50 rounded-xl p-6 border border-blue-100">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">新しいフィードを追加</h3>
|
||||
<form onSubmit={addFeed} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="feedUrl" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
RSS フィード URL
|
||||
</label>
|
||||
<div className="flex space-x-3">
|
||||
<input
|
||||
type="url"
|
||||
id="feedUrl"
|
||||
value={newFeedUrl}
|
||||
onChange={(e) => setNewFeedUrl(e.target.value)}
|
||||
placeholder="https://example.com/feed.xml"
|
||||
className="flex-1 border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
disabled={addingFeed}
|
||||
aria-describedby="feedUrl-help"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={addingFeed || !newFeedUrl.trim()}
|
||||
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
|
||||
>
|
||||
{addingFeed ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
<span>追加中...</span>
|
||||
</div>
|
||||
) : (
|
||||
"追加"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<p id="feedUrl-help" className="text-xs text-gray-500 mt-2">
|
||||
RSS または Atom フィードの URL を入力してください
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<div className="flex items-center">
|
||||
<div className="text-red-400">
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800">エラー</h3>
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Feeds List */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">登録済みフィード</h3>
|
||||
<span className="text-sm text-gray-500">{feeds.length} フィード</span>
|
||||
</div>
|
||||
|
||||
{feeds.length === 0 ? (
|
||||
<div className="text-center py-12 bg-gray-50 rounded-xl">
|
||||
<div className="w-16 h-16 bg-gray-200 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<span role="img" aria-hidden="true" className="text-2xl">📡</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">フィードがありません</h3>
|
||||
<p className="text-gray-500">上のフォームから RSS フィードを追加してください</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{feeds.map((feed) => (
|
||||
<div
|
||||
key={feed.id}
|
||||
className="bg-white border border-gray-200 rounded-xl p-6 hover:shadow-md transition-shadow duration-200"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center space-x-3 mb-2">
|
||||
<div className={`w-3 h-3 rounded-full ${feed.active ? 'bg-green-400' : 'bg-gray-400'}`}></div>
|
||||
<h4 className="text-lg font-medium text-gray-900 truncate">
|
||||
{feed.title || 'タイトル未取得'}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
{feed.description && (
|
||||
<p className="text-sm text-gray-600 mb-3 line-clamp-2">
|
||||
{feed.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-xs font-medium text-gray-500">URL:</span>
|
||||
<a
|
||||
href={feed.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-blue-600 hover:text-blue-800 truncate max-w-xs"
|
||||
title={feed.url}
|
||||
>
|
||||
{feed.url}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4 text-xs text-gray-500">
|
||||
<span>追加日: {new Date(feed.createdAt).toLocaleDateString('ja-JP')}</span>
|
||||
{feed.lastUpdated && (
|
||||
<span>最終更新: {new Date(feed.lastUpdated).toLocaleDateString('ja-JP')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 ml-4">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
feed.active
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{feed.active ? 'アクティブ' : '無効'}
|
||||
</span>
|
||||
|
||||
{/* Future: Add edit/delete buttons here */}
|
||||
<button
|
||||
className="text-gray-400 hover:text-gray-600 p-1"
|
||||
title="設定"
|
||||
aria-label={`${feed.title || feed.url}の設定`}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
Reference in New Issue
Block a user