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 && (
)}
- {episode.feed?.title || '不明'} |
- {formatDate(episode.createdAt)} |
-
- {currentAudio === episode.audioPath && (
-
- |
+ {formatDate(episode.pubDate)} |
+
+
+
+
+
- )}
+ {currentAudio === episode.audioUrl && (
+
+
+ )}
+
|
))}
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();