This commit is contained in:
2025-06-07 10:13:46 +09:00
parent e43a35f64a
commit 6ffb7e23d4
9 changed files with 813 additions and 376 deletions

View File

@ -35,7 +35,7 @@ export default function Dashboard() {
// Fetch stats and recent episodes in parallel
const [statsResponse, episodesResponse] = await Promise.all([
fetch("/api/stats"),
fetch("/api/episodes")
fetch("/api/episodes"),
]);
if (!statsResponse.ok || !episodesResponse.ok) {
@ -58,7 +58,9 @@ export default function Dashboard() {
try {
const response = await fetch("/api/batch/trigger", { method: "POST" });
if (response.ok) {
alert("バッチ処理を開始しました。新しいエピソードの生成には時間がかかる場合があります。");
alert(
"バッチ処理を開始しました。新しいエピソードの生成には時間がかかる場合があります。",
);
} else {
alert("バッチ処理の開始に失敗しました。");
}
@ -82,11 +84,6 @@ export default function Dashboard() {
return (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex items-center">
<div className="text-red-400">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800"></h3>
<p className="text-sm text-red-700">{error}</p>
@ -99,157 +96,187 @@ export default function Dashboard() {
return (
<div className="space-y-8">
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="bg-white rounded-xl shadow-sm p-6 border border-gray-100">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center">
<span role="img" aria-hidden="true" className="text-lg">📡</span>
</div>
</div>
<div className="ml-4">
<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-semibold text-gray-900">{stats?.totalFeeds || 0}</p>
</div>
</div>
</div>
<div className="bg-white rounded-xl shadow-sm p-6 border border-gray-100">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="w-8 h-8 bg-green-100 rounded-lg flex items-center justify-center">
<span role="img" aria-hidden="true" className="text-lg"></span>
</div>
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600"></p>
<p className="text-2xl font-semibold text-gray-900">{stats?.activeFeeds || 0}</p>
</div>
</div>
</div>
<div className="bg-white rounded-xl shadow-sm p-6 border border-gray-100">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="w-8 h-8 bg-purple-100 rounded-lg flex items-center justify-center">
<span role="img" aria-hidden="true" className="text-lg">🎧</span>
</div>
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600"></p>
<p className="text-2xl font-semibold text-gray-900">{stats?.totalEpisodes || 0}</p>
</div>
</div>
</div>
<div className="bg-white rounded-xl shadow-sm p-6 border border-gray-100">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="w-8 h-8 bg-yellow-100 rounded-lg flex items-center justify-center">
<span role="img" aria-hidden="true" className="text-lg">🕒</span>
</div>
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600"></p>
<p className="text-sm font-semibold text-gray-900">
{stats?.lastUpdated ? new Date(stats.lastUpdated).toLocaleDateString('ja-JP') : '未取得'}
<p 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 md:grid-cols-2 gap-6">
<div className="bg-gradient-to-r from-blue-500 to-purple-600 rounded-xl shadow-sm p-6 text-white">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold"></h3>
<p className="text-blue-100 text-sm mt-1">
</p>
</div>
<button
onClick={triggerBatchProcess}
className="bg-white bg-opacity-20 hover:bg-opacity-30 text-white font-medium py-2 px-4 rounded-lg transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-white focus:ring-opacity-50"
aria-label="バッチ処理を手動実行"
>
</button>
</div>
<div 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>
<div className="bg-white rounded-xl shadow-sm p-6 border border-gray-100">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-900"></h3>
<div className="flex items-center space-x-2 mt-2">
<div className="w-3 h-3 bg-green-400 rounded-full animate-pulse"></div>
<span className="text-sm text-gray-600"> (6)</span>
{/* 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="text-green-500">
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div 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-xl shadow-sm p-6 border border-gray-100">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-gray-900"></h3>
<span className="text-sm text-gray-500">{recentEpisodes.length} </span>
</div>
{recentEpisodes.length === 0 ? (
<div className="text-center py-8">
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
<span role="img" aria-hidden="true" className="text-2xl">🎧</span>
</div>
<p className="text-gray-500"></p>
<p className="text-sm text-gray-400 mt-1"></p>
<div 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 className="space-y-4">
{recentEpisodes.map((episode) => (
<div
key={episode.id}
className="flex items-start space-x-4 p-4 rounded-lg border border-gray-100 hover:bg-gray-50 transition-colors duration-200"
>
<div className="w-10 h-10 bg-gradient-to-r from-blue-400 to-purple-500 rounded-lg flex items-center justify-center text-white font-semibold text-sm">
<span role="img" aria-hidden="true">🎵</span>
</div>
<div className="flex-1 min-w-0">
<h4 className="text-sm font-medium text-gray-900 truncate">
{episode.title}
</h4>
<p className="text-sm text-gray-500 mt-1">
{episode.feed?.title} {new Date(episode.createdAt).toLocaleDateString('ja-JP')}
</p>
{episode.article && (
<a
href={episode.article.link}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-blue-600 hover:text-blue-800 mt-1 inline-block"
>
</a>
)}
</div>
<div className="flex-shrink-0">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
</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>
</div>
</div>
);
}
}

View File

@ -31,7 +31,7 @@ export default function EpisodePlayer() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState("");
const audioRef = useRef<HTMLAudioElement>(null);
useEffect(() => {
@ -46,14 +46,14 @@ export default function EpisodePlayer() {
const updateDuration = () => setDuration(audio.duration);
const handleEnded = () => setIsPlaying(false);
audio.addEventListener('timeupdate', updateTime);
audio.addEventListener('loadedmetadata', updateDuration);
audio.addEventListener('ended', handleEnded);
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);
audio.removeEventListener("timeupdate", updateTime);
audio.removeEventListener("loadedmetadata", updateDuration);
audio.removeEventListener("ended", handleEnded);
};
}, [selectedEpisode]);
@ -103,7 +103,7 @@ export default function EpisodePlayer() {
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')}`;
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
};
const formatFileSize = (bytes?: number) => {
@ -112,10 +112,11 @@ export default function EpisodePlayer() {
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())
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) {
@ -133,11 +134,6 @@ export default function EpisodePlayer() {
return (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex items-center">
<div className="text-red-400">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800"></h3>
<p className="text-sm text-red-700">{error}</p>
@ -151,11 +147,6 @@ export default function EpisodePlayer() {
<div className="space-y-6">
{/* Search */}
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg className="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<input
type="text"
placeholder="エピソードを検索..."
@ -170,29 +161,25 @@ export default function EpisodePlayer() {
<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>
<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>
<h3 className="text-lg font-semibold text-gray-900 truncate">
{selectedEpisode.title}
</h3>
<p className="text-sm text-gray-600">
{selectedEpisode.feed.title}
</p>
</div>
<button
onClick={() => handlePlay(selectedEpisode)}
className="w-12 h-12 bg-white rounded-full shadow-md flex items-center justify-center hover:shadow-lg transition-shadow duration-200"
aria-label={isPlaying ? "一時停止" : "再生"}
>
{isPlaying ? (
<svg className="w-6 h-6 text-gray-700" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zM7 8a1 1 0 012 0v4a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v4a1 1 0 102 0V8a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
) : (
<svg className="w-6 h-6 text-gray-700 ml-1" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clipRule="evenodd" />
</svg>
)}
</button>
></button>
</div>
{/* Progress Bar */}
<div className="space-y-2">
<input
@ -216,7 +203,12 @@ export default function EpisodePlayer() {
<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>
🗓{" "}
{new Date(selectedEpisode.createdAt).toLocaleDateString(
"ja-JP",
)}
</span>
<span>💾 {formatFileSize(selectedEpisode.fileSize)}</span>
{selectedEpisode.article.link && (
<a
@ -244,20 +236,28 @@ export default function EpisodePlayer() {
{/* 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>
<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>
<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 ? "別のキーワードで検索してみてください" : "フィードを追加してバッチ処理を実行してください"}
{searchTerm
? "別のキーワードで検索してみてください"
: "フィードを追加してバッチ処理を実行してください"}
</p>
</div>
) : (
@ -267,14 +267,14 @@ export default function EpisodePlayer() {
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'
? "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 === ' ') {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handlePlay(episode);
}
@ -282,24 +282,6 @@ export default function EpisodePlayer() {
aria-label={`エピソード: ${episode.title}`}
>
<div className="flex items-start space-x-4">
<div className="flex-shrink-0">
<div className={`w-12 h-12 rounded-full flex items-center justify-center ${
selectedEpisode?.id === episode.id
? 'bg-purple-500 text-white'
: 'bg-gray-100 text-gray-600'
}`}>
{selectedEpisode?.id === episode.id && isPlaying ? (
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zM7 8a1 1 0 012 0v4a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v4a1 1 0 102 0V8a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
) : (
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clipRule="evenodd" />
</svg>
)}
</div>
</div>
<div className="flex-1 min-w-0">
<h4 className="text-base font-medium text-gray-900 mb-1 line-clamp-2">
{episode.title}
@ -313,7 +295,12 @@ export default function EpisodePlayer() {
</p>
)}
<div className="flex items-center space-x-4 text-xs text-gray-500">
<span>📅 {new Date(episode.createdAt).toLocaleDateString('ja-JP')}</span>
<span>
📅{" "}
{new Date(episode.createdAt).toLocaleDateString(
"ja-JP",
)}
</span>
<span>💾 {formatFileSize(episode.fileSize)}</span>
{episode.article.link && (
<a
@ -336,4 +323,4 @@ export default function EpisodePlayer() {
</div>
</div>
);
}
}

View File

@ -6,12 +6,24 @@ interface FeedItem {
link: string;
pubDate: string;
contentSnippet?: string;
source?: {
title?: string;
url?: string;
};
category?: string;
}
export default function FeedList() {
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();
@ -19,44 +31,242 @@ export default function FeedList() {
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);
setLoading(false);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "エラーが発生しました");
} finally {
setLoading(false);
}
};
if (loading) return <div>...</div>;
if (error) return <div className="text-red-500">: {error}</div>;
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-4">
{feeds.map((feed) => (
<div
key={feed.id}
className="border rounded-lg p-4 hover:shadow-md transition-shadow"
>
<h3 className="text-lg font-medium">{feed.title}</h3>
<p className="text-sm text-gray-500">{feed.pubDate}</p>
{feed.contentSnippet && (
<p className="mt-2 text-gray-700">{feed.contentSnippet}</p>
)}
<a
href={feed.link}
className="mt-3 inline-block text-blue-500 hover:underline"
target="_blank"
rel="noopener noreferrer"
>
</a>
<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

@ -40,13 +40,13 @@ export default function FeedManager() {
const addFeed = async (e: React.FormEvent) => {
e.preventDefault();
if (!newFeedUrl.trim()) {
alert("フィードURLを入力してください");
return;
}
if (!newFeedUrl.startsWith('http')) {
if (!newFeedUrl.startsWith("http")) {
alert("有効なURLを入力してください");
return;
}
@ -96,10 +96,15 @@ export default function FeedManager() {
<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>
<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">
<label
htmlFor="feedUrl"
className="block text-sm font-medium text-gray-700 mb-2"
>
RSS URL
</label>
<div className="flex space-x-3">
@ -139,11 +144,6 @@ export default function FeedManager() {
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex items-center">
<div className="text-red-400">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800"></h3>
<p className="text-sm text-red-700">{error}</p>
@ -155,17 +155,25 @@ export default function FeedManager() {
{/* Feeds List */}
<div>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900"></h3>
<h3 className="text-lg font-semibold text-gray-900">
</h3>
<span className="text-sm text-gray-500">{feeds.length} </span>
</div>
{feeds.length === 0 ? (
<div className="text-center py-12 bg-gray-50 rounded-xl">
<div className="w-16 h-16 bg-gray-200 rounded-full flex items-center justify-center mx-auto mb-4">
<span role="img" aria-hidden="true" className="text-2xl">📡</span>
<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>
<h3 className="text-lg font-medium text-gray-900 mb-2">
</h3>
<p className="text-gray-500">
RSS
</p>
</div>
) : (
<div className="grid gap-4">
@ -177,21 +185,25 @@ export default function FeedManager() {
<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>
<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 || 'タイトル未取得'}
{feed.title || "タイトル未取得"}
</h4>
</div>
{feed.description && (
<p className="text-sm text-gray-600 mb-3 line-clamp-2">
{feed.description}
</p>
)}
<div className="space-y-2">
<div className="flex items-center space-x-2">
<span className="text-xs font-medium text-gray-500">URL:</span>
<span className="text-xs font-medium text-gray-500">
URL:
</span>
<a
href={feed.url}
target="_blank"
@ -202,35 +214,41 @@ export default function FeedManager() {
{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>
<span>
:{" "}
{new Date(feed.createdAt).toLocaleDateString("ja-JP")}
</span>
{feed.lastUpdated && (
<span>: {new Date(feed.lastUpdated).toLocaleDateString('ja-JP')}</span>
<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
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
feed.active
? "bg-green-100 text-green-800"
: "bg-gray-100 text-gray-800"
}`}
>
{feed.active ? "アクティブ" : "無効"}
</span>
{/* Future: Add edit/delete buttons here */}
<button
className="text-gray-400 hover:text-gray-600 p-1"
title="設定"
aria-label={`${feed.title || feed.url}の設定`}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
</svg>
</button>
></button>
</div>
</div>
</div>
@ -240,4 +258,4 @@ export default function FeedManager() {
</div>
</div>
);
}
}