Add dedicated video pages and related content recommendations
Introduce a new route for individual video pages, enabling direct navigation to videos. The video cards now link to this new page. Additionally, a new `VideoPage` component is added to display video details, track plays, and show recommended videos similar to YouTube's interface. 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/gfjV9kW
This commit is contained in:
parent
2c508bf4bc
commit
e57072e917
@ -4,6 +4,7 @@ import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import Home from "@/pages/home";
|
||||
import VideoPage from "@/pages/VideoPage";
|
||||
import Admin from "@/pages/admin";
|
||||
import BunnyAdminPage from "@/pages/BunnyAdminPage";
|
||||
import NotFound from "@/pages/not-found";
|
||||
@ -12,6 +13,7 @@ function Router() {
|
||||
return (
|
||||
<Switch>
|
||||
<Route path="/" component={Home} />
|
||||
<Route path="/video/:id" component={VideoPage} />
|
||||
<Route path="/admin" component={Admin} />
|
||||
<Route path="/bunny-admin" component={BunnyAdminPage} />
|
||||
<Route component={NotFound} />
|
||||
|
||||
@ -49,7 +49,7 @@ export default function VideoCard({ video, onClick }: VideoCardProps) {
|
||||
{/* Simple thumbnail with fallback - no HLS loading until needed */}
|
||||
<div
|
||||
className="relative bg-bunny-gray rounded-xl overflow-hidden mb-4 aspect-video cursor-pointer group"
|
||||
onClick={() => onClick(video)}
|
||||
onClick={() => window.location.href = `/video/${video.id}`}
|
||||
>
|
||||
<img
|
||||
src={video.thumbnailUrl}
|
||||
@ -85,7 +85,7 @@ export default function VideoCard({ video, onClick }: VideoCardProps) {
|
||||
<div className="space-y-2">
|
||||
<h3
|
||||
className="font-semibold line-clamp-2 hover:text-bunny-blue transition-colors text-bunny-light cursor-pointer"
|
||||
onClick={() => onClick(video)}
|
||||
onClick={() => window.location.href = `/video/${video.id}`}
|
||||
data-testid={`text-title-${video.id}`}
|
||||
>
|
||||
{video.title}
|
||||
|
||||
280
client/src/pages/VideoPage.tsx
Normal file
280
client/src/pages/VideoPage.tsx
Normal file
@ -0,0 +1,280 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRoute } from "wouter";
|
||||
import { type Video } from "@shared/schema";
|
||||
// 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('sl-SI', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
};
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Share2, X, Edit3 } from "lucide-react";
|
||||
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);
|
||||
|
||||
// Fetch current video
|
||||
const { data: currentVideo, isLoading: videoLoading } = useQuery<Video>({
|
||||
queryKey: ["/api/videos", videoId],
|
||||
enabled: !!videoId,
|
||||
});
|
||||
|
||||
// Fetch recommended videos (excluding current video)
|
||||
const { data: recommendedResponse } = useQuery<VideosResponse>({
|
||||
queryKey: ["/api/videos", { limit: 20, offset: 0 }],
|
||||
enabled: !!videoId,
|
||||
});
|
||||
|
||||
const recommendedVideos = recommendedResponse?.videos?.filter(v => v.id !== videoId) || [];
|
||||
|
||||
// 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;
|
||||
return `${window.location.origin}/video/${currentVideo.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);
|
||||
}
|
||||
};
|
||||
|
||||
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 ni najden</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-bunny-dark">
|
||||
{/* Header */}
|
||||
<header className="bg-bunny-gray/50 border-b border-gray-700 p-4">
|
||||
<div className="max-w-7xl mx-auto flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-8 h-8 bg-bunny-blue rounded-full flex items-center justify-center">
|
||||
<div className="w-0 h-0 border-l-[6px] border-l-white border-y-[4px] border-y-transparent ml-1"></div>
|
||||
</div>
|
||||
<h1 className="text-xl font-bold text-bunny-light">go4.video</h1>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => window.history.back()}
|
||||
className="bg-gray-700 hover:bg-gray-600 text-white"
|
||||
>
|
||||
← Nazaj
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="max-w-7xl mx-auto p-4 lg:p-6">
|
||||
<div className="flex flex-col lg:flex-row gap-6">
|
||||
{/* 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">
|
||||
{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 ni na voljo</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" />
|
||||
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]">
|
||||
<FacebookShareButton url={getShareUrl()}>
|
||||
<button className="w-full px-4 py-2 text-left text-white hover:bg-gray-700 flex items-center gap-2">
|
||||
<FacebookIcon size={16} round />
|
||||
Facebook
|
||||
</button>
|
||||
</FacebookShareButton>
|
||||
<TwitterShareButton url={getShareUrl()} title={`Poglej si "${currentVideo.title}" na go4.video`}>
|
||||
<button className="w-full px-4 py-2 text-left text-white hover:bg-gray-700 flex items-center gap-2">
|
||||
<TwitterIcon size={16} round />
|
||||
Twitter
|
||||
</button>
|
||||
</TwitterShareButton>
|
||||
<WhatsappShareButton url={getShareUrl()} title={`Poglej si "${currentVideo.title}" na go4.video`}>
|
||||
<button className="w-full px-4 py-2 text-left text-white hover:bg-gray-700 flex items-center gap-2">
|
||||
<WhatsappIcon size={16} round />
|
||||
WhatsApp
|
||||
</button>
|
||||
</WhatsappShareButton>
|
||||
<button
|
||||
onClick={copyToClipboard}
|
||||
className="w-full px-4 py-2 text-left text-white hover:bg-gray-700"
|
||||
>
|
||||
Kopiraj povezavo
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-4 text-sm text-bunny-muted mb-4">
|
||||
<span>{formatViews(currentVideo.views)} ogledov</span>
|
||||
<span>{formatDuration(currentVideo.duration)}</span>
|
||||
<span>{formatDate(currentVideo.createdAt)}</span>
|
||||
</div>
|
||||
|
||||
{currentVideo.category && (
|
||||
<div className="mb-4">
|
||||
<span className="inline-block bg-bunny-blue text-white px-3 py-1 rounded-full text-sm">
|
||||
{currentVideo.category}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentVideo.description && (
|
||||
<div className="text-bunny-light">
|
||||
<p className="text-sm leading-relaxed">{currentVideo.description}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recommended videos sidebar */}
|
||||
<div className="lg:w-96">
|
||||
<h2 className="text-lg font-semibold text-bunny-light mb-4">Priporočeni videji</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;
|
||||
target.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
<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)} ogledov</div>
|
||||
<div>{formatDate(video.createdAt)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user