Replace color values `#ec4c73` and `gradient-primary` with `#e91e63` across multiple components including CookieConsent, loading-spinner, netflix-grid, search-header, thumbnail-generator, video-ads, video-card, video-grid, video-modal, and various page components to unify the brand's visual identity. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 946a0075-7e32-454b-b348-9e7f576d7f45 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/60d372ff-2c10-46c7-b01b-10c3435136b0/946a0075-7e32-454b-b348-9e7f576d7f45/nDZrZ7M
395 lines
16 KiB
TypeScript
395 lines
16 KiB
TypeScript
import { Play, Volume2, VolumeX } from "lucide-react";
|
|
import { type Video } from "@shared/schema";
|
|
import { useState, useRef, useEffect } from "react";
|
|
import Hls from "hls.js";
|
|
|
|
interface VideoCardProps {
|
|
video: Video;
|
|
onClick: (video: Video) => void;
|
|
className?: string;
|
|
hideOverlay?: boolean;
|
|
}
|
|
|
|
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 views`;
|
|
} else if (views >= 1000) {
|
|
return `${(views / 1000).toFixed(1)}K views`;
|
|
}
|
|
return `${views} views`;
|
|
}
|
|
|
|
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 "Unknown";
|
|
}
|
|
|
|
const diffTime = Math.abs(now.getTime() - createdDate.getTime());
|
|
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
|
|
|
|
if (diffDays === 0) return "Today";
|
|
if (diffDays === 1) return "1 day ago";
|
|
if (diffDays < 7) return `${diffDays} days ago`;
|
|
if (diffDays < 30) return `${Math.floor(diffDays / 7)} week${Math.floor(diffDays / 7) > 1 ? 's' : ''} ago`;
|
|
return `${Math.floor(diffDays / 30)} month${Math.floor(diffDays / 30) > 1 ? 's' : ''} ago`;
|
|
}
|
|
|
|
export default function VideoCard({ video, onClick, className = "", hideOverlay = false }: VideoCardProps) {
|
|
// Generate short ID for cleaner URLs (first 8 chars without dashes)
|
|
const shortId = video.id.replace(/-/g, '').substring(0, 8);
|
|
const [isHovered, setIsHovered] = useState(false);
|
|
const [showPreview, setShowPreview] = useState(false);
|
|
const [isMuted, setIsMuted] = useState(true);
|
|
const [showMoreInfo, setShowMoreInfo] = useState(false);
|
|
const [isMobile, setIsMobile] = useState(false);
|
|
const [isVideoPage, setIsVideoPage] = useState(false);
|
|
const hoverTimeoutRef = useRef<NodeJS.Timeout>();
|
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
const hlsRef = useRef<Hls | null>(null);
|
|
const [currentTime, setCurrentTime] = useState(0);
|
|
const [duration, setDuration] = useState(0);
|
|
|
|
// Check device type and page location
|
|
useEffect(() => {
|
|
const checkDevice = () => {
|
|
setIsMobile(window.innerWidth < 768);
|
|
setIsVideoPage(window.location.pathname.startsWith('/video/'));
|
|
};
|
|
|
|
checkDevice(); // Initial check
|
|
|
|
window.addEventListener('resize', checkDevice);
|
|
window.addEventListener('popstate', checkDevice);
|
|
|
|
return () => {
|
|
window.removeEventListener('resize', checkDevice);
|
|
window.removeEventListener('popstate', checkDevice);
|
|
};
|
|
}, []);
|
|
|
|
// Load mute preference on component mount
|
|
useEffect(() => {
|
|
const savedMuteState = localStorage.getItem('videoPreviewMuted');
|
|
const shouldBeMuted = savedMuteState === null ? true : savedMuteState === 'true';
|
|
setIsMuted(shouldBeMuted);
|
|
}, []);
|
|
|
|
// Listen for changes in localStorage from other components
|
|
useEffect(() => {
|
|
const handleStorageChange = (e: StorageEvent) => {
|
|
if (e.key === 'videoPreviewMuted' && e.newValue !== null) {
|
|
setIsMuted(e.newValue === 'true');
|
|
}
|
|
};
|
|
|
|
const handleMuteChange = (e: CustomEvent) => {
|
|
setIsMuted(e.detail.isMuted);
|
|
};
|
|
|
|
window.addEventListener('storage', handleStorageChange);
|
|
window.addEventListener('videoMuteChanged', handleMuteChange as EventListener);
|
|
|
|
return () => {
|
|
window.removeEventListener('storage', handleStorageChange);
|
|
window.removeEventListener('videoMuteChanged', handleMuteChange as EventListener);
|
|
};
|
|
}, []);
|
|
|
|
// Enable video preview on hover for desktop devices
|
|
useEffect(() => {
|
|
if (isHovered) {
|
|
// Enable preview for desktop devices after delay
|
|
if (window.innerWidth >= 768) {
|
|
const delay = 800;
|
|
hoverTimeoutRef.current = setTimeout(() => {
|
|
setShowPreview(true);
|
|
}, delay);
|
|
}
|
|
} else {
|
|
if (hoverTimeoutRef.current) {
|
|
clearTimeout(hoverTimeoutRef.current);
|
|
}
|
|
setShowPreview(false);
|
|
// Clean up HLS when not hovering
|
|
if (hlsRef.current) {
|
|
hlsRef.current.destroy();
|
|
hlsRef.current = null;
|
|
}
|
|
}
|
|
|
|
return () => {
|
|
if (hoverTimeoutRef.current) {
|
|
clearTimeout(hoverTimeoutRef.current);
|
|
}
|
|
if (hlsRef.current) {
|
|
hlsRef.current.destroy();
|
|
}
|
|
};
|
|
}, [isHovered]);
|
|
|
|
// Initialize HLS when preview shows
|
|
useEffect(() => {
|
|
if (showPreview && videoRef.current && video.videoUrl.includes('.m3u8')) {
|
|
const videoElement = videoRef.current;
|
|
|
|
// Ensure video element respects current mute state
|
|
videoElement.muted = isMuted;
|
|
|
|
if (Hls.isSupported()) {
|
|
const hls = new Hls({
|
|
startLevel: 0, // Start with lowest quality
|
|
capLevelToPlayerSize: true,
|
|
maxBufferLength: 5, // Minimal buffering
|
|
});
|
|
|
|
hls.loadSource(video.videoUrl);
|
|
hls.attachMedia(videoElement);
|
|
hlsRef.current = hls;
|
|
|
|
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
|
// Set muted state before playing
|
|
videoElement.muted = isMuted;
|
|
videoElement.play().catch(e => console.log('Preview autoplay failed:', e));
|
|
});
|
|
|
|
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
|
|
// Set muted state after media is attached
|
|
videoElement.muted = isMuted;
|
|
videoElement.addEventListener('loadedmetadata', () => {
|
|
setDuration(videoElement.duration);
|
|
// Ensure muted state is preserved after metadata loads
|
|
videoElement.muted = isMuted;
|
|
});
|
|
});
|
|
} else if (videoElement.canPlayType('application/vnd.apple.mpegurl')) {
|
|
// Safari native HLS support
|
|
videoElement.src = video.videoUrl;
|
|
videoElement.muted = isMuted;
|
|
videoElement.addEventListener('loadedmetadata', () => {
|
|
setDuration(videoElement.duration);
|
|
// Ensure muted state is preserved after metadata loads
|
|
videoElement.muted = isMuted;
|
|
});
|
|
videoElement.play().catch(e => console.log('Preview autoplay failed:', e));
|
|
}
|
|
}
|
|
}, [showPreview, video.videoUrl, isMuted]);
|
|
|
|
return (
|
|
<div
|
|
data-testid={`card-video-${shortId}`}
|
|
className={`video-card transition-transform duration-200 ${isMobile ? '' : 'hover:scale-[1.02]'} ${className}`}
|
|
onMouseEnter={() => !isMobile && setIsHovered(true)}
|
|
onMouseLeave={() => !isMobile && setIsHovered(false)}
|
|
>
|
|
{/* Video preview container */}
|
|
<div
|
|
className="relative gradient-card rounded-xl overflow-hidden aspect-[16/9] cursor-pointer group hover:shadow-2xl transition-all duration-300"
|
|
onClick={() => onClick?.(video)}
|
|
>
|
|
{/* Static thumbnail - always visible */}
|
|
<img
|
|
src={video.thumbnailUrl}
|
|
alt={video.title}
|
|
className="w-full h-full object-cover"
|
|
style={{
|
|
objectPosition: video.faceCenterPosition || 'center center',
|
|
objectFit: 'cover'
|
|
}}
|
|
data-testid={`img-thumbnail-${shortId}`}
|
|
loading="lazy"
|
|
decoding="async"
|
|
onError={(e) => {
|
|
const target = e.target as HTMLImageElement;
|
|
console.log('Thumbnail failed to load:', target.src);
|
|
|
|
// Show placeholder immediately instead of trying multiple URLs
|
|
target.style.display = 'none';
|
|
if (target.parentElement && !target.parentElement.querySelector('.thumbnail-placeholder')) {
|
|
target.parentElement.style.background = 'linear-gradient(135deg, #1f2937, #374151)';
|
|
const placeholder = document.createElement('div');
|
|
placeholder.className = 'thumbnail-placeholder absolute inset-0 flex flex-col items-center justify-center text-white';
|
|
placeholder.innerHTML = '<div style="font-size: 28px; margin-bottom: 4px;">🎬</div><div style="font-size: 10px; opacity: 0.7;">Video</div>';
|
|
target.parentElement.appendChild(placeholder);
|
|
}
|
|
}}
|
|
/>
|
|
|
|
{/* Video preview - only load when hovering */}
|
|
{showPreview && (
|
|
<div className="absolute inset-0 z-10">
|
|
<video
|
|
ref={videoRef}
|
|
className="w-full h-full object-cover"
|
|
style={{
|
|
objectPosition: video.faceCenterPosition || 'center center',
|
|
objectFit: 'cover'
|
|
}}
|
|
autoPlay
|
|
muted={isMuted}
|
|
loop
|
|
playsInline
|
|
controls={false}
|
|
disablePictureInPicture
|
|
onLoadStart={() => {
|
|
// Force muted state on load start
|
|
if (videoRef.current) {
|
|
videoRef.current.muted = isMuted;
|
|
videoRef.current.volume = 0;
|
|
}
|
|
}}
|
|
onError={() => {}}
|
|
onPlay={() => {
|
|
// Force muted state on play
|
|
if (videoRef.current) {
|
|
videoRef.current.muted = isMuted;
|
|
videoRef.current.volume = 0;
|
|
}
|
|
}}
|
|
onCanPlay={() => {
|
|
// Force muted state when video can play
|
|
if (videoRef.current) {
|
|
videoRef.current.muted = isMuted;
|
|
videoRef.current.volume = 0;
|
|
}
|
|
}}
|
|
onTimeUpdate={(e) => setCurrentTime(e.currentTarget.currentTime)}
|
|
onLoadedMetadata={(e) => setDuration(e.currentTarget.duration)}
|
|
onMouseMove={(e) => {
|
|
if (!videoRef.current || duration === 0) return;
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
const x = e.clientX - rect.left;
|
|
const percentage = x / rect.width;
|
|
const newTime = Math.max(0, Math.min(duration, percentage * duration));
|
|
videoRef.current.currentTime = newTime;
|
|
setCurrentTime(newTime);
|
|
}}
|
|
/>
|
|
|
|
{/* Mute/Unmute button */}
|
|
<button
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const newMutedState = !isMuted;
|
|
setIsMuted(newMutedState);
|
|
|
|
// Save preference to localStorage for all future previews
|
|
localStorage.setItem('videoPreviewMuted', newMutedState.toString());
|
|
|
|
// Dispatch custom event to notify other components
|
|
window.dispatchEvent(new CustomEvent('videoMuteChanged', {
|
|
detail: { isMuted: newMutedState }
|
|
}));
|
|
|
|
if (videoRef.current) {
|
|
videoRef.current.muted = newMutedState;
|
|
}
|
|
}}
|
|
className="absolute top-2 right-2 z-20 bg-black/50 hover:bg-black/70 text-white border-none p-2 rounded-full transition-all duration-200 opacity-75 hover:opacity-100"
|
|
data-testid="button-mute-toggle"
|
|
>
|
|
{isMuted ? (
|
|
<VolumeX className="w-4 h-4" />
|
|
) : (
|
|
<Volume2 className="w-4 h-4" />
|
|
)}
|
|
</button>
|
|
|
|
{/* Video scrubbing progress bar - only show during preview */}
|
|
{duration > 0 && (
|
|
<div className="absolute bottom-1 left-2 right-2 h-2 bg-black/40 rounded-full overflow-hidden backdrop-blur-sm border border-white/20">
|
|
<div
|
|
className="h-full bg-gradient-to-r from-[#e91e63] to-[#e91e63] transition-all duration-200 ease-out shadow-lg relative"
|
|
style={{ width: `${(currentTime / duration) * 100}%` }}
|
|
>
|
|
{/* Glow effect */}
|
|
<div className="absolute inset-0 bg-gradient-to-r from-[#f57496] to-[#f06292] opacity-60 blur-sm"></div>
|
|
{/* Progress dot at the end */}
|
|
<div className="absolute right-0 top-1/2 transform translate-x-1/2 -translate-y-1/2 w-3 h-3 bg-white rounded-full shadow-lg border-2 border-[#e91e63]"></div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
|
|
{/* Desktop gradient overlay with title - hidden on mobile video pages */}
|
|
{!showPreview && !hideOverlay && !(isMobile && isVideoPage) && (
|
|
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/20 to-transparent z-10 flex items-end p-4 group-hover:from-black/95 transition-all duration-300">
|
|
<div className="w-full">
|
|
<h3 className={`text-white font-extrabold leading-tight drop-shadow-2xl tracking-wide transform group-hover:scale-105 transition-transform duration-300 mb-1 ${isMobile ? 'text-2xl' : 'text-xl'}`} style={{fontFamily: 'Poppins, Inter, sans-serif', textShadow: '2px 2px 8px rgba(0,0,0,0.8)'}}>
|
|
{(video.title.split(' - ')[0] || 'video.folx.tv').substring(0, 35)}
|
|
</h3>
|
|
<p className={`text-white/85 font-medium drop-shadow-xl transform group-hover:scale-105 transition-transform duration-300 ${isMobile ? 'text-base' : 'text-sm'}`} style={{fontFamily: 'Inter, sans-serif', textShadow: '1px 1px 4px rgba(0,0,0,0.8)'}}>
|
|
{(video.title.split(' - ')[1] || video.title).substring(0, 50)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Mobile info section - below video - only on video pages */}
|
|
{isMobile && isVideoPage && (
|
|
<div className="mt-3 px-1">
|
|
{/* Full title */}
|
|
<h3 className="text-white font-bold text-base leading-tight mb-2" style={{fontFamily: 'Poppins, Inter, sans-serif'}}>
|
|
{video.title.length > 60 ? video.title.substring(0, 60) + '...' : video.title}
|
|
</h3>
|
|
|
|
{/* Views and Date in one line */}
|
|
<div className="flex items-center justify-between text-white/70 text-sm mb-2">
|
|
<span>{formatViews(video.views || 0)}</span>
|
|
<span>{formatDate(video.createdAt)}</span>
|
|
</div>
|
|
|
|
{/* Description with expand/collapse */}
|
|
{(video.title.split(' - ')[1] || video.description) && (
|
|
<div className="text-white/70 text-sm">
|
|
<div className="flex items-end">
|
|
<p className={`flex-1 ${showMoreInfo ? '' : 'line-clamp-1'}`}>
|
|
{video.title.split(' - ')[1] || video.description || ''}
|
|
</p>
|
|
{!showMoreInfo && (
|
|
<button
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setShowMoreInfo(true);
|
|
}}
|
|
className="ml-1 text-blue-400 hover:text-blue-300 text-sm transition-colors duration-200 flex-shrink-0"
|
|
>
|
|
preberi več
|
|
</button>
|
|
)}
|
|
</div>
|
|
{showMoreInfo && (
|
|
<button
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setShowMoreInfo(false);
|
|
}}
|
|
className="mt-2 text-blue-400 hover:text-blue-300 text-sm transition-colors duration-200"
|
|
>
|
|
pokaži manj
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
</div>
|
|
);
|
|
}
|