Update
This commit is contained in:
@ -3,11 +3,10 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ポッドキャスト管理画面</title>
|
||||
<meta name="description" content="RSSフィードから自動生成された音声ポッドキャストを再生・管理できます。" />
|
||||
<title>Voice RSS Summary</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./src/main.tsx" defer></script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
@ -1,133 +1,38 @@
|
||||
import { useState } from "react";
|
||||
import "./app/globals.css";
|
||||
import Dashboard from "./components/Dashboard";
|
||||
import EpisodePlayer from "./components/EpisodePlayer";
|
||||
import FeedManager from "./components/FeedManager";
|
||||
import React from "react";
|
||||
import { useState } from 'react'
|
||||
import EpisodeList from './components/EpisodeList'
|
||||
import FeedManager from './components/FeedManager'
|
||||
|
||||
type TabType = "dashboard" | "episodes" | "feeds";
|
||||
|
||||
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);
|
||||
function App() {
|
||||
const [activeTab, setActiveTab] = useState<'episodes' | 'feeds'>('episodes')
|
||||
|
||||
return (
|
||||
<html lang="ja">
|
||||
<head>
|
||||
<meta charSet="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<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 className="container">
|
||||
<div className="header">
|
||||
<div className="title">Voice RSS Summary</div>
|
||||
<div className="subtitle">RSS フィードから自動生成された音声ポッドキャスト</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile menu button */}
|
||||
<button
|
||||
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
||||
className="md:hidden p-2 rounded-lg bg-gray-100 hover:bg-gray-200 transition-colors"
|
||||
aria-label="メニューを開く"
|
||||
></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div className="tabs">
|
||||
<button
|
||||
className={`tab ${activeTab === 'episodes' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('episodes')}
|
||||
>
|
||||
エピソード一覧
|
||||
</button>
|
||||
<button
|
||||
className={`tab ${activeTab === 'feeds' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('feeds')}
|
||||
>
|
||||
フィード管理
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
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>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{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>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
<div className="content">
|
||||
{activeTab === 'episodes' && <EpisodeList />}
|
||||
{activeTab === 'feeds' && <FeedManager />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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,263 +1,213 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import React from "react";
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
interface Feed {
|
||||
id: string;
|
||||
url: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
lastUpdated?: string;
|
||||
createdAt: string;
|
||||
active: boolean;
|
||||
id: string
|
||||
url: string
|
||||
title?: string
|
||||
description?: string
|
||||
active: boolean
|
||||
lastUpdated?: string
|
||||
}
|
||||
|
||||
export default function FeedManager() {
|
||||
const [feeds, setFeeds] = useState<Feed[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [newFeedUrl, setNewFeedUrl] = useState("");
|
||||
const [addingFeed, setAddingFeed] = useState(false);
|
||||
function FeedManager() {
|
||||
const [feeds, setFeeds] = useState<Feed[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState<string | null>(null)
|
||||
const [newFeedUrl, setNewFeedUrl] = useState('')
|
||||
const [adding, setAdding] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchFeeds();
|
||||
}, []);
|
||||
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);
|
||||
setLoading(true)
|
||||
const response = await fetch('/api/feeds')
|
||||
if (!response.ok) throw new Error('フィードの取得に失敗しました')
|
||||
const data = await response.json()
|
||||
setFeeds(data)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "エラーが発生しました");
|
||||
setError(err instanceof Error ? err.message : 'エラーが発生しました')
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoading(false)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const addFeed = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
e.preventDefault()
|
||||
if (!newFeedUrl.trim()) return
|
||||
|
||||
if (!newFeedUrl.trim()) {
|
||||
alert("フィードURLを入力してください");
|
||||
return;
|
||||
try {
|
||||
setAdding(true)
|
||||
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 || 'フィードの追加に失敗しました')
|
||||
}
|
||||
|
||||
setSuccess('フィードを追加しました')
|
||||
setNewFeedUrl('')
|
||||
await fetchFeeds()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'エラーが発生しました')
|
||||
} finally {
|
||||
setAdding(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!newFeedUrl.startsWith("http")) {
|
||||
alert("有効なURLを入力してください");
|
||||
return;
|
||||
const deleteFeed = async (feedId: string) => {
|
||||
if (!confirm('このフィードを削除しますか?関連するエピソードも削除されます。')) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setAddingFeed(true);
|
||||
const response = await fetch("/api/feeds", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ feedUrl: newFeedUrl }),
|
||||
});
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
|
||||
const result = await response.json();
|
||||
const response = await fetch(`/api/feeds/${feedId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
if (result.result === "EXISTS") {
|
||||
alert("このフィードは既に登録されています");
|
||||
} else {
|
||||
alert("フィードが正常に追加されました");
|
||||
setNewFeedUrl("");
|
||||
fetchFeeds(); // Refresh the list
|
||||
}
|
||||
} else {
|
||||
alert(result.error || "フィードの追加に失敗しました");
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'フィードの削除に失敗しました')
|
||||
}
|
||||
|
||||
setSuccess('フィードを削除しました')
|
||||
await fetchFeeds()
|
||||
} catch (err) {
|
||||
alert("エラーが発生しました");
|
||||
console.error("Feed addition error:", err);
|
||||
} finally {
|
||||
setAddingFeed(false);
|
||||
setError(err instanceof Error ? err.message : 'エラーが発生しました')
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
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) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
|
||||
<span className="text-gray-600">読み込み中...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <div className="loading">読み込み中...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Add New Feed Form */}
|
||||
<div className="bg-gradient-to-r from-blue-50 to-purple-50 rounded-xl p-6 border border-blue-100">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
新しいフィードを追加
|
||||
</h3>
|
||||
<form onSubmit={addFeed} className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="feedUrl"
|
||||
className="block text-sm font-medium text-gray-700 mb-2"
|
||||
>
|
||||
RSS フィード URL
|
||||
</label>
|
||||
<div className="flex space-x-3">
|
||||
<input
|
||||
type="url"
|
||||
id="feedUrl"
|
||||
value={newFeedUrl}
|
||||
onChange={(e) => setNewFeedUrl(e.target.value)}
|
||||
placeholder="https://example.com/feed.xml"
|
||||
className="flex-1 border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
disabled={addingFeed}
|
||||
aria-describedby="feedUrl-help"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={addingFeed || !newFeedUrl.trim()}
|
||||
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200"
|
||||
>
|
||||
{addingFeed ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
<span>追加中...</span>
|
||||
</div>
|
||||
) : (
|
||||
"追加"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<p id="feedUrl-help" className="text-xs text-gray-500 mt-2">
|
||||
RSS または Atom フィードの URL を入力してください
|
||||
</p>
|
||||
<div>
|
||||
{error && <div className="error">{error}</div>}
|
||||
{success && <div className="success">{success}</div>}
|
||||
|
||||
<div style={{ marginBottom: '30px' }}>
|
||||
<h2>新しいフィードを追加</h2>
|
||||
<form onSubmit={addFeed}>
|
||||
<div className="form-group">
|
||||
<label className="form-label">RSS フィード URL</label>
|
||||
<input
|
||||
type="url"
|
||||
className="form-input"
|
||||
value={newFeedUrl}
|
||||
onChange={(e) => setNewFeedUrl(e.target.value)}
|
||||
placeholder="https://example.com/feed.xml"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={adding}
|
||||
>
|
||||
{adding ? '追加中...' : 'フィードを追加'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<div className="flex items-center">
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800">エラー</h3>
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Feeds List */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
登録済みフィード
|
||||
</h3>
|
||||
<span className="text-sm text-gray-500">{feeds.length} フィード</span>
|
||||
</div>
|
||||
|
||||
<h2>登録済みフィード ({feeds.length}件)</h2>
|
||||
|
||||
{feeds.length === 0 ? (
|
||||
<div className="text-center py-12 bg-gray-50 rounded-xl">
|
||||
<div className="w-16 h-16 bg-gray-200 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<span role="img" aria-hidden="true" className="text-2xl">
|
||||
📡
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
フィードがありません
|
||||
</h3>
|
||||
<p className="text-gray-500">
|
||||
上のフォームから RSS フィードを追加してください
|
||||
</p>
|
||||
<div className="empty-state">
|
||||
<p>登録されているフィードがありません</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
<div>
|
||||
{feeds.map((feed) => (
|
||||
<div
|
||||
key={feed.id}
|
||||
className="bg-white border border-gray-200 rounded-xl p-6 hover:shadow-md transition-shadow duration-200"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center space-x-3 mb-2">
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full ${feed.active ? "bg-green-400" : "bg-gray-400"}`}
|
||||
></div>
|
||||
<h4 className="text-lg font-medium text-gray-900 truncate">
|
||||
{feed.title || "タイトル未取得"}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
{feed.description && (
|
||||
<p className="text-sm text-gray-600 mb-3 line-clamp-2">
|
||||
{feed.description}
|
||||
</p>
|
||||
<div key={feed.id} className="feed-item">
|
||||
<div className="feed-info">
|
||||
<div className="feed-title">
|
||||
{feed.title || 'タイトル不明'}
|
||||
{!feed.active && (
|
||||
<span style={{
|
||||
marginLeft: '8px',
|
||||
padding: '2px 6px',
|
||||
backgroundColor: '#dc3545',
|
||||
color: 'white',
|
||||
fontSize: '12px',
|
||||
borderRadius: '3px'
|
||||
}}>
|
||||
無効
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-xs font-medium text-gray-500">
|
||||
URL:
|
||||
</span>
|
||||
<a
|
||||
href={feed.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-blue-600 hover:text-blue-800 truncate max-w-xs"
|
||||
title={feed.url}
|
||||
>
|
||||
{feed.url}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4 text-xs text-gray-500">
|
||||
<span>
|
||||
追加日:{" "}
|
||||
{new Date(feed.createdAt).toLocaleDateString("ja-JP")}
|
||||
</span>
|
||||
{feed.lastUpdated && (
|
||||
<span>
|
||||
最終更新:{" "}
|
||||
{new Date(feed.lastUpdated).toLocaleDateString(
|
||||
"ja-JP",
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 ml-4">
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
feed.active
|
||||
? "bg-green-100 text-green-800"
|
||||
: "bg-gray-100 text-gray-800"
|
||||
}`}
|
||||
>
|
||||
{feed.active ? "アクティブ" : "無効"}
|
||||
</span>
|
||||
|
||||
{/* Future: Add edit/delete buttons here */}
|
||||
<button
|
||||
className="text-gray-400 hover:text-gray-600 p-1"
|
||||
title="設定"
|
||||
aria-label={`${feed.title || feed.url}の設定`}
|
||||
></button>
|
||||
<div className="feed-url">{feed.url}</div>
|
||||
<div style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
|
||||
最終更新: {formatDate(feed.lastUpdated)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="feed-actions">
|
||||
<button
|
||||
className={`btn ${feed.active ? 'btn-secondary' : 'btn-primary'}`}
|
||||
onClick={() => toggleFeed(feed.id, !feed.active)}
|
||||
>
|
||||
{feed.active ? '無効化' : '有効化'}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
onClick={() => deleteFeed(feed.id)}
|
||||
>
|
||||
削除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export default FeedManager
|
@ -1,10 +1,10 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App.tsx";
|
||||
import React from "react";
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
import './styles.css'
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</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)
|
||||
);
|
||||
|
||||
-- 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 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_processed ON articles(processed);
|
||||
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_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...");
|
||||
|
||||
try {
|
||||
// Process retry queue first
|
||||
await processRetryQueue();
|
||||
|
||||
// Get unprocessed articles (limit to prevent overwhelming)
|
||||
const unprocessedArticles = await getUnprocessedArticles(
|
||||
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`);
|
||||
|
||||
// Track articles that successfully generated audio
|
||||
const successfullyGeneratedArticles: string[] = [];
|
||||
|
||||
for (const article of unprocessedArticles) {
|
||||
try {
|
||||
await generatePodcastForArticle(article);
|
||||
await markArticleAsProcessed(article.id);
|
||||
console.log(`✅ Podcast generated for: ${article.title}`);
|
||||
await updatePodcastRSS(); // Update RSS after each article
|
||||
successfullyGeneratedArticles.push(article.id);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`❌ 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
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
console.error("💥 Error processing unprocessed articles:", 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
|
||||
*/
|
||||
|
82
server.ts
82
server.ts
@ -107,6 +107,88 @@ app.get("/", 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
|
||||
app.get("*", serveIndex);
|
||||
|
||||
|
@ -60,11 +60,23 @@ function initializeDatabase(): Database {
|
||||
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_pub_date ON articles(pub_date);
|
||||
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_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;
|
||||
}
|
||||
@ -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 {
|
||||
db.close();
|
||||
}
|
||||
|
@ -45,9 +45,22 @@ function createItemXml(episode: Episode): string {
|
||||
export async function updatePodcastRSS(): Promise<void> {
|
||||
try {
|
||||
const episodes: Episode[] = await fetchAllEpisodes();
|
||||
const lastBuildDate = new Date().toUTCString();
|
||||
|
||||
// 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;
|
||||
}
|
||||
});
|
||||
|
||||
const itemsXml = episodes.map(createItemXml).join("\n");
|
||||
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");
|
||||
|
||||
// Create RSS XML content
|
||||
@ -69,7 +82,7 @@ export async function updatePodcastRSS(): Promise<void> {
|
||||
await fs.mkdir(dirname(outputPath), { recursive: true });
|
||||
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) {
|
||||
console.error("Error updating podcast RSS:", error);
|
||||
throw error;
|
||||
|
185
services/tts.ts
185
services/tts.ts
@ -15,6 +15,7 @@ const defaultVoiceStyle: VoiceStyle = {
|
||||
export async function generateTTS(
|
||||
itemId: string,
|
||||
scriptText: string,
|
||||
retryCount: number = 0,
|
||||
): Promise<string> {
|
||||
if (!itemId || itemId.trim() === "") {
|
||||
throw new Error("Item ID is required for TTS generation");
|
||||
@ -24,93 +25,107 @@ export async function generateTTS(
|
||||
throw new Error("Script text is required for TTS generation");
|
||||
}
|
||||
|
||||
console.log(`TTS生成開始: ${itemId}`);
|
||||
const encodedText = encodeURIComponent(scriptText);
|
||||
const maxRetries = 2;
|
||||
|
||||
try {
|
||||
console.log(`TTS生成開始: ${itemId} (試行回数: ${retryCount + 1}/${maxRetries + 1})`);
|
||||
const encodedText = encodeURIComponent(scriptText);
|
||||
|
||||
const queryUrl = `${config.voicevox.host}/audio_query?text=${encodedText}&speaker=${defaultVoiceStyle.styleId}`;
|
||||
const synthesisUrl = `${config.voicevox.host}/synthesis?speaker=${defaultVoiceStyle.styleId}`;
|
||||
const queryUrl = `${config.voicevox.host}/audio_query?text=${encodedText}&speaker=${defaultVoiceStyle.styleId}`;
|
||||
const synthesisUrl = `${config.voicevox.host}/synthesis?speaker=${defaultVoiceStyle.styleId}`;
|
||||
|
||||
const queryResponse = await fetch(queryUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
const queryResponse = await fetch(queryUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!queryResponse.ok) {
|
||||
const errorText = await queryResponse.text();
|
||||
throw new Error(
|
||||
`VOICEVOX audio query failed (${queryResponse.status}): ${errorText}`,
|
||||
);
|
||||
if (!queryResponse.ok) {
|
||||
const errorText = await queryResponse.text();
|
||||
throw new Error(
|
||||
`VOICEVOX audio query failed (${queryResponse.status}): ${errorText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const audioQuery = await queryResponse.json();
|
||||
|
||||
console.log(`音声合成開始: ${itemId}`);
|
||||
const audioResponse = await fetch(synthesisUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(audioQuery),
|
||||
signal: AbortSignal.timeout(600000), // 10分のタイムアウト
|
||||
});
|
||||
|
||||
if (!audioResponse.ok) {
|
||||
const errorText = await audioResponse.text();
|
||||
console.error(`音声合成失敗: ${itemId}`);
|
||||
throw new Error(
|
||||
`VOICEVOX synthesis failed (${audioResponse.status}): ${errorText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const audioArrayBuffer = await audioResponse.arrayBuffer();
|
||||
const audioBuffer = Buffer.from(audioArrayBuffer);
|
||||
|
||||
// 出力ディレクトリの準備
|
||||
const outputDir = config.paths.podcastAudioDir;
|
||||
if (!fs.existsSync(outputDir)) {
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
|
||||
const wavFilePath = path.resolve(outputDir, `${itemId}.wav`);
|
||||
const mp3FilePath = path.resolve(outputDir, `${itemId}.mp3`);
|
||||
|
||||
console.log(`WAVファイル保存開始: ${wavFilePath}`);
|
||||
fs.writeFileSync(wavFilePath, audioBuffer);
|
||||
console.log(`WAVファイル保存完了: ${wavFilePath}`);
|
||||
|
||||
console.log(`MP3変換開始: ${wavFilePath} -> ${mp3FilePath}`);
|
||||
|
||||
const ffmpegCmd = ffmpegPath || "ffmpeg";
|
||||
const result = Bun.spawnSync({
|
||||
cmd: [
|
||||
ffmpegCmd,
|
||||
"-i",
|
||||
wavFilePath,
|
||||
"-codec:a",
|
||||
"libmp3lame",
|
||||
"-qscale:a",
|
||||
"2",
|
||||
"-y", // Overwrite output file
|
||||
mp3FilePath,
|
||||
],
|
||||
});
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
const stderr = result.stderr
|
||||
? new TextDecoder().decode(result.stderr)
|
||||
: "Unknown error";
|
||||
throw new Error(`FFmpeg conversion failed: ${stderr}`);
|
||||
}
|
||||
|
||||
// Wavファイルを削除
|
||||
if (fs.existsSync(wavFilePath)) {
|
||||
fs.unlinkSync(wavFilePath);
|
||||
}
|
||||
|
||||
console.log(`TTS生成完了: ${itemId}`);
|
||||
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
const audioQuery = await queryResponse.json();
|
||||
|
||||
console.log(`音声合成開始: ${itemId}`);
|
||||
const audioResponse = await fetch(synthesisUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(audioQuery),
|
||||
signal: AbortSignal.timeout(600000), // 10分のタイムアウト
|
||||
});
|
||||
|
||||
if (!audioResponse.ok) {
|
||||
const errorText = await audioResponse.text();
|
||||
console.error(`音声合成失敗: ${itemId}`);
|
||||
throw new Error(
|
||||
`VOICEVOX synthesis failed (${audioResponse.status}): ${errorText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const audioArrayBuffer = await audioResponse.arrayBuffer();
|
||||
const audioBuffer = Buffer.from(audioArrayBuffer);
|
||||
|
||||
// 出力ディレクトリの準備
|
||||
const outputDir = config.paths.podcastAudioDir;
|
||||
if (!fs.existsSync(outputDir)) {
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
|
||||
const wavFilePath = path.resolve(outputDir, `${itemId}.wav`);
|
||||
const mp3FilePath = path.resolve(outputDir, `${itemId}.mp3`);
|
||||
|
||||
console.log(`WAVファイル保存開始: ${wavFilePath}`);
|
||||
fs.writeFileSync(wavFilePath, audioBuffer);
|
||||
console.log(`WAVファイル保存完了: ${wavFilePath}`);
|
||||
|
||||
console.log(`MP3変換開始: ${wavFilePath} -> ${mp3FilePath}`);
|
||||
|
||||
const ffmpegCmd = ffmpegPath || "ffmpeg";
|
||||
const result = Bun.spawnSync({
|
||||
cmd: [
|
||||
ffmpegCmd,
|
||||
"-i",
|
||||
wavFilePath,
|
||||
"-codec:a",
|
||||
"libmp3lame",
|
||||
"-qscale:a",
|
||||
"2",
|
||||
"-y", // Overwrite output file
|
||||
mp3FilePath,
|
||||
],
|
||||
});
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
const stderr = result.stderr
|
||||
? new TextDecoder().decode(result.stderr)
|
||||
: "Unknown error";
|
||||
throw new Error(`FFmpeg conversion failed: ${stderr}`);
|
||||
}
|
||||
|
||||
// Wavファイルを削除
|
||||
if (fs.existsSync(wavFilePath)) {
|
||||
fs.unlinkSync(wavFilePath);
|
||||
}
|
||||
|
||||
console.log(`TTS生成完了: ${itemId}`);
|
||||
|
||||
return path.basename(mp3FilePath);
|
||||
}
|
||||
|
Reference in New Issue
Block a user