feat: implement AI Podcast Generator UI with 3-column layout
- Add responsive 3-column design similar to NotebookLM - Implement URL input form with loading states and validation - Create conversation display between two hosts (Alex & Sarah) - Build functional audio player with play/pause/restart controls - Add progress bar, volume control, and episode information - Include mock conversation data with 10 rounds of dialogue - Implement hover effects and smooth transitions - Add dark mode support and custom scrollbar styling - Update metadata and application title 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -3,11 +3,15 @@
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
--card: #ffffff;
|
||||
--card-foreground: #171717;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
@@ -16,6 +20,8 @@
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
--card: #0a0a0a;
|
||||
--card-foreground: #ededed;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,3 +30,21 @@ body {
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
/* Custom scrollbar for conversation area */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #888;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #666;
|
||||
}
|
||||
|
||||
@@ -13,8 +13,8 @@ const geistMono = Geist_Mono({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "AI Podcast Generator",
|
||||
description: "Convert any URL into a natural sounding podcast audio file",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
|
||||
394
src/app/page.tsx
394
src/app/page.tsx
@@ -1,103 +1,307 @@
|
||||
import Image from "next/image";
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
speaker: 'host1' | 'host2';
|
||||
text: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="font-sans grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20">
|
||||
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={180}
|
||||
height={38}
|
||||
priority
|
||||
/>
|
||||
<ol className="font-mono list-inside list-decimal text-sm/6 text-center sm:text-left">
|
||||
<li className="mb-2 tracking-[-.01em]">
|
||||
Get started by editing{" "}
|
||||
<code className="bg-black/[.05] dark:bg-white/[.06] font-mono font-semibold px-1 py-0.5 rounded">
|
||||
src/app/page.tsx
|
||||
</code>
|
||||
.
|
||||
</li>
|
||||
<li className="tracking-[-.01em]">
|
||||
Save and see your changes instantly.
|
||||
</li>
|
||||
</ol>
|
||||
const [url, setUrl] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [progressInterval, setProgressInterval] = useState<NodeJS.Timeout | null>(null);
|
||||
|
||||
<div className="flex gap-4 items-center flex-col sm:flex-row">
|
||||
<a
|
||||
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
Deploy now
|
||||
</a>
|
||||
<a
|
||||
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Read our docs
|
||||
</a>
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
|
||||
// Simulate API call
|
||||
setTimeout(() => {
|
||||
setMessages(mockMessages);
|
||||
setIsLoading(false);
|
||||
setDuration(320); // 5 minutes 20 seconds
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const togglePlay = () => {
|
||||
if (isPlaying) {
|
||||
setIsPlaying(false);
|
||||
if (progressInterval) {
|
||||
clearInterval(progressInterval);
|
||||
setProgressInterval(null);
|
||||
}
|
||||
} else {
|
||||
setIsPlaying(true);
|
||||
const interval = setInterval(() => {
|
||||
setCurrentTime(prev => {
|
||||
if (prev >= duration) {
|
||||
setIsPlaying(false);
|
||||
clearInterval(interval);
|
||||
setProgressInterval(null);
|
||||
return duration;
|
||||
}
|
||||
return prev + 1;
|
||||
});
|
||||
}, 1000);
|
||||
setProgressInterval(interval);
|
||||
}
|
||||
};
|
||||
|
||||
const restartAudio = () => {
|
||||
setCurrentTime(0);
|
||||
setIsPlaying(false);
|
||||
if (progressInterval) {
|
||||
clearInterval(progressInterval);
|
||||
setProgressInterval(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Header */}
|
||||
<header className="border-b border-gray-200 dark:border-gray-800">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<h1 className="text-2xl font-bold text-foreground">AI Podcast Generator</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4 lg:gap-6 h-[calc(100vh-10rem)] sm:h-[calc(100vh-12rem)]">
|
||||
{/* Left Column - URL Input */}
|
||||
<div className="xl:col-span-1">
|
||||
<div className="bg-card border border-gray-200 dark:border-gray-800 rounded-lg p-6 h-full">
|
||||
<h2 className="text-lg font-semibold mb-4">Generate Podcast</h2>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="url" className="block text-sm font-medium mb-2">
|
||||
Enter Website URL
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
id="url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://example.com"
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !url}
|
||||
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isLoading ? 'Generating...' : 'Generate Podcast'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{messages.length > 0 && (
|
||||
<div className="mt-6 p-4 bg-green-50 dark:bg-green-900/20 rounded-md">
|
||||
<p className="text-sm text-green-800 dark:text-green-200">
|
||||
Podcast generated successfully! Listen to the conversation below.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Middle Column - Conversation */}
|
||||
<div className="xl:col-span-1">
|
||||
<div className="bg-card border border-gray-200 dark:border-gray-800 rounded-lg p-6 h-full flex flex-col">
|
||||
<h2 className="text-lg font-semibold mb-4">Podcast Conversation</h2>
|
||||
<div className="flex-1 overflow-y-auto space-y-4">
|
||||
{messages.length === 0 ? (
|
||||
<div className="text-center text-gray-500 mt-8">
|
||||
<p>Enter a URL and generate a podcast to see the conversation here.</p>
|
||||
</div>
|
||||
) : (
|
||||
messages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`flex ${message.speaker === 'host1' ? 'justify-start' : 'justify-end'}`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[80%] rounded-lg px-4 py-2 transition-all duration-200 hover:scale-[1.02] ${
|
||||
message.speaker === 'host1'
|
||||
? 'bg-blue-100 dark:bg-blue-900/30 hover:bg-blue-200 dark:hover:bg-blue-900/50'
|
||||
: 'bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium text-sm mb-1">
|
||||
{message.speaker === 'host1' ? 'Alex' : 'Sarah'}
|
||||
</div>
|
||||
<p className="text-sm">{message.text}</p>
|
||||
<div className="text-xs text-gray-500 mt-1">{message.timestamp}</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column - Audio Player */}
|
||||
<div className="xl:col-span-1">
|
||||
<div className="bg-card border border-gray-200 dark:border-gray-800 rounded-lg p-6 h-full">
|
||||
<h2 className="text-lg font-semibold mb-4">Audio Player</h2>
|
||||
{messages.length === 0 ? (
|
||||
<div className="text-center text-gray-500 mt-8">
|
||||
<p>Generate a podcast to enable audio playback.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Audio Controls */}
|
||||
<div className="flex justify-center items-center space-x-4">
|
||||
<button
|
||||
onClick={restartAudio}
|
||||
className="p-2 rounded-full bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 transition-all duration-200 hover:scale-110"
|
||||
title="Restart"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={togglePlay}
|
||||
className="p-3 rounded-full bg-blue-600 text-white hover:bg-blue-700 transition-all duration-200 hover:scale-110 shadow-lg hover:shadow-xl"
|
||||
title={isPlaying ? 'Pause' : 'Play'}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 9v6m4-6v6m7-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="space-y-2">
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${(currentTime / duration) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm text-gray-500">
|
||||
<span>{formatTime(currentTime)}</span>
|
||||
<span>{formatTime(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Volume Control */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<svg className="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.536 8.464a5 5 0 010 7.072M12 6a9 9 0 010 12m-5.5-7.5h.01M4 12h.01" />
|
||||
</svg>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
defaultValue="70"
|
||||
className="flex-1 h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Episode Info */}
|
||||
<div className="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||
<h3 className="font-medium mb-2">Episode Details</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Duration: {formatTime(duration)}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Speakers: Alex & Sarah
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/file.svg"
|
||||
alt="File icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Learn
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/window.svg"
|
||||
alt="Window icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Examples
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/globe.svg"
|
||||
alt="Globe icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Go to nextjs.org →
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper function to format time
|
||||
function formatTime(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// Mock conversation data
|
||||
const mockMessages: Message[] = [
|
||||
{
|
||||
id: '1',
|
||||
speaker: 'host1',
|
||||
text: "Hey Sarah! Today we're discussing this fascinating article about artificial intelligence and its impact on creative industries.",
|
||||
timestamp: '0:15'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
speaker: 'host2',
|
||||
text: "That's really interesting, Alex! I've been following this topic closely. The article mentions some really compelling points about AI-generated art and music.",
|
||||
timestamp: '0:45'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
speaker: 'host1',
|
||||
text: "Exactly! What I found most surprising was the section about how AI is actually helping human creators become more productive rather than replacing them entirely.",
|
||||
timestamp: '1:20'
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
speaker: 'host2',
|
||||
text: "That's a crucial distinction. The article highlights several case studies where artists are using AI as a collaborative tool to enhance their creative process.",
|
||||
timestamp: '1:55'
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
speaker: 'host1',
|
||||
text: "I particularly loved the example about the musician who used AI to generate backing tracks and then added their own creative twist on top.",
|
||||
timestamp: '2:30'
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
speaker: 'host2',
|
||||
text: "And what about the ethical considerations? The article raises some important questions about copyright and attribution when AI is involved in the creative process.",
|
||||
timestamp: '3:05'
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
speaker: 'host1',
|
||||
text: "That's definitely something we need to think about. The author suggests that we might need new frameworks for understanding creativity in the age of AI.",
|
||||
timestamp: '3:40'
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
speaker: 'host2',
|
||||
text: "I agree. It's not about rejecting AI, but finding ways to integrate it responsibly into our creative workflows while maintaining human oversight and artistic vision.",
|
||||
timestamp: '4:15'
|
||||
},
|
||||
{
|
||||
id: '9',
|
||||
speaker: 'host1',
|
||||
text: "The future looks really exciting! Imagine what we'll be able to create when we embrace these technologies as tools rather than threats.",
|
||||
timestamp: '4:50'
|
||||
},
|
||||
{
|
||||
id: '10',
|
||||
speaker: 'host2',
|
||||
text: "Absolutely! Thanks for sharing this article with our listeners. It really gives us a lot to think about regarding the future of creativity and AI.",
|
||||
timestamp: '5:20'
|
||||
}
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user