Fix episode player and episode list
This commit is contained in:
		
							
								
								
									
										17
									
								
								bun.lock
									
									
									
									
									
								
							
							
						
						
									
										17
									
								
								bun.lock
									
									
									
									
									
								
							@@ -6,12 +6,15 @@
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "@aws-sdk/client-polly": "^3.823.0",
 | 
			
		||||
        "@hono/node-server": "^1.14.3",
 | 
			
		||||
        "@types/xml2js": "^0.4.14",
 | 
			
		||||
        "ffmpeg-static": "^5.2.0",
 | 
			
		||||
        "hono": "^4.7.11",
 | 
			
		||||
        "openai": "^4.104.0",
 | 
			
		||||
        "react": "^19.1.0",
 | 
			
		||||
        "react-dom": "^19.1.0",
 | 
			
		||||
        "react-router-dom": "^7.6.2",
 | 
			
		||||
        "rss-parser": "^3.13.0",
 | 
			
		||||
        "xml2js": "^0.6.2",
 | 
			
		||||
      },
 | 
			
		||||
      "devDependencies": {
 | 
			
		||||
        "@types/bun": "latest",
 | 
			
		||||
@@ -324,6 +327,8 @@
 | 
			
		||||
 | 
			
		||||
    "@types/react-dom": ["@types/react-dom@19.1.5", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-CMCjrWucUBZvohgZxkjd6S9h0nZxXjzus6yDfUb+xLxYM7VvjKNH1tQrE9GWLql1XoOP4/Ds3bwFqShHUYraGg=="],
 | 
			
		||||
 | 
			
		||||
    "@types/xml2js": ["@types/xml2js@0.4.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4YnrRemBShWRO2QjvUin8ESA41rH+9nQGLUGZV/1IDhi3SL9OhdpNC/MrulTWuptXKwhx/aDxE7toV0f/ypIXQ=="],
 | 
			
		||||
 | 
			
		||||
    "@vitejs/plugin-react": ["@vitejs/plugin-react@4.5.1", "", { "dependencies": { "@babel/core": "^7.26.10", "@babel/plugin-transform-react-jsx-self": "^7.25.9", "@babel/plugin-transform-react-jsx-source": "^7.25.9", "@rolldown/pluginutils": "1.0.0-beta.9", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0" } }, "sha512-uPZBqSI0YD4lpkIru6M35sIfylLGTyhGHvDZbNLuMA73lMlwJKz5xweH7FajfcCAc2HnINciejA9qTz0dr0M7A=="],
 | 
			
		||||
 | 
			
		||||
    "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
 | 
			
		||||
@@ -354,6 +359,8 @@
 | 
			
		||||
 | 
			
		||||
    "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
 | 
			
		||||
 | 
			
		||||
    "cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="],
 | 
			
		||||
 | 
			
		||||
    "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
 | 
			
		||||
 | 
			
		||||
    "debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
 | 
			
		||||
@@ -466,6 +473,10 @@
 | 
			
		||||
 | 
			
		||||
    "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
 | 
			
		||||
 | 
			
		||||
    "react-router": ["react-router@7.6.2", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-U7Nv3y+bMimgWjhlT5CRdzHPu2/KVmqPwKUCChW8en5P3znxUqwlYFlbmyj8Rgp1SF6zs5X4+77kBVknkg6a0w=="],
 | 
			
		||||
 | 
			
		||||
    "react-router-dom": ["react-router-dom@7.6.2", "", { "dependencies": { "react-router": "7.6.2" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-Q8zb6VlTbdYKK5JJBLQEN06oTUa/RAbG/oQS1auK1I0TbJOXktqm+QENEVJU6QvWynlXPRBXI3fiOQcSEA78rA=="],
 | 
			
		||||
 | 
			
		||||
    "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
 | 
			
		||||
 | 
			
		||||
    "rollup": ["rollup@4.41.1", "", { "dependencies": { "@types/estree": "1.0.7" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.41.1", "@rollup/rollup-android-arm64": "4.41.1", "@rollup/rollup-darwin-arm64": "4.41.1", "@rollup/rollup-darwin-x64": "4.41.1", "@rollup/rollup-freebsd-arm64": "4.41.1", "@rollup/rollup-freebsd-x64": "4.41.1", "@rollup/rollup-linux-arm-gnueabihf": "4.41.1", "@rollup/rollup-linux-arm-musleabihf": "4.41.1", "@rollup/rollup-linux-arm64-gnu": "4.41.1", "@rollup/rollup-linux-arm64-musl": "4.41.1", "@rollup/rollup-linux-loongarch64-gnu": "4.41.1", "@rollup/rollup-linux-powerpc64le-gnu": "4.41.1", "@rollup/rollup-linux-riscv64-gnu": "4.41.1", "@rollup/rollup-linux-riscv64-musl": "4.41.1", "@rollup/rollup-linux-s390x-gnu": "4.41.1", "@rollup/rollup-linux-x64-gnu": "4.41.1", "@rollup/rollup-linux-x64-musl": "4.41.1", "@rollup/rollup-win32-arm64-msvc": "4.41.1", "@rollup/rollup-win32-ia32-msvc": "4.41.1", "@rollup/rollup-win32-x64-msvc": "4.41.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-cPmwD3FnFv8rKMBc1MxWCwVQFxwf1JEmSX3iQXrRVVG15zerAIXRjMFVWnd5Q5QvgKF7Aj+5ykXFhUl+QGnyOw=="],
 | 
			
		||||
@@ -480,6 +491,8 @@
 | 
			
		||||
 | 
			
		||||
    "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
 | 
			
		||||
 | 
			
		||||
    "set-cookie-parser": ["set-cookie-parser@2.7.1", "", {}, "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ=="],
 | 
			
		||||
 | 
			
		||||
    "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
 | 
			
		||||
 | 
			
		||||
    "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
 | 
			
		||||
@@ -512,7 +525,7 @@
 | 
			
		||||
 | 
			
		||||
    "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
 | 
			
		||||
 | 
			
		||||
    "xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="],
 | 
			
		||||
    "xml2js": ["xml2js@0.6.2", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA=="],
 | 
			
		||||
 | 
			
		||||
    "xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
 | 
			
		||||
 | 
			
		||||
@@ -530,6 +543,8 @@
 | 
			
		||||
 | 
			
		||||
    "openai/@types/node": ["@types/node@18.19.110", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-WW2o4gTmREtSnqKty9nhqF/vA0GKd0V/rbC0OyjSk9Bz6bzlsXKT+i7WDdS/a0z74rfT2PO4dArVCSnapNLA5Q=="],
 | 
			
		||||
 | 
			
		||||
    "rss-parser/xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="],
 | 
			
		||||
 | 
			
		||||
    "@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
 | 
			
		||||
 | 
			
		||||
    "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
 | 
			
		||||
 
 | 
			
		||||
@@ -1,9 +1,19 @@
 | 
			
		||||
import { useState } from 'react'
 | 
			
		||||
import { Routes, Route, Link, useLocation } from 'react-router-dom'
 | 
			
		||||
import EpisodeList from './components/EpisodeList'
 | 
			
		||||
import FeedManager from './components/FeedManager'
 | 
			
		||||
import EpisodeDetail from './components/EpisodeDetail'
 | 
			
		||||
 | 
			
		||||
function App() {
 | 
			
		||||
  const [activeTab, setActiveTab] = useState<'episodes' | 'feeds'>('episodes')
 | 
			
		||||
  const location = useLocation()
 | 
			
		||||
  const isEpisodeDetail = location.pathname.startsWith('/episode/')
 | 
			
		||||
 | 
			
		||||
  if (isEpisodeDetail) {
 | 
			
		||||
    return (
 | 
			
		||||
      <Routes>
 | 
			
		||||
        <Route path="/episode/:episodeId" element={<EpisodeDetail />} />
 | 
			
		||||
      </Routes>
 | 
			
		||||
    )
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className="container">
 | 
			
		||||
@@ -13,23 +23,25 @@ function App() {
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <div className="tabs">
 | 
			
		||||
        <button
 | 
			
		||||
          className={`tab ${activeTab === 'episodes' ? 'active' : ''}`}
 | 
			
		||||
          onClick={() => setActiveTab('episodes')}
 | 
			
		||||
        <Link
 | 
			
		||||
          to="/"
 | 
			
		||||
          className={`tab ${location.pathname === '/' ? 'active' : ''}`}
 | 
			
		||||
        >
 | 
			
		||||
          エピソード一覧
 | 
			
		||||
        </button>
 | 
			
		||||
        <button
 | 
			
		||||
          className={`tab ${activeTab === 'feeds' ? 'active' : ''}`}
 | 
			
		||||
          onClick={() => setActiveTab('feeds')}
 | 
			
		||||
        </Link>
 | 
			
		||||
        <Link
 | 
			
		||||
          to="/feeds"
 | 
			
		||||
          className={`tab ${location.pathname === '/feeds' ? 'active' : ''}`}
 | 
			
		||||
        >
 | 
			
		||||
          フィードリクエスト
 | 
			
		||||
        </button>
 | 
			
		||||
        </Link>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <div className="content">
 | 
			
		||||
        {activeTab === 'episodes' && <EpisodeList />}
 | 
			
		||||
        {activeTab === 'feeds' && <FeedManager />}
 | 
			
		||||
        <Routes>
 | 
			
		||||
          <Route path="/" element={<EpisodeList />} />
 | 
			
		||||
          <Route path="/feeds" element={<FeedManager />} />
 | 
			
		||||
        </Routes>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  )
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										200
									
								
								frontend/src/components/EpisodeDetail.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										200
									
								
								frontend/src/components/EpisodeDetail.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,200 @@
 | 
			
		||||
import { useState, useEffect } from 'react'
 | 
			
		||||
import { useParams, Link } from 'react-router-dom'
 | 
			
		||||
 | 
			
		||||
interface Episode {
 | 
			
		||||
  id: string
 | 
			
		||||
  title: string
 | 
			
		||||
  description: string
 | 
			
		||||
  pubDate: string
 | 
			
		||||
  audioUrl: string
 | 
			
		||||
  audioLength: string
 | 
			
		||||
  guid: string
 | 
			
		||||
  link: string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function EpisodeDetail() {
 | 
			
		||||
  const { episodeId } = useParams<{ episodeId: string }>()
 | 
			
		||||
  const [episode, setEpisode] = useState<Episode | null>(null)
 | 
			
		||||
  const [loading, setLoading] = useState(true)
 | 
			
		||||
  const [error, setError] = useState<string | null>(null)
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    fetchEpisode()
 | 
			
		||||
  }, [episodeId])
 | 
			
		||||
 | 
			
		||||
  const fetchEpisode = async () => {
 | 
			
		||||
    if (!episodeId) return
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      setLoading(true)
 | 
			
		||||
      const response = await fetch(`/api/episode/${episodeId}`)
 | 
			
		||||
      if (!response.ok) {
 | 
			
		||||
        const errorData = await response.json()
 | 
			
		||||
        throw new Error(errorData.error || 'エピソードの取得に失敗しました')
 | 
			
		||||
      }
 | 
			
		||||
      const data = await response.json()
 | 
			
		||||
      setEpisode(data.episode)
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      console.error('Episode fetch error:', err)
 | 
			
		||||
      setError(err instanceof Error ? err.message : 'エラーが発生しました')
 | 
			
		||||
    } finally {
 | 
			
		||||
      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 formatDate = (dateString: string) => {
 | 
			
		||||
    return new Date(dateString).toLocaleString('ja-JP')
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const formatFileSize = (bytes: string) => {
 | 
			
		||||
    const size = parseInt(bytes)
 | 
			
		||||
    if (isNaN(size)) return ''
 | 
			
		||||
    
 | 
			
		||||
    const units = ['B', 'KB', 'MB', 'GB']
 | 
			
		||||
    let unitIndex = 0
 | 
			
		||||
    let fileSize = size
 | 
			
		||||
 | 
			
		||||
    while (fileSize >= 1024 && unitIndex < units.length - 1) {
 | 
			
		||||
      fileSize /= 1024
 | 
			
		||||
      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>
 | 
			
		||||
      </div>
 | 
			
		||||
    )
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (!episode) {
 | 
			
		||||
    return (
 | 
			
		||||
      <div className="container">
 | 
			
		||||
        <div className="error">エピソードが見つかりません</div>
 | 
			
		||||
        <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>
 | 
			
		||||
        <div className="title" style={{ fontSize: '28px', marginBottom: '10px' }}>
 | 
			
		||||
          {episode.title}
 | 
			
		||||
        </div>
 | 
			
		||||
        <div className="subtitle" style={{ color: '#666', marginBottom: '20px' }}>
 | 
			
		||||
          公開日: {formatDate(episode.pubDate)}
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <div className="content">
 | 
			
		||||
        <div style={{ marginBottom: '30px' }}>
 | 
			
		||||
          <audio
 | 
			
		||||
            controls
 | 
			
		||||
            className="audio-player"
 | 
			
		||||
            src={episode.audioUrl}
 | 
			
		||||
            style={{ width: '100%', height: '60px' }}
 | 
			
		||||
          >
 | 
			
		||||
            お使いのブラウザは音声の再生に対応していません。
 | 
			
		||||
          </audio>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <div style={{ display: 'flex', gap: '15px', marginBottom: '30px' }}>
 | 
			
		||||
          <button 
 | 
			
		||||
            className="btn btn-primary"
 | 
			
		||||
            onClick={shareEpisode}
 | 
			
		||||
          >
 | 
			
		||||
            このエピソードを共有
 | 
			
		||||
          </button>
 | 
			
		||||
          {episode.link && (
 | 
			
		||||
            <a 
 | 
			
		||||
              href={episode.link} 
 | 
			
		||||
              target="_blank" 
 | 
			
		||||
              rel="noopener noreferrer"
 | 
			
		||||
              className="btn btn-secondary"
 | 
			
		||||
            >
 | 
			
		||||
              元記事を見る
 | 
			
		||||
            </a>
 | 
			
		||||
          )}
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <div style={{ marginBottom: '30px' }}>
 | 
			
		||||
          <h3 style={{ marginBottom: '15px' }}>エピソード情報</h3>
 | 
			
		||||
          <div style={{ 
 | 
			
		||||
            backgroundColor: '#f8f9fa', 
 | 
			
		||||
            padding: '20px', 
 | 
			
		||||
            borderRadius: '8px',
 | 
			
		||||
            fontSize: '14px'
 | 
			
		||||
          }}>
 | 
			
		||||
            <div style={{ marginBottom: '10px' }}>
 | 
			
		||||
              <strong>ファイルサイズ:</strong> {formatFileSize(episode.audioLength)}
 | 
			
		||||
            </div>
 | 
			
		||||
            {episode.guid && (
 | 
			
		||||
              <div style={{ marginBottom: '10px' }}>
 | 
			
		||||
                <strong>エピソードID:</strong> {episode.guid}
 | 
			
		||||
              </div>
 | 
			
		||||
            )}
 | 
			
		||||
            <div>
 | 
			
		||||
              <strong>音声URL:</strong> 
 | 
			
		||||
              <a href={episode.audioUrl} target="_blank" rel="noopener noreferrer" style={{ marginLeft: '5px' }}>
 | 
			
		||||
                直接ダウンロード
 | 
			
		||||
              </a>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        {episode.description && (
 | 
			
		||||
          <div>
 | 
			
		||||
            <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
 | 
			
		||||
@@ -3,14 +3,12 @@ import { useState, useEffect } from 'react'
 | 
			
		||||
interface Episode {
 | 
			
		||||
  id: string
 | 
			
		||||
  title: string
 | 
			
		||||
  audioPath: string
 | 
			
		||||
  createdAt: string
 | 
			
		||||
  article?: {
 | 
			
		||||
  description: string
 | 
			
		||||
  pubDate: string
 | 
			
		||||
  audioUrl: string
 | 
			
		||||
  audioLength: string
 | 
			
		||||
  guid: string
 | 
			
		||||
  link: string
 | 
			
		||||
  }
 | 
			
		||||
  feed?: {
 | 
			
		||||
    title: string
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function EpisodeList() {
 | 
			
		||||
@@ -26,14 +24,14 @@ function EpisodeList() {
 | 
			
		||||
  const fetchEpisodes = async () => {
 | 
			
		||||
    try {
 | 
			
		||||
      setLoading(true)
 | 
			
		||||
      const response = await fetch('/api/episodes')
 | 
			
		||||
      const response = await fetch('/api/episodes-from-xml')
 | 
			
		||||
      if (!response.ok) {
 | 
			
		||||
        const errorData = await response.json()
 | 
			
		||||
        throw new Error(errorData.error || 'エピソードの取得に失敗しました')
 | 
			
		||||
      }
 | 
			
		||||
      const data = await response.json()
 | 
			
		||||
      console.log('Fetched episodes:', data)
 | 
			
		||||
      setEpisodes(data)
 | 
			
		||||
      console.log('Fetched episodes from XML:', data)
 | 
			
		||||
      setEpisodes(data.episodes || [])
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      console.error('Episode fetch error:', err)
 | 
			
		||||
      setError(err instanceof Error ? err.message : 'エラーが発生しました')
 | 
			
		||||
@@ -46,7 +44,7 @@ function EpisodeList() {
 | 
			
		||||
    return new Date(dateString).toLocaleString('ja-JP')
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const playAudio = (audioPath: string) => {
 | 
			
		||||
  const playAudio = (audioUrl: string) => {
 | 
			
		||||
    if (currentAudio) {
 | 
			
		||||
      const currentPlayer = document.getElementById(currentAudio) as HTMLAudioElement
 | 
			
		||||
      if (currentPlayer) {
 | 
			
		||||
@@ -54,7 +52,23 @@ function EpisodeList() {
 | 
			
		||||
        currentPlayer.currentTime = 0
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    setCurrentAudio(audioPath)
 | 
			
		||||
    setCurrentAudio(audioUrl)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const shareEpisode = (episode: Episode) => {
 | 
			
		||||
    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('エピソードリンクをクリップボードにコピーしました')
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (loading) {
 | 
			
		||||
@@ -93,10 +107,10 @@ function EpisodeList() {
 | 
			
		||||
      <table className="table">
 | 
			
		||||
        <thead>
 | 
			
		||||
          <tr>
 | 
			
		||||
            <th style={{ width: '40%' }}>タイトル</th>
 | 
			
		||||
            <th style={{ width: '20%' }}>フィード</th>
 | 
			
		||||
            <th style={{ width: '20%' }}>作成日時</th>
 | 
			
		||||
            <th style={{ width: '20%' }}>操作</th>
 | 
			
		||||
            <th style={{ width: '35%' }}>タイトル</th>
 | 
			
		||||
            <th style={{ width: '25%' }}>説明</th>
 | 
			
		||||
            <th style={{ width: '15%' }}>公開日</th>
 | 
			
		||||
            <th style={{ width: '25%' }}>操作</th>
 | 
			
		||||
          </tr>
 | 
			
		||||
        </thead>
 | 
			
		||||
        <tbody>
 | 
			
		||||
@@ -106,9 +120,9 @@ function EpisodeList() {
 | 
			
		||||
                <div style={{ marginBottom: '8px' }}>
 | 
			
		||||
                  <strong>{episode.title}</strong>
 | 
			
		||||
                </div>
 | 
			
		||||
                {episode.article?.link && (
 | 
			
		||||
                {episode.link && (
 | 
			
		||||
                  <a 
 | 
			
		||||
                    href={episode.article.link} 
 | 
			
		||||
                    href={episode.link} 
 | 
			
		||||
                    target="_blank" 
 | 
			
		||||
                    rel="noopener noreferrer"
 | 
			
		||||
                    style={{ fontSize: '12px', color: '#666' }}
 | 
			
		||||
@@ -117,27 +131,46 @@ function EpisodeList() {
 | 
			
		||||
                  </a>
 | 
			
		||||
                )}
 | 
			
		||||
              </td>
 | 
			
		||||
              <td>{episode.feed?.title || '不明'}</td>
 | 
			
		||||
              <td>{formatDate(episode.createdAt)}</td>
 | 
			
		||||
              <td>
 | 
			
		||||
                <div style={{ 
 | 
			
		||||
                  fontSize: '14px', 
 | 
			
		||||
                  maxWidth: '200px', 
 | 
			
		||||
                  overflow: 'hidden', 
 | 
			
		||||
                  textOverflow: 'ellipsis',
 | 
			
		||||
                  whiteSpace: 'nowrap'
 | 
			
		||||
                }}>
 | 
			
		||||
                  {episode.description || 'No description'}
 | 
			
		||||
                </div>
 | 
			
		||||
              </td>
 | 
			
		||||
              <td>{formatDate(episode.pubDate)}</td>
 | 
			
		||||
              <td>
 | 
			
		||||
                <div style={{ display: 'flex', gap: '8px', flexDirection: 'column' }}>
 | 
			
		||||
                  <div style={{ display: 'flex', gap: '8px' }}>
 | 
			
		||||
                    <button 
 | 
			
		||||
                      className="btn btn-primary"
 | 
			
		||||
                  onClick={() => playAudio(episode.audioPath)}
 | 
			
		||||
                  style={{ marginBottom: '8px' }}
 | 
			
		||||
                      onClick={() => playAudio(episode.audioUrl)}
 | 
			
		||||
                    >
 | 
			
		||||
                      再生
 | 
			
		||||
                    </button>
 | 
			
		||||
                {currentAudio === episode.audioPath && (
 | 
			
		||||
                    <button 
 | 
			
		||||
                      className="btn btn-secondary"
 | 
			
		||||
                      onClick={() => shareEpisode(episode)}
 | 
			
		||||
                    >
 | 
			
		||||
                      共有
 | 
			
		||||
                    </button>
 | 
			
		||||
                  </div>
 | 
			
		||||
                  {currentAudio === episode.audioUrl && (
 | 
			
		||||
                    <div>
 | 
			
		||||
                      <audio
 | 
			
		||||
                      id={episode.audioPath}
 | 
			
		||||
                        id={episode.audioUrl}
 | 
			
		||||
                        controls
 | 
			
		||||
                        className="audio-player"
 | 
			
		||||
                      src={`/podcast_audio/${episode.audioPath}`}
 | 
			
		||||
                        src={episode.audioUrl}
 | 
			
		||||
                        onEnded={() => setCurrentAudio(null)}
 | 
			
		||||
                      />
 | 
			
		||||
                    </div>
 | 
			
		||||
                  )}
 | 
			
		||||
                </div>
 | 
			
		||||
              </td>
 | 
			
		||||
            </tr>
 | 
			
		||||
          ))}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,10 +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'
 | 
			
		||||
 | 
			
		||||
createRoot(document.getElementById('root')!).render(
 | 
			
		||||
  <StrictMode>
 | 
			
		||||
    <BrowserRouter>
 | 
			
		||||
      <App />
 | 
			
		||||
    </BrowserRouter>
 | 
			
		||||
  </StrictMode>,
 | 
			
		||||
)
 | 
			
		||||
@@ -12,12 +12,15 @@
 | 
			
		||||
  "dependencies": {
 | 
			
		||||
    "@aws-sdk/client-polly": "^3.823.0",
 | 
			
		||||
    "@hono/node-server": "^1.14.3",
 | 
			
		||||
    "@types/xml2js": "^0.4.14",
 | 
			
		||||
    "ffmpeg-static": "^5.2.0",
 | 
			
		||||
    "hono": "^4.7.11",
 | 
			
		||||
    "openai": "^4.104.0",
 | 
			
		||||
    "react": "^19.1.0",
 | 
			
		||||
    "react-dom": "^19.1.0",
 | 
			
		||||
    "rss-parser": "^3.13.0"
 | 
			
		||||
    "react-router-dom": "^7.6.2",
 | 
			
		||||
    "rss-parser": "^3.13.0",
 | 
			
		||||
    "xml2js": "^0.6.2"
 | 
			
		||||
  },
 | 
			
		||||
  "type": "module",
 | 
			
		||||
  "devDependencies": {
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										100
									
								
								server.ts
									
									
									
									
									
								
							
							
						
						
									
										100
									
								
								server.ts
									
									
									
									
									
								
							@@ -119,6 +119,106 @@ app.get("/api/episodes", async (c) => {
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
app.get("/api/episodes-from-xml", async (c) => {
 | 
			
		||||
  try {
 | 
			
		||||
    const xml2js = await import("xml2js");
 | 
			
		||||
    const fs = await import("fs");
 | 
			
		||||
    const podcastXmlPath = path.join(config.paths.publicDir, "podcast.xml");
 | 
			
		||||
    
 | 
			
		||||
    // Check if podcast.xml exists
 | 
			
		||||
    if (!fs.existsSync(podcastXmlPath)) {
 | 
			
		||||
      return c.json({ episodes: [], message: "podcast.xml not found" });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Read and parse XML
 | 
			
		||||
    const xmlContent = fs.readFileSync(podcastXmlPath, 'utf-8');
 | 
			
		||||
    const parser = new xml2js.Parser();
 | 
			
		||||
    const result = await parser.parseStringPromise(xmlContent);
 | 
			
		||||
    
 | 
			
		||||
    const episodes = [];
 | 
			
		||||
    const items = result?.rss?.channel?.[0]?.item || [];
 | 
			
		||||
    
 | 
			
		||||
    for (const item of items) {
 | 
			
		||||
      const episode = {
 | 
			
		||||
        id: generateEpisodeId(item),
 | 
			
		||||
        title: item.title?.[0] || 'Untitled',
 | 
			
		||||
        description: item.description?.[0] || '',
 | 
			
		||||
        pubDate: item.pubDate?.[0] || '',
 | 
			
		||||
        audioUrl: item.enclosure?.[0]?.$?.url || '',
 | 
			
		||||
        audioLength: item.enclosure?.[0]?.$?.length || '0',
 | 
			
		||||
        guid: item.guid?.[0] || '',
 | 
			
		||||
        link: item.link?.[0] || ''
 | 
			
		||||
      };
 | 
			
		||||
      episodes.push(episode);
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    return c.json({ episodes });
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error("Error parsing podcast XML:", error);
 | 
			
		||||
    return c.json({ error: "Failed to parse podcast XML" }, 500);
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// Helper function to generate episode ID from XML item
 | 
			
		||||
function generateEpisodeId(item: any): string {
 | 
			
		||||
  // Use GUID if available, otherwise generate from title and audio URL
 | 
			
		||||
  if (item.guid?.[0]) {
 | 
			
		||||
    return encodeURIComponent(item.guid[0].replace(/[^a-zA-Z0-9-_]/g, '-'));
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  const title = item.title?.[0] || '';
 | 
			
		||||
  const audioUrl = item.enclosure?.[0]?.$?.url || '';
 | 
			
		||||
  const titleSlug = title.toLowerCase()
 | 
			
		||||
    .replace(/[^a-zA-Z0-9\s]/g, '')
 | 
			
		||||
    .replace(/\s+/g, '-')
 | 
			
		||||
    .substring(0, 50);
 | 
			
		||||
  
 | 
			
		||||
  // Extract filename from audio URL as fallback
 | 
			
		||||
  const audioFilename = audioUrl.split('/').pop()?.split('.')[0] || 'episode';
 | 
			
		||||
  
 | 
			
		||||
  return titleSlug || audioFilename;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
app.get("/api/episode/:episodeId", async (c) => {
 | 
			
		||||
  try {
 | 
			
		||||
    const episodeId = c.req.param("episodeId");
 | 
			
		||||
    const xml2js = await import("xml2js");
 | 
			
		||||
    const fs = await import("fs");
 | 
			
		||||
    const podcastXmlPath = path.join(config.paths.publicDir, "podcast.xml");
 | 
			
		||||
    
 | 
			
		||||
    if (!fs.existsSync(podcastXmlPath)) {
 | 
			
		||||
      return c.json({ error: "podcast.xml not found" }, 404);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const xmlContent = fs.readFileSync(podcastXmlPath, 'utf-8');
 | 
			
		||||
    const parser = new xml2js.Parser();
 | 
			
		||||
    const result = await parser.parseStringPromise(xmlContent);
 | 
			
		||||
    
 | 
			
		||||
    const items = result?.rss?.channel?.[0]?.item || [];
 | 
			
		||||
    const targetItem = items.find((item: any) => generateEpisodeId(item) === episodeId);
 | 
			
		||||
    
 | 
			
		||||
    if (!targetItem) {
 | 
			
		||||
      return c.json({ error: "Episode not found" }, 404);
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    const episode = {
 | 
			
		||||
      id: episodeId,
 | 
			
		||||
      title: targetItem.title?.[0] || 'Untitled',
 | 
			
		||||
      description: targetItem.description?.[0] || '',
 | 
			
		||||
      pubDate: targetItem.pubDate?.[0] || '',
 | 
			
		||||
      audioUrl: targetItem.enclosure?.[0]?.$?.url || '',
 | 
			
		||||
      audioLength: targetItem.enclosure?.[0]?.$?.length || '0',
 | 
			
		||||
      guid: targetItem.guid?.[0] || '',
 | 
			
		||||
      link: targetItem.link?.[0] || ''
 | 
			
		||||
    };
 | 
			
		||||
    
 | 
			
		||||
    return c.json({ episode });
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error("Error fetching episode:", error);
 | 
			
		||||
    return c.json({ error: "Failed to fetch episode" }, 500);
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
app.post("/api/feed-requests", async (c) => {
 | 
			
		||||
  try {
 | 
			
		||||
    const body = await c.req.json();
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user