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

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