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