Enable users to quickly share videos on social media platforms
Implements one-click social sharing feature via `<SocialShare>` component and URL parameters for video sharing. Replit-Commit-Author: Agent Replit-Commit-Session-Id: aa92e7e2-ec62-4c92-b21b-02ef78a664c2 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/8cc42625-c1f5-4e43-99bd-77f2c4dedee2/aa92e7e2-ec62-4c92-b21b-02ef78a664c2/Xt4Awd6
This commit is contained in:
parent
da5c38c235
commit
68800e6eb4
154
client/src/components/social-share.tsx
Normal file
154
client/src/components/social-share.tsx
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Copy, Check, Share2 } from "lucide-react";
|
||||||
|
import {
|
||||||
|
SiX,
|
||||||
|
SiFacebook,
|
||||||
|
SiLinkedin,
|
||||||
|
SiWhatsapp,
|
||||||
|
SiTelegram,
|
||||||
|
SiReddit
|
||||||
|
} from "react-icons/si";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { type Video } from "@shared/schema";
|
||||||
|
|
||||||
|
interface SocialShareProps {
|
||||||
|
video: Video;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SocialShare({ video, className = "" }: SocialShareProps) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [shareOpen, setShareOpen] = useState(false);
|
||||||
|
|
||||||
|
// Generate shareable URL (in production, this would be your actual domain)
|
||||||
|
const shareUrl = `${window.location.origin}/?video=${video.id}`;
|
||||||
|
const shareText = `Check out this video: ${video.title}`;
|
||||||
|
const shareTextEncoded = encodeURIComponent(shareText);
|
||||||
|
const shareUrlEncoded = encodeURIComponent(shareUrl);
|
||||||
|
|
||||||
|
const handleCopyLink = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(shareUrl);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to copy link:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const socialPlatforms = [
|
||||||
|
{
|
||||||
|
name: "X",
|
||||||
|
icon: SiX,
|
||||||
|
url: `https://twitter.com/intent/tweet?text=${shareTextEncoded}&url=${shareUrlEncoded}`,
|
||||||
|
color: "hover:bg-gray-900 hover:text-white",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Facebook",
|
||||||
|
icon: SiFacebook,
|
||||||
|
url: `https://www.facebook.com/sharer/sharer.php?u=${shareUrlEncoded}`,
|
||||||
|
color: "hover:bg-blue-600 hover:text-white",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "LinkedIn",
|
||||||
|
icon: SiLinkedin,
|
||||||
|
url: `https://www.linkedin.com/sharing/share-offsite/?url=${shareUrlEncoded}`,
|
||||||
|
color: "hover:bg-blue-700 hover:text-white",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "WhatsApp",
|
||||||
|
icon: SiWhatsapp,
|
||||||
|
url: `https://wa.me/?text=${shareTextEncoded}%20${shareUrlEncoded}`,
|
||||||
|
color: "hover:bg-green-500 hover:text-white",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Telegram",
|
||||||
|
icon: SiTelegram,
|
||||||
|
url: `https://t.me/share/url?url=${shareUrlEncoded}&text=${shareTextEncoded}`,
|
||||||
|
color: "hover:bg-blue-400 hover:text-white",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Reddit",
|
||||||
|
icon: SiReddit,
|
||||||
|
url: `https://reddit.com/submit?url=${shareUrlEncoded}&title=${shareTextEncoded}`,
|
||||||
|
color: "hover:bg-orange-500 hover:text-white",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const handlePlatformShare = (platform: typeof socialPlatforms[0]) => {
|
||||||
|
window.open(platform.url, '_blank', 'width=600,height=400');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`relative ${className}`}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShareOpen(!shareOpen)}
|
||||||
|
className="text-gray-300 hover:text-white transition-colors"
|
||||||
|
data-testid="button-share-toggle"
|
||||||
|
>
|
||||||
|
<Share2 className="w-4 h-4 mr-2" />
|
||||||
|
Share
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{shareOpen && (
|
||||||
|
<>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-40"
|
||||||
|
onClick={() => setShareOpen(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Share Menu */}
|
||||||
|
<div className="absolute bottom-full right-0 mb-2 bg-gray-800 rounded-lg p-4 shadow-xl z-50 min-w-64">
|
||||||
|
<h3 className="text-white font-semibold mb-3">Share this video</h3>
|
||||||
|
|
||||||
|
{/* Copy Link */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleCopyLink}
|
||||||
|
className="w-full justify-start"
|
||||||
|
data-testid="button-copy-link"
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<>
|
||||||
|
<Check className="w-4 h-4 mr-2 text-green-500" />
|
||||||
|
Copied!
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Copy className="w-4 h-4 mr-2" />
|
||||||
|
Copy Link
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Social Platforms */}
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{socialPlatforms.map((platform) => {
|
||||||
|
const IconComponent = platform.icon;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={platform.name}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handlePlatformShare(platform)}
|
||||||
|
className={`flex flex-col items-center p-3 h-auto ${platform.color} transition-colors`}
|
||||||
|
data-testid={`button-share-${platform.name.toLowerCase()}`}
|
||||||
|
>
|
||||||
|
<IconComponent className="w-5 h-5 mb-1" />
|
||||||
|
<span className="text-xs">{platform.name}</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import { Play } from "lucide-react";
|
import { Play, Share2 } from "lucide-react";
|
||||||
import { type Video } from "@shared/schema";
|
import { type Video } from "@shared/schema";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
interface VideoCardProps {
|
interface VideoCardProps {
|
||||||
video: Video;
|
video: Video;
|
||||||
@ -40,6 +41,30 @@ function formatDate(date: Date | string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function VideoCard({ video, onClick }: VideoCardProps) {
|
export default function VideoCard({ video, onClick }: VideoCardProps) {
|
||||||
|
const handleShare = async (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation(); // Prevent video modal from opening
|
||||||
|
|
||||||
|
const shareUrl = `${window.location.origin}/?video=${video.id}`;
|
||||||
|
const shareData = {
|
||||||
|
title: video.title,
|
||||||
|
text: `Check out this video: ${video.title}`,
|
||||||
|
url: shareUrl,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (navigator.share) {
|
||||||
|
// Use native share API if available (mobile devices)
|
||||||
|
await navigator.share(shareData);
|
||||||
|
} else {
|
||||||
|
// Fallback: copy to clipboard
|
||||||
|
await navigator.clipboard.writeText(shareUrl);
|
||||||
|
alert('Video link copied to clipboard!');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error sharing:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="group cursor-pointer"
|
className="group cursor-pointer"
|
||||||
@ -65,6 +90,19 @@ export default function VideoCard({ video, onClick }: VideoCardProps) {
|
|||||||
{formatDuration(video.duration)}
|
{formatDuration(video.duration)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Share button */}
|
||||||
|
<div className="absolute top-3 right-3 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleShare}
|
||||||
|
className="bg-black/80 hover:bg-black/90 text-white border-0 h-8 w-8 p-0"
|
||||||
|
data-testid={`button-share-${video.id}`}
|
||||||
|
>
|
||||||
|
<Share2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { X } from "lucide-react";
|
import { X, Share2, Copy, Check } from "lucide-react";
|
||||||
import { type Video } from "@shared/schema";
|
import { type Video } from "@shared/schema";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { apiRequest } from "@/lib/queryClient";
|
import { apiRequest } from "@/lib/queryClient";
|
||||||
import Hls from "hls.js";
|
import Hls from "hls.js";
|
||||||
|
import { SocialShare } from "@/components/social-share";
|
||||||
|
|
||||||
interface VideoModalProps {
|
interface VideoModalProps {
|
||||||
video: Video | null;
|
video: Video | null;
|
||||||
@ -212,16 +213,19 @@ export default function VideoModal({ video, isOpen, onClose }: VideoModalProps)
|
|||||||
>
|
>
|
||||||
{video.title}
|
{video.title}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex items-center space-x-4 text-sm text-gray-300">
|
<div className="flex items-center justify-between">
|
||||||
<span data-testid="text-modal-views">
|
<div className="flex items-center space-x-4 text-sm text-gray-300">
|
||||||
{formatViews(video.views)}
|
<span data-testid="text-modal-views">
|
||||||
</span>
|
{formatViews(video.views)}
|
||||||
<span data-testid="text-modal-date">
|
</span>
|
||||||
{formatDate(video.createdAt)}
|
<span data-testid="text-modal-date">
|
||||||
</span>
|
{formatDate(video.createdAt)}
|
||||||
<span data-testid="text-modal-duration">
|
</span>
|
||||||
{formatDuration(video.duration)}
|
<span data-testid="text-modal-duration">
|
||||||
</span>
|
{formatDuration(video.duration)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<SocialShare video={video} />
|
||||||
</div>
|
</div>
|
||||||
{video.description && (
|
{video.description && (
|
||||||
<p className="mt-3 text-gray-300 text-sm" data-testid="text-modal-description">
|
<p className="mt-3 text-gray-300 text-sm" data-testid="text-modal-description">
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import { type Video } from "@shared/schema";
|
import { type Video } from "@shared/schema";
|
||||||
import SearchHeader from "@/components/search-header";
|
import SearchHeader from "@/components/search-header";
|
||||||
import VideoGrid from "@/components/video-grid";
|
import VideoGrid from "@/components/video-grid";
|
||||||
|
import VideoModal from "@/components/video-modal";
|
||||||
|
|
||||||
interface VideosResponse {
|
interface VideosResponse {
|
||||||
videos: Video[];
|
videos: Video[];
|
||||||
@ -15,6 +16,23 @@ export default function Home() {
|
|||||||
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
|
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
|
||||||
const [offset, setOffset] = useState(0);
|
const [offset, setOffset] = useState(0);
|
||||||
const [allVideos, setAllVideos] = useState<Video[]>([]);
|
const [allVideos, setAllVideos] = useState<Video[]>([]);
|
||||||
|
const [selectedVideo, setSelectedVideo] = useState<Video | null>(null);
|
||||||
|
const [isVideoModalOpen, setIsVideoModalOpen] = useState(false);
|
||||||
|
|
||||||
|
// Check for shared video in URL and open modal when videos are loaded
|
||||||
|
useEffect(() => {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const videoId = urlParams.get('video');
|
||||||
|
if (videoId && allVideos.length > 0) {
|
||||||
|
const sharedVideo = allVideos.find(v => v.id === videoId);
|
||||||
|
if (sharedVideo) {
|
||||||
|
setSelectedVideo(sharedVideo);
|
||||||
|
setIsVideoModalOpen(true);
|
||||||
|
// Clean up URL after opening modal
|
||||||
|
window.history.replaceState({}, '', window.location.pathname);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [allVideos]);
|
||||||
|
|
||||||
// Fetch videos
|
// Fetch videos
|
||||||
const { data: videosResponse, isLoading, refetch } = useQuery<VideosResponse>({
|
const { data: videosResponse, isLoading, refetch } = useQuery<VideosResponse>({
|
||||||
@ -68,6 +86,11 @@ export default function Home() {
|
|||||||
refetch();
|
refetch();
|
||||||
}, [searchQuery, offset, refetch]);
|
}, [searchQuery, offset, refetch]);
|
||||||
|
|
||||||
|
const handleCloseModal = () => {
|
||||||
|
setIsVideoModalOpen(false);
|
||||||
|
setSelectedVideo(null);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-bunny-dark">
|
<div className="min-h-screen bg-bunny-dark">
|
||||||
<SearchHeader
|
<SearchHeader
|
||||||
@ -86,6 +109,15 @@ export default function Home() {
|
|||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{/* Shared video modal - handles deep links */}
|
||||||
|
{selectedVideo && isVideoModalOpen && (
|
||||||
|
<VideoModal
|
||||||
|
video={selectedVideo}
|
||||||
|
isOpen={isVideoModalOpen}
|
||||||
|
onClose={handleCloseModal}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<footer className="bg-bunny-gray/50 border-t border-gray-700 mt-16">
|
<footer className="bg-bunny-gray/50 border-t border-gray-700 mt-16">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user