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:
2025-06-07 08:47:10 +09:00
parent c8cadfed62
commit 986743949f
14 changed files with 2395 additions and 568 deletions

View File

@ -1,23 +1,122 @@
/* Global styles for the Next.js app */
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Custom styles for better accessibility and design */
.line-clamp-1 {
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
}
.line-clamp-2 {
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
/* Custom slider styles */
.slider::-webkit-slider-thumb {
appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: #8b5cf6;
cursor: pointer;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
transition: all 0.2s ease;
}
.slider::-webkit-slider-thumb:hover {
background: #7c3aed;
transform: scale(1.1);
}
.slider::-moz-range-thumb {
width: 20px;
height: 20px;
border-radius: 50%;
background: #8b5cf6;
cursor: pointer;
border: none;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
}
/* Focus states for accessibility */
.focus-ring:focus {
outline: 2px solid #8b5cf6;
outline-offset: 2px;
}
/* Animation for loading states */
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: .5;
}
}
.animate-pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
/* Improved scrollbar */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: #f1f5f9;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
/* High contrast mode support */
@media (prefers-contrast: high) {
.border-gray-200 {
border-color: #000;
}
.text-gray-600 {
color: #000;
}
.bg-gray-50 {
background-color: #fff;
}
}
/* Reduced motion support */
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* Global font and base styles */
html,
body {
padding: 0;
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans',
sans-serif;
background-color: #f9fafb; /* light background */
color: #111827; /* dark text */
}
*,
*::before,
*::after {
box-sizing: border-box;
}
/* Center content by default */
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 1rem;
}
}

View File

@ -1,5 +1,7 @@
import FeedList from "../components/FeedList";
import { useState } from "react";
import FeedManager from "../components/FeedManager";
import EpisodePlayer from "../components/EpisodePlayer";
import Dashboard from "../components/Dashboard";
export const metadata = {
title: "Voice RSS Summary",
@ -7,20 +9,99 @@ export const metadata = {
};
export default function Home() {
const [activeTab, setActiveTab] = useState<'dashboard' | 'episodes' | 'feeds'>('dashboard');
return (
<div className="space-y-8">
<section>
<h1 className="text-2xl font-bold mb-4">Voice RSS Summary</h1>
<p className="mb-6">RSSフィードから自動生成された音声ポッドキャストを再生</p>
</section>
<section>
<h2 className="text-xl font-semibold mb-4"></h2>
<FeedList />
</section>
<section>
<h2 className="text-xl font-semibold mb-4"></h2>
<EpisodePlayer />
</section>
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-purple-50">
{/* Header */}
<header className="bg-white shadow-sm border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center py-6">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M9 12a3 3 0 106 0 3 3 0 00-6 0z" />
</svg>
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900">Voice RSS Summary</h1>
<p className="text-sm text-gray-600">AI音声ポッドキャスト自動生成システム</p>
</div>
</div>
<div className="hidden md:flex items-center space-x-4">
<div className="flex items-center space-x-2 text-sm text-gray-500">
<div className="w-2 h-2 bg-green-400 rounded-full animate-pulse"></div>
<span></span>
</div>
</div>
</div>
</div>
</header>
{/* Navigation */}
<nav className="bg-white border-b border-gray-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex space-x-8">
{[
{ id: 'dashboard', label: 'ダッシュボード', icon: '📊' },
{ id: 'episodes', label: 'エピソード', icon: '🎧' },
{ id: 'feeds', label: 'フィード管理', icon: '📡' }
].map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as any)}
className={`flex items-center space-x-2 py-4 px-1 border-b-2 font-medium text-sm transition-colors duration-200 ${
activeTab === tab.id
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
aria-current={activeTab === tab.id ? 'page' : undefined}
>
<span role="img" aria-hidden="true">{tab.icon}</span>
<span>{tab.label}</span>
</button>
))}
</div>
</div>
</nav>
{/* Main Content */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="space-y-8">
{activeTab === 'dashboard' && <Dashboard />}
{activeTab === 'episodes' && (
<div className="bg-white rounded-xl shadow-sm p-6">
<div className="flex items-center space-x-3 mb-6">
<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>
<h2 className="text-xl font-semibold text-gray-900"></h2>
</div>
<EpisodePlayer />
</div>
)}
{activeTab === 'feeds' && (
<div className="bg-white rounded-xl shadow-sm p-6">
<div className="flex items-center space-x-3 mb-6">
<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>
<h2 className="text-xl font-semibold text-gray-900"></h2>
</div>
<FeedManager />
</div>
)}
</div>
</main>
{/* Footer */}
<footer className="bg-gray-50 border-t mt-16">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="text-center text-gray-500 text-sm">
<p>© 2025 Voice RSS Summary. AI技術により最新のニュースを音声でお届けします</p>
</div>
</div>
</footer>
</div>
);
}
}

View 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>
);
}

View File

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

View 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>
);
}