From 6830d06ed42435bcd70158caba3787f98a1a6311 Mon Sep 17 00:00:00 2001 From: Satsuki Akiba Date: Sat, 7 Jun 2025 14:24:33 +0900 Subject: [PATCH] Fix episode player and episode list --- bun.lock | 17 +- frontend/src/App.tsx | 36 ++-- frontend/src/components/EpisodeDetail.tsx | 200 ++++++++++++++++++++++ frontend/src/components/EpisodeList.tsx | 109 ++++++++---- frontend/src/main.tsx | 5 +- package.json | 5 +- server.ts | 100 +++++++++++ 7 files changed, 419 insertions(+), 53 deletions(-) create mode 100644 frontend/src/components/EpisodeDetail.tsx diff --git a/bun.lock b/bun.lock index 9536e3f..4e5951f 100644 --- a/bun.lock +++ b/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=="], diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f517e24..14a2539 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 ( + + } /> + + ) + } return (
@@ -13,23 +23,25 @@ function App() {
- - +
- {activeTab === 'episodes' && } - {activeTab === 'feeds' && } + + } /> + } /> +
) diff --git a/frontend/src/components/EpisodeDetail.tsx b/frontend/src/components/EpisodeDetail.tsx new file mode 100644 index 0000000..f774a8c --- /dev/null +++ b/frontend/src/components/EpisodeDetail.tsx @@ -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(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(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 ( +
+
読み込み中...
+
+ ) + } + + if (error) { + return ( +
+
{error}
+ + エピソード一覧に戻る + +
+ ) + } + + if (!episode) { + return ( +
+
エピソードが見つかりません
+ + エピソード一覧に戻る + +
+ ) + } + + return ( +
+
+ + ← エピソード一覧に戻る + +
+ {episode.title} +
+
+ 公開日: {formatDate(episode.pubDate)} +
+
+ +
+
+ +
+ +
+ + {episode.link && ( + + 元記事を見る + + )} +
+ +
+

エピソード情報

+
+
+ ファイルサイズ: {formatFileSize(episode.audioLength)} +
+ {episode.guid && ( +
+ エピソードID: {episode.guid} +
+ )} + +
+
+ + {episode.description && ( +
+

エピソード詳細

+
+ {episode.description} +
+
+ )} +
+
+ ) +} + +export default EpisodeDetail \ No newline at end of file diff --git a/frontend/src/components/EpisodeList.tsx b/frontend/src/components/EpisodeList.tsx index 29ae41c..15cb1c9 100644 --- a/frontend/src/components/EpisodeList.tsx +++ b/frontend/src/components/EpisodeList.tsx @@ -3,14 +3,12 @@ import { useState, useEffect } from 'react' interface Episode { id: string title: string - audioPath: string - createdAt: string - article?: { - link: string - } - feed?: { - title: string - } + description: string + pubDate: string + audioUrl: string + audioLength: string + guid: string + link: 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() { - - - - + + + + @@ -106,9 +120,9 @@ function EpisodeList() {
{episode.title}
- {episode.article?.link && ( + {episode.link && ( )} - - + + ))} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index f615309..c730bf5 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -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( - + + + , ) \ No newline at end of file diff --git a/package.json b/package.json index 041d041..a9208c9 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/server.ts b/server.ts index f931415..00bb681 100644 --- a/server.ts +++ b/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();
タイトルフィード作成日時操作タイトル説明公開日操作
{episode.feed?.title || '不明'}{formatDate(episode.createdAt)} - - {currentAudio === episode.audioPath && ( -
-
{formatDate(episode.pubDate)} +
+
+ +
- )} + {currentAudio === episode.audioUrl && ( +
+
+ )} +