From ed8ad48310019a3e7da5bad6fe2514e8145f2489 Mon Sep 17 00:00:00 2001 From: Francisco Pessano <76450203+FranP-code@users.noreply.github.com> Date: Sat, 12 Jul 2025 00:37:07 -0300 Subject: [PATCH] YouTube Video Classifier Platform --- web/src/components/SearchAndFilter.tsx | 129 ++++++++++++++ web/src/components/StatsOverview.tsx | 41 +++++ web/src/components/VideoClassifierApp.tsx | 195 ++++++++++++++++++++++ web/src/components/VirtualTable.tsx | 163 ++++++++++++++++++ web/src/components/ui/badge.tsx | 36 ++++ web/src/components/ui/input.tsx | 25 +++ web/src/components/ui/select.tsx | 23 +++ web/src/env.d.ts | 2 + web/src/pages/api/videos.ts | 51 ++++++ web/src/pages/index.astro | 33 ++-- web/src/types/video.ts | 22 +++ web/src/utils/csvParser.ts | 41 +++++ web/src/utils/search.ts | 57 +++++++ 13 files changed, 803 insertions(+), 15 deletions(-) create mode 100644 web/src/components/SearchAndFilter.tsx create mode 100644 web/src/components/StatsOverview.tsx create mode 100644 web/src/components/VideoClassifierApp.tsx create mode 100644 web/src/components/VirtualTable.tsx create mode 100644 web/src/components/ui/badge.tsx create mode 100644 web/src/components/ui/input.tsx create mode 100644 web/src/components/ui/select.tsx create mode 100644 web/src/env.d.ts create mode 100644 web/src/pages/api/videos.ts create mode 100644 web/src/types/video.ts create mode 100644 web/src/utils/csvParser.ts create mode 100644 web/src/utils/search.ts diff --git a/web/src/components/SearchAndFilter.tsx b/web/src/components/SearchAndFilter.tsx new file mode 100644 index 0000000..9f80e16 --- /dev/null +++ b/web/src/components/SearchAndFilter.tsx @@ -0,0 +1,129 @@ +import React from 'react'; +import { Input } from './ui/input'; +import { Select } from './ui/select'; +import { Button } from './ui/button'; +import { Badge } from './ui/badge'; +import type { VideoData } from '../types/video'; +import { getUniqueValues } from '../utils/search'; + +interface SearchAndFilterProps { + data: VideoData[]; + searchTerm: string; + onSearchChange: (term: string) => void; + filters: { + classification: string; + language: string; + playlist_name: string; + }; + onFilterChange: (filters: any) => void; + totalResults: number; + filteredResults: number; +} + +export function SearchAndFilter({ + data, + searchTerm, + onSearchChange, + filters, + onFilterChange, + totalResults, + filteredResults +}: SearchAndFilterProps) { + const classifications = getUniqueValues(data, 'classification'); + const languages = getUniqueValues(data, 'language'); + const playlists = getUniqueValues(data, 'playlist_name'); + + const clearFilters = () => { + onSearchChange(''); + onFilterChange({ + classification: 'all', + language: 'all', + playlist_name: 'all' + }); + }; + + const activeFiltersCount = Object.values(filters).filter(value => value !== 'all').length + (searchTerm ? 1 : 0); + + return ( +
+
+
+

Search & Filter

+

+ {filteredResults} of {totalResults} videos + {activeFiltersCount > 0 && ( + + {activeFiltersCount} filter{activeFiltersCount !== 1 ? 's' : ''} active + + )} +

+
+ {activeFiltersCount > 0 && ( + + )} +
+ +
+ {/* Search */} +
+ + onSearchChange(e.target.value)} + /> +
+ + {/* Classification Filter */} +
+ + +
+ + {/* Language Filter */} +
+ + +
+ + {/* Playlist Filter */} +
+ + +
+
+
+ ); +} \ No newline at end of file diff --git a/web/src/components/StatsOverview.tsx b/web/src/components/StatsOverview.tsx new file mode 100644 index 0000000..827f89a --- /dev/null +++ b/web/src/components/StatsOverview.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { Badge } from './ui/badge'; +import type { VideoData } from '../types/video'; +import { getUniqueValues } from '../utils/search'; + +interface StatsOverviewProps { + data: VideoData[]; +} + +export function StatsOverview({ data }: StatsOverviewProps) { + const totalVideos = data.length; + const totalDuration = data.reduce((sum, video) => sum + video.video_length_seconds, 0); + const uniqueChannels = getUniqueValues(data, 'channel_name').length; + const uniqueClassifications = getUniqueValues(data, 'classification').length; + const uniqueLanguages = getUniqueValues(data, 'language').length; + + const formatTotalDuration = (seconds: number) => { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + return `${hours}h ${minutes}m`; + }; + + const stats = [ + { label: 'Total Videos', value: totalVideos.toLocaleString() }, + { label: 'Total Duration', value: formatTotalDuration(totalDuration) }, + { label: 'Channels', value: uniqueChannels }, + { label: 'Categories', value: uniqueClassifications }, + { label: 'Languages', value: uniqueLanguages }, + ]; + + return ( +
+ {stats.map((stat) => ( +
+
{stat.value}
+
{stat.label}
+
+ ))} +
+ ); +} \ No newline at end of file diff --git a/web/src/components/VideoClassifierApp.tsx b/web/src/components/VideoClassifierApp.tsx new file mode 100644 index 0000000..2801fa5 --- /dev/null +++ b/web/src/components/VideoClassifierApp.tsx @@ -0,0 +1,195 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import { VirtualTable } from './VirtualTable'; +import { SearchAndFilter } from './SearchAndFilter'; +import { StatsOverview } from './StatsOverview'; +import { Button } from './ui/button'; +import type { VideoData } from '../types/video'; +import { searchVideos, filterVideos } from '../utils/search'; + +interface ApiResponse { + videos?: VideoData[]; + error?: string; + message?: string; +} + +export default function VideoClassifierApp() { + const [data, setData] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [searchTerm, setSearchTerm] = useState(''); + const [filters, setFilters] = useState({ + classification: 'all', + language: 'all', + playlist_name: 'all' + }); + + // Fetch data + useEffect(() => { + const fetchData = async () => { + try { + setLoading(true); + const response = await fetch('/api/videos'); + const result: ApiResponse = await response.json(); + + if (!response.ok || result.error) { + setError(result.message || result.error || 'Failed to load data'); + setData([]); + } else { + setData(result.videos || []); + setError(null); + } + } catch (err) { + setError('Failed to fetch video data'); + setData([]); + } finally { + setLoading(false); + } + }; + + fetchData(); + }, []); + + // Filter and search data + const filteredData = useMemo(() => { + let result = data; + + // Apply filters + result = filterVideos(result, filters); + + // Apply search + result = searchVideos(result, searchTerm); + + return result; + }, [data, searchTerm, filters]); + + const refreshData = async () => { + setLoading(true); + setError(null); + + try { + const response = await fetch('/api/videos'); + const result: ApiResponse = await response.json(); + + if (!response.ok || result.error) { + setError(result.message || result.error || 'Failed to load data'); + setData([]); + } else { + setData(result.videos || []); + setError(null); + } + } catch (err) { + setError('Failed to fetch video data'); + setData([]); + } finally { + setLoading(false); + } + }; + + if (loading) { + return ( +
+
+
+

Loading video data...

+
+
+ ); + } + + if (error) { + return ( +
+
+
📁
+

CSV File Not Found

+

+ {error} +

+
+

To fix this:

+
    +
  1. Make sure video_classifications.csv exists in your project root
  2. +
  3. Run your YouTube classifier script to generate the CSV
  4. +
  5. Refresh this page
  6. +
+
+ +
+
+ ); + } + + return ( +
+
+ {/* Header */} +
+

+ YouTube Video Classifier +

+

+ AI-powered video classification and management platform. Search, filter, and organize your YouTube video collection with intelligent categorization. +

+
+ + {/* Stats Overview */} + {data.length > 0 && ( + + )} + + {/* Search and Filter */} + {data.length > 0 && ( + + )} + + {/* Results */} + {data.length > 0 ? ( + filteredData.length > 0 ? ( + + ) : ( +
+
🔍
+

No results found

+

+ Try adjusting your search terms or filters +

+
+ ) + ) : ( +
+
📹
+

No videos found

+

+ Your CSV file appears to be empty +

+
+ )} + + {/* Footer */} +
+

+ Powered by AI classification • {data.length} videos indexed +

+ +
+
+
+ ); +} \ No newline at end of file diff --git a/web/src/components/VirtualTable.tsx b/web/src/components/VirtualTable.tsx new file mode 100644 index 0000000..dbe0586 --- /dev/null +++ b/web/src/components/VirtualTable.tsx @@ -0,0 +1,163 @@ +import React, { useMemo, useRef, useState, useEffect } from 'react'; +import { Virtualizer, VirtualizerOptions } from '@tanstack/virtual-core'; +import type { VideoData } from '../types/video'; +import { formatDuration, formatDate } from '../utils/csvParser'; +import { Badge } from './ui/badge'; +import { Button } from './ui/button'; + +interface VirtualTableProps { + data: VideoData[]; +} + +export function VirtualTable({ data }: VirtualTableProps) { + const parentRef = useRef(null); + const [virtualizer, setVirtualizer] = useState | null>(null); + + useEffect(() => { + if (!parentRef.current) return; + + const virtualizerOptions: VirtualizerOptions = { + count: data.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 120, + overscan: 5, + }; + + const newVirtualizer = new Virtualizer(virtualizerOptions); + setVirtualizer(newVirtualizer); + + return () => { + newVirtualizer.destroy(); + }; + }, [data.length]); + + const items = virtualizer?.getVirtualItems() ?? []; + + if (!virtualizer) { + return
Loading...
; + } + + return ( +
+ {/* Table Header */} +
+
+
Video
+
Channel
+
Classification
+
Language
+
Duration
+
Date
+
Actions
+
+
+ + {/* Virtual Container */} +
+
+ {items.map((item) => { + const video = data[item.index]; + if (!video) return null; + + return ( +
+
+
+ {/* Video Info */} +
+

+ {video.video_title} +

+
+ {video.detailed_subtags.split(',').slice(0, 3).map((tag, i) => ( + + {tag.trim()} + + ))} +
+
+ + {/* Channel */} +
+
{video.channel_name}
+
{video.playlist_name}
+
+ + {/* Classification */} +
+ + {video.classification} + +
+ + {/* Language */} +
+ + {video.language} + +
+ + {/* Duration */} +
+ {formatDuration(video.video_length_seconds)} +
+ + {/* Date */} +
+
{formatDate(video.video_date)}
+
+ Added: {formatDate(video.timestamp)} +
+
+ + {/* Actions */} +
+ +
+
+
+
+ ); + })} +
+
+ + {/* Footer */} +
+
+ Showing {data.length} videos +
+
+
+ ); +} \ No newline at end of file diff --git a/web/src/components/ui/badge.tsx b/web/src/components/ui/badge.tsx new file mode 100644 index 0000000..9c20844 --- /dev/null +++ b/web/src/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } \ No newline at end of file diff --git a/web/src/components/ui/input.tsx b/web/src/components/ui/input.tsx new file mode 100644 index 0000000..7030e99 --- /dev/null +++ b/web/src/components/ui/input.tsx @@ -0,0 +1,25 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +export interface InputProps + extends React.InputHTMLAttributes {} + +const Input = React.forwardRef( + ({ className, type, ...props }, ref) => { + return ( + + ) + } +) +Input.displayName = "Input" + +export { Input } \ No newline at end of file diff --git a/web/src/components/ui/select.tsx b/web/src/components/ui/select.tsx new file mode 100644 index 0000000..3ca61aa --- /dev/null +++ b/web/src/components/ui/select.tsx @@ -0,0 +1,23 @@ +import * as React from "react" +import { cn } from "@/lib/utils" + +const Select = React.forwardRef< + HTMLSelectElement, + React.SelectHTMLAttributes +>(({ className, children, ...props }, ref) => { + return ( + + ) +}) +Select.displayName = "Select" + +export { Select } \ No newline at end of file diff --git a/web/src/env.d.ts b/web/src/env.d.ts new file mode 100644 index 0000000..c13bd73 --- /dev/null +++ b/web/src/env.d.ts @@ -0,0 +1,2 @@ +/// +/// \ No newline at end of file diff --git a/web/src/pages/api/videos.ts b/web/src/pages/api/videos.ts new file mode 100644 index 0000000..6f96853 --- /dev/null +++ b/web/src/pages/api/videos.ts @@ -0,0 +1,51 @@ +import type { APIRoute } from 'astro'; +import { readFileSync, existsSync } from 'fs'; +import { join } from 'path'; +import { parseCSV } from '../../utils/csvParser'; + +export const GET: APIRoute = async () => { + try { + // Look for CSV file in the project root (outside web folder) + const csvPath = join(process.cwd(), '..', 'video_classifications.csv'); + + if (!existsSync(csvPath)) { + return new Response( + JSON.stringify({ + error: 'CSV file not found', + message: 'video_classifications.csv not found in project root', + path: csvPath + }), + { + status: 404, + headers: { + 'Content-Type': 'application/json', + }, + } + ); + } + + const csvContent = readFileSync(csvPath, 'utf-8'); + const videos = parseCSV(csvContent); + + return new Response(JSON.stringify({ videos }), { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + }); + } catch (error) { + console.error('Error reading CSV file:', error); + return new Response( + JSON.stringify({ + error: 'Failed to read CSV file', + message: error instanceof Error ? error.message : 'Unknown error' + }), + { + status: 500, + headers: { + 'Content-Type': 'application/json', + }, + } + ); + } +}; \ No newline at end of file diff --git a/web/src/pages/index.astro b/web/src/pages/index.astro index e9a590c..b77e7e1 100644 --- a/web/src/pages/index.astro +++ b/web/src/pages/index.astro @@ -1,27 +1,30 @@ --- import '../styles/global.css'; -// Component Imports -import Button from '../components/Button.astro'; -import {Button as ShadcnButton} from '../components/ui/button.tsx'; - -// Full Astro Component Syntax: -// https://docs.astro.build/basics/astro-components/ --- - + - Astro + TailwindCSS + YouTube Video Classifier + - -
- - Markdown is also supported... - Shadcn Button -
+ +
+ + - + \ No newline at end of file diff --git a/web/src/types/video.ts b/web/src/types/video.ts new file mode 100644 index 0000000..5463e4e --- /dev/null +++ b/web/src/types/video.ts @@ -0,0 +1,22 @@ +export interface VideoData { + video_title: string; + video_url: string; + thumbnail_url: string; + classification: string; + language: string; + channel_name: string; + channel_link: string; + video_length_seconds: number; + video_date: string; + detailed_subtags: string; + playlist_name: string; + playlist_link: string; + image_data: string; + timestamp: string; +} + +export interface FilterOptions { + classification: string[]; + language: string[]; + playlist_name: string[]; +} \ No newline at end of file diff --git a/web/src/utils/csvParser.ts b/web/src/utils/csvParser.ts new file mode 100644 index 0000000..d54f9ee --- /dev/null +++ b/web/src/utils/csvParser.ts @@ -0,0 +1,41 @@ +import Papa from 'papaparse'; +import type { VideoData } from '../types/video'; + +export function parseCSV(csvText: string): VideoData[] { + const result = Papa.parse(csvText, { + header: true, + skipEmptyLines: true, + transform: (value, field) => { + // Convert numeric fields + if (field === 'video_length_seconds') { + return parseInt(value) || 0; + } + return value; + } + }); + + if (result.errors.length > 0) { + console.warn('CSV parsing errors:', result.errors); + } + + return result.data; +} + +export function formatDuration(seconds: number): string { + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`; +} + +export function formatDate(dateString: string): string { + try { + const date = new Date(dateString); + return date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric' + }); + } catch { + return dateString; + } +} \ No newline at end of file diff --git a/web/src/utils/search.ts b/web/src/utils/search.ts new file mode 100644 index 0000000..fd8be21 --- /dev/null +++ b/web/src/utils/search.ts @@ -0,0 +1,57 @@ +import type { VideoData } from '../types/video'; + +export function searchVideos(videos: VideoData[], searchTerm: string): VideoData[] { + if (!searchTerm.trim()) return videos; + + const term = searchTerm.toLowerCase(); + + return videos.filter(video => { + const searchableFields = [ + video.video_title, + video.channel_name, + video.detailed_subtags, + video.classification, + video.language, + video.playlist_name, + video.video_length_seconds.toString(), + formatDateForSearch(video.video_date), + formatDateForSearch(video.timestamp) + ]; + + return searchableFields.some(field => + field && field.toLowerCase().includes(term) + ); + }); +} + +function formatDateForSearch(dateString: string): string { + try { + const date = new Date(dateString); + return date.toLocaleDateString('en-US'); + } catch { + return dateString; + } +} + +export function filterVideos( + videos: VideoData[], + filters: { classification?: string; language?: string; playlist_name?: string } +): VideoData[] { + return videos.filter(video => { + if (filters.classification && filters.classification !== 'all' && video.classification !== filters.classification) { + return false; + } + if (filters.language && filters.language !== 'all' && video.language !== filters.language) { + return false; + } + if (filters.playlist_name && filters.playlist_name !== 'all' && video.playlist_name !== filters.playlist_name) { + return false; + } + return true; + }); +} + +export function getUniqueValues(videos: VideoData[], field: keyof VideoData): string[] { + const values = videos.map(video => video[field] as string).filter(Boolean); + return [...new Set(values)].sort(); +} \ No newline at end of file