diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5ef6a52 --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/README.md b/README.md new file mode 100644 index 0000000..706dc59 --- /dev/null +++ b/README.md @@ -0,0 +1,176 @@ +# PM AI Agent 🤖 + +A sophisticated Next.js application that transforms Loom video demonstrations into detailed user stories using AI. This tool helps Product Managers streamline their documentation process by automatically generating structured user stories from video content. The project was primarily developed (95%) by Cline AI, with only the initial scaffolding done manually. + +## Features + +### 1. Loom Video Processing +- Upload video files with optional SRT subtitles +- Built-in video player with frame selection capabilities +- Automatic subtitle parsing and integration +- Powered by FFmpeg for reliable video processing + +### 2. AI-Powered Story Generation +- Utilizes Google's Gemini 1.5 Pro AI model +- Analyzes video frames and subtitles to understand feature demonstrations +- Generates comprehensive user stories in standard Agile format +- Includes technical considerations and story points + +### 3. Story Management & Integration +- Save and manage multiple user stories +- Delete unwanted stories +- Organized timeline view of generated content +- Export stories to ClickUp with screenshots +- Track export progress with visual indicators + +### 4. ClickUp Integration +- Configure ClickUp API token in Integrations page +- Export user stories directly to ClickUp spaces and lists +- Automatic status handling using list defaults +- Markdown-formatted story content +- Scene screenshots uploaded as attachments +- Progress tracking for multi-image uploads + +## Integrations + +### ClickUp Integration +1. **Configuration** + - Navigate to the Integrations page + - Add your ClickUp API token + - Token is securely stored and validated + +2. **Export Features** + - Export stories directly to ClickUp + - Select target space and list + - Automatic status assignment + - Scene screenshots included as attachments + - Progress tracking for uploads + +3. **Task Format** + - Structured user story content + - Markdown formatting for readability + - Metadata section with clip details + - Notes section (if available) + - Scene screenshots as visual context + +## How It Works + +1. **Upload Phase** + - Download your Loom video demonstration with SRT subtitles + - Upload the video and SRT file to the application + - Automatic subtitle parsing and synchronization + +2. **Clip Selection** + - Use the built-in video player to select relevant clips + - Add context notes for better AI understanding + - Frame-by-frame selection for precise feature demonstration capture + +3. **Story Generation** + - AI analyzes selected video frames + - Processes any available subtitles/dialogue + - Generates structured user stories including: + - User story title + - User/Action/Benefit format + - Acceptance criteria + - Technical notes + - Story point estimation + +## Technical Stack + +- **Framework**: Next.js 15.1.3 with TypeScript +- **UI**: TailwindCSS with custom components +- **Video Processing**: FFmpeg +- **AI Integration**: + - Google Generative AI (Gemini 1.5 Pro) + - OpenAI (auxiliary processing) +- **Subtitle Processing**: Subtitle parser for SRT files +- **File Handling**: React Dropzone + +## Setup + +1. Clone the repository +2. Install dependencies: + ```bash + npm install + ``` +3. Set up environment variables: + ```env + # AI Services + NEXT_PUBLIC_GEMINI_API_KEY=your_gemini_api_key + + # ClickUp Integration (Optional) + # Configure through the Integrations page in the app + # The token will be stored securely in the environment + # CLICKUP_TOKEN=your_clickup_api_token + ``` +4. Run the development server: + ```bash + npm run dev + ``` + +## Usage + +1. **Start the Application** + - Navigate to the application in your browser + - You'll see the main interface with a file upload section + +2. **Upload Content** + - Record your feature demonstration using Loom + - Download both the video file and SRT subtitles from Loom + - Drag and drop or select both files in the application + - The video player will automatically load with synchronized subtitles + +3. **Select Clips** + - Use the video player controls to navigate + - Select key frames that demonstrate the feature + - Add context notes to guide the AI + +4. **Generate Stories** + - Click generate to create the user story + - Review the generated content + - Save or modify as needed + +5. **Export to ClickUp** (Optional) + - Configure ClickUp integration in the Integrations page + - Click the export icon on any generated story + - Select the target space and list + - Wait for the export to complete, including: + * Story content with markdown formatting + * Scene screenshots as attachments + * Metadata and notes + +## Best Practices + +- Use clear, well-lit video demonstrations +- Include relevant subtitles for better context +- Select frames that clearly show feature transitions +- Provide detailed context notes for better AI understanding +- Review and edit generated stories as needed + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +## License + +MIT License + +Copyright (c) 2024 PM AI Agent Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/app/api/integrations/clickup/route.ts b/app/api/integrations/clickup/route.ts new file mode 100644 index 0000000..367526b --- /dev/null +++ b/app/api/integrations/clickup/route.ts @@ -0,0 +1,79 @@ +import { NextResponse } from 'next/server'; +import { ClickUpService } from '../../../../lib/clickup-service'; + +export async function POST(request: Request) { + try { + const { token } = await request.json(); + + if (!token) { + return NextResponse.json( + { error: 'Token is required' }, + { status: 400 } + ); + } + + // Validate the token by making a test request + const clickupService = new ClickUpService(token); + const isValid = await clickupService.validateToken(); + + if (!isValid) { + return NextResponse.json( + { error: 'Invalid ClickUp token' }, + { status: 401 } + ); + } + + // In a production environment, you would want to: + // 1. Encrypt the token before storing + // 2. Use a secure storage solution (e.g., database, secure key store) + // 3. Associate the token with the current user's session + // For demo purposes, we'll store in an environment variable + process.env.CLICKUP_TOKEN = token; + + return NextResponse.json( + { message: 'ClickUp token saved successfully' }, + { status: 200 } + ); + } catch (error) { + console.error('Error saving ClickUp token:', error); + return NextResponse.json( + { error: 'Failed to save ClickUp token' }, + { status: 500 } + ); + } +} + +export async function GET() { + try { + const token = process.env.CLICKUP_TOKEN; + + if (!token) { + return NextResponse.json( + { error: 'No ClickUp token found' }, + { status: 404 } + ); + } + + // Validate the stored token + const clickupService = new ClickUpService(token); + const isValid = await clickupService.validateToken(); + + if (!isValid) { + return NextResponse.json( + { error: 'Stored ClickUp token is invalid' }, + { status: 401 } + ); + } + + return NextResponse.json( + { message: 'ClickUp integration is configured' }, + { status: 200 } + ); + } catch (error) { + console.error('Error checking ClickUp token:', error); + return NextResponse.json( + { error: 'Failed to check ClickUp token' }, + { status: 500 } + ); + } +} diff --git a/app/api/integrations/clickup/token/route.ts b/app/api/integrations/clickup/token/route.ts new file mode 100644 index 0000000..58d6657 --- /dev/null +++ b/app/api/integrations/clickup/token/route.ts @@ -0,0 +1,22 @@ +import { NextResponse } from 'next/server'; + +export async function GET() { + try { + const token = process.env.CLICKUP_TOKEN; + + if (!token) { + return NextResponse.json( + { error: 'ClickUp token not configured' }, + { status: 404 } + ); + } + + return NextResponse.json({ token }); + } catch (error) { + console.error('Error retrieving ClickUp token:', error); + return NextResponse.json( + { error: 'Failed to retrieve ClickUp token' }, + { status: 500 } + ); + } +} diff --git a/app/favicon.ico b/app/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/app/favicon.ico differ diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..a23ac26 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,72 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { + font-family: Arial, Helvetica, sans-serif; +} + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 0 0% 3.9%; + --card: 0 0% 100%; + --card-foreground: 0 0% 3.9%; + --popover: 0 0% 100%; + --popover-foreground: 0 0% 3.9%; + --primary: 0 0% 9%; + --primary-foreground: 0 0% 98%; + --secondary: 0 0% 96.1%; + --secondary-foreground: 0 0% 9%; + --muted: 0 0% 96.1%; + --muted-foreground: 0 0% 45.1%; + --accent: 0 0% 96.1%; + --accent-foreground: 0 0% 9%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 89.8%; + --input: 0 0% 89.8%; + --ring: 0 0% 3.9%; + --chart-1: 12 76% 61%; + --chart-2: 173 58% 39%; + --chart-3: 197 37% 24%; + --chart-4: 43 74% 66%; + --chart-5: 27 87% 67%; + --radius: 0.5rem; + } + .dark { + --background: 0 0% 3.9%; + --foreground: 0 0% 98%; + --card: 0 0% 3.9%; + --card-foreground: 0 0% 98%; + --popover: 0 0% 3.9%; + --popover-foreground: 0 0% 98%; + --primary: 0 0% 98%; + --primary-foreground: 0 0% 9%; + --secondary: 0 0% 14.9%; + --secondary-foreground: 0 0% 98%; + --muted: 0 0% 14.9%; + --muted-foreground: 0 0% 63.9%; + --accent: 0 0% 14.9%; + --accent-foreground: 0 0% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 14.9%; + --input: 0 0% 14.9%; + --ring: 0 0% 83.1%; + --chart-1: 220 70% 50%; + --chart-2: 160 60% 45%; + --chart-3: 30 80% 55%; + --chart-4: 280 65% 60%; + --chart-5: 340 75% 55%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/app/integrations/page.tsx b/app/integrations/page.tsx new file mode 100644 index 0000000..9252cd6 --- /dev/null +++ b/app/integrations/page.tsx @@ -0,0 +1,142 @@ +"use client" + +import { useState } from 'react'; + +export default function IntegrationsPage() { + const [clickupToken, setClickupToken] = useState(''); + const [isSaving, setIsSaving] = useState(false); + const [savedToken, setSavedToken] = useState(null); + + const handleSaveToken = async () => { + setIsSaving(true); + try { + const response = await fetch('/api/integrations/clickup', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ token: clickupToken }), + }); + + if (!response.ok) { + throw new Error('Failed to save token'); + } + + setSavedToken(clickupToken); + setClickupToken(''); + } catch (error) { + console.error('Error saving token:', error); + alert('Failed to save ClickUp token. Please try again.'); + } finally { + setIsSaving(false); + } + }; + + return ( +
+ {/* Header */} +
+
+
+

Integrations

+
+
+
+ +
+ {/* Introduction */} +
+

Configure Integrations

+

+ Connect your PM AI Agent with external tools to streamline your workflow. + Currently supporting ClickUp integration for direct story exports. +

+
+ + {/* ClickUp Integration Section */} +
+
+
+
+ + + +
+
+

ClickUp Integration

+

+ Connect with ClickUp to export generated user stories directly to your workspace. +

+
+
+ +
+
+ + setClickupToken(e.target.value)} + placeholder="Enter your ClickUp API token" + className="w-full px-3 py-2 rounded-md border bg-background" + /> +

+ You can find your API token in ClickUp Settings → Apps +

+
+ + +
+ + {savedToken && ( +
+ ClickUp integration configured successfully! Your stories can now be exported to ClickUp. +
+ )} + +
+

How to get your API token:

+
    +
  1. Log in to your ClickUp account
  2. +
  3. Go to Settings (click your avatar)
  4. +
  5. Click on "Apps"
  6. +
  7. Generate a new "API Token"
  8. +
  9. Copy and paste the token here
  10. +
+
+
+
+
+
+ ); +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..f7fa87e --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,34 @@ +import type { Metadata } from "next"; +import { Geist, Geist_Mono } from "next/font/google"; +import "./globals.css"; + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + +export const metadata: Metadata = { + title: "Create Next App", + description: "Generated by create next app", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {children} + + + ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..f7ab24a --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,196 @@ +"use client" + +import { useState } from 'react'; +import { FileUpload } from '../components/ui/file-upload'; +import { VideoPlayer } from '../components/ui/video-player'; +import { StoryManager } from '../components/ui/story-manager'; +import { generateUserStory } from '../lib/gemini-service'; +import { Story, SceneInfo } from '../lib/types'; +import { getSubtitlesForClip, parseSRT, type Subtitle } from '../lib/srt-utils'; + +interface VideoData { + video: File | null; + subtitles?: string; +} + +export default function Home() { + const [videoFile, setVideoFile] = useState(null); + const [parsedSubtitles, setParsedSubtitles] = useState([]); + const [stories, setStories] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [showVideoPlayer, setShowVideoPlayer] = useState(false); + + const handleFileSelect = (files: VideoData) => { + setVideoFile(files.video); + if (files.subtitles) { + try { + // Parse the SRT content into structured subtitles + const srtContent = files.subtitles.trim(); + if (!srtContent) { + console.warn('Empty SRT content'); + setParsedSubtitles([]); + return; + } + console.log('Parsing SRT content:', srtContent); // Debug log + const parsed = parseSRT(srtContent); + console.log('Parsed subtitles:', parsed); // Debug log + setParsedSubtitles(parsed); + } catch (error) { + console.error('Failed to parse SRT:', error); + setParsedSubtitles([]); + } + } else { + setParsedSubtitles([]); + } + }; + + const handleClipSelect = async (scenes: SceneInfo[], notes: string, clipRange: { start: number; end: number }) => { + if (!videoFile) return; + + setIsLoading(true); + try { + // Get subtitles for the selected clip range + const clipSubtitles = parsedSubtitles.length > 0 + ? getSubtitlesForClip(parsedSubtitles, clipRange.start, clipRange.end) + : undefined; + + const content = await generateUserStory(scenes, notes, clipSubtitles); + const newStory: Story = { + id: crypto.randomUUID(), + content, + scenes, + notes, + clipRange, + timestamp: Date.now() + }; + + setStories(prevStories => [...prevStories, newStory]); + } catch (error) { + console.error('Error generating user story:', error); + alert('Failed to generate user story. Please try again.'); + } finally { + setIsLoading(false); + } + }; + + const handleDeleteStory = (storyId: string) => { + setStories(prevStories => prevStories.filter(story => story.id !== storyId)); + }; + + return ( +
+ {/* Sticky Header with Progress */} +
+
+
+
+

PM AI Agent

+ + + + + + Integrations + +
+
+
+
1
+ Upload +
+
+
+
2
+ Select Clips +
+
+
0 ? 'text-primary' : 'text-muted-foreground'}`}> +
0 ? 'bg-primary text-primary-foreground' : 'bg-muted'}`}>3
+ Stories +
+
+
+
+
+ +
+ {/* Introduction */} +
+

Generate User Stories from Video

+

+ Transform your video content into detailed user stories automatically. Upload a video, + select key moments, and let AI generate comprehensive user stories for your product management needs. +

+
+ + {/* File Upload Section */} +
+ { + handleFileSelect(files); + setShowVideoPlayer(true); + }} + /> +
+ + {/* Video Player Section */} + {videoFile && showVideoPlayer && ( +
+
+

Video Clip Selection

+ +
+ handleClipSelect(scenes, notes, clipRange)} + /> +
+ )} + + {/* Loading Indicator */} + {isLoading && ( +
+
+
+
+
+

+ Analyzing scenes and generating user story... +

+
+
+ )} + + {/* Stories Section */} + {stories.length > 0 && ( +
+

Generated Stories

+ +
+ )} +
+
+ ); +} diff --git a/components.json b/components.json new file mode 100644 index 0000000..dea737b --- /dev/null +++ b/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} \ No newline at end of file diff --git a/components/ui/clickup-export-modal.tsx b/components/ui/clickup-export-modal.tsx new file mode 100644 index 0000000..105f844 --- /dev/null +++ b/components/ui/clickup-export-modal.tsx @@ -0,0 +1,190 @@ +"use client" + +import { useState, useEffect } from 'react'; +import { ClickUpService, ClickUpSpace, ClickUpList } from '../../lib/clickup-service'; + +interface ClickUpExportModalProps { + isOpen: boolean; + onClose: () => void; + onExport: (listId: string) => Promise; +} + +export function ClickUpExportModal({ isOpen, onClose, onExport }: ClickUpExportModalProps) { + const [spaces, setSpaces] = useState([]); + const [lists, setLists] = useState([]); + const [selectedSpace, setSelectedSpace] = useState(''); + const [selectedList, setSelectedList] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (isOpen) { + loadSpaces(); + } + }, [isOpen]); + + useEffect(() => { + if (selectedSpace) { + loadLists(selectedSpace); + } else { + setLists([]); + setSelectedList(''); + } + }, [selectedSpace]); + + const loadSpaces = async () => { + setIsLoading(true); + setError(null); + try { + const tokenResponse = await fetch('/api/integrations/clickup/token'); + if (!tokenResponse.ok) { + throw new Error('ClickUp integration not configured'); + } + + const { token } = await tokenResponse.json(); + const clickupService = new ClickUpService(token); + const spacesList = await clickupService.getSpaces(); + setSpaces(spacesList); + } catch (error) { + setError(error instanceof Error ? error.message : 'Failed to load spaces'); + } finally { + setIsLoading(false); + } + }; + + const loadLists = async (spaceId: string) => { + setIsLoading(true); + setError(null); + try { + const tokenResponse = await fetch('/api/integrations/clickup/token'); + if (!tokenResponse.ok) { + throw new Error('ClickUp integration not configured'); + } + + const { token } = await tokenResponse.json(); + const clickupService = new ClickUpService(token); + const listsList = await clickupService.getLists(spaceId); + setLists(listsList); + } catch (error) { + setError(error instanceof Error ? error.message : 'Failed to load lists'); + } finally { + setIsLoading(false); + } + }; + + const handleExport = async () => { + if (!selectedList) return; + + setIsLoading(true); + setError(null); + try { + await onExport(selectedList); + onClose(); + } catch (error) { + setError(error instanceof Error ? error.message : 'Failed to export to ClickUp'); + } finally { + setIsLoading(false); + } + }; + + if (!isOpen) return null; + + return ( +
+
+
+
+

Export to ClickUp

+ +
+ +
+ {error && ( +
+ {error} +
+ )} + +
+ + +
+ + {selectedSpace && ( +
+ + +
+ )} +
+ +
+ + +
+
+
+
+ ); +} diff --git a/components/ui/file-upload.tsx b/components/ui/file-upload.tsx new file mode 100644 index 0000000..39a96ec --- /dev/null +++ b/components/ui/file-upload.tsx @@ -0,0 +1,278 @@ +"use client" + +import { useState, useCallback } from 'react'; +import { useDropzone } from 'react-dropzone'; +import { parseSRT, validateSRTWithVideo } from '../../lib/srt-utils'; + +interface FileUploadProps { + onFileSelect: (files: { video: File | null; subtitles?: string }) => void; +} + +export function FileUpload({ onFileSelect }: FileUploadProps) { + const [isDragging, setIsDragging] = useState(false); + const [videoFile, setVideoFile] = useState(null); + const [srtFile, setSrtFile] = useState(null); + const [error, setError] = useState(null); + + const processSRTFile = async (file: File, videoDuration: number) => { + try { + const text = await file.text(); + console.log('Processing SRT content:', text); // Debug log + + const subtitles = parseSRT(text); + console.log('Parsed subtitles:', subtitles); // Debug log + + // Validate SRT timing with video duration + if (!validateSRTWithVideo(subtitles, videoDuration)) { + throw new Error('SRT file timings do not match video duration'); + } + + setSrtFile(file); + return text; + } catch (error) { + console.error('SRT processing error:', error); // Debug log + setError(error instanceof Error ? error.message : 'Failed to process SRT file'); + setSrtFile(null); + return null; + } + }; + + const getVideoDuration = (file: File): Promise => { + return new Promise((resolve, reject) => { + const video = document.createElement('video'); + video.preload = 'metadata'; + + video.onloadedmetadata = () => { + URL.revokeObjectURL(video.src); + resolve(video.duration); + }; + + video.onerror = () => { + URL.revokeObjectURL(video.src); + reject(new Error('Failed to load video metadata')); + }; + + video.src = URL.createObjectURL(file); + }); + }; + + const onDrop = useCallback(async (acceptedFiles: File[]) => { + setError(null); + + for (const file of acceptedFiles) { + if (file.type.startsWith('video/')) { + try { + const duration = await getVideoDuration(file); + setVideoFile(file); + + // If we already have an SRT file, reprocess it with the new video + if (srtFile) { + const srtContent = await processSRTFile(srtFile, duration); + if (srtContent) { + onFileSelect({ video: file, subtitles: srtContent }); + } + } else { + onFileSelect({ video: file }); + } + } catch (error) { + setError(error instanceof Error ? error.message : 'Failed to process video file'); + setVideoFile(null); + } + } else if (file.name.endsWith('.srt')) { + if (!videoFile) { + setError('Please upload a video file first'); + return; + } + + // Quick size check for obviously invalid files + if (file.size === 0) { + setError('SRT file is empty'); + return; + } + + if (file.size > 1024 * 1024) { // 1MB limit + setError('SRT file is too large. Maximum size is 1MB.'); + return; + } + + try { + const duration = await getVideoDuration(videoFile); + const srtContent = await processSRTFile(file, duration); + if (srtContent) { + onFileSelect({ video: videoFile, subtitles: srtContent }); + } + } catch (error) { + setError(error instanceof Error ? error.message : 'Failed to process SRT file'); + } + } + } + }, [videoFile, srtFile, onFileSelect]); + + const VideoUpload = () => { + const { getRootProps: getVideoRootProps, getInputProps: getVideoInputProps } = useDropzone({ + onDrop, + accept: { + 'video/*': [], + }, + multiple: false, + disabled: !!videoFile + }); + + return ( +
!videoFile && setIsDragging(true)} + onDragLeave={() => !videoFile && setIsDragging(false)} + > + +
+

+ {!videoFile ? 'Upload Video File' : 'Video Uploaded'} +

+

+ {!videoFile ? 'Drag and drop or click to select' : videoFile.name} +

+ {videoFile && ( + + )} +
+
+ ); + }; + + const SubtitleUpload = () => { + const { getRootProps: getSrtRootProps, getInputProps: getSrtInputProps } = useDropzone({ + onDrop, + accept: { + 'application/x-subrip': ['.srt'], + 'text/plain': ['.srt'] + }, + multiple: false, + disabled: !videoFile + }); + + return ( +
videoFile && setIsDragging(true)} + onDragLeave={() => videoFile && setIsDragging(false)} + > + +
+

+ {!videoFile ? 'Upload Video First' : !srtFile ? 'Upload Subtitles (Optional)' : 'Subtitles Uploaded'} +

+

+ {!videoFile ? 'Video required for subtitle upload' : + !srtFile ? 'Drag and drop or click to select .srt file' : srtFile.name} +

+ {srtFile && ( + + )} +
+
+ ); + }; + + return ( +
+ {error && ( +
+ + + + + + {error} +
+ )} + +
+ {/* Video Upload */} +
+
+

Video Upload

+

+ Upload your video file to generate user stories +

+
+ + {videoFile && ( +
+ + + + + + Video uploaded successfully +
+ )} +
+ + {/* Subtitles Upload */} +
+
+

Subtitles Upload

+

+ Optional: Add .srt subtitles for better context +

+
+ + {srtFile && ( +
+ + + + + + Subtitles uploaded successfully +
+ )} +
+
+ + {/* Help Text */} +
+
+ + + + + +
+

Supported formats:

+
    +
  • Video: MP4, WebM, MOV
  • +
  • Subtitles: SRT format only
  • +
+
+
+
+
+ ); +} diff --git a/components/ui/frame-selector.tsx b/components/ui/frame-selector.tsx new file mode 100644 index 0000000..4bc8697 --- /dev/null +++ b/components/ui/frame-selector.tsx @@ -0,0 +1,112 @@ +"use client" + +import { useState } from 'react'; +import { SceneInfo } from '../../lib/types'; + +interface FrameSelectorProps { + scenes: SceneInfo[]; + onSend: (selectedScenes: SceneInfo[], notes: string) => void; +} + +export function FrameSelector({ scenes, onSend }: FrameSelectorProps) { + const [selectedScenes, setSelectedScenes] = useState>(new Set()); + const [notes, setNotes] = useState(''); + + const toggleScene = (index: number) => { + const newSelected = new Set(selectedScenes); + if (newSelected.has(index)) { + newSelected.delete(index); + } else { + newSelected.add(index); + } + setSelectedScenes(newSelected); + }; + + const handleSend = () => { + const selectedSceneInfo = Array.from(selectedScenes).map(index => scenes[index]); + onSend(selectedSceneInfo, notes); + }; + + const formatTime = (time: number) => { + const minutes = Math.floor(time / 60); + const seconds = Math.floor(time % 60); + return `${minutes}:${seconds.toString().padStart(2, '0')}`; + }; + + return ( +
+ {/* Scene Grid */} +
+ {scenes.map((scene, index) => ( +
toggleScene(index)} + title={`Scene at ${formatTime(scene.timestamp)}`} + > + {`Scene +
+ {formatTime(scene.timestamp)} +
+
+
+ {selectedScenes.has(index) ? '✓ Selected' : 'Click to Select'} +
+
+
+ ))} +
+ + {/* Notes and Actions */} +
+
+
+

Additional Notes

+ + {selectedScenes.size} scene{selectedScenes.size !== 1 ? 's' : ''} selected + +
+