videofolxtv/client/src/pages/BunnyAdminPage.tsx
sebastjanartic d8fa916902 Improve display of Bunny.net CDN statistics on admin page
Update BunnyAdminPage to safely render CDN statistics (total videos, total views, storage, bandwidth) by ensuring fallback values of 0 are used when data is not yet available, preventing potential rendering errors.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: d7424866-83d1-4486-a212-ac12b4c7becf
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/8cc42625-c1f5-4e43-99bd-77f2c4dedee2/d7424866-83d1-4486-a212-ac12b4c7becf/VMZ5vW0
2025-08-08 21:26:05 +00:00

346 lines
14 KiB
TypeScript

import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
Plus,
Search,
Edit3,
Trash2,
Upload,
Play,
Pause,
Settings,
BarChart3,
Users,
Video as VideoIcon
} from "lucide-react";
import { type Video } from "@shared/schema";
import { apiRequest } from "@/lib/queryClient";
import BunnyVideoModal from "@/components/bunny-video-modal";
interface BunnyStats {
totalVideos: number;
totalViews: number;
totalStorage: number;
bandwidth: number;
}
function formatFileSize(bytes: number): string {
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
if (bytes === 0) return '0 B';
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
}
function formatDuration(seconds: number): string {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
}
function formatViews(views: number): string {
if (views >= 1000000) {
return `${(views / 1000000).toFixed(1)}M`;
} else if (views >= 1000) {
return `${(views / 1000).toFixed(1)}K`;
}
return views.toString();
}
export default function BunnyAdminPage() {
const [searchTerm, setSearchTerm] = useState("");
const [selectedVideo, setSelectedVideo] = useState<Video | null>(null);
const [showUploadModal, setShowUploadModal] = useState(false);
const queryClient = useQueryClient();
// Fetch videos from Bunny.net
const { data: videosData, isLoading } = useQuery({
queryKey: ['/api/videos', searchTerm],
queryFn: () => apiRequest('GET', `/api/videos?search=${searchTerm}&limit=100`),
});
// Fetch Bunny.net statistics
const { data: statsData } = useQuery({
queryKey: ['/api/bunny/stats'],
queryFn: () => apiRequest('GET', '/api/bunny/stats'),
});
const videos: Video[] = (videosData as any)?.videos || [];
const stats: BunnyStats = (statsData as any) || {
totalVideos: 0,
totalViews: 0,
totalStorage: 0,
bandwidth: 0
};
const deleteVideoMutation = useMutation({
mutationFn: (videoId: string) => apiRequest('DELETE', `/api/bunny/videos/${videoId}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/videos'] });
},
});
const handleDeleteVideo = async (video: Video) => {
if (confirm(`Ali ste prepričani, da želite izbrisati video "${video.title}"?`)) {
try {
await deleteVideoMutation.mutateAsync(video.id);
} catch (error) {
alert('Napaka pri brisanju videa');
}
}
};
const filteredVideos: Video[] = videos.filter((video: Video) =>
video.title.toLowerCase().includes(searchTerm.toLowerCase())
);
return (
<div className="min-h-screen bg-gray-900 text-white">
{/* Header */}
<div className="bg-gray-800 border-b border-gray-700 px-6 py-4">
<div className="max-w-7xl mx-auto">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Bunny.net Administracija</h1>
<p className="text-gray-400 mt-1">Upravljanje videov in analitika</p>
</div>
<div className="flex gap-3">
<Button
onClick={() => setShowUploadModal(true)}
className="bg-blue-600 hover:bg-blue-700"
data-testid="button-upload-video"
>
<Upload className="w-4 h-4 mr-2" />
Naloži video
</Button>
</div>
</div>
</div>
</div>
<div className="max-w-7xl mx-auto px-6 py-8">
{/* Statistics Dashboard */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<Card className="bg-gray-800 border-gray-700">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-gray-400">Skupno videov</CardTitle>
<VideoIcon className="h-4 w-4 text-blue-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-white" data-testid="text-total-videos">
{stats?.totalVideos || 0}
</div>
</CardContent>
</Card>
<Card className="bg-gray-800 border-gray-700">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-gray-400">Skupno ogledov</CardTitle>
<BarChart3 className="h-4 w-4 text-green-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-white" data-testid="text-total-views">
{formatViews(stats?.totalViews || 0)}
</div>
</CardContent>
</Card>
<Card className="bg-gray-800 border-gray-700">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-gray-400">Shramba</CardTitle>
<Settings className="h-4 w-4 text-purple-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-white" data-testid="text-storage">
{formatFileSize(stats?.totalStorage || 0)}
</div>
</CardContent>
</Card>
<Card className="bg-gray-800 border-gray-700">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-gray-400">Pasovna širina</CardTitle>
<Users className="h-4 w-4 text-orange-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-white" data-testid="text-bandwidth">
{formatFileSize(stats?.bandwidth || 0)}
</div>
</CardContent>
</Card>
</div>
{/* Search and Filters */}
<div className="mb-8">
<div className="flex gap-4">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<Input
placeholder="Iskanje po naslovu videa..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 bg-gray-800 border-gray-700 text-white"
data-testid="input-search-videos"
/>
</div>
</div>
</div>
{/* Video Grid */}
<div className="space-y-4">
{isLoading ? (
<div className="grid grid-cols-1 gap-4">
{Array.from({ length: 5 }).map((_, index) => (
<Card key={index} className="bg-gray-800 border-gray-700 animate-pulse">
<CardContent className="p-4">
<div className="flex gap-4">
<div className="w-32 h-20 bg-gray-700 rounded"></div>
<div className="flex-1 space-y-2">
<div className="h-4 bg-gray-700 rounded w-3/4"></div>
<div className="h-3 bg-gray-700 rounded w-1/2"></div>
<div className="h-3 bg-gray-700 rounded w-1/4"></div>
</div>
</div>
</CardContent>
</Card>
))}
</div>
) : filteredVideos.length === 0 ? (
<Card className="bg-gray-800 border-gray-700">
<CardContent className="p-8 text-center">
<VideoIcon className="w-12 h-12 text-gray-500 mx-auto mb-4" />
<h3 className="text-lg font-medium text-white mb-2">Ni najdenih videov</h3>
<p className="text-gray-400">
{searchTerm ? "Poskusite z drugim iskanjem" : "Dodajte svoj prvi video"}
</p>
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 gap-4" data-testid="grid-videos">
{filteredVideos.map((video: Video) => (
<Card key={video.id} className="bg-gray-800 border-gray-700 hover:bg-gray-750 transition-colors">
<CardContent className="p-4">
<div className="flex gap-4">
{/* Video Thumbnail */}
<div className="relative w-32 h-20 bg-gray-700 rounded overflow-hidden flex-shrink-0">
{video.thumbnailUrl ? (
<img
src={video.thumbnailUrl}
alt={video.title}
className="w-full h-full object-cover"
loading="lazy"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<VideoIcon className="w-6 h-6 text-gray-500" />
</div>
)}
<div className="absolute bottom-1 right-1 bg-black bg-opacity-75 text-white text-xs px-1 rounded">
{formatDuration(video.duration)}
</div>
</div>
{/* Video Info */}
<div className="flex-1 min-w-0">
<h3 className="text-lg font-medium text-white truncate mb-1" data-testid={`text-video-title-${video.id}`}>
{video.title}
</h3>
<div className="flex items-center gap-4 text-sm text-gray-400 mb-2">
<span data-testid={`text-video-views-${video.id}`}>
{formatViews(video.views)} ogledov
</span>
<span data-testid={`text-video-duration-${video.id}`}>
{formatDuration(video.duration)}
</span>
{video.category && (
<Badge variant="secondary" className="bg-blue-600 text-white">
{video.category}
</Badge>
)}
</div>
{video.description && (
<p className="text-sm text-gray-400 line-clamp-2" data-testid={`text-video-description-${video.id}`}>
{video.description}
</p>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedVideo(video)}
className="text-gray-400 hover:text-white"
data-testid={`button-view-video-${video.id}`}
>
<Play className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteVideo(video)}
className="text-red-400 hover:text-red-300"
data-testid={`button-delete-video-${video.id}`}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
</div>
{/* Video Modal */}
<BunnyVideoModal
video={selectedVideo}
isOpen={!!selectedVideo}
onClose={() => setSelectedVideo(null)}
/>
{/* Upload Modal */}
{showUploadModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<Card className="bg-gray-800 border-gray-700 w-full max-w-md">
<CardHeader>
<CardTitle className="text-white">Naloži video</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-gray-400">
Za nalaganje videov uporabite Bunny.net administracijsko ploščo:
</p>
<div className="space-y-2">
<a
href="https://dash.bunny.net"
target="_blank"
rel="noopener noreferrer"
className="block"
>
<Button className="w-full bg-orange-600 hover:bg-orange-700">
Odpri Bunny.net Dashboard
</Button>
</a>
<Button
variant="outline"
onClick={() => setShowUploadModal(false)}
className="w-full border-gray-600 text-gray-300 hover:bg-gray-700"
>
Zapri
</Button>
</div>
</CardContent>
</Card>
</div>
)}
</div>
);
}