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:
sebastjanartic 2025-08-04 18:45:34 +00:00
parent e087324c76
commit a2b430c2e0
6 changed files with 504 additions and 9 deletions

View File

@ -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>
);

View 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}&quote=${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>
);
}

View File

@ -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>

View File

@ -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
View 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>
);
}

View File

@ -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