mirror of
https://github.com/FranP-code/classify_saved_videos_yt.git
synced 2025-10-13 00:32:25 +00:00
Refactor VideoClassifierApp to use props for videos and update VirtualTable to utilize @tanstack/react-virtual
This commit is contained in:
@@ -13,7 +13,7 @@
|
||||
"@astrojs/react": "^4.3.0",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@tailwindcss/vite": "^4.1.3",
|
||||
"@tanstack/virtual-core": "^3.13.12",
|
||||
"@tanstack/react-virtual": "^3.13.12",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
|
||||
16
web/pnpm-lock.yaml
generated
16
web/pnpm-lock.yaml
generated
@@ -20,9 +20,9 @@ importers:
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.1.3
|
||||
version: 4.1.11(vite@6.3.5(@types/node@24.0.13)(jiti@2.4.2)(lightningcss@1.30.1))
|
||||
'@tanstack/virtual-core':
|
||||
'@tanstack/react-virtual':
|
||||
specifier: ^3.13.12
|
||||
version: 3.13.12
|
||||
version: 3.13.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
'@types/canvas-confetti':
|
||||
specifier: ^1.9.0
|
||||
version: 1.9.0
|
||||
@@ -725,6 +725,12 @@ packages:
|
||||
peerDependencies:
|
||||
vite: ^5.2.0 || ^6 || ^7
|
||||
|
||||
'@tanstack/react-virtual@3.13.12':
|
||||
resolution: {integrity: sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
'@tanstack/virtual-core@3.13.12':
|
||||
resolution: {integrity: sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==}
|
||||
|
||||
@@ -2711,6 +2717,12 @@ snapshots:
|
||||
tailwindcss: 4.1.11
|
||||
vite: 6.3.5(@types/node@24.0.13)(jiti@2.4.2)(lightningcss@1.30.1)
|
||||
|
||||
'@tanstack/react-virtual@3.13.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
|
||||
dependencies:
|
||||
'@tanstack/virtual-core': 3.13.12
|
||||
react: 19.1.0
|
||||
react-dom: 19.1.0(react@19.1.0)
|
||||
|
||||
'@tanstack/virtual-core@3.13.12': {}
|
||||
|
||||
'@types/babel__core@7.20.5':
|
||||
|
||||
@@ -12,10 +12,7 @@ interface ApiResponse {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export default function VideoClassifierApp() {
|
||||
const [data, setData] = useState<VideoData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
export default function VideoClassifierApp({ videos }: { videos: VideoData[] }) {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [filters, setFilters] = useState({
|
||||
classification: 'all',
|
||||
@@ -23,35 +20,9 @@ export default function VideoClassifierApp() {
|
||||
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;
|
||||
let result = videos;
|
||||
|
||||
// Apply filters
|
||||
result = filterVideos(result, filters);
|
||||
@@ -60,50 +31,16 @@ export default function VideoClassifierApp() {
|
||||
result = searchVideos(result, searchTerm);
|
||||
|
||||
return result;
|
||||
}, [data, searchTerm, filters]);
|
||||
}, [videos, 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 (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center space-y-4">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
|
||||
<p className="text-muted-foreground">Loading video data...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
if (!videos) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-6">
|
||||
<div className="max-w-md text-center space-y-4">
|
||||
<div className="text-6xl">📁</div>
|
||||
<h1 className="text-2xl font-bold text-destructive">CSV File Not Found</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{error}
|
||||
It seems the <code className="bg-background px-1 rounded">video_classifications.csv</code> file is missing or not properly configured.
|
||||
</p>
|
||||
<div className="bg-muted p-4 rounded-lg text-sm text-left">
|
||||
<p className="font-medium mb-2">To fix this:</p>
|
||||
@@ -113,9 +50,6 @@ export default function VideoClassifierApp() {
|
||||
<li>Refresh this page</li>
|
||||
</ol>
|
||||
</div>
|
||||
<Button onClick={refreshData} className="mt-4">
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -135,25 +69,25 @@ export default function VideoClassifierApp() {
|
||||
</div>
|
||||
|
||||
{/* Stats Overview */}
|
||||
{data.length > 0 && (
|
||||
<StatsOverview data={data} />
|
||||
{videos.length > 0 && (
|
||||
<StatsOverview data={videos} />
|
||||
)}
|
||||
|
||||
{/* Search and Filter */}
|
||||
{data.length > 0 && (
|
||||
{videos.length > 0 && (
|
||||
<SearchAndFilter
|
||||
data={data}
|
||||
data={videos}
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
filters={filters}
|
||||
onFilterChange={setFilters}
|
||||
totalResults={data.length}
|
||||
totalResults={videos.length}
|
||||
filteredResults={filteredData.length}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{data.length > 0 ? (
|
||||
{videos.length > 0 ? (
|
||||
filteredData.length > 0 ? (
|
||||
<VirtualTable data={filteredData} />
|
||||
) : (
|
||||
@@ -178,12 +112,12 @@ export default function VideoClassifierApp() {
|
||||
{/* Footer */}
|
||||
<div className="text-center text-sm text-muted-foreground border-t pt-8">
|
||||
<p>
|
||||
Powered by AI classification • {data.length} videos indexed
|
||||
{videos.length} videos indexed
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={refreshData}
|
||||
onClick={() => window.location.reload}
|
||||
className="mt-2"
|
||||
>
|
||||
Refresh Data
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useMemo, useRef, useState, useEffect } from 'react';
|
||||
import { Virtualizer, type VirtualizerOptions } from '@tanstack/virtual-core';
|
||||
import React, { useRef } from 'react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import type { VideoData } from '../types/video';
|
||||
import { formatDuration, formatDate } from '../utils/csvParser';
|
||||
import { Badge } from './ui/badge';
|
||||
@@ -11,41 +11,26 @@ interface VirtualTableProps {
|
||||
|
||||
export function VirtualTable({ data }: VirtualTableProps) {
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
const [virtualizer, setVirtualizer] = useState<Virtualizer<HTMLDivElement, Element> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!parentRef.current) return;
|
||||
console.log(data);
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: data.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 120,
|
||||
overscan: 5,
|
||||
});
|
||||
|
||||
const virtualizerOptions: VirtualizerOptions<HTMLDivElement, Element> = {
|
||||
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 <div>Loading...</div>;
|
||||
}
|
||||
const virtualItems = rowVirtualizer.getVirtualItems();
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
{/* Table Header */}
|
||||
<div className="bg-muted/50 border-b p-4">
|
||||
<div className="grid grid-cols-12 gap-4 text-sm font-medium text-muted-foreground">
|
||||
<div className="col-span-3">Video</div>
|
||||
<div className="col-span-2">Video</div>
|
||||
<div className="col-span-2">Channel</div>
|
||||
<div className="col-span-2">Classification</div>
|
||||
<div className="col-span-1">Language</div>
|
||||
<div className="col-span-2">Language</div>
|
||||
<div className="col-span-1">Duration</div>
|
||||
<div className="col-span-2">Date</div>
|
||||
<div className="col-span-1">Actions</div>
|
||||
@@ -62,32 +47,32 @@ export function VirtualTable({ data }: VirtualTableProps) {
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: `${virtualizer.getTotalSize()}px`,
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{items.map((item) => {
|
||||
const video = data[item.index];
|
||||
{virtualItems.map((virtualItem) => {
|
||||
const video = data[virtualItem.index];
|
||||
console.log(data, virtualItem, video);
|
||||
if (!video) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.key}
|
||||
data-index={item.index}
|
||||
ref={virtualizer.measureElement}
|
||||
key={virtualItem.key}
|
||||
data-index={virtualItem.index}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${item.start}px)`,
|
||||
transform: `translateY(${virtualItem.start}px)`,
|
||||
}}
|
||||
>
|
||||
<div className="border-b p-4 hover:bg-muted/50 transition-colors">
|
||||
<div className="grid grid-cols-12 gap-4 items-start">
|
||||
<div className="grid grid-cols-12 gap-4 items-center">
|
||||
{/* Video Info */}
|
||||
<div className="col-span-3 space-y-1">
|
||||
<div className="col-span-2 space-y-1">
|
||||
<h3 className="font-medium text-sm leading-tight line-clamp-2">
|
||||
{video.video_title}
|
||||
</h3>
|
||||
@@ -114,7 +99,7 @@ export function VirtualTable({ data }: VirtualTableProps) {
|
||||
</div>
|
||||
|
||||
{/* Language */}
|
||||
<div className="col-span-1">
|
||||
<div className="col-span-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{video.language}
|
||||
</Badge>
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
---
|
||||
import VideoClassifierApp from '@/components/VideoClassifierApp';
|
||||
import '../styles/global.css';
|
||||
import { join } from 'path';
|
||||
import { readFileSync } from 'fs';
|
||||
import { parseCSV } from '@/utils/csvParser';
|
||||
|
||||
const csvPath = join(process.cwd(), '..', 'video_classifications.csv')
|
||||
const csvContent = readFileSync(csvPath, 'utf-8');
|
||||
const videos = parseCSV(csvContent);
|
||||
|
||||
---
|
||||
|
||||
<html lang="en">
|
||||
@@ -13,18 +22,6 @@ import '../styles/global.css';
|
||||
</head>
|
||||
|
||||
<body class="min-h-screen bg-background">
|
||||
<div id="app"></div>
|
||||
|
||||
<script>
|
||||
import VideoClassifierApp from '../components/VideoClassifierApp.tsx';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import React from 'react';
|
||||
|
||||
const container = document.getElementById('app');
|
||||
if (container) {
|
||||
const root = createRoot(container);
|
||||
root.render(React.createElement(VideoClassifierApp));
|
||||
}
|
||||
</script>
|
||||
<VideoClassifierApp client:load videos={videos} />
|
||||
</body>
|
||||
</html>
|
||||
@@ -39,6 +39,33 @@
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--font-sans: Poppins, sans-serif;
|
||||
--font-mono: Fira Code, monospace;
|
||||
--font-serif: Lora, serif;
|
||||
--radius: 0.4rem;
|
||||
--tracking-tighter: calc(var(--tracking-normal) - 0.05em);
|
||||
--tracking-tight: calc(var(--tracking-normal) - 0.025em);
|
||||
--tracking-wide: calc(var(--tracking-normal) + 0.025em);
|
||||
--tracking-wider: calc(var(--tracking-normal) + 0.05em);
|
||||
--tracking-widest: calc(var(--tracking-normal) + 0.1em);
|
||||
--tracking-normal: var(--tracking-normal);
|
||||
--shadow-2xl: var(--shadow-2xl);
|
||||
--shadow-xl: var(--shadow-xl);
|
||||
--shadow-lg: var(--shadow-lg);
|
||||
--shadow-md: var(--shadow-md);
|
||||
--shadow: var(--shadow);
|
||||
--shadow-sm: var(--shadow-sm);
|
||||
--shadow-xs: var(--shadow-xs);
|
||||
--shadow-2xs: var(--shadow-2xs);
|
||||
--spacing: var(--spacing);
|
||||
--letter-spacing: var(--letter-spacing);
|
||||
--shadow-offset-y: var(--shadow-offset-y);
|
||||
--shadow-offset-x: var(--shadow-offset-x);
|
||||
--shadow-spread: var(--shadow-spread);
|
||||
--shadow-blur: var(--shadow-blur);
|
||||
--shadow-opacity: var(--shadow-opacity);
|
||||
--color-shadow-color: var(--shadow-color);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
}
|
||||
|
||||
:root {
|
||||
@@ -74,6 +101,27 @@
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
--destructive-foreground: oklch(1.0000 0 0);
|
||||
--font-sans: Poppins, sans-serif;
|
||||
--font-serif: Lora, serif;
|
||||
--font-mono: Fira Code, monospace;
|
||||
--shadow-color: hsl(325.78 58.18% 56.86% / 0.5);
|
||||
--shadow-opacity: 1.0;
|
||||
--shadow-blur: 0px;
|
||||
--shadow-spread: 0px;
|
||||
--shadow-offset-x: 3px;
|
||||
--shadow-offset-y: 3px;
|
||||
--letter-spacing: 0em;
|
||||
--spacing: 0.25rem;
|
||||
--shadow-2xs: 3px 3px 0px 0px hsl(325.7800 58.1800% 56.8600% / 0.50);
|
||||
--shadow-xs: 3px 3px 0px 0px hsl(325.7800 58.1800% 56.8600% / 0.50);
|
||||
--shadow-sm: 3px 3px 0px 0px hsl(325.7800 58.1800% 56.8600% / 1.00), 3px 1px 2px -1px hsl(325.7800 58.1800% 56.8600% / 1.00);
|
||||
--shadow: 3px 3px 0px 0px hsl(325.7800 58.1800% 56.8600% / 1.00), 3px 1px 2px -1px hsl(325.7800 58.1800% 56.8600% / 1.00);
|
||||
--shadow-md: 3px 3px 0px 0px hsl(325.7800 58.1800% 56.8600% / 1.00), 3px 2px 4px -1px hsl(325.7800 58.1800% 56.8600% / 1.00);
|
||||
--shadow-lg: 3px 3px 0px 0px hsl(325.7800 58.1800% 56.8600% / 1.00), 3px 4px 6px -1px hsl(325.7800 58.1800% 56.8600% / 1.00);
|
||||
--shadow-xl: 3px 3px 0px 0px hsl(325.7800 58.1800% 56.8600% / 1.00), 3px 8px 10px -1px hsl(325.7800 58.1800% 56.8600% / 1.00);
|
||||
--shadow-2xl: 3px 3px 0px 0px hsl(325.7800 58.1800% 56.8600% / 2.50);
|
||||
--tracking-normal: 0em;
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -108,6 +156,27 @@
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
--destructive-foreground: oklch(0.2497 0.0305 234.1628);
|
||||
--radius: 0.4rem;
|
||||
--font-sans: Poppins, sans-serif;
|
||||
--font-serif: Lora, serif;
|
||||
--font-mono: Fira Code, monospace;
|
||||
--shadow-color: #324859;
|
||||
--shadow-opacity: 1.0;
|
||||
--shadow-blur: 0px;
|
||||
--shadow-spread: 0px;
|
||||
--shadow-offset-x: 3px;
|
||||
--shadow-offset-y: 3px;
|
||||
--letter-spacing: 0em;
|
||||
--spacing: 0.25rem;
|
||||
--shadow-2xs: 3px 3px 0px 0px hsl(206.1538 28.0576% 27.2549% / 0.50);
|
||||
--shadow-xs: 3px 3px 0px 0px hsl(206.1538 28.0576% 27.2549% / 0.50);
|
||||
--shadow-sm: 3px 3px 0px 0px hsl(206.1538 28.0576% 27.2549% / 1.00), 3px 1px 2px -1px hsl(206.1538 28.0576% 27.2549% / 1.00);
|
||||
--shadow: 3px 3px 0px 0px hsl(206.1538 28.0576% 27.2549% / 1.00), 3px 1px 2px -1px hsl(206.1538 28.0576% 27.2549% / 1.00);
|
||||
--shadow-md: 3px 3px 0px 0px hsl(206.1538 28.0576% 27.2549% / 1.00), 3px 2px 4px -1px hsl(206.1538 28.0576% 27.2549% / 1.00);
|
||||
--shadow-lg: 3px 3px 0px 0px hsl(206.1538 28.0576% 27.2549% / 1.00), 3px 4px 6px -1px hsl(206.1538 28.0576% 27.2549% / 1.00);
|
||||
--shadow-xl: 3px 3px 0px 0px hsl(206.1538 28.0576% 27.2549% / 1.00), 3px 8px 10px -1px hsl(206.1538 28.0576% 27.2549% / 1.00);
|
||||
--shadow-2xl: 3px 3px 0px 0px hsl(206.1538 28.0576% 27.2549% / 2.50);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@@ -116,6 +185,7 @@
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
letter-spacing: var(--tracking-normal);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user