mirror of
https://github.com/FranP-code/classify_saved_videos_yt.git
synced 2025-10-13 00:32:25 +00:00
Add dark mode, tooltips, and fix column alignment
This commit is contained in:
committed by
GitHub
parent
c34044d8a1
commit
b318550a29
73
web/src/components/ThemeProvider.tsx
Normal file
73
web/src/components/ThemeProvider.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import React, { createContext, useContext, useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
type Theme = 'dark' | 'light' | 'system'
|
||||||
|
|
||||||
|
type ThemeProviderProps = {
|
||||||
|
children: React.ReactNode
|
||||||
|
defaultTheme?: Theme
|
||||||
|
storageKey?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ThemeProviderState = {
|
||||||
|
theme: Theme
|
||||||
|
setTheme: (theme: Theme) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: ThemeProviderState = {
|
||||||
|
theme: 'system',
|
||||||
|
setTheme: () => null,
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
|
||||||
|
|
||||||
|
export function ThemeProvider({
|
||||||
|
children,
|
||||||
|
defaultTheme = 'system',
|
||||||
|
storageKey = 'vite-ui-theme',
|
||||||
|
...props
|
||||||
|
}: ThemeProviderProps) {
|
||||||
|
const [theme, setTheme] = useState<Theme>(
|
||||||
|
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = window.document.documentElement
|
||||||
|
|
||||||
|
root.classList.remove('light', 'dark')
|
||||||
|
|
||||||
|
if (theme === 'system') {
|
||||||
|
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)')
|
||||||
|
.matches
|
||||||
|
? 'dark'
|
||||||
|
: 'light'
|
||||||
|
|
||||||
|
root.classList.add(systemTheme)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
root.classList.add(theme)
|
||||||
|
}, [theme])
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
theme,
|
||||||
|
setTheme: (theme: Theme) => {
|
||||||
|
localStorage.setItem(storageKey, theme)
|
||||||
|
setTheme(theme)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeProviderContext.Provider {...props} value={value}>
|
||||||
|
{children}
|
||||||
|
</ThemeProviderContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTheme = () => {
|
||||||
|
const context = useContext(ThemeProviderContext)
|
||||||
|
|
||||||
|
if (context === undefined)
|
||||||
|
throw new Error('useTheme must be used within a ThemeProvider')
|
||||||
|
|
||||||
|
return context
|
||||||
|
}
|
||||||
21
web/src/components/ThemeToggle.tsx
Normal file
21
web/src/components/ThemeToggle.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Moon, Sun } from 'lucide-react'
|
||||||
|
import { Button } from './ui/button'
|
||||||
|
import { useTheme } from './ThemeProvider'
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const { theme, setTheme } = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}
|
||||||
|
className="relative"
|
||||||
|
>
|
||||||
|
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||||
|
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||||
|
<span className="sr-only">Toggle theme</span>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ import { VirtualTable } from './VirtualTable';
|
|||||||
import { SearchAndFilter } from './SearchAndFilter';
|
import { SearchAndFilter } from './SearchAndFilter';
|
||||||
import { StatsOverview } from './StatsOverview';
|
import { StatsOverview } from './StatsOverview';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
|
import { ThemeProvider } from './ThemeProvider';
|
||||||
|
import { ThemeToggle } from './ThemeToggle';
|
||||||
import type { VideoData } from '../types/video';
|
import type { VideoData } from '../types/video';
|
||||||
import { searchVideos, filterVideos } from '../utils/search';
|
import { searchVideos, filterVideos } from '../utils/search';
|
||||||
|
|
||||||
@@ -56,74 +58,79 @@ export default function VideoClassifierApp({ videos }: { videos: VideoData[] })
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<ThemeProvider defaultTheme="system" storageKey="video-classifier-theme">
|
||||||
<div className="container mx-auto px-6 py-8 space-y-8">
|
<div className="min-h-screen bg-background">
|
||||||
{/* Header */}
|
<div className="container mx-auto px-6 py-8 space-y-8">
|
||||||
<div className="text-center space-y-4">
|
{/* Header */}
|
||||||
<h1 className="text-4xl font-bold bg-gradient-to-r from-primary to-primary/60 bg-clip-text text-transparent">
|
<div className="text-center space-y-4">
|
||||||
YouTube Video Classifier
|
<div className="flex items-center justify-center gap-4">
|
||||||
</h1>
|
<h1 className="text-4xl font-bold bg-gradient-to-r from-primary to-primary/60 bg-clip-text text-transparent">
|
||||||
<p className="text-muted-foreground max-w-2xl mx-auto">
|
YouTube Video Classifier
|
||||||
AI-powered video classification and management platform. Search, filter, and organize your YouTube video collection with intelligent categorization.
|
</h1>
|
||||||
</p>
|
<ThemeToggle />
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats Overview */}
|
|
||||||
{videos.length > 0 && (
|
|
||||||
<StatsOverview data={videos} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Search and Filter */}
|
|
||||||
{videos.length > 0 && (
|
|
||||||
<SearchAndFilter
|
|
||||||
data={videos}
|
|
||||||
searchTerm={searchTerm}
|
|
||||||
onSearchChange={setSearchTerm}
|
|
||||||
filters={filters}
|
|
||||||
onFilterChange={setFilters}
|
|
||||||
totalResults={videos.length}
|
|
||||||
filteredResults={filteredData.length}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Results */}
|
|
||||||
{videos.length > 0 ? (
|
|
||||||
filteredData.length > 0 ? (
|
|
||||||
<VirtualTable data={filteredData} />
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<div className="text-6xl mb-4">🔍</div>
|
|
||||||
<h3 className="text-lg font-medium mb-2">No results found</h3>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Try adjusting your search terms or filters
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
<p className="text-muted-foreground max-w-2xl mx-auto">
|
||||||
) : (
|
AI-powered video classification and management platform. Search, filter, and organize your YouTube video collection with intelligent categorization.
|
||||||
<div className="text-center py-12">
|
|
||||||
<div className="text-6xl mb-4">📹</div>
|
|
||||||
<h3 className="text-lg font-medium mb-2">No videos found</h3>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Your CSV file appears to be empty
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Stats Overview */}
|
||||||
<div className="text-center text-sm text-muted-foreground border-t pt-8">
|
{videos.length > 0 && (
|
||||||
<p>
|
<StatsOverview data={videos} />
|
||||||
{videos.length} videos indexed
|
)}
|
||||||
</p>
|
|
||||||
<Button
|
{/* Search and Filter */}
|
||||||
variant="ghost"
|
{videos.length > 0 && (
|
||||||
size="sm"
|
<SearchAndFilter
|
||||||
onClick={() => window.location.reload}
|
data={videos}
|
||||||
className="mt-2"
|
searchTerm={searchTerm}
|
||||||
>
|
onSearchChange={setSearchTerm}
|
||||||
Refresh Data
|
filters={filters}
|
||||||
</Button>
|
onFilterChange={setFilters}
|
||||||
|
totalResults={videos.length}
|
||||||
|
filteredResults={filteredData.length}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
{videos.length > 0 ? (
|
||||||
|
filteredData.length > 0 ? (
|
||||||
|
<VirtualTable data={filteredData} />
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="text-6xl mb-4">🔍</div>
|
||||||
|
<h3 className="text-lg font-medium mb-2">No results found</h3>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Try adjusting your search terms or filters
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="text-6xl mb-4">📹</div>
|
||||||
|
<h3 className="text-lg font-medium mb-2">No videos found</h3>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Your CSV file appears to be empty
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="text-center text-sm text-muted-foreground border-t pt-8">
|
||||||
|
<p>
|
||||||
|
{videos.length} videos indexed
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => window.location.reload}
|
||||||
|
className="mt-2"
|
||||||
|
>
|
||||||
|
Refresh Data
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@ import type { VideoData } from '../types/video';
|
|||||||
import { formatDuration, formatDate } from '../utils/csvParser';
|
import { formatDuration, formatDate } from '../utils/csvParser';
|
||||||
import { Badge } from './ui/badge';
|
import { Badge } from './ui/badge';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
|
import { Tooltip } from './ui/tooltip';
|
||||||
|
|
||||||
interface VirtualTableProps {
|
interface VirtualTableProps {
|
||||||
data: VideoData[];
|
data: VideoData[];
|
||||||
@@ -25,15 +26,15 @@ export function VirtualTable({ data }: VirtualTableProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="border rounded-lg overflow-hidden">
|
<div className="border rounded-lg overflow-hidden">
|
||||||
{/* Table Header */}
|
{/* Table Header */}
|
||||||
<div className="bg-muted/50 border-b p-4">
|
<div className="bg-muted/50 border-b">
|
||||||
<div className="grid grid-cols-12 gap-4 text-sm font-medium text-muted-foreground">
|
<div className="grid grid-cols-12 gap-3 px-4 py-3 text-sm font-medium text-muted-foreground">
|
||||||
<div className="col-span-2">Video</div>
|
<div className="col-span-3 flex items-center">Video</div>
|
||||||
<div className="col-span-2">Channel</div>
|
<div className="col-span-2 flex items-center">Channel</div>
|
||||||
<div className="col-span-2">Classification</div>
|
<div className="col-span-2 flex items-center">Classification</div>
|
||||||
<div className="col-span-2">Language</div>
|
<div className="col-span-1 flex items-center">Language</div>
|
||||||
<div className="col-span-1">Duration</div>
|
<div className="col-span-1 flex items-center">Duration</div>
|
||||||
<div className="col-span-2">Date</div>
|
<div className="col-span-2 flex items-center">Date</div>
|
||||||
<div className="col-span-1">Actions</div>
|
<div className="col-span-1 flex items-center">Actions</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -69,51 +70,66 @@ export function VirtualTable({ data }: VirtualTableProps) {
|
|||||||
transform: `translateY(${virtualItem.start}px)`,
|
transform: `translateY(${virtualItem.start}px)`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="border-b p-4 hover:bg-muted/50 transition-colors">
|
<div className="border-b hover:bg-muted/50 transition-colors">
|
||||||
<div className="grid grid-cols-12 gap-4 items-center">
|
<div className="grid grid-cols-12 gap-3 px-4 py-3 items-center min-h-[80px]">
|
||||||
{/* Video Info */}
|
{/* Video Info */}
|
||||||
<div className="col-span-2 space-y-1">
|
<div className="col-span-3 space-y-2 min-w-0">
|
||||||
<h3 className="font-medium text-sm leading-tight line-clamp-2">
|
<Tooltip content={video.video_title}>
|
||||||
{video.video_title}
|
<h3 className="font-medium text-sm leading-tight truncate">
|
||||||
</h3>
|
{video.video_title}
|
||||||
|
</h3>
|
||||||
|
</Tooltip>
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{video.detailed_subtags.split(',').slice(0, 3).map((tag, i) => (
|
{video.detailed_subtags.split(',').slice(0, 2).map((tag, i) => (
|
||||||
<Badge key={i} variant="secondary" className="text-xs">
|
<Tooltip key={i} content={tag.trim()}>
|
||||||
{tag.trim()}
|
<Badge variant="secondary" className="text-xs max-w-20 truncate">
|
||||||
</Badge>
|
{tag.trim()}
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
))}
|
))}
|
||||||
|
{video.detailed_subtags.split(',').length > 2 && (
|
||||||
|
<Tooltip content={`+${video.detailed_subtags.split(',').length - 2} more tags`}>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
+{video.detailed_subtags.split(',').length - 2}
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Channel */}
|
{/* Channel */}
|
||||||
<div className="col-span-2">
|
<div className="col-span-2 min-w-0">
|
||||||
<div className="text-sm font-medium">{video.channel_name}</div>
|
<Tooltip content={video.channel_name}>
|
||||||
<div className="text-xs text-muted-foreground">{video.playlist_name}</div>
|
<div className="text-sm font-medium truncate">{video.channel_name}</div>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip content={video.playlist_name}>
|
||||||
|
<div className="text-xs text-muted-foreground truncate">{video.playlist_name}</div>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Classification */}
|
{/* Classification */}
|
||||||
<div className="col-span-2">
|
<div className="col-span-2 min-w-0">
|
||||||
<Badge variant="default" className="text-xs">
|
<Badge variant="default" className="text-xs truncate max-w-full">
|
||||||
{video.classification}
|
{video.classification}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Language */}
|
{/* Language */}
|
||||||
<div className="col-span-2">
|
<div className="col-span-1 min-w-0">
|
||||||
<Badge variant="outline" className="text-xs">
|
<Badge variant="outline" className="text-xs truncate max-w-full">
|
||||||
{video.language}
|
{video.language}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Duration */}
|
{/* Duration */}
|
||||||
<div className="col-span-1 text-sm">
|
<div className="col-span-1 text-sm font-mono">
|
||||||
{formatDuration(video.video_length_seconds)}
|
{formatDuration(video.video_length_seconds)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Date */}
|
{/* Date */}
|
||||||
<div className="col-span-2 space-y-1">
|
<div className="col-span-2 space-y-1 min-w-0">
|
||||||
<div className="text-sm">{formatDate(video.video_date)}</div>
|
<div className="text-sm">{formatDate(video.video_date)}</div>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground truncate">
|
||||||
Added: {formatDate(video.timestamp)}
|
Added: {formatDate(video.timestamp)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -138,7 +154,7 @@ export function VirtualTable({ data }: VirtualTableProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="bg-muted/50 border-t p-4">
|
<div className="bg-muted/50 border-t px-4 py-3">
|
||||||
<div className="text-sm text-muted-foreground text-center">
|
<div className="text-sm text-muted-foreground text-center">
|
||||||
Showing {data.length} videos
|
Showing {data.length} videos
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
36
web/src/components/ui/tooltip.tsx
Normal file
36
web/src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
interface TooltipProps {
|
||||||
|
content: string
|
||||||
|
children: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Tooltip({ content, children, className }: TooltipProps) {
|
||||||
|
const [isVisible, setIsVisible] = React.useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative inline-block">
|
||||||
|
<div
|
||||||
|
onMouseEnter={() => setIsVisible(true)}
|
||||||
|
onMouseLeave={() => setIsVisible(false)}
|
||||||
|
className="cursor-help"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
{isVisible && (
|
||||||
|
<div className={cn(
|
||||||
|
"absolute z-50 px-3 py-2 text-sm text-white bg-gray-900 dark:bg-gray-700 rounded-lg shadow-lg",
|
||||||
|
"bottom-full left-1/2 transform -translate-x-1/2 mb-2",
|
||||||
|
"max-w-xs break-words",
|
||||||
|
"before:content-[''] before:absolute before:top-full before:left-1/2 before:transform before:-translate-x-1/2",
|
||||||
|
"before:border-4 before:border-transparent before:border-t-gray-900 dark:before:border-t-gray-700",
|
||||||
|
className
|
||||||
|
)}>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user