Remove unused administrative and advertising features
Remove admin, bunny admin, and ad settings routes and components. 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/xV80ifH
This commit is contained in:
parent
886475de42
commit
f8c8854ed7
@ -5,8 +5,6 @@ import { Toaster } from "@/components/ui/toaster";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import Home from "@/pages/home";
|
||||
import VideoPage from "@/pages/VideoPage";
|
||||
import Admin from "@/pages/admin";
|
||||
import BunnyAdminPage from "@/pages/BunnyAdminPage";
|
||||
import NotFound from "@/pages/not-found";
|
||||
|
||||
function Router() {
|
||||
@ -14,8 +12,6 @@ function Router() {
|
||||
<Switch>
|
||||
<Route path="/" component={Home} />
|
||||
<Route path="/video/:id" component={VideoPage} />
|
||||
<Route path="/admin" component={Admin} />
|
||||
<Route path="/bunny-admin" component={BunnyAdminPage} />
|
||||
<Route component={NotFound} />
|
||||
</Switch>
|
||||
);
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { Search, Play, Menu, Grid3X3, List, DollarSign, Settings, Info } from "lucide-react";
|
||||
import { Search, Play, Menu, Grid3X3, List } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
@ -8,16 +8,12 @@ interface SearchHeaderProps {
|
||||
onSearch: (query: string) => void;
|
||||
onViewChange: (view: "grid" | "list") => void;
|
||||
currentView: "grid" | "list";
|
||||
onAdSettingsOpen?: () => void;
|
||||
onAdExplanationOpen?: () => void;
|
||||
}
|
||||
|
||||
export default function SearchHeader({
|
||||
onSearch,
|
||||
onViewChange,
|
||||
currentView,
|
||||
onAdSettingsOpen,
|
||||
onAdExplanationOpen
|
||||
currentView
|
||||
}: SearchHeaderProps) {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
@ -44,26 +40,6 @@ export default function SearchHeader({
|
||||
<a href="/" className="text-bunny-light hover:text-bunny-blue transition-colors" data-testid="link-home">
|
||||
Home
|
||||
</a>
|
||||
<a href="/admin" className="text-bunny-muted hover:text-bunny-light transition-colors" data-testid="link-admin">
|
||||
Admin
|
||||
</a>
|
||||
<a href="/bunny-admin" className="text-bunny-muted hover:text-bunny-light transition-colors" data-testid="link-bunny-admin">
|
||||
Bunny Admin
|
||||
</a>
|
||||
<button
|
||||
onClick={onAdSettingsOpen}
|
||||
className="text-bunny-muted hover:text-bunny-light transition-colors flex items-center space-x-1"
|
||||
data-testid="button-ad-settings"
|
||||
>
|
||||
<DollarSign className="w-4 h-4" />
|
||||
<span>Monetization</span>
|
||||
</button>
|
||||
<a href="#" className="text-bunny-muted hover:text-bunny-light transition-colors" data-testid="link-library">
|
||||
Library
|
||||
</a>
|
||||
<a href="#" className="text-bunny-muted hover:text-bunny-light transition-colors" data-testid="link-upload">
|
||||
Upload
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div className="relative">
|
||||
|
||||
@ -1,346 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -1,370 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { type Video } from "@shared/schema";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Edit3, Trash2, Save, X, Upload, BarChart3, Users, PlayCircle, Clock } from "lucide-react";
|
||||
import { queryClient } from "@/lib/queryClient";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
interface VideosResponse {
|
||||
videos: Video[];
|
||||
total: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
export default function Admin() {
|
||||
const [editingVideo, setEditingVideo] = useState<Video | null>(null);
|
||||
const [showUploadForm, setShowUploadForm] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
// Fetch videos
|
||||
const { data: videosResponse, isLoading } = useQuery<VideosResponse>({
|
||||
queryKey: ["/api/videos", { limit: 100, offset: 0 }],
|
||||
queryFn: async ({ queryKey }) => {
|
||||
const [, params] = queryKey as [string, any];
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
searchParams.append(key, String(value));
|
||||
}
|
||||
});
|
||||
|
||||
const response = await fetch(`/api/videos?${searchParams}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch videos');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
});
|
||||
|
||||
const videos = videosResponse?.videos || [];
|
||||
const totalVideos = videosResponse?.total || 0;
|
||||
const totalViews = videos.reduce((sum, video) => sum + video.views, 0);
|
||||
const avgDuration = videos.length > 0 ? videos.reduce((sum, video) => sum + video.duration, 0) / videos.length : 0;
|
||||
|
||||
// Update video mutation
|
||||
const updateVideoMutation = useMutation({
|
||||
mutationFn: async (updatedVideo: Partial<Video> & { id: string }) => {
|
||||
const response = await fetch(`/api/videos/${updatedVideo.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updatedVideo)
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to update video');
|
||||
return response.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/videos"] });
|
||||
setEditingVideo(null);
|
||||
toast({
|
||||
title: "Video Updated",
|
||||
description: "Video has been successfully updated.",
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to update video. Please try again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Delete video mutation
|
||||
const deleteVideoMutation = useMutation({
|
||||
mutationFn: async (videoId: string) => {
|
||||
const response = await fetch(`/api/videos/${videoId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to delete video');
|
||||
return response.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/videos"] });
|
||||
toast({
|
||||
title: "Video Deleted",
|
||||
description: "Video has been successfully deleted.",
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to delete video. Please try again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const handleSaveEdit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (!editingVideo) return;
|
||||
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const updatedVideo = {
|
||||
id: editingVideo.id,
|
||||
title: formData.get('title') as string,
|
||||
description: formData.get('description') as string,
|
||||
category: formData.get('category') as string,
|
||||
tags: (formData.get('tags') as string).split(',').map(tag => tag.trim()).filter(Boolean),
|
||||
isPublic: formData.get('isPublic') === 'true'
|
||||
};
|
||||
|
||||
updateVideoMutation.mutate(updatedVideo);
|
||||
};
|
||||
|
||||
const formatDuration = (seconds: number): string => {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const 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();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-bunny-dark">
|
||||
{/* Admin Header */}
|
||||
<div className="bg-bunny-gray/80 backdrop-blur-sm border-b border-gray-700">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-20">
|
||||
<div className="flex items-center space-x-3">
|
||||
<BarChart3 className="text-bunny-blue text-4xl" />
|
||||
<h1 className="text-4xl font-bold bg-gradient-to-r from-bunny-light to-bunny-blue bg-clip-text text-transparent tracking-wide">g4.video Admin</h1>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => setShowUploadForm(true)}
|
||||
className="bg-bunny-blue hover:bg-blue-600 text-white"
|
||||
data-testid="button-upload-video"
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
Upload Video
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
||||
<Card className="bg-bunny-gray border-gray-700">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-bunny-muted">Total Videos</CardTitle>
|
||||
<PlayCircle className="h-4 w-4 text-bunny-blue" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-bunny-light">{totalVideos}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-bunny-gray border-gray-700">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-bunny-muted">Total Views</CardTitle>
|
||||
<Users className="h-4 w-4 text-bunny-blue" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-bunny-light">{formatViews(totalViews)}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-bunny-gray border-gray-700">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-bunny-muted">Avg Duration</CardTitle>
|
||||
<Clock className="h-4 w-4 text-bunny-blue" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-bunny-light">{formatDuration(Math.round(avgDuration))}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-bunny-gray border-gray-700">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-bunny-muted">Storage</CardTitle>
|
||||
<BarChart3 className="h-4 w-4 text-bunny-blue" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-bunny-light">Bunny.net</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Videos Management */}
|
||||
<Card className="bg-bunny-gray border-gray-700">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-bunny-light">Video Management</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-bunny-blue mx-auto"></div>
|
||||
<p className="text-bunny-muted mt-4">Loading videos...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{videos.map((video) => (
|
||||
<div key={video.id} className="flex items-center justify-between p-4 bg-bunny-dark rounded-lg border border-gray-600">
|
||||
<div className="flex items-center space-x-4">
|
||||
<img
|
||||
src={video.thumbnailUrl}
|
||||
alt={video.title}
|
||||
className="w-24 h-16 object-cover rounded"
|
||||
data-testid={`img-admin-thumbnail-${video.id}`}
|
||||
/>
|
||||
<div>
|
||||
<h3 className="font-medium text-bunny-light" data-testid={`text-admin-title-${video.id}`}>
|
||||
{video.title}
|
||||
</h3>
|
||||
<p className="text-sm text-bunny-muted">
|
||||
{formatViews(video.views)} views • {formatDuration(video.duration)}
|
||||
</p>
|
||||
<p className="text-xs text-bunny-muted">
|
||||
Category: {video.category || 'None'} • {video.isPublic ? 'Public' : 'Private'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
onClick={() => setEditingVideo(video)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-bunny-light hover:text-bunny-blue"
|
||||
data-testid={`button-edit-${video.id}`}
|
||||
>
|
||||
<Edit3 className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => deleteVideoMutation.mutate(video.id)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-red-400 hover:text-red-300"
|
||||
data-testid={`button-delete-${video.id}`}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Edit Video Modal */}
|
||||
{editingVideo && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-bunny-gray rounded-lg p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-bold text-bunny-light">Edit Video</h2>
|
||||
<Button
|
||||
onClick={() => setEditingVideo(null)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
data-testid="button-close-edit"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSaveEdit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-bunny-light mb-2">Title</label>
|
||||
<Input
|
||||
name="title"
|
||||
defaultValue={editingVideo.title}
|
||||
className="bg-bunny-dark border-gray-600 text-bunny-light"
|
||||
data-testid="input-edit-title"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-bunny-light mb-2">Description</label>
|
||||
<Textarea
|
||||
name="description"
|
||||
defaultValue={editingVideo.description || ''}
|
||||
className="bg-bunny-dark border-gray-600 text-bunny-light"
|
||||
rows={4}
|
||||
data-testid="textarea-edit-description"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-bunny-light mb-2">Category</label>
|
||||
<Select name="category" defaultValue={editingVideo.category || ''}>
|
||||
<SelectTrigger className="bg-bunny-dark border-gray-600 text-bunny-light" data-testid="select-edit-category">
|
||||
<SelectValue placeholder="Select category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="music">Music</SelectItem>
|
||||
<SelectItem value="entertainment">Entertainment</SelectItem>
|
||||
<SelectItem value="education">Education</SelectItem>
|
||||
<SelectItem value="gaming">Gaming</SelectItem>
|
||||
<SelectItem value="sports">Sports</SelectItem>
|
||||
<SelectItem value="technology">Technology</SelectItem>
|
||||
<SelectItem value="other">Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-bunny-light mb-2">Tags (comma separated)</label>
|
||||
<Input
|
||||
name="tags"
|
||||
defaultValue={editingVideo.tags?.join(', ') || ''}
|
||||
className="bg-bunny-dark border-gray-600 text-bunny-light"
|
||||
placeholder="tag1, tag2, tag3"
|
||||
data-testid="input-edit-tags"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-bunny-light mb-2">Privacy</label>
|
||||
<Select name="isPublic" defaultValue={editingVideo.isPublic ? 'true' : 'false'}>
|
||||
<SelectTrigger className="bg-bunny-dark border-gray-600 text-bunny-light" data-testid="select-edit-privacy">
|
||||
<SelectValue placeholder="Select privacy" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">Public</SelectItem>
|
||||
<SelectItem value="false">Private</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-2 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setEditingVideo(null)}
|
||||
variant="ghost"
|
||||
data-testid="button-cancel-edit"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="bg-bunny-blue hover:bg-blue-600 text-white"
|
||||
disabled={updateVideoMutation.isPending}
|
||||
data-testid="button-save-edit"
|
||||
>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{updateVideoMutation.isPending ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -3,7 +3,6 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { type Video } from "@shared/schema";
|
||||
import SearchHeader from "@/components/search-header";
|
||||
import VideoGrid from "@/components/video-grid";
|
||||
import AdSettings from "@/components/ad-settings";
|
||||
|
||||
|
||||
interface VideosResponse {
|
||||
@ -17,7 +16,7 @@ export default function Home() {
|
||||
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [allVideos, setAllVideos] = useState<Video[]>([]);
|
||||
const [showAdSettings, setShowAdSettings] = useState(false);
|
||||
|
||||
|
||||
|
||||
// Fetch videos
|
||||
@ -78,7 +77,6 @@ export default function Home() {
|
||||
onSearch={handleSearch}
|
||||
onViewChange={setViewMode}
|
||||
currentView={viewMode}
|
||||
onAdSettingsOpen={() => setShowAdSettings(true)}
|
||||
/>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
@ -91,11 +89,7 @@ export default function Home() {
|
||||
/>
|
||||
</main>
|
||||
|
||||
{/* VAST Ad Settings Modal */}
|
||||
<AdSettings
|
||||
isOpen={showAdSettings}
|
||||
onClose={() => setShowAdSettings(false)}
|
||||
/>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user