Add administration interface for managing videos and viewing platform statistics

Implements a new /bunny-admin route with a dedicated page for video management and statistics, integrates a BunnyVideoModal for video playback and sharing, and adds backend API endpoints for Bunny.net statistics and video deletion.

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/plFksdF
This commit is contained in:
sebastjanartic
2025-08-08 21:10:03 +00:00
parent 27358b647a
commit 78b5327e59
7 changed files with 688 additions and 6 deletions
+287
View File
@@ -0,0 +1,287 @@
import { useEffect, useState } from "react";
import { X, Share2, Edit3 } from "lucide-react";
import { type Video } from "@shared/schema";
import { Button } from "@/components/ui/button";
import { apiRequest } from "@/lib/queryClient";
import {
FacebookIcon,
TwitterIcon,
WhatsappIcon
} from "react-share";
interface BunnyVideoModalProps {
video: Video | null;
isOpen: boolean;
onClose: () => void;
onEdit?: () => void;
}
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 ogledov`;
} else if (views >= 1000) {
return `${(views / 1000).toFixed(1)}K ogledov`;
}
return `${views} ogledov`;
}
function formatDate(date: Date | string): string {
const now = new Date();
const createdDate = typeof date === 'string' ? new Date(date) : date;
if (!createdDate || isNaN(createdDate.getTime())) {
return "Neznano";
}
const diffTime = Math.abs(now.getTime() - createdDate.getTime());
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
if (diffDays === 0) return "Danes";
if (diffDays === 1) return "Pred 1 dnem";
if (diffDays < 7) return `Pred ${diffDays} dnevi`;
if (diffDays < 30) return `Pred ${Math.floor(diffDays / 7)} tednom${Math.floor(diffDays / 7) > 1 ? 'a' : ''}`;
return `Pred ${Math.floor(diffDays / 30)} mesec${Math.floor(diffDays / 30) > 1 ? 'i' : 'em'}`;
}
export default function BunnyVideoModal({ video, isOpen, onClose, onEdit }: BunnyVideoModalProps) {
const [showShareMenu, setShowShareMenu] = useState(false);
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape" && isOpen) {
onClose();
}
};
if (isOpen) {
document.addEventListener("keydown", handleEscape);
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
}
return () => {
document.removeEventListener("keydown", handleEscape);
document.body.style.overflow = "";
};
}, [isOpen, onClose]);
const handleVideoPlay = async () => {
if (video) {
try {
await apiRequest("POST", `/api/videos/${video.id}/view`);
} catch (error) {
console.error("Failed to track video view:", error);
}
}
};
const getShareUrl = () => {
if (!video?.id) return window.location.origin;
return `${window.location.origin}?video=${video.id}`;
};
const copyToClipboard = async () => {
try {
await navigator.clipboard.writeText(getShareUrl());
const notification = document.createElement('div');
notification.textContent = 'Povezava kopirana!';
notification.className = 'fixed top-4 right-4 bg-green-500 text-white px-4 py-2 rounded-lg z-50 transition-opacity duration-300';
document.body.appendChild(notification);
setTimeout(() => {
notification.style.opacity = '0';
setTimeout(() => document.body.removeChild(notification), 300);
}, 2000);
setShowShareMenu(false);
} catch (error) {
console.error('Failed to copy link:', error);
const textArea = document.createElement('textarea');
textArea.value = getShareUrl();
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
setShowShareMenu(false);
}
};
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
onClose();
}
};
const shareOnFacebook = () => {
const url = `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(getShareUrl())}&t=${encodeURIComponent(video?.title || '')}`;
window.open(url, 'facebook-share', 'width=600,height=400');
setShowShareMenu(false);
};
const shareOnTwitter = () => {
const text = `Poglej si "${video?.title}" na go4.video`;
const url = `https://twitter.com/intent/tweet?text=${encodeURIComponent(text)}&url=${encodeURIComponent(getShareUrl())}`;
window.open(url, 'twitter-share', 'width=600,height=400');
setShowShareMenu(false);
};
const shareOnWhatsApp = () => {
const text = `Poglej si "${video?.title}" na go4.video: ${getShareUrl()}`;
const url = `https://wa.me/?text=${encodeURIComponent(text)}`;
window.open(url, 'whatsapp-share', 'width=600,height=400');
setShowShareMenu(false);
};
if (!isOpen || !video) return null;
return (
<div
className="fixed inset-0 bg-black bg-opacity-90 flex items-center justify-center z-50"
onClick={handleBackdropClick}
style={{ backgroundColor: '#1f2937' }}
>
<div className="relative w-full h-full max-w-7xl mx-auto p-4 flex flex-col">
{/* Header with close button */}
<div className="flex justify-between items-center mb-4 z-10">
<h2 className="text-xl font-bold text-white truncate flex-1 mr-4">
{video.title}
</h2>
<div className="flex gap-2">
{onEdit && (
<Button
variant="ghost"
size="sm"
onClick={onEdit}
className="text-white hover:bg-gray-700"
data-testid="button-edit-video"
>
<Edit3 className="w-4 h-4" />
Uredi
</Button>
)}
<div className="relative">
<Button
variant="ghost"
size="sm"
onClick={() => setShowShareMenu(!showShareMenu)}
className="text-white hover:bg-gray-700"
data-testid="button-share-video"
>
<Share2 className="w-4 h-4" />
Deli
</Button>
{showShareMenu && (
<div
className="absolute right-0 top-full mt-2 bg-gray-800 rounded-lg shadow-lg py-2 z-50 min-w-[200px]"
style={{ backgroundColor: '#374151' }}
>
<button
onClick={shareOnFacebook}
className="w-full px-4 py-2 text-left text-white hover:bg-gray-700 flex items-center gap-2"
data-testid="button-share-facebook"
>
<FacebookIcon size={16} round />
Facebook
</button>
<button
onClick={shareOnTwitter}
className="w-full px-4 py-2 text-left text-white hover:bg-gray-700 flex items-center gap-2"
data-testid="button-share-twitter"
>
<TwitterIcon size={16} round />
Twitter
</button>
<button
onClick={shareOnWhatsApp}
className="w-full px-4 py-2 text-left text-white hover:bg-gray-700 flex items-center gap-2"
data-testid="button-share-whatsapp"
>
<WhatsappIcon size={16} round />
WhatsApp
</button>
<button
onClick={copyToClipboard}
className="w-full px-4 py-2 text-left text-white hover:bg-gray-700"
data-testid="button-copy-link"
>
Kopiraj povezavo
</button>
</div>
)}
</div>
<Button
variant="ghost"
size="sm"
onClick={onClose}
className="text-white hover:bg-gray-700"
data-testid="button-close-modal"
>
<X className="w-4 h-4" />
</Button>
</div>
</div>
{/* Video player area */}
<div className="flex-1 flex flex-col lg:flex-row gap-4">
{/* Main video player */}
<div className="flex-1">
<div className="relative w-full h-0 pb-[56.25%] bg-black rounded-lg overflow-hidden">
{video.videoUrlIframe ? (
<iframe
src={video.videoUrlIframe}
className="absolute inset-0 w-full h-full"
frameBorder="0"
allowFullScreen
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
onLoad={handleVideoPlay}
title={video.title}
/>
) : (
<div className="absolute inset-0 flex items-center justify-center text-white">
<p>Video ni na voljo</p>
</div>
)}
</div>
</div>
{/* Video info sidebar */}
<div className="lg:w-80 space-y-4">
<div className="p-4 bg-gray-800 rounded-lg text-white">
<h3 className="font-bold text-lg mb-2" data-testid="text-video-title">
{video.title}
</h3>
<div className="flex flex-wrap gap-4 text-sm text-gray-300 mb-3">
<span data-testid="text-video-views">{formatViews(video.views)}</span>
<span data-testid="text-video-duration">{formatDuration(video.duration)}</span>
<span data-testid="text-video-date">{formatDate(video.createdAt)}</span>
</div>
{video.category && (
<div className="mb-3">
<span className="inline-block bg-blue-600 text-white px-2 py-1 rounded text-xs" data-testid="text-video-category">
{video.category}
</span>
</div>
)}
{video.description && (
<div className="text-sm text-gray-300">
<p className="leading-relaxed" data-testid="text-video-description">
{video.description}
</p>
</div>
)}
</div>
</div>
</div>
</div>
</div>
);
}
+3
View File
@@ -45,6 +45,9 @@ export default function SearchHeader({
<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"
+2 -2
View File
@@ -1,7 +1,7 @@
import { useState } from "react";
import { type Video } from "@shared/schema";
import VideoCard from "./video-card";
import VideoModal from "./video-modal";
import BunnyVideoModal from "./bunny-video-modal";
import { Button } from "@/components/ui/button";
import { ChevronDown } from "lucide-react";
@@ -95,7 +95,7 @@ export default function VideoGrid({ videos, isLoading, hasMore, onLoadMore, view
</div>
)}
<VideoModal
<BunnyVideoModal
video={selectedVideo}
isOpen={isModalOpen}
onClose={handleCloseModal}