feat: 記事ごとのポッドキャスト生成と新規記事検出システム、モダンUIの実装
- 新規記事検出システム: 記事の重複チェックと新規記事のみ処理 - 記事単位ポッドキャスト: フィード統合から記事個別エピソードに変更 - 6時間間隔バッチ処理: 自動定期実行スケジュールの改善 - 完全UIリニューアル: ダッシュボード・フィード管理・エピソード管理の3画面構成 - アクセシビリティ強化: ARIA属性、キーボードナビ、高コントラスト対応 - データベース刷新: feeds/articles/episodes階層構造への移行 - 中央集権設定管理: services/config.ts による設定統一 - エラーハンドリング改善: 全モジュールでの堅牢なエラー処理 - TypeScript型安全性向上: null安全性とインターフェース改善 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@ -1,23 +1,122 @@
|
||||
/* Global styles for the Next.js app */
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Custom styles for better accessibility and design */
|
||||
.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;
|
||||
}
|
||||
|
||||
/* Custom slider styles */
|
||||
.slider::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: #8b5cf6;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.slider::-webkit-slider-thumb:hover {
|
||||
background: #7c3aed;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.slider::-moz-range-thumb {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: #8b5cf6;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Focus states for accessibility */
|
||||
.focus-ring:focus {
|
||||
outline: 2px solid #8b5cf6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Animation for loading states */
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: .5;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-pulse {
|
||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
/* Improved scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
||||
/* High contrast mode support */
|
||||
@media (prefers-contrast: high) {
|
||||
.border-gray-200 {
|
||||
border-color: #000;
|
||||
}
|
||||
|
||||
.text-gray-600 {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.bg-gray-50 {
|
||||
background-color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reduced motion support */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Global font and base styles */
|
||||
html,
|
||||
body {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans',
|
||||
sans-serif;
|
||||
background-color: #f9fafb; /* light background */
|
||||
color: #111827; /* dark text */
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Center content by default */
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
}
|
@ -1,5 +1,7 @@
|
||||
import FeedList from "../components/FeedList";
|
||||
import { useState } from "react";
|
||||
import FeedManager from "../components/FeedManager";
|
||||
import EpisodePlayer from "../components/EpisodePlayer";
|
||||
import Dashboard from "../components/Dashboard";
|
||||
|
||||
export const metadata = {
|
||||
title: "Voice RSS Summary",
|
||||
@ -7,20 +9,99 @@ export const metadata = {
|
||||
};
|
||||
|
||||
export default function Home() {
|
||||
const [activeTab, setActiveTab] = useState<'dashboard' | 'episodes' | 'feeds'>('dashboard');
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<section>
|
||||
<h1 className="text-2xl font-bold mb-4">Voice RSS Summary</h1>
|
||||
<p className="mb-6">RSSフィードから自動生成された音声ポッドキャストを再生・管理できます。</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold mb-4">フィード一覧</h2>
|
||||
<FeedList />
|
||||
</section>
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold mb-4">エピソードプレイヤー</h2>
|
||||
<EpisodePlayer />
|
||||
</section>
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-purple-50">
|
||||
{/* Header */}
|
||||
<header className="bg-white shadow-sm border-b">
|
||||
<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-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M9 12a3 3 0 106 0 3 3 0 00-6 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Voice RSS Summary</h1>
|
||||
<p className="text-sm text-gray-600">AI音声ポッドキャスト自動生成システム</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden md:flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-2 text-sm text-gray-500">
|
||||
<div className="w-2 h-2 bg-green-400 rounded-full animate-pulse"></div>
|
||||
<span>システム稼働中</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="bg-white border-b border-gray-200">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex space-x-8">
|
||||
{[
|
||||
{ id: 'dashboard', label: 'ダッシュボード', icon: '📊' },
|
||||
{ id: 'episodes', label: 'エピソード', icon: '🎧' },
|
||||
{ id: 'feeds', label: 'フィード管理', icon: '📡' }
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id as any)}
|
||||
className={`flex items-center space-x-2 py-4 px-1 border-b-2 font-medium text-sm transition-colors duration-200 ${
|
||||
activeTab === tab.id
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
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="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="space-y-8">
|
||||
{activeTab === 'dashboard' && <Dashboard />}
|
||||
{activeTab === 'episodes' && (
|
||||
<div className="bg-white rounded-xl shadow-sm p-6">
|
||||
<div className="flex items-center space-x-3 mb-6">
|
||||
<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>
|
||||
<h2 className="text-xl font-semibold text-gray-900">エピソード管理</h2>
|
||||
</div>
|
||||
<EpisodePlayer />
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'feeds' && (
|
||||
<div className="bg-white rounded-xl shadow-sm p-6">
|
||||
<div className="flex items-center space-x-3 mb-6">
|
||||
<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>
|
||||
<h2 className="text-xl font-semibold text-gray-900">フィード管理</h2>
|
||||
</div>
|
||||
<FeedManager />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="bg-gray-50 border-t mt-16">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="text-center text-gray-500 text-sm">
|
||||
<p>© 2025 Voice RSS Summary. AI技術により最新のニュースを音声でお届けします。</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
255
frontend/src/components/Dashboard.tsx
Normal file
255
frontend/src/components/Dashboard.tsx
Normal file
@ -0,0 +1,255 @@
|
||||
import { useEffect, useState } 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("エラーが発生しました。");
|
||||
}
|
||||
};
|
||||
|
||||
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="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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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">
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
) : (
|
||||
<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">
|
||||
生成済み
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,24 +1,62 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
|
||||
interface Episode {
|
||||
id: string;
|
||||
title: string;
|
||||
pubDate: string;
|
||||
description?: string;
|
||||
audioPath: string;
|
||||
sourceLink: 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 [audioUrl, setAudioUrl] = useState<string | 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]);
|
||||
|
||||
const fetchEpisodes = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/episodes");
|
||||
@ -35,45 +73,267 @@ export default function EpisodePlayer() {
|
||||
};
|
||||
|
||||
const handlePlay = (episode: Episode) => {
|
||||
setSelectedEpisode(episode);
|
||||
setAudioUrl(`/podcast_audio/${episode.audioPath}`);
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div>読み込み中...</div>;
|
||||
if (error) return <div className="text-red-500">エラー: {error}</div>;
|
||||
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="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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">最近のエピソード</h3>
|
||||
<div className="space-y-2">
|
||||
{episodes.map((episode) => (
|
||||
<div
|
||||
key={episode.id}
|
||||
className="flex justify-between items-center p-2 hover:bg-gray-50 rounded"
|
||||
>
|
||||
<span>{episode.title}</span>
|
||||
<button
|
||||
onClick={() => handlePlay(episode)}
|
||||
className="px-3 py-1 bg-blue-500 text-white rounded hover:bg-blue-600"
|
||||
>
|
||||
再生
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<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="エピソードを検索..."
|
||||
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="mt-6">
|
||||
<h4 className="text-md font-semibold mb-2">
|
||||
再生中: {selectedEpisode.title}
|
||||
</h4>
|
||||
{audioUrl ? (
|
||||
<audio src={audioUrl} controls className="w-full" />
|
||||
) : (
|
||||
<div>音声ファイルを読み込み中...</div>
|
||||
)}
|
||||
<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 ? "一時停止" : "再生"}
|
||||
>
|
||||
{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>
|
||||
</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-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}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
}
|
243
frontend/src/components/FeedManager.tsx
Normal file
243
frontend/src/components/FeedManager.tsx
Normal file
@ -0,0 +1,243 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface Feed {
|
||||
id: string;
|
||||
url: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
lastUpdated?: string;
|
||||
createdAt: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
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 addFeed = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!newFeedUrl.trim()) {
|
||||
alert("フィードURLを入力してください");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!newFeedUrl.startsWith('http')) {
|
||||
alert("有効なURLを入力してください");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setAddingFeed(true);
|
||||
const response = await fetch("/api/feeds", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ feedUrl: newFeedUrl }),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
if (result.result === "EXISTS") {
|
||||
alert("このフィードは既に登録されています");
|
||||
} else {
|
||||
alert("フィードが正常に追加されました");
|
||||
setNewFeedUrl("");
|
||||
fetchFeeds(); // Refresh the list
|
||||
}
|
||||
} else {
|
||||
alert(result.error || "フィードの追加に失敗しました");
|
||||
}
|
||||
} catch (err) {
|
||||
alert("エラーが発生しました");
|
||||
} finally {
|
||||
setAddingFeed(false);
|
||||
}
|
||||
};
|
||||
|
||||
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="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>
|
||||
</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="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>
|
||||
</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>
|
||||
|
||||
{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>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{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 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}の設定`}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
Reference in New Issue
Block a user