Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion components/Chatbar/components/ChatbarSettings.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { IconFileExport, IconSettings, IconPlug } from '@tabler/icons-react';
import { IconFileExport, IconSettings, IconPlug, IconVideo } from '@tabler/icons-react';
import { useContext, useState } from 'react';

import { useTranslation } from 'next-i18next';
Expand All @@ -7,6 +7,7 @@ import HomeContext from '@/pages/api/home/home.context';

import { SettingDialog } from '@/components/Settings/SettingDialog';
import { MCPModal } from '@/components/MCP/MCPModal';
import { VideoLibraryModal } from '@/components/Media/VideoLibraryModal';

import { Import } from '../../Settings/Import';
import { SidebarButton } from '../../Sidebar/SidebarButton';
Expand All @@ -17,6 +18,7 @@ export const ChatbarSettings = () => {
const { t } = useTranslation('sidebar');
const [isSettingDialogOpen, setIsSettingDialog] = useState<boolean>(false);
const [isMCPModalOpen, setIsMCPModalOpen] = useState<boolean>(false);
const [isVideoLibraryOpen, setIsVideoLibraryOpen] = useState<boolean>(false);

const {
state: { lightMode, conversations },
Expand All @@ -37,6 +39,12 @@ export const ChatbarSettings = () => {
onClick={() => setIsMCPModalOpen(true)}
/>

<SidebarButton
text={'Video Library'}
icon={<IconVideo size={18} />}
onClick={() => setIsVideoLibraryOpen(true)}
/>

{conversations.length > 0 ? (
<ClearConversations onClearConversations={handleClearConversations} />
) : null}
Expand Down Expand Up @@ -68,6 +76,13 @@ export const ChatbarSettings = () => {
setIsMCPModalOpen(false);
}}
/>

<VideoLibraryModal
open={isVideoLibraryOpen}
onClose={() => {
setIsVideoLibraryOpen(false);
}}
/>
</div>
);
};
155 changes: 155 additions & 0 deletions components/Media/VideoLibrary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { IconVideo, IconTrash, IconSearch } from '@tabler/icons-react';
import { FC, useState, useEffect } from 'react';
import toast from 'react-hot-toast';

import { VideoUpload } from './VideoUpload';

import { VideoUploadResponse, VideoItem, listVideos, deleteVideo } from '@/utils/api/videoUpload';

interface Props {
onSelectVideo?: (_videoKey: string) => void;
}

export const VideoLibrary: FC<Props> = ({ onSelectVideo }) => {
const [videos, setVideos] = useState<VideoItem[]>([]);
const [searchTerm, setSearchTerm] = useState('');
const [isLoading, setIsLoading] = useState(false);

const fetchVideos = async () => {
setIsLoading(true);
try {
const videos = await listVideos();
setVideos(videos);
} catch (error) {
console.error('Error fetching videos:', error);
toast.error('Failed to load videos');
} finally {
setIsLoading(false);
}
};

useEffect(() => {
fetchVideos();
}, []);

const handleUploadSuccess = (_response: VideoUploadResponse) => {
// Refresh video list to show the new upload
fetchVideos();
};

const handleDeleteVideo = async (videoKey: string) => {
if (!confirm('Are you sure you want to delete this video?')) {
return;
}

try {
await deleteVideo(videoKey);

// Refresh video list to reflect deletion
fetchVideos();
toast.success('Video deleted successfully');
} catch (error) {
console.error('Error deleting video:', error);
const errorMessage = error instanceof Error ? error.message : 'Failed to delete video';
toast.error(errorMessage);
}
};

const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
};

const filteredVideos = videos.filter(video =>
video.filename?.toLowerCase().includes(searchTerm.toLowerCase()) ||
video.video_key.toLowerCase().includes(searchTerm.toLowerCase())
);

return (
<div className="w-full h-full flex flex-col">
{/* Header */}
<div className="flex items-center gap-2 p-4 border-b border-gray-200 dark:border-gray-700">
<IconVideo size={24} className="text-[#76b900]" />
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
{'Video Library'}
</h2>
</div>

{/* Upload Section*/}
<div className="p-4 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
<VideoUpload onUploadSuccess={handleUploadSuccess} />
</div>

{/* Search */}
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
<div className="relative">
<IconSearch
size={20}
className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"
/>
<input
type="text"
placeholder={'Search videos...'}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-[#76b900]"
/>
</div>
</div>

{/* Video List */}
<div className="flex-1 overflow-y-auto p-4">
{isLoading ? (
<div className="text-center text-gray-500 dark:text-gray-400 py-8">
{'Loading videos...'}
</div>
) : filteredVideos.length === 0 ? (
<div className="text-center text-gray-500 dark:text-gray-400 py-8">
{searchTerm
? 'No videos found'
: 'No videos uploaded yet'}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredVideos.map((video) => (
<div
key={video.video_key}
className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:shadow-lg transition-shadow bg-white dark:bg-gray-800"
>
<div className="flex items-start justify-between mb-2">
<div className="flex-1 min-w-0">
<h3 className="font-medium text-gray-900 dark:text-white truncate">
{video.filename || video.video_key.split('/').pop()}
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{formatFileSize(video.size)} • {video.content_type}
</p>
</div>
<button
onClick={() => handleDeleteVideo(video.video_key)}
className="ml-2 p-1 text-gray-400 hover:text-red-500 transition-colors"
aria-label="Delete video"
>
<IconTrash size={18} />
</button>
</div>
{onSelectVideo && (
<button
onClick={() => onSelectVideo(video.video_key)}
className="w-full mt-2 px-3 py-1.5 text-sm bg-[#76b900] text-white rounded hover:bg-[#5a9100] transition-colors"
>
{'Select'}
</button>
)}
</div>
))}
</div>
)}
</div>
</div>
);
};

30 changes: 30 additions & 0 deletions components/Media/VideoLibraryModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { FC } from 'react';
import { IconX } from '@tabler/icons-react';

import { VideoLibrary } from './VideoLibrary';

interface Props {
open: boolean;
onClose: () => void;
onSelectVideo?: (_videoKey: string) => void;
}

export const VideoLibraryModal: FC<Props> = ({ open, onClose, onSelectVideo }) => {
if (!open) return null;

return (
<div className="fixed inset-0 flex items-center justify-center bg-black bg-opacity-50 backdrop-blur-sm z-50 dark:bg-opacity-20">
<div className="w-full max-w-6xl h-[90vh] bg-white dark:bg-[#202123] rounded-2xl shadow-lg flex flex-col relative">
<button
onClick={onClose}
className="absolute top-4 right-4 p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors z-10"
aria-label="Close"
>
<IconX size={24} />
</button>
<VideoLibrary onSelectVideo={onSelectVideo} />
</div>
</div>
);
};

Loading