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