├── 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 |
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 |
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 |

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 |
45 |
46 |
47 |
48 |
49 |
--------------------------------------------------------------------------------
/src/components/Credits.tsx:
--------------------------------------------------------------------------------
1 | export default function Credits() {
2 | return (
3 |
9 |
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 | [](https://opensource.org/licenses/MIT)
6 | [](https://developers.google.com/youtube/v3)
7 | [](https://reactjs.org/)
8 | [](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 | 
35 | 
36 | 
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 |
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 |
--------------------------------------------------------------------------------