This commit is contained in:
2025-06-07 13:45:48 +09:00
parent d642b913ab
commit 9580740398
18 changed files with 936 additions and 1669 deletions

View File

@ -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>

View File

@ -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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

@ -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

View File

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

View File

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

View File

@ -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
*/

View File

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

View File

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

View File

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

View File

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