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