Files
Trackeep/frontend/src/pages/Youtube.tsx
T
Tomas Dvorak d27cf14110 first test
2026-02-08 14:14:55 +01:00

1275 lines
50 KiB
TypeScript

import { createSignal, For, Show, onMount } from 'solid-js';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { VideoPreviewModal } from '@/components/ui/VideoPreviewModal';
import { getMockVideos } from '@/lib/mockData';
import { getAuthHeaders } from '@/lib/auth';
import {
IconAlertCircle
} from '@tabler/icons-solidjs';
type TabType = 'search' | 'predefined' | 'bookmarked';
interface YouTubeVideo {
video_id: string;
channel_name: string;
url: string;
title: string;
duration?: string;
published_at?: string;
view_count?: string;
category?: string;
}
interface FeaturedChannel {
id: string;
name: string;
channel_id: string;
description?: string;
}
// VideoCard component
interface VideoCardProps {
video: YouTubeVideo;
onPreview: (video: YouTubeVideo) => void;
onSave?: (video: YouTubeVideo) => void;
}
const VideoCard = (props: VideoCardProps) => (
<Card class="overflow-hidden hover:shadow-lg transition-shadow duration-200 cursor-pointer group">
{/* Thumbnail */}
<div class="relative aspect-video bg-muted overflow-hidden">
<img
src={`https://img.youtube.com/vi/${props.video.video_id}/maxresdefault.jpg`}
alt={props.video.title}
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
onError={(e) => {
// Fallback to default thumbnail if maxresdefault fails
(e.target as HTMLImageElement).src = `https://img.youtube.com/vi/${props.video.video_id}/hqdefault.jpg`;
}}
/>
<div class="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors duration-200"></div>
{/* Play button overlay */}
<div
class="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-200 cursor-pointer"
onClick={() => props.onPreview(props.video)}
role="button"
aria-label="Play video"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
props.onPreview(props.video);
}
}}
>
<div class="w-16 h-16 bg-black/70 rounded-full flex items-center justify-center shadow-lg">
<svg class="w-6 h-6 text-white ml-1" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
</div>
</div>
</div>
{/* Video Info */}
<div class="p-4">
<h3 class="font-semibold text-base mb-2 line-clamp-2 leading-tight">
{props.video.title}
</h3>
<p class="text-sm text-muted-foreground mb-2">
{props.video.channel_name}
</p>
<div class="flex items-center gap-4 text-xs text-muted-foreground mb-2">
<span>Views: {props.video.view_count}</span>
<span>Published: {props.video.published_at}</span>
</div>
<p class="text-xs text-muted-foreground line-clamp-2">
Video from {props.video.channel_name}
</p>
<div class="mt-3 flex items-center justify-between">
<span class="text-xs text-muted-foreground">
YouTube Video
</span>
<div class="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => props.onPreview(props.video)}
>
Preview
</Button>
{props.onSave && (
<Button
size="sm"
onClick={() => props.onSave?.(props.video)}
>
Save
</Button>
)}
</div>
</div>
</div>
</Card>
);
export const Youtube = () => {
const [activeTab, setActiveTab] = createSignal<TabType>('search');
const [searchQuery, setSearchQuery] = createSignal('');
const [videos, setVideos] = createSignal<YouTubeVideo[]>([]);
const [predefinedVideos, setPredefinedVideos] = createSignal<YouTubeVideo[]>([]);
const [savedVideos, setSavedVideos] = createSignal<YouTubeVideo[]>([]);
const [isLoading, setIsLoading] = createSignal(false);
const [isLoadingPredefined, setIsLoadingPredefined] = createSignal(false);
const [isLoadingSaved, setIsLoadingSaved] = createSignal(false);
const [error, setError] = createSignal('');
const [predefinedError, setPredefinedError] = createSignal('');
const [showPreviewModal, setShowPreviewModal] = createSignal(false);
const [selectedVideo, setSelectedVideo] = createSignal<YouTubeVideo | null>(null);
const [sortBy, setSortBy] = createSignal<'relevance' | 'date' | 'views'>('relevance');
const [showChannelEditor, setShowChannelEditor] = createSignal(false);
const [featuredChannels, setFeaturedChannels] = createSignal<FeaturedChannel[]>([
{ id: '1', name: 'NetworkChuck', channel_id: '@NetworkChuck', description: 'Networking and IT tutorials' },
{ id: '2', name: 'Fireship', channel_id: '@Fireship', description: 'High-intensity tech tutorials' },
{ id: '3', name: 'Beyond Fireship', channel_id: '@beyondfireship', description: 'Extended tech content' },
{ id: '4', name: 'Linus Tech Tips', channel_id: '@LinusTechTips', description: 'Technology hardware and reviews' },
{ id: '5', name: 'Mrwhosetheboss', channel_id: '@Mrwhosetheboss', description: 'Tech reviews and comparisons' },
{ id: '6', name: 'JerryRigEverything', channel_id: '@JerryRigEverything', description: 'Durability tests and teardowns' },
{ id: '7', name: 'Jeff Geerling', channel_id: '@JeffGeerling', description: 'Homelab and networking projects' },
{ id: '8', name: 'MKBHD', channel_id: '@mkbhd', description: 'Tech reviews and industry analysis' }
]);
const [newChannelName, setNewChannelName] = createSignal('');
const [newChannelId, setNewChannelId] = createSignal('');
const [newChannelDescription, setNewChannelDescription] = createSignal('');
const [editingChannel, setEditingChannel] = createSignal<FeaturedChannel | null>(null);
const [successMessage, setSuccessMessage] = createSignal('');
const [channelFilter, setChannelFilter] = createSignal('');
// Filter channels based on search query
const filteredChannels = () => {
const filter = channelFilter().toLowerCase();
if (!filter) return featuredChannels();
return featuredChannels().filter(channel =>
channel.name.toLowerCase().includes(filter) ||
channel.channel_id.toLowerCase().includes(filter) ||
(channel.description && channel.description.toLowerCase().includes(filter))
);
};
// Check if we're in demo mode
const isDemoMode = () => {
const demoMode = localStorage.getItem('demoMode') === 'true' ||
document.title.includes('Demo Mode') ||
window.location.search.includes('demo=true');
console.log('YouTube page - Demo mode check:', {
localStorage: localStorage.getItem('demoMode'),
title: document.title,
search: window.location.search,
result: demoMode
});
return demoMode;
};
// Extract video ID from YouTube URL
const extractVideoId = (url: string): string | null => {
const regex = /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/;
const match = url.match(regex);
return match ? match[1] : null;
};
// Get video info from YouTube API using video ID
const getVideoInfo = async (videoId: string) => {
try {
if (isDemoMode()) {
// Use mock data in demo mode
const mockVideos = getMockVideos();
const mockVideo = mockVideos.find(v => v.id === videoId);
if (mockVideo) {
return {
video_id: mockVideo.id,
channel_name: mockVideo.channel,
url: mockVideo.url,
title: mockVideo.title,
duration: mockVideo.duration,
published_at: mockVideo.publishedAt,
view_count: '1000',
category: mockVideo.category
};
}
// Fallback mock data
return {
video_id: videoId,
channel_name: 'Demo Channel',
url: `https://www.youtube.com/watch?v=${videoId}`,
title: `Demo Video ${videoId}`,
duration: '10:30',
published_at: '2024-01-15',
view_count: '1000',
category: 'Technology'
};
}
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
const response = await fetch(`${API_BASE_URL}/youtube/video-details`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ video_id: videoId }),
});
if (!response.ok) {
const errorText = await response.text();
let errorData;
try {
errorData = JSON.parse(errorText);
} catch {
throw new Error(`Server error: ${response.status}`);
}
throw new Error(errorData?.details || errorData?.error || 'Failed to fetch video info');
}
return await response.json();
} catch (err) {
// Return a fallback video object with basic info
return {
video_id: videoId,
channel_name: 'Unknown Channel',
url: `https://www.youtube.com/watch?v=${videoId}`,
title: `Video ${videoId}`,
duration: 'Unknown',
published_at: 'Unknown',
view_count: '0',
category: 'General'
};
}
};
// Load predefined channel videos
const loadPredefinedVideos = async () => {
setIsLoadingPredefined(true);
setPredefinedError('');
try {
const channels = featuredChannels();
console.log('Using integrated YouTube service for featured channels');
// Use the integrated backend API
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
try {
// Fetch videos from all featured channels using the integrated API
const channelPromises = channels.map(async (channel) => {
try {
const response = await fetch(
`${API_BASE_URL}/youtube/channel-videos`,
{
method: 'POST',
headers: {
...getAuthHeaders(),
},
body: JSON.stringify({
channel_id: channel.channel_id,
max_results: 5
})
}
);
if (response.ok) {
const data = await response.json();
return data.videos || [];
} else if (response.status === 401) {
console.warn(`Authentication required for ${channel.name}`);
return [];
} else {
console.warn(`Failed to fetch videos for ${channel.name}:`, response.status);
return [];
}
} catch (error) {
console.warn(`Error fetching videos for ${channel.name}:`, error);
return [];
}
});
const allChannelVideos = await Promise.all(channelPromises);
const allVideos = allChannelVideos.flat();
// Convert scraping service format to our YouTubeVideo format
const videos: YouTubeVideo[] = allVideos.map((video: any) => ({
video_id: video.video_id,
channel_name: video.channel || 'Unknown Channel',
url: `https://www.youtube.com/watch?v=${video.video_id}`,
title: video.title || 'Untitled Video',
duration: video.length || 'Unknown',
published_at: video.published_date || video.published_text || 'Unknown',
view_count: video.views ? video.views.toLocaleString() : '0',
category: 'General'
}));
// Sort by published date (most recent first) and limit to 20 videos
const sortedVideos = videos
.sort((a, b) => {
const dateA = a.published_at && a.published_at !== 'Unknown' ? new Date(a.published_at).getTime() : 0;
const dateB = b.published_at && b.published_at !== 'Unknown' ? new Date(b.published_at).getTime() : 0;
return dateB - dateA;
})
.slice(0, 20);
setPredefinedVideos(sortedVideos);
setIsLoadingPredefined(false);
return;
} catch (scraperError) {
console.warn('YouTube scraping service failed:', scraperError);
}
// Fallback to backend API
const YOUTUBE_API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
try {
const response = await fetch(`${YOUTUBE_API_BASE_URL}/youtube/predefined-channels`, {
method: 'GET',
headers: getAuthHeaders(),
});
if (response.ok) {
const data = await response.json();
// Convert the API response to our YouTubeVideo format
const videos: YouTubeVideo[] = data.videos.map((video: any) => ({
video_id: video.id,
channel_name: video.channel_title || 'Unknown Channel',
url: `https://www.youtube.com/watch?v=${video.id}`,
title: video.title,
duration: video.duration || 'Unknown',
published_at: video.published_at || 'Unknown',
view_count: video.view_count?.toString() || '0',
category: 'General'
}));
// Sort by published date (most recent first) and limit to 20 videos
const sortedVideos = videos
.sort((a, b) => {
const dateA = a.published_at ? new Date(a.published_at).getTime() : 0;
const dateB = b.published_at ? new Date(b.published_at).getTime() : 0;
return dateB - dateA;
})
.slice(0, 20);
setPredefinedVideos(sortedVideos);
setIsLoadingPredefined(false);
return;
}
} catch (backendError) {
console.warn('Backend API failed for featured channels:', backendError);
}
// Final fallback to demo mode
console.log('All API methods failed, using demo mode for featured channels');
const mockVideos = getMockVideos();
const videos: YouTubeVideo[] = mockVideos.slice(0, 5).map((video) => ({
video_id: video.id,
channel_name: video.channel,
url: video.url,
title: video.title,
duration: video.duration,
published_at: video.publishedAt,
view_count: '1000',
category: video.category || 'General'
}));
setPredefinedVideos(videos);
} catch (err) {
console.error('Error in loadPredefinedVideos:', err);
setPredefinedError(err instanceof Error ? err.message : 'Failed to load predefined channel videos');
// Fallback to demo mode
const mockVideos = getMockVideos();
const videos: YouTubeVideo[] = mockVideos.slice(0, 5).map((video) => ({
video_id: video.id,
channel_name: video.channel,
url: video.url,
title: video.title,
duration: video.duration,
published_at: video.publishedAt,
view_count: '1000',
category: video.category || 'General'
}));
setPredefinedVideos(videos);
} finally {
setIsLoadingPredefined(false);
}
};
// Load saved YouTube videos from bookmarks
const loadSavedVideos = async () => {
setIsLoadingSaved(true);
try {
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
const response = await fetch(`${API_BASE_URL}/video-bookmarks`, {
headers: getAuthHeaders(),
});
if (response.ok) {
const data = await response.json();
const bookmarks = data.bookmarks || [];
const videos: YouTubeVideo[] = bookmarks.map((bookmark: any) => ({
video_id: bookmark.video_id,
channel_name: bookmark.channel || 'Unknown Channel',
url: bookmark.url,
title: bookmark.title || 'Untitled Video',
duration: 'Unknown',
published_at: bookmark.created_at || 'Unknown',
view_count: '0',
category: 'General',
}));
setSavedVideos(videos);
} else {
console.warn('Failed to load video bookmarks:', response.status);
setSavedVideos([]);
}
} catch (err) {
console.warn('Failed to load saved YouTube videos:', err);
setSavedVideos([]);
} finally {
setIsLoadingSaved(false);
}
};
// Add keyboard event handler for ESC key
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && showChannelEditor()) {
setShowChannelEditor(false);
setEditingChannel(null);
setNewChannelName('');
setNewChannelId('');
setNewChannelDescription('');
}
};
// Add and remove keyboard event listener
onMount(() => {
console.log('YouTube page mounted, demo mode:', isDemoMode());
loadPredefinedVideos();
loadSavedVideos();
document.addEventListener('keydown', handleKeyDown);
// Return cleanup function
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
});
// Load predefined videos when tab is switched to predefined
const handleTabChange = (tab: TabType) => {
setActiveTab(tab);
if (tab === 'predefined' && predefinedVideos().length === 0) {
loadPredefinedVideos();
}
};
const handleSearch = async () => {
const query = searchQuery().trim();
if (!query) return;
setIsLoading(true);
setError('');
try {
// Check if we're in demo mode first
if (isDemoMode()) {
console.log('Using demo mode for search');
const mockVideos = getMockVideos();
const filteredVideos = mockVideos
.filter(video =>
video.title.toLowerCase().includes(query.toLowerCase()) ||
video.description.toLowerCase().includes(query.toLowerCase()) ||
video.channel.toLowerCase().includes(query.toLowerCase())
)
.slice(0, 10)
.map((video) => ({
video_id: video.id,
channel_name: video.channel,
url: video.url,
title: video.title,
duration: video.duration,
published_at: video.publishedAt,
view_count: '1000',
category: video.category || 'General'
}));
setVideos(filteredVideos);
setIsLoading(false);
return;
}
// Check if the input is a YouTube URL
const videoId = extractVideoId(query);
if (videoId) {
// It's a YouTube URL, get video info directly
const data = await getVideoInfo(videoId);
const video: YouTubeVideo = {
video_id: data.video_id,
channel_name: data.channel_name,
url: data.url,
title: data.title,
duration: data.duration || 'Unknown',
published_at: data.published_at || 'Unknown',
view_count: data.view_count || '0',
category: 'General'
};
setVideos([video]);
} else {
// It's a regular search query - use backend API for now (will be replaced with scraping service)
try {
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
const response = await fetch(`${API_BASE_URL}/youtube/search`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ query: query }),
});
if (response.ok) {
const data = await response.json();
// Convert the API response to our YouTubeVideo format
const videos: YouTubeVideo[] = data.videos.map((video: any) => ({
video_id: video.id,
channel_name: video.channel_title || 'Unknown Channel',
url: `https://www.youtube.com/watch?v=${video.id}`,
title: video.title,
duration: video.duration || 'Unknown',
published_at: video.published_at || 'Unknown',
view_count: video.view_count?.toString() || '0',
category: 'General'
}));
setVideos(videos);
} else {
throw new Error('Search API failed');
}
} catch (apiError) {
console.warn('Backend search API failed:', apiError);
// Fallback to demo mode
console.log('Using demo mode fallback for search');
const mockVideos = getMockVideos();
const filteredVideos = mockVideos
.filter(video =>
video.title.toLowerCase().includes(query.toLowerCase()) ||
video.description.toLowerCase().includes(query.toLowerCase()) ||
video.channel.toLowerCase().includes(query.toLowerCase())
)
.slice(0, 10)
.map((video) => ({
video_id: video.id,
channel_name: video.channel,
url: video.url,
title: video.title,
duration: video.duration,
published_at: video.publishedAt,
view_count: '1000',
category: video.category || 'General'
}));
setVideos(filteredVideos);
}
}
} catch (err) {
console.warn('Search failed, falling back to demo mode:', err);
// Fallback to demo mode
const mockVideos = getMockVideos();
const filteredVideos = mockVideos
.filter(video =>
video.title.toLowerCase().includes(query.toLowerCase()) ||
video.description.toLowerCase().includes(query.toLowerCase()) ||
video.channel.toLowerCase().includes(query.toLowerCase())
)
.slice(0, 10)
.map((video) => ({
video_id: video.id,
channel_name: video.channel,
url: video.url,
title: video.title,
duration: video.duration,
published_at: video.publishedAt,
view_count: '1000',
category: video.category || 'General'
}));
setVideos(filteredVideos);
} finally {
setIsLoading(false);
}
};
const handleKeyPress = (e: KeyboardEvent) => {
if (e.key === 'Enter') {
handleSearch();
}
};
const handleInput = (e: InputEvent) => {
const target = e.currentTarget as HTMLInputElement;
if (target) {
setSearchQuery(target.value);
}
};
const handlePreviewVideo = (video: YouTubeVideo) => {
setSelectedVideo(video);
setShowPreviewModal(true);
};
const handleSaveVideo = async (video: YouTubeVideo) => {
try {
if (isDemoMode()) {
// Simulate save in demo mode
console.log('Video saved (demo mode):', video);
setSavedVideos((prev) => {
if (prev.some((v) => v.video_id === video.video_id)) {
return prev;
}
return [video, ...prev];
});
setSuccessMessage('Video saved successfully!');
setTimeout(() => setSuccessMessage(''), 3000);
return;
}
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
const bookmarkData = {
url: video.url,
description: `Video from ${video.channel_name}`,
tags: '',
is_favorite: false,
};
const response = await fetch(`${API_BASE_URL}/video-bookmarks`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(bookmarkData),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to create bookmark');
}
const result = await response.json();
console.log('Video bookmarked successfully:', result);
// Refresh saved videos
await loadSavedVideos();
setSuccessMessage('Video saved successfully!');
setTimeout(() => setSuccessMessage(''), 3000);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save video');
setTimeout(() => setError(''), 3000);
}
};
return (
<div class="min-h-screen bg-background p-6">
<div class="max-w-7xl mx-auto">
{/* Header */}
<div class="mb-8">
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold tracking-tight mb-2">YouTube Video Storage</h1>
<p class="text-muted-foreground">Search, discover, and store YouTube videos</p>
<Show when={isDemoMode()}>
<div class="flex items-center gap-2 mt-2">
<span class="px-2 py-1 bg-yellow-100 text-yellow-800 text-xs font-medium rounded-full">
Demo Mode
</span>
<span class="text-sm text-muted-foreground">Using sample data</span>
</div>
</Show>
</div>
</div>
</div>
{/* Tabs */}
<Card class="p-6 mb-8">
<div class="flex space-x-1 mb-6">
<Button
variant={activeTab() === 'search' ? 'default' : 'ghost'}
onClick={() => handleTabChange('search')}
class="flex items-center gap-2"
>
<svg
class={`w-4 h-4 ${activeTab() === 'search' ? 'text-black' : 'text-white'}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
Search & Save
</Button>
<Button
variant={activeTab() === 'predefined' ? 'default' : 'ghost'}
onClick={() => handleTabChange('predefined')}
class="flex items-center gap-2"
>
<svg
class={`w-4 h-4 ${activeTab() === 'predefined' ? 'text-black' : 'text-white'}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z" />
</svg>
Featured Channels
</Button>
<Button
variant={activeTab() === 'bookmarked' ? 'default' : 'ghost'}
onClick={() => handleTabChange('bookmarked')}
class="flex items-center gap-2"
>
<svg
class={`w-4 h-4 ${activeTab() === 'bookmarked' ? 'text-black' : 'text-white'}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
</svg>
Bookmarked Videos
</Button>
</div>
{/* Search Tab Content */}
<Show when={activeTab() === 'search'}>
<div class="space-y-4">
{/* Search Input */}
<div class="flex gap-4">
<div class="flex-1">
<Input
type="text"
placeholder="Search for videos, channels, topics, or paste YouTube URLs..."
value={searchQuery()}
onInput={handleInput}
class="text-base"
onKeyDown={handleKeyPress}
/>
</div>
<Button
onClick={handleSearch}
disabled={isLoading() || !searchQuery().trim()}
size="lg"
class="px-8"
>
{isLoading() ? (
<span class="flex items-center gap-2">
<span class="w-4 h-4 border-2 border-primary-foreground/30 border-t-primary-foreground rounded-full animate-spin"></span>
Searching...
</span>
) : (
'Search'
)}
</Button>
</div>
{/* Filters */}
<div class="flex flex-wrap gap-4">
<div class="flex items-center gap-2">
<label class="text-sm font-medium">Sort by:</label>
<select
value={sortBy()}
onChange={(e) => setSortBy(e.target.value as any)}
class="flex h-10 w-32 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<option value="relevance">Relevance</option>
<option value="date">Date</option>
<option value="views">Views</option>
</select>
</div>
</div>
</div>
</Show>
{/* Predefined Channels Tab Content */}
<Show when={activeTab() === 'predefined'}>
<div class="space-y-4">
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold">Featured YouTube Channels</h3>
<p class="text-sm text-muted-foreground">Latest videos from your selected channels</p>
</div>
<div class="flex gap-2">
<Button
variant="outline"
onClick={() => setShowChannelEditor(true)}
>
Edit Channels
</Button>
<Button
variant="outline"
onClick={loadPredefinedVideos}
disabled={isLoadingPredefined()}
>
{isLoadingPredefined() ? (
<span class="flex items-center gap-2">
<span class="w-4 h-4 border-2 border-primary/30 border-t-primary rounded-full animate-spin"></span>
Refreshing...
</span>
) : (
'Refresh'
)}
</Button>
</div>
</div>
{/* Channel Filter */}
<div class="flex gap-4">
<div class="flex-1">
<Input
type="text"
placeholder="Filter channels by name, handle, or description..."
value={channelFilter()}
onInput={(e: InputEvent) => {
const target = e.currentTarget as HTMLInputElement;
if (target) {
setChannelFilter(target.value);
}
}}
class="text-base"
/>
</div>
</div>
</div>
</Show>
{/* Bookmarked Videos Tab Content */}
<Show when={activeTab() === 'bookmarked'}>
<div class="space-y-4">
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold">Your Bookmarked Videos</h3>
<p class="text-sm text-muted-foreground">Videos you have saved for later</p>
</div>
<Button
variant="outline"
onClick={loadSavedVideos}
disabled={isLoadingSaved()}
>
{isLoadingSaved() ? (
<span class="flex items-center gap-2">
<span class="w-4 h-4 border-2 border-primary/30 border-t-primary rounded-full animate-spin"></span>
Refreshing...
</span>
) : (
'Refresh'
)}
</Button>
</div>
</div>
</Show>
</Card>
{/* Error Messages */}
<Show when={error()}>
<Card class="p-4 mb-6 border-destructive/20 bg-destructive/5">
<div class="flex items-center gap-2">
<IconAlertCircle class="size-4 text-destructive" />
<p class="text-destructive text-sm">{error()}</p>
</div>
</Card>
</Show>
<Show when={successMessage()}>
<Card class="p-4 mb-6 border-primary/20 bg-primary/5">
<div class="flex items-center gap-2">
<IconAlertCircle class="size-4 text-primary" />
<p class="text-primary text-sm">{successMessage()}</p>
</div>
</Card>
</Show>
<Show when={predefinedError()}>
<Card class="p-4 mb-6 border-destructive/20 bg-destructive/5">
<div class="flex items-center gap-2">
<IconAlertCircle class="size-4 text-destructive" />
<p class="text-destructive text-sm">{predefinedError()}</p>
</div>
</Card>
</Show>
{/* Search Results */}
<Show when={activeTab() === 'search' && videos().length > 0}>
<div class="mb-6">
<h2 class="text-xl font-semibold mb-4">Search Results</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<For each={videos()}>
{(video) => (
<VideoCard video={video} onPreview={handlePreviewVideo} onSave={handleSaveVideo} />
)}
</For>
</div>
</div>
</Show>
{/* Predefined Channel Videos */}
<Show when={activeTab() === 'predefined' && predefinedVideos().length > 0}>
<div class="mb-6">
<h2 class="text-xl font-semibold mb-4">Latest Videos from Featured Channels</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<For each={predefinedVideos()}>
{(video) => (
<VideoCard video={video} onPreview={handlePreviewVideo} onSave={handleSaveVideo} />
)}
</For>
</div>
</div>
</Show>
{/* Bookmarked Videos */}
<Show when={activeTab() === 'bookmarked' && savedVideos().length > 0}>
<div class="mb-6">
<h2 class="text-xl font-semibold mb-4">Your Bookmarked Videos</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<For each={savedVideos()}>
{(video) => (
<VideoCard video={video} onPreview={handlePreviewVideo} />
)}
</For>
</div>
</div>
</Show>
{/* Saved Videos */}
<Show when={savedVideos().length > 0}>
<div class="mb-6">
<h2 class="text-xl font-semibold mb-4">Saved Videos</h2>
{isLoadingSaved() ? (
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<For each={[1, 2, 3]}>
{() => (
<Card class="h-full p-4 animate-pulse">
<div class="aspect-video bg-muted rounded mb-3" />
<div class="h-4 bg-muted rounded mb-2" />
<div class="h-3 bg-muted rounded w-2/3" />
</Card>
)}
</For>
</div>
) : (
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<For each={savedVideos()}>
{(video) => (
<VideoCard video={video} onPreview={handlePreviewVideo} />
)}
</For>
</div>
)}
</div>
</Show>
{/* Bookmarked tab empty state */}
<Show when={activeTab() === 'bookmarked' && !isLoadingSaved() && savedVideos().length === 0}>
<Card class="p-12 text-center">
<div class="max-w-md mx-auto">
<div class="w-16 h-16 bg-muted rounded-full flex items-center justify-center mx-auto mb-4">
<svg class="w-8 h-8 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
</svg>
</div>
<h3 class="text-lg font-semibold mb-2">No bookmarked videos</h3>
<p class="text-muted-foreground">
Start saving videos from the search results to see them here.
</p>
</div>
</Card>
</Show>
{/* Bookmarked tab loading state */}
<Show when={activeTab() === 'bookmarked' && isLoadingSaved()}>
<Card class="p-12 text-center">
<div class="max-w-md mx-auto">
<div class="w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center mx-auto mb-4">
<span class="w-8 h-8 border-4 border-primary/30 border-t-primary rounded-full animate-spin"></span>
</div>
<h3 class="text-lg font-semibold mb-2">Loading Bookmarked Videos</h3>
<p class="text-muted-foreground">
Fetching your saved videos...
</p>
</div>
</Card>
</Show>
{/* Empty States */}
{/* Search tab empty state */}
<Show when={activeTab() === 'search' && !isLoading() && videos().length === 0 && searchQuery()}>
<Card class="p-12 text-center">
<div class="max-w-md mx-auto">
<div class="w-16 h-16 bg-muted rounded-full flex items-center justify-center mx-auto mb-4">
<svg class="w-8 h-8 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<h3 class="text-lg font-semibold mb-2">No videos found</h3>
<p class="text-muted-foreground">
Try searching with different keywords or check your spelling.
</p>
</div>
</Card>
</Show>
{/* Search tab initial state */}
<Show when={activeTab() === 'search' && !searchQuery()}>
<Card class="p-12 text-center">
<div class="max-w-md mx-auto">
<div class="w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center mx-auto mb-4">
<svg class="w-8 h-8 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</div>
<h3 class="text-lg font-semibold mb-2">Search YouTube Videos</h3>
<p class="text-muted-foreground">
Enter keywords above to search for videos, channels, or topics. Use filters to narrow down your results.
</p>
</div>
</Card>
</Show>
{/* Predefined tab loading state */}
<Show when={activeTab() === 'predefined' && isLoadingPredefined()}>
<Card class="p-12 text-center">
<div class="max-w-md mx-auto">
<div class="w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center mx-auto mb-4">
<span class="w-8 h-8 border-4 border-primary/30 border-t-primary rounded-full animate-spin"></span>
</div>
<h3 class="text-lg font-semibold mb-2">Loading Featured Videos</h3>
<p class="text-muted-foreground">
Fetching latest videos from NetworkChuck, Fireship, and Beyond Fireship...
</p>
</div>
</Card>
</Show>
{/* Predefined tab empty state */}
<Show when={activeTab() === 'predefined' && !isLoadingPredefined() && predefinedVideos().length === 0 && !predefinedError()}>
<Card class="p-12 text-center">
<div class="max-w-md mx-auto">
<div class="w-16 h-16 bg-muted rounded-full flex items-center justify-center mx-auto mb-4">
<svg class="w-8 h-8 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z" />
</svg>
</div>
<h3 class="text-lg font-semibold mb-2">No Videos Available</h3>
<p class="text-muted-foreground">
Click the Refresh button to load the latest videos from featured channels.
</p>
</div>
</Card>
</Show>
{/* Video Preview Modal */}
<VideoPreviewModal
isOpen={showPreviewModal()}
onClose={() => setShowPreviewModal(false)}
video={selectedVideo()}
/>
{/* Channel Editor Modal */}
<Show when={showChannelEditor()}>
<div
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowChannelEditor(false);
setEditingChannel(null);
setNewChannelName('');
setNewChannelId('');
setNewChannelDescription('');
}
}}
>
<div class="bg-background rounded-lg shadow-lg max-w-2xl w-full mx-4 max-h-[80vh] overflow-y-auto">
<div class="p-6">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-semibold">Manage Featured Channels</h2>
<Button
variant="ghost"
size="sm"
onClick={() => {
setShowChannelEditor(false);
setEditingChannel(null);
setNewChannelName('');
setNewChannelId('');
setNewChannelDescription('');
}}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</Button>
</div>
{/* Add New Channel Form */}
<div class="mb-6 p-4 border rounded-lg">
<h3 class="font-semibold mb-4">
{editingChannel() ? 'Edit Channel' : 'Add New Channel'}
</h3>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium mb-2">Channel Name</label>
<Input
type="text"
placeholder="e.g., Fireship"
value={newChannelName()}
onInput={(e: InputEvent) => {
const target = e.currentTarget as HTMLInputElement;
if (target) {
setNewChannelName(target.value);
}
}}
/>
</div>
<div>
<label class="block text-sm font-medium mb-2">Channel Handle or URL</label>
<Input
type="text"
placeholder="e.g., @Fireship or https://www.youtube.com/@Fireship/videos"
value={newChannelId()}
onInput={(e: InputEvent) => {
const target = e.currentTarget as HTMLInputElement;
if (target) {
setNewChannelId(target.value);
}
}}
/>
<p class="text-xs text-muted-foreground mt-1">
Use the channel handle or full channel URL as accepted by the YouTube Channel Scraper API.
</p>
</div>
<div>
<label class="block text-sm font-medium mb-2">Description (Optional)</label>
<Input
type="text"
placeholder="Brief description of the channel"
value={newChannelDescription()}
onInput={(e: InputEvent) => {
const target = e.currentTarget as HTMLInputElement;
if (target) {
setNewChannelDescription(target.value);
}
}}
/>
</div>
<div class="flex gap-2">
<Button
onClick={() => {
if (newChannelName() && newChannelId()) {
if (editingChannel()) {
// Update existing channel
setFeaturedChannels(prev =>
prev.map(ch =>
ch.id === editingChannel()!.id
? { ...ch, name: newChannelName(), channel_id: newChannelId(), description: newChannelDescription() }
: ch
)
);
} else {
// Add new channel
const newChannel: FeaturedChannel = {
id: Date.now().toString(),
name: newChannelName(),
channel_id: newChannelId(),
description: newChannelDescription()
};
setFeaturedChannels(prev => [...prev, newChannel]);
}
// Reset form
setNewChannelName('');
setNewChannelId('');
setNewChannelDescription('');
setEditingChannel(null);
}
}}
disabled={!newChannelName() || !newChannelId()}
>
{editingChannel() ? 'Update Channel' : 'Add Channel'}
</Button>
{editingChannel() && (
<Button
variant="outline"
onClick={() => {
setEditingChannel(null);
setNewChannelName('');
setNewChannelId('');
setNewChannelDescription('');
}}
>
Cancel
</Button>
)}
</div>
</div>
</div>
{/* Current Channels List */}
<div>
<h3 class="font-semibold mb-4">Current Channels ({filteredChannels().length})</h3>
<div class="space-y-2">
<For each={filteredChannels()}>
{(channel) => (
<div class="flex items-center justify-between p-3 border rounded-lg">
<div class="flex-1">
<h4 class="font-medium">{channel.name}</h4>
<p class="text-sm text-muted-foreground">Handle/URL: {channel.channel_id}</p>
{channel.description && (
<p class="text-xs text-muted-foreground">{channel.description}</p>
)}
</div>
<div class="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
setEditingChannel(channel);
setNewChannelName(channel.name);
setNewChannelId(channel.channel_id);
setNewChannelDescription(channel.description || '');
}}
>
Edit
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => {
setFeaturedChannels(prev => prev.filter(ch => ch.id !== channel.id));
}}
>
Remove
</Button>
</div>
</div>
)}
</For>
{filteredChannels().length === 0 && (
<div class="text-center py-8 text-muted-foreground">
<p>No channels found matching "{channelFilter()}"</p>
</div>
)}
</div>
</div>
</div>
</div>
</div>
</Show>
</div>
</div>
);
};