Update
This commit is contained in:
		@@ -3,11 +3,10 @@
 | 
				
			|||||||
  <head>
 | 
					  <head>
 | 
				
			||||||
    <meta charset="UTF-8" />
 | 
					    <meta charset="UTF-8" />
 | 
				
			||||||
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
 | 
					    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
 | 
				
			||||||
    <title>ポッドキャスト管理画面</title>
 | 
					    <title>Voice RSS Summary</title>
 | 
				
			||||||
    <meta name="description" content="RSSフィードから自動生成された音声ポッドキャストを再生・管理できます。" />
 | 
					 | 
				
			||||||
  </head>
 | 
					  </head>
 | 
				
			||||||
  <body>
 | 
					  <body>
 | 
				
			||||||
    <div id="root"></div>
 | 
					    <div id="root"></div>
 | 
				
			||||||
    <script type="module" src="./src/main.tsx" defer></script>
 | 
					    <script type="module" src="/src/main.tsx"></script>
 | 
				
			||||||
  </body>
 | 
					  </body>
 | 
				
			||||||
</html>
 | 
					</html>
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,133 +1,38 @@
 | 
				
			|||||||
import { useState } from "react";
 | 
					import { useState } from 'react'
 | 
				
			||||||
import "./app/globals.css";
 | 
					import EpisodeList from './components/EpisodeList'
 | 
				
			||||||
import Dashboard from "./components/Dashboard";
 | 
					import FeedManager from './components/FeedManager'
 | 
				
			||||||
import EpisodePlayer from "./components/EpisodePlayer";
 | 
					 | 
				
			||||||
import FeedManager from "./components/FeedManager";
 | 
					 | 
				
			||||||
import React from "react";
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
type TabType = "dashboard" | "episodes" | "feeds";
 | 
					function App() {
 | 
				
			||||||
 | 
					  const [activeTab, setActiveTab] = useState<'episodes' | 'feeds'>('episodes')
 | 
				
			||||||
interface Tab {
 | 
					 | 
				
			||||||
  id: TabType;
 | 
					 | 
				
			||||||
  label: string;
 | 
					 | 
				
			||||||
  icon: string;
 | 
					 | 
				
			||||||
  description: string;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
const tabs: Tab[] = [
 | 
					 | 
				
			||||||
  {
 | 
					 | 
				
			||||||
    id: "dashboard",
 | 
					 | 
				
			||||||
    label: "ダッシュボード",
 | 
					 | 
				
			||||||
    icon: "📊",
 | 
					 | 
				
			||||||
    description: "システム概要と最新情報",
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
  {
 | 
					 | 
				
			||||||
    id: "episodes",
 | 
					 | 
				
			||||||
    label: "エピソード",
 | 
					 | 
				
			||||||
    icon: "🎧",
 | 
					 | 
				
			||||||
    description: "ポッドキャスト再生と管理",
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
  {
 | 
					 | 
				
			||||||
    id: "feeds",
 | 
					 | 
				
			||||||
    label: "フィード管理",
 | 
					 | 
				
			||||||
    icon: "📡",
 | 
					 | 
				
			||||||
    description: "RSSフィードの設定",
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
];
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export default function App() {
 | 
					 | 
				
			||||||
  const [activeTab, setActiveTab] = useState<TabType>("dashboard");
 | 
					 | 
				
			||||||
  const [isMenuOpen, setIsMenuOpen] = useState(false);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const activeTabInfo = tabs.find((tab) => tab.id === activeTab);
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <html lang="ja">
 | 
					    <div className="container">
 | 
				
			||||||
      <head>
 | 
					      <div className="header">
 | 
				
			||||||
        <meta charSet="UTF-8" />
 | 
					        <div className="title">Voice RSS Summary</div>
 | 
				
			||||||
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
 | 
					        <div className="subtitle">RSS フィードから自動生成された音声ポッドキャスト</div>
 | 
				
			||||||
        <title>Voice RSS Summary - AI音声ポッドキャスト生成システム</title>
 | 
					 | 
				
			||||||
        <meta
 | 
					 | 
				
			||||||
          name="description"
 | 
					 | 
				
			||||||
          content="RSSフィードから自動生成された音声ポッドキャストをご紹介します。"
 | 
					 | 
				
			||||||
        />
 | 
					 | 
				
			||||||
      </head>
 | 
					 | 
				
			||||||
      <body className="min-h-screen bg-gray-50">
 | 
					 | 
				
			||||||
        <div className="min-h-screen flex flex-col">
 | 
					 | 
				
			||||||
          {/* Header */}
 | 
					 | 
				
			||||||
          <header className="bg-white border-b border-gray-200 sticky top-0 z-50">
 | 
					 | 
				
			||||||
            <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
 | 
					 | 
				
			||||||
              <div className="flex justify-between items-center py-4">
 | 
					 | 
				
			||||||
                {/* Status and Mobile Menu */}
 | 
					 | 
				
			||||||
                <div className="flex items-center space-x-4">
 | 
					 | 
				
			||||||
                  {/* System Status */}
 | 
					 | 
				
			||||||
                  <div className="hidden md:flex items-center space-x-2 px-3 py-1 rounded-full bg-green-100 text-green-800">
 | 
					 | 
				
			||||||
                    <div className="w-2 h-2 bg-green-500 rounded-full"></div>
 | 
					 | 
				
			||||||
                    <span className="text-sm font-medium">稼働中</span>
 | 
					 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                  {/* Mobile menu button */}
 | 
					      <div className="tabs">
 | 
				
			||||||
        <button
 | 
					        <button
 | 
				
			||||||
                    onClick={() => setIsMenuOpen(!isMenuOpen)}
 | 
					          className={`tab ${activeTab === 'episodes' ? 'active' : ''}`}
 | 
				
			||||||
                    className="md:hidden p-2 rounded-lg bg-gray-100 hover:bg-gray-200 transition-colors"
 | 
					          onClick={() => setActiveTab('episodes')}
 | 
				
			||||||
                    aria-label="メニューを開く"
 | 
					 | 
				
			||||||
                  ></button>
 | 
					 | 
				
			||||||
                </div>
 | 
					 | 
				
			||||||
              </div>
 | 
					 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
          </header>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
          {/* Navigation */}
 | 
					 | 
				
			||||||
          <nav
 | 
					 | 
				
			||||||
            className={`bg-white border-b border-gray-200 ${isMenuOpen ? "block" : "hidden md:block"}`}
 | 
					 | 
				
			||||||
        >
 | 
					        >
 | 
				
			||||||
            <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
 | 
					          エピソード一覧
 | 
				
			||||||
              <div className="flex flex-col md:flex-row">
 | 
					        </button>
 | 
				
			||||||
                {tabs.map((tab) => (
 | 
					        <button
 | 
				
			||||||
                  <button
 | 
					          className={`tab ${activeTab === 'feeds' ? 'active' : ''}`}
 | 
				
			||||||
                    key={tab.id}
 | 
					          onClick={() => setActiveTab('feeds')}
 | 
				
			||||||
                    onClick={() => {
 | 
					        >
 | 
				
			||||||
                      setActiveTab(tab.id);
 | 
					          フィード管理
 | 
				
			||||||
                      setIsMenuOpen(false);
 | 
					 | 
				
			||||||
                    }}
 | 
					 | 
				
			||||||
                    className={`flex items-center space-x-3 py-3 px-4 text-sm font-medium transition-colors ${
 | 
					 | 
				
			||||||
                      activeTab === tab.id
 | 
					 | 
				
			||||||
                        ? "text-blue-600 border-b-2 border-blue-600"
 | 
					 | 
				
			||||||
                        : "text-gray-600 hover:text-gray-900 border-b-2 border-transparent"
 | 
					 | 
				
			||||||
                    }`}
 | 
					 | 
				
			||||||
                    aria-current={activeTab === tab.id ? "page" : undefined}
 | 
					 | 
				
			||||||
                  >
 | 
					 | 
				
			||||||
                    <span role="img" aria-hidden="true">
 | 
					 | 
				
			||||||
                      {tab.icon}
 | 
					 | 
				
			||||||
                    </span>
 | 
					 | 
				
			||||||
                    <span>{tab.label}</span>
 | 
					 | 
				
			||||||
        </button>
 | 
					        </button>
 | 
				
			||||||
                ))}
 | 
					 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
          </nav>
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
          {/* Main Content */}
 | 
					      <div className="content">
 | 
				
			||||||
          <main className="flex-1">
 | 
					        {activeTab === 'episodes' && <EpisodeList />}
 | 
				
			||||||
            <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
 | 
					        {activeTab === 'feeds' && <FeedManager />}
 | 
				
			||||||
              {activeTab === "dashboard" && <Dashboard />}
 | 
					 | 
				
			||||||
              {activeTab === "episodes" && <EpisodePlayer />}
 | 
					 | 
				
			||||||
              {activeTab === "feeds" && <FeedManager />}
 | 
					 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
          </main>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
          {/* Footer */}
 | 
					 | 
				
			||||||
          <footer className="mt-auto bg-white border-t border-gray-200">
 | 
					 | 
				
			||||||
            <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
 | 
					 | 
				
			||||||
              <div className="text-center">
 | 
					 | 
				
			||||||
                <p className="text-gray-500 text-sm">
 | 
					 | 
				
			||||||
                  © 2025 Voice RSS Summary. All rights reserved.
 | 
					 | 
				
			||||||
                </p>
 | 
					 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
          </footer>
 | 
					  )
 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
      </body>
 | 
					 | 
				
			||||||
    </html>
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default App
 | 
				
			||||||
@@ -1,125 +0,0 @@
 | 
				
			|||||||
@tailwind base;
 | 
					 | 
				
			||||||
@tailwind components;
 | 
					 | 
				
			||||||
@tailwind utilities;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/* Minimal CSS Variables */
 | 
					 | 
				
			||||||
:root {
 | 
					 | 
				
			||||||
  --primary: #3b82f6;
 | 
					 | 
				
			||||||
  --primary-hover: #2563eb;
 | 
					 | 
				
			||||||
  --border: #e5e7eb;
 | 
					 | 
				
			||||||
  --border-hover: #d1d5db;
 | 
					 | 
				
			||||||
  --background: #ffffff;
 | 
					 | 
				
			||||||
  --muted: #f9fafb;
 | 
					 | 
				
			||||||
  --text: #111827;
 | 
					 | 
				
			||||||
  --text-muted: #6b7280;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.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;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.slider {
 | 
					 | 
				
			||||||
  background: #e5e7eb;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.slider::-webkit-slider-thumb {
 | 
					 | 
				
			||||||
  appearance: none;
 | 
					 | 
				
			||||||
  width: 20px;
 | 
					 | 
				
			||||||
  height: 20px;
 | 
					 | 
				
			||||||
  border-radius: 50%;
 | 
					 | 
				
			||||||
  background: var(--primary);
 | 
					 | 
				
			||||||
  cursor: pointer;
 | 
					 | 
				
			||||||
  border: 2px solid white;
 | 
					 | 
				
			||||||
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.slider::-moz-range-thumb {
 | 
					 | 
				
			||||||
  width: 20px;
 | 
					 | 
				
			||||||
  height: 20px;
 | 
					 | 
				
			||||||
  border-radius: 50%;
 | 
					 | 
				
			||||||
  background: var(--primary);
 | 
					 | 
				
			||||||
  cursor: pointer;
 | 
					 | 
				
			||||||
  border: 2px solid white;
 | 
					 | 
				
			||||||
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
button:focus,
 | 
					 | 
				
			||||||
input:focus,
 | 
					 | 
				
			||||||
select:focus,
 | 
					 | 
				
			||||||
textarea:focus {
 | 
					 | 
				
			||||||
  outline: 2px solid var(--primary);
 | 
					 | 
				
			||||||
  outline-offset: 2px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
::-webkit-scrollbar {
 | 
					 | 
				
			||||||
  width: 8px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
::-webkit-scrollbar-track {
 | 
					 | 
				
			||||||
  background: #f1f5f9;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
::-webkit-scrollbar-thumb {
 | 
					 | 
				
			||||||
  background: #cbd5e1;
 | 
					 | 
				
			||||||
  border-radius: 4px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
::-webkit-scrollbar-thumb:hover {
 | 
					 | 
				
			||||||
  background: #94a3b8;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.btn-primary {
 | 
					 | 
				
			||||||
  background: var(--primary);
 | 
					 | 
				
			||||||
  color: white;
 | 
					 | 
				
			||||||
  border: none;
 | 
					 | 
				
			||||||
  padding: 0.5rem 1rem;
 | 
					 | 
				
			||||||
  border-radius: 0.375rem;
 | 
					 | 
				
			||||||
  font-weight: 500;
 | 
					 | 
				
			||||||
  transition: background-color 0.2s;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.btn-primary:hover {
 | 
					 | 
				
			||||||
  background: var(--primary-hover);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@media (prefers-reduced-motion: reduce) {
 | 
					 | 
				
			||||||
  * {
 | 
					 | 
				
			||||||
    animation-duration: 0.01ms !important;
 | 
					 | 
				
			||||||
    transition-duration: 0.01ms !important;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
html {
 | 
					 | 
				
			||||||
  scroll-behavior: smooth;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
body {
 | 
					 | 
				
			||||||
  margin: 0;
 | 
					 | 
				
			||||||
  padding: 0 1rem;
 | 
					 | 
				
			||||||
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
 | 
					 | 
				
			||||||
  background: #f9fafb;
 | 
					 | 
				
			||||||
  color: var(--text);
 | 
					 | 
				
			||||||
  line-height: 1.5;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
* {
 | 
					 | 
				
			||||||
  box-sizing: border-box;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
::selection {
 | 
					 | 
				
			||||||
  background: #dbeafe;
 | 
					 | 
				
			||||||
  color: #1e40af;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@@ -1,23 +0,0 @@
 | 
				
			|||||||
import "./globals.css";
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export default function RootLayout({
 | 
					 | 
				
			||||||
  children,
 | 
					 | 
				
			||||||
}: {
 | 
					 | 
				
			||||||
  children: React.ReactNode;
 | 
					 | 
				
			||||||
}) {
 | 
					 | 
				
			||||||
  return (
 | 
					 | 
				
			||||||
    <html lang="ja">
 | 
					 | 
				
			||||||
      <body>
 | 
					 | 
				
			||||||
        <div className="container">
 | 
					 | 
				
			||||||
          <header className="py-4 border-b">
 | 
					 | 
				
			||||||
            <h1 className="text-2xl font-bold">ポッドキャスト管理画面</h1>
 | 
					 | 
				
			||||||
          </header>
 | 
					 | 
				
			||||||
          <main className="py-6">{children}</main>
 | 
					 | 
				
			||||||
          <footer className="py-4 border-t text-center text-gray-500">
 | 
					 | 
				
			||||||
            <p>© 2025 Podcast Generator</p>
 | 
					 | 
				
			||||||
          </footer>
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
      </body>
 | 
					 | 
				
			||||||
    </html>
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@@ -1,179 +0,0 @@
 | 
				
			|||||||
import { useState } from "react";
 | 
					 | 
				
			||||||
import FeedManager from "../components/FeedManager";
 | 
					 | 
				
			||||||
import EpisodePlayer from "../components/EpisodePlayer";
 | 
					 | 
				
			||||||
import Dashboard from "../components/Dashboard";
 | 
					 | 
				
			||||||
import React from "react";
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export const metadata = {
 | 
					 | 
				
			||||||
  title: "Voice RSS Summary",
 | 
					 | 
				
			||||||
  description:
 | 
					 | 
				
			||||||
    "RSSフィードから自動生成された音声ポッドキャストをご紹介します。",
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export default function Home() {
 | 
					 | 
				
			||||||
  const [activeTab, setActiveTab] = useState<
 | 
					 | 
				
			||||||
    "dashboard" | "episodes" | "feeds"
 | 
					 | 
				
			||||||
  >("dashboard");
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  return (
 | 
					 | 
				
			||||||
    <div className="min-h-screen">
 | 
					 | 
				
			||||||
      {/* Header */}
 | 
					 | 
				
			||||||
      <header className="glass-effect border-b border-white/20 sticky top-0 z-50 backdrop-blur-xl">
 | 
					 | 
				
			||||||
        <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-4 animate-fadeIn">
 | 
					 | 
				
			||||||
              <div>
 | 
					 | 
				
			||||||
                <h1 className="text-3xl font-bold gradient-text">
 | 
					 | 
				
			||||||
                  Voice RSS Summary
 | 
					 | 
				
			||||||
                </h1>
 | 
					 | 
				
			||||||
                <p className="text-sm text-slate-600 font-medium">
 | 
					 | 
				
			||||||
                  AI音声ポッドキャスト自動生成システム
 | 
					 | 
				
			||||||
                </p>
 | 
					 | 
				
			||||||
              </div>
 | 
					 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
            <div className="hidden md:flex items-center space-x-6">
 | 
					 | 
				
			||||||
              <div className="flex items-center space-x-3 px-4 py-2 rounded-full bg-green-50 border border-green-200">
 | 
					 | 
				
			||||||
                <div className="w-3 h-3 bg-green-500 rounded-full animate-pulse shadow-lg"></div>
 | 
					 | 
				
			||||||
                <span className="text-sm font-semibold text-green-700">
 | 
					 | 
				
			||||||
                  システム稼働中
 | 
					 | 
				
			||||||
                </span>
 | 
					 | 
				
			||||||
              </div>
 | 
					 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
          </div>
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
      </header>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      {/* Navigation */}
 | 
					 | 
				
			||||||
      <nav className="glass-effect border-b border-white/20 relative">
 | 
					 | 
				
			||||||
        <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
 | 
					 | 
				
			||||||
          <div className="flex space-x-2">
 | 
					 | 
				
			||||||
            {[
 | 
					 | 
				
			||||||
              { id: "dashboard", label: "ダッシュボード", icon: "📊" },
 | 
					 | 
				
			||||||
              { id: "episodes", label: "エピソード", icon: "🎧" },
 | 
					 | 
				
			||||||
              { id: "feeds", label: "フィード管理", icon: "📡" },
 | 
					 | 
				
			||||||
            ].map((tab, index) => (
 | 
					 | 
				
			||||||
              <button
 | 
					 | 
				
			||||||
                key={tab.id}
 | 
					 | 
				
			||||||
                onClick={() => setActiveTab(tab.id as any)}
 | 
					 | 
				
			||||||
                className={`relative flex items-center space-x-3 py-4 px-6 font-semibold text-sm rounded-t-xl transition-all duration-300 transform hover:scale-105 ${
 | 
					 | 
				
			||||||
                  activeTab === tab.id
 | 
					 | 
				
			||||||
                    ? "bg-white text-slate-800 shadow-lg border-b-2 border-transparent"
 | 
					 | 
				
			||||||
                    : "text-slate-600 hover:text-slate-800 hover:bg-white/50"
 | 
					 | 
				
			||||||
                }`}
 | 
					 | 
				
			||||||
                style={{
 | 
					 | 
				
			||||||
                  animationDelay: `${index * 0.1}s`,
 | 
					 | 
				
			||||||
                }}
 | 
					 | 
				
			||||||
                aria-current={activeTab === tab.id ? "page" : undefined}
 | 
					 | 
				
			||||||
              >
 | 
					 | 
				
			||||||
                <span
 | 
					 | 
				
			||||||
                  className="text-lg animate-fadeIn"
 | 
					 | 
				
			||||||
                  role="img"
 | 
					 | 
				
			||||||
                  aria-hidden="true"
 | 
					 | 
				
			||||||
                >
 | 
					 | 
				
			||||||
                  {tab.icon}
 | 
					 | 
				
			||||||
                </span>
 | 
					 | 
				
			||||||
                <span className="animate-slideIn">{tab.label}</span>
 | 
					 | 
				
			||||||
                {activeTab === tab.id && (
 | 
					 | 
				
			||||||
                  <div
 | 
					 | 
				
			||||||
                    className="absolute bottom-0 left-0 right-0 h-1 rounded-t-full"
 | 
					 | 
				
			||||||
                    style={{ background: "var(--gradient-primary)" }}
 | 
					 | 
				
			||||||
                  ></div>
 | 
					 | 
				
			||||||
                )}
 | 
					 | 
				
			||||||
              </button>
 | 
					 | 
				
			||||||
            ))}
 | 
					 | 
				
			||||||
          </div>
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
      </nav>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      {/* Main Content */}
 | 
					 | 
				
			||||||
      <main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
 | 
					 | 
				
			||||||
        <div className="space-y-8">
 | 
					 | 
				
			||||||
          {activeTab === "dashboard" && (
 | 
					 | 
				
			||||||
            <div className="animate-fadeIn">
 | 
					 | 
				
			||||||
              <Dashboard />
 | 
					 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
          )}
 | 
					 | 
				
			||||||
          {activeTab === "episodes" && (
 | 
					 | 
				
			||||||
            <div className="glass-effect rounded-3xl shadow-2xl p-8 border border-white/20 animate-fadeIn">
 | 
					 | 
				
			||||||
              <div className="flex items-center space-x-4 mb-8">
 | 
					 | 
				
			||||||
                <div
 | 
					 | 
				
			||||||
                  className="w-12 h-12 rounded-2xl flex items-center justify-center shadow-lg"
 | 
					 | 
				
			||||||
                  style={{
 | 
					 | 
				
			||||||
                    background: "linear-gradient(135deg, #a855f7, #7c3aed)",
 | 
					 | 
				
			||||||
                  }}
 | 
					 | 
				
			||||||
                >
 | 
					 | 
				
			||||||
                  <span role="img" aria-hidden="true" className="text-2xl">
 | 
					 | 
				
			||||||
                    🎧
 | 
					 | 
				
			||||||
                  </span>
 | 
					 | 
				
			||||||
                </div>
 | 
					 | 
				
			||||||
                <div>
 | 
					 | 
				
			||||||
                  <h2 className="text-2xl font-bold text-slate-800">
 | 
					 | 
				
			||||||
                    エピソード管理
 | 
					 | 
				
			||||||
                  </h2>
 | 
					 | 
				
			||||||
                  <p className="text-slate-600 text-sm">
 | 
					 | 
				
			||||||
                    ポッドキャストエピソードの再生と管理
 | 
					 | 
				
			||||||
                  </p>
 | 
					 | 
				
			||||||
                </div>
 | 
					 | 
				
			||||||
              </div>
 | 
					 | 
				
			||||||
              <EpisodePlayer />
 | 
					 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
          )}
 | 
					 | 
				
			||||||
          {activeTab === "feeds" && (
 | 
					 | 
				
			||||||
            <div className="glass-effect rounded-3xl shadow-2xl p-8 border border-white/20 animate-fadeIn">
 | 
					 | 
				
			||||||
              <div className="flex items-center space-x-4 mb-8">
 | 
					 | 
				
			||||||
                <div
 | 
					 | 
				
			||||||
                  className="w-12 h-12 rounded-2xl flex items-center justify-center shadow-lg"
 | 
					 | 
				
			||||||
                  style={{
 | 
					 | 
				
			||||||
                    background: "linear-gradient(135deg, #0ea5e9, #0284c7)",
 | 
					 | 
				
			||||||
                  }}
 | 
					 | 
				
			||||||
                >
 | 
					 | 
				
			||||||
                  <span role="img" aria-hidden="true" className="text-2xl">
 | 
					 | 
				
			||||||
                    📡
 | 
					 | 
				
			||||||
                  </span>
 | 
					 | 
				
			||||||
                </div>
 | 
					 | 
				
			||||||
                <div>
 | 
					 | 
				
			||||||
                  <h2 className="text-2xl font-bold text-slate-800">
 | 
					 | 
				
			||||||
                    フィード管理
 | 
					 | 
				
			||||||
                  </h2>
 | 
					 | 
				
			||||||
                  <p className="text-slate-600 text-sm">
 | 
					 | 
				
			||||||
                    RSSフィードの追加と管理
 | 
					 | 
				
			||||||
                  </p>
 | 
					 | 
				
			||||||
                </div>
 | 
					 | 
				
			||||||
              </div>
 | 
					 | 
				
			||||||
              <FeedManager />
 | 
					 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
          )}
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
      </main>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      {/* Footer */}
 | 
					 | 
				
			||||||
      <footer className="mt-24 relative">
 | 
					 | 
				
			||||||
        <div className="glass-effect border-t border-white/20">
 | 
					 | 
				
			||||||
          <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
 | 
					 | 
				
			||||||
            <div className="text-center">
 | 
					 | 
				
			||||||
              <div className="flex items-center justify-center space-x-3 mb-4">
 | 
					 | 
				
			||||||
                <div
 | 
					 | 
				
			||||||
                  className="w-8 h-8 rounded-lg flex items-center justify-center"
 | 
					 | 
				
			||||||
                  style={{ background: "var(--gradient-primary)" }}
 | 
					 | 
				
			||||||
                ></div>
 | 
					 | 
				
			||||||
                <span className="text-lg font-bold gradient-text">
 | 
					 | 
				
			||||||
                  Voice RSS Summary
 | 
					 | 
				
			||||||
                </span>
 | 
					 | 
				
			||||||
              </div>
 | 
					 | 
				
			||||||
              <p className="text-slate-600 text-sm font-medium max-w-2xl mx-auto leading-relaxed">
 | 
					 | 
				
			||||||
                AI技術により最新のニュースを音声でお届けします。
 | 
					 | 
				
			||||||
                <br />
 | 
					 | 
				
			||||||
                自動化されたポッドキャスト生成で、いつでもどこでも情報をキャッチアップ。
 | 
					 | 
				
			||||||
              </p>
 | 
					 | 
				
			||||||
              <div className="mt-6 pt-6 border-t border-white/20">
 | 
					 | 
				
			||||||
                <p className="text-slate-500 text-xs">
 | 
					 | 
				
			||||||
                  © 2025 Voice RSS Summary. All rights reserved.
 | 
					 | 
				
			||||||
                </p>
 | 
					 | 
				
			||||||
              </div>
 | 
					 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
          </div>
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
      </footer>
 | 
					 | 
				
			||||||
    </div>
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@@ -1,284 +0,0 @@
 | 
				
			|||||||
import { useEffect, useState } from "react";
 | 
					 | 
				
			||||||
import React 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("エラーが発生しました。");
 | 
					 | 
				
			||||||
      console.error("Batch process trigger error:", error);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  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="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 xl:grid-cols-4 gap-6">
 | 
					 | 
				
			||||||
        <div className="bg-white rounded-lg shadow p-6 border border-gray-200">
 | 
					 | 
				
			||||||
          <div className="flex items-center justify-between">
 | 
					 | 
				
			||||||
            <div>
 | 
					 | 
				
			||||||
              <p className="text-sm font-medium text-gray-600">総フィード数</p>
 | 
					 | 
				
			||||||
              <p className="text-2xl font-bold text-gray-900 mt-2">
 | 
					 | 
				
			||||||
                {stats?.totalFeeds || 0}
 | 
					 | 
				
			||||||
              </p>
 | 
					 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
            <div className="w-8 h-8 rounded-lg flex items-center justify-center text-white bg-blue-600">
 | 
					 | 
				
			||||||
              <span className="text-sm">📡</span>
 | 
					 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
          </div>
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        <div className="bg-white rounded-lg shadow p-6 border border-gray-200">
 | 
					 | 
				
			||||||
          <div className="flex items-center justify-between">
 | 
					 | 
				
			||||||
            <div>
 | 
					 | 
				
			||||||
              <p className="text-sm font-medium text-gray-600">
 | 
					 | 
				
			||||||
                アクティブフィード
 | 
					 | 
				
			||||||
              </p>
 | 
					 | 
				
			||||||
              <p className="text-2xl font-bold text-gray-900 mt-2">
 | 
					 | 
				
			||||||
                {stats?.activeFeeds || 0}
 | 
					 | 
				
			||||||
              </p>
 | 
					 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
            <div className="w-8 h-8 rounded-lg flex items-center justify-center text-white bg-green-600">
 | 
					 | 
				
			||||||
              <span className="text-sm">✅</span>
 | 
					 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
          </div>
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        <div className="bg-white rounded-lg shadow p-6 border border-gray-200">
 | 
					 | 
				
			||||||
          <div className="flex items-center justify-between">
 | 
					 | 
				
			||||||
            <div>
 | 
					 | 
				
			||||||
              <p className="text-sm font-medium text-gray-600">
 | 
					 | 
				
			||||||
                総エピソード数
 | 
					 | 
				
			||||||
              </p>
 | 
					 | 
				
			||||||
              <p className="text-2xl font-bold text-gray-900 mt-2">
 | 
					 | 
				
			||||||
                {stats?.totalEpisodes || 0}
 | 
					 | 
				
			||||||
              </p>
 | 
					 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
            <div className="w-8 h-8 rounded-lg flex items-center justify-center text-white bg-purple-600">
 | 
					 | 
				
			||||||
              <span className="text-sm">🎧</span>
 | 
					 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
          </div>
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        <div className="bg-white rounded-lg shadow p-6 border border-gray-200">
 | 
					 | 
				
			||||||
          <div className="flex items-center justify-between">
 | 
					 | 
				
			||||||
            <div>
 | 
					 | 
				
			||||||
              <p className="text-sm font-medium text-gray-600">最終更新</p>
 | 
					 | 
				
			||||||
              <p className="text-lg font-bold text-gray-900 mt-2">
 | 
					 | 
				
			||||||
                {stats?.lastUpdated
 | 
					 | 
				
			||||||
                  ? new Date(stats.lastUpdated).toLocaleDateString("ja-JP")
 | 
					 | 
				
			||||||
                  : "未取得"}
 | 
					 | 
				
			||||||
              </p>
 | 
					 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
            <div className="w-8 h-8 rounded-lg flex items-center justify-center text-white bg-orange-600">
 | 
					 | 
				
			||||||
              <span className="text-sm">🕒</span>
 | 
					 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
          </div>
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
      </div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      {/* Action Cards */}
 | 
					 | 
				
			||||||
      <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
 | 
					 | 
				
			||||||
        {/* Manual Batch Execution */}
 | 
					 | 
				
			||||||
        <div className="bg-blue-600 rounded-lg shadow p-6 text-white">
 | 
					 | 
				
			||||||
          <h3 className="text-lg font-semibold mb-2">手動バッチ実行</h3>
 | 
					 | 
				
			||||||
          <p className="text-blue-100 text-sm mb-4">
 | 
					 | 
				
			||||||
            新しい記事をすぐにチェックしてポッドキャストを生成します。
 | 
					 | 
				
			||||||
          </p>
 | 
					 | 
				
			||||||
          <button
 | 
					 | 
				
			||||||
            onClick={triggerBatchProcess}
 | 
					 | 
				
			||||||
            className="bg-white/20 hover:bg-white/30 text-white font-medium py-2 px-4 rounded transition-colors"
 | 
					 | 
				
			||||||
          >
 | 
					 | 
				
			||||||
            実行
 | 
					 | 
				
			||||||
          </button>
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        {/* System Status */}
 | 
					 | 
				
			||||||
        <div className="bg-white rounded-lg shadow p-6 border border-gray-200">
 | 
					 | 
				
			||||||
          <h3 className="text-lg font-semibold text-gray-900 mb-4">
 | 
					 | 
				
			||||||
            システム状態
 | 
					 | 
				
			||||||
          </h3>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
          <div className="space-y-3">
 | 
					 | 
				
			||||||
            <div className="flex items-center space-x-3 p-3 rounded-lg bg-green-50">
 | 
					 | 
				
			||||||
              <div className="w-2 h-2 bg-green-500 rounded-full"></div>
 | 
					 | 
				
			||||||
              <div>
 | 
					 | 
				
			||||||
                <span className="text-sm font-medium text-green-800">
 | 
					 | 
				
			||||||
                  自動バッチ処理
 | 
					 | 
				
			||||||
                </span>
 | 
					 | 
				
			||||||
                <p className="text-xs text-green-600">6時間間隔で実行中</p>
 | 
					 | 
				
			||||||
              </div>
 | 
					 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            <div className="flex items-center space-x-3 p-3 rounded-lg bg-blue-50">
 | 
					 | 
				
			||||||
              <div className="w-2 h-2 bg-blue-500 rounded-full"></div>
 | 
					 | 
				
			||||||
              <div>
 | 
					 | 
				
			||||||
                <span className="text-sm font-medium text-blue-800">
 | 
					 | 
				
			||||||
                  AI音声生成
 | 
					 | 
				
			||||||
                </span>
 | 
					 | 
				
			||||||
                <p className="text-xs text-blue-600">VOICEVOX連携済み</p>
 | 
					 | 
				
			||||||
              </div>
 | 
					 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
          </div>
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
      </div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      {/* Recent Episodes */}
 | 
					 | 
				
			||||||
      <div className="bg-white rounded-lg shadow border border-gray-200">
 | 
					 | 
				
			||||||
        <div className="px-6 py-4 border-b border-gray-200">
 | 
					 | 
				
			||||||
          <div className="flex items-center justify-between">
 | 
					 | 
				
			||||||
            <h3 className="text-lg font-semibold text-gray-900">
 | 
					 | 
				
			||||||
              最新エピソード
 | 
					 | 
				
			||||||
            </h3>
 | 
					 | 
				
			||||||
            <span className="text-sm text-gray-500">
 | 
					 | 
				
			||||||
              {recentEpisodes.length} エピソード
 | 
					 | 
				
			||||||
            </span>
 | 
					 | 
				
			||||||
          </div>
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        <div className="p-6">
 | 
					 | 
				
			||||||
          {recentEpisodes.length === 0 ? (
 | 
					 | 
				
			||||||
            <div className="text-center py-12">
 | 
					 | 
				
			||||||
              <div className="text-4xl mb-4">🎧</div>
 | 
					 | 
				
			||||||
              <h4 className="text-lg font-semibold text-gray-900 mb-2">
 | 
					 | 
				
			||||||
                まだエピソードがありません
 | 
					 | 
				
			||||||
              </h4>
 | 
					 | 
				
			||||||
              <p className="text-gray-500">
 | 
					 | 
				
			||||||
                フィードを追加してバッチ処理を実行してください。
 | 
					 | 
				
			||||||
              </p>
 | 
					 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
          ) : (
 | 
					 | 
				
			||||||
            <div className="space-y-4">
 | 
					 | 
				
			||||||
              {recentEpisodes.map((episode) => (
 | 
					 | 
				
			||||||
                <div
 | 
					 | 
				
			||||||
                  key={episode.id}
 | 
					 | 
				
			||||||
                  className="flex items-start space-x-4 p-4 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
 | 
					 | 
				
			||||||
                >
 | 
					 | 
				
			||||||
                  <div className="w-8 h-8 rounded-lg bg-blue-600 flex items-center justify-center flex-shrink-0">
 | 
					 | 
				
			||||||
                    <span className="text-white text-xs">🎵</span>
 | 
					 | 
				
			||||||
                  </div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                  <div className="flex-1 min-w-0">
 | 
					 | 
				
			||||||
                    <h4 className="text-sm font-medium text-gray-900 line-clamp-2">
 | 
					 | 
				
			||||||
                      {episode.title}
 | 
					 | 
				
			||||||
                    </h4>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                    <div className="flex items-center space-x-3 text-xs text-gray-500 mt-1">
 | 
					 | 
				
			||||||
                      <span>{episode.feed?.title}</span>
 | 
					 | 
				
			||||||
                      <span>•</span>
 | 
					 | 
				
			||||||
                      <span>
 | 
					 | 
				
			||||||
                        {new Date(episode.createdAt).toLocaleDateString(
 | 
					 | 
				
			||||||
                          "ja-JP",
 | 
					 | 
				
			||||||
                        )}
 | 
					 | 
				
			||||||
                      </span>
 | 
					 | 
				
			||||||
                    </div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                    {episode.article && (
 | 
					 | 
				
			||||||
                      <a
 | 
					 | 
				
			||||||
                        href={episode.article.link}
 | 
					 | 
				
			||||||
                        target="_blank"
 | 
					 | 
				
			||||||
                        rel="noopener noreferrer"
 | 
					 | 
				
			||||||
                        className="inline-flex items-center space-x-1 text-xs text-blue-600 hover:text-blue-800 mt-2"
 | 
					 | 
				
			||||||
                      >
 | 
					 | 
				
			||||||
                        <span>元記事を読む</span>
 | 
					 | 
				
			||||||
                      </a>
 | 
					 | 
				
			||||||
                    )}
 | 
					 | 
				
			||||||
                  </div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                  <span className="inline-flex items-center px-2 py-1 rounded-full text-xs bg-green-100 text-green-800">
 | 
					 | 
				
			||||||
                    生成済み
 | 
					 | 
				
			||||||
                  </span>
 | 
					 | 
				
			||||||
                </div>
 | 
					 | 
				
			||||||
              ))}
 | 
					 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
          )}
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
      </div>
 | 
					 | 
				
			||||||
    </div>
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
							
								
								
									
										138
									
								
								frontend/src/components/EpisodeList.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										138
									
								
								frontend/src/components/EpisodeList.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,138 @@
 | 
				
			|||||||
 | 
					import { useState, useEffect } from 'react'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					interface Episode {
 | 
				
			||||||
 | 
					  id: string
 | 
				
			||||||
 | 
					  title: string
 | 
				
			||||||
 | 
					  audioPath: string
 | 
				
			||||||
 | 
					  createdAt: string
 | 
				
			||||||
 | 
					  article?: {
 | 
				
			||||||
 | 
					    link: string
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  feed?: {
 | 
				
			||||||
 | 
					    title: string
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					function EpisodeList() {
 | 
				
			||||||
 | 
					  const [episodes, setEpisodes] = useState<Episode[]>([])
 | 
				
			||||||
 | 
					  const [loading, setLoading] = useState(true)
 | 
				
			||||||
 | 
					  const [error, setError] = useState<string | null>(null)
 | 
				
			||||||
 | 
					  const [currentAudio, setCurrentAudio] = useState<string | null>(null)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  useEffect(() => {
 | 
				
			||||||
 | 
					    fetchEpisodes()
 | 
				
			||||||
 | 
					  }, [])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const fetchEpisodes = async () => {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      setLoading(true)
 | 
				
			||||||
 | 
					      const response = await fetch('/api/episodes')
 | 
				
			||||||
 | 
					      if (!response.ok) throw new Error('エピソードの取得に失敗しました')
 | 
				
			||||||
 | 
					      const data = await response.json()
 | 
				
			||||||
 | 
					      setEpisodes(data)
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      setError(err instanceof Error ? err.message : 'エラーが発生しました')
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setLoading(false)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const formatDate = (dateString: string) => {
 | 
				
			||||||
 | 
					    return new Date(dateString).toLocaleString('ja-JP')
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const playAudio = (audioPath: string) => {
 | 
				
			||||||
 | 
					    if (currentAudio) {
 | 
				
			||||||
 | 
					      const currentPlayer = document.getElementById(currentAudio) as HTMLAudioElement
 | 
				
			||||||
 | 
					      if (currentPlayer) {
 | 
				
			||||||
 | 
					        currentPlayer.pause()
 | 
				
			||||||
 | 
					        currentPlayer.currentTime = 0
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    setCurrentAudio(audioPath)
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (loading) {
 | 
				
			||||||
 | 
					    return <div className="loading">読み込み中...</div>
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (error) {
 | 
				
			||||||
 | 
					    return <div className="error">{error}</div>
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (episodes.length === 0) {
 | 
				
			||||||
 | 
					    return (
 | 
				
			||||||
 | 
					      <div className="empty-state">
 | 
				
			||||||
 | 
					        <p>エピソードがありません</p>
 | 
				
			||||||
 | 
					        <p>フィード管理でRSSフィードを追加してください</p>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <div>
 | 
				
			||||||
 | 
					      <div style={{ marginBottom: '20px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
 | 
				
			||||||
 | 
					        <h2>エピソード一覧 ({episodes.length}件)</h2>
 | 
				
			||||||
 | 
					        <button className="btn btn-secondary" onClick={fetchEpisodes}>
 | 
				
			||||||
 | 
					          更新
 | 
				
			||||||
 | 
					        </button>
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      <table className="table">
 | 
				
			||||||
 | 
					        <thead>
 | 
				
			||||||
 | 
					          <tr>
 | 
				
			||||||
 | 
					            <th style={{ width: '40%' }}>タイトル</th>
 | 
				
			||||||
 | 
					            <th style={{ width: '20%' }}>フィード</th>
 | 
				
			||||||
 | 
					            <th style={{ width: '20%' }}>作成日時</th>
 | 
				
			||||||
 | 
					            <th style={{ width: '20%' }}>操作</th>
 | 
				
			||||||
 | 
					          </tr>
 | 
				
			||||||
 | 
					        </thead>
 | 
				
			||||||
 | 
					        <tbody>
 | 
				
			||||||
 | 
					          {episodes.map((episode) => (
 | 
				
			||||||
 | 
					            <tr key={episode.id}>
 | 
				
			||||||
 | 
					              <td>
 | 
				
			||||||
 | 
					                <div style={{ marginBottom: '8px' }}>
 | 
				
			||||||
 | 
					                  <strong>{episode.title}</strong>
 | 
				
			||||||
 | 
					                </div>
 | 
				
			||||||
 | 
					                {episode.article?.link && (
 | 
				
			||||||
 | 
					                  <a 
 | 
				
			||||||
 | 
					                    href={episode.article.link} 
 | 
				
			||||||
 | 
					                    target="_blank" 
 | 
				
			||||||
 | 
					                    rel="noopener noreferrer"
 | 
				
			||||||
 | 
					                    style={{ fontSize: '12px', color: '#666' }}
 | 
				
			||||||
 | 
					                  >
 | 
				
			||||||
 | 
					                    元記事を見る
 | 
				
			||||||
 | 
					                  </a>
 | 
				
			||||||
 | 
					                )}
 | 
				
			||||||
 | 
					              </td>
 | 
				
			||||||
 | 
					              <td>{episode.feed?.title || '不明'}</td>
 | 
				
			||||||
 | 
					              <td>{formatDate(episode.createdAt)}</td>
 | 
				
			||||||
 | 
					              <td>
 | 
				
			||||||
 | 
					                <button 
 | 
				
			||||||
 | 
					                  className="btn btn-primary"
 | 
				
			||||||
 | 
					                  onClick={() => playAudio(episode.audioPath)}
 | 
				
			||||||
 | 
					                  style={{ marginBottom: '8px' }}
 | 
				
			||||||
 | 
					                >
 | 
				
			||||||
 | 
					                  再生
 | 
				
			||||||
 | 
					                </button>
 | 
				
			||||||
 | 
					                {currentAudio === episode.audioPath && (
 | 
				
			||||||
 | 
					                  <div>
 | 
				
			||||||
 | 
					                    <audio
 | 
				
			||||||
 | 
					                      id={episode.audioPath}
 | 
				
			||||||
 | 
					                      controls
 | 
				
			||||||
 | 
					                      className="audio-player"
 | 
				
			||||||
 | 
					                      src={`/podcast_audio/${episode.audioPath}`}
 | 
				
			||||||
 | 
					                      onEnded={() => setCurrentAudio(null)}
 | 
				
			||||||
 | 
					                    />
 | 
				
			||||||
 | 
					                  </div>
 | 
				
			||||||
 | 
					                )}
 | 
				
			||||||
 | 
					              </td>
 | 
				
			||||||
 | 
					            </tr>
 | 
				
			||||||
 | 
					          ))}
 | 
				
			||||||
 | 
					        </tbody>
 | 
				
			||||||
 | 
					      </table>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default EpisodeList
 | 
				
			||||||
@@ -1,327 +0,0 @@
 | 
				
			|||||||
import { useEffect, useState, useRef } from "react";
 | 
					 | 
				
			||||||
import React from "react";
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
interface Episode {
 | 
					 | 
				
			||||||
  id: string;
 | 
					 | 
				
			||||||
  title: string;
 | 
					 | 
				
			||||||
  description?: string;
 | 
					 | 
				
			||||||
  audioPath: 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 [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, isPlaying, audioRef, currentTime, duration]);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const fetchEpisodes = async () => {
 | 
					 | 
				
			||||||
    try {
 | 
					 | 
				
			||||||
      const response = await fetch("/api/episodes");
 | 
					 | 
				
			||||||
      if (!response.ok) {
 | 
					 | 
				
			||||||
        throw new Error("エピソードの取得に失敗しました");
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
      const data = await response.json();
 | 
					 | 
				
			||||||
      setEpisodes(data);
 | 
					 | 
				
			||||||
      setLoading(false);
 | 
					 | 
				
			||||||
    } catch (err) {
 | 
					 | 
				
			||||||
      setError(err instanceof Error ? err.message : "エラーが発生しました");
 | 
					 | 
				
			||||||
      setLoading(false);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const handlePlay = (episode: Episode) => {
 | 
					 | 
				
			||||||
    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);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  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="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-6">
 | 
					 | 
				
			||||||
      {/* Search */}
 | 
					 | 
				
			||||||
      <div className="relative">
 | 
					 | 
				
			||||||
        <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="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 ? "一時停止" : "再生"}
 | 
					 | 
				
			||||||
            ></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-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>
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@@ -1,292 +0,0 @@
 | 
				
			|||||||
import { useEffect, useState } from "react";
 | 
					 | 
				
			||||||
import React from "react";
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
interface FeedItem {
 | 
					 | 
				
			||||||
  id: string;
 | 
					 | 
				
			||||||
  title: string;
 | 
					 | 
				
			||||||
  link: string;
 | 
					 | 
				
			||||||
  pubDate: string;
 | 
					 | 
				
			||||||
  contentSnippet?: string;
 | 
					 | 
				
			||||||
  source?: {
 | 
					 | 
				
			||||||
    title?: string;
 | 
					 | 
				
			||||||
    url?: string;
 | 
					 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
  category?: string;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
interface FeedListProps {
 | 
					 | 
				
			||||||
  searchTerm?: string;
 | 
					 | 
				
			||||||
  categoryFilter?: string;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export default function FeedList({
 | 
					 | 
				
			||||||
  searchTerm = "",
 | 
					 | 
				
			||||||
  categoryFilter = "",
 | 
					 | 
				
			||||||
}: FeedListProps = {}) {
 | 
					 | 
				
			||||||
  const [feeds, setFeeds] = useState<FeedItem[]>([]);
 | 
					 | 
				
			||||||
  const [loading, setLoading] = useState(true);
 | 
					 | 
				
			||||||
  const [error, setError] = useState<string | null>(null);
 | 
					 | 
				
			||||||
  const [sortBy, setSortBy] = useState<"date" | "title">("date");
 | 
					 | 
				
			||||||
  const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  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 filteredAndSortedFeeds = feeds
 | 
					 | 
				
			||||||
    .filter((feed) => {
 | 
					 | 
				
			||||||
      const matchesSearch =
 | 
					 | 
				
			||||||
        !searchTerm ||
 | 
					 | 
				
			||||||
        feed.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
 | 
					 | 
				
			||||||
        feed.contentSnippet?.toLowerCase().includes(searchTerm.toLowerCase()) ||
 | 
					 | 
				
			||||||
        feed.source?.title?.toLowerCase().includes(searchTerm.toLowerCase());
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      const matchesCategory =
 | 
					 | 
				
			||||||
        !categoryFilter || feed.category === categoryFilter;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      return matchesSearch && matchesCategory;
 | 
					 | 
				
			||||||
    })
 | 
					 | 
				
			||||||
    .sort((a, b) => {
 | 
					 | 
				
			||||||
      const multiplier = sortOrder === "asc" ? 1 : -1;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      if (sortBy === "date") {
 | 
					 | 
				
			||||||
        return (
 | 
					 | 
				
			||||||
          (new Date(a.pubDate).getTime() - new Date(b.pubDate).getTime()) *
 | 
					 | 
				
			||||||
          multiplier
 | 
					 | 
				
			||||||
        );
 | 
					 | 
				
			||||||
      } else {
 | 
					 | 
				
			||||||
        return a.title.localeCompare(b.title) * multiplier;
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const handleSort = (field: "date" | "title") => {
 | 
					 | 
				
			||||||
    if (sortBy === field) {
 | 
					 | 
				
			||||||
      setSortOrder(sortOrder === "asc" ? "desc" : "asc");
 | 
					 | 
				
			||||||
    } else {
 | 
					 | 
				
			||||||
      setSortBy(field);
 | 
					 | 
				
			||||||
      setSortOrder("desc");
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const formatDate = (dateString: string) => {
 | 
					 | 
				
			||||||
    try {
 | 
					 | 
				
			||||||
      return new Date(dateString).toLocaleDateString("ja-JP", {
 | 
					 | 
				
			||||||
        year: "numeric",
 | 
					 | 
				
			||||||
        month: "short",
 | 
					 | 
				
			||||||
        day: "numeric",
 | 
					 | 
				
			||||||
        hour: "2-digit",
 | 
					 | 
				
			||||||
        minute: "2-digit",
 | 
					 | 
				
			||||||
      });
 | 
					 | 
				
			||||||
    } catch {
 | 
					 | 
				
			||||||
      return dateString;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  if (loading) {
 | 
					 | 
				
			||||||
    return (
 | 
					 | 
				
			||||||
      <div className="space-y-6">
 | 
					 | 
				
			||||||
        {[...Array(5)].map((_, i) => (
 | 
					 | 
				
			||||||
          <div
 | 
					 | 
				
			||||||
            key={i}
 | 
					 | 
				
			||||||
            className="glass-effect rounded-3xl p-6 border border-white/20 animate-pulse"
 | 
					 | 
				
			||||||
          >
 | 
					 | 
				
			||||||
            <div className="flex items-start space-x-4">
 | 
					 | 
				
			||||||
              <div className="w-16 h-16 bg-slate-200 rounded-2xl"></div>
 | 
					 | 
				
			||||||
              <div className="flex-1 space-y-3">
 | 
					 | 
				
			||||||
                <div className="h-5 bg-slate-200 rounded-lg w-3/4"></div>
 | 
					 | 
				
			||||||
                <div className="h-4 bg-slate-200 rounded-lg w-1/2"></div>
 | 
					 | 
				
			||||||
                <div className="h-4 bg-slate-200 rounded-lg w-full"></div>
 | 
					 | 
				
			||||||
                <div className="h-4 bg-slate-200 rounded-lg w-2/3"></div>
 | 
					 | 
				
			||||||
              </div>
 | 
					 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
          </div>
 | 
					 | 
				
			||||||
        ))}
 | 
					 | 
				
			||||||
      </div>
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  if (error) {
 | 
					 | 
				
			||||||
    return (
 | 
					 | 
				
			||||||
      <div className="glass-effect rounded-3xl p-8 border border-red-200 bg-red-50">
 | 
					 | 
				
			||||||
        <div className="flex items-center space-x-4">
 | 
					 | 
				
			||||||
          <div className="w-12 h-12 rounded-2xl bg-red-100 flex items-center justify-center">
 | 
					 | 
				
			||||||
            ⚠️
 | 
					 | 
				
			||||||
          </div>
 | 
					 | 
				
			||||||
          <div>
 | 
					 | 
				
			||||||
            <h3 className="text-lg font-bold text-red-800">
 | 
					 | 
				
			||||||
              エラーが発生しました
 | 
					 | 
				
			||||||
            </h3>
 | 
					 | 
				
			||||||
            <p className="text-red-700">{error}</p>
 | 
					 | 
				
			||||||
            <button onClick={fetchFeeds} className="mt-3 btn-primary text-sm">
 | 
					 | 
				
			||||||
              再読み込み
 | 
					 | 
				
			||||||
            </button>
 | 
					 | 
				
			||||||
          </div>
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
      </div>
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  return (
 | 
					 | 
				
			||||||
    <div className="space-y-6">
 | 
					 | 
				
			||||||
      {/* Sort Controls */}
 | 
					 | 
				
			||||||
      <div className="glass-effect rounded-2xl p-4 border border-white/20">
 | 
					 | 
				
			||||||
        <div className="flex items-center space-x-4">
 | 
					 | 
				
			||||||
          <span className="text-sm font-semibold text-slate-700">
 | 
					 | 
				
			||||||
            並び替え:
 | 
					 | 
				
			||||||
          </span>
 | 
					 | 
				
			||||||
          <div className="flex space-x-2">
 | 
					 | 
				
			||||||
            <button
 | 
					 | 
				
			||||||
              onClick={() => handleSort("date")}
 | 
					 | 
				
			||||||
              className={`flex items-center space-x-2 px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200 ${
 | 
					 | 
				
			||||||
                sortBy === "date"
 | 
					 | 
				
			||||||
                  ? "bg-blue-100 text-blue-800 border border-blue-200"
 | 
					 | 
				
			||||||
                  : "text-slate-600 hover:text-slate-800 hover:bg-slate-100"
 | 
					 | 
				
			||||||
              }`}
 | 
					 | 
				
			||||||
            >
 | 
					 | 
				
			||||||
              <span>日時</span>
 | 
					 | 
				
			||||||
              {sortBy === "date" && (
 | 
					 | 
				
			||||||
                <span>{sortOrder === "asc" ? "↑" : "↓"}</span>
 | 
					 | 
				
			||||||
              )}
 | 
					 | 
				
			||||||
            </button>
 | 
					 | 
				
			||||||
            <button
 | 
					 | 
				
			||||||
              onClick={() => handleSort("title")}
 | 
					 | 
				
			||||||
              className={`flex items-center space-x-2 px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200 ${
 | 
					 | 
				
			||||||
                sortBy === "title"
 | 
					 | 
				
			||||||
                  ? "bg-blue-100 text-blue-800 border border-blue-200"
 | 
					 | 
				
			||||||
                  : "text-slate-600 hover:text-slate-800 hover:bg-slate-100"
 | 
					 | 
				
			||||||
              }`}
 | 
					 | 
				
			||||||
            >
 | 
					 | 
				
			||||||
              <span>タイトル</span>
 | 
					 | 
				
			||||||
              {sortBy === "title" && (
 | 
					 | 
				
			||||||
                <span>{sortOrder === "asc" ? "↑" : "↓"}</span>
 | 
					 | 
				
			||||||
              )}
 | 
					 | 
				
			||||||
            </button>
 | 
					 | 
				
			||||||
          </div>
 | 
					 | 
				
			||||||
          <div className="text-sm text-slate-500 ml-auto">
 | 
					 | 
				
			||||||
            {filteredAndSortedFeeds.length} / {feeds.length} 件表示中
 | 
					 | 
				
			||||||
          </div>
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
      </div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      {/* Feed Cards */}
 | 
					 | 
				
			||||||
      {filteredAndSortedFeeds.length === 0 ? (
 | 
					 | 
				
			||||||
        <div className="text-center py-20">
 | 
					 | 
				
			||||||
          <div
 | 
					 | 
				
			||||||
            className="w-24 h-24 rounded-3xl flex items-center justify-center mx-auto mb-6 shadow-lg"
 | 
					 | 
				
			||||||
            style={{ background: "linear-gradient(135deg, #e2e8f0, #cbd5e1)" }}
 | 
					 | 
				
			||||||
          >
 | 
					 | 
				
			||||||
            <span role="img" aria-hidden="true" className="text-4xl">
 | 
					 | 
				
			||||||
              📰
 | 
					 | 
				
			||||||
            </span>
 | 
					 | 
				
			||||||
          </div>
 | 
					 | 
				
			||||||
          <h3 className="text-xl font-bold text-slate-700 mb-2">
 | 
					 | 
				
			||||||
            {searchTerm || categoryFilter
 | 
					 | 
				
			||||||
              ? "検索結果がありません"
 | 
					 | 
				
			||||||
              : "フィードがありません"}
 | 
					 | 
				
			||||||
          </h3>
 | 
					 | 
				
			||||||
          <p className="text-slate-500 max-w-md mx-auto">
 | 
					 | 
				
			||||||
            {searchTerm || categoryFilter
 | 
					 | 
				
			||||||
              ? "別のキーワードやカテゴリで検索してみてください"
 | 
					 | 
				
			||||||
              : "RSSフィードを追加してバッチ処理を実行してください"}
 | 
					 | 
				
			||||||
          </p>
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
      ) : (
 | 
					 | 
				
			||||||
        <div className="space-y-4">
 | 
					 | 
				
			||||||
          {filteredAndSortedFeeds.map((feed, index) => (
 | 
					 | 
				
			||||||
            <article
 | 
					 | 
				
			||||||
              key={feed.id}
 | 
					 | 
				
			||||||
              className="group glass-effect rounded-3xl border border-white/20 hover:border-white/40 hover:shadow-2xl transition-all duration-300 overflow-hidden"
 | 
					 | 
				
			||||||
              style={{
 | 
					 | 
				
			||||||
                animationDelay: `${index * 0.05}s`,
 | 
					 | 
				
			||||||
              }}
 | 
					 | 
				
			||||||
            >
 | 
					 | 
				
			||||||
              <div className="p-6">
 | 
					 | 
				
			||||||
                <div className="flex items-start space-x-5">
 | 
					 | 
				
			||||||
                  {/* Article Icon */}
 | 
					 | 
				
			||||||
                  <div className="flex-shrink-0">
 | 
					 | 
				
			||||||
                    <div
 | 
					 | 
				
			||||||
                      className="w-16 h-16 rounded-2xl flex items-center justify-center shadow-lg group-hover:shadow-xl transition-all duration-300 group-hover:scale-110"
 | 
					 | 
				
			||||||
                      style={{ background: "var(--gradient-primary)" }}
 | 
					 | 
				
			||||||
                    >
 | 
					 | 
				
			||||||
                      <span role="img" aria-hidden="true" className="text-2xl">
 | 
					 | 
				
			||||||
                        📰
 | 
					 | 
				
			||||||
                      </span>
 | 
					 | 
				
			||||||
                    </div>
 | 
					 | 
				
			||||||
                  </div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                  {/* Article Content */}
 | 
					 | 
				
			||||||
                  <div className="flex-1 min-w-0">
 | 
					 | 
				
			||||||
                    {/* Header */}
 | 
					 | 
				
			||||||
                    <div className="flex items-start justify-between mb-3">
 | 
					 | 
				
			||||||
                      <div className="flex-1">
 | 
					 | 
				
			||||||
                        <h3 className="text-lg font-bold text-slate-800 line-clamp-2 group-hover:text-slate-900 transition-colors duration-200">
 | 
					 | 
				
			||||||
                          {feed.title}
 | 
					 | 
				
			||||||
                        </h3>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                        {/* Meta Info */}
 | 
					 | 
				
			||||||
                        <div className="flex items-center space-x-3 mt-2 text-sm text-slate-600">
 | 
					 | 
				
			||||||
                          {feed.source?.title && (
 | 
					 | 
				
			||||||
                            <span className="font-medium">
 | 
					 | 
				
			||||||
                              {feed.source.title}
 | 
					 | 
				
			||||||
                            </span>
 | 
					 | 
				
			||||||
                          )}
 | 
					 | 
				
			||||||
                          <span className="text-slate-400">•</span>
 | 
					 | 
				
			||||||
                          <span>{formatDate(feed.pubDate)}</span>
 | 
					 | 
				
			||||||
                        </div>
 | 
					 | 
				
			||||||
                      </div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                      {/* Category Badge */}
 | 
					 | 
				
			||||||
                      {feed.category && (
 | 
					 | 
				
			||||||
                        <span className="ml-4 inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800 border border-blue-200">
 | 
					 | 
				
			||||||
                          {feed.category}
 | 
					 | 
				
			||||||
                        </span>
 | 
					 | 
				
			||||||
                      )}
 | 
					 | 
				
			||||||
                    </div>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                    {/* Content Snippet */}
 | 
					 | 
				
			||||||
                    {feed.contentSnippet && (
 | 
					 | 
				
			||||||
                      <p className="text-slate-600 leading-relaxed line-clamp-3 mb-4">
 | 
					 | 
				
			||||||
                        {feed.contentSnippet}
 | 
					 | 
				
			||||||
                      </p>
 | 
					 | 
				
			||||||
                    )}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                    {/* Actions */}
 | 
					 | 
				
			||||||
                    <div className="flex items-center justify-between">
 | 
					 | 
				
			||||||
                      <a
 | 
					 | 
				
			||||||
                        href={feed.link}
 | 
					 | 
				
			||||||
                        target="_blank"
 | 
					 | 
				
			||||||
                        rel="noopener noreferrer"
 | 
					 | 
				
			||||||
                        className="text-sm font-semibold text-blue-600 hover:text-blue-800 transition-colors duration-200"
 | 
					 | 
				
			||||||
                      >
 | 
					 | 
				
			||||||
                        元記事を読む →
 | 
					 | 
				
			||||||
                      </a>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                      <div className="text-xs text-slate-400">#{index + 1}</div>
 | 
					 | 
				
			||||||
                    </div>
 | 
					 | 
				
			||||||
                  </div>
 | 
					 | 
				
			||||||
                </div>
 | 
					 | 
				
			||||||
              </div>
 | 
					 | 
				
			||||||
            </article>
 | 
					 | 
				
			||||||
          ))}
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
      )}
 | 
					 | 
				
			||||||
    </div>
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
@@ -1,257 +1,205 @@
 | 
				
			|||||||
import { useEffect, useState } from "react";
 | 
					import { useState, useEffect } from 'react'
 | 
				
			||||||
import React from "react";
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
interface Feed {
 | 
					interface Feed {
 | 
				
			||||||
  id: string;
 | 
					  id: string
 | 
				
			||||||
  url: string;
 | 
					  url: string
 | 
				
			||||||
  title?: string;
 | 
					  title?: string
 | 
				
			||||||
  description?: string;
 | 
					  description?: string
 | 
				
			||||||
  lastUpdated?: string;
 | 
					  active: boolean
 | 
				
			||||||
  createdAt: string;
 | 
					  lastUpdated?: string
 | 
				
			||||||
  active: boolean;
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default function FeedManager() {
 | 
					function FeedManager() {
 | 
				
			||||||
  const [feeds, setFeeds] = useState<Feed[]>([]);
 | 
					  const [feeds, setFeeds] = useState<Feed[]>([])
 | 
				
			||||||
  const [loading, setLoading] = useState(true);
 | 
					  const [loading, setLoading] = useState(true)
 | 
				
			||||||
  const [error, setError] = useState<string | null>(null);
 | 
					  const [error, setError] = useState<string | null>(null)
 | 
				
			||||||
  const [newFeedUrl, setNewFeedUrl] = useState("");
 | 
					  const [success, setSuccess] = useState<string | null>(null)
 | 
				
			||||||
  const [addingFeed, setAddingFeed] = useState(false);
 | 
					  const [newFeedUrl, setNewFeedUrl] = useState('')
 | 
				
			||||||
 | 
					  const [adding, setAdding] = useState(false)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  useEffect(() => {
 | 
					  useEffect(() => {
 | 
				
			||||||
    fetchFeeds();
 | 
					    fetchFeeds()
 | 
				
			||||||
  }, []);
 | 
					  }, [])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const fetchFeeds = async () => {
 | 
					  const fetchFeeds = async () => {
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
      setLoading(true);
 | 
					      setLoading(true)
 | 
				
			||||||
      const response = await fetch("/api/feeds");
 | 
					      const response = await fetch('/api/feeds')
 | 
				
			||||||
      if (!response.ok) {
 | 
					      if (!response.ok) throw new Error('フィードの取得に失敗しました')
 | 
				
			||||||
        throw new Error("フィードの取得に失敗しました");
 | 
					      const data = await response.json()
 | 
				
			||||||
      }
 | 
					      setFeeds(data)
 | 
				
			||||||
      const data = await response.json();
 | 
					 | 
				
			||||||
      setFeeds(data);
 | 
					 | 
				
			||||||
      setError(null);
 | 
					 | 
				
			||||||
    } catch (err) {
 | 
					    } catch (err) {
 | 
				
			||||||
      setError(err instanceof Error ? err.message : "エラーが発生しました");
 | 
					      setError(err instanceof Error ? err.message : 'エラーが発生しました')
 | 
				
			||||||
    } finally {
 | 
					    } finally {
 | 
				
			||||||
      setLoading(false);
 | 
					      setLoading(false)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const addFeed = async (e: React.FormEvent) => {
 | 
					  const addFeed = async (e: React.FormEvent) => {
 | 
				
			||||||
    e.preventDefault();
 | 
					    e.preventDefault()
 | 
				
			||||||
 | 
					    if (!newFeedUrl.trim()) return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (!newFeedUrl.trim()) {
 | 
					    try {
 | 
				
			||||||
      alert("フィードURLを入力してください");
 | 
					      setAdding(true)
 | 
				
			||||||
      return;
 | 
					      setError(null)
 | 
				
			||||||
 | 
					      setSuccess(null)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const response = await fetch('/api/feeds', {
 | 
				
			||||||
 | 
					        method: 'POST',
 | 
				
			||||||
 | 
					        headers: {
 | 
				
			||||||
 | 
					          'Content-Type': 'application/json',
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        body: JSON.stringify({ url: newFeedUrl.trim() }),
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (!response.ok) {
 | 
				
			||||||
 | 
					        const errorData = await response.json()
 | 
				
			||||||
 | 
					        throw new Error(errorData.error || 'フィードの追加に失敗しました')
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (!newFeedUrl.startsWith("http")) {
 | 
					      setSuccess('フィードを追加しました')
 | 
				
			||||||
      alert("有効なURLを入力してください");
 | 
					      setNewFeedUrl('')
 | 
				
			||||||
      return;
 | 
					      await fetchFeeds()
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      setError(err instanceof Error ? err.message : 'エラーが発生しました')
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      setAdding(false)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const deleteFeed = async (feedId: string) => {
 | 
				
			||||||
 | 
					    if (!confirm('このフィードを削除しますか?関連するエピソードも削除されます。')) {
 | 
				
			||||||
 | 
					      return
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    try {
 | 
					    try {
 | 
				
			||||||
      setAddingFeed(true);
 | 
					      setError(null)
 | 
				
			||||||
      const response = await fetch("/api/feeds", {
 | 
					      setSuccess(null)
 | 
				
			||||||
        method: "POST",
 | 
					 | 
				
			||||||
        headers: {
 | 
					 | 
				
			||||||
          "Content-Type": "application/json",
 | 
					 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
        body: JSON.stringify({ feedUrl: newFeedUrl }),
 | 
					 | 
				
			||||||
      });
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
      const result = await response.json();
 | 
					      const response = await fetch(`/api/feeds/${feedId}`, {
 | 
				
			||||||
 | 
					        method: 'DELETE',
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      if (response.ok) {
 | 
					      if (!response.ok) {
 | 
				
			||||||
        if (result.result === "EXISTS") {
 | 
					        const errorData = await response.json()
 | 
				
			||||||
          alert("このフィードは既に登録されています");
 | 
					        throw new Error(errorData.error || 'フィードの削除に失敗しました')
 | 
				
			||||||
        } else {
 | 
					 | 
				
			||||||
          alert("フィードが正常に追加されました");
 | 
					 | 
				
			||||||
          setNewFeedUrl("");
 | 
					 | 
				
			||||||
          fetchFeeds(); // Refresh the list
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      } else {
 | 
					 | 
				
			||||||
        alert(result.error || "フィードの追加に失敗しました");
 | 
					 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      setSuccess('フィードを削除しました')
 | 
				
			||||||
 | 
					      await fetchFeeds()
 | 
				
			||||||
    } catch (err) {
 | 
					    } catch (err) {
 | 
				
			||||||
      alert("エラーが発生しました");
 | 
					      setError(err instanceof Error ? err.message : 'エラーが発生しました')
 | 
				
			||||||
      console.error("Feed addition error:", err);
 | 
					    }
 | 
				
			||||||
    } finally {
 | 
					  }
 | 
				
			||||||
      setAddingFeed(false);
 | 
					
 | 
				
			||||||
 | 
					  const toggleFeed = async (feedId: string, active: boolean) => {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      setError(null)
 | 
				
			||||||
 | 
					      setSuccess(null)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const response = await fetch(`/api/feeds/${feedId}/toggle`, {
 | 
				
			||||||
 | 
					        method: 'PATCH',
 | 
				
			||||||
 | 
					        headers: {
 | 
				
			||||||
 | 
					          'Content-Type': 'application/json',
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					        body: JSON.stringify({ active }),
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (!response.ok) {
 | 
				
			||||||
 | 
					        const errorData = await response.json()
 | 
				
			||||||
 | 
					        throw new Error(errorData.error || 'フィードの状態変更に失敗しました')
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      setSuccess(`フィードを${active ? '有効' : '無効'}にしました`)
 | 
				
			||||||
 | 
					      await fetchFeeds()
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      setError(err instanceof Error ? err.message : 'エラーが発生しました')
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const formatDate = (dateString?: string) => {
 | 
				
			||||||
 | 
					    if (!dateString) return '未更新'
 | 
				
			||||||
 | 
					    return new Date(dateString).toLocaleString('ja-JP')
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  if (loading) {
 | 
					  if (loading) {
 | 
				
			||||||
    return (
 | 
					    return <div className="loading">読み込み中...</div>
 | 
				
			||||||
      <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 (
 | 
					  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>
 | 
					    <div>
 | 
				
			||||||
            <label
 | 
					      {error && <div className="error">{error}</div>}
 | 
				
			||||||
              htmlFor="feedUrl"
 | 
					      {success && <div className="success">{success}</div>}
 | 
				
			||||||
              className="block text-sm font-medium text-gray-700 mb-2"
 | 
					
 | 
				
			||||||
            >
 | 
					      <div style={{ marginBottom: '30px' }}>
 | 
				
			||||||
              RSS フィード URL
 | 
					        <h2>新しいフィードを追加</h2>
 | 
				
			||||||
            </label>
 | 
					        <form onSubmit={addFeed}>
 | 
				
			||||||
            <div className="flex space-x-3">
 | 
					          <div className="form-group">
 | 
				
			||||||
 | 
					            <label className="form-label">RSS フィード URL</label>
 | 
				
			||||||
            <input
 | 
					            <input
 | 
				
			||||||
              type="url"
 | 
					              type="url"
 | 
				
			||||||
                id="feedUrl"
 | 
					              className="form-input"
 | 
				
			||||||
              value={newFeedUrl}
 | 
					              value={newFeedUrl}
 | 
				
			||||||
              onChange={(e) => setNewFeedUrl(e.target.value)}
 | 
					              onChange={(e) => setNewFeedUrl(e.target.value)}
 | 
				
			||||||
              placeholder="https://example.com/feed.xml"
 | 
					              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"
 | 
					              required
 | 
				
			||||||
                disabled={addingFeed}
 | 
					 | 
				
			||||||
                aria-describedby="feedUrl-help"
 | 
					 | 
				
			||||||
            />
 | 
					            />
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
          <button 
 | 
					          <button 
 | 
				
			||||||
            type="submit" 
 | 
					            type="submit" 
 | 
				
			||||||
                disabled={addingFeed || !newFeedUrl.trim()}
 | 
					            className="btn btn-primary"
 | 
				
			||||||
                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"
 | 
					            disabled={adding}
 | 
				
			||||||
          >
 | 
					          >
 | 
				
			||||||
                {addingFeed ? (
 | 
					            {adding ? '追加中...' : 'フィードを追加'}
 | 
				
			||||||
                  <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>
 | 
					          </button>
 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
            <p id="feedUrl-help" className="text-xs text-gray-500 mt-2">
 | 
					 | 
				
			||||||
              RSS または Atom フィードの URL を入力してください
 | 
					 | 
				
			||||||
            </p>
 | 
					 | 
				
			||||||
          </div>
 | 
					 | 
				
			||||||
        </form>
 | 
					        </form>
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      {/* Error Display */}
 | 
					 | 
				
			||||||
      {error && (
 | 
					 | 
				
			||||||
        <div className="bg-red-50 border border-red-200 rounded-lg p-4">
 | 
					 | 
				
			||||||
          <div className="flex items-center">
 | 
					 | 
				
			||||||
            <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>
 | 
				
			||||||
        <div className="flex items-center justify-between mb-4">
 | 
					        <h2>登録済みフィード ({feeds.length}件)</h2>
 | 
				
			||||||
          <h3 className="text-lg font-semibold text-gray-900">
 | 
					 | 
				
			||||||
            登録済みフィード
 | 
					 | 
				
			||||||
          </h3>
 | 
					 | 
				
			||||||
          <span className="text-sm text-gray-500">{feeds.length} フィード</span>
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
        
 | 
					        
 | 
				
			||||||
        {feeds.length === 0 ? (
 | 
					        {feeds.length === 0 ? (
 | 
				
			||||||
          <div className="text-center py-12 bg-gray-50 rounded-xl">
 | 
					          <div className="empty-state">
 | 
				
			||||||
            <div className="w-16 h-16 bg-gray-200 rounded-full flex items-center justify-center mx-auto mb-4">
 | 
					            <p>登録されているフィードがありません</p>
 | 
				
			||||||
              <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>
 | 
				
			||||||
        ) : (
 | 
					        ) : (
 | 
				
			||||||
          <div className="grid gap-4">
 | 
					          <div>
 | 
				
			||||||
            {feeds.map((feed) => (
 | 
					            {feeds.map((feed) => (
 | 
				
			||||||
              <div
 | 
					              <div key={feed.id} className="feed-item">
 | 
				
			||||||
                key={feed.id}
 | 
					                <div className="feed-info">
 | 
				
			||||||
                className="bg-white border border-gray-200 rounded-xl p-6 hover:shadow-md transition-shadow duration-200"
 | 
					                  <div className="feed-title">
 | 
				
			||||||
              >
 | 
					                    {feed.title || 'タイトル不明'}
 | 
				
			||||||
                <div className="flex items-start justify-between">
 | 
					                    {!feed.active && (
 | 
				
			||||||
                  <div className="flex-1 min-w-0">
 | 
					                      <span style={{ 
 | 
				
			||||||
                    <div className="flex items-center space-x-3 mb-2">
 | 
					                        marginLeft: '8px', 
 | 
				
			||||||
                      <div
 | 
					                        padding: '2px 6px', 
 | 
				
			||||||
                        className={`w-3 h-3 rounded-full ${feed.active ? "bg-green-400" : "bg-gray-400"}`}
 | 
					                        backgroundColor: '#dc3545', 
 | 
				
			||||||
                      ></div>
 | 
					                        color: 'white', 
 | 
				
			||||||
                      <h4 className="text-lg font-medium text-gray-900 truncate">
 | 
					                        fontSize: '12px', 
 | 
				
			||||||
                        {feed.title || "タイトル未取得"}
 | 
					                        borderRadius: '3px' 
 | 
				
			||||||
                      </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>
 | 
					                      </span>
 | 
				
			||||||
                    )}
 | 
					                    )}
 | 
				
			||||||
                  </div>
 | 
					                  </div>
 | 
				
			||||||
 | 
					                  <div className="feed-url">{feed.url}</div>
 | 
				
			||||||
 | 
					                  <div style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
 | 
				
			||||||
 | 
					                    最終更新: {formatDate(feed.lastUpdated)}
 | 
				
			||||||
                  </div>
 | 
					                  </div>
 | 
				
			||||||
                </div>
 | 
					                </div>
 | 
				
			||||||
 | 
					                <div className="feed-actions">
 | 
				
			||||||
                  <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
 | 
					                  <button
 | 
				
			||||||
                      className="text-gray-400 hover:text-gray-600 p-1"
 | 
					                    className={`btn ${feed.active ? 'btn-secondary' : 'btn-primary'}`}
 | 
				
			||||||
                      title="設定"
 | 
					                    onClick={() => toggleFeed(feed.id, !feed.active)}
 | 
				
			||||||
                      aria-label={`${feed.title || feed.url}の設定`}
 | 
					                  >
 | 
				
			||||||
                    ></button>
 | 
					                    {feed.active ? '無効化' : '有効化'}
 | 
				
			||||||
                  </div>
 | 
					                  </button>
 | 
				
			||||||
 | 
					                  <button
 | 
				
			||||||
 | 
					                    className="btn btn-danger"
 | 
				
			||||||
 | 
					                    onClick={() => deleteFeed(feed.id)}
 | 
				
			||||||
 | 
					                  >
 | 
				
			||||||
 | 
					                    削除
 | 
				
			||||||
 | 
					                  </button>
 | 
				
			||||||
                </div>
 | 
					                </div>
 | 
				
			||||||
              </div>
 | 
					              </div>
 | 
				
			||||||
            ))}
 | 
					            ))}
 | 
				
			||||||
@@ -259,5 +207,7 @@ export default function FeedManager() {
 | 
				
			|||||||
        )}
 | 
					        )}
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
  );
 | 
					  )
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default FeedManager
 | 
				
			||||||
@@ -1,10 +1,10 @@
 | 
				
			|||||||
import { StrictMode } from "react";
 | 
					import { StrictMode } from 'react'
 | 
				
			||||||
import { createRoot } from "react-dom/client";
 | 
					import { createRoot } from 'react-dom/client'
 | 
				
			||||||
import App from "./App.tsx";
 | 
					import App from './App.tsx'
 | 
				
			||||||
import React from "react";
 | 
					import './styles.css'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
createRoot(document.getElementById("root")!).render(
 | 
					createRoot(document.getElementById('root')!).render(
 | 
				
			||||||
  <StrictMode>
 | 
					  <StrictMode>
 | 
				
			||||||
    <App />
 | 
					    <App />
 | 
				
			||||||
  </StrictMode>,
 | 
					  </StrictMode>,
 | 
				
			||||||
);
 | 
					)
 | 
				
			||||||
							
								
								
									
										225
									
								
								frontend/src/styles.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										225
									
								
								frontend/src/styles.css
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,225 @@
 | 
				
			|||||||
 | 
					* {
 | 
				
			||||||
 | 
					  margin: 0;
 | 
				
			||||||
 | 
					  padding: 0;
 | 
				
			||||||
 | 
					  box-sizing: border-box;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					body {
 | 
				
			||||||
 | 
					  font-family: system-ui, -apple-system, sans-serif;
 | 
				
			||||||
 | 
					  background-color: #f5f5f5;
 | 
				
			||||||
 | 
					  color: #333;
 | 
				
			||||||
 | 
					  line-height: 1.6;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.container {
 | 
				
			||||||
 | 
					  max-width: 1200px;
 | 
				
			||||||
 | 
					  margin: 0 auto;
 | 
				
			||||||
 | 
					  padding: 20px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.header {
 | 
				
			||||||
 | 
					  background-color: #fff;
 | 
				
			||||||
 | 
					  border-radius: 8px;
 | 
				
			||||||
 | 
					  padding: 20px;
 | 
				
			||||||
 | 
					  margin-bottom: 20px;
 | 
				
			||||||
 | 
					  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.title {
 | 
				
			||||||
 | 
					  font-size: 24px;
 | 
				
			||||||
 | 
					  font-weight: bold;
 | 
				
			||||||
 | 
					  margin-bottom: 8px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.subtitle {
 | 
				
			||||||
 | 
					  color: #666;
 | 
				
			||||||
 | 
					  font-size: 14px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.tabs {
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  background-color: #fff;
 | 
				
			||||||
 | 
					  border-radius: 8px;
 | 
				
			||||||
 | 
					  margin-bottom: 20px;
 | 
				
			||||||
 | 
					  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.tab {
 | 
				
			||||||
 | 
					  flex: 1;
 | 
				
			||||||
 | 
					  padding: 15px 20px;
 | 
				
			||||||
 | 
					  border: none;
 | 
				
			||||||
 | 
					  background: transparent;
 | 
				
			||||||
 | 
					  cursor: pointer;
 | 
				
			||||||
 | 
					  font-size: 16px;
 | 
				
			||||||
 | 
					  transition: background-color 0.2s;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.tab:first-child {
 | 
				
			||||||
 | 
					  border-radius: 8px 0 0 8px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.tab:last-child {
 | 
				
			||||||
 | 
					  border-radius: 0 8px 8px 0;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.tab.active {
 | 
				
			||||||
 | 
					  background-color: #007bff;
 | 
				
			||||||
 | 
					  color: white;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.tab:hover:not(.active) {
 | 
				
			||||||
 | 
					  background-color: #f8f9fa;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.content {
 | 
				
			||||||
 | 
					  background-color: #fff;
 | 
				
			||||||
 | 
					  border-radius: 8px;
 | 
				
			||||||
 | 
					  padding: 20px;
 | 
				
			||||||
 | 
					  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.table {
 | 
				
			||||||
 | 
					  width: 100%;
 | 
				
			||||||
 | 
					  border-collapse: collapse;
 | 
				
			||||||
 | 
					  margin-top: 10px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.table th,
 | 
				
			||||||
 | 
					.table td {
 | 
				
			||||||
 | 
					  padding: 12px;
 | 
				
			||||||
 | 
					  text-align: left;
 | 
				
			||||||
 | 
					  border-bottom: 1px solid #e9ecef;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.table th {
 | 
				
			||||||
 | 
					  background-color: #f8f9fa;
 | 
				
			||||||
 | 
					  font-weight: 600;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.table tr:hover {
 | 
				
			||||||
 | 
					  background-color: #f8f9fa;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.btn {
 | 
				
			||||||
 | 
					  display: inline-block;
 | 
				
			||||||
 | 
					  padding: 8px 16px;
 | 
				
			||||||
 | 
					  border: none;
 | 
				
			||||||
 | 
					  border-radius: 4px;
 | 
				
			||||||
 | 
					  cursor: pointer;
 | 
				
			||||||
 | 
					  font-size: 14px;
 | 
				
			||||||
 | 
					  text-decoration: none;
 | 
				
			||||||
 | 
					  transition: background-color 0.2s;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.btn-primary {
 | 
				
			||||||
 | 
					  background-color: #007bff;
 | 
				
			||||||
 | 
					  color: white;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.btn-primary:hover {
 | 
				
			||||||
 | 
					  background-color: #0056b3;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.btn-danger {
 | 
				
			||||||
 | 
					  background-color: #dc3545;
 | 
				
			||||||
 | 
					  color: white;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.btn-danger:hover {
 | 
				
			||||||
 | 
					  background-color: #c82333;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.btn-secondary {
 | 
				
			||||||
 | 
					  background-color: #6c757d;
 | 
				
			||||||
 | 
					  color: white;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.btn-secondary:hover {
 | 
				
			||||||
 | 
					  background-color: #545b62;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.form-group {
 | 
				
			||||||
 | 
					  margin-bottom: 15px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.form-label {
 | 
				
			||||||
 | 
					  display: block;
 | 
				
			||||||
 | 
					  margin-bottom: 5px;
 | 
				
			||||||
 | 
					  font-weight: 500;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.form-input {
 | 
				
			||||||
 | 
					  width: 100%;
 | 
				
			||||||
 | 
					  padding: 10px;
 | 
				
			||||||
 | 
					  border: 1px solid #ddd;
 | 
				
			||||||
 | 
					  border-radius: 4px;
 | 
				
			||||||
 | 
					  font-size: 14px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.form-input:focus {
 | 
				
			||||||
 | 
					  outline: none;
 | 
				
			||||||
 | 
					  border-color: #007bff;
 | 
				
			||||||
 | 
					  box-shadow: 0 0 0 2px rgba(0,123,255,0.25);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.audio-player {
 | 
				
			||||||
 | 
					  width: 100%;
 | 
				
			||||||
 | 
					  margin: 10px 0;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.loading {
 | 
				
			||||||
 | 
					  text-align: center;
 | 
				
			||||||
 | 
					  padding: 40px;
 | 
				
			||||||
 | 
					  color: #666;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.error {
 | 
				
			||||||
 | 
					  background-color: #f8d7da;
 | 
				
			||||||
 | 
					  color: #721c24;
 | 
				
			||||||
 | 
					  padding: 10px;
 | 
				
			||||||
 | 
					  border-radius: 4px;
 | 
				
			||||||
 | 
					  margin-bottom: 20px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.success {
 | 
				
			||||||
 | 
					  background-color: #d4edda;
 | 
				
			||||||
 | 
					  color: #155724;
 | 
				
			||||||
 | 
					  padding: 10px;
 | 
				
			||||||
 | 
					  border-radius: 4px;
 | 
				
			||||||
 | 
					  margin-bottom: 20px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.empty-state {
 | 
				
			||||||
 | 
					  text-align: center;
 | 
				
			||||||
 | 
					  padding: 40px;
 | 
				
			||||||
 | 
					  color: #666;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.feed-item {
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  align-items: center;
 | 
				
			||||||
 | 
					  justify-content: space-between;
 | 
				
			||||||
 | 
					  padding: 15px;
 | 
				
			||||||
 | 
					  border: 1px solid #e9ecef;
 | 
				
			||||||
 | 
					  border-radius: 4px;
 | 
				
			||||||
 | 
					  margin-bottom: 10px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.feed-info {
 | 
				
			||||||
 | 
					  flex: 1;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.feed-title {
 | 
				
			||||||
 | 
					  font-weight: 500;
 | 
				
			||||||
 | 
					  margin-bottom: 4px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.feed-url {
 | 
				
			||||||
 | 
					  color: #666;
 | 
				
			||||||
 | 
					  font-size: 14px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.feed-actions {
 | 
				
			||||||
 | 
					  display: flex;
 | 
				
			||||||
 | 
					  gap: 10px;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										13
									
								
								schema.sql
									
									
									
									
									
								
							
							
						
						
									
										13
									
								
								schema.sql
									
									
									
									
									
								
							@@ -42,9 +42,22 @@ CREATE TABLE IF NOT EXISTS processed_feed_items (
 | 
				
			|||||||
  PRIMARY KEY(feed_url, item_id)
 | 
					  PRIMARY KEY(feed_url, item_id)
 | 
				
			||||||
);
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					-- TTS generation retry queue
 | 
				
			||||||
 | 
					CREATE TABLE IF NOT EXISTS tts_queue (
 | 
				
			||||||
 | 
					  id TEXT PRIMARY KEY,
 | 
				
			||||||
 | 
					  item_id TEXT NOT NULL,
 | 
				
			||||||
 | 
					  script_text TEXT NOT NULL,
 | 
				
			||||||
 | 
					  retry_count INTEGER DEFAULT 0,
 | 
				
			||||||
 | 
					  created_at TEXT NOT NULL,
 | 
				
			||||||
 | 
					  last_attempted_at TEXT,
 | 
				
			||||||
 | 
					  status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'processing', 'failed'))
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
-- Create indexes for better performance
 | 
					-- Create indexes for better performance
 | 
				
			||||||
CREATE INDEX IF NOT EXISTS idx_articles_feed_id ON articles(feed_id);
 | 
					CREATE INDEX IF NOT EXISTS idx_articles_feed_id ON articles(feed_id);
 | 
				
			||||||
CREATE INDEX IF NOT EXISTS idx_articles_pub_date ON articles(pub_date);
 | 
					CREATE INDEX IF NOT EXISTS idx_articles_pub_date ON articles(pub_date);
 | 
				
			||||||
CREATE INDEX IF NOT EXISTS idx_articles_processed ON articles(processed);
 | 
					CREATE INDEX IF NOT EXISTS idx_articles_processed ON articles(processed);
 | 
				
			||||||
CREATE INDEX IF NOT EXISTS idx_episodes_article_id ON episodes(article_id);
 | 
					CREATE INDEX IF NOT EXISTS idx_episodes_article_id ON episodes(article_id);
 | 
				
			||||||
CREATE INDEX IF NOT EXISTS idx_feeds_active ON feeds(active);
 | 
					CREATE INDEX IF NOT EXISTS idx_feeds_active ON feeds(active);
 | 
				
			||||||
 | 
					CREATE INDEX IF NOT EXISTS idx_tts_queue_status ON tts_queue(status);
 | 
				
			||||||
 | 
					CREATE INDEX IF NOT EXISTS idx_tts_queue_created_at ON tts_queue(created_at);
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -192,6 +192,9 @@ async function processUnprocessedArticles(): Promise<void> {
 | 
				
			|||||||
  console.log("🎧 Processing unprocessed articles...");
 | 
					  console.log("🎧 Processing unprocessed articles...");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  try {
 | 
					  try {
 | 
				
			||||||
 | 
					    // Process retry queue first
 | 
				
			||||||
 | 
					    await processRetryQueue();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Get unprocessed articles (limit to prevent overwhelming)
 | 
					    // Get unprocessed articles (limit to prevent overwhelming)
 | 
				
			||||||
    const unprocessedArticles = await getUnprocessedArticles(
 | 
					    const unprocessedArticles = await getUnprocessedArticles(
 | 
				
			||||||
      Number.parseInt(import.meta.env["LIMIT_UNPROCESSED_ARTICLES"] || "10"),
 | 
					      Number.parseInt(import.meta.env["LIMIT_UNPROCESSED_ARTICLES"] || "10"),
 | 
				
			||||||
@@ -204,12 +207,15 @@ async function processUnprocessedArticles(): Promise<void> {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    console.log(`🎯 Found ${unprocessedArticles.length} unprocessed articles`);
 | 
					    console.log(`🎯 Found ${unprocessedArticles.length} unprocessed articles`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Track articles that successfully generated audio
 | 
				
			||||||
 | 
					    const successfullyGeneratedArticles: string[] = [];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    for (const article of unprocessedArticles) {
 | 
					    for (const article of unprocessedArticles) {
 | 
				
			||||||
      try {
 | 
					      try {
 | 
				
			||||||
        await generatePodcastForArticle(article);
 | 
					        await generatePodcastForArticle(article);
 | 
				
			||||||
        await markArticleAsProcessed(article.id);
 | 
					        await markArticleAsProcessed(article.id);
 | 
				
			||||||
        console.log(`✅ Podcast generated for: ${article.title}`);
 | 
					        console.log(`✅ Podcast generated for: ${article.title}`);
 | 
				
			||||||
        await updatePodcastRSS(); // Update RSS after each article
 | 
					        successfullyGeneratedArticles.push(article.id);
 | 
				
			||||||
      } catch (error) {
 | 
					      } catch (error) {
 | 
				
			||||||
        console.error(
 | 
					        console.error(
 | 
				
			||||||
          `❌ Failed to generate podcast for article: ${article.title}`,
 | 
					          `❌ Failed to generate podcast for article: ${article.title}`,
 | 
				
			||||||
@@ -218,12 +224,68 @@ async function processUnprocessedArticles(): Promise<void> {
 | 
				
			|||||||
        // Don't mark as processed if generation failed
 | 
					        // Don't mark as processed if generation failed
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Only update RSS if at least one article was successfully processed
 | 
				
			||||||
 | 
					    if (successfullyGeneratedArticles.length > 0) {
 | 
				
			||||||
 | 
					      console.log(`📻 Updating podcast RSS for ${successfullyGeneratedArticles.length} new episodes...`);
 | 
				
			||||||
 | 
					      await updatePodcastRSS();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
  } catch (error) {
 | 
					  } catch (error) {
 | 
				
			||||||
    console.error("💥 Error processing unprocessed articles:", error);
 | 
					    console.error("💥 Error processing unprocessed articles:", error);
 | 
				
			||||||
    throw error;
 | 
					    throw error;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Process retry queue for failed TTS generation
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					async function processRetryQueue(): Promise<void> {
 | 
				
			||||||
 | 
					  const { getQueueItems, updateQueueItemStatus, removeFromQueue } = await import("../services/database.js");
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  console.log("🔄 Processing TTS retry queue...");
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    const queueItems = await getQueueItems(5); // Process 5 items at a time
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    if (queueItems.length === 0) {
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    console.log(`📋 Found ${queueItems.length} items in retry queue`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    for (const item of queueItems) {
 | 
				
			||||||
 | 
					      try {
 | 
				
			||||||
 | 
					        console.log(`🔁 Retrying TTS generation for: ${item.itemId} (attempt ${item.retryCount + 1})`);
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        // Mark as processing
 | 
				
			||||||
 | 
					        await updateQueueItemStatus(item.id, 'processing');
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        // Attempt TTS generation
 | 
				
			||||||
 | 
					        await generateTTS(item.itemId, item.scriptText, item.retryCount);
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        // Success - remove from queue
 | 
				
			||||||
 | 
					        await removeFromQueue(item.id);
 | 
				
			||||||
 | 
					        console.log(`✅ TTS retry successful for: ${item.itemId}`);
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					      } catch (error) {
 | 
				
			||||||
 | 
					        console.error(`❌ TTS retry failed for: ${item.itemId}`, error);
 | 
				
			||||||
 | 
					        
 | 
				
			||||||
 | 
					        if (item.retryCount >= 2) {
 | 
				
			||||||
 | 
					          // Max retries reached, mark as failed
 | 
				
			||||||
 | 
					          await updateQueueItemStatus(item.id, 'failed');
 | 
				
			||||||
 | 
					          console.log(`💀 Max retries reached for: ${item.itemId}, marking as failed`);
 | 
				
			||||||
 | 
					        } else {
 | 
				
			||||||
 | 
					          // Reset to pending for next retry
 | 
				
			||||||
 | 
					          await updateQueueItemStatus(item.id, 'pending');
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    console.error("💥 Error processing retry queue:", error);
 | 
				
			||||||
 | 
					    throw error;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
 * Generate podcast for a single article
 | 
					 * Generate podcast for a single article
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										82
									
								
								server.ts
									
									
									
									
									
								
							
							
						
						
									
										82
									
								
								server.ts
									
									
									
									
									
								
							@@ -107,6 +107,88 @@ app.get("/", serveIndex);
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
app.get("/index.html", serveIndex);
 | 
					app.get("/index.html", serveIndex);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// API endpoints for frontend
 | 
				
			||||||
 | 
					app.get("/api/episodes", async (c) => {
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    const { fetchEpisodesWithArticles } = await import("./services/database.js");
 | 
				
			||||||
 | 
					    const episodes = await fetchEpisodesWithArticles();
 | 
				
			||||||
 | 
					    return c.json(episodes);
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    console.error("Error fetching episodes:", error);
 | 
				
			||||||
 | 
					    return c.json({ error: "Failed to fetch episodes" }, 500);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					app.get("/api/feeds", async (c) => {
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    const { getAllFeedsIncludingInactive } = await import("./services/database.js");
 | 
				
			||||||
 | 
					    const feeds = await getAllFeedsIncludingInactive();
 | 
				
			||||||
 | 
					    return c.json(feeds);
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    console.error("Error fetching feeds:", error);
 | 
				
			||||||
 | 
					    return c.json({ error: "Failed to fetch feeds" }, 500);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					app.post("/api/feeds", async (c) => {
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    const body = await c.req.json();
 | 
				
			||||||
 | 
					    const { url } = body;
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    if (!url || typeof url !== 'string') {
 | 
				
			||||||
 | 
					      return c.json({ error: "URL is required" }, 400);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const { addNewFeedUrl } = await import("./scripts/fetch_and_generate.js");
 | 
				
			||||||
 | 
					    await addNewFeedUrl(url);
 | 
				
			||||||
 | 
					    return c.json({ success: true, message: "Feed added successfully" });
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    console.error("Error adding feed:", error);
 | 
				
			||||||
 | 
					    return c.json({ error: "Failed to add feed" }, 500);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					app.delete("/api/feeds/:id", async (c) => {
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    const feedId = c.req.param("id");
 | 
				
			||||||
 | 
					    const { deleteFeed } = await import("./services/database.js");
 | 
				
			||||||
 | 
					    const success = await deleteFeed(feedId);
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    if (!success) {
 | 
				
			||||||
 | 
					      return c.json({ error: "Feed not found" }, 404);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    return c.json({ success: true, message: "Feed deleted successfully" });
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    console.error("Error deleting feed:", error);
 | 
				
			||||||
 | 
					    return c.json({ error: "Failed to delete feed" }, 500);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					app.patch("/api/feeds/:id/toggle", async (c) => {
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    const feedId = c.req.param("id");
 | 
				
			||||||
 | 
					    const body = await c.req.json();
 | 
				
			||||||
 | 
					    const { active } = body;
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    if (typeof active !== 'boolean') {
 | 
				
			||||||
 | 
					      return c.json({ error: "Active status must be boolean" }, 400);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const { toggleFeedActive } = await import("./services/database.js");
 | 
				
			||||||
 | 
					    const success = await toggleFeedActive(feedId, active);
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    if (!success) {
 | 
				
			||||||
 | 
					      return c.json({ error: "Feed not found" }, 404);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    return c.json({ success: true, message: "Feed status updated successfully" });
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    console.error("Error toggling feed:", error);
 | 
				
			||||||
 | 
					    return c.json({ error: "Failed to update feed status" }, 500);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Catch-all for SPA routing
 | 
					// Catch-all for SPA routing
 | 
				
			||||||
app.get("*", serveIndex);
 | 
					app.get("*", serveIndex);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -60,11 +60,23 @@ function initializeDatabase(): Database {
 | 
				
			|||||||
    PRIMARY KEY(feed_url, item_id)
 | 
					    PRIMARY KEY(feed_url, item_id)
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  CREATE TABLE IF NOT EXISTS tts_queue (
 | 
				
			||||||
 | 
					    id TEXT PRIMARY KEY,
 | 
				
			||||||
 | 
					    item_id TEXT NOT NULL,
 | 
				
			||||||
 | 
					    script_text TEXT NOT NULL,
 | 
				
			||||||
 | 
					    retry_count INTEGER DEFAULT 0,
 | 
				
			||||||
 | 
					    created_at TEXT NOT NULL,
 | 
				
			||||||
 | 
					    last_attempted_at TEXT,
 | 
				
			||||||
 | 
					    status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'processing', 'failed'))
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  CREATE INDEX IF NOT EXISTS idx_articles_feed_id ON articles(feed_id);
 | 
					  CREATE INDEX IF NOT EXISTS idx_articles_feed_id ON articles(feed_id);
 | 
				
			||||||
  CREATE INDEX IF NOT EXISTS idx_articles_pub_date ON articles(pub_date);
 | 
					  CREATE INDEX IF NOT EXISTS idx_articles_pub_date ON articles(pub_date);
 | 
				
			||||||
  CREATE INDEX IF NOT EXISTS idx_articles_processed ON articles(processed);
 | 
					  CREATE INDEX IF NOT EXISTS idx_articles_processed ON articles(processed);
 | 
				
			||||||
  CREATE INDEX IF NOT EXISTS idx_episodes_article_id ON episodes(article_id);
 | 
					  CREATE INDEX IF NOT EXISTS idx_episodes_article_id ON episodes(article_id);
 | 
				
			||||||
  CREATE INDEX IF NOT EXISTS idx_feeds_active ON feeds(active);`);
 | 
					  CREATE INDEX IF NOT EXISTS idx_feeds_active ON feeds(active);
 | 
				
			||||||
 | 
					  CREATE INDEX IF NOT EXISTS idx_tts_queue_status ON tts_queue(status);
 | 
				
			||||||
 | 
					  CREATE INDEX IF NOT EXISTS idx_tts_queue_created_at ON tts_queue(created_at);`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return db;
 | 
					  return db;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@@ -506,6 +518,89 @@ export async function fetchEpisodesWithArticles(): Promise<
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// TTS Queue management functions
 | 
				
			||||||
 | 
					export interface TTSQueueItem {
 | 
				
			||||||
 | 
					  id: string;
 | 
				
			||||||
 | 
					  itemId: string;
 | 
				
			||||||
 | 
					  scriptText: string;
 | 
				
			||||||
 | 
					  retryCount: number;
 | 
				
			||||||
 | 
					  createdAt: string;
 | 
				
			||||||
 | 
					  lastAttemptedAt?: string;
 | 
				
			||||||
 | 
					  status: 'pending' | 'processing' | 'failed';
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export async function addToQueue(
 | 
				
			||||||
 | 
					  itemId: string,
 | 
				
			||||||
 | 
					  scriptText: string,
 | 
				
			||||||
 | 
					  retryCount: number = 0,
 | 
				
			||||||
 | 
					): Promise<string> {
 | 
				
			||||||
 | 
					  const id = crypto.randomUUID();
 | 
				
			||||||
 | 
					  const createdAt = new Date().toISOString();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    const stmt = db.prepare(
 | 
				
			||||||
 | 
					      "INSERT INTO tts_queue (id, item_id, script_text, retry_count, created_at, status) VALUES (?, ?, ?, ?, ?, 'pending')",
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					    stmt.run(id, itemId, scriptText, retryCount, createdAt);
 | 
				
			||||||
 | 
					    console.log(`TTS queue に追加: ${itemId} (試行回数: ${retryCount})`);
 | 
				
			||||||
 | 
					    return id;
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    console.error("Error adding to TTS queue:", error);
 | 
				
			||||||
 | 
					    throw error;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export async function getQueueItems(limit: number = 10): Promise<TTSQueueItem[]> {
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    const stmt = db.prepare(`
 | 
				
			||||||
 | 
					      SELECT * FROM tts_queue
 | 
				
			||||||
 | 
					      WHERE status = 'pending'
 | 
				
			||||||
 | 
					      ORDER BY created_at ASC
 | 
				
			||||||
 | 
					      LIMIT ?
 | 
				
			||||||
 | 
					    `);
 | 
				
			||||||
 | 
					    const rows = stmt.all(limit) as any[];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return rows.map((row) => ({
 | 
				
			||||||
 | 
					      id: row.id,
 | 
				
			||||||
 | 
					      itemId: row.item_id,
 | 
				
			||||||
 | 
					      scriptText: row.script_text,
 | 
				
			||||||
 | 
					      retryCount: row.retry_count,
 | 
				
			||||||
 | 
					      createdAt: row.created_at,
 | 
				
			||||||
 | 
					      lastAttemptedAt: row.last_attempted_at,
 | 
				
			||||||
 | 
					      status: row.status,
 | 
				
			||||||
 | 
					    }));
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    console.error("Error getting queue items:", error);
 | 
				
			||||||
 | 
					    throw error;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export async function updateQueueItemStatus(
 | 
				
			||||||
 | 
					  queueId: string,
 | 
				
			||||||
 | 
					  status: 'pending' | 'processing' | 'failed',
 | 
				
			||||||
 | 
					  lastAttemptedAt?: string,
 | 
				
			||||||
 | 
					): Promise<void> {
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    const stmt = db.prepare(
 | 
				
			||||||
 | 
					      "UPDATE tts_queue SET status = ?, last_attempted_at = ? WHERE id = ?",
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					    stmt.run(status, lastAttemptedAt || new Date().toISOString(), queueId);
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    console.error("Error updating queue item status:", error);
 | 
				
			||||||
 | 
					    throw error;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export async function removeFromQueue(queueId: string): Promise<void> {
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    const stmt = db.prepare("DELETE FROM tts_queue WHERE id = ?");
 | 
				
			||||||
 | 
					    stmt.run(queueId);
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    console.error("Error removing from queue:", error);
 | 
				
			||||||
 | 
					    throw error;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function closeDatabase(): void {
 | 
					export function closeDatabase(): void {
 | 
				
			||||||
  db.close();
 | 
					  db.close();
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -45,9 +45,22 @@ function createItemXml(episode: Episode): string {
 | 
				
			|||||||
export async function updatePodcastRSS(): Promise<void> {
 | 
					export async function updatePodcastRSS(): Promise<void> {
 | 
				
			||||||
  try {
 | 
					  try {
 | 
				
			||||||
    const episodes: Episode[] = await fetchAllEpisodes();
 | 
					    const episodes: Episode[] = await fetchAllEpisodes();
 | 
				
			||||||
    const lastBuildDate = new Date().toUTCString();
 | 
					 | 
				
			||||||
    
 | 
					    
 | 
				
			||||||
    const itemsXml = episodes.map(createItemXml).join("\n");
 | 
					    // Filter episodes to only include those with valid audio files
 | 
				
			||||||
 | 
					    const validEpisodes = episodes.filter(episode => {
 | 
				
			||||||
 | 
					      try {
 | 
				
			||||||
 | 
					        const audioPath = path.join(config.paths.podcastAudioDir, episode.audioPath);
 | 
				
			||||||
 | 
					        return fsSync.existsSync(audioPath);
 | 
				
			||||||
 | 
					      } catch (error) {
 | 
				
			||||||
 | 
					        console.warn(`Audio file not found for episode: ${episode.title}`);
 | 
				
			||||||
 | 
					        return false;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    console.log(`Found ${episodes.length} episodes, ${validEpisodes.length} with valid audio files`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const lastBuildDate = new Date().toUTCString();
 | 
				
			||||||
 | 
					    const itemsXml = validEpisodes.map(createItemXml).join("\n");
 | 
				
			||||||
    const outputPath = path.join(config.paths.publicDir, "podcast.xml");
 | 
					    const outputPath = path.join(config.paths.publicDir, "podcast.xml");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Create RSS XML content
 | 
					    // Create RSS XML content
 | 
				
			||||||
@@ -69,7 +82,7 @@ export async function updatePodcastRSS(): Promise<void> {
 | 
				
			|||||||
    await fs.mkdir(dirname(outputPath), { recursive: true });
 | 
					    await fs.mkdir(dirname(outputPath), { recursive: true });
 | 
				
			||||||
    await fs.writeFile(outputPath, rssXml);
 | 
					    await fs.writeFile(outputPath, rssXml);
 | 
				
			||||||
    
 | 
					    
 | 
				
			||||||
    console.log(`RSS feed updated with ${episodes.length} episodes`);
 | 
					    console.log(`RSS feed updated with ${validEpisodes.length} episodes (audio files verified)`);
 | 
				
			||||||
  } catch (error) {
 | 
					  } catch (error) {
 | 
				
			||||||
    console.error("Error updating podcast RSS:", error);
 | 
					    console.error("Error updating podcast RSS:", error);
 | 
				
			||||||
    throw error;
 | 
					    throw error;
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -15,6 +15,7 @@ const defaultVoiceStyle: VoiceStyle = {
 | 
				
			|||||||
export async function generateTTS(
 | 
					export async function generateTTS(
 | 
				
			||||||
  itemId: string,
 | 
					  itemId: string,
 | 
				
			||||||
  scriptText: string,
 | 
					  scriptText: string,
 | 
				
			||||||
 | 
					  retryCount: number = 0,
 | 
				
			||||||
): Promise<string> {
 | 
					): Promise<string> {
 | 
				
			||||||
  if (!itemId || itemId.trim() === "") {
 | 
					  if (!itemId || itemId.trim() === "") {
 | 
				
			||||||
    throw new Error("Item ID is required for TTS generation");
 | 
					    throw new Error("Item ID is required for TTS generation");
 | 
				
			||||||
@@ -24,7 +25,10 @@ export async function generateTTS(
 | 
				
			|||||||
    throw new Error("Script text is required for TTS generation");
 | 
					    throw new Error("Script text is required for TTS generation");
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  console.log(`TTS生成開始: ${itemId}`);
 | 
					  const maxRetries = 2;
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    console.log(`TTS生成開始: ${itemId} (試行回数: ${retryCount + 1}/${maxRetries + 1})`);
 | 
				
			||||||
    const encodedText = encodeURIComponent(scriptText);
 | 
					    const encodedText = encodeURIComponent(scriptText);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const queryUrl = `${config.voicevox.host}/audio_query?text=${encodedText}&speaker=${defaultVoiceStyle.styleId}`;
 | 
					    const queryUrl = `${config.voicevox.host}/audio_query?text=${encodedText}&speaker=${defaultVoiceStyle.styleId}`;
 | 
				
			||||||
@@ -113,4 +117,15 @@ export async function generateTTS(
 | 
				
			|||||||
    console.log(`TTS生成完了: ${itemId}`);
 | 
					    console.log(`TTS生成完了: ${itemId}`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return path.basename(mp3FilePath);
 | 
					    return path.basename(mp3FilePath);
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    console.error(`TTS生成エラー: ${itemId} (試行回数: ${retryCount + 1})`, error);
 | 
				
			||||||
 | 
					    
 | 
				
			||||||
 | 
					    if (retryCount < maxRetries) {
 | 
				
			||||||
 | 
					      const { addToQueue } = await import("../services/database.js");
 | 
				
			||||||
 | 
					      await addToQueue(itemId, scriptText, retryCount + 1);
 | 
				
			||||||
 | 
					      throw new Error(`TTS generation failed, added to retry queue: ${error}`);
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      throw new Error(`TTS generation failed after ${maxRetries + 1} attempts: ${error}`);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user