Apply formatting
This commit is contained in:
@ -1,28 +1,28 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist'] },
|
||||
{ ignores: ["dist"] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
"react-hooks": reactHooks,
|
||||
"react-refresh": reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
"react-refresh/only-export-components": [
|
||||
"warn",
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
);
|
||||
|
@ -1,13 +1,15 @@
|
||||
import { Routes, Route, Link, useLocation } from 'react-router-dom'
|
||||
import EpisodeList from './components/EpisodeList'
|
||||
import FeedManager from './components/FeedManager'
|
||||
import FeedList from './components/FeedList'
|
||||
import FeedDetail from './components/FeedDetail'
|
||||
import EpisodeDetail from './components/EpisodeDetail'
|
||||
import { Routes, Route, Link, useLocation } from "react-router-dom";
|
||||
import EpisodeList from "./components/EpisodeList";
|
||||
import FeedManager from "./components/FeedManager";
|
||||
import FeedList from "./components/FeedList";
|
||||
import FeedDetail from "./components/FeedDetail";
|
||||
import EpisodeDetail from "./components/EpisodeDetail";
|
||||
|
||||
function App() {
|
||||
const location = useLocation()
|
||||
const isMainPage = ['/', '/feeds', '/feed-requests'].includes(location.pathname)
|
||||
const location = useLocation();
|
||||
const isMainPage = ["/", "/feeds", "/feed-requests"].includes(
|
||||
location.pathname,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
@ -15,25 +17,27 @@ function App() {
|
||||
<>
|
||||
<div className="header">
|
||||
<div className="title">Voice RSS Summary</div>
|
||||
<div className="subtitle">RSS フィードから自動生成された音声ポッドキャスト</div>
|
||||
<div className="subtitle">
|
||||
RSS フィードから自動生成された音声ポッドキャスト
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tabs">
|
||||
<Link
|
||||
to="/"
|
||||
className={`tab ${location.pathname === '/' ? 'active' : ''}`}
|
||||
className={`tab ${location.pathname === "/" ? "active" : ""}`}
|
||||
>
|
||||
エピソード一覧
|
||||
</Link>
|
||||
<Link
|
||||
to="/feeds"
|
||||
className={`tab ${location.pathname === '/feeds' ? 'active' : ''}`}
|
||||
className={`tab ${location.pathname === "/feeds" ? "active" : ""}`}
|
||||
>
|
||||
フィード一覧
|
||||
</Link>
|
||||
<Link
|
||||
to="/feed-requests"
|
||||
className={`tab ${location.pathname === '/feed-requests' ? 'active' : ''}`}
|
||||
className={`tab ${location.pathname === "/feed-requests" ? "active" : ""}`}
|
||||
>
|
||||
フィードリクエスト
|
||||
</Link>
|
||||
@ -51,7 +55,7 @@ function App() {
|
||||
</Routes>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default App
|
||||
export default App;
|
||||
|
@ -1,59 +1,58 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useParams, Link } from "react-router-dom";
|
||||
|
||||
interface EpisodeWithFeedInfo {
|
||||
id: string
|
||||
title: string
|
||||
description?: string
|
||||
audioPath: string
|
||||
duration?: number
|
||||
fileSize?: number
|
||||
createdAt: string
|
||||
articleId: string
|
||||
articleTitle: string
|
||||
articleLink: string
|
||||
articlePubDate: string
|
||||
feedId: string
|
||||
feedTitle?: string
|
||||
feedUrl: string
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
audioPath: string;
|
||||
duration?: number;
|
||||
fileSize?: number;
|
||||
createdAt: string;
|
||||
articleId: string;
|
||||
articleTitle: string;
|
||||
articleLink: string;
|
||||
articlePubDate: string;
|
||||
feedId: string;
|
||||
feedTitle?: string;
|
||||
feedUrl: string;
|
||||
}
|
||||
|
||||
function EpisodeDetail() {
|
||||
const { episodeId } = useParams<{ episodeId: string }>()
|
||||
const [episode, setEpisode] = useState<EpisodeWithFeedInfo | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [useDatabase, setUseDatabase] = useState(true)
|
||||
const { episodeId } = useParams<{ episodeId: string }>();
|
||||
const [episode, setEpisode] = useState<EpisodeWithFeedInfo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [useDatabase, setUseDatabase] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchEpisode()
|
||||
}, [episodeId, useDatabase])
|
||||
fetchEpisode();
|
||||
}, [episodeId, useDatabase]);
|
||||
|
||||
const fetchEpisode = async () => {
|
||||
if (!episodeId) return
|
||||
if (!episodeId) return;
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
|
||||
setLoading(true);
|
||||
|
||||
if (useDatabase) {
|
||||
// Try to fetch from database with source info first
|
||||
const response = await fetch(`/api/episode-with-source/${episodeId}`)
|
||||
const response = await fetch(`/api/episode-with-source/${episodeId}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('データベースからの取得に失敗しました')
|
||||
throw new Error("データベースからの取得に失敗しました");
|
||||
}
|
||||
const data = await response.json()
|
||||
setEpisode(data.episode)
|
||||
const data = await response.json();
|
||||
setEpisode(data.episode);
|
||||
} else {
|
||||
// Fallback to XML parsing (existing functionality)
|
||||
const response = await fetch(`/api/episode/${episodeId}`)
|
||||
const response = await fetch(`/api/episode/${episodeId}`);
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'エピソードの取得に失敗しました')
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || "エピソードの取得に失敗しました");
|
||||
}
|
||||
const data = await response.json()
|
||||
const xmlEpisode = data.episode
|
||||
|
||||
const data = await response.json();
|
||||
const xmlEpisode = data.episode;
|
||||
|
||||
// Convert XML episode to EpisodeWithFeedInfo format
|
||||
const convertedEpisode: EpisodeWithFeedInfo = {
|
||||
id: xmlEpisode.id,
|
||||
@ -65,128 +64,146 @@ function EpisodeDetail() {
|
||||
articleTitle: xmlEpisode.title,
|
||||
articleLink: xmlEpisode.link,
|
||||
articlePubDate: xmlEpisode.pubDate,
|
||||
feedId: '',
|
||||
feedTitle: 'RSS Feed',
|
||||
feedUrl: ''
|
||||
}
|
||||
setEpisode(convertedEpisode)
|
||||
feedId: "",
|
||||
feedTitle: "RSS Feed",
|
||||
feedUrl: "",
|
||||
};
|
||||
setEpisode(convertedEpisode);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Episode fetch error:', err)
|
||||
console.error("Episode fetch error:", err);
|
||||
if (useDatabase) {
|
||||
// Fallback to XML if database fails
|
||||
console.log('Falling back to XML parsing...')
|
||||
setUseDatabase(false)
|
||||
return
|
||||
console.log("Falling back to XML parsing...");
|
||||
setUseDatabase(false);
|
||||
return;
|
||||
}
|
||||
setError(err instanceof Error ? err.message : 'エラーが発生しました')
|
||||
setError(err instanceof Error ? err.message : "エラーが発生しました");
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const shareEpisode = () => {
|
||||
const shareUrl = window.location.href
|
||||
navigator.clipboard.writeText(shareUrl).then(() => {
|
||||
alert('エピソードリンクをクリップボードにコピーしました')
|
||||
}).catch(() => {
|
||||
// Fallback for older browsers
|
||||
const textArea = document.createElement('textarea')
|
||||
textArea.value = shareUrl
|
||||
document.body.appendChild(textArea)
|
||||
textArea.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(textArea)
|
||||
alert('エピソードリンクをクリップボードにコピーしました')
|
||||
})
|
||||
}
|
||||
const shareUrl = window.location.href;
|
||||
navigator.clipboard
|
||||
.writeText(shareUrl)
|
||||
.then(() => {
|
||||
alert("エピソードリンクをクリップボードにコピーしました");
|
||||
})
|
||||
.catch(() => {
|
||||
// Fallback for older browsers
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = shareUrl;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(textArea);
|
||||
alert("エピソードリンクをクリップボードにコピーしました");
|
||||
});
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString('ja-JP')
|
||||
}
|
||||
return new Date(dateString).toLocaleString("ja-JP");
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes?: number) => {
|
||||
if (!bytes) return ''
|
||||
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
let unitIndex = 0
|
||||
let fileSize = bytes
|
||||
if (!bytes) return "";
|
||||
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
let unitIndex = 0;
|
||||
let fileSize = bytes;
|
||||
|
||||
while (fileSize >= 1024 && unitIndex < units.length - 1) {
|
||||
fileSize /= 1024
|
||||
unitIndex++
|
||||
fileSize /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
return `${fileSize.toFixed(1)} ${units[unitIndex]}`
|
||||
}
|
||||
return `${fileSize.toFixed(1)} ${units[unitIndex]}`;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="loading">読み込み中...</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="error">{error}</div>
|
||||
<Link to="/" className="btn btn-secondary" style={{ marginTop: '20px' }}>
|
||||
<Link
|
||||
to="/"
|
||||
className="btn btn-secondary"
|
||||
style={{ marginTop: "20px" }}
|
||||
>
|
||||
エピソード一覧に戻る
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!episode) {
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="error">エピソードが見つかりません</div>
|
||||
<Link to="/" className="btn btn-secondary" style={{ marginTop: '20px' }}>
|
||||
<Link
|
||||
to="/"
|
||||
className="btn btn-secondary"
|
||||
style={{ marginTop: "20px" }}
|
||||
>
|
||||
エピソード一覧に戻る
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="header">
|
||||
<Link to="/" className="btn btn-secondary" style={{ marginBottom: '20px' }}>
|
||||
<Link
|
||||
to="/"
|
||||
className="btn btn-secondary"
|
||||
style={{ marginBottom: "20px" }}
|
||||
>
|
||||
← エピソード一覧に戻る
|
||||
</Link>
|
||||
<div className="title" style={{ fontSize: '28px', marginBottom: '10px' }}>
|
||||
<div
|
||||
className="title"
|
||||
style={{ fontSize: "28px", marginBottom: "10px" }}
|
||||
>
|
||||
{episode.title}
|
||||
</div>
|
||||
<div className="subtitle" style={{ color: '#666', marginBottom: '20px' }}>
|
||||
<div
|
||||
className="subtitle"
|
||||
style={{ color: "#666", marginBottom: "20px" }}
|
||||
>
|
||||
作成日: {formatDate(episode.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="content">
|
||||
<div style={{ marginBottom: '30px' }}>
|
||||
<div style={{ marginBottom: "30px" }}>
|
||||
<audio
|
||||
controls
|
||||
className="audio-player"
|
||||
src={episode.audioPath}
|
||||
style={{ width: '100%', height: '60px' }}
|
||||
style={{ width: "100%", height: "60px" }}
|
||||
>
|
||||
お使いのブラウザは音声の再生に対応していません。
|
||||
</audio>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '15px', marginBottom: '30px' }}>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={shareEpisode}
|
||||
>
|
||||
<div style={{ display: "flex", gap: "15px", marginBottom: "30px" }}>
|
||||
<button className="btn btn-primary" onClick={shareEpisode}>
|
||||
このエピソードを共有
|
||||
</button>
|
||||
{episode.articleLink && (
|
||||
<a
|
||||
href={episode.articleLink}
|
||||
target="_blank"
|
||||
<a
|
||||
href={episode.articleLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
@ -194,62 +211,77 @@ function EpisodeDetail() {
|
||||
</a>
|
||||
)}
|
||||
{episode.feedId && (
|
||||
<Link
|
||||
to={`/feeds/${episode.feedId}`}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
<Link to={`/feeds/${episode.feedId}`} className="btn btn-secondary">
|
||||
このフィードの他のエピソード
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '30px' }}>
|
||||
<h3 style={{ marginBottom: '15px' }}>エピソード情報</h3>
|
||||
<div style={{
|
||||
backgroundColor: '#f8f9fa',
|
||||
padding: '20px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px'
|
||||
}}>
|
||||
<div style={{ marginBottom: "30px" }}>
|
||||
<h3 style={{ marginBottom: "15px" }}>エピソード情報</h3>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: "#f8f9fa",
|
||||
padding: "20px",
|
||||
borderRadius: "8px",
|
||||
fontSize: "14px",
|
||||
}}
|
||||
>
|
||||
{episode.feedTitle && (
|
||||
<div style={{ marginBottom: '10px' }}>
|
||||
<strong>ソースフィード:</strong>
|
||||
<Link to={`/feeds/${episode.feedId}`} style={{ marginLeft: '5px', color: '#007bff' }}>
|
||||
<div style={{ marginBottom: "10px" }}>
|
||||
<strong>ソースフィード:</strong>
|
||||
<Link
|
||||
to={`/feeds/${episode.feedId}`}
|
||||
style={{ marginLeft: "5px", color: "#007bff" }}
|
||||
>
|
||||
{episode.feedTitle}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
{episode.feedUrl && (
|
||||
<div style={{ marginBottom: '10px' }}>
|
||||
<strong>フィードURL:</strong>
|
||||
<a href={episode.feedUrl} target="_blank" rel="noopener noreferrer" style={{ marginLeft: '5px' }}>
|
||||
<div style={{ marginBottom: "10px" }}>
|
||||
<strong>フィードURL:</strong>
|
||||
<a
|
||||
href={episode.feedUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ marginLeft: "5px" }}
|
||||
>
|
||||
{episode.feedUrl}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{episode.articleTitle && episode.articleTitle !== episode.title && (
|
||||
<div style={{ marginBottom: '10px' }}>
|
||||
<div style={{ marginBottom: "10px" }}>
|
||||
<strong>元記事タイトル:</strong> {episode.articleTitle}
|
||||
</div>
|
||||
)}
|
||||
{episode.articlePubDate && (
|
||||
<div style={{ marginBottom: '10px' }}>
|
||||
<strong>記事公開日:</strong> {formatDate(episode.articlePubDate)}
|
||||
<div style={{ marginBottom: "10px" }}>
|
||||
<strong>記事公開日:</strong>{" "}
|
||||
{formatDate(episode.articlePubDate)}
|
||||
</div>
|
||||
)}
|
||||
{episode.fileSize && (
|
||||
<div style={{ marginBottom: '10px' }}>
|
||||
<strong>ファイルサイズ:</strong> {formatFileSize(episode.fileSize)}
|
||||
<div style={{ marginBottom: "10px" }}>
|
||||
<strong>ファイルサイズ:</strong>{" "}
|
||||
{formatFileSize(episode.fileSize)}
|
||||
</div>
|
||||
)}
|
||||
{episode.duration && (
|
||||
<div style={{ marginBottom: '10px' }}>
|
||||
<strong>再生時間:</strong> {Math.floor(episode.duration / 60)}分{episode.duration % 60}秒
|
||||
<div style={{ marginBottom: "10px" }}>
|
||||
<strong>再生時間:</strong> {Math.floor(episode.duration / 60)}分
|
||||
{episode.duration % 60}秒
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<strong>音声URL:</strong>
|
||||
<a href={episode.audioPath} target="_blank" rel="noopener noreferrer" style={{ marginLeft: '5px' }}>
|
||||
<strong>音声URL:</strong>
|
||||
<a
|
||||
href={episode.audioPath}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ marginLeft: "5px" }}
|
||||
>
|
||||
直接ダウンロード
|
||||
</a>
|
||||
</div>
|
||||
@ -258,21 +290,23 @@ function EpisodeDetail() {
|
||||
|
||||
{episode.description && (
|
||||
<div>
|
||||
<h3 style={{ marginBottom: '15px' }}>エピソード詳細</h3>
|
||||
<div style={{
|
||||
backgroundColor: '#fff',
|
||||
padding: '20px',
|
||||
border: '1px solid #e9ecef',
|
||||
borderRadius: '8px',
|
||||
lineHeight: '1.6'
|
||||
}}>
|
||||
<h3 style={{ marginBottom: "15px" }}>エピソード詳細</h3>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: "#fff",
|
||||
padding: "20px",
|
||||
border: "1px solid #e9ecef",
|
||||
borderRadius: "8px",
|
||||
lineHeight: "1.6",
|
||||
}}
|
||||
>
|
||||
{episode.description}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default EpisodeDetail
|
||||
export default EpisodeDetail;
|
||||
|
@ -1,186 +1,202 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useState, useEffect } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
interface Episode {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
pubDate: string
|
||||
audioUrl: string
|
||||
audioLength: string
|
||||
guid: string
|
||||
link: string
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
pubDate: string;
|
||||
audioUrl: string;
|
||||
audioLength: string;
|
||||
guid: string;
|
||||
link: string;
|
||||
}
|
||||
|
||||
interface EpisodeWithFeedInfo {
|
||||
id: string
|
||||
title: string
|
||||
description?: string
|
||||
audioPath: string
|
||||
duration?: number
|
||||
fileSize?: number
|
||||
createdAt: string
|
||||
articleId: string
|
||||
articleTitle: string
|
||||
articleLink: string
|
||||
articlePubDate: string
|
||||
feedId: string
|
||||
feedTitle?: string
|
||||
feedUrl: string
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
audioPath: string;
|
||||
duration?: number;
|
||||
fileSize?: number;
|
||||
createdAt: string;
|
||||
articleId: string;
|
||||
articleTitle: string;
|
||||
articleLink: string;
|
||||
articlePubDate: string;
|
||||
feedId: string;
|
||||
feedTitle?: string;
|
||||
feedUrl: string;
|
||||
}
|
||||
|
||||
function EpisodeList() {
|
||||
const [episodes, setEpisodes] = useState<EpisodeWithFeedInfo[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [currentAudio, setCurrentAudio] = useState<string | null>(null)
|
||||
const [useDatabase, setUseDatabase] = useState(true)
|
||||
const [episodes, setEpisodes] = useState<EpisodeWithFeedInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [currentAudio, setCurrentAudio] = useState<string | null>(null);
|
||||
const [useDatabase, setUseDatabase] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchEpisodes()
|
||||
}, [useDatabase])
|
||||
fetchEpisodes();
|
||||
}, [useDatabase]);
|
||||
|
||||
const fetchEpisodes = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
if (useDatabase) {
|
||||
// Try to fetch from database first
|
||||
const response = await fetch('/api/episodes-with-feed-info')
|
||||
const response = await fetch("/api/episodes-with-feed-info");
|
||||
if (!response.ok) {
|
||||
throw new Error('データベースからの取得に失敗しました')
|
||||
throw new Error("データベースからの取得に失敗しました");
|
||||
}
|
||||
const data = await response.json()
|
||||
const dbEpisodes = data.episodes || []
|
||||
|
||||
const data = await response.json();
|
||||
const dbEpisodes = data.episodes || [];
|
||||
|
||||
if (dbEpisodes.length === 0) {
|
||||
// Database is empty, fallback to XML
|
||||
console.log('Database is empty, falling back to XML parsing...')
|
||||
setUseDatabase(false)
|
||||
return
|
||||
console.log("Database is empty, falling back to XML parsing...");
|
||||
setUseDatabase(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setEpisodes(dbEpisodes)
|
||||
|
||||
setEpisodes(dbEpisodes);
|
||||
} else {
|
||||
// Use XML parsing as primary source
|
||||
const response = await fetch('/api/episodes-from-xml')
|
||||
const response = await fetch("/api/episodes-from-xml");
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'エピソードの取得に失敗しました')
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || "エピソードの取得に失敗しました");
|
||||
}
|
||||
const data = await response.json()
|
||||
console.log('Fetched episodes from XML:', data)
|
||||
|
||||
const data = await response.json();
|
||||
console.log("Fetched episodes from XML:", data);
|
||||
|
||||
// Convert XML episodes to EpisodeWithFeedInfo format
|
||||
const xmlEpisodes = data.episodes || []
|
||||
const convertedEpisodes: EpisodeWithFeedInfo[] = xmlEpisodes.map((episode: Episode) => ({
|
||||
id: episode.id,
|
||||
title: episode.title,
|
||||
description: episode.description,
|
||||
audioPath: episode.audioUrl,
|
||||
createdAt: episode.pubDate,
|
||||
articleId: episode.guid,
|
||||
articleTitle: episode.title,
|
||||
articleLink: episode.link,
|
||||
articlePubDate: episode.pubDate,
|
||||
feedId: '',
|
||||
feedTitle: 'RSS Feed',
|
||||
feedUrl: ''
|
||||
}))
|
||||
setEpisodes(convertedEpisodes)
|
||||
const xmlEpisodes = data.episodes || [];
|
||||
const convertedEpisodes: EpisodeWithFeedInfo[] = xmlEpisodes.map(
|
||||
(episode: Episode) => ({
|
||||
id: episode.id,
|
||||
title: episode.title,
|
||||
description: episode.description,
|
||||
audioPath: episode.audioUrl,
|
||||
createdAt: episode.pubDate,
|
||||
articleId: episode.guid,
|
||||
articleTitle: episode.title,
|
||||
articleLink: episode.link,
|
||||
articlePubDate: episode.pubDate,
|
||||
feedId: "",
|
||||
feedTitle: "RSS Feed",
|
||||
feedUrl: "",
|
||||
}),
|
||||
);
|
||||
setEpisodes(convertedEpisodes);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Episode fetch error:', err)
|
||||
console.error("Episode fetch error:", err);
|
||||
if (useDatabase) {
|
||||
// Fallback to XML if database fails
|
||||
console.log('Falling back to XML parsing...')
|
||||
setUseDatabase(false)
|
||||
return
|
||||
console.log("Falling back to XML parsing...");
|
||||
setUseDatabase(false);
|
||||
return;
|
||||
}
|
||||
setError(err instanceof Error ? err.message : 'エラーが発生しました')
|
||||
setError(err instanceof Error ? err.message : "エラーが発生しました");
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString('ja-JP')
|
||||
}
|
||||
return new Date(dateString).toLocaleString("ja-JP");
|
||||
};
|
||||
|
||||
const playAudio = (audioPath: string) => {
|
||||
if (currentAudio) {
|
||||
const currentPlayer = document.getElementById(currentAudio) as HTMLAudioElement
|
||||
const currentPlayer = document.getElementById(
|
||||
currentAudio,
|
||||
) as HTMLAudioElement;
|
||||
if (currentPlayer) {
|
||||
currentPlayer.pause()
|
||||
currentPlayer.currentTime = 0
|
||||
currentPlayer.pause();
|
||||
currentPlayer.currentTime = 0;
|
||||
}
|
||||
}
|
||||
setCurrentAudio(audioPath)
|
||||
}
|
||||
setCurrentAudio(audioPath);
|
||||
};
|
||||
|
||||
const shareEpisode = (episode: EpisodeWithFeedInfo) => {
|
||||
const shareUrl = `${window.location.origin}/episode/${episode.id}`
|
||||
navigator.clipboard.writeText(shareUrl).then(() => {
|
||||
alert('エピソードリンクをクリップボードにコピーしました')
|
||||
}).catch(() => {
|
||||
// Fallback for older browsers
|
||||
const textArea = document.createElement('textarea')
|
||||
textArea.value = shareUrl
|
||||
document.body.appendChild(textArea)
|
||||
textArea.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(textArea)
|
||||
alert('エピソードリンクをクリップボードにコピーしました')
|
||||
})
|
||||
}
|
||||
const shareUrl = `${window.location.origin}/episode/${episode.id}`;
|
||||
navigator.clipboard
|
||||
.writeText(shareUrl)
|
||||
.then(() => {
|
||||
alert("エピソードリンクをクリップボードにコピーしました");
|
||||
})
|
||||
.catch(() => {
|
||||
// Fallback for older browsers
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = shareUrl;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(textArea);
|
||||
alert("エピソードリンクをクリップボードにコピーしました");
|
||||
});
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes?: number) => {
|
||||
if (!bytes) return ''
|
||||
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
let unitIndex = 0
|
||||
let fileSize = bytes
|
||||
if (!bytes) return "";
|
||||
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
let unitIndex = 0;
|
||||
let fileSize = bytes;
|
||||
|
||||
while (fileSize >= 1024 && unitIndex < units.length - 1) {
|
||||
fileSize /= 1024
|
||||
unitIndex++
|
||||
fileSize /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
return `${fileSize.toFixed(1)} ${units[unitIndex]}`
|
||||
}
|
||||
return `${fileSize.toFixed(1)} ${units[unitIndex]}`;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="loading">読み込み中...</div>
|
||||
return <div className="loading">読み込み中...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="error">{error}</div>
|
||||
return <div className="error">{error}</div>;
|
||||
}
|
||||
|
||||
if (episodes.length === 0) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<p>エピソードがありません</p>
|
||||
<p>フィードリクエストでRSSフィードをリクエストするか、管理者にバッチ処理の実行を依頼してください</p>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
<p>
|
||||
フィードリクエストでRSSフィードをリクエストするか、管理者にバッチ処理の実行を依頼してください
|
||||
</p>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={fetchEpisodes}
|
||||
style={{ marginTop: '10px' }}
|
||||
style={{ marginTop: "10px" }}
|
||||
>
|
||||
再読み込み
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: '20px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: "20px",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<h2>エピソード一覧 ({episodes.length}件)</h2>
|
||||
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: '12px', color: '#666' }}>
|
||||
データソース: {useDatabase ? 'データベース' : 'XML'}
|
||||
<div style={{ display: "flex", gap: "10px", alignItems: "center" }}>
|
||||
<span style={{ fontSize: "12px", color: "#666" }}>
|
||||
データソース: {useDatabase ? "データベース" : "XML"}
|
||||
</span>
|
||||
<button className="btn btn-secondary" onClick={fetchEpisodes}>
|
||||
更新
|
||||
@ -191,74 +207,107 @@ function EpisodeList() {
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '35%' }}>タイトル</th>
|
||||
<th style={{ width: '25%' }}>説明</th>
|
||||
<th style={{ width: '15%' }}>作成日</th>
|
||||
<th style={{ width: '25%' }}>操作</th>
|
||||
<th style={{ width: "35%" }}>タイトル</th>
|
||||
<th style={{ width: "25%" }}>説明</th>
|
||||
<th style={{ width: "15%" }}>作成日</th>
|
||||
<th style={{ width: "25%" }}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{episodes.map((episode) => (
|
||||
<tr key={episode.id}>
|
||||
<td>
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
<div style={{ marginBottom: "8px" }}>
|
||||
<strong>
|
||||
<Link
|
||||
<Link
|
||||
to={`/episode/${episode.id}`}
|
||||
style={{ textDecoration: 'none', color: '#007bff' }}
|
||||
style={{ textDecoration: "none", color: "#007bff" }}
|
||||
>
|
||||
{episode.title}
|
||||
</Link>
|
||||
</strong>
|
||||
</div>
|
||||
{episode.feedTitle && (
|
||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>
|
||||
フィード: <Link to={`/feeds/${episode.feedId}`} style={{ color: '#007bff' }}>{episode.feedTitle}</Link>
|
||||
</div>
|
||||
)}
|
||||
{episode.articleTitle && episode.articleTitle !== episode.title && (
|
||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>
|
||||
元記事: <strong>{episode.articleTitle}</strong>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "#666",
|
||||
marginBottom: "4px",
|
||||
}}
|
||||
>
|
||||
フィード:{" "}
|
||||
<Link
|
||||
to={`/feeds/${episode.feedId}`}
|
||||
style={{ color: "#007bff" }}
|
||||
>
|
||||
{episode.feedTitle}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
{episode.articleTitle &&
|
||||
episode.articleTitle !== episode.title && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "#666",
|
||||
marginBottom: "4px",
|
||||
}}
|
||||
>
|
||||
元記事: <strong>{episode.articleTitle}</strong>
|
||||
</div>
|
||||
)}
|
||||
{episode.articleLink && (
|
||||
<a
|
||||
href={episode.articleLink}
|
||||
target="_blank"
|
||||
<a
|
||||
href={episode.articleLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ fontSize: '12px', color: '#666' }}
|
||||
style={{ fontSize: "12px", color: "#666" }}
|
||||
>
|
||||
元記事を見る
|
||||
</a>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
maxWidth: '200px',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}}>
|
||||
{episode.description || 'No description'}
|
||||
<div
|
||||
style={{
|
||||
fontSize: "14px",
|
||||
maxWidth: "200px",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{episode.description || "No description"}
|
||||
</div>
|
||||
{episode.fileSize && (
|
||||
<div style={{ fontSize: '12px', color: '#666', marginTop: '4px' }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "#666",
|
||||
marginTop: "4px",
|
||||
}}
|
||||
>
|
||||
{formatFileSize(episode.fileSize)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td>{formatDate(episode.createdAt)}</td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', gap: '8px', flexDirection: 'column' }}>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "8px",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", gap: "8px" }}>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => playAudio(episode.audioPath)}
|
||||
>
|
||||
再生
|
||||
</button>
|
||||
<button
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={() => shareEpisode(episode)}
|
||||
>
|
||||
@ -283,7 +332,7 @@ function EpisodeList() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default EpisodeList
|
||||
export default EpisodeList;
|
||||
|
@ -1,180 +1,198 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { useState, useEffect } from "react";
|
||||
import { useParams, Link } from "react-router-dom";
|
||||
|
||||
interface Feed {
|
||||
id: string
|
||||
url: string
|
||||
title?: string
|
||||
description?: string
|
||||
lastUpdated?: string
|
||||
createdAt: string
|
||||
active: boolean
|
||||
id: string;
|
||||
url: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
lastUpdated?: string;
|
||||
createdAt: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
interface EpisodeWithFeedInfo {
|
||||
id: string
|
||||
title: string
|
||||
description?: string
|
||||
audioPath: string
|
||||
duration?: number
|
||||
fileSize?: number
|
||||
createdAt: string
|
||||
articleId: string
|
||||
articleTitle: string
|
||||
articleLink: string
|
||||
articlePubDate: string
|
||||
feedId: string
|
||||
feedTitle?: string
|
||||
feedUrl: string
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
audioPath: string;
|
||||
duration?: number;
|
||||
fileSize?: number;
|
||||
createdAt: string;
|
||||
articleId: string;
|
||||
articleTitle: string;
|
||||
articleLink: string;
|
||||
articlePubDate: string;
|
||||
feedId: string;
|
||||
feedTitle?: string;
|
||||
feedUrl: string;
|
||||
}
|
||||
|
||||
function FeedDetail() {
|
||||
const { feedId } = useParams<{ feedId: string }>()
|
||||
const [feed, setFeed] = useState<Feed | null>(null)
|
||||
const [episodes, setEpisodes] = useState<EpisodeWithFeedInfo[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [currentAudio, setCurrentAudio] = useState<string | null>(null)
|
||||
const { feedId } = useParams<{ feedId: string }>();
|
||||
const [feed, setFeed] = useState<Feed | null>(null);
|
||||
const [episodes, setEpisodes] = useState<EpisodeWithFeedInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [currentAudio, setCurrentAudio] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (feedId) {
|
||||
fetchFeedAndEpisodes()
|
||||
fetchFeedAndEpisodes();
|
||||
}
|
||||
}, [feedId])
|
||||
}, [feedId]);
|
||||
|
||||
const fetchFeedAndEpisodes = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
|
||||
setLoading(true);
|
||||
|
||||
// Fetch feed info and episodes in parallel
|
||||
const [feedResponse, episodesResponse] = await Promise.all([
|
||||
fetch(`/api/feeds/${feedId}`),
|
||||
fetch(`/api/feeds/${feedId}/episodes`)
|
||||
])
|
||||
fetch(`/api/feeds/${feedId}/episodes`),
|
||||
]);
|
||||
|
||||
if (!feedResponse.ok) {
|
||||
const errorData = await feedResponse.json()
|
||||
throw new Error(errorData.error || 'フィード情報の取得に失敗しました')
|
||||
const errorData = await feedResponse.json();
|
||||
throw new Error(errorData.error || "フィード情報の取得に失敗しました");
|
||||
}
|
||||
|
||||
if (!episodesResponse.ok) {
|
||||
const errorData = await episodesResponse.json()
|
||||
throw new Error(errorData.error || 'エピソードの取得に失敗しました')
|
||||
const errorData = await episodesResponse.json();
|
||||
throw new Error(errorData.error || "エピソードの取得に失敗しました");
|
||||
}
|
||||
|
||||
const feedData = await feedResponse.json()
|
||||
const episodesData = await episodesResponse.json()
|
||||
const feedData = await feedResponse.json();
|
||||
const episodesData = await episodesResponse.json();
|
||||
|
||||
setFeed(feedData.feed)
|
||||
setEpisodes(episodesData.episodes || [])
|
||||
setFeed(feedData.feed);
|
||||
setEpisodes(episodesData.episodes || []);
|
||||
} catch (err) {
|
||||
console.error('Feed detail fetch error:', err)
|
||||
setError(err instanceof Error ? err.message : 'エラーが発生しました')
|
||||
console.error("Feed detail fetch error:", err);
|
||||
setError(err instanceof Error ? err.message : "エラーが発生しました");
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString('ja-JP')
|
||||
}
|
||||
return new Date(dateString).toLocaleString("ja-JP");
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes?: number) => {
|
||||
if (!bytes) return ''
|
||||
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
let unitIndex = 0
|
||||
let fileSize = bytes
|
||||
if (!bytes) return "";
|
||||
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
let unitIndex = 0;
|
||||
let fileSize = bytes;
|
||||
|
||||
while (fileSize >= 1024 && unitIndex < units.length - 1) {
|
||||
fileSize /= 1024
|
||||
unitIndex++
|
||||
fileSize /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
return `${fileSize.toFixed(1)} ${units[unitIndex]}`
|
||||
}
|
||||
return `${fileSize.toFixed(1)} ${units[unitIndex]}`;
|
||||
};
|
||||
|
||||
const playAudio = (audioPath: string) => {
|
||||
if (currentAudio) {
|
||||
const currentPlayer = document.getElementById(currentAudio) as HTMLAudioElement
|
||||
const currentPlayer = document.getElementById(
|
||||
currentAudio,
|
||||
) as HTMLAudioElement;
|
||||
if (currentPlayer) {
|
||||
currentPlayer.pause()
|
||||
currentPlayer.currentTime = 0
|
||||
currentPlayer.pause();
|
||||
currentPlayer.currentTime = 0;
|
||||
}
|
||||
}
|
||||
setCurrentAudio(audioPath)
|
||||
}
|
||||
setCurrentAudio(audioPath);
|
||||
};
|
||||
|
||||
const shareEpisode = (episode: EpisodeWithFeedInfo) => {
|
||||
const shareUrl = `${window.location.origin}/episode/${episode.id}`
|
||||
navigator.clipboard.writeText(shareUrl).then(() => {
|
||||
alert('エピソードリンクをクリップボードにコピーしました')
|
||||
}).catch(() => {
|
||||
const textArea = document.createElement('textarea')
|
||||
textArea.value = shareUrl
|
||||
document.body.appendChild(textArea)
|
||||
textArea.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(textArea)
|
||||
alert('エピソードリンクをクリップボードにコピーしました')
|
||||
})
|
||||
}
|
||||
const shareUrl = `${window.location.origin}/episode/${episode.id}`;
|
||||
navigator.clipboard
|
||||
.writeText(shareUrl)
|
||||
.then(() => {
|
||||
alert("エピソードリンクをクリップボードにコピーしました");
|
||||
})
|
||||
.catch(() => {
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = shareUrl;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(textArea);
|
||||
alert("エピソードリンクをクリップボードにコピーしました");
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="loading">読み込み中...</div>
|
||||
return <div className="loading">読み込み中...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="error">
|
||||
{error}
|
||||
<Link to="/feeds" className="btn btn-secondary" style={{ marginTop: '20px', display: 'block' }}>
|
||||
<Link
|
||||
to="/feeds"
|
||||
className="btn btn-secondary"
|
||||
style={{ marginTop: "20px", display: "block" }}
|
||||
>
|
||||
フィード一覧に戻る
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!feed) {
|
||||
return (
|
||||
<div className="error">
|
||||
フィードが見つかりません
|
||||
<Link to="/feeds" className="btn btn-secondary" style={{ marginTop: '20px', display: 'block' }}>
|
||||
<Link
|
||||
to="/feeds"
|
||||
className="btn btn-secondary"
|
||||
style={{ marginTop: "20px", display: "block" }}
|
||||
>
|
||||
フィード一覧に戻る
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<div style={{ marginBottom: "20px" }}>
|
||||
<Link to="/feeds" className="btn btn-secondary">
|
||||
← フィード一覧に戻る
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="feed-header" style={{ marginBottom: '30px' }}>
|
||||
<h1 style={{ marginBottom: '10px' }}>
|
||||
{feed.title || feed.url}
|
||||
</h1>
|
||||
<div style={{ color: '#666', marginBottom: '10px' }}>
|
||||
<div className="feed-header" style={{ marginBottom: "30px" }}>
|
||||
<h1 style={{ marginBottom: "10px" }}>{feed.title || feed.url}</h1>
|
||||
<div style={{ color: "#666", marginBottom: "10px" }}>
|
||||
<a href={feed.url} target="_blank" rel="noopener noreferrer">
|
||||
{feed.url}
|
||||
</a>
|
||||
</div>
|
||||
{feed.description && (
|
||||
<div style={{ marginBottom: '15px', color: '#333' }}>
|
||||
<div style={{ marginBottom: "15px", color: "#333" }}>
|
||||
{feed.description}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: '14px', color: '#666' }}>
|
||||
<div style={{ fontSize: "14px", color: "#666" }}>
|
||||
作成日: {formatDate(feed.createdAt)}
|
||||
{feed.lastUpdated && ` | 最終更新: ${formatDate(feed.lastUpdated)}`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '20px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: "20px",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<h2>エピソード一覧 ({episodes.length}件)</h2>
|
||||
<button className="btn btn-secondary" onClick={fetchFeedAndEpisodes}>
|
||||
更新
|
||||
@ -190,67 +208,87 @@ function FeedDetail() {
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '35%' }}>タイトル</th>
|
||||
<th style={{ width: '25%' }}>説明</th>
|
||||
<th style={{ width: '15%' }}>作成日</th>
|
||||
<th style={{ width: '25%' }}>操作</th>
|
||||
<th style={{ width: "35%" }}>タイトル</th>
|
||||
<th style={{ width: "25%" }}>説明</th>
|
||||
<th style={{ width: "15%" }}>作成日</th>
|
||||
<th style={{ width: "25%" }}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{episodes.map((episode) => (
|
||||
<tr key={episode.id}>
|
||||
<td>
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
<div style={{ marginBottom: "8px" }}>
|
||||
<strong>
|
||||
<Link
|
||||
<Link
|
||||
to={`/episode/${episode.id}`}
|
||||
style={{ textDecoration: 'none', color: '#007bff' }}
|
||||
style={{ textDecoration: "none", color: "#007bff" }}
|
||||
>
|
||||
{episode.title}
|
||||
</Link>
|
||||
</strong>
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#666', marginBottom: '4px' }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "#666",
|
||||
marginBottom: "4px",
|
||||
}}
|
||||
>
|
||||
元記事: <strong>{episode.articleTitle}</strong>
|
||||
</div>
|
||||
{episode.articleLink && (
|
||||
<a
|
||||
href={episode.articleLink}
|
||||
target="_blank"
|
||||
<a
|
||||
href={episode.articleLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ fontSize: '12px', color: '#666' }}
|
||||
style={{ fontSize: "12px", color: "#666" }}
|
||||
>
|
||||
元記事を見る
|
||||
</a>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
maxWidth: '200px',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}}>
|
||||
{episode.description || 'No description'}
|
||||
<div
|
||||
style={{
|
||||
fontSize: "14px",
|
||||
maxWidth: "200px",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{episode.description || "No description"}
|
||||
</div>
|
||||
{episode.fileSize && (
|
||||
<div style={{ fontSize: '12px', color: '#666', marginTop: '4px' }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: "#666",
|
||||
marginTop: "4px",
|
||||
}}
|
||||
>
|
||||
{formatFileSize(episode.fileSize)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td>{formatDate(episode.createdAt)}</td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', gap: '8px', flexDirection: 'column' }}>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "8px",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", gap: "8px" }}>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => playAudio(episode.audioPath)}
|
||||
>
|
||||
再生
|
||||
</button>
|
||||
<button
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={() => shareEpisode(episode)}
|
||||
>
|
||||
@ -276,7 +314,7 @@ function FeedDetail() {
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default FeedDetail
|
||||
export default FeedDetail;
|
||||
|
@ -1,74 +1,83 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useState, useEffect } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
interface Feed {
|
||||
id: string
|
||||
url: string
|
||||
title?: string
|
||||
description?: string
|
||||
lastUpdated?: string
|
||||
createdAt: string
|
||||
active: boolean
|
||||
id: string;
|
||||
url: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
lastUpdated?: string;
|
||||
createdAt: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
function FeedList() {
|
||||
const [feeds, setFeeds] = useState<Feed[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [feeds, setFeeds] = useState<Feed[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchFeeds()
|
||||
}, [])
|
||||
fetchFeeds();
|
||||
}, []);
|
||||
|
||||
const fetchFeeds = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await fetch('/api/feeds')
|
||||
setLoading(true);
|
||||
const response = await fetch("/api/feeds");
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'フィードの取得に失敗しました')
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || "フィードの取得に失敗しました");
|
||||
}
|
||||
const data = await response.json()
|
||||
setFeeds(data.feeds || [])
|
||||
const data = await response.json();
|
||||
setFeeds(data.feeds || []);
|
||||
} catch (err) {
|
||||
console.error('Feed fetch error:', err)
|
||||
setError(err instanceof Error ? err.message : 'エラーが発生しました')
|
||||
console.error("Feed fetch error:", err);
|
||||
setError(err instanceof Error ? err.message : "エラーが発生しました");
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString('ja-JP')
|
||||
}
|
||||
return new Date(dateString).toLocaleString("ja-JP");
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="loading">読み込み中...</div>
|
||||
return <div className="loading">読み込み中...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="error">{error}</div>
|
||||
return <div className="error">{error}</div>;
|
||||
}
|
||||
|
||||
if (feeds.length === 0) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<p>アクティブなフィードがありません</p>
|
||||
<p>フィードリクエストでRSSフィードをリクエストするか、管理者にフィード追加を依頼してください</p>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
<p>
|
||||
フィードリクエストでRSSフィードをリクエストするか、管理者にフィード追加を依頼してください
|
||||
</p>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={fetchFeeds}
|
||||
style={{ marginTop: '10px' }}
|
||||
style={{ marginTop: "10px" }}
|
||||
>
|
||||
再読み込み
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: '20px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: "20px",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<h2>フィード一覧 ({feeds.length}件)</h2>
|
||||
<button className="btn btn-secondary" onClick={fetchFeeds}>
|
||||
更新
|
||||
@ -90,13 +99,11 @@ function FeedList() {
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{feed.description && (
|
||||
<div className="feed-description">
|
||||
{feed.description}
|
||||
</div>
|
||||
<div className="feed-description">{feed.description}</div>
|
||||
)}
|
||||
|
||||
|
||||
<div className="feed-meta">
|
||||
<div>作成日: {formatDate(feed.createdAt)}</div>
|
||||
{feed.lastUpdated && (
|
||||
@ -182,7 +189,7 @@ function FeedList() {
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default FeedList
|
||||
export default FeedList;
|
||||
|
@ -1,58 +1,60 @@
|
||||
import { useState } from 'react'
|
||||
import { useState } from "react";
|
||||
|
||||
function FeedManager() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState<string | null>(null)
|
||||
const [newFeedUrl, setNewFeedUrl] = useState('')
|
||||
const [requestMessage, setRequestMessage] = useState('')
|
||||
const [requesting, setRequesting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [newFeedUrl, setNewFeedUrl] = useState("");
|
||||
const [requestMessage, setRequestMessage] = useState("");
|
||||
const [requesting, setRequesting] = useState(false);
|
||||
|
||||
const submitRequest = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!newFeedUrl.trim()) return
|
||||
e.preventDefault();
|
||||
if (!newFeedUrl.trim()) return;
|
||||
|
||||
try {
|
||||
setRequesting(true)
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
setRequesting(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
const response = await fetch('/api/feed-requests', {
|
||||
method: 'POST',
|
||||
const response = await fetch("/api/feed-requests", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
body: JSON.stringify({
|
||||
url: newFeedUrl.trim(),
|
||||
requestMessage: requestMessage.trim() || undefined
|
||||
requestMessage: requestMessage.trim() || undefined,
|
||||
}),
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'リクエストの送信に失敗しました')
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || "リクエストの送信に失敗しました");
|
||||
}
|
||||
|
||||
setSuccess('フィードリクエストを送信しました。管理者の承認をお待ちください。')
|
||||
setNewFeedUrl('')
|
||||
setRequestMessage('')
|
||||
setSuccess(
|
||||
"フィードリクエストを送信しました。管理者の承認をお待ちください。",
|
||||
);
|
||||
setNewFeedUrl("");
|
||||
setRequestMessage("");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'エラーが発生しました')
|
||||
setError(err instanceof Error ? err.message : "エラーが発生しました");
|
||||
} finally {
|
||||
setRequesting(false)
|
||||
setRequesting(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{error && <div className="error">{error}</div>}
|
||||
{success && <div className="success">{success}</div>}
|
||||
|
||||
<div style={{ marginBottom: '30px' }}>
|
||||
<div style={{ marginBottom: "30px" }}>
|
||||
<h2>新しいフィードをリクエスト</h2>
|
||||
<p style={{ color: '#666', marginBottom: '20px' }}>
|
||||
<p style={{ color: "#666", marginBottom: "20px" }}>
|
||||
追加したいRSSフィードのURLを送信してください。管理者が承認後、フィードが追加されます。
|
||||
</p>
|
||||
|
||||
|
||||
<form onSubmit={submitRequest}>
|
||||
<div className="form-group">
|
||||
<label className="form-label">RSS フィード URL *</label>
|
||||
@ -65,7 +67,7 @@ function FeedManager() {
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">メッセージ(任意)</label>
|
||||
<textarea
|
||||
@ -74,31 +76,39 @@ function FeedManager() {
|
||||
onChange={(e) => setRequestMessage(e.target.value)}
|
||||
placeholder="このフィードについての説明や追加理由があれば記載してください"
|
||||
rows={3}
|
||||
style={{ resize: 'vertical', minHeight: '80px' }}
|
||||
style={{ resize: "vertical", minHeight: "80px" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={requesting}
|
||||
>
|
||||
{requesting ? 'リクエスト送信中...' : 'フィードをリクエスト'}
|
||||
{requesting ? "リクエスト送信中..." : "フィードをリクエスト"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div style={{ backgroundColor: '#f8f9fa', padding: '20px', borderRadius: '8px' }}>
|
||||
<h3 style={{ marginBottom: '15px' }}>フィードリクエストについて</h3>
|
||||
<ul style={{ paddingLeft: '20px', color: '#666' }}>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: "#f8f9fa",
|
||||
padding: "20px",
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
>
|
||||
<h3 style={{ marginBottom: "15px" }}>フィードリクエストについて</h3>
|
||||
<ul style={{ paddingLeft: "20px", color: "#666" }}>
|
||||
<li>送信されたフィードリクエストは管理者が確認します</li>
|
||||
<li>適切なRSSフィードと判断された場合、承認されて自動的に追加されます</li>
|
||||
<li>
|
||||
適切なRSSフィードと判断された場合、承認されて自動的に追加されます
|
||||
</li>
|
||||
<li>承認までにお時間をいただく場合があります</li>
|
||||
<li>不適切なフィードや重複フィードは拒否される場合があります</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default FeedManager
|
||||
export default FeedManager;
|
||||
|
@ -1,13 +1,13 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App.tsx'
|
||||
import './styles.css'
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App.tsx";
|
||||
import "./styles.css";
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
)
|
||||
);
|
||||
|
@ -22,7 +22,7 @@ body {
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.title {
|
||||
@ -41,7 +41,7 @@ body {
|
||||
background-color: #fff;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.tab {
|
||||
@ -75,7 +75,7 @@ body {
|
||||
background-color: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.table {
|
||||
@ -159,7 +159,7 @@ body {
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: #007bff;
|
||||
box-shadow: 0 0 0 2px rgba(0,123,255,0.25);
|
||||
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
|
||||
}
|
||||
|
||||
.audio-player {
|
||||
@ -222,4 +222,4 @@ body {
|
||||
.feed-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user