youtube-summarizer/docs/stories/1.4.basic-web-interface.md

31 KiB

Story 1.4: Basic Web Interface

Status

Draft

Story

As a user
I want a clean, responsive web interface to submit YouTube URLs and view extraction progress
so that I can easily interact with the transcript extraction system

Acceptance Criteria

  1. Responsive web interface works on desktop, tablet, and mobile devices
  2. URL input form with real-time validation and user-friendly error messages
  3. Progress indicators show extraction status (validating → extracting → completed)
  4. Extracted transcripts display in readable format with metadata
  5. Error states handled gracefully with actionable recovery suggestions
  6. Interface follows modern UI/UX best practices with shadcn/ui components

Tasks / Subtasks

  • Task 1: Project Foundation & Layout (AC: 1, 6)

    • Set up React 18 + TypeScript + Vite development environment
    • Install and configure shadcn/ui component library
    • Create responsive layout structure with header, main content, and footer
    • Implement mobile-first responsive design system
  • Task 2: URL Submission Form (AC: 2, 5)

    • Create SummarizeForm component with URL input and submit functionality
    • Integrate useURLValidation hook for real-time URL validation
    • Add visual validation indicators (checkmarks, error states)
    • Implement form submission with loading states and error handling
  • Task 3: Progress Tracking Interface (AC: 3)

    • Create ProgressTracker component with multi-stage progress display
    • Implement progress states: validating → extracting → processing → complete
    • Add estimated time remaining and cancellation functionality
    • Create WebSocket integration for real-time progress updates
  • Task 4: Transcript Display (AC: 4)

    • Create TranscriptViewer component with formatted text display
    • Add metadata display (word count, extraction method, processing time)
    • Implement copy-to-clipboard functionality
    • Add basic text search and highlighting within transcripts
  • Task 5: Error Handling & User Experience (AC: 5, 6)

    • Create comprehensive error display components
    • Implement toast notifications for success/error feedback
    • Add loading skeletons and optimistic UI updates
    • Create help documentation and format examples
  • Task 6: API Integration (AC: 2, 3, 4, 5)

    • Create API client service for backend communication
    • Implement async transcript extraction with job status polling
    • Add retry logic and exponential backoff for failed requests
    • Handle WebSocket connections for real-time updates
  • Task 7: Testing & Accessibility (AC: 1, 6)

    • Write component unit tests with React Testing Library
    • Add accessibility features (ARIA labels, keyboard navigation)
    • Test responsive design across multiple device sizes
    • Validate accessibility with automated testing tools

Dev Notes

Architecture Context

This story creates the user-facing interface that demonstrates the YouTube Summarizer's core value proposition. The interface must be intuitive, responsive, and reliable while providing clear feedback throughout the transcript extraction process.

Frontend Architecture Requirements

[Source: docs/architecture.md#frontend-architecture]

Technology Stack:

// Core Technologies
React 18          // User interface framework
TypeScript 5+     // Type safety and developer experience
Vite 4+           // Build tool and development server
shadcn/ui         // Component library
Tailwind CSS      // Utility-first CSS framework
React Hook Form   // Form handling and validation
React Query       // Server state management
Zustand          // Client state management

Project Structure:

frontend/
├── src/
│   ├── components/
│   │   ├── ui/                 # shadcn/ui base components
│   │   ├── forms/              # Form components
│   │   │   ├── SummarizeForm.tsx
│   │   │   └── ValidationFeedback.tsx
│   │   ├── display/            # Display components
│   │   │   ├── TranscriptViewer.tsx
│   │   │   ├── ProgressTracker.tsx
│   │   │   └── MetadataDisplay.tsx
│   │   └── layout/             # Layout components
│   │       ├── Header.tsx
│   │       ├── Footer.tsx
│   │       └── MainLayout.tsx
│   ├── hooks/                  # Custom React hooks
│   │   ├── useURLValidation.ts
│   │   ├── useTranscriptExtraction.ts
│   │   └── useWebSocket.ts
│   ├── services/              # API and external services
│   │   ├── apiClient.ts
│   │   └── websocketService.ts
│   ├── types/                 # TypeScript type definitions
│   │   └── api.types.ts
│   └── utils/                 # Utility functions
│       ├── validators.ts
│       └── formatters.ts

Component Implementation Requirements

[Source: docs/architecture.md#ui-components]

Main Application Component:

// src/App.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Toaster } from '@/components/ui/toaster';
import { MainLayout } from '@/components/layout/MainLayout';
import { SummarizePage } from '@/pages/SummarizePage';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 5 * 60 * 1000, // 5 minutes
      retry: 3,
    },
  },
});

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <MainLayout>
        <SummarizePage />
        <Toaster />
      </MainLayout>
    </QueryClientProvider>
  );
}

Main Summarization Interface:

// src/pages/SummarizePage.tsx
import { useState } from 'react';
import { SummarizeForm } from '@/components/forms/SummarizeForm';
import { ProgressTracker } from '@/components/display/ProgressTracker';
import { TranscriptViewer } from '@/components/display/TranscriptViewer';
import { useTranscriptExtraction } from '@/hooks/useTranscriptExtraction';

export function SummarizePage() {
  const [videoId, setVideoId] = useState<string>('');
  const { 
    extractTranscript, 
    progress, 
    transcript, 
    isLoading, 
    error 
  } = useTranscriptExtraction();

  const handleSubmit = async (url: string) => {
    const extractedId = extractVideoId(url);
    setVideoId(extractedId);
    await extractTranscript(extractedId);
  };

  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">
            <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
            </p>
          </header>

          <SummarizeForm 
            onSubmit={handleSubmit}
            disabled={isLoading}
          />

          {isLoading && (
            <ProgressTracker 
              progress={progress}
              videoId={videoId}
            />
          )}

          {error && (
            <ErrorDisplay error={error} />
          )}

          {transcript && (
            <TranscriptViewer 
              transcript={transcript}
              videoId={videoId}
            />
          )}
        </div>
      </div>
    </div>
  );
}

URL Submission Form Component:

// src/components/forms/SummarizeForm.tsx
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { useURLValidation } from '@/hooks/useURLValidation';
import { ValidationFeedback } from './ValidationFeedback';

const formSchema = z.object({
  url: z.string().url('Please enter a valid YouTube URL'),
});

interface SummarizeFormProps {
  onSubmit: (url: string) => Promise<void>;
  disabled?: boolean;
}

export function SummarizeForm({ onSubmit, disabled = false }: SummarizeFormProps) {
  const [isSubmitting, setIsSubmitting] = useState(false);
  const { validateURL, validationState } = useURLValidation();
  
  const form = useForm<z.infer<typeof formSchema>>({
    resolver: zodResolver(formSchema),
    defaultValues: { url: '' },
  });

  const handleSubmit = async (values: z.infer<typeof formSchema>) => {
    setIsSubmitting(true);
    try {
      await onSubmit(values.url);
    } catch (error) {
      console.error('Submission error:', error);
    } finally {
      setIsSubmitting(false);
    }
  };

  const handleURLChange = async (url: string) => {
    if (url.trim()) {
      await validateURL(url);
    }
  };

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
        <FormField
          control={form.control}
          name="url"
          render={({ field }) => (
            <FormItem>
              <FormLabel>YouTube URL</FormLabel>
              <FormControl>
                <div className="relative">
                  <Input
                    placeholder="https://youtube.com/watch?v=..."
                    {...field}
                    onChange={(e) => {
                      field.onChange(e);
                      handleURLChange(e.target.value);
                    }}
                    disabled={disabled || isSubmitting}
                    className={cn(
                      "pr-10",
                      validationState.isValid && "border-green-500",
                      validationState.error && "border-red-500"
                    )}
                  />
                  <ValidationFeedback 
                    validationState={validationState}
                    className="absolute right-3 top-1/2 -translate-y-1/2"
                  />
                </div>
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        
        <Button 
          type="submit" 
          disabled={disabled || isSubmitting || !validationState.isValid}
          className="w-full"
        >
          {isSubmitting ? 'Extracting...' : 'Extract Transcript'}
        </Button>
      </form>
    </Form>
  );
}

Progress Tracking Component:

// src/components/display/ProgressTracker.tsx
import { useEffect } from 'react';
import { Progress } from '@/components/ui/progress';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { CheckCircle, Clock, Loader2, XCircle } from 'lucide-react';
import { useWebSocket } from '@/hooks/useWebSocket';

interface ProgressStep {
  id: string;
  label: string;
  status: 'pending' | 'in-progress' | 'completed' | 'failed';
}

interface ProgressTrackerProps {
  progress: {
    currentStep: string;
    percentage: number;
    estimatedTimeRemaining?: number;
    steps: ProgressStep[];
  };
  videoId: string;
  onCancel?: () => void;
}

export function ProgressTracker({ progress, videoId, onCancel }: ProgressTrackerProps) {
  const { connect, disconnect } = useWebSocket({
    onProgress: (update) => {
      // Progress updates handled by parent component
      console.log('Progress update:', update);
    },
  });

  useEffect(() => {
    connect(videoId);
    return () => disconnect();
  }, [videoId, connect, disconnect]);

  const getStepIcon = (status: ProgressStep['status']) => {
    switch (status) {
      case 'completed':
        return <CheckCircle className="w-4 h-4 text-green-500" />;
      case 'in-progress':
        return <Loader2 className="w-4 h-4 text-blue-500 animate-spin" />;
      case 'failed':
        return <XCircle className="w-4 h-4 text-red-500" />;
      default:
        return <Clock className="w-4 h-4 text-gray-400" />;
    }
  };

  return (
    <Card>
      <CardHeader>
        <CardTitle className="flex items-center justify-between">
          <span>Extracting Transcript</span>
          {onCancel && (
            <Button variant="outline" size="sm" onClick={onCancel}>
              Cancel
            </Button>
          )}
        </CardTitle>
      </CardHeader>
      <CardContent className="space-y-4">
        <div className="space-y-2">
          <div className="flex justify-between text-sm">
            <span>{progress.currentStep}</span>
            <span>{Math.round(progress.percentage)}%</span>
          </div>
          <Progress value={progress.percentage} className="w-full" />
          {progress.estimatedTimeRemaining && (
            <p className="text-sm text-muted-foreground">
              Estimated time remaining: {Math.round(progress.estimatedTimeRemaining)}s
            </p>
          )}
        </div>

        <div className="space-y-2">
          {progress.steps.map((step) => (
            <div key={step.id} className="flex items-center space-x-3">
              {getStepIcon(step.status)}
              <span className={cn(
                "text-sm",
                step.status === 'completed' && "text-green-600",
                step.status === 'in-progress' && "text-blue-600",
                step.status === 'failed' && "text-red-600"
              )}>
                {step.label}
              </span>
            </div>
          ))}
        </div>
      </CardContent>
    </Card>
  );
}

Transcript Display Component:

// src/components/display/TranscriptViewer.tsx
import { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Copy, Search, Download } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';

interface TranscriptMetadata {
  wordCount: number;
  extractionMethod: string;
  language: string;
  processingTimeSeconds: number;
}

interface TranscriptViewerProps {
  transcript: string;
  metadata: TranscriptMetadata;
  videoId: string;
}

export function TranscriptViewer({ transcript, metadata, videoId }: TranscriptViewerProps) {
  const [searchTerm, setSearchTerm] = useState('');
  const { toast } = useToast();

  const copyToClipboard = async () => {
    try {
      await navigator.clipboard.writeText(transcript);
      toast({
        title: "Copied to clipboard",
        description: "Transcript has been copied to your clipboard.",
      });
    } catch (error) {
      toast({
        title: "Copy failed",
        description: "Failed to copy transcript to clipboard.",
        variant: "destructive",
      });
    }
  };

  const downloadTranscript = () => {
    const blob = new Blob([transcript], { type: 'text/plain' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = `youtube-transcript-${videoId}.txt`;
    document.body.appendChild(a);
    a.click();
    document.body.removeChild(a);
    URL.revokeObjectURL(url);
  };

  const highlightSearchTerm = (text: string, term: string) => {
    if (!term.trim()) return text;
    
    const regex = new RegExp(`(${term})`, 'gi');
    return text.split(regex).map((part, index) => 
      regex.test(part) ? 
        <mark key={index} className="bg-yellow-200 px-1 rounded">{part}</mark> : 
        part
    );
  };

  return (
    <Card>
      <CardHeader>
        <CardTitle className="flex items-center justify-between">
          <span>Transcript</span>
          <div className="flex space-x-2">
            <Button variant="outline" size="sm" onClick={copyToClipboard}>
              <Copy className="w-4 h-4 mr-2" />
              Copy
            </Button>
            <Button variant="outline" size="sm" onClick={downloadTranscript}>
              <Download className="w-4 h-4 mr-2" />
              Download
            </Button>
          </div>
        </CardTitle>
        
        <div className="flex flex-wrap gap-2">
          <Badge variant="secondary">
            {metadata.wordCount} words
          </Badge>
          <Badge variant="secondary">
            {metadata.language}
          </Badge>
          <Badge variant="secondary">
            {metadata.extractionMethod}
          </Badge>
          <Badge variant="secondary">
            {metadata.processingTimeSeconds.toFixed(1)}s
          </Badge>
        </div>
      </CardHeader>
      
      <CardContent className="space-y-4">
        <div className="relative">
          <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
          <Input
            placeholder="Search in transcript..."
            value={searchTerm}
            onChange={(e) => setSearchTerm(e.target.value)}
            className="pl-10"
          />
        </div>
        
        <div className="bg-muted p-4 rounded-lg max-h-96 overflow-y-auto">
          <div className="whitespace-pre-wrap text-sm leading-relaxed">
            {highlightSearchTerm(transcript, searchTerm)}
          </div>
        </div>
      </CardContent>
    </Card>
  );
}

Custom Hooks Implementation

[Source: docs/architecture.md#react-patterns]

URL Validation Hook:

// src/hooks/useURLValidation.ts
import { useState, useCallback } from 'react';
import { apiClient } from '@/services/apiClient';

interface URLValidationState {
  isValid: boolean;
  isValidating: boolean;
  error?: {
    code: string;
    message: string;
    supportedFormats: string[];
  };
}

export function useURLValidation() {
  const [validationState, setValidationState] = useState<URLValidationState>({
    isValid: false,
    isValidating: false,
  });

  const validateURL = useCallback(async (url: string) => {
    if (!url.trim()) {
      setValidationState({ isValid: false, isValidating: false });
      return;
    }

    setValidationState({ isValid: false, isValidating: true });

    // Client-side validation first
    const youtubePatterns = [
      /^https?:\/\/(www\.)?(youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)/,
    ];

    const hasValidPattern = youtubePatterns.some(pattern => pattern.test(url));
    if (!hasValidPattern) {
      setValidationState({
        isValid: false,
        isValidating: false,
        error: {
          code: 'INVALID_URL',
          message: 'Invalid YouTube URL format',
          supportedFormats: [
            'https://youtube.com/watch?v=VIDEO_ID',
            'https://youtu.be/VIDEO_ID',
            'https://youtube.com/embed/VIDEO_ID'
          ]
        }
      });
      return;
    }

    // Server-side validation for security
    try {
      const response = await apiClient.validateURL(url);
      setValidationState({
        isValid: response.is_valid,
        isValidating: false,
        error: response.error ? {
          code: response.error.code,
          message: response.error.message,
          supportedFormats: response.error.details?.supported_formats || []
        } : undefined
      });
    } catch (error) {
      setValidationState({
        isValid: false,
        isValidating: false,
        error: {
          code: 'VALIDATION_ERROR',
          message: 'Failed to validate URL',
          supportedFormats: []
        }
      });
    }
  }, []);

  return { validateURL, validationState };
}

Transcript Extraction Hook:

// src/hooks/useTranscriptExtraction.ts
import { useState, useCallback } from 'react';
import { useMutation } from '@tanstack/react-query';
import { apiClient } from '@/services/apiClient';
import { useWebSocket } from './useWebSocket';

interface ProgressState {
  currentStep: string;
  percentage: number;
  estimatedTimeRemaining?: number;
  steps: Array<{
    id: string;
    label: string;
    status: 'pending' | 'in-progress' | 'completed' | 'failed';
  }>;
}

export function useTranscriptExtraction() {
  const [progress, setProgress] = useState<ProgressState>({
    currentStep: 'Preparing...',
    percentage: 0,
    steps: [
      { id: 'validate', label: 'Validating URL', status: 'pending' },
      { id: 'extract', label: 'Extracting Transcript', status: 'pending' },
      { id: 'process', label: 'Processing Content', status: 'pending' },
      { id: 'complete', label: 'Complete', status: 'pending' }
    ]
  });

  const { connect, disconnect } = useWebSocket({
    onProgress: (update) => {
      setProgress(prev => ({
        ...prev,
        currentStep: update.current_step,
        percentage: update.progress_percentage,
        estimatedTimeRemaining: update.estimated_time_remaining,
        steps: prev.steps.map(step => 
          step.id === update.step_id 
            ? { ...step, status: update.status }
            : step
        )
      }));
    }
  });

  const mutation = useMutation({
    mutationFn: async (videoId: string) => {
      // Start WebSocket connection for progress updates
      connect(videoId);
      
      // Start extraction
      const response = await apiClient.extractTranscript(videoId);
      return response;
    },
    onSuccess: (data) => {
      setProgress(prev => ({
        ...prev,
        currentStep: 'Complete',
        percentage: 100,
        steps: prev.steps.map(step => ({ ...step, status: 'completed' }))
      }));
      disconnect();
    },
    onError: (error) => {
      setProgress(prev => ({
        ...prev,
        currentStep: 'Failed',
        steps: prev.steps.map(step => 
          step.status === 'in-progress' 
            ? { ...step, status: 'failed' }
            : step
        )
      }));
      disconnect();
    }
  });

  return {
    extractTranscript: mutation.mutateAsync,
    progress,
    transcript: mutation.data?.transcript,
    metadata: mutation.data?.metadata,
    isLoading: mutation.isPending,
    error: mutation.error
  };
}

API Client Implementation

[Source: docs/architecture.md#api-integration]

// src/services/apiClient.ts
import axios, { AxiosInstance, AxiosError } from 'axios';

class APIClient {
  private client: AxiosInstance;

  constructor(baseURL: string = '/api') {
    this.client = axios.create({
      baseURL,
      timeout: 30000,
      headers: {
        'Content-Type': 'application/json',
      },
    });

    this.setupInterceptors();
  }

  private setupInterceptors() {
    // Request interceptor
    this.client.interceptors.request.use((config) => {
      console.log(`API Request: ${config.method?.toUpperCase()} ${config.url}`);
      return config;
    });

    // Response interceptor
    this.client.interceptors.response.use(
      (response) => response,
      (error: AxiosError) => {
        console.error('API Error:', error.response?.data || error.message);
        return Promise.reject(error);
      }
    );
  }

  async validateURL(url: string) {
    const response = await this.client.post('/validate-url', { url });
    return response.data;
  }

  async extractTranscript(videoId: string, options: any = {}) {
    const response = await this.client.post('/transcripts/extract', {
      video_id: videoId,
      ...options
    });
    return response.data;
  }

  async getTranscript(videoId: string) {
    const response = await this.client.get(`/transcripts/${videoId}`);
    return response.data;
  }

  async getExtractionStatus(jobId: string) {
    const response = await this.client.get(`/transcripts/jobs/${jobId}`);
    return response.data;
  }
}

export const apiClient = new APIClient();

File Locations and Structure

[Source: docs/architecture.md#project-structure]

Frontend Files:

  • frontend/src/App.tsx - Main application component
  • frontend/src/pages/SummarizePage.tsx - Primary user interface page
  • frontend/src/components/forms/SummarizeForm.tsx - URL input form
  • frontend/src/components/display/ProgressTracker.tsx - Progress visualization
  • frontend/src/components/display/TranscriptViewer.tsx - Transcript display
  • frontend/src/hooks/useURLValidation.ts - URL validation hook
  • frontend/src/hooks/useTranscriptExtraction.ts - Extraction management hook
  • frontend/src/hooks/useWebSocket.ts - WebSocket connection hook
  • frontend/src/services/apiClient.ts - Backend API integration
  • frontend/src/types/api.types.ts - TypeScript type definitions

Testing Standards

Component Testing

[Source: docs/architecture.md#testing-strategy]

Test File: frontend/src/components/forms/SummarizeForm.test.tsx

import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { SummarizeForm } from './SummarizeForm';

const createWrapper = () => {
  const queryClient = new QueryClient({
    defaultOptions: { queries: { retry: false } }
  });
  
  return ({ children }: { children: React.ReactNode }) => (
    <QueryClientProvider client={queryClient}>
      {children}
    </QueryClientProvider>
  );
};

describe('SummarizeForm', () => {
  it('validates YouTube URL format', async () => {
    const mockOnSubmit = jest.fn();
    render(<SummarizeForm onSubmit={mockOnSubmit} />, { wrapper: createWrapper() });
    
    const input = screen.getByPlaceholderText(/youtube url/i);
    const submitButton = screen.getByRole('button', { name: /extract transcript/i });
    
    fireEvent.change(input, { target: { value: 'invalid-url' } });
    fireEvent.click(submitButton);
    
    await waitFor(() => {
      expect(screen.getByText(/invalid youtube url/i)).toBeInTheDocument();
    });
    
    expect(mockOnSubmit).not.toHaveBeenCalled();
  });

  it('accepts valid YouTube URLs and calls onSubmit', async () => {
    const mockOnSubmit = jest.fn().mockResolvedValue(undefined);
    render(<SummarizeForm onSubmit={mockOnSubmit} />, { wrapper: createWrapper() });
    
    const input = screen.getByPlaceholderText(/youtube url/i);
    const submitButton = screen.getByRole('button', { name: /extract transcript/i });
    
    fireEvent.change(input, { target: { value: 'https://youtube.com/watch?v=dQw4w9WgXcQ' } });
    
    await waitFor(() => {
      expect(submitButton).not.toBeDisabled();
    });
    
    fireEvent.click(submitButton);
    
    await waitFor(() => {
      expect(mockOnSubmit).toHaveBeenCalledWith('https://youtube.com/watch?v=dQw4w9WgXcQ');
    });
  });
});

Integration Testing

[Source: docs/architecture.md#testing-strategy]

Test File: frontend/src/pages/SummarizePage.test.tsx

import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { server } from '@/mocks/server';
import { SummarizePage } from './SummarizePage';

// Mock WebSocket
const mockWebSocket = {
  connect: jest.fn(),
  disconnect: jest.fn(),
  on: jest.fn(),
};

jest.mock('@/hooks/useWebSocket', () => ({
  useWebSocket: () => mockWebSocket,
}));

describe('SummarizePage Integration', () => {
  beforeEach(() => {
    server.listen();
  });

  afterEach(() => {
    server.resetHandlers();
  });

  afterAll(() => {
    server.close();
  });

  it('completes full transcript extraction flow', async () => {
    render(<SummarizePage />);
    
    // Submit valid URL
    const input = screen.getByPlaceholderText(/youtube url/i);
    const submitButton = screen.getByRole('button', { name: /extract transcript/i });
    
    fireEvent.change(input, { target: { value: 'https://youtube.com/watch?v=dQw4w9WgXcQ' } });
    fireEvent.click(submitButton);
    
    // Check progress display
    await waitFor(() => {
      expect(screen.getByText(/extracting transcript/i)).toBeInTheDocument();
    });
    
    // Check final transcript display
    await waitFor(() => {
      expect(screen.getByText(/transcript/i)).toBeInTheDocument();
      expect(screen.getByText(/sample transcript content/i)).toBeInTheDocument();
    }, { timeout: 5000 });
  });
});

Development Environment Setup

[Source: docs/architecture.md#development-setup]

Package Configuration:

// frontend/package.json
{
  "name": "youtube-summarizer-frontend",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "preview": "vite preview",
    "test": "vitest",
    "test:ui": "vitest --ui",
    "test:coverage": "vitest --coverage",
    "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
    "lint:fix": "eslint . --ext ts,tsx --fix"
  },
  "dependencies": {
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "@tanstack/react-query": "^5.0.0",
    "react-hook-form": "^7.48.0",
    "@hookform/resolvers": "^3.3.0",
    "zod": "^3.22.0",
    "axios": "^1.6.0",
    "lucide-react": "^0.294.0",
    "class-variance-authority": "^0.7.0",
    "clsx": "^2.0.0",
    "tailwind-merge": "^2.0.0"
  },
  "devDependencies": {
    "@types/react": "^18.2.0",
    "@types/react-dom": "^18.2.0",
    "@typescript-eslint/eslint-plugin": "^6.0.0",
    "@typescript-eslint/parser": "^6.0.0",
    "@vitejs/plugin-react": "^4.2.0",
    "eslint": "^8.45.0",
    "eslint-plugin-react-hooks": "^4.6.0",
    "eslint-plugin-react-refresh": "^0.4.0",
    "typescript": "^5.0.2",
    "vite": "^5.0.0",
    "vitest": "^1.0.0",
    "@testing-library/react": "^14.0.0",
    "@testing-library/jest-dom": "^6.0.0",
    "@testing-library/user-event": "^14.0.0",
    "msw": "^2.0.0",
    "tailwindcss": "^3.3.0",
    "autoprefixer": "^10.4.0",
    "postcss": "^8.4.0"
  }
}

Performance Optimization

  • Code Splitting: Lazy load components for better initial load performance
  • Bundle Optimization: Tree shaking and module bundling with Vite
  • API Caching: React Query for intelligent server state caching
  • Optimistic UI: Show immediate feedback for user actions
  • WebSocket Efficiency: Connection pooling and automatic reconnection

Accessibility Features

  • Keyboard Navigation: Full keyboard accessibility for all interactive elements
  • Screen Reader Support: Proper ARIA labels and semantic HTML
  • Color Contrast: WCAG 2.1 AA compliant color schemes
  • Focus Management: Visible focus indicators and logical tab order
  • Alternative Text: Descriptive text for all visual elements

Security Considerations

  • XSS Prevention: Input sanitization and Content Security Policy
  • CSRF Protection: CSRF tokens for state-changing operations
  • HTTPS Only: Enforce secure connections in production
  • Content Validation: Client and server-side input validation
  • Error Information: Avoid exposing sensitive error details

Change Log

Date Version Description Author
2025-01-25 1.0 Initial story creation Bob (Scrum Master)

Dev Agent Record

This section will be populated by the development agent during implementation

Agent Model Used

To be filled by dev agent

Debug Log References

To be filled by dev agent

Completion Notes List

To be filled by dev agent

File List

To be filled by dev agent

QA Results

Results from QA Agent review of the completed story implementation will be added here