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
442 lines
16 KiB
TypeScript
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>
|
|
);
|
|
} |