├── src ├── vite-env.d.ts ├── main.tsx ├── components │ ├── LoadingSpinner.tsx │ ├── Pagination.tsx │ ├── Header.tsx │ ├── ErrorToast.tsx │ ├── CommentList.tsx │ ├── Credits.tsx │ ├── ExportButton.tsx │ └── CommentForm.tsx ├── App.css ├── index.css └── App.tsx ├── public ├── favicon.png └── sitemap.xml ├── screenshots ├── Main.png ├── VideoID.png └── CommentsLoaded.png ├── postcss.config.js ├── tsconfig.json ├── vite.config.ts ├── tailwind.config.js ├── .gitignore ├── tsconfig.node.json ├── tsconfig.app.json ├── eslint.config.js ├── package.json ├── LICENSE ├── index.html └── README.md /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steventete/YouTubeCommentScraper/HEAD/public/favicon.png -------------------------------------------------------------------------------- /screenshots/Main.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steventete/YouTubeCommentScraper/HEAD/screenshots/Main.png -------------------------------------------------------------------------------- /screenshots/VideoID.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steventete/YouTubeCommentScraper/HEAD/screenshots/VideoID.png -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /screenshots/CommentsLoaded.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/steventete/YouTubeCommentScraper/HEAD/screenshots/CommentsLoaded.png -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react-swc' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }) 8 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: [ 4 | "./index.html", 5 | "./src/**/*.{js,ts,jsx,tsx}", 6 | ], 7 | theme: { 8 | extend: {}, 9 | }, 10 | plugins: [], 11 | } -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import App from './App.tsx' 4 | import './index.css' 5 | 6 | createRoot(document.getElementById('root')!).render( 7 | 8 | 9 | , 10 | ) 11 | -------------------------------------------------------------------------------- /src/components/LoadingSpinner.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const LoadingSpinner: React.FC = () => { 4 | return ( 5 |
6 |
7 |
8 | ); 9 | }; -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | .env 16 | 17 | # Editor directories and files 18 | .vscode/* 19 | !.vscode/extensions.json 20 | .idea 21 | .DS_Store 22 | *.suo 23 | *.ntvs* 24 | *.njsproj 25 | *.sln 26 | *.sw? 27 | .env 28 | -------------------------------------------------------------------------------- /public/sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | 10 | 11 | https://youtubecommentscraper.vercel.app/ 12 | 2025-01-01T21:47:36+00:00 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "lib": ["ES2023"], 5 | "module": "ESNext", 6 | "skipLibCheck": true, 7 | 8 | /* Bundler mode */ 9 | "moduleResolution": "bundler", 10 | "allowImportingTsExtensions": true, 11 | "isolatedModules": true, 12 | "moduleDetection": "force", 13 | "noEmit": true, 14 | 15 | /* Linting */ 16 | "strict": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "noFallthroughCasesInSwitch": true 20 | }, 21 | "include": ["vite.config.ts"] 22 | } 23 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "isolatedModules": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src"] 24 | } 25 | -------------------------------------------------------------------------------- /src/components/Pagination.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface PaginationProps { 4 | onLoadMore: () => void; 5 | hasMore: boolean; 6 | } 7 | 8 | export const Pagination: React.FC = ({ onLoadMore, hasMore }) => { 9 | if (!hasMore) return null; 10 | 11 | return ( 12 |
13 | 19 |
20 | 21 | ); 22 | }; -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | #root { 2 | max-width: 1280px; 3 | margin: 0 auto; 4 | padding: 2rem; 5 | text-align: center; 6 | } 7 | 8 | .logo { 9 | height: 6em; 10 | padding: 1.5em; 11 | will-change: filter; 12 | transition: filter 300ms; 13 | } 14 | .logo:hover { 15 | filter: drop-shadow(0 0 2em #646cffaa); 16 | } 17 | .logo.react:hover { 18 | filter: drop-shadow(0 0 2em #61dafbaa); 19 | } 20 | 21 | @keyframes logo-spin { 22 | from { 23 | transform: rotate(0deg); 24 | } 25 | to { 26 | transform: rotate(360deg); 27 | } 28 | } 29 | 30 | @media (prefers-reduced-motion: no-preference) { 31 | a:nth-of-type(2) .logo { 32 | animation: logo-spin infinite 20s linear; 33 | } 34 | } 35 | 36 | .card { 37 | padding: 2em; 38 | } 39 | 40 | .read-the-docs { 41 | color: #888; 42 | } 43 | -------------------------------------------------------------------------------- /src/components/Header.tsx: -------------------------------------------------------------------------------- 1 | export default function Header() { 2 | return ( 3 |

4 | 10 | YouTube 11 | 12 | 13 | YouTube Comment Scraper 14 |

15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import reactHooks from 'eslint-plugin-react-hooks' 4 | import reactRefresh from 'eslint-plugin-react-refresh' 5 | import tseslint from 'typescript-eslint' 6 | 7 | export default tseslint.config( 8 | { ignores: ['dist'] }, 9 | { 10 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 11 | files: ['**/*.{ts,tsx}'], 12 | languageOptions: { 13 | ecmaVersion: 2020, 14 | globals: globals.browser, 15 | }, 16 | plugins: { 17 | 'react-hooks': reactHooks, 18 | 'react-refresh': reactRefresh, 19 | }, 20 | rules: { 21 | ...reactHooks.configs.recommended.rules, 22 | 'react-refresh/only-export-components': [ 23 | 'warn', 24 | { allowConstantExport: true }, 25 | ], 26 | }, 27 | }, 28 | ) 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "youtubecommentscraper", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc -b && vite build", 9 | "lint": "eslint .", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@vercel/analytics": "^1.3.1", 14 | "lucide-react": "^0.446.0", 15 | "react": "^18.3.1", 16 | "react-dom": "^18.3.1" 17 | }, 18 | "devDependencies": { 19 | "@eslint/js": "^9.9.0", 20 | "@types/react": "^18.3.3", 21 | "@types/react-dom": "^18.3.0", 22 | "@vitejs/plugin-react-swc": "^3.5.0", 23 | "autoprefixer": "^10.4.20", 24 | "eslint": "^9.9.0", 25 | "eslint-plugin-react-hooks": "^5.1.0-rc.0", 26 | "eslint-plugin-react-refresh": "^0.4.9", 27 | "globals": "^15.9.0", 28 | "postcss": "^8.4.47", 29 | "tailwindcss": "^3.4.13", 30 | "typescript": "^5.5.3", 31 | "typescript-eslint": "^8.0.1", 32 | "vite": "^7.1.10" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Breiner Steven Tete Vergara 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/components/ErrorToast.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { X, XCircle } from "lucide-react"; 3 | 4 | interface ErrorToastProps { 5 | message: string; 6 | onClose?: () => void; 7 | } 8 | 9 | export const ErrorToast: React.FC = ({ message, onClose }) => { 10 | if (!message) return null; 11 | 12 | return ( 13 |
17 |
18 | 19 | Error icon 20 |
21 |
{message}
22 | 30 |
31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /src/components/CommentList.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface Comment { 4 | id: string; 5 | authorDisplayName: string; 6 | authorProfileImageUrl: string; 7 | publishedAt: string; 8 | textDisplay: string; 9 | } 10 | 11 | interface CommentListProps { 12 | comments: Comment[]; 13 | } 14 | 15 | export const CommentList: React.FC = ({ comments }) => { 16 | if (comments.length === 0) { 17 | return

No comments loaded yet.

; 18 | } 19 | 20 | return ( 21 |
22 |
23 | {comments.map((comment) => ( 24 |
25 |
26 | Avatar 27 |
28 |

{comment.authorDisplayName}

29 |

{new Date(comment.publishedAt).toLocaleDateString()}

30 |
31 |
32 |

{comment.textDisplay}

33 |
34 | ))} 35 |
36 |
37 | 38 | ); 39 | }; -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | * { 6 | -ms-overflow-style: none; /* Internet Explorer 10+ */ 7 | scrollbar-width: none; /* Firefox */ 8 | } 9 | *::-webkit-scrollbar { 10 | display: none; /* Safari and Chrome */ 11 | } 12 | 13 | * { 14 | transition: all 0.2s ease; 15 | } 16 | 17 | @keyframes appears { 18 | 0% { 19 | opacity: 0; 20 | transform: translateY(20px) scale(0); 21 | } 22 | 60% { 23 | transform: translateY(0) scale(1.1); 24 | opacity: 1; 25 | } 26 | 100% { 27 | transform: translateY(0) scale(1); 28 | opacity: 1; 29 | } 30 | } 31 | 32 | .appears { 33 | animation: appears 1s cubic-bezier(0.22, 1, 0.36, 1); 34 | } 35 | 36 | @keyframes fadeIn { 37 | 0% { 38 | opacity: 0; 39 | } 40 | 100% { 41 | opacity: 1; 42 | } 43 | } 44 | 45 | .fadeIn { 46 | animation: fadeIn 1s ease; 47 | } 48 | 49 | @keyframes toast-bounce-in { 50 | 0% { 51 | opacity: 0; 52 | transform: translateY(-40%) scale(0.9); 53 | } 54 | 40% { 55 | opacity: 1; 56 | transform: translateY(10%) scale(1.05); 57 | } 58 | 65% { 59 | transform: translateY(-5%) scale(0.97); 60 | } 61 | 85% { 62 | transform: translateY(3%) scale(1.02); 63 | } 64 | 100% { 65 | transform: translateY(0) scale(1); 66 | } 67 | } 68 | 69 | .toast-bounce-in { 70 | animation: toast-bounce-in 0.55s cubic-bezier(0.34, 1.56, 0.64, 1); 71 | } 72 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 19 | 23 | 24 | 28 | 32 | 37 | YouTube Comments Scraper 38 | 39 | 40 |
43 |
44 |
45 | 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /src/components/Credits.tsx: -------------------------------------------------------------------------------- 1 | export default function Credits() { 2 | return ( 3 | 9 | 16 | 17 | 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/components/ExportButton.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { FileText } from "lucide-react"; 3 | import { Sheet } from "lucide-react"; 4 | import { FileJson } from "lucide-react"; 5 | 6 | interface Comment { 7 | authorDisplayName: string; 8 | textDisplay: string; 9 | } 10 | 11 | interface ExportButtonProps { 12 | comments: Comment[]; 13 | videoId: String; 14 | } 15 | 16 | export const ExportButton: React.FC = ({ 17 | comments, 18 | videoId, 19 | }) => { 20 | const fileBaseName = videoId ? `comments_${videoId}` : "comments"; 21 | 22 | // Exportar a TXT 23 | const exportToTxt = () => { 24 | const blob = new Blob( 25 | [ 26 | comments 27 | .map( 28 | (comment) => `${comment.authorDisplayName}: ${comment.textDisplay}`, 29 | ) 30 | .join("\n\n"), 31 | ], 32 | { type: "text/plain" }, 33 | ); 34 | const link = document.createElement("a"); 35 | link.href = URL.createObjectURL(blob); 36 | link.download = `${fileBaseName}.txt`; 37 | link.click(); 38 | }; 39 | 40 | // Exportar a CSV 41 | const exportToCsv = () => { 42 | const csvRows = [ 43 | ["Author", "Comment"], 44 | ...comments.map((comment) => [ 45 | comment.authorDisplayName, 46 | comment.textDisplay, 47 | ]), 48 | ]; 49 | 50 | const csvContent = csvRows 51 | .map((row) => row.map((item) => `"${item}"`).join(",")) 52 | .join("\n"); 53 | 54 | const blob = new Blob([csvContent], { type: "text/csv" }); 55 | const link = document.createElement("a"); 56 | link.href = URL.createObjectURL(blob); 57 | link.download = `${fileBaseName}.csv`; 58 | link.click(); 59 | }; 60 | 61 | // Exportar a JSON 62 | const exportToJson = () => { 63 | const jsonContent = JSON.stringify(comments, null, 2); 64 | const blob = new Blob([jsonContent], { type: "application/json" }); 65 | const link = document.createElement("a"); 66 | link.href = URL.createObjectURL(blob); 67 | link.download = `${fileBaseName}.json`; 68 | link.click(); 69 | }; 70 | 71 | return ( 72 |
73 | 80 | 87 | 94 |
95 | ); 96 | }; 97 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # YouTube Comment Scraper 2 | 3 | Welcome to **YouTube Comment Scraper**, your sleek and intuitive tool for extracting and managing YouTube comments! 📝 4 | 5 | [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT) 6 | [![Powered by YouTube Data API](https://img.shields.io/badge/Powered%20by-YouTube%20Data%20API-FF0000.svg)](https://developers.google.com/youtube/v3) 7 | [![UI by React](https://img.shields.io/badge/UI%20Framework-React-blue.svg)](https://reactjs.org/) 8 | [![Styling by Tailwind CSS](https://img.shields.io/badge/Styled%20with-Tailwind%20CSS-06B6D4.svg)](https://tailwindcss.com/) 9 | 10 | ## Overview 11 | 12 | **YouTube Comment Scraper** is a cutting-edge web application designed to make scraping and exporting comments from YouTube videos seamless and efficient. With a modern UI, multiple export options, and real-time loading, this tool is perfect for content analysis, sentiment analysis, or just gathering insights from YouTube's vast audience. 13 | 14 | ## Key Features 15 | 16 | - **Effortless Comment Extraction**: Scrape comments quickly and efficiently using the YouTube Data API. 17 | - **Export Options**: Export scraped comments in **TXT**, **CSV**, or **JSON** formats to suit your needs. 18 | - **Beautiful UI**: A minimalist and responsive design ensures an intuitive user experience. 19 | - **Real-time Pagination**: Load comments progressively for large datasets without overwhelming the system. 20 | - **Easy to Use**: Enter a YouTube video ID, set your preferences, and scrape away! 21 | 22 | ## Technologies Used 23 | 24 | This project is powered by: 25 | 26 | - **React**: Framework used for building the dynamic user interface. 27 | - **Tailwind CSS**: Utility-first CSS framework for responsive, sleek layouts. 28 | - **YouTube Data API v3**: Fetches comments directly from YouTube. 29 | - **Vite**: Lightning-fast project bundler and setup tool. 30 | - **Lucide React Icons**: Clean, customizable icon library for modern interfaces. 31 | 32 | ## Screenshots 33 | 34 | ![YouTube Comment Scraper](./screenshots/Main.png) 35 | ![YouTube Comment Scraper](./screenshots/VideoID.png) 36 | ![YouTube Comment Scraper](./screenshots/CommentsLoaded.png) 37 | 38 | ## Installation 39 | 40 | Follow these steps to run the project locally: 41 | 42 | 1. Clone the repository: 43 | ```bash 44 | git clone https://github.com/StevenTete/YouTubeCommentScraper.git 45 | cd YouTubeCommentScraper 46 | ``` 47 | 2. Install dependencies: 48 | ```bash 49 | npm install 50 | ``` 51 | 3. Set up the YouTube Data API by creating a `.env` file and adding your API key: 52 | ```bash 53 | VITE_YOUTUBE_API_KEY=your-youtube-api-key 54 | ``` 55 | 4. Start the local development server: 56 | ```bash 57 | npm run dev 58 | ``` 59 | 5. Open your browser and go to `http://localhost:5173`. 60 | 61 | ## Usage 62 | 63 | 1. Enter the YouTube video ID in the input field. 64 | 2. Specify the number of comments to scrape (default: 50). 65 | 3. Click on "Load Comments" to begin the process. 66 | 4. Once completed, export the comments in your preferred format: **TXT**, **CSV**, or **JSON**. 67 | 68 | ## Export Formats 69 | 70 | - **TXT**: For simple, text-based readability. 71 | - **CSV**: Ideal for data analysis in Excel or Google Sheets. 72 | - **JSON**: Perfect for programmatic or API-based use. 73 | 74 | ## Feedback and Contributions 75 | 76 | We'd love to hear your feedback! If you have suggestions, issues, or ideas for improvements, feel free to [open an issue](https://github.com/StevenTete/YouTubeCommentScrapper/issues) or submit a pull request. Your contributions are always welcome! 🎉 77 | 78 | ## License 79 | 80 | **YouTube Comment Scrapper** is licensed under the [MIT License](https://opensource.org/licenses/MIT). Fork, modify, and share freely! 81 | 82 | --- 83 | 84 | *YouTube Comment Scraper - Extract, Analyze, and Export with Ease.* 85 | -------------------------------------------------------------------------------- /src/components/CommentForm.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { ErrorToast } from "./ErrorToast"; 3 | import { RefreshCcw } from "lucide-react"; 4 | 5 | interface CommentFormProps { 6 | onSubmit: (videoId: string, maxResults: number) => void; 7 | } 8 | 9 | export const CommentForm: React.FC = ({ onSubmit }) => { 10 | const [inputValue, setInputValue] = useState(""); 11 | const [maxResults, setMaxResults] = useState(100); 12 | const [errorMessage, setErrorMessage] = useState(""); 13 | 14 | // Validates if the provided input is a YouTube URL or valid video ID 15 | const isYouTubeUrl = (value: string): boolean => { 16 | if (/^[a-zA-Z0-9_-]{11}$/.test(value)) return true; 17 | try { 18 | const url = new URL(value); 19 | return ( 20 | url.hostname.includes("youtube.com") || 21 | url.hostname.includes("youtu.be") 22 | ); 23 | } catch { 24 | return false; 25 | } 26 | }; 27 | 28 | // Extracts the video ID from a YouTube link or returns it directly if already provided 29 | const extractVideoId = (urlOrId: string): string | null => { 30 | if (/^[a-zA-Z0-9_-]{11}$/.test(urlOrId)) return urlOrId; 31 | 32 | const patterns = [ 33 | /[?&]v=([a-zA-Z0-9_-]{11})/, // youtube.com/watch?v=... 34 | /(?:be\/)([a-zA-Z0-9_-]{11})/, // youtu.be/... 35 | /(?:embed\/)([a-zA-Z0-9_-]{11})/, // youtube.com/embed/... 36 | ]; 37 | 38 | for (const pattern of patterns) { 39 | const match = urlOrId.match(pattern); 40 | if (match && match[1]) return match[1]; 41 | } 42 | 43 | return null; 44 | }; 45 | 46 | const handleSubmit = (e: React.FormEvent) => { 47 | e.preventDefault(); 48 | const trimmed = inputValue.trim(); 49 | 50 | // Validate domain first 51 | if (!isYouTubeUrl(trimmed)) { 52 | setErrorMessage("Please provide a valid YouTube URL or video ID."); 53 | return; 54 | } 55 | 56 | // Try extracting the ID 57 | const id = extractVideoId(trimmed); 58 | if (!id) { 59 | setErrorMessage("Unable to extract a valid YouTube video ID."); 60 | return; 61 | } 62 | 63 | // Clear any previous errors and submit 64 | setErrorMessage(""); 65 | onSubmit(id, maxResults); 66 | }; 67 | 68 | return ( 69 |
70 | {/* Form */} 71 |
72 |
73 | setInputValue(e.target.value)} 77 | placeholder="Enter URL (e.g. https://youtube.com/watch?v=FGMLcb9LX3Q)" 78 | required 79 | className="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring focus:ring-gray-400 focus:border-gray-400 bg-gray-800 text-white placeholder-gray-500 border-gray-600 text-xs" 80 | /> 81 | setMaxResults(Number(e.target.value))} 85 | placeholder="Max Results" 86 | min="1" 87 | max="100" 88 | className="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring focus:ring-gray-400 focus:border-gray-400 bg-gray-800 text-white placeholder-gray-500 border-gray-600" 89 | /> 90 |
91 | 98 |
99 | 100 | {/* Error Toast */} 101 | setErrorMessage("")} /> 102 |
103 | ); 104 | }; 105 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | import { CommentList } from "./components/CommentList"; 3 | import { CommentForm } from "./components/CommentForm"; 4 | import { Pagination } from "./components/Pagination"; 5 | import { ExportButton } from "./components/ExportButton"; 6 | import { LoadingSpinner } from "./components/LoadingSpinner"; 7 | import { Analytics } from "@vercel/analytics/react"; 8 | import Credits from "./components/Credits"; 9 | import Header from "./components/Header"; 10 | 11 | // Interface for individual comment structure 12 | interface Comment { 13 | id: string; 14 | authorDisplayName: string; 15 | authorProfileImageUrl: string; 16 | publishedAt: string; 17 | textDisplay: string; 18 | } 19 | 20 | // Interface for YouTube API response structure 21 | interface YouTubeApiResponse { 22 | result: { 23 | items: YouTubeCommentThread[]; 24 | nextPageToken?: string; 25 | }; 26 | } 27 | 28 | // Interface for YouTube comment thread structure 29 | interface YouTubeCommentThread { 30 | id: string; 31 | snippet: { 32 | topLevelComment: { 33 | snippet: { 34 | authorDisplayName: string; 35 | authorProfileImageUrl: string; 36 | publishedAt: string; 37 | textDisplay: string; 38 | }; 39 | }; 40 | }; 41 | } 42 | 43 | // Interface for parameters to commentThreads.list 44 | interface CommentThreadsListParams { 45 | part: "snippet"; 46 | videoId: string; 47 | maxResults: number; 48 | pageToken?: string; 49 | } 50 | 51 | // Extend Window interface to include gapi 52 | declare global { 53 | interface Window { 54 | gapi: { 55 | load: (api: string, callback: () => void) => void; 56 | client: { 57 | init: (args: { 58 | apiKey: string; 59 | discoveryDocs: string[]; 60 | }) => Promise; 61 | youtube: { 62 | commentThreads: { 63 | list: ( 64 | params: CommentThreadsListParams, 65 | ) => Promise; 66 | }; 67 | }; 68 | }; 69 | }; 70 | } 71 | } 72 | 73 | export default function App() { 74 | const [comments, setComments] = useState([]); 75 | const [nextPageToken, setNextPageToken] = useState(""); 76 | const [videoId, setVideoId] = useState(""); 77 | const [loading, setLoading] = useState(false); 78 | const [error, setError] = useState(""); 79 | const [maxResults, setMaxResults] = useState(100); 80 | 81 | useEffect(() => { 82 | const initializeGapi = async () => { 83 | try { 84 | await new Promise((resolve) => { 85 | window.gapi.load("client", resolve); 86 | }); 87 | await window.gapi.client.init({ 88 | apiKey: import.meta.env.VITE_YOUTUBE_API_KEY, 89 | discoveryDocs: [ 90 | "https://www.googleapis.com/discovery/v1/apis/youtube/v3/rest", 91 | ], 92 | }); 93 | console.log("GAPI client loaded for API"); 94 | } catch (err) { 95 | console.error("Error loading GAPI client for API", err); 96 | setError("Failed to load YouTube API. Please try again later."); 97 | } 98 | }; 99 | 100 | initializeGapi(); 101 | }, []); 102 | 103 | // Watch for videoId changes and load comments when it updates 104 | useEffect(() => { 105 | if (videoId) { 106 | loadComments(maxResults); 107 | } 108 | }, [videoId]); 109 | 110 | const loadComments = async (maxResults: number, pageToken: string = "") => { 111 | setLoading(true); 112 | setError(""); 113 | 114 | try { 115 | const response = await window.gapi.client.youtube.commentThreads.list({ 116 | part: "snippet", 117 | videoId: videoId, 118 | maxResults: maxResults, 119 | pageToken: pageToken, 120 | }); 121 | 122 | const newComments: Comment[] = response.result.items.map( 123 | (item: YouTubeCommentThread) => ({ 124 | id: item.id, 125 | authorDisplayName: 126 | item.snippet.topLevelComment.snippet.authorDisplayName, 127 | authorProfileImageUrl: 128 | item.snippet.topLevelComment.snippet.authorProfileImageUrl, 129 | publishedAt: item.snippet.topLevelComment.snippet.publishedAt, 130 | textDisplay: item.snippet.topLevelComment.snippet.textDisplay, 131 | }), 132 | ); 133 | 134 | setComments((prevComments) => [...prevComments, ...newComments]); 135 | setNextPageToken(response.result.nextPageToken || ""); 136 | } catch (err) { 137 | console.error("Error fetching comments", err); 138 | setError("Failed to fetch comments. Please try again."); 139 | } finally { 140 | setLoading(false); 141 | } 142 | }; 143 | 144 | const handleSubmit = (newVideoId: string, maxResults: number) => { 145 | setVideoId(newVideoId); // Update the videoId and let useEffect handle loading comments 146 | setComments([]); // Reset comments 147 | setNextPageToken(""); // Reset pagination 148 | setMaxResults(maxResults); // Store maxResults for later use 149 | }; 150 | 151 | const handleLoadMore = () => { 152 | loadComments(maxResults, nextPageToken); 153 | }; 154 | 155 | return ( 156 | <> 157 | 158 |
159 |
160 | 161 | {loading && } 162 | {error &&

{error}

} 163 | 164 | 165 | 166 |
167 | 168 | 169 | ); 170 | } 171 | --------------------------------------------------------------------------------