1229 lines
37 KiB
Markdown
1229 lines
37 KiB
Markdown
# CLAUDE.md - YouTube Summarizer Frontend
|
|
|
|
This file provides guidance to Claude Code when working with the YouTube Summarizer frontend application.
|
|
|
|
## 🚨 CRITICAL: Server Status Checking Protocol
|
|
|
|
**MANDATORY**: Always check server status before testing or debugging:
|
|
|
|
```bash
|
|
# ALWAYS check server status FIRST
|
|
lsof -i :3002 | grep LISTEN # Check if frontend is running
|
|
lsof -i :8000 | grep LISTEN # Check if backend is running
|
|
|
|
# If servers are NOT running, restart them:
|
|
./scripts/restart-frontend.sh # Restart frontend
|
|
./scripts/restart-backend.sh # Restart backend
|
|
./scripts/restart-both.sh # Restart both
|
|
|
|
# After making code changes:
|
|
1. ALWAYS restart the appropriate server
|
|
2. VERIFY the server is running (lsof -i)
|
|
3. ONLY THEN proceed with testing
|
|
```
|
|
|
|
**Key Rules**:
|
|
- ✅ ALWAYS check server status before testing
|
|
- ✅ ALWAYS restart after code changes
|
|
- ✅ ALWAYS verify restart was successful
|
|
- ❌ NEVER assume servers are running
|
|
- ❌ NEVER test without confirming server status
|
|
|
|
## 🚨 CRITICAL: Documentation Preservation Rule
|
|
|
|
**MANDATORY**: NEVER remove critical documentation sections:
|
|
|
|
- ❌ **NEVER** remove existing critical sections from CLAUDE.md or AGENTS.md
|
|
- ❌ **NEVER** delete server checking protocols
|
|
- ❌ **NEVER** remove development standards sections
|
|
- ❌ **NEVER** delete troubleshooting guides
|
|
- ✅ **ONLY** remove sections when explicitly instructed by the user
|
|
- ✅ **ALWAYS** preserve and build upon existing documentation
|
|
|
|
## Frontend Architecture Overview
|
|
|
|
The frontend is built with React, TypeScript, and Vite, following modern React patterns with comprehensive UI components.
|
|
|
|
### ✅ **NEW**: Dual Transcript Functionality Complete
|
|
The frontend now includes comprehensive dual transcript components that integrate seamlessly with the backend services:
|
|
|
|
- **TranscriptSelector**: Interactive component for choosing between YouTube captions, Whisper AI, or both
|
|
- **TranscriptComparison**: Side-by-side comparison with quality metrics and difference highlighting
|
|
- **Enhanced SummarizeForm**: Updated to support transcript source selection
|
|
- **Demo Page**: `/demo/transcript-comparison` showcasing all functionality with mock data
|
|
|
|
The frontend implementation is production-ready with TypeScript safety, comprehensive error handling, and excellent developer experience.
|
|
|
|
```
|
|
frontend/
|
|
├── src/
|
|
│ ├── components/ # Reusable UI components
|
|
│ ├── hooks/ # Custom React hooks
|
|
│ ├── services/ # API client and external integrations
|
|
│ ├── types/ # TypeScript type definitions
|
|
│ ├── utils/ # Utility functions and helpers
|
|
│ └── pages/ # Page components and routing
|
|
│ ├── AdminPage.tsx # No-auth admin interface (NEW)
|
|
│ ├── SummarizePage.tsx # Protected main interface
|
|
│ └── auth/ # Authentication pages
|
|
├── public/ # Static assets
|
|
└── tests/ # Frontend tests (Vitest + RTL)
|
|
```
|
|
|
|
## Development & Testing Routes
|
|
|
|
### Admin Page (No Authentication Required) 🚀
|
|
- **File**: `src/pages/AdminPage.tsx`
|
|
- **Route**: `/admin`
|
|
- **URL**: `http://localhost:3002/admin`
|
|
- **Purpose**: Direct access for testing and development
|
|
- **Features**:
|
|
- Complete YouTube Summarizer functionality
|
|
- No login or authentication required
|
|
- Orange "Admin Mode" visual indicators
|
|
- Shield icon and badges for clear identification
|
|
- Perfect for quick testing and demos
|
|
|
|
### Protected Routes (Authentication Required)
|
|
- **Dashboard**: `/dashboard` → `SummarizePage.tsx` (requires verified user)
|
|
- **History**: `/history` → `SummaryHistoryPage.tsx` (requires verified user)
|
|
- **Batch**: `/batch` → `BatchProcessingPage.tsx` (requires verified user)
|
|
- **Login/Auth**: `/login`, `/register`, `/verify-email` etc.
|
|
|
|
## Tech Stack
|
|
|
|
- **Framework**: React 18 with TypeScript
|
|
- **Build Tool**: Vite for fast development and building
|
|
- **Styling**: Tailwind CSS with shadcn/ui components
|
|
- **State Management**: React Query (TanStack Query) for server state
|
|
- **WebSocket**: Native WebSocket API for real-time updates
|
|
- **Testing**: Vitest + React Testing Library
|
|
- **Type Safety**: Full TypeScript coverage with production-ready configuration
|
|
|
|
## TypeScript Configuration (Project-Specific)
|
|
|
|
### Resolved Configuration Issues
|
|
|
|
This project had specific TypeScript compilation issues that were systematically resolved:
|
|
|
|
1. **Fixed `tsconfig.app.json`** - Removed overly strict `verbatimModuleSyntax` setting that caused module resolution failures
|
|
2. **Corrected Path Aliases** - Synchronized Vite and TypeScript path mapping for `@/*` imports
|
|
3. **Added Node.js Types** - Included `@types/node` for proper `import.meta.env` support
|
|
4. **Module Resolution** - Fixed ES module import/export patterns in `summaryAPI.ts`
|
|
|
|
### Production-Ready Configuration
|
|
|
|
```json
|
|
// tsconfig.app.json - Working configuration for this project
|
|
{
|
|
"compilerOptions": {
|
|
"target": "ES2020",
|
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
|
"module": "ESNext",
|
|
"moduleResolution": "bundler",
|
|
"paths": {
|
|
"@/*": ["./src/*"]
|
|
},
|
|
"strict": true,
|
|
"noUncheckedIndexedAccess": true,
|
|
"skipLibCheck": true
|
|
}
|
|
}
|
|
```
|
|
|
|
### Project-Specific TypeScript Troubleshooting
|
|
|
|
**Common Issues in This Codebase**:
|
|
|
|
1. **Import/Export Resolution** - `SummaryCard.tsx` importing `Summary` from `summaryAPI.ts`
|
|
- **Issue**: Module not providing export despite correct syntax
|
|
- **Solution**: Simplified `summaryAPI.ts` to remove circular dependencies
|
|
|
|
2. **Vite vs TypeScript Alignment** - Path aliases not resolving
|
|
- **Issue**: `@/services/summaryAPI` not found despite existing file
|
|
- **Solution**: Ensure `vite.config.ts` aliases match `tsconfig.json` paths
|
|
|
|
3. **Development vs Production** - Dev server working but build failing
|
|
- **Issue**: Vite bypasses TypeScript errors in development
|
|
- **Solution**: Regular `npm run build` checks during development
|
|
|
|
### API Interface Completeness
|
|
|
|
The `summaryAPI.ts` file should export complete interfaces:
|
|
|
|
```typescript
|
|
// Essential exports for this project
|
|
export interface Summary {
|
|
id: string;
|
|
video_url: string;
|
|
video_title: string;
|
|
// ... complete interface definition
|
|
}
|
|
|
|
export interface SummaryListResponse {
|
|
summaries: Summary[];
|
|
total: number;
|
|
page: number;
|
|
page_size: number;
|
|
has_more: boolean;
|
|
}
|
|
|
|
export const summaryAPI = new SummaryAPI();
|
|
```
|
|
|
|
## Key Components and Patterns
|
|
|
|
### API Integration
|
|
|
|
**API Client** (`src/services/apiClient.ts`)
|
|
```typescript
|
|
class APIClient {
|
|
private baseURL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
|
|
|
|
async processVideo(url: string, config: PipelineConfig): Promise<ProcessVideoResponse> {
|
|
const response = await fetch(`${this.baseURL}/api/process`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ video_url: url, ...config })
|
|
})
|
|
return response.json()
|
|
}
|
|
|
|
async getPipelineStatus(jobId: string): Promise<PipelineStatusResponse> {
|
|
const response = await fetch(`${this.baseURL}/api/process/${jobId}`)
|
|
return response.json()
|
|
}
|
|
}
|
|
|
|
export const apiClient = new APIClient()
|
|
```
|
|
|
|
### Real-time Updates with WebSocket
|
|
|
|
**WebSocket Hook** (`src/hooks/useWebSocket.ts`)
|
|
```typescript
|
|
import { useEffect, useRef, useState } from 'react'
|
|
|
|
interface WebSocketHookProps {
|
|
url?: string
|
|
onProgressUpdate?: (update: ProgressUpdate) => void
|
|
onComplete?: (result: CompletionData) => void
|
|
onError?: (error: ErrorData) => void
|
|
}
|
|
|
|
export function useWebSocket({
|
|
url = 'ws://localhost:8000/ws',
|
|
onProgressUpdate,
|
|
onComplete,
|
|
onError
|
|
}: WebSocketHookProps) {
|
|
const ws = useRef<WebSocket | null>(null)
|
|
const [isConnected, setIsConnected] = useState(false)
|
|
|
|
const connect = useCallback((jobId?: string) => {
|
|
const wsUrl = jobId ? `${url}?job_id=${jobId}` : url
|
|
ws.current = new WebSocket(wsUrl)
|
|
|
|
ws.current.onopen = () => setIsConnected(true)
|
|
ws.current.onclose = () => setIsConnected(false)
|
|
|
|
ws.current.onmessage = (event) => {
|
|
const data = JSON.parse(event.data)
|
|
|
|
switch (data.type) {
|
|
case 'progress_update':
|
|
onProgressUpdate?.(data.data)
|
|
break
|
|
case 'completion_notification':
|
|
onComplete?.(data.data)
|
|
break
|
|
case 'error_notification':
|
|
onError?.(data.data)
|
|
break
|
|
}
|
|
}
|
|
}, [url, onProgressUpdate, onComplete, onError])
|
|
|
|
const disconnect = useCallback(() => {
|
|
ws.current?.close()
|
|
}, [])
|
|
|
|
return { connect, disconnect, isConnected }
|
|
}
|
|
```
|
|
|
|
### Pipeline Processing Hook
|
|
|
|
**Pipeline Hook** (`src/hooks/usePipelineProcessor.ts`)
|
|
```typescript
|
|
import { useMutation, useQuery } from '@tanstack/react-query'
|
|
import { useState } from 'react'
|
|
import { apiClient } from '@/services/apiClient'
|
|
import { useWebSocket } from './useWebSocket'
|
|
|
|
export function usePipelineProcessor() {
|
|
const [activeJobId, setActiveJobId] = useState<string | null>(null)
|
|
const [progress, setProgress] = useState<ProgressUpdate | null>(null)
|
|
|
|
const { connect, disconnect } = useWebSocket({
|
|
onProgressUpdate: (update) => setProgress(update),
|
|
onComplete: (result) => {
|
|
setProgress({ stage: 'completed', percentage: 100, message: 'Processing complete!' })
|
|
// Trigger refetch of final result
|
|
statusQuery.refetch()
|
|
},
|
|
onError: (error) => {
|
|
setProgress({ stage: 'failed', percentage: 0, message: error.message })
|
|
}
|
|
})
|
|
|
|
const processVideo = useMutation({
|
|
mutationFn: async (params: ProcessVideoParams) => {
|
|
const result = await apiClient.processVideo(params.url, params.config)
|
|
return result
|
|
},
|
|
onSuccess: (data) => {
|
|
setActiveJobId(data.job_id)
|
|
connect(data.job_id) // Connect WebSocket for this job
|
|
setProgress({ stage: 'initialized', percentage: 0, message: 'Starting processing...' })
|
|
},
|
|
onError: (error) => {
|
|
console.error('Failed to start processing:', error)
|
|
}
|
|
})
|
|
|
|
const statusQuery = useQuery({
|
|
queryKey: ['pipeline-status', activeJobId],
|
|
queryFn: () => activeJobId ? apiClient.getPipelineStatus(activeJobId) : null,
|
|
enabled: !!activeJobId,
|
|
refetchInterval: (data) => {
|
|
// Stop polling when completed or failed
|
|
return data?.status === 'completed' || data?.status === 'failed' ? false : 2000
|
|
}
|
|
})
|
|
|
|
const cancelProcessing = useMutation({
|
|
mutationFn: async (jobId: string) => {
|
|
await apiClient.cancelPipeline(jobId)
|
|
},
|
|
onSuccess: () => {
|
|
setActiveJobId(null)
|
|
setProgress(null)
|
|
disconnect()
|
|
}
|
|
})
|
|
|
|
return {
|
|
processVideo: processVideo.mutateAsync,
|
|
cancelProcessing: cancelProcessing.mutateAsync,
|
|
isProcessing: processVideo.isPending || statusQuery.data?.status === 'processing',
|
|
progress: progress || (statusQuery.data ? {
|
|
stage: statusQuery.data.status,
|
|
percentage: statusQuery.data.progress_percentage,
|
|
message: statusQuery.data.current_message
|
|
} : null),
|
|
result: statusQuery.data?.result,
|
|
error: processVideo.error || statusQuery.error,
|
|
jobId: activeJobId
|
|
}
|
|
}
|
|
```
|
|
|
|
### Admin Page Implementation
|
|
|
|
**AdminPage Component** (`src/pages/AdminPage.tsx`) - **NEW FEATURE**
|
|
```tsx
|
|
import { SummarizeForm } from '@/components/forms/SummarizeForm';
|
|
import { ProgressTracker } from '@/components/display/ProgressTracker';
|
|
import { TranscriptViewer } from '@/components/display/TranscriptViewer';
|
|
import { useTranscriptExtraction } from '@/hooks/useTranscriptExtraction';
|
|
import { Shield } from 'lucide-react';
|
|
import { Badge } from '@/components/ui/badge';
|
|
|
|
export function AdminPage() {
|
|
const { extractTranscript, progress, transcript, metadata, isLoading, error } = useTranscriptExtraction();
|
|
|
|
return (
|
|
<div className="min-h-screen bg-background">
|
|
<div className="container mx-auto px-4 py-8">
|
|
<div className="max-w-4xl mx-auto space-y-8">
|
|
<header className="text-center mb-8">
|
|
{/* Admin Mode Visual Indicators */}
|
|
<div className="flex items-center justify-center gap-3 mb-4">
|
|
<Shield className="h-8 w-8 text-orange-600" />
|
|
<Badge variant="outline" className="text-orange-600 border-orange-600">
|
|
Admin Mode
|
|
</Badge>
|
|
</div>
|
|
|
|
<h1 className="text-4xl font-bold tracking-tight">YouTube Summarizer</h1>
|
|
<p className="text-xl text-muted-foreground mt-2">
|
|
Extract and analyze YouTube video transcripts - No Authentication Required
|
|
</p>
|
|
|
|
{/* Status Badge */}
|
|
<div className="mt-4">
|
|
<Badge variant="secondary" className="text-sm">
|
|
Direct Access • Full Functionality • Testing Mode
|
|
</Badge>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Same functionality as protected routes but no auth */}
|
|
<SummarizeForm onSubmit={handleSubmit} disabled={isLoading} />
|
|
{isLoading && <ProgressTracker progress={progress} videoId="" />}
|
|
{error && <Alert variant="destructive">...</Alert>}
|
|
{transcript && <TranscriptViewer transcript={transcript} metadata={metadata} videoId="" />}
|
|
|
|
<footer className="text-center pt-8 border-t">
|
|
<p className="text-sm text-muted-foreground">
|
|
Admin Mode - For testing and development purposes
|
|
</p>
|
|
</footer>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
**Key Features of AdminPage**:
|
|
- **No Authentication**: Bypasses all auth checks and protected routes
|
|
- **Visual Indicators**: Orange theme, shield icon, clear "Admin Mode" badges
|
|
- **Full Functionality**: Uses same components as protected dashboard
|
|
- **Development-Friendly**: Perfect for testing, demos, and development workflow
|
|
- **Self-Contained**: No dependencies on AuthContext or user state
|
|
|
|
### Main Application Component
|
|
|
|
**App Component** (`src/App.tsx`) - **UPDATED WITH ADMIN ROUTE**
|
|
```tsx
|
|
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
import { AuthProvider } from '@/contexts/AuthContext';
|
|
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
|
|
|
|
// Import the new AdminPage
|
|
import { AdminPage } from '@/pages/AdminPage';
|
|
|
|
function App() {
|
|
return (
|
|
<QueryClientProvider client={queryClient}>
|
|
<AuthProvider>
|
|
<Router>
|
|
<Routes>
|
|
{/* Public routes */}
|
|
<Route path="/login" element={<LoginPage />} />
|
|
<Route path="/register" element={<RegisterPage />} />
|
|
|
|
{/* ADMIN ROUTE - No authentication required */}
|
|
<Route path="/admin" element={<AdminPage />} />
|
|
|
|
{/* Protected routes */}
|
|
<Route path="/dashboard" element={
|
|
<ProtectedRoute requireVerified={true}>
|
|
<SummarizePage />
|
|
</ProtectedRoute>
|
|
} />
|
|
|
|
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
|
</Routes>
|
|
</Router>
|
|
</AuthProvider>
|
|
</QueryClientProvider>
|
|
);
|
|
}
|
|
```
|
|
|
|
### UI Components with shadcn/ui
|
|
|
|
**Video Processor Page** (`src/pages/VideoProcessorPage.tsx`)
|
|
```tsx
|
|
import React, { useState } from 'react'
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Label } from '@/components/ui/label'
|
|
import { Progress } from '@/components/ui/progress'
|
|
import { Textarea } from '@/components/ui/textarea'
|
|
import { useToast } from '@/hooks/use-toast'
|
|
import { usePipelineProcessor } from '@/hooks/usePipelineProcessor'
|
|
import { VideoUrlValidator } from '@/components/VideoUrlValidator'
|
|
import { SummaryDisplay } from '@/components/SummaryDisplay'
|
|
import { ProcessingProgress } from '@/components/ProcessingProgress'
|
|
|
|
export function VideoProcessorPage() {
|
|
const [videoUrl, setVideoUrl] = useState('')
|
|
const [summaryLength, setSummaryLength] = useState<'brief' | 'standard' | 'detailed'>('standard')
|
|
const [focusAreas, setFocusAreas] = useState<string[]>([])
|
|
|
|
const { toast } = useToast()
|
|
const { processVideo, isProcessing, progress, result, cancelProcessing } = usePipelineProcessor()
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
|
|
try {
|
|
await processVideo({
|
|
url: videoUrl,
|
|
config: {
|
|
summary_length: summaryLength,
|
|
focus_areas: focusAreas.filter(area => area.trim()),
|
|
include_timestamps: false,
|
|
enable_notifications: true,
|
|
quality_threshold: 0.7
|
|
}
|
|
})
|
|
|
|
toast({
|
|
title: "Processing Started",
|
|
description: "Your video is being processed. You'll see real-time updates below.",
|
|
})
|
|
} catch (error) {
|
|
toast({
|
|
title: "Processing Failed",
|
|
description: error instanceof Error ? error.message : 'Unknown error occurred',
|
|
variant: "destructive",
|
|
})
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="container mx-auto py-8 px-4 max-w-4xl">
|
|
<div className="space-y-6">
|
|
<div className="text-center">
|
|
<h1 className="text-4xl font-bold tracking-tight">YouTube Summarizer</h1>
|
|
<p className="text-xl text-muted-foreground mt-2">
|
|
Transform any YouTube video into intelligent summaries powered by AI
|
|
</p>
|
|
</div>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Video Processing</CardTitle>
|
|
<CardDescription>
|
|
Enter a YouTube URL to generate an AI-powered summary with key insights
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<form onSubmit={handleSubmit} className="space-y-6">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="video-url">YouTube URL</Label>
|
|
<VideoUrlValidator
|
|
value={videoUrl}
|
|
onChange={setVideoUrl}
|
|
disabled={isProcessing}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="summary-length">Summary Length</Label>
|
|
<Select value={summaryLength} onValueChange={setSummaryLength}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Choose length" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="brief">Brief (100-200 words)</SelectItem>
|
|
<SelectItem value="standard">Standard (300-500 words)</SelectItem>
|
|
<SelectItem value="detailed">Detailed (500-800 words)</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="focus-areas">Focus Areas (optional)</Label>
|
|
<Input
|
|
placeholder="e.g., key insights, action items"
|
|
value={focusAreas.join(', ')}
|
|
onChange={(e) => setFocusAreas(e.target.value.split(',').map(s => s.trim()))}
|
|
disabled={isProcessing}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
<Button
|
|
type="submit"
|
|
disabled={!videoUrl || isProcessing}
|
|
className="flex-1"
|
|
>
|
|
{isProcessing ? 'Processing...' : 'Generate Summary'}
|
|
</Button>
|
|
|
|
{isProcessing && (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() => cancelProcessing()}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</form>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{progress && (
|
|
<ProcessingProgress
|
|
progress={progress}
|
|
onCancel={() => cancelProcessing()}
|
|
/>
|
|
)}
|
|
|
|
{result && (
|
|
<SummaryDisplay
|
|
result={result}
|
|
videoMetadata={result.video_metadata}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
```
|
|
|
|
## Component Patterns
|
|
|
|
### Form Validation Component
|
|
|
|
**Video URL Validator** (`src/components/VideoUrlValidator.tsx`)
|
|
```tsx
|
|
import React, { useState, useEffect } from 'react'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
|
import { CheckCircle2, XCircle } from 'lucide-react'
|
|
|
|
interface VideoUrlValidatorProps {
|
|
value: string
|
|
onChange: (value: string) => void
|
|
disabled?: boolean
|
|
}
|
|
|
|
export function VideoUrlValidator({ value, onChange, disabled }: VideoUrlValidatorProps) {
|
|
const [validationStatus, setValidationStatus] = useState<'valid' | 'invalid' | 'idle'>('idle')
|
|
const [validationMessage, setValidationMessage] = useState('')
|
|
|
|
useEffect(() => {
|
|
if (!value) {
|
|
setValidationStatus('idle')
|
|
return
|
|
}
|
|
|
|
const youtubeRegex = /^(https?:\/\/)?(www\.)?(youtube\.com\/watch\?v=|youtu\.be\/)([\w\-_]+)(&\S*)?$/
|
|
|
|
if (youtubeRegex.test(value)) {
|
|
setValidationStatus('valid')
|
|
setValidationMessage('Valid YouTube URL')
|
|
} else {
|
|
setValidationStatus('invalid')
|
|
setValidationMessage('Please enter a valid YouTube URL')
|
|
}
|
|
}, [value])
|
|
|
|
return (
|
|
<div className="space-y-2">
|
|
<div className="relative">
|
|
<Input
|
|
id="video-url"
|
|
placeholder="https://youtube.com/watch?v=..."
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
disabled={disabled}
|
|
className={cn(
|
|
"pr-10",
|
|
validationStatus === 'valid' && "border-green-500",
|
|
validationStatus === 'invalid' && "border-red-500"
|
|
)}
|
|
/>
|
|
|
|
{validationStatus !== 'idle' && (
|
|
<div className="absolute right-3 top-1/2 transform -translate-y-1/2">
|
|
{validationStatus === 'valid' ? (
|
|
<CheckCircle2 className="h-5 w-5 text-green-500" />
|
|
) : (
|
|
<XCircle className="h-5 w-5 text-red-500" />
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{validationStatus !== 'idle' && (
|
|
<Alert variant={validationStatus === 'valid' ? 'default' : 'destructive'}>
|
|
<AlertDescription>{validationMessage}</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
```
|
|
|
|
### Progress Display Component
|
|
|
|
**Processing Progress** (`src/components/ProcessingProgress.tsx`)
|
|
```tsx
|
|
import React from 'react'
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
import { Progress } from '@/components/ui/progress'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Loader2, X } from 'lucide-react'
|
|
|
|
interface ProcessingProgressProps {
|
|
progress: {
|
|
stage: string
|
|
percentage: number
|
|
message: string
|
|
details?: any
|
|
}
|
|
onCancel: () => void
|
|
}
|
|
|
|
export function ProcessingProgress({ progress, onCancel }: ProcessingProgressProps) {
|
|
const getStageDisplayName = (stage: string) => {
|
|
const stageNames = {
|
|
'initialized': 'Initializing',
|
|
'validating_url': 'Validating URL',
|
|
'extracting_metadata': 'Extracting Video Info',
|
|
'extracting_transcript': 'Getting Transcript',
|
|
'analyzing_content': 'Analyzing Content',
|
|
'generating_summary': 'Generating Summary',
|
|
'validating_quality': 'Quality Check',
|
|
'completed': 'Complete',
|
|
'failed': 'Failed'
|
|
}
|
|
return stageNames[stage] || stage.replace('_', ' ').toUpperCase()
|
|
}
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between">
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Loader2 className="h-5 w-5 animate-spin" />
|
|
Processing Video
|
|
</CardTitle>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={onCancel}
|
|
className="text-muted-foreground hover:text-foreground"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="space-y-2">
|
|
<div className="flex justify-between text-sm">
|
|
<span>{getStageDisplayName(progress.stage)}</span>
|
|
<span>{Math.round(progress.percentage)}%</span>
|
|
</div>
|
|
<Progress value={progress.percentage} className="h-2" />
|
|
</div>
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
{progress.message}
|
|
</p>
|
|
|
|
{progress.details && (
|
|
<details className="text-xs text-muted-foreground">
|
|
<summary className="cursor-pointer hover:text-foreground">
|
|
Technical Details
|
|
</summary>
|
|
<pre className="mt-2 p-2 bg-muted rounded text-xs overflow-auto">
|
|
{JSON.stringify(progress.details, null, 2)}
|
|
</pre>
|
|
</details>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
```
|
|
|
|
### Summary Display Component
|
|
|
|
**Summary Display** (`src/components/SummaryDisplay.tsx`)
|
|
```tsx
|
|
import React from 'react'
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Separator } from '@/components/ui/separator'
|
|
import { Clock, ThumbsUp, Download, Share2 } from 'lucide-react'
|
|
import { Button } from '@/components/ui/button'
|
|
|
|
interface SummaryDisplayProps {
|
|
result: {
|
|
summary: string
|
|
key_points: string[]
|
|
main_themes: string[]
|
|
actionable_insights: string[]
|
|
confidence_score: number
|
|
quality_score: number
|
|
}
|
|
videoMetadata?: {
|
|
title: string
|
|
duration: string
|
|
thumbnail_url: string
|
|
}
|
|
}
|
|
|
|
export function SummaryDisplay({ result, videoMetadata }: SummaryDisplayProps) {
|
|
const handleExport = (format: 'markdown' | 'json' | 'pdf') => {
|
|
// Implementation for exporting summary in different formats
|
|
console.log(`Exporting as ${format}`)
|
|
}
|
|
|
|
const handleShare = () => {
|
|
// Implementation for sharing summary
|
|
console.log('Sharing summary')
|
|
}
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex justify-between items-start">
|
|
<div className="space-y-2">
|
|
<CardTitle className="flex items-center gap-2">
|
|
Summary Complete
|
|
<Badge variant="secondary" className="flex items-center gap-1">
|
|
<ThumbsUp className="h-3 w-3" />
|
|
{Math.round(result.quality_score * 100)}% Quality
|
|
</Badge>
|
|
</CardTitle>
|
|
{videoMetadata && (
|
|
<p className="text-sm text-muted-foreground flex items-center gap-2">
|
|
<Clock className="h-4 w-4" />
|
|
{videoMetadata.title}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
<Button variant="outline" size="sm" onClick={handleShare}>
|
|
<Share2 className="h-4 w-4 mr-1" />
|
|
Share
|
|
</Button>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="outline" size="sm">
|
|
<Download className="h-4 w-4 mr-1" />
|
|
Export
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent>
|
|
<DropdownMenuItem onClick={() => handleExport('markdown')}>
|
|
Markdown
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => handleExport('json')}>
|
|
JSON
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => handleExport('pdf')}>
|
|
PDF
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
|
|
<CardContent className="space-y-6">
|
|
{/* Main Summary */}
|
|
<div>
|
|
<h3 className="font-semibold mb-3">Summary</h3>
|
|
<p className="text-sm leading-relaxed">{result.summary}</p>
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
{/* Key Points */}
|
|
<div>
|
|
<h3 className="font-semibold mb-3">Key Points</h3>
|
|
<ul className="space-y-2">
|
|
{result.key_points.map((point, index) => (
|
|
<li key={index} className="text-sm flex items-start gap-2">
|
|
<span className="flex-shrink-0 w-5 h-5 rounded-full bg-primary/10 text-primary text-xs flex items-center justify-center font-medium mt-0.5">
|
|
{index + 1}
|
|
</span>
|
|
{point}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
{/* Main Themes */}
|
|
<div>
|
|
<h3 className="font-semibold mb-3">Main Themes</h3>
|
|
<div className="flex flex-wrap gap-2">
|
|
{result.main_themes.map((theme, index) => (
|
|
<Badge key={index} variant="outline">
|
|
{theme}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Actionable Insights */}
|
|
{result.actionable_insights.length > 0 && (
|
|
<>
|
|
<Separator />
|
|
<div>
|
|
<h3 className="font-semibold mb-3">Actionable Insights</h3>
|
|
<ul className="space-y-2">
|
|
{result.actionable_insights.map((insight, index) => (
|
|
<li key={index} className="text-sm flex items-start gap-2">
|
|
<span className="text-primary">•</span>
|
|
{insight}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
</>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
```
|
|
|
|
## Development Setup
|
|
|
|
### Environment Configuration
|
|
|
|
**Environment Variables** (`.env.local`)
|
|
```bash
|
|
VITE_API_URL=http://localhost:8000
|
|
VITE_WS_URL=ws://localhost:8000/ws
|
|
VITE_APP_TITLE=YouTube Summarizer
|
|
```
|
|
|
|
### Development Commands
|
|
|
|
```bash
|
|
# Install dependencies
|
|
npm install
|
|
|
|
# Start development server
|
|
npm run dev
|
|
|
|
# Build for production
|
|
npm run build
|
|
|
|
# Preview production build
|
|
npm run preview
|
|
|
|
# Run tests
|
|
npm run test
|
|
|
|
# Run tests with coverage
|
|
npm run test:coverage
|
|
|
|
# Type checking
|
|
npm run type-check
|
|
|
|
# Linting
|
|
npm run lint
|
|
npm run lint:fix
|
|
```
|
|
|
|
### Testing Setup
|
|
|
|
**Vitest Configuration** (`vitest.config.ts`)
|
|
```typescript
|
|
import { defineConfig } from 'vitest/config'
|
|
import react from '@vitejs/plugin-react'
|
|
import path from 'path'
|
|
|
|
export default defineConfig({
|
|
plugins: [react()],
|
|
test: {
|
|
globals: true,
|
|
environment: 'jsdom',
|
|
setupFiles: ['./src/test/setup.ts'],
|
|
},
|
|
resolve: {
|
|
alias: {
|
|
'@': path.resolve(__dirname, './src'),
|
|
},
|
|
},
|
|
})
|
|
```
|
|
|
|
**Test Utilities** (`src/test/test-utils.tsx`)
|
|
```tsx
|
|
import React, { ReactElement } from 'react'
|
|
import { render, RenderOptions } from '@testing-library/react'
|
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
|
|
const createTestQueryClient = () => new QueryClient({
|
|
defaultOptions: {
|
|
queries: { retry: false },
|
|
mutations: { retry: false },
|
|
},
|
|
})
|
|
|
|
const AllTheProviders = ({ children }: { children: React.ReactNode }) => {
|
|
const testQueryClient = createTestQueryClient()
|
|
|
|
return (
|
|
<QueryClientProvider client={testQueryClient}>
|
|
{children}
|
|
</QueryClientProvider>
|
|
)
|
|
}
|
|
|
|
const customRender = (
|
|
ui: ReactElement,
|
|
options?: Omit<RenderOptions, 'wrapper'>
|
|
) => render(ui, { wrapper: AllTheProviders, ...options })
|
|
|
|
export * from '@testing-library/react'
|
|
export { customRender as render }
|
|
```
|
|
|
|
## Type Definitions
|
|
|
|
**API Types** (`src/types/api.ts`)
|
|
```typescript
|
|
export interface ProcessVideoRequest {
|
|
video_url: string
|
|
summary_length: 'brief' | 'standard' | 'detailed'
|
|
focus_areas?: string[]
|
|
include_timestamps?: boolean
|
|
enable_notifications?: boolean
|
|
quality_threshold?: number
|
|
}
|
|
|
|
export interface ProcessVideoResponse {
|
|
job_id: string
|
|
status: string
|
|
message: string
|
|
estimated_completion_time?: number
|
|
}
|
|
|
|
export interface PipelineStatusResponse {
|
|
job_id: string
|
|
status: string
|
|
progress_percentage: number
|
|
current_message: string
|
|
video_metadata?: VideoMetadata
|
|
result?: SummaryResult
|
|
error?: ErrorData
|
|
processing_time_seconds?: number
|
|
}
|
|
|
|
export interface SummaryResult {
|
|
summary: string
|
|
key_points: string[]
|
|
main_themes: string[]
|
|
actionable_insights: string[]
|
|
confidence_score: number
|
|
quality_score: number
|
|
cost_data: {
|
|
input_cost_usd: number
|
|
output_cost_usd: number
|
|
total_cost_usd: number
|
|
}
|
|
}
|
|
|
|
export interface VideoMetadata {
|
|
title: string
|
|
description: string
|
|
duration: string
|
|
thumbnail_url: string
|
|
channel_name: string
|
|
published_at: string
|
|
}
|
|
|
|
export interface ProgressUpdate {
|
|
stage: string
|
|
percentage: number
|
|
message: string
|
|
details?: Record<string, any>
|
|
}
|
|
|
|
export interface ErrorData {
|
|
message: string
|
|
type: string
|
|
stage?: string
|
|
retry_count?: number
|
|
}
|
|
```
|
|
|
|
## Styling with Tailwind CSS
|
|
|
|
The project uses Tailwind CSS with shadcn/ui components for consistent styling:
|
|
|
|
**Tailwind Config** (`tailwind.config.js`)
|
|
```javascript
|
|
module.exports = {
|
|
darkMode: ["class"],
|
|
content: [
|
|
"./pages/**/*.{ts,tsx}",
|
|
"./components/**/*.{ts,tsx}",
|
|
"./app/**/*.{ts,tsx}",
|
|
"./src/**/*.{ts,tsx}",
|
|
],
|
|
theme: {
|
|
container: {
|
|
center: true,
|
|
padding: "2rem",
|
|
screens: {
|
|
"2xl": "1400px",
|
|
},
|
|
},
|
|
extend: {
|
|
colors: {
|
|
border: "hsl(var(--border))",
|
|
input: "hsl(var(--input))",
|
|
ring: "hsl(var(--ring))",
|
|
background: "hsl(var(--background))",
|
|
foreground: "hsl(var(--foreground))",
|
|
primary: {
|
|
DEFAULT: "hsl(var(--primary))",
|
|
foreground: "hsl(var(--primary-foreground))",
|
|
},
|
|
secondary: {
|
|
DEFAULT: "hsl(var(--secondary))",
|
|
foreground: "hsl(var(--secondary-foreground))",
|
|
},
|
|
// ... other color definitions
|
|
},
|
|
keyframes: {
|
|
"accordion-down": {
|
|
from: { height: 0 },
|
|
to: { height: "var(--radix-accordion-content-height)" },
|
|
},
|
|
"accordion-up": {
|
|
from: { height: "var(--radix-accordion-content-height)" },
|
|
to: { height: 0 },
|
|
},
|
|
},
|
|
animation: {
|
|
"accordion-down": "accordion-down 0.2s ease-out",
|
|
"accordion-up": "accordion-up 0.2s ease-out",
|
|
},
|
|
},
|
|
},
|
|
plugins: [require("tailwindcss-animate")],
|
|
}
|
|
```
|
|
|
|
## Error Handling and User Experience
|
|
|
|
### Error Boundary Component
|
|
|
|
```tsx
|
|
import React from 'react'
|
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
|
import { Button } from '@/components/ui/button'
|
|
import { RefreshCw, AlertTriangle } from 'lucide-react'
|
|
|
|
interface ErrorBoundaryState {
|
|
hasError: boolean
|
|
error?: Error
|
|
}
|
|
|
|
export class ErrorBoundary extends React.Component<
|
|
React.PropsWithChildren<{}>,
|
|
ErrorBoundaryState
|
|
> {
|
|
constructor(props: React.PropsWithChildren<{}>) {
|
|
super(props)
|
|
this.state = { hasError: false }
|
|
}
|
|
|
|
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
|
return { hasError: true, error }
|
|
}
|
|
|
|
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
|
console.error('ErrorBoundary caught an error:', error, errorInfo)
|
|
}
|
|
|
|
render() {
|
|
if (this.state.hasError) {
|
|
return (
|
|
<Alert variant="destructive">
|
|
<AlertTriangle className="h-4 w-4" />
|
|
<AlertTitle>Something went wrong</AlertTitle>
|
|
<AlertDescription className="mt-2 space-y-2">
|
|
<p>An unexpected error occurred. Please try refreshing the page.</p>
|
|
{this.state.error && (
|
|
<details className="text-xs">
|
|
<summary className="cursor-pointer">Error details</summary>
|
|
<pre className="mt-1 whitespace-pre-wrap">
|
|
{this.state.error.message}
|
|
</pre>
|
|
</details>
|
|
)}
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => window.location.reload()}
|
|
className="mt-2"
|
|
>
|
|
<RefreshCw className="h-4 w-4 mr-2" />
|
|
Refresh Page
|
|
</Button>
|
|
</AlertDescription>
|
|
</Alert>
|
|
)
|
|
}
|
|
|
|
return this.props.children
|
|
}
|
|
}
|
|
```
|
|
|
|
## Performance Optimization
|
|
|
|
### React Query Configuration
|
|
- Stale time: 5 minutes for cached data
|
|
- Retry: Only 1 retry for failed requests
|
|
- Background refetch disabled for completed operations
|
|
|
|
### WebSocket Management
|
|
- Automatic reconnection with exponential backoff
|
|
- Connection cleanup on component unmount
|
|
- Heartbeat mechanism for connection health
|
|
|
|
### Bundle Optimization
|
|
- Lazy loading for non-critical components
|
|
- Code splitting by routes
|
|
- Tree shaking for unused imports
|
|
- Optimized build with Vite
|
|
|
|
## Accessibility
|
|
|
|
### ARIA Labels and Semantic HTML
|
|
```tsx
|
|
<form role="form" aria-label="Video processing form">
|
|
<fieldset>
|
|
<legend className="sr-only">Video URL and Options</legend>
|
|
<Input
|
|
aria-label="YouTube video URL"
|
|
aria-describedby="url-help"
|
|
aria-invalid={validationStatus === 'invalid'}
|
|
/>
|
|
<div id="url-help" className="sr-only">
|
|
Enter a valid YouTube URL to process
|
|
</div>
|
|
</fieldset>
|
|
</form>
|
|
```
|
|
|
|
### Keyboard Navigation
|
|
- All interactive elements focusable
|
|
- Logical tab order
|
|
- Escape key support for modals
|
|
- Enter key support for form submission
|
|
|
|
### Screen Reader Support
|
|
- Proper heading hierarchy
|
|
- Live regions for progress updates
|
|
- Descriptive button labels
|
|
- Status announcements
|
|
|
|
This frontend provides a comprehensive, accessible, and user-friendly interface for the YouTube Summarizer with real-time updates and modern UI patterns. |