Apply formatting

This commit is contained in:
2025-06-08 15:21:58 +09:00
parent b5ff912fcb
commit a728ebb66c
28 changed files with 1809 additions and 1137 deletions

View File

@ -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 },
],
},
},
)
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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>,
)
);

View File

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