Fix episode player and episode list
This commit is contained in:
17
bun.lock
17
bun.lock
@ -6,12 +6,15 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-polly": "^3.823.0",
|
"@aws-sdk/client-polly": "^3.823.0",
|
||||||
"@hono/node-server": "^1.14.3",
|
"@hono/node-server": "^1.14.3",
|
||||||
|
"@types/xml2js": "^0.4.14",
|
||||||
"ffmpeg-static": "^5.2.0",
|
"ffmpeg-static": "^5.2.0",
|
||||||
"hono": "^4.7.11",
|
"hono": "^4.7.11",
|
||||||
"openai": "^4.104.0",
|
"openai": "^4.104.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
|
"react-router-dom": "^7.6.2",
|
||||||
"rss-parser": "^3.13.0",
|
"rss-parser": "^3.13.0",
|
||||||
|
"xml2js": "^0.6.2",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest",
|
"@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/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=="],
|
"@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=="],
|
"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=="],
|
"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=="],
|
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
||||||
|
|
||||||
"debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
|
"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-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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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/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=="],
|
"@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 EpisodeList from './components/EpisodeList'
|
||||||
import FeedManager from './components/FeedManager'
|
import FeedManager from './components/FeedManager'
|
||||||
|
import EpisodeDetail from './components/EpisodeDetail'
|
||||||
|
|
||||||
function App() {
|
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 (
|
return (
|
||||||
<div className="container">
|
<div className="container">
|
||||||
@ -13,23 +23,25 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="tabs">
|
<div className="tabs">
|
||||||
<button
|
<Link
|
||||||
className={`tab ${activeTab === 'episodes' ? 'active' : ''}`}
|
to="/"
|
||||||
onClick={() => setActiveTab('episodes')}
|
className={`tab ${location.pathname === '/' ? 'active' : ''}`}
|
||||||
>
|
>
|
||||||
エピソード一覧
|
エピソード一覧
|
||||||
</button>
|
</Link>
|
||||||
<button
|
<Link
|
||||||
className={`tab ${activeTab === 'feeds' ? 'active' : ''}`}
|
to="/feeds"
|
||||||
onClick={() => setActiveTab('feeds')}
|
className={`tab ${location.pathname === '/feeds' ? 'active' : ''}`}
|
||||||
>
|
>
|
||||||
フィードリクエスト
|
フィードリクエスト
|
||||||
</button>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="content">
|
<div className="content">
|
||||||
{activeTab === 'episodes' && <EpisodeList />}
|
<Routes>
|
||||||
{activeTab === 'feeds' && <FeedManager />}
|
<Route path="/" element={<EpisodeList />} />
|
||||||
|
<Route path="/feeds" element={<FeedManager />} />
|
||||||
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
</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 {
|
interface Episode {
|
||||||
id: string
|
id: string
|
||||||
title: string
|
title: string
|
||||||
audioPath: string
|
description: string
|
||||||
createdAt: string
|
pubDate: string
|
||||||
article?: {
|
audioUrl: string
|
||||||
link: string
|
audioLength: string
|
||||||
}
|
guid: string
|
||||||
feed?: {
|
link: string
|
||||||
title: string
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function EpisodeList() {
|
function EpisodeList() {
|
||||||
@ -26,14 +24,14 @@ function EpisodeList() {
|
|||||||
const fetchEpisodes = async () => {
|
const fetchEpisodes = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const response = await fetch('/api/episodes')
|
const response = await fetch('/api/episodes-from-xml')
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = await response.json()
|
const errorData = await response.json()
|
||||||
throw new Error(errorData.error || 'エピソードの取得に失敗しました')
|
throw new Error(errorData.error || 'エピソードの取得に失敗しました')
|
||||||
}
|
}
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
console.log('Fetched episodes:', data)
|
console.log('Fetched episodes from XML:', data)
|
||||||
setEpisodes(data)
|
setEpisodes(data.episodes || [])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Episode fetch error:', err)
|
console.error('Episode fetch error:', err)
|
||||||
setError(err instanceof Error ? err.message : 'エラーが発生しました')
|
setError(err instanceof Error ? err.message : 'エラーが発生しました')
|
||||||
@ -46,7 +44,7 @@ function EpisodeList() {
|
|||||||
return new Date(dateString).toLocaleString('ja-JP')
|
return new Date(dateString).toLocaleString('ja-JP')
|
||||||
}
|
}
|
||||||
|
|
||||||
const playAudio = (audioPath: string) => {
|
const playAudio = (audioUrl: string) => {
|
||||||
if (currentAudio) {
|
if (currentAudio) {
|
||||||
const currentPlayer = document.getElementById(currentAudio) as HTMLAudioElement
|
const currentPlayer = document.getElementById(currentAudio) as HTMLAudioElement
|
||||||
if (currentPlayer) {
|
if (currentPlayer) {
|
||||||
@ -54,7 +52,23 @@ function EpisodeList() {
|
|||||||
currentPlayer.currentTime = 0
|
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) {
|
if (loading) {
|
||||||
@ -93,10 +107,10 @@ function EpisodeList() {
|
|||||||
<table className="table">
|
<table className="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style={{ width: '40%' }}>タイトル</th>
|
<th style={{ width: '35%' }}>タイトル</th>
|
||||||
<th style={{ width: '20%' }}>フィード</th>
|
<th style={{ width: '25%' }}>説明</th>
|
||||||
<th style={{ width: '20%' }}>作成日時</th>
|
<th style={{ width: '15%' }}>公開日</th>
|
||||||
<th style={{ width: '20%' }}>操作</th>
|
<th style={{ width: '25%' }}>操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@ -106,9 +120,9 @@ function EpisodeList() {
|
|||||||
<div style={{ marginBottom: '8px' }}>
|
<div style={{ marginBottom: '8px' }}>
|
||||||
<strong>{episode.title}</strong>
|
<strong>{episode.title}</strong>
|
||||||
</div>
|
</div>
|
||||||
{episode.article?.link && (
|
{episode.link && (
|
||||||
<a
|
<a
|
||||||
href={episode.article.link}
|
href={episode.link}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
style={{ fontSize: '12px', color: '#666' }}
|
style={{ fontSize: '12px', color: '#666' }}
|
||||||
@ -117,27 +131,46 @@ function EpisodeList() {
|
|||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td>{episode.feed?.title || '不明'}</td>
|
|
||||||
<td>{formatDate(episode.createdAt)}</td>
|
|
||||||
<td>
|
<td>
|
||||||
<button
|
<div style={{
|
||||||
className="btn btn-primary"
|
fontSize: '14px',
|
||||||
onClick={() => playAudio(episode.audioPath)}
|
maxWidth: '200px',
|
||||||
style={{ marginBottom: '8px' }}
|
overflow: 'hidden',
|
||||||
>
|
textOverflow: 'ellipsis',
|
||||||
再生
|
whiteSpace: 'nowrap'
|
||||||
</button>
|
}}>
|
||||||
{currentAudio === episode.audioPath && (
|
{episode.description || 'No description'}
|
||||||
<div>
|
</div>
|
||||||
<audio
|
</td>
|
||||||
id={episode.audioPath}
|
<td>{formatDate(episode.pubDate)}</td>
|
||||||
controls
|
<td>
|
||||||
className="audio-player"
|
<div style={{ display: 'flex', gap: '8px', flexDirection: 'column' }}>
|
||||||
src={`/podcast_audio/${episode.audioPath}`}
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
onEnded={() => setCurrentAudio(null)}
|
<button
|
||||||
/>
|
className="btn btn-primary"
|
||||||
|
onClick={() => playAudio(episode.audioUrl)}
|
||||||
|
>
|
||||||
|
再生
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => shareEpisode(episode)}
|
||||||
|
>
|
||||||
|
共有
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
{currentAudio === episode.audioUrl && (
|
||||||
|
<div>
|
||||||
|
<audio
|
||||||
|
id={episode.audioUrl}
|
||||||
|
controls
|
||||||
|
className="audio-player"
|
||||||
|
src={episode.audioUrl}
|
||||||
|
onEnded={() => setCurrentAudio(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
@ -1,10 +1,13 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
import './styles.css'
|
import './styles.css'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
@ -12,12 +12,15 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-polly": "^3.823.0",
|
"@aws-sdk/client-polly": "^3.823.0",
|
||||||
"@hono/node-server": "^1.14.3",
|
"@hono/node-server": "^1.14.3",
|
||||||
|
"@types/xml2js": "^0.4.14",
|
||||||
"ffmpeg-static": "^5.2.0",
|
"ffmpeg-static": "^5.2.0",
|
||||||
"hono": "^4.7.11",
|
"hono": "^4.7.11",
|
||||||
"openai": "^4.104.0",
|
"openai": "^4.104.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^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",
|
"type": "module",
|
||||||
"devDependencies": {
|
"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) => {
|
app.post("/api/feed-requests", async (c) => {
|
||||||
try {
|
try {
|
||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
|
Reference in New Issue
Block a user