Update
This commit is contained in:
@ -1,11 +1,132 @@
|
|||||||
import './app/globals.css';
|
import { useState } from "react";
|
||||||
import RootLayout from './app/layout';
|
import "./app/globals.css";
|
||||||
import Home from './app/page';
|
import Dashboard from "./components/Dashboard";
|
||||||
|
import EpisodePlayer from "./components/EpisodePlayer";
|
||||||
|
import FeedManager from "./components/FeedManager";
|
||||||
|
|
||||||
|
type TabType = "dashboard" | "episodes" | "feeds";
|
||||||
|
|
||||||
|
interface Tab {
|
||||||
|
id: TabType;
|
||||||
|
label: string;
|
||||||
|
icon: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabs: Tab[] = [
|
||||||
|
{
|
||||||
|
id: "dashboard",
|
||||||
|
label: "ダッシュボード",
|
||||||
|
icon: "📊",
|
||||||
|
description: "システム概要と最新情報",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "episodes",
|
||||||
|
label: "エピソード",
|
||||||
|
icon: "🎧",
|
||||||
|
description: "ポッドキャスト再生と管理",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "feeds",
|
||||||
|
label: "フィード管理",
|
||||||
|
icon: "📡",
|
||||||
|
description: "RSSフィードの設定",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
const [activeTab, setActiveTab] = useState<TabType>("dashboard");
|
||||||
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||||
|
|
||||||
|
const activeTabInfo = tabs.find((tab) => tab.id === activeTab);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RootLayout>
|
<html lang="ja">
|
||||||
<Home />
|
<head>
|
||||||
</RootLayout>
|
<meta charSet="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Voice RSS Summary - AI音声ポッドキャスト生成システム</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="RSSフィードから自動生成された音声ポッドキャストをご紹介します。"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body className="min-h-screen bg-gray-50">
|
||||||
|
<div className="min-h-screen flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="bg-white border-b border-gray-200 sticky top-0 z-50">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex justify-between items-center py-4">
|
||||||
|
{/* Status and Mobile Menu */}
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
{/* System Status */}
|
||||||
|
<div className="hidden md:flex items-center space-x-2 px-3 py-1 rounded-full bg-green-100 text-green-800">
|
||||||
|
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||||
|
<span className="text-sm font-medium">稼働中</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile menu button */}
|
||||||
|
<button
|
||||||
|
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
||||||
|
className="md:hidden p-2 rounded-lg bg-gray-100 hover:bg-gray-200 transition-colors"
|
||||||
|
aria-label="メニューを開く"
|
||||||
|
></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<nav
|
||||||
|
className={`bg-white border-b border-gray-200 ${isMenuOpen ? "block" : "hidden md:block"}`}
|
||||||
|
>
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex flex-col md:flex-row">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => {
|
||||||
|
setActiveTab(tab.id);
|
||||||
|
setIsMenuOpen(false);
|
||||||
|
}}
|
||||||
|
className={`flex items-center space-x-3 py-3 px-4 text-sm font-medium transition-colors ${
|
||||||
|
activeTab === tab.id
|
||||||
|
? "text-blue-600 border-b-2 border-blue-600"
|
||||||
|
: "text-gray-600 hover:text-gray-900 border-b-2 border-transparent"
|
||||||
|
}`}
|
||||||
|
aria-current={activeTab === tab.id ? "page" : undefined}
|
||||||
|
>
|
||||||
|
<span role="img" aria-hidden="true">
|
||||||
|
{tab.icon}
|
||||||
|
</span>
|
||||||
|
<span>{tab.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<main className="flex-1">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
{activeTab === "dashboard" && <Dashboard />}
|
||||||
|
{activeTab === "episodes" && <EpisodePlayer />}
|
||||||
|
{activeTab === "feeds" && <FeedManager />}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className="mt-auto bg-white border-t border-gray-200">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-gray-500 text-sm">
|
||||||
|
© 2025 Voice RSS Summary. All rights reserved.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -2,7 +2,19 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
/* Custom styles for better accessibility and design */
|
/* Minimal CSS Variables */
|
||||||
|
:root {
|
||||||
|
--primary: #3b82f6;
|
||||||
|
--primary-hover: #2563eb;
|
||||||
|
--border: #e5e7eb;
|
||||||
|
--border-hover: #d1d5db;
|
||||||
|
--background: #ffffff;
|
||||||
|
--muted: #f9fafb;
|
||||||
|
--text: #111827;
|
||||||
|
--text-muted: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.line-clamp-1 {
|
.line-clamp-1 {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
@ -17,54 +29,40 @@
|
|||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Custom slider styles */
|
.slider {
|
||||||
|
background: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
.slider::-webkit-slider-thumb {
|
.slider::-webkit-slider-thumb {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #8b5cf6;
|
background: var(--primary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
|
border: 2px solid white;
|
||||||
transition: all 0.2s ease;
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
}
|
|
||||||
|
|
||||||
.slider::-webkit-slider-thumb:hover {
|
|
||||||
background: #7c3aed;
|
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.slider::-moz-range-thumb {
|
.slider::-moz-range-thumb {
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #8b5cf6;
|
background: var(--primary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border: none;
|
border: 2px solid white;
|
||||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Focus states for accessibility */
|
button:focus,
|
||||||
.focus-ring:focus {
|
input:focus,
|
||||||
outline: 2px solid #8b5cf6;
|
select:focus,
|
||||||
|
textarea:focus {
|
||||||
|
outline: 2px solid var(--primary);
|
||||||
outline-offset: 2px;
|
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 {
|
::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
}
|
}
|
||||||
@ -82,41 +80,46 @@
|
|||||||
background: #94a3b8;
|
background: #94a3b8;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* High contrast mode support */
|
.btn-primary {
|
||||||
@media (prefers-contrast: high) {
|
background: var(--primary);
|
||||||
.border-gray-200 {
|
color: white;
|
||||||
border-color: #000;
|
border: none;
|
||||||
}
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
.text-gray-600 {
|
font-weight: 500;
|
||||||
color: #000;
|
transition: background-color 0.2s;
|
||||||
}
|
|
||||||
|
|
||||||
.bg-gray-50 {
|
|
||||||
background-color: #fff;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Reduced motion support */
|
.btn-primary:hover {
|
||||||
|
background: var(--primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
* {
|
* {
|
||||||
animation-duration: 0.01ms !important;
|
animation-duration: 0.01ms !important;
|
||||||
animation-iteration-count: 1 !important;
|
|
||||||
transition-duration: 0.01ms !important;
|
transition-duration: 0.01ms !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Global font and base styles */
|
html {
|
||||||
html,
|
scroll-behavior: smooth;
|
||||||
body {
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans',
|
|
||||||
sans-serif;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
*,
|
body {
|
||||||
*::before,
|
margin: 0;
|
||||||
*::after {
|
padding: 0 1rem;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: #f9fafb;
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: #dbeafe;
|
||||||
|
color: #1e40af;
|
||||||
}
|
}
|
@ -5,33 +5,37 @@ import Dashboard from "../components/Dashboard";
|
|||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "Voice RSS Summary",
|
title: "Voice RSS Summary",
|
||||||
description: "RSSフィードから自動生成された音声ポッドキャストをご紹介します。",
|
description:
|
||||||
|
"RSSフィードから自動生成された音声ポッドキャストをご紹介します。",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const [activeTab, setActiveTab] = useState<'dashboard' | 'episodes' | 'feeds'>('dashboard');
|
const [activeTab, setActiveTab] = useState<
|
||||||
|
"dashboard" | "episodes" | "feeds"
|
||||||
|
>("dashboard");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-purple-50">
|
<div className="min-h-screen">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="bg-white shadow-sm border-b">
|
<header className="glass-effect border-b border-white/20 sticky top-0 z-50 backdrop-blur-xl">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="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 justify-between items-center py-6">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-4 animate-fadeIn">
|
||||||
<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>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Voice RSS Summary</h1>
|
<h1 className="text-3xl font-bold gradient-text">
|
||||||
<p className="text-sm text-gray-600">AI音声ポッドキャスト自動生成システム</p>
|
Voice RSS Summary
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-slate-600 font-medium">
|
||||||
|
AI音声ポッドキャスト自動生成システム
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden md:flex items-center space-x-4">
|
<div className="hidden md:flex items-center space-x-6">
|
||||||
<div className="flex items-center space-x-2 text-sm text-gray-500">
|
<div className="flex items-center space-x-3 px-4 py-2 rounded-full bg-green-50 border border-green-200">
|
||||||
<div className="w-2 h-2 bg-green-400 rounded-full animate-pulse"></div>
|
<div className="w-3 h-3 bg-green-500 rounded-full animate-pulse shadow-lg"></div>
|
||||||
<span>システム稼働中</span>
|
<span className="text-sm font-semibold text-green-700">
|
||||||
|
システム稼働中
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -39,26 +43,41 @@ export default function Home() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<nav className="bg-white border-b border-gray-200">
|
<nav className="glass-effect border-b border-white/20 relative">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="flex space-x-8">
|
<div className="flex space-x-2">
|
||||||
{[
|
{[
|
||||||
{ id: 'dashboard', label: 'ダッシュボード', icon: '📊' },
|
{ id: "dashboard", label: "ダッシュボード", icon: "📊" },
|
||||||
{ id: 'episodes', label: 'エピソード', icon: '🎧' },
|
{ id: "episodes", label: "エピソード", icon: "🎧" },
|
||||||
{ id: 'feeds', label: 'フィード管理', icon: '📡' }
|
{ id: "feeds", label: "フィード管理", icon: "📡" },
|
||||||
].map((tab) => (
|
].map((tab, index) => (
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
onClick={() => setActiveTab(tab.id as any)}
|
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 ${
|
className={`relative flex items-center space-x-3 py-4 px-6 font-semibold text-sm rounded-t-xl transition-all duration-300 transform hover:scale-105 ${
|
||||||
activeTab === tab.id
|
activeTab === tab.id
|
||||||
? 'border-blue-500 text-blue-600'
|
? "bg-white text-slate-800 shadow-lg border-b-2 border-transparent"
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
: "text-slate-600 hover:text-slate-800 hover:bg-white/50"
|
||||||
}`}
|
}`}
|
||||||
aria-current={activeTab === tab.id ? 'page' : undefined}
|
style={{
|
||||||
|
animationDelay: `${index * 0.1}s`,
|
||||||
|
}}
|
||||||
|
aria-current={activeTab === tab.id ? "page" : undefined}
|
||||||
>
|
>
|
||||||
<span role="img" aria-hidden="true">{tab.icon}</span>
|
<span
|
||||||
<span>{tab.label}</span>
|
className="text-lg animate-fadeIn"
|
||||||
|
role="img"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{tab.icon}
|
||||||
|
</span>
|
||||||
|
<span className="animate-slideIn">{tab.label}</span>
|
||||||
|
{activeTab === tab.id && (
|
||||||
|
<div
|
||||||
|
className="absolute bottom-0 left-0 right-0 h-1 rounded-t-full"
|
||||||
|
style={{ background: "var(--gradient-primary)" }}
|
||||||
|
></div>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -66,27 +85,59 @@ export default function Home() {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{activeTab === 'dashboard' && <Dashboard />}
|
{activeTab === "dashboard" && (
|
||||||
{activeTab === 'episodes' && (
|
<div className="animate-fadeIn">
|
||||||
<div className="bg-white rounded-xl shadow-sm p-6">
|
<Dashboard />
|
||||||
<div className="flex items-center space-x-3 mb-6">
|
</div>
|
||||||
<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>
|
{activeTab === "episodes" && (
|
||||||
|
<div className="glass-effect rounded-3xl shadow-2xl p-8 border border-white/20 animate-fadeIn">
|
||||||
|
<div className="flex items-center space-x-4 mb-8">
|
||||||
|
<div
|
||||||
|
className="w-12 h-12 rounded-2xl flex items-center justify-center shadow-lg"
|
||||||
|
style={{
|
||||||
|
background: "linear-gradient(135deg, #a855f7, #7c3aed)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span role="img" aria-hidden="true" className="text-2xl">
|
||||||
|
🎧
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-slate-800">
|
||||||
|
エピソード管理
|
||||||
|
</h2>
|
||||||
|
<p className="text-slate-600 text-sm">
|
||||||
|
ポッドキャストエピソードの再生と管理
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-xl font-semibold text-gray-900">エピソード管理</h2>
|
|
||||||
</div>
|
</div>
|
||||||
<EpisodePlayer />
|
<EpisodePlayer />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{activeTab === 'feeds' && (
|
{activeTab === "feeds" && (
|
||||||
<div className="bg-white rounded-xl shadow-sm p-6">
|
<div className="glass-effect rounded-3xl shadow-2xl p-8 border border-white/20 animate-fadeIn">
|
||||||
<div className="flex items-center space-x-3 mb-6">
|
<div className="flex items-center space-x-4 mb-8">
|
||||||
<div className="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center">
|
<div
|
||||||
<span role="img" aria-hidden="true" className="text-lg">📡</span>
|
className="w-12 h-12 rounded-2xl flex items-center justify-center shadow-lg"
|
||||||
|
style={{
|
||||||
|
background: "linear-gradient(135deg, #0ea5e9, #0284c7)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span role="img" aria-hidden="true" className="text-2xl">
|
||||||
|
📡
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-slate-800">
|
||||||
|
フィード管理
|
||||||
|
</h2>
|
||||||
|
<p className="text-slate-600 text-sm">
|
||||||
|
RSSフィードの追加と管理
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-xl font-semibold text-gray-900">フィード管理</h2>
|
|
||||||
</div>
|
</div>
|
||||||
<FeedManager />
|
<FeedManager />
|
||||||
</div>
|
</div>
|
||||||
@ -95,13 +146,33 @@ export default function Home() {
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<footer className="bg-gray-50 border-t mt-16">
|
<footer className="mt-24 relative">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div className="glass-effect border-t border-white/20">
|
||||||
<div className="text-center text-gray-500 text-sm">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||||
<p>© 2025 Voice RSS Summary. AI技術により最新のニュースを音声でお届けします。</p>
|
<div className="text-center">
|
||||||
|
<div className="flex items-center justify-center space-x-3 mb-4">
|
||||||
|
<div
|
||||||
|
className="w-8 h-8 rounded-lg flex items-center justify-center"
|
||||||
|
style={{ background: "var(--gradient-primary)" }}
|
||||||
|
></div>
|
||||||
|
<span className="text-lg font-bold gradient-text">
|
||||||
|
Voice RSS Summary
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-slate-600 text-sm font-medium max-w-2xl mx-auto leading-relaxed">
|
||||||
|
AI技術により最新のニュースを音声でお届けします。
|
||||||
|
<br />
|
||||||
|
自動化されたポッドキャスト生成で、いつでもどこでも情報をキャッチアップ。
|
||||||
|
</p>
|
||||||
|
<div className="mt-6 pt-6 border-t border-white/20">
|
||||||
|
<p className="text-slate-500 text-xs">
|
||||||
|
© 2025 Voice RSS Summary. All rights reserved.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -35,7 +35,7 @@ export default function Dashboard() {
|
|||||||
// Fetch stats and recent episodes in parallel
|
// Fetch stats and recent episodes in parallel
|
||||||
const [statsResponse, episodesResponse] = await Promise.all([
|
const [statsResponse, episodesResponse] = await Promise.all([
|
||||||
fetch("/api/stats"),
|
fetch("/api/stats"),
|
||||||
fetch("/api/episodes")
|
fetch("/api/episodes"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!statsResponse.ok || !episodesResponse.ok) {
|
if (!statsResponse.ok || !episodesResponse.ok) {
|
||||||
@ -58,7 +58,9 @@ export default function Dashboard() {
|
|||||||
try {
|
try {
|
||||||
const response = await fetch("/api/batch/trigger", { method: "POST" });
|
const response = await fetch("/api/batch/trigger", { method: "POST" });
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
alert("バッチ処理を開始しました。新しいエピソードの生成には時間がかかる場合があります。");
|
alert(
|
||||||
|
"バッチ処理を開始しました。新しいエピソードの生成には時間がかかる場合があります。",
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
alert("バッチ処理の開始に失敗しました。");
|
alert("バッチ処理の開始に失敗しました。");
|
||||||
}
|
}
|
||||||
@ -82,11 +84,6 @@ export default function Dashboard() {
|
|||||||
return (
|
return (
|
||||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||||
<div className="flex items-center">
|
<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">
|
<div className="ml-3">
|
||||||
<h3 className="text-sm font-medium text-red-800">エラー</h3>
|
<h3 className="text-sm font-medium text-red-800">エラー</h3>
|
||||||
<p className="text-sm text-red-700">{error}</p>
|
<p className="text-sm text-red-700">{error}</p>
|
||||||
@ -99,157 +96,187 @@ export default function Dashboard() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* Stats Cards */}
|
{/* Stats Cards */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6">
|
||||||
<div className="bg-white rounded-xl shadow-sm p-6 border border-gray-100">
|
<div className="bg-white rounded-lg shadow p-6 border border-gray-200">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex-shrink-0">
|
<div>
|
||||||
<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-sm font-medium text-gray-600">総フィード数</p>
|
||||||
<p className="text-2xl font-semibold text-gray-900">{stats?.totalFeeds || 0}</p>
|
<p className="text-2xl font-bold text-gray-900 mt-2">
|
||||||
</div>
|
{stats?.totalFeeds || 0}
|
||||||
</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>
|
</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action Cards */}
|
{/* Action Cards */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg: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">
|
{/* Manual Batch Execution */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="bg-blue-600 rounded-lg shadow p-6 text-white">
|
||||||
<div>
|
<h3 className="text-lg font-semibold mb-2">手動バッチ実行</h3>
|
||||||
<h3 className="text-lg font-semibold">手動バッチ実行</h3>
|
<p className="text-blue-100 text-sm mb-4">
|
||||||
<p className="text-blue-100 text-sm mt-1">
|
新しい記事をすぐにチェックしてポッドキャストを生成します。
|
||||||
新しい記事をすぐにチェックしてポッドキャストを生成
|
</p>
|
||||||
</p>
|
<button
|
||||||
</div>
|
onClick={triggerBatchProcess}
|
||||||
<button
|
className="bg-white/20 hover:bg-white/30 text-white font-medium py-2 px-4 rounded transition-colors"
|
||||||
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>
|
||||||
>
|
|
||||||
実行
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white rounded-xl shadow-sm p-6 border border-gray-100">
|
{/* System Status */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="bg-white rounded-lg shadow p-6 border border-gray-200">
|
||||||
<div>
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||||
<h3 className="text-lg font-semibold text-gray-900">システム状態</h3>
|
システム状態
|
||||||
<div className="flex items-center space-x-2 mt-2">
|
</h3>
|
||||||
<div className="w-3 h-3 bg-green-400 rounded-full animate-pulse"></div>
|
|
||||||
<span className="text-sm text-gray-600">自動バッチ処理 (6時間間隔)</span>
|
<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>
|
</div>
|
||||||
<div className="text-green-500">
|
|
||||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<div className="flex items-center space-x-3 p-3 rounded-lg bg-blue-50">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
|
||||||
</svg>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Recent Episodes */}
|
{/* Recent Episodes */}
|
||||||
<div className="bg-white rounded-xl shadow-sm p-6 border border-gray-100">
|
<div className="bg-white rounded-lg shadow border border-gray-200">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="px-6 py-4 border-b border-gray-200">
|
||||||
<h3 className="text-lg font-semibold text-gray-900">最新エピソード</h3>
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm text-gray-500">{recentEpisodes.length} エピソード</span>
|
<h3 className="text-lg font-semibold text-gray-900">
|
||||||
</div>
|
最新エピソード
|
||||||
|
</h3>
|
||||||
{recentEpisodes.length === 0 ? (
|
<span className="text-sm text-gray-500">
|
||||||
<div className="text-center py-8">
|
{recentEpisodes.length} エピソード
|
||||||
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
</span>
|
||||||
<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>
|
||||||
) : (
|
</div>
|
||||||
<div className="space-y-4">
|
|
||||||
{recentEpisodes.map((episode) => (
|
<div className="p-6">
|
||||||
<div
|
{recentEpisodes.length === 0 ? (
|
||||||
key={episode.id}
|
<div className="text-center py-12">
|
||||||
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="text-4xl mb-4">🎧</div>
|
||||||
>
|
<h4 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
<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>
|
</h4>
|
||||||
</div>
|
<p className="text-gray-500">
|
||||||
<div className="flex-1 min-w-0">
|
フィードを追加してバッチ処理を実行してください。
|
||||||
<h4 className="text-sm font-medium text-gray-900 truncate">
|
</p>
|
||||||
{episode.title}
|
</div>
|
||||||
</h4>
|
) : (
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
<div className="space-y-4">
|
||||||
{episode.feed?.title} • {new Date(episode.createdAt).toLocaleDateString('ja-JP')}
|
{recentEpisodes.map((episode) => (
|
||||||
</p>
|
<div
|
||||||
{episode.article && (
|
key={episode.id}
|
||||||
<a
|
className="flex items-start space-x-4 p-4 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
|
||||||
href={episode.article.link}
|
>
|
||||||
target="_blank"
|
<div className="w-8 h-8 rounded-lg bg-blue-600 flex items-center justify-center flex-shrink-0">
|
||||||
rel="noopener noreferrer"
|
<span className="text-white text-xs">🎵</span>
|
||||||
className="text-xs text-blue-600 hover:text-blue-800 mt-1 inline-block"
|
</div>
|
||||||
>
|
|
||||||
元記事を見る →
|
<div className="flex-1 min-w-0">
|
||||||
</a>
|
<h4 className="text-sm font-medium text-gray-900 line-clamp-2">
|
||||||
)}
|
{episode.title}
|
||||||
</div>
|
</h4>
|
||||||
<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 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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
))}
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -31,7 +31,7 @@ export default function EpisodePlayer() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
|
||||||
const audioRef = useRef<HTMLAudioElement>(null);
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -46,14 +46,14 @@ export default function EpisodePlayer() {
|
|||||||
const updateDuration = () => setDuration(audio.duration);
|
const updateDuration = () => setDuration(audio.duration);
|
||||||
const handleEnded = () => setIsPlaying(false);
|
const handleEnded = () => setIsPlaying(false);
|
||||||
|
|
||||||
audio.addEventListener('timeupdate', updateTime);
|
audio.addEventListener("timeupdate", updateTime);
|
||||||
audio.addEventListener('loadedmetadata', updateDuration);
|
audio.addEventListener("loadedmetadata", updateDuration);
|
||||||
audio.addEventListener('ended', handleEnded);
|
audio.addEventListener("ended", handleEnded);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
audio.removeEventListener('timeupdate', updateTime);
|
audio.removeEventListener("timeupdate", updateTime);
|
||||||
audio.removeEventListener('loadedmetadata', updateDuration);
|
audio.removeEventListener("loadedmetadata", updateDuration);
|
||||||
audio.removeEventListener('ended', handleEnded);
|
audio.removeEventListener("ended", handleEnded);
|
||||||
};
|
};
|
||||||
}, [selectedEpisode]);
|
}, [selectedEpisode]);
|
||||||
|
|
||||||
@ -103,7 +103,7 @@ export default function EpisodePlayer() {
|
|||||||
if (isNaN(time)) return "0:00";
|
if (isNaN(time)) return "0:00";
|
||||||
const minutes = Math.floor(time / 60);
|
const minutes = Math.floor(time / 60);
|
||||||
const seconds = 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) => {
|
const formatFileSize = (bytes?: number) => {
|
||||||
@ -112,10 +112,11 @@ export default function EpisodePlayer() {
|
|||||||
return `${mb.toFixed(1)} MB`;
|
return `${mb.toFixed(1)} MB`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredEpisodes = episodes.filter(episode =>
|
const filteredEpisodes = episodes.filter(
|
||||||
episode.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
(episode) =>
|
||||||
episode.article.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
episode.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
episode.feed.title?.toLowerCase().includes(searchTerm.toLowerCase())
|
episode.article.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
episode.feed.title?.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@ -133,11 +134,6 @@ export default function EpisodePlayer() {
|
|||||||
return (
|
return (
|
||||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||||
<div className="flex items-center">
|
<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">
|
<div className="ml-3">
|
||||||
<h3 className="text-sm font-medium text-red-800">エラー</h3>
|
<h3 className="text-sm font-medium text-red-800">エラー</h3>
|
||||||
<p className="text-sm text-red-700">{error}</p>
|
<p className="text-sm text-red-700">{error}</p>
|
||||||
@ -151,11 +147,6 @@ export default function EpisodePlayer() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
<div className="relative">
|
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="エピソードを検索..."
|
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="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="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">
|
<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>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 truncate">{selectedEpisode.title}</h3>
|
<h3 className="text-lg font-semibold text-gray-900 truncate">
|
||||||
<p className="text-sm text-gray-600">{selectedEpisode.feed.title}</p>
|
{selectedEpisode.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
{selectedEpisode.feed.title}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => handlePlay(selectedEpisode)}
|
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"
|
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 ? "一時停止" : "再生"}
|
aria-label={isPlaying ? "一時停止" : "再生"}
|
||||||
>
|
></button>
|
||||||
{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>
|
</div>
|
||||||
|
|
||||||
{/* Progress Bar */}
|
{/* Progress Bar */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<input
|
<input
|
||||||
@ -216,7 +203,12 @@ export default function EpisodePlayer() {
|
|||||||
<p className="text-gray-700">{selectedEpisode.description}</p>
|
<p className="text-gray-700">{selectedEpisode.description}</p>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center space-x-4 text-gray-500">
|
<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>
|
<span>💾 {formatFileSize(selectedEpisode.fileSize)}</span>
|
||||||
{selectedEpisode.article.link && (
|
{selectedEpisode.article.link && (
|
||||||
<a
|
<a
|
||||||
@ -244,20 +236,28 @@ export default function EpisodePlayer() {
|
|||||||
{/* Episodes List */}
|
{/* Episodes List */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<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">
|
||||||
<span className="text-sm text-gray-500">{filteredEpisodes.length} エピソード</span>
|
エピソード一覧
|
||||||
|
</h3>
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
{filteredEpisodes.length} エピソード
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{filteredEpisodes.length === 0 ? (
|
{filteredEpisodes.length === 0 ? (
|
||||||
<div className="text-center py-12 bg-gray-50 rounded-xl">
|
<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">
|
<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>
|
</div>
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||||
{searchTerm ? "検索結果がありません" : "エピソードがありません"}
|
{searchTerm ? "検索結果がありません" : "エピソードがありません"}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-500">
|
<p className="text-gray-500">
|
||||||
{searchTerm ? "別のキーワードで検索してみてください" : "フィードを追加してバッチ処理を実行してください"}
|
{searchTerm
|
||||||
|
? "別のキーワードで検索してみてください"
|
||||||
|
: "フィードを追加してバッチ処理を実行してください"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@ -267,14 +267,14 @@ export default function EpisodePlayer() {
|
|||||||
key={episode.id}
|
key={episode.id}
|
||||||
className={`border rounded-xl p-4 transition-all duration-200 cursor-pointer ${
|
className={`border rounded-xl p-4 transition-all duration-200 cursor-pointer ${
|
||||||
selectedEpisode?.id === episode.id
|
selectedEpisode?.id === episode.id
|
||||||
? 'border-purple-300 bg-purple-50 shadow-md'
|
? "border-purple-300 bg-purple-50 shadow-md"
|
||||||
: 'border-gray-200 bg-white hover:border-gray-300 hover:shadow-sm'
|
: "border-gray-200 bg-white hover:border-gray-300 hover:shadow-sm"
|
||||||
}`}
|
}`}
|
||||||
onClick={() => handlePlay(episode)}
|
onClick={() => handlePlay(episode)}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handlePlay(episode);
|
handlePlay(episode);
|
||||||
}
|
}
|
||||||
@ -282,24 +282,6 @@ export default function EpisodePlayer() {
|
|||||||
aria-label={`エピソード: ${episode.title}`}
|
aria-label={`エピソード: ${episode.title}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start space-x-4">
|
<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">
|
<div className="flex-1 min-w-0">
|
||||||
<h4 className="text-base font-medium text-gray-900 mb-1 line-clamp-2">
|
<h4 className="text-base font-medium text-gray-900 mb-1 line-clamp-2">
|
||||||
{episode.title}
|
{episode.title}
|
||||||
@ -313,7 +295,12 @@ export default function EpisodePlayer() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center space-x-4 text-xs text-gray-500">
|
<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>
|
<span>💾 {formatFileSize(episode.fileSize)}</span>
|
||||||
{episode.article.link && (
|
{episode.article.link && (
|
||||||
<a
|
<a
|
||||||
@ -336,4 +323,4 @@ export default function EpisodePlayer() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -6,12 +6,24 @@ interface FeedItem {
|
|||||||
link: string;
|
link: string;
|
||||||
pubDate: string;
|
pubDate: string;
|
||||||
contentSnippet?: 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 [feeds, setFeeds] = useState<FeedItem[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [sortBy, setSortBy] = useState<'date' | 'title'>('date');
|
||||||
|
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchFeeds();
|
fetchFeeds();
|
||||||
@ -19,44 +31,242 @@ export default function FeedList() {
|
|||||||
|
|
||||||
const fetchFeeds = async () => {
|
const fetchFeeds = async () => {
|
||||||
try {
|
try {
|
||||||
|
setLoading(true);
|
||||||
const response = await fetch("/api/feeds");
|
const response = await fetch("/api/feeds");
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error("フィードの取得に失敗しました");
|
throw new Error("フィードの取得に失敗しました");
|
||||||
}
|
}
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setFeeds(data);
|
setFeeds(data);
|
||||||
setLoading(false);
|
setError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "エラーが発生しました");
|
setError(err instanceof Error ? err.message : "エラーが発生しました");
|
||||||
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) return <div>読み込み中...</div>;
|
const filteredAndSortedFeeds = feeds
|
||||||
if (error) return <div className="text-red-500">エラー: {error}</div>;
|
.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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-6">
|
||||||
{feeds.map((feed) => (
|
{/* Sort Controls */}
|
||||||
<div
|
<div className="glass-effect rounded-2xl p-4 border border-white/20">
|
||||||
key={feed.id}
|
<div className="flex items-center space-x-4">
|
||||||
className="border rounded-lg p-4 hover:shadow-md transition-shadow"
|
<span className="text-sm font-semibold text-slate-700">並び替え:</span>
|
||||||
>
|
<div className="flex space-x-2">
|
||||||
<h3 className="text-lg font-medium">{feed.title}</h3>
|
<button
|
||||||
<p className="text-sm text-gray-500">{feed.pubDate}</p>
|
onClick={() => handleSort('date')}
|
||||||
{feed.contentSnippet && (
|
className={`flex items-center space-x-2 px-3 py-1.5 rounded-lg text-sm font-medium transition-all duration-200 ${
|
||||||
<p className="mt-2 text-gray-700">{feed.contentSnippet}</p>
|
sortBy === 'date'
|
||||||
)}
|
? 'bg-blue-100 text-blue-800 border border-blue-200'
|
||||||
<a
|
: 'text-slate-600 hover:text-slate-800 hover:bg-slate-100'
|
||||||
href={feed.link}
|
}`}
|
||||||
className="mt-3 inline-block text-blue-500 hover:underline"
|
>
|
||||||
target="_blank"
|
<span>日時</span>
|
||||||
rel="noopener noreferrer"
|
{sortBy === 'date' && (
|
||||||
>
|
<span>
|
||||||
オリジナル記事へ
|
{sortOrder === 'asc' ? '↑' : '↓'}
|
||||||
</a>
|
</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>
|
||||||
))}
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -40,13 +40,13 @@ export default function FeedManager() {
|
|||||||
|
|
||||||
const addFeed = async (e: React.FormEvent) => {
|
const addFeed = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (!newFeedUrl.trim()) {
|
if (!newFeedUrl.trim()) {
|
||||||
alert("フィードURLを入力してください");
|
alert("フィードURLを入力してください");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!newFeedUrl.startsWith('http')) {
|
if (!newFeedUrl.startsWith("http")) {
|
||||||
alert("有効なURLを入力してください");
|
alert("有効なURLを入力してください");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -96,10 +96,15 @@ export default function FeedManager() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Add New Feed Form */}
|
{/* Add New Feed Form */}
|
||||||
<div className="bg-gradient-to-r from-blue-50 to-purple-50 rounded-xl p-6 border border-blue-100">
|
<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">
|
<form onSubmit={addFeed} className="space-y-4">
|
||||||
<div>
|
<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
|
RSS フィード URL
|
||||||
</label>
|
</label>
|
||||||
<div className="flex space-x-3">
|
<div className="flex space-x-3">
|
||||||
@ -139,11 +144,6 @@ export default function FeedManager() {
|
|||||||
{error && (
|
{error && (
|
||||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||||
<div className="flex items-center">
|
<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">
|
<div className="ml-3">
|
||||||
<h3 className="text-sm font-medium text-red-800">エラー</h3>
|
<h3 className="text-sm font-medium text-red-800">エラー</h3>
|
||||||
<p className="text-sm text-red-700">{error}</p>
|
<p className="text-sm text-red-700">{error}</p>
|
||||||
@ -155,17 +155,25 @@ export default function FeedManager() {
|
|||||||
{/* Feeds List */}
|
{/* Feeds List */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<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>
|
<span className="text-sm text-gray-500">{feeds.length} フィード</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{feeds.length === 0 ? (
|
{feeds.length === 0 ? (
|
||||||
<div className="text-center py-12 bg-gray-50 rounded-xl">
|
<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">
|
<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>
|
</div>
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-2">フィードがありません</h3>
|
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||||
<p className="text-gray-500">上のフォームから RSS フィードを追加してください</p>
|
フィードがありません
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-500">
|
||||||
|
上のフォームから RSS フィードを追加してください
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
@ -177,21 +185,25 @@ export default function FeedManager() {
|
|||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center space-x-3 mb-2">
|
<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">
|
<h4 className="text-lg font-medium text-gray-900 truncate">
|
||||||
{feed.title || 'タイトル未取得'}
|
{feed.title || "タイトル未取得"}
|
||||||
</h4>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{feed.description && (
|
{feed.description && (
|
||||||
<p className="text-sm text-gray-600 mb-3 line-clamp-2">
|
<p className="text-sm text-gray-600 mb-3 line-clamp-2">
|
||||||
{feed.description}
|
{feed.description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center space-x-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
|
<a
|
||||||
href={feed.url}
|
href={feed.url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@ -202,35 +214,41 @@ export default function FeedManager() {
|
|||||||
{feed.url}
|
{feed.url}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-4 text-xs text-gray-500">
|
<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 && (
|
{feed.lastUpdated && (
|
||||||
<span>最終更新: {new Date(feed.lastUpdated).toLocaleDateString('ja-JP')}</span>
|
<span>
|
||||||
|
最終更新:{" "}
|
||||||
|
{new Date(feed.lastUpdated).toLocaleDateString(
|
||||||
|
"ja-JP",
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-2 ml-4">
|
<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 ${
|
<span
|
||||||
feed.active
|
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||||
? 'bg-green-100 text-green-800'
|
feed.active
|
||||||
: 'bg-gray-100 text-gray-800'
|
? "bg-green-100 text-green-800"
|
||||||
}`}>
|
: "bg-gray-100 text-gray-800"
|
||||||
{feed.active ? 'アクティブ' : '無効'}
|
}`}
|
||||||
|
>
|
||||||
|
{feed.active ? "アクティブ" : "無効"}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Future: Add edit/delete buttons here */}
|
{/* Future: Add edit/delete buttons here */}
|
||||||
<button
|
<button
|
||||||
className="text-gray-400 hover:text-gray-600 p-1"
|
className="text-gray-400 hover:text-gray-600 p-1"
|
||||||
title="設定"
|
title="設定"
|
||||||
aria-label={`${feed.title || feed.url}の設定`}
|
aria-label={`${feed.title || feed.url}の設定`}
|
||||||
>
|
></button>
|
||||||
<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>
|
||||||
@ -240,4 +258,4 @@ export default function FeedManager() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
import React from 'react';
|
import { StrictMode } from 'react'
|
||||||
import ReactDOM from 'react-dom/client';
|
import { createRoot } from 'react-dom/client'
|
||||||
import App from './App';
|
import App from './App.tsx'
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<App />
|
||||||
</React.StrictMode>
|
</StrictMode>,
|
||||||
);
|
)
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
"name": "podcast-generator",
|
"name": "podcast-generator",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "bun run server.ts",
|
"start": "bun run build:frontend && bun run server.ts",
|
||||||
"build:frontend": "cd frontend && bun vite build",
|
"build:frontend": "cd frontend && bun vite build",
|
||||||
"dev:frontend": "cd frontend && bun vite dev"
|
"dev:frontend": "cd frontend && bun vite dev"
|
||||||
},
|
},
|
||||||
|
Reference in New Issue
Block a user