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 { StatsOverview } from './StatsOverview';
|
||||
import { Button } from './ui/button';
|
||||
import { ThemeProvider } from './ThemeProvider';
|
||||
import { ThemeToggle } from './ThemeToggle';
|
||||
import type { VideoData } from '../types/video';
|
||||
import { searchVideos, filterVideos } from '../utils/search';
|
||||
|
||||
@@ -56,74 +58,79 @@ export default function VideoClassifierApp({ videos }: { videos: VideoData[] })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="container mx-auto px-6 py-8 space-y-8">
|
||||
{/* Header */}
|
||||
<div className="text-center space-y-4">
|
||||
<h1 className="text-4xl font-bold bg-gradient-to-r from-primary to-primary/60 bg-clip-text text-transparent">
|
||||
YouTube Video Classifier
|
||||
</h1>
|
||||
<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.
|
||||
</p>
|
||||
</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>
|
||||
<ThemeProvider defaultTheme="system" storageKey="video-classifier-theme">
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="container mx-auto px-6 py-8 space-y-8">
|
||||
{/* Header */}
|
||||
<div className="text-center space-y-4">
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<h1 className="text-4xl font-bold bg-gradient-to-r from-primary to-primary/60 bg-clip-text text-transparent">
|
||||
YouTube Video Classifier
|
||||
</h1>
|
||||
<ThemeToggle />
|
||||
</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 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.
|
||||
</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>
|
||||
{/* 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 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>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type { VideoData } from '../types/video';
|
||||
import { formatDuration, formatDate } from '../utils/csvParser';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Button } from './ui/button';
|
||||
import { Tooltip } from './ui/tooltip';
|
||||
|
||||
interface VirtualTableProps {
|
||||
data: VideoData[];
|
||||
@@ -25,15 +26,15 @@ export function VirtualTable({ data }: VirtualTableProps) {
|
||||
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-2">Video</div>
|
||||
<div className="col-span-2">Channel</div>
|
||||
<div className="col-span-2">Classification</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>
|
||||
<div className="bg-muted/50 border-b">
|
||||
<div className="grid grid-cols-12 gap-3 px-4 py-3 text-sm font-medium text-muted-foreground">
|
||||
<div className="col-span-3 flex items-center">Video</div>
|
||||
<div className="col-span-2 flex items-center">Channel</div>
|
||||
<div className="col-span-2 flex items-center">Classification</div>
|
||||
<div className="col-span-1 flex items-center">Language</div>
|
||||
<div className="col-span-1 flex items-center">Duration</div>
|
||||
<div className="col-span-2 flex items-center">Date</div>
|
||||
<div className="col-span-1 flex items-center">Actions</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -69,51 +70,66 @@ export function VirtualTable({ data }: VirtualTableProps) {
|
||||
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-center">
|
||||
<div className="border-b hover:bg-muted/50 transition-colors">
|
||||
<div className="grid grid-cols-12 gap-3 px-4 py-3 items-center min-h-[80px]">
|
||||
{/* Video Info */}
|
||||
<div className="col-span-2 space-y-1">
|
||||
<h3 className="font-medium text-sm leading-tight line-clamp-2">
|
||||
{video.video_title}
|
||||
</h3>
|
||||
<div className="col-span-3 space-y-2 min-w-0">
|
||||
<Tooltip content={video.video_title}>
|
||||
<h3 className="font-medium text-sm leading-tight truncate">
|
||||
{video.video_title}
|
||||
</h3>
|
||||
</Tooltip>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{video.detailed_subtags.split(',').slice(0, 3).map((tag, i) => (
|
||||
<Badge key={i} variant="secondary" className="text-xs">
|
||||
{tag.trim()}
|
||||
</Badge>
|
||||
{video.detailed_subtags.split(',').slice(0, 2).map((tag, i) => (
|
||||
<Tooltip key={i} content={tag.trim()}>
|
||||
<Badge variant="secondary" className="text-xs max-w-20 truncate">
|
||||
{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>
|
||||
|
||||
{/* Channel */}
|
||||
<div className="col-span-2">
|
||||
<div className="text-sm font-medium">{video.channel_name}</div>
|
||||
<div className="text-xs text-muted-foreground">{video.playlist_name}</div>
|
||||
<div className="col-span-2 min-w-0">
|
||||
<Tooltip content={video.channel_name}>
|
||||
<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>
|
||||
|
||||
{/* Classification */}
|
||||
<div className="col-span-2">
|
||||
<Badge variant="default" className="text-xs">
|
||||
<div className="col-span-2 min-w-0">
|
||||
<Badge variant="default" className="text-xs truncate max-w-full">
|
||||
{video.classification}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Language */}
|
||||
<div className="col-span-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
<div className="col-span-1 min-w-0">
|
||||
<Badge variant="outline" className="text-xs truncate max-w-full">
|
||||
{video.language}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Duration */}
|
||||
<div className="col-span-1 text-sm">
|
||||
<div className="col-span-1 text-sm font-mono">
|
||||
{formatDuration(video.video_length_seconds)}
|
||||
</div>
|
||||
|
||||
{/* 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-xs text-muted-foreground">
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
Added: {formatDate(video.timestamp)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -138,7 +154,7 @@ export function VirtualTable({ data }: VirtualTableProps) {
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
Showing {data.length} videos
|
||||
</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