feat: setup Next.js frontend structure
This commit is contained in:
		
							
								
								
									
										8
									
								
								frontend/next.config.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								frontend/next.config.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,8 @@
 | 
				
			|||||||
 | 
					/** @type {import('next').NextConfig} */
 | 
				
			||||||
 | 
					const nextConfig = {
 | 
				
			||||||
 | 
					  reactStrictMode: true,
 | 
				
			||||||
 | 
					  swcMinify: true,
 | 
				
			||||||
 | 
					  // 他のカスタム設定をここに追加
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default nextConfig;
 | 
				
			||||||
							
								
								
									
										30
									
								
								frontend/src/app/layout.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								frontend/src/app/layout.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,30 @@
 | 
				
			|||||||
 | 
					import React from "react";
 | 
				
			||||||
 | 
					import "./globals.css";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const metadata = {
 | 
				
			||||||
 | 
					  title: "ポッドキャスト管理画面",
 | 
				
			||||||
 | 
					  description: "RSSフィードから自動生成されたポッドキャストを管理",
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default function RootLayout({
 | 
				
			||||||
 | 
					  children,
 | 
				
			||||||
 | 
					}: {
 | 
				
			||||||
 | 
					  children: React.ReactNode;
 | 
				
			||||||
 | 
					}) {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <html lang="ja">
 | 
				
			||||||
 | 
					      <body>
 | 
				
			||||||
 | 
					        <div className="container">
 | 
				
			||||||
 | 
					          <header className="py-4 border-b">
 | 
				
			||||||
 | 
					            <h1 className="text-2xl font-bold">ポッドキャスト管理画面</h1>
 | 
				
			||||||
 | 
					          </h1>
 | 
				
			||||||
 | 
					          </header>
 | 
				
			||||||
 | 
					          <main className="py-6">{children}</main>
 | 
				
			||||||
 | 
					          <footer className="py-4 border-t text-center text-gray-500">
 | 
				
			||||||
 | 
					            <p>© 2025 Podcast Generator</p>
 | 
				
			||||||
 | 
					          </footer>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </body>
 | 
				
			||||||
 | 
					    </html>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										18
									
								
								frontend/src/app/page.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								frontend/src/app/page.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,18 @@
 | 
				
			|||||||
 | 
					import React from "react";
 | 
				
			||||||
 | 
					import FeedList from "../components/FeedList";
 | 
				
			||||||
 | 
					import EpisodePlayer from "../components/EpisodePlayer";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default function Home() {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <div className="space-y-8">
 | 
				
			||||||
 | 
					      <section>
 | 
				
			||||||
 | 
					        <h2 className="text-xl font-semibold mb-4">フィード一覧</h2>
 | 
				
			||||||
 | 
					        <FeedList />
 | 
				
			||||||
 | 
					      </section>
 | 
				
			||||||
 | 
					      <section>
 | 
				
			||||||
 | 
					        <h2 className="text-xl font-semibold mb-4">エピソードプレイヤー</h2>
 | 
				
			||||||
 | 
					        <EpisodePlayer />
 | 
				
			||||||
 | 
					      </section>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										87
									
								
								frontend/src/components/EpisodePlayer.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										87
									
								
								frontend/src/components/EpisodePlayer.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,87 @@
 | 
				
			|||||||
 | 
					import React, { useState } from "react";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					interface Episode {
 | 
				
			||||||
 | 
					  id: string;
 | 
				
			||||||
 | 
					  title: string;
 | 
				
			||||||
 | 
					  pubDate: string;
 | 
				
			||||||
 | 
					  audioPath: string;
 | 
				
			||||||
 | 
					  sourceLink: string;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default function EpisodePlayer() {
 | 
				
			||||||
 | 
					  const [episodes, setEpisodes] = useState<Episode[]>([]);
 | 
				
			||||||
 | 
					  const [selectedEpisode, setSelectedEpisode] = useState<Episode | null>(null);
 | 
				
			||||||
 | 
					  const [isPlaying, setIsPlaying] = useState(false);
 | 
				
			||||||
 | 
					  const [audioUrl, setAudioUrl] = useState<string | null>(null);
 | 
				
			||||||
 | 
					  const [loading, setLoading] = useState(true);
 | 
				
			||||||
 | 
					  const [error, setError] = useState<string | null>(null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  useEffect(() => {
 | 
				
			||||||
 | 
					    fetchEpisodes();
 | 
				
			||||||
 | 
					  }, []);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const fetchEpisodes = async () => {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      const response = await fetch("/api/episodes");
 | 
				
			||||||
 | 
					      if (!response.ok) {
 | 
				
			||||||
 | 
					        throw new Error("エピソードの取得に失敗しました");
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      const data = await response.json();
 | 
				
			||||||
 | 
					      setEpisodes(data);
 | 
				
			||||||
 | 
					      setLoading(false);
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      setError(err instanceof Error ? err.message : "エラーが発生しました");
 | 
				
			||||||
 | 
					      setLoading(false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const handlePlay = (episode: Episode) => {
 | 
				
			||||||
 | 
					    setSelectedEpisode(episode);
 | 
				
			||||||
 | 
					    setAudioUrl(`/podcast_audio/${episode.id}.mp3`);
 | 
				
			||||||
 | 
					    setIsPlaying(true);
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (loading) return <div>読み込み中...</div>;
 | 
				
			||||||
 | 
					  if (error) return <div className="text-red-500">エラー: {error}</div>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <div className="space-y-4">
 | 
				
			||||||
 | 
					      <h3 className="text-lg font-medium">最近のエピソード</h3>
 | 
				
			||||||
 | 
					      <div className="space-y-2">
 | 
				
			||||||
 | 
					        {episodes.map((episode) => (
 | 
				
			||||||
 | 
					          <div
 | 
				
			||||||
 | 
					            key={episode.id}
 | 
				
			||||||
 | 
					            className="flex justify-between items-center p-2 hover:bg-gray-50 rounded"
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
 | 
					            <span>{episode.title}</span>
 | 
				
			||||||
 | 
					            <button
 | 
				
			||||||
 | 
					              onClick={() => handlePlay(episode)}
 | 
				
			||||||
 | 
					              className="px-3 py-1 bg-blue-500 text-white rounded hover:bg-blue-600"
 | 
				
			||||||
 | 
					            >
 | 
				
			||||||
 | 
					              再生
 | 
				
			||||||
 | 
					            </button>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        ))}
 | 
				
			||||||
 | 
					      </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      {selectedEpisode && (
 | 
				
			||||||
 | 
					        <div className="mt-6">
 | 
				
			||||||
 | 
					          <h4 className="text-md font-semibold mb-2">
 | 
				
			||||||
 | 
					            再生中: {selectedEpisode.title}
 | 
				
			||||||
 | 
					          </h4>
 | 
				
			||||||
 | 
					          {audioUrl ? (
 | 
				
			||||||
 | 
					            <audio
 | 
				
			||||||
 | 
					              src={audioUrl}
 | 
				
			||||||
 | 
					              controls
 | 
				
			||||||
 | 
					              className="w-full"
 | 
				
			||||||
 | 
					              onPlay={() => setIsPlaying(true)}
 | 
				
			||||||
 | 
					              onPause={() => setIsPlaying(false)}
 | 
				
			||||||
 | 
					            />
 | 
				
			||||||
 | 
					          ) : (
 | 
				
			||||||
 | 
					            <div>音声ファイルを読み込み中...</div>
 | 
				
			||||||
 | 
					          )}
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										62
									
								
								frontend/src/components/FeedList.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								frontend/src/components/FeedList.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,62 @@
 | 
				
			|||||||
 | 
					import React, { useEffect, useState } from "react";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					interface FeedItem {
 | 
				
			||||||
 | 
					  id: string;
 | 
				
			||||||
 | 
					  title: string;
 | 
				
			||||||
 | 
					  link: string;
 | 
				
			||||||
 | 
					  pubDate: string;
 | 
				
			||||||
 | 
					  contentSnippet?: string;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default function FeedList() {
 | 
				
			||||||
 | 
					  const [feeds, setFeeds] = useState<FeedItem[]>([]);
 | 
				
			||||||
 | 
					  const [loading, setLoading] = useState(true);
 | 
				
			||||||
 | 
					  const [error, setError] = useState<string | null>(null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  useEffect(() => {
 | 
				
			||||||
 | 
					    fetchFeeds();
 | 
				
			||||||
 | 
					  }, []);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const fetchFeeds = async () => {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      const response = await fetch("/api/feeds");
 | 
				
			||||||
 | 
					      if (!response.ok) {
 | 
				
			||||||
 | 
					        throw new Error("フィードの取得に失敗しました");
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      const data = await response.json();
 | 
				
			||||||
 | 
					      setFeeds(data);
 | 
				
			||||||
 | 
					      setLoading(false);
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      setError(err instanceof Error ? err.message : "エラーが発生しました");
 | 
				
			||||||
 | 
					      setLoading(false);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (loading) return <div>読み込み中...</div>;
 | 
				
			||||||
 | 
					  if (error) return <div className="text-red-500">エラー: {error}</div>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <div className="space-y-4">
 | 
				
			||||||
 | 
					      {feeds.map((feed) => (
 | 
				
			||||||
 | 
					        <div
 | 
				
			||||||
 | 
					          key={feed.id}
 | 
				
			||||||
 | 
					          className="border rounded-lg p-4 hover:shadow-md transition-shadow"
 | 
				
			||||||
 | 
					        >
 | 
				
			||||||
 | 
					          <h3 className="text-lg font-medium">{feed.title}</h3>
 | 
				
			||||||
 | 
					          <p className="text-sm text-gray-500">{feed.pubDate}</p>
 | 
				
			||||||
 | 
					          {feed.contentSnippet && (
 | 
				
			||||||
 | 
					            <p className="mt-2 text-gray-700">{feed.contentSnippet}</p>
 | 
				
			||||||
 | 
					          )}
 | 
				
			||||||
 | 
					          <a
 | 
				
			||||||
 | 
					            href={feed.link}
 | 
				
			||||||
 | 
					            className="mt-3 inline-block text-blue-500 hover:underline"
 | 
				
			||||||
 | 
					            target="_blank"
 | 
				
			||||||
 | 
					            rel="noopener noreferrer"
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
 | 
					            オリジナル記事へ
 | 
				
			||||||
 | 
					          </a>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      ))}
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
		Reference in New Issue
	
	Block a user