videofolxtv/client/src/components/bunny-video-modal.tsx
sebastjanartic 38fafb0e50 Improve video player dragging and add visual feedback for navigation
Refine horizontal drag limits and transition animations in the video modal component, enhancing the user experience for navigating between videos.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 2eb1084e-b728-4449-9231-f1665924c8d5
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/8cc42625-c1f5-4e43-99bd-77f2c4dedee2/2eb1084e-b728-4449-9231-f1665924c8d5/plzfVnD
2025-08-28 20:28:48 +00:00

442 lines
16 KiB
TypeScript

import { useEffect, useState } from "react";
import { X, Share2, Edit3, ChevronLeft, ChevronRight } 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;
videos?: Video[];
onVideoChange?: (video: Video) => 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, videos = [], onVideoChange }: BunnyVideoModalProps) {
const [showShareMenu, setShowShareMenu] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
const [dragOffset, setDragOffset] = useState(0);
// Navigation functions
const getCurrentVideoIndex = () => {
if (!video || !videos.length) return -1;
return videos.findIndex((v: Video) => v.id === video.id);
};
const navigateToVideo = (direction: 'next' | 'prev') => {
const currentIndex = getCurrentVideoIndex();
if (currentIndex === -1 || !onVideoChange) return;
let newIndex;
if (direction === 'next') {
newIndex = currentIndex + 1 >= videos.length ? 0 : currentIndex + 1;
} else {
newIndex = currentIndex - 1 < 0 ? videos.length - 1 : currentIndex - 1;
}
const newVideo = videos[newIndex];
if (newVideo) {
onVideoChange(newVideo);
}
};
// Touch and mouse drag handlers
const handleDragStart = (clientX: number, clientY: number) => {
setIsDragging(true);
setDragStart({ x: clientX, y: clientY });
setDragOffset(0);
};
const handleDragMove = (clientX: number, clientY: number) => {
if (!isDragging) return;
const deltaX = clientX - dragStart.x;
const deltaY = Math.abs(clientY - dragStart.y);
// Only allow horizontal drag if it's more horizontal than vertical
if (deltaY < Math.abs(deltaX)) {
// Limit drag distance to prevent excessive movement
const maxDrag = 300;
const limitedDelta = Math.max(-maxDrag, Math.min(maxDrag, deltaX));
setDragOffset(limitedDelta);
}
};
const handleDragEnd = () => {
if (!isDragging) return;
const threshold = 100; // minimum drag distance to trigger navigation
if (Math.abs(dragOffset) > threshold && videos.length > 1) {
if (dragOffset > 0) {
navigateToVideo('prev'); // drag right = previous video
} else {
navigateToVideo('next'); // drag left = next video
}
}
setIsDragging(false);
setDragOffset(0);
};
// Mouse events
const handleMouseDown = (e: React.MouseEvent) => {
// Allow drag on video area but not on buttons
if ((e.target as Element).closest('button')) return;
handleDragStart(e.clientX, e.clientY);
};
const handleMouseMove = (e: React.MouseEvent) => {
handleDragMove(e.clientX, e.clientY);
};
const handleMouseUp = () => {
handleDragEnd();
};
// Touch events
const handleTouchStart = (e: React.TouchEvent) => {
if (e.touches.length !== 1) return;
// Allow drag on video area but not on buttons
if ((e.target as Element).closest('button')) return;
const touch = e.touches[0];
handleDragStart(touch.clientX, touch.clientY);
e.preventDefault();
};
const handleTouchMove = (e: React.TouchEvent) => {
if (e.touches.length !== 1) return;
if (!isDragging) return;
const touch = e.touches[0];
handleDragMove(touch.clientX, touch.clientY);
e.preventDefault();
};
const handleTouchEnd = (e: React.TouchEvent) => {
handleDragEnd();
e.preventDefault();
};
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 flex-1 mr-4 leading-tight">
{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 min-h-0">
{/* Main video player */}
<div className="flex-1">
<div
className="relative w-full h-0 pb-[56.25%] bg-black rounded-lg overflow-hidden select-none"
style={{
transform: `translateX(${dragOffset}px)`,
transition: isDragging ? 'none' : 'transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
willChange: 'transform'
}}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
{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>
)}
{/* Transparent overlay for drag detection */}
<div
className="absolute inset-0 z-10 cursor-grab active:cursor-grabbing"
onMouseDown={handleMouseDown}
onTouchStart={handleTouchStart}
></div>
{/* Navigation buttons */}
{videos.length > 1 && (
<>
<Button
onClick={() => navigateToVideo('prev')}
className="absolute left-4 top-1/2 transform -translate-y-1/2 bg-black bg-opacity-50 hover:bg-opacity-80 text-white border-none p-2 rounded-full z-20"
size="sm"
data-testid="button-prev-video"
>
<ChevronLeft className="w-6 h-6" />
</Button>
<Button
onClick={() => navigateToVideo('next')}
className="absolute right-4 top-1/2 transform -translate-y-1/2 bg-black bg-opacity-50 hover:bg-opacity-80 text-white border-none p-2 rounded-full z-20"
size="sm"
data-testid="button-next-video"
>
<ChevronRight className="w-6 h-6" />
</Button>
{/* Swipe indicators */}
<div className="absolute top-1/2 left-8 right-8 flex justify-between items-center pointer-events-none z-10">
<div className={`bg-black bg-opacity-70 rounded-full p-3 text-white transition-all duration-200 ${dragOffset > 50 ? 'opacity-100 scale-110' : 'opacity-0 scale-95'}`}>
<span className="text-sm font-medium"> Prejšnji</span>
</div>
<div className={`bg-black bg-opacity-70 rounded-full p-3 text-white transition-all duration-200 ${dragOffset < -50 ? 'opacity-100 scale-110' : 'opacity-0 scale-95'}`}>
<span className="text-sm font-medium">Naslednji </span>
</div>
</div>
{/* Video counter */}
<div className="absolute bottom-4 left-1/2 transform -translate-x-1/2 bg-black bg-opacity-50 rounded-full px-3 py-1 text-white text-sm pointer-events-none z-10">
{getCurrentVideoIndex() + 1} od {videos.length}
</div>
</>
)}
</div>
</div>
{/* Video info sidebar */}
<div className="lg:w-80 lg:min-w-80 space-y-4 overflow-y-auto">
<div className="p-4 bg-gray-800 rounded-lg text-white max-h-none overflow-visible">
<h3 className="font-bold text-lg mb-2 whitespace-normal break-words" 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.description && (
<div className="text-sm text-gray-300">
<p className="leading-relaxed whitespace-pre-wrap break-words" data-testid="text-video-description">
{video.description}
</p>
</div>
)}
</div>
</div>
</div>
</div>
</div>
);
}