Enable users to easily share videos on social media platforms
Implements video sharing functionality with a dedicated /video/:id route, ShareModal component and Open Graph meta tags. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 50814a1e-92e4-4968-856f-7bc7eedf5e8f Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/8cc42625-c1f5-4e43-99bd-77f2c4dedee2/50814a1e-92e4-4968-856f-7bc7eedf5e8f/k2GlI5l
This commit is contained in:
parent
e087324c76
commit
a2b430c2e0
@ -4,12 +4,14 @@ 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/video";
|
||||
import NotFound from "@/pages/not-found";
|
||||
|
||||
function Router() {
|
||||
return (
|
||||
<Switch>
|
||||
<Route path="/" component={Home} />
|
||||
<Route path="/video/:id" component={VideoPage} />
|
||||
<Route component={NotFound} />
|
||||
</Switch>
|
||||
);
|
||||
|
||||
171
client/src/components/share-modal.tsx
Normal file
171
client/src/components/share-modal.tsx
Normal file
@ -0,0 +1,171 @@
|
||||
import { useState } from "react";
|
||||
import { X, Copy, Facebook, Twitter, Link, Share2 } from "lucide-react";
|
||||
import { type Video } from "@shared/schema";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
interface ShareModalProps {
|
||||
video: Video | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function ShareModal({ video, isOpen, onClose }: ShareModalProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
if (!isOpen || !video) return null;
|
||||
|
||||
// Generate shareable URL
|
||||
const shareUrl = `${window.location.origin}/video/${video.id}`;
|
||||
const encodedUrl = encodeURIComponent(shareUrl);
|
||||
const encodedTitle = encodeURIComponent(video.title);
|
||||
const encodedDescription = encodeURIComponent(video.description || `Oglej si ta video: ${video.title}`);
|
||||
|
||||
// Social media URLs
|
||||
const facebookUrl = `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}"e=${encodedTitle}`;
|
||||
const twitterUrl = `https://twitter.com/intent/tweet?url=${encodedUrl}&text=${encodedTitle}`;
|
||||
const whatsappUrl = `https://wa.me/?text=${encodedTitle}%20${encodedUrl}`;
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareUrl);
|
||||
setCopied(true);
|
||||
toast({
|
||||
title: "Povezava kopirana!",
|
||||
description: "Povezava do videa je bila kopirana v odložišče.",
|
||||
});
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Napaka",
|
||||
description: "Povezave ni bilo mogoče kopirati.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/90 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
||||
onClick={handleBackdropClick}
|
||||
data-testid="modal-share"
|
||||
>
|
||||
<div className="relative w-full max-w-md bg-bunny-gray rounded-lg p-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 text-bunny-muted hover:text-bunny-light"
|
||||
data-testid="button-close-share"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</Button>
|
||||
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center space-x-3 mb-4">
|
||||
<Share2 className="w-6 h-6 text-bunny-blue" />
|
||||
<h2 className="text-xl font-bold text-bunny-light">Deli video</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-3 mb-4">
|
||||
<img
|
||||
src={video.thumbnailUrl}
|
||||
alt={video.title}
|
||||
className="w-16 h-16 rounded-lg object-cover"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-medium text-bunny-light truncate">
|
||||
{video.title}
|
||||
</h3>
|
||||
<p className="text-xs text-bunny-muted mt-1">
|
||||
{Math.floor(video.duration / 60)}:{(video.duration % 60).toString().padStart(2, '0')} min
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-bunny-light mb-2">
|
||||
Povezava do videa
|
||||
</label>
|
||||
<div className="flex space-x-2">
|
||||
<Input
|
||||
value={shareUrl}
|
||||
readOnly
|
||||
className="bg-bunny-dark border-gray-600 text-bunny-light"
|
||||
data-testid="input-share-url"
|
||||
/>
|
||||
<Button
|
||||
onClick={copyToClipboard}
|
||||
className={`px-4 ${copied ? 'bg-green-600 hover:bg-green-700' : 'bg-bunny-blue hover:bg-blue-600'} text-white`}
|
||||
data-testid="button-copy-url"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<span className="text-sm">Kopirano!</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="w-4 h-4 mr-2" />
|
||||
<span className="text-sm">Kopiraj</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-bunny-light mb-3">
|
||||
Deli na socialnih omrežjih
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<Button
|
||||
asChild
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white flex flex-col items-center py-4 h-auto"
|
||||
data-testid="button-share-facebook"
|
||||
>
|
||||
<a href={facebookUrl} target="_blank" rel="noopener noreferrer">
|
||||
<Facebook className="w-6 h-6 mb-1" />
|
||||
<span className="text-xs">Facebook</span>
|
||||
</a>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
asChild
|
||||
className="bg-sky-500 hover:bg-sky-600 text-white flex flex-col items-center py-4 h-auto"
|
||||
data-testid="button-share-twitter"
|
||||
>
|
||||
<a href={twitterUrl} target="_blank" rel="noopener noreferrer">
|
||||
<Twitter className="w-6 h-6 mb-1" />
|
||||
<span className="text-xs">Twitter</span>
|
||||
</a>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
asChild
|
||||
className="bg-green-600 hover:bg-green-700 text-white flex flex-col items-center py-4 h-auto"
|
||||
data-testid="button-share-whatsapp"
|
||||
>
|
||||
<a href={whatsappUrl} target="_blank" rel="noopener noreferrer">
|
||||
<svg className="w-6 h-6 mb-1" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893A11.821 11.821 0 0020.885 3.488"/>
|
||||
</svg>
|
||||
<span className="text-xs">WhatsApp</span>
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,9 +1,11 @@
|
||||
import { Play } from "lucide-react";
|
||||
import { Play, Share2 } from "lucide-react";
|
||||
import { type Video } from "@shared/schema";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface VideoCardProps {
|
||||
video: Video;
|
||||
onClick: (video: Video) => void;
|
||||
onShare?: (video: Video) => void;
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
@ -39,7 +41,14 @@ function formatDate(date: Date | string): string {
|
||||
return `${Math.floor(diffDays / 30)} month${Math.floor(diffDays / 30) > 1 ? 's' : ''} ago`;
|
||||
}
|
||||
|
||||
export default function VideoCard({ video, onClick }: VideoCardProps) {
|
||||
export default function VideoCard({ video, onClick, onShare }: VideoCardProps) {
|
||||
|
||||
const handleShareClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (onShare) {
|
||||
onShare(video);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div
|
||||
className="group cursor-pointer"
|
||||
@ -65,6 +74,20 @@ export default function VideoCard({ video, onClick }: VideoCardProps) {
|
||||
{formatDuration(video.duration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{onShare && (
|
||||
<div className="absolute top-3 right-3 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleShareClick}
|
||||
className="bg-black/60 hover:bg-black/80 text-white p-2 h-auto"
|
||||
data-testid={`button-share-${video.id}`}
|
||||
>
|
||||
<Share2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
@ -74,13 +97,26 @@ export default function VideoCard({ video, onClick }: VideoCardProps) {
|
||||
>
|
||||
{video.title}
|
||||
</h3>
|
||||
<div className="flex items-center space-x-3 text-sm text-bunny-muted">
|
||||
<span data-testid={`text-views-${video.id}`}>
|
||||
{formatViews(video.views)}
|
||||
</span>
|
||||
<span data-testid={`text-date-${video.id}`}>
|
||||
{formatDate(video.createdAt)}
|
||||
</span>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3 text-sm text-bunny-muted">
|
||||
<span data-testid={`text-views-${video.id}`}>
|
||||
{formatViews(video.views)}
|
||||
</span>
|
||||
<span data-testid={`text-date-${video.id}`}>
|
||||
{formatDate(video.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
{onShare && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleShareClick}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity text-bunny-muted hover:text-bunny-blue p-1 h-auto"
|
||||
data-testid={`button-share-bottom-${video.id}`}
|
||||
>
|
||||
<Share2 className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -2,6 +2,7 @@ import { useState } from "react";
|
||||
import { type Video } from "@shared/schema";
|
||||
import VideoCard from "./video-card";
|
||||
import VideoModal from "./video-modal";
|
||||
import ShareModal from "./share-modal";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
@ -16,6 +17,8 @@ interface VideoGridProps {
|
||||
export default function VideoGrid({ videos, isLoading, hasMore, onLoadMore, viewMode }: VideoGridProps) {
|
||||
const [selectedVideo, setSelectedVideo] = useState<Video | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [shareVideo, setShareVideo] = useState<Video | null>(null);
|
||||
const [isShareModalOpen, setIsShareModalOpen] = useState(false);
|
||||
|
||||
const handleVideoClick = (video: Video) => {
|
||||
setSelectedVideo(video);
|
||||
@ -27,6 +30,16 @@ export default function VideoGrid({ videos, isLoading, hasMore, onLoadMore, view
|
||||
setSelectedVideo(null);
|
||||
};
|
||||
|
||||
const handleShareVideo = (video: Video) => {
|
||||
setShareVideo(video);
|
||||
setIsShareModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseShareModal = () => {
|
||||
setIsShareModalOpen(false);
|
||||
setShareVideo(null);
|
||||
};
|
||||
|
||||
if (isLoading && videos.length === 0) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6" data-testid="grid-loading">
|
||||
@ -68,6 +81,7 @@ export default function VideoGrid({ videos, isLoading, hasMore, onLoadMore, view
|
||||
key={video.id}
|
||||
video={video}
|
||||
onClick={handleVideoClick}
|
||||
onShare={handleShareVideo}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@ -100,6 +114,12 @@ export default function VideoGrid({ videos, isLoading, hasMore, onLoadMore, view
|
||||
isOpen={isModalOpen}
|
||||
onClose={handleCloseModal}
|
||||
/>
|
||||
|
||||
<ShareModal
|
||||
video={shareVideo}
|
||||
isOpen={isShareModalOpen}
|
||||
onClose={handleCloseShareModal}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
251
client/src/pages/video.tsx
Normal file
251
client/src/pages/video.tsx
Normal file
@ -0,0 +1,251 @@
|
||||
import { useParams } from "wouter";
|
||||
import { useLocation } from "wouter";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { type Video } from "@shared/schema";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowLeft, Share2, Eye, Clock } from "lucide-react";
|
||||
import { Link } from "wouter";
|
||||
import { useState, useEffect } from "react";
|
||||
import ShareModal from "@/components/share-modal";
|
||||
|
||||
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 ? 'i' : ''}`;
|
||||
return `Pred ${Math.floor(diffDays / 30)} mesec${Math.floor(diffDays / 30) > 1 ? 'i' : 'em'}`;
|
||||
}
|
||||
|
||||
export default function VideoPage() {
|
||||
const params = useParams();
|
||||
const [location] = useLocation();
|
||||
const videoId = params.id;
|
||||
const [isShareModalOpen, setIsShareModalOpen] = useState(false);
|
||||
const [viewTracked, setViewTracked] = useState(false);
|
||||
|
||||
// Fetch video data
|
||||
const { data: video, isLoading, error } = useQuery<Video>({
|
||||
queryKey: ["/api/videos", videoId],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`/api/videos/${videoId}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Video not found');
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
enabled: !!videoId
|
||||
});
|
||||
|
||||
// Track video view
|
||||
useEffect(() => {
|
||||
if (video && !viewTracked) {
|
||||
fetch(`/api/videos/${video.id}/view`, { method: 'POST' })
|
||||
.catch(console.error);
|
||||
setViewTracked(true);
|
||||
}
|
||||
}, [video, viewTracked]);
|
||||
|
||||
// Set page title and meta tags
|
||||
useEffect(() => {
|
||||
if (video) {
|
||||
document.title = `${video.title} - VideoStream`;
|
||||
|
||||
// Update meta tags for social sharing
|
||||
const updateMeta = (name: string, content: string) => {
|
||||
let meta = document.querySelector(`meta[property="${name}"]`) as HTMLMetaElement;
|
||||
if (!meta) {
|
||||
meta = document.createElement('meta');
|
||||
meta.setAttribute('property', name);
|
||||
document.head.appendChild(meta);
|
||||
}
|
||||
meta.content = content;
|
||||
};
|
||||
|
||||
const shareUrl = `${window.location.origin}/video/${video.id}`;
|
||||
updateMeta('og:title', video.title);
|
||||
updateMeta('og:description', video.description || `Oglej si ta video na VideoStream`);
|
||||
updateMeta('og:image', video.thumbnailUrl);
|
||||
updateMeta('og:url', shareUrl);
|
||||
updateMeta('og:type', 'video.other');
|
||||
updateMeta('twitter:card', 'summary_large_image');
|
||||
updateMeta('twitter:title', video.title);
|
||||
updateMeta('twitter:description', video.description || `Oglej si ta video na VideoStream`);
|
||||
updateMeta('twitter:image', video.thumbnailUrl);
|
||||
}
|
||||
}, [video]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-bunny-dark flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-bunny-blue"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !video) {
|
||||
return (
|
||||
<div className="min-h-screen bg-bunny-dark flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold text-bunny-light mb-4">Video ni najden</h1>
|
||||
<p className="text-bunny-muted mb-6">Video, ki ga iščete, ne obstaja ali je bil odstranjen.</p>
|
||||
<Link href="/">
|
||||
<Button className="bg-bunny-blue hover:bg-blue-600 text-white">
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Nazaj na domačo stran
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-bunny-dark">
|
||||
{/* Header */}
|
||||
<header className="bg-bunny-gray/80 backdrop-blur-sm border-b border-gray-700">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<Link href="/">
|
||||
<Button variant="ghost" className="text-bunny-light hover:text-bunny-blue">
|
||||
<ArrowLeft className="w-5 h-5 mr-2" />
|
||||
Nazaj na seznam
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Button
|
||||
onClick={() => setIsShareModalOpen(true)}
|
||||
className="bg-bunny-blue hover:bg-blue-600 text-white"
|
||||
data-testid="button-share-video"
|
||||
>
|
||||
<Share2 className="w-4 h-4 mr-2" />
|
||||
Deli
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Video Player */}
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<div className="lg:col-span-2">
|
||||
<div className="bg-black rounded-lg overflow-hidden aspect-video">
|
||||
{video.videoUrl.includes('iframe.mediadelivery.net') ? (
|
||||
<iframe
|
||||
src={video.videoUrl}
|
||||
className="w-full h-full"
|
||||
allow="accelerometer; gyroscope; autoplay; encrypted-media; picture-in-picture;"
|
||||
allowFullScreen
|
||||
data-testid="video-iframe"
|
||||
/>
|
||||
) : (
|
||||
<video
|
||||
className="w-full h-full"
|
||||
controls
|
||||
preload="metadata"
|
||||
data-testid="video-player"
|
||||
>
|
||||
<source src={video.videoUrl} type="video/mp4" />
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<h1 className="text-2xl font-bold text-bunny-light mb-4" data-testid="text-video-title">
|
||||
{video.title}
|
||||
</h1>
|
||||
|
||||
<div className="flex items-center space-x-6 text-bunny-muted mb-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Eye className="w-4 h-4" />
|
||||
<span data-testid="text-video-views">{formatViews(video.views)}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span data-testid="text-video-duration">{formatDuration(video.duration)}</span>
|
||||
</div>
|
||||
<span data-testid="text-video-date">{formatDate(video.createdAt)}</span>
|
||||
{video.category && (
|
||||
<span className="bg-bunny-gray px-3 py-1 rounded-full text-sm" data-testid="text-video-category">
|
||||
{video.category}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{video.description && (
|
||||
<div className="bg-bunny-gray rounded-lg p-4">
|
||||
<h3 className="font-semibold text-bunny-light mb-2">Opis</h3>
|
||||
<p className="text-bunny-muted leading-relaxed" data-testid="text-video-description">
|
||||
{video.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-1">
|
||||
<div className="bg-bunny-gray rounded-lg p-6">
|
||||
<h3 className="font-semibold text-bunny-light mb-4">Deli ta video</h3>
|
||||
<Button
|
||||
onClick={() => setIsShareModalOpen(true)}
|
||||
className="w-full bg-bunny-blue hover:bg-blue-600 text-white mb-4"
|
||||
data-testid="button-share-sidebar"
|
||||
>
|
||||
<Share2 className="w-4 h-4 mr-2" />
|
||||
Deli video
|
||||
</Button>
|
||||
|
||||
<div className="space-y-3 text-sm text-bunny-muted">
|
||||
<div>
|
||||
<strong className="text-bunny-light">Trajanje:</strong> {formatDuration(video.duration)}
|
||||
</div>
|
||||
<div>
|
||||
<strong className="text-bunny-light">Ogledi:</strong> {formatViews(video.views)}
|
||||
</div>
|
||||
<div>
|
||||
<strong className="text-bunny-light">Dodano:</strong> {formatDate(video.createdAt)}
|
||||
</div>
|
||||
{video.category && (
|
||||
<div>
|
||||
<strong className="text-bunny-light">Kategorija:</strong> {video.category}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<ShareModal
|
||||
video={video}
|
||||
isOpen={isShareModalOpen}
|
||||
onClose={() => setIsShareModalOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
15
replit.md
15
replit.md
@ -8,6 +8,21 @@ VideoStream is a modern web application for streaming and managing video content
|
||||
|
||||
Preferred communication style: Simple, everyday language.
|
||||
|
||||
## Recent Updates (August 4, 2025)
|
||||
|
||||
### Video Sharing Functionality
|
||||
- Added comprehensive share functionality for social media platforms (Facebook, Twitter, WhatsApp)
|
||||
- Created dedicated video pages with shareable URLs (/video/:id)
|
||||
- Implemented Open Graph meta tags for proper social media previews
|
||||
- Added share buttons on video cards and in video modal
|
||||
- Created ShareModal component with copy-to-clipboard functionality
|
||||
|
||||
### Private Video Access
|
||||
- Resolved Bunny.net private video streaming issues using iframe embed approach
|
||||
- Implemented iframe.mediadelivery.net integration for private video libraries
|
||||
- Videos now properly stream using Bunny.net's secure embed system
|
||||
- Maintained thumbnail display from CDN while using iframe for video playback
|
||||
|
||||
## System Architecture
|
||||
|
||||
### Frontend Architecture
|
||||
|
||||
Loading…
Reference in New Issue
Block a user