Update CSS to change the header's position from sticky to fixed, ensuring it remains visible at the top of the viewport. Add padding to the body to prevent content from being obscured by the fixed header. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 051a65da-1176-4478-a61c-c662f2a15536 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/8cc42625-c1f5-4e43-99bd-77f2c4dedee2/051a65da-1176-4478-a61c-c662f2a15536/BesXHXW
518 lines
21 KiB
TypeScript
518 lines
21 KiB
TypeScript
import { useState, useEffect } from "react";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { useRoute } from "wouter";
|
|
import { type Video } from "@shared/schema";
|
|
|
|
import go4LogoPath from "@assets/go4_1756394900352.png";
|
|
// Helper functions
|
|
const formatViews = (views: number): string => {
|
|
if (views >= 1000000) return `${(views / 1000000).toFixed(1)}M`;
|
|
if (views >= 1000) return `${(views / 1000).toFixed(1)}K`;
|
|
return views.toString();
|
|
};
|
|
|
|
const formatDuration = (seconds: number): string => {
|
|
const hours = Math.floor(seconds / 3600);
|
|
const minutes = Math.floor((seconds % 3600) / 60);
|
|
const secs = seconds % 60;
|
|
|
|
if (hours > 0) {
|
|
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
|
}
|
|
return `${minutes}:${secs.toString().padStart(2, '0')}`;
|
|
};
|
|
|
|
const formatDate = (date: Date | string): string => {
|
|
const d = typeof date === 'string' ? new Date(date) : date;
|
|
return d.toLocaleDateString('en-US', {
|
|
year: 'numeric',
|
|
month: 'long',
|
|
day: 'numeric'
|
|
});
|
|
};
|
|
import { Button } from "@/components/ui/button";
|
|
import { Share2, X, Edit3, Menu, Search, ChevronLeft, ChevronRight } from "lucide-react";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Link } from "wouter";
|
|
import { apiRequest } from "@/lib/queryClient";
|
|
import {
|
|
FacebookShareButton,
|
|
TwitterShareButton,
|
|
WhatsappShareButton,
|
|
FacebookIcon,
|
|
TwitterIcon,
|
|
WhatsappIcon,
|
|
} from "react-share";
|
|
|
|
interface VideosResponse {
|
|
videos: Video[];
|
|
total: number;
|
|
hasMore: boolean;
|
|
}
|
|
|
|
export default function VideoPage() {
|
|
const [, params] = useRoute("/video/:id");
|
|
const videoId = params?.id;
|
|
const [showShareMenu, setShowShareMenu] = useState(false);
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
|
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
|
|
|
// Fetch current video
|
|
const { data: currentVideo, isLoading: videoLoading } = useQuery<Video>({
|
|
queryKey: [`/api/videos/${videoId}`],
|
|
queryFn: () => fetch(`/api/videos/${videoId}`).then(res => res.json()),
|
|
enabled: !!videoId,
|
|
});
|
|
|
|
// Fetch recommended videos (excluding current video)
|
|
const { data: recommendedResponse } = useQuery<VideosResponse>({
|
|
queryKey: ["/api/videos"],
|
|
queryFn: () => fetch("/api/videos?limit=20&offset=0").then(res => res.json()),
|
|
enabled: !!videoId,
|
|
});
|
|
|
|
const recommendedVideos = recommendedResponse?.videos?.filter(v => v.id !== videoId) || [];
|
|
const allVideos = recommendedResponse?.videos || [];
|
|
|
|
// Navigation functions
|
|
const getCurrentVideoIndex = () => {
|
|
if (!currentVideo || !allVideos.length) return -1;
|
|
return allVideos.findIndex((v) => v.id === currentVideo.id);
|
|
};
|
|
|
|
const navigateToVideo = (direction: 'next' | 'prev') => {
|
|
const currentIndex = getCurrentVideoIndex();
|
|
if (currentIndex === -1) return;
|
|
|
|
let newIndex;
|
|
if (direction === 'next') {
|
|
newIndex = currentIndex + 1 >= allVideos.length ? 0 : currentIndex + 1;
|
|
} else {
|
|
newIndex = currentIndex - 1 < 0 ? allVideos.length - 1 : currentIndex - 1;
|
|
}
|
|
|
|
const newVideo = allVideos[newIndex];
|
|
if (newVideo) {
|
|
window.location.href = `/video/${newVideo.id}`;
|
|
}
|
|
};
|
|
|
|
|
|
// Update page meta tags for social sharing
|
|
useEffect(() => {
|
|
if (currentVideo) {
|
|
// Update page title
|
|
document.title = `${currentVideo.title} | go4.video`;
|
|
|
|
// Update meta description
|
|
const metaDescription = document.querySelector('meta[name="description"]');
|
|
if (metaDescription) {
|
|
metaDescription.setAttribute('content', currentVideo.description || `Watch ${currentVideo.title} on go4.video`);
|
|
}
|
|
|
|
// Update Open Graph tags
|
|
const updateMetaTag = (property: string, content: string) => {
|
|
let meta = document.querySelector(`meta[property="${property}"]`);
|
|
if (!meta) {
|
|
meta = document.createElement('meta');
|
|
meta.setAttribute('property', property);
|
|
document.head.appendChild(meta);
|
|
}
|
|
meta.setAttribute('content', content);
|
|
};
|
|
|
|
updateMetaTag('og:title', currentVideo.title);
|
|
updateMetaTag('og:description', currentVideo.description || `Watch ${currentVideo.title} on go4.video`);
|
|
// Use custom thumbnail if available, otherwise default thumbnail
|
|
const thumbnailForSharing = currentVideo.customThumbnailUrl || currentVideo.thumbnailUrl;
|
|
updateMetaTag('og:image', thumbnailForSharing);
|
|
updateMetaTag('og:url', window.location.href);
|
|
updateMetaTag('og:type', 'video.other');
|
|
updateMetaTag('og:video:duration', currentVideo.duration.toString());
|
|
|
|
// Update Twitter Card tags
|
|
const updateTwitterTag = (name: string, content: string) => {
|
|
let meta = document.querySelector(`meta[name="${name}"]`);
|
|
if (!meta) {
|
|
meta = document.createElement('meta');
|
|
meta.setAttribute('name', name);
|
|
document.head.appendChild(meta);
|
|
}
|
|
meta.setAttribute('content', content);
|
|
};
|
|
|
|
updateTwitterTag('twitter:title', currentVideo.title);
|
|
updateTwitterTag('twitter:description', currentVideo.description || `Watch ${currentVideo.title} on go4.video`);
|
|
updateTwitterTag('twitter:image', thumbnailForSharing);
|
|
}
|
|
}, [currentVideo]);
|
|
|
|
|
|
// Track video view
|
|
const handleVideoPlay = async () => {
|
|
if (currentVideo) {
|
|
try {
|
|
await apiRequest("POST", `/api/videos/${currentVideo.id}/view`);
|
|
} catch (error) {
|
|
console.error("Failed to track video view:", error);
|
|
}
|
|
}
|
|
};
|
|
|
|
const getShareUrl = () => {
|
|
if (!currentVideo?.id) return window.location.origin;
|
|
// Use custom domain if set, otherwise current domain
|
|
const baseUrl = import.meta.env.VITE_SHARE_DOMAIN || window.location.origin;
|
|
return `${baseUrl}/video/${currentVideo.id}`;
|
|
};
|
|
|
|
const copyToClipboard = async () => {
|
|
try {
|
|
await navigator.clipboard.writeText(getShareUrl());
|
|
const notification = document.createElement('div');
|
|
notification.textContent = 'Link copied!';
|
|
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);
|
|
}
|
|
};
|
|
|
|
if (videoLoading) {
|
|
return (
|
|
<div className="min-h-screen bg-bunny-dark flex items-center justify-center">
|
|
<div className="text-white">Nalagam video...</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!currentVideo) {
|
|
return (
|
|
<div className="min-h-screen bg-bunny-dark flex items-center justify-center">
|
|
<div className="text-white">Video not found</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bunny-dark static-triangles has-fixed-header">
|
|
{/* Header */}
|
|
<div className="header-sticky bg-transparent overflow-hidden">
|
|
<div className="max-w-7xl mx-auto px-4 py-4">
|
|
<div className="flex items-center justify-between">
|
|
{/* Left side - Logo */}
|
|
<div className="flex items-center space-x-4">
|
|
<Link href="/" className="flex items-center space-x-2 hover:opacity-80 transition-opacity">
|
|
<div className="w-9 h-9 gradient-primary rounded-lg flex items-center justify-center shadow-lg">
|
|
<div className="w-0 h-0 border-l-[10px] border-l-white border-y-[7px] border-y-transparent ml-1"></div>
|
|
</div>
|
|
<h1 className="text-2xl font-bold text-white tracking-wide">go4.video</h1>
|
|
</Link>
|
|
</div>
|
|
|
|
{/* Right side - Navigation + Search */}
|
|
<div className="flex items-center gap-4">
|
|
{/* Desktop navigation */}
|
|
<div className="hidden md:flex items-center space-x-6">
|
|
<nav className="flex space-x-6">
|
|
<Link href="/" className="text-bunny-light hover:text-bunny-blue transition-colors">
|
|
Home
|
|
</Link>
|
|
<Link href="/folx-stadl" className="text-bunny-light hover:text-bunny-blue transition-colors">
|
|
FOLX STADL
|
|
</Link>
|
|
</nav>
|
|
|
|
<div className="relative">
|
|
<Input
|
|
type="search"
|
|
placeholder="Search videos..."
|
|
value={searchQuery}
|
|
onChange={(e) => {
|
|
setSearchQuery(e.target.value);
|
|
if (e.target.value) window.location.href = `/?search=${encodeURIComponent(e.target.value)}`;
|
|
}}
|
|
className="bg-white border border-gray-300 rounded-lg px-4 py-2 pl-10 text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:border-bunny-blue transition-colors w-64"
|
|
/>
|
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="max-w-7xl mx-auto p-4 lg:p-6 relative">
|
|
{/* Background logo decorations */}
|
|
<div
|
|
style={{
|
|
position: 'absolute',
|
|
top: '20%',
|
|
right: '8%',
|
|
transform: 'rotate(-15deg)',
|
|
width: 'clamp(200px, 20vw, 400px)',
|
|
height: 'clamp(100px, 10vw, 200px)',
|
|
backgroundImage: `url(${go4LogoPath})`,
|
|
backgroundSize: 'contain',
|
|
backgroundRepeat: 'no-repeat',
|
|
backgroundPosition: 'center',
|
|
pointerEvents: 'none',
|
|
zIndex: 0,
|
|
opacity: 0.15,
|
|
filter: 'blur(1px)'
|
|
}}
|
|
/>
|
|
<div
|
|
style={{
|
|
position: 'absolute',
|
|
top: '60%',
|
|
left: '5%',
|
|
transform: 'rotate(20deg)',
|
|
width: 'clamp(150px, 15vw, 300px)',
|
|
height: 'clamp(75px, 7.5vw, 150px)',
|
|
backgroundImage: `url(${go4LogoPath})`,
|
|
backgroundSize: 'contain',
|
|
backgroundRepeat: 'no-repeat',
|
|
backgroundPosition: 'center',
|
|
pointerEvents: 'none',
|
|
zIndex: 0,
|
|
opacity: 0.12,
|
|
filter: 'blur(0.5px)'
|
|
}}
|
|
/>
|
|
<div
|
|
style={{
|
|
position: 'absolute',
|
|
top: '80%',
|
|
right: '30%',
|
|
transform: 'rotate(-25deg)',
|
|
width: 'clamp(100px, 10vw, 200px)',
|
|
height: 'clamp(50px, 5vw, 100px)',
|
|
backgroundImage: `url(${go4LogoPath})`,
|
|
backgroundSize: 'contain',
|
|
backgroundRepeat: 'no-repeat',
|
|
backgroundPosition: 'center',
|
|
pointerEvents: 'none',
|
|
zIndex: 0,
|
|
opacity: 0.08,
|
|
filter: 'blur(0.5px)'
|
|
}}
|
|
/>
|
|
<div
|
|
style={{
|
|
position: 'absolute',
|
|
top: '5%',
|
|
left: '75%',
|
|
transform: 'rotate(40deg)',
|
|
width: 'clamp(150px, 15vw, 300px)',
|
|
height: 'clamp(75px, 7.5vw, 150px)',
|
|
backgroundImage: `url(${go4LogoPath})`,
|
|
backgroundSize: 'contain',
|
|
backgroundRepeat: 'no-repeat',
|
|
backgroundPosition: 'center',
|
|
pointerEvents: 'none',
|
|
zIndex: 0,
|
|
opacity: 0.12,
|
|
filter: 'blur(0.5px)'
|
|
}}
|
|
/>
|
|
|
|
<div className="flex flex-col lg:flex-row gap-6 relative z-[40]">
|
|
{/* Main video section */}
|
|
<div className="flex-1">
|
|
{/* Video player */}
|
|
<div className="relative w-full h-0 pb-[56.25%] bg-black rounded-lg overflow-hidden mb-4">
|
|
{/* Navigation arrows */}
|
|
{allVideos.length > 1 && (
|
|
<>
|
|
<Button
|
|
onClick={() => navigateToVideo('prev')}
|
|
className="absolute left-4 top-1/2 transform -translate-y-1/2 bg-black/60 hover:bg-black/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/60 hover:bg-black/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>
|
|
</>
|
|
)}
|
|
{currentVideo.videoUrlIframe ? (
|
|
<iframe
|
|
src={currentVideo.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={currentVideo.title}
|
|
/>
|
|
) : (
|
|
<div className="absolute inset-0 flex items-center justify-center text-white">
|
|
<p>Video not available</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Video info */}
|
|
<div className="bg-bunny-gray/50 rounded-lg p-4">
|
|
<div className="flex justify-between items-start mb-4">
|
|
<h1 className="text-xl font-bold text-bunny-light flex-1 pr-4">
|
|
{currentVideo.title}
|
|
</h1>
|
|
|
|
<div className="relative">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setShowShareMenu(!showShareMenu)}
|
|
className="text-white hover:bg-gray-700"
|
|
>
|
|
<Share2 className="w-4 h-4 mr-2" />
|
|
Share
|
|
</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]">
|
|
<FacebookShareButton url={getShareUrl()}>
|
|
<div className="w-full px-4 py-2 text-left text-white hover:bg-gray-700 flex items-center gap-2 cursor-pointer">
|
|
<FacebookIcon size={16} round />
|
|
Facebook
|
|
</div>
|
|
</FacebookShareButton>
|
|
<TwitterShareButton url={getShareUrl()} title={`Watch "${currentVideo.title}" on go4.video`}>
|
|
<div className="w-full px-4 py-2 text-left text-white hover:bg-gray-700 flex items-center gap-2 cursor-pointer">
|
|
<TwitterIcon size={16} round />
|
|
Twitter
|
|
</div>
|
|
</TwitterShareButton>
|
|
<WhatsappShareButton url={getShareUrl()} title={`Watch "${currentVideo.title}" on go4.video`}>
|
|
<div className="w-full px-4 py-2 text-left text-white hover:bg-gray-700 flex items-center gap-2 cursor-pointer">
|
|
<WhatsappIcon size={16} round />
|
|
WhatsApp
|
|
</div>
|
|
</WhatsappShareButton>
|
|
<button
|
|
onClick={copyToClipboard}
|
|
className="w-full px-4 py-2 text-left text-white hover:bg-gray-700"
|
|
>
|
|
Copy Link
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap gap-4 text-sm text-bunny-muted mb-4">
|
|
<span>{formatViews(currentVideo.views)} views</span>
|
|
<span>{formatDuration(currentVideo.duration)}</span>
|
|
<span>{formatDate(currentVideo.createdAt)}</span>
|
|
</div>
|
|
|
|
|
|
|
|
{currentVideo.description ? (
|
|
<div className="text-bunny-light">
|
|
<p className="text-sm leading-relaxed">{currentVideo.description}</p>
|
|
</div>
|
|
) : (
|
|
<div className="text-bunny-muted text-sm">
|
|
<p>Video description not available.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Recommended videos sidebar */}
|
|
<div className="w-full lg:w-96">
|
|
<h2 className="text-lg font-semibold text-bunny-light mb-4">Recommended Videos</h2>
|
|
|
|
<div className="space-y-3">
|
|
{recommendedVideos.slice(0, 10).map((video) => (
|
|
<div
|
|
key={video.id}
|
|
onClick={() => window.location.href = `/video/${video.id}`}
|
|
className="flex gap-3 p-3 bg-bunny-gray/30 hover:bg-bunny-gray/50 rounded-lg cursor-pointer transition-colors"
|
|
>
|
|
<div className="relative w-24 h-16 bg-gray-700 rounded overflow-hidden flex-shrink-0">
|
|
<img
|
|
src={video.thumbnailUrl}
|
|
alt={video.title}
|
|
className="w-full h-full object-cover"
|
|
onError={(e) => {
|
|
const target = e.target as HTMLImageElement;
|
|
console.log('Sidebar thumbnail failed:', target.src);
|
|
|
|
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 items-center justify-center text-white text-xs';
|
|
placeholder.innerHTML = '<div>🎬</div>';
|
|
target.parentElement.appendChild(placeholder);
|
|
}
|
|
}}
|
|
/>
|
|
<div className="absolute bottom-1 right-1 bg-black/70 text-white text-xs px-1 py-0.5 rounded">
|
|
{formatDuration(video.duration)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1 min-w-0">
|
|
<h3 className="text-sm font-medium text-bunny-light mb-1 line-clamp-2"
|
|
style={{
|
|
display: '-webkit-box',
|
|
WebkitLineClamp: 2,
|
|
WebkitBoxOrient: 'vertical',
|
|
overflow: 'hidden'
|
|
}}>
|
|
{video.title}
|
|
</h3>
|
|
<div className="text-xs text-bunny-muted space-y-0.5">
|
|
<div>{formatViews(video.views)} views</div>
|
|
<div>{formatDate(video.createdAt)}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Footer - same as home page */}
|
|
<footer className="bunny-gray/50 border-t border-white/10 mt-16 relative overflow-hidden">
|
|
{/* Triangle decorations in footer - manjši in bolje pozicionirani */}
|
|
<div className="absolute top-2 right-10 w-0 h-0 border-l-[20px] border-l-transparent border-r-[20px] border-r-transparent border-b-[25px] border-b-purple-400/5 rotate-12 z-0"></div>
|
|
<div className="absolute top-1 left-10 w-0 h-0 border-l-[15px] border-l-transparent border-r-[15px] border-r-transparent border-b-[20px] border-b-blue-400/4 -rotate-12 z-0"></div>
|
|
|
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 relative z-10">
|
|
<div className="flex flex-col md:flex-row items-center justify-between">
|
|
<div className="flex items-center space-x-2 mb-4 md:mb-0">
|
|
<div className="w-8 h-8 gradient-primary rounded-lg flex items-center justify-center shadow-md">
|
|
<div className="w-0 h-0 border-l-[8px] border-l-white border-y-[6px] border-y-transparent ml-0.5"></div>
|
|
</div>
|
|
<span className="text-lg font-semibold text-white">go4.video</span>
|
|
</div>
|
|
|
|
<div className="text-sm text-bunny-muted">
|
|
© 2024 go4.video. All rights reserved.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</footer>
|
|
</div>
|
|
);
|
|
} |