Enable users to manage video playlists and mark favorite content
Adds new user, playlist, and favorites management API endpoints and React components. 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/hISDNbZ
This commit is contained in:
@@ -5,6 +5,8 @@ import { Toaster } from "@/components/ui/toaster";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import Home from "@/pages/home";
|
||||
import VideoPage from "@/pages/video";
|
||||
import { PlaylistsPage } from "@/pages/playlists";
|
||||
import { FavoritesPage } from "@/pages/favorites";
|
||||
import NotFound from "@/pages/not-found";
|
||||
|
||||
function Router() {
|
||||
@@ -12,6 +14,8 @@ function Router() {
|
||||
<Switch>
|
||||
<Route path="/" component={Home} />
|
||||
<Route path="/video/:id" component={VideoPage} />
|
||||
<Route path="/playlists" component={PlaylistsPage} />
|
||||
<Route path="/favorites" component={FavoritesPage} />
|
||||
<Route component={NotFound} />
|
||||
</Switch>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Link, useLocation } from "wouter";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Home, List, Heart, User } from "lucide-react";
|
||||
|
||||
export function Navigation() {
|
||||
const [location] = useLocation();
|
||||
|
||||
const navItems = [
|
||||
{ path: "/", label: "Home", icon: Home },
|
||||
{ path: "/playlists", label: "Playlists", icon: List },
|
||||
{ path: "/favorites", label: "Favorites", icon: Heart },
|
||||
];
|
||||
|
||||
return (
|
||||
<nav className="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700 sticky top-0 z-50">
|
||||
<div className="container mx-auto px-6">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<div className="flex items-center space-x-8">
|
||||
<Link href="/">
|
||||
<div className="text-xl font-bold text-blue-600 dark:text-blue-400 cursor-pointer" data-testid="link-logo">
|
||||
VideoStream
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className="hidden md:flex items-center space-x-4">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = location === item.path;
|
||||
|
||||
return (
|
||||
<Link key={item.path} href={item.path}>
|
||||
<Button
|
||||
variant={isActive ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className="flex items-center space-x-2"
|
||||
data-testid={`nav-${item.label.toLowerCase()}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
<span>{item.label}</span>
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<User className="w-4 h-4" />
|
||||
<span>Demo User</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile navigation */}
|
||||
<div className="md:hidden pb-4">
|
||||
<div className="flex space-x-2">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = location === item.path;
|
||||
|
||||
return (
|
||||
<Link key={item.path} href={item.path}>
|
||||
<Button
|
||||
variant={isActive ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className="flex-1 flex items-center justify-center space-x-1"
|
||||
data-testid={`nav-mobile-${item.label.toLowerCase()}`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
<span className="text-xs">{item.label}</span>
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -88,7 +88,7 @@ export default function ThumbnailGenerator({ video, onThumbnailGenerated }: Thum
|
||||
}
|
||||
};
|
||||
|
||||
useState(() => {
|
||||
React.useEffect(() => {
|
||||
loadExistingThumbnails();
|
||||
}, [video.id]);
|
||||
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Heart, Plus, List, Check } from "lucide-react";
|
||||
import { useFavorites } from "@/hooks/use-favorites";
|
||||
import { usePlaylists } from "@/hooks/use-playlists";
|
||||
import type { Video, Playlist } from "@shared/schema";
|
||||
|
||||
interface VideoActionsProps {
|
||||
video: Video;
|
||||
showLabel?: boolean;
|
||||
}
|
||||
|
||||
export function VideoActions({ video, showLabel = false }: VideoActionsProps) {
|
||||
const [isPlaylistDialogOpen, setIsPlaylistDialogOpen] = useState(false);
|
||||
const { useIsFavorited, addToFavorites, removeFromFavorites } = useFavorites();
|
||||
const { useUserPlaylists, addVideoToPlaylist } = usePlaylists();
|
||||
|
||||
const { data: favoriteStatus } = useIsFavorited(video.id);
|
||||
const isFavorited = favoriteStatus?.isFavorited || false;
|
||||
|
||||
const { data: playlists } = useUserPlaylists();
|
||||
const playlistsArray = Array.isArray(playlists) ? playlists : [];
|
||||
|
||||
const handleFavoriteToggle = () => {
|
||||
if (isFavorited) {
|
||||
removeFromFavorites(video.id);
|
||||
} else {
|
||||
addToFavorites(video.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddToPlaylist = (playlistId: string) => {
|
||||
addVideoToPlaylist({ playlistId, videoId: video.id });
|
||||
setIsPlaylistDialogOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
{/* Favorite Button */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant={isFavorited ? "default" : "outline"}
|
||||
onClick={handleFavoriteToggle}
|
||||
className={`${
|
||||
isFavorited
|
||||
? "bg-red-500 hover:bg-red-600 text-white"
|
||||
: "hover:bg-red-50 hover:text-red-500 hover:border-red-500"
|
||||
}`}
|
||||
data-testid={`button-favorite-${video.id}`}
|
||||
>
|
||||
<Heart
|
||||
className="w-4 h-4"
|
||||
fill={isFavorited ? "currentColor" : "none"}
|
||||
/>
|
||||
{showLabel && (
|
||||
<span className="ml-2">
|
||||
{isFavorited ? "Favorited" : "Add to Favorites"}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Add to Playlist Button */}
|
||||
<Dialog open={isPlaylistDialogOpen} onOpenChange={setIsPlaylistDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="hover:bg-blue-50 hover:text-blue-600 hover:border-blue-500"
|
||||
data-testid={`button-add-to-playlist-${video.id}`}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
{showLabel && <span className="ml-2">Add to Playlist</span>}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add to Playlist</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Select a playlist to add "{video.title}" to:
|
||||
</div>
|
||||
|
||||
{playlistsArray.length > 0 ? (
|
||||
<div className="grid gap-3 max-h-96 overflow-y-auto">
|
||||
{playlistsArray.map((playlist: Playlist) => (
|
||||
<Card
|
||||
key={playlist.id}
|
||||
className="cursor-pointer hover:shadow-md transition-shadow"
|
||||
onClick={() => handleAddToPlaylist(playlist.id)}
|
||||
data-testid={`card-playlist-option-${playlist.id}`}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="font-medium">{playlist.name}</h4>
|
||||
{playlist.description && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
{playlist.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Plus className="w-5 h-5 text-gray-400" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<List className="w-12 h-12 mx-auto text-gray-400 mb-4" />
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
You don't have any playlists yet.
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setIsPlaylistDialogOpen(false);
|
||||
// You could navigate to /playlists here
|
||||
}}
|
||||
data-testid="button-create-first-playlist-from-video"
|
||||
>
|
||||
Create Your First Playlist
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Play, Share2 } from "lucide-react";
|
||||
import { type Video } from "@shared/schema";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { VideoActions } from "@/components/video-actions";
|
||||
|
||||
interface VideoCardProps {
|
||||
video: Video;
|
||||
@@ -121,17 +122,20 @@ export default function VideoCard({ video, onClick, onShare }: VideoCardProps) {
|
||||
{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 className="flex items-center space-x-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<VideoActions video={video} />
|
||||
{onShare && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleShareClick}
|
||||
className="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>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { X, Settings } from "lucide-react";
|
||||
import { type Video } from "@shared/schema";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import Hls from "hls.js";
|
||||
import ThumbnailGenerator from "./thumbnail-generator";
|
||||
|
||||
interface VideoModalProps {
|
||||
video: Video | null;
|
||||
@@ -47,6 +49,7 @@ function formatDate(date: Date | string): string {
|
||||
export default function VideoModal({ video, isOpen, onClose }: VideoModalProps) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const hlsRef = useRef<Hls | null>(null);
|
||||
const [activeTab, setActiveTab] = useState("video");
|
||||
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
const CURRENT_USER_ID = "test-user-123"; // For demo purposes
|
||||
|
||||
export function useFavorites() {
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
// Check if video is favorited
|
||||
const useIsFavorited = (videoId: string) => {
|
||||
return useQuery({
|
||||
queryKey: ["/api/favorites", CURRENT_USER_ID, videoId],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`/api/favorites/${CURRENT_USER_ID}/${videoId}`);
|
||||
if (!response.ok) throw new Error('Failed to check favorite status');
|
||||
return response.json();
|
||||
},
|
||||
enabled: !!videoId,
|
||||
});
|
||||
};
|
||||
|
||||
// Add to favorites
|
||||
const addToFavoritesMutation = useMutation({
|
||||
mutationFn: (videoId: string) =>
|
||||
apiRequest("/api/favorites", "POST", {
|
||||
userId: CURRENT_USER_ID,
|
||||
videoId,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/favorites"] });
|
||||
toast({ title: "Added to favorites!" });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
if (error.message?.includes("already in favorites")) {
|
||||
toast({ title: "Already in favorites", variant: "destructive" });
|
||||
} else {
|
||||
toast({ title: "Failed to add to favorites", variant: "destructive" });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Remove from favorites
|
||||
const removeFromFavoritesMutation = useMutation({
|
||||
mutationFn: (videoId: string) =>
|
||||
apiRequest(`/api/favorites/${CURRENT_USER_ID}/${videoId}`, "DELETE"),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/favorites"] });
|
||||
toast({ title: "Removed from favorites" });
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: "Failed to remove from favorites", variant: "destructive" });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
useIsFavorited,
|
||||
addToFavorites: addToFavoritesMutation.mutate,
|
||||
removeFromFavorites: removeFromFavoritesMutation.mutate,
|
||||
isAddingToFavorites: addToFavoritesMutation.isPending,
|
||||
isRemovingFromFavorites: removeFromFavoritesMutation.isPending,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type { Playlist, InsertPlaylist } from "@shared/schema";
|
||||
|
||||
const CURRENT_USER_ID = "test-user-123"; // For demo purposes
|
||||
|
||||
export function usePlaylists() {
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
|
||||
// Get user playlists
|
||||
const useUserPlaylists = () => {
|
||||
return useQuery({
|
||||
queryKey: ["/api/playlists", CURRENT_USER_ID],
|
||||
});
|
||||
};
|
||||
|
||||
// Get single playlist
|
||||
const usePlaylist = (playlistId: string) => {
|
||||
return useQuery({
|
||||
queryKey: ["/api/playlists", playlistId],
|
||||
enabled: !!playlistId,
|
||||
});
|
||||
};
|
||||
|
||||
// Get playlist videos
|
||||
const usePlaylistVideos = (playlistId: string) => {
|
||||
return useQuery({
|
||||
queryKey: ["/api/playlists", playlistId, "videos"],
|
||||
enabled: !!playlistId,
|
||||
});
|
||||
};
|
||||
|
||||
// Add video to playlist
|
||||
const addVideoToPlaylistMutation = useMutation({
|
||||
mutationFn: ({ playlistId, videoId }: { playlistId: string; videoId: string }) =>
|
||||
apiRequest(`/api/playlists/${playlistId}/videos`, "POST", {
|
||||
videoId,
|
||||
position: 0,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/playlists"] });
|
||||
toast({ title: "Added to playlist!" });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
if (error.message?.includes("already in playlist")) {
|
||||
toast({ title: "Video is already in this playlist", variant: "destructive" });
|
||||
} else {
|
||||
toast({ title: "Failed to add to playlist", variant: "destructive" });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Remove video from playlist
|
||||
const removeVideoFromPlaylistMutation = useMutation({
|
||||
mutationFn: ({ playlistId, videoId }: { playlistId: string; videoId: string }) =>
|
||||
apiRequest(`/api/playlists/${playlistId}/videos/${videoId}`, "DELETE"),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/playlists"] });
|
||||
toast({ title: "Removed from playlist" });
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: "Failed to remove from playlist", variant: "destructive" });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
useUserPlaylists,
|
||||
usePlaylist,
|
||||
usePlaylistVideos,
|
||||
addVideoToPlaylist: addVideoToPlaylistMutation.mutate,
|
||||
removeVideoFromPlaylist: removeVideoFromPlaylistMutation.mutate,
|
||||
isAddingToPlaylist: addVideoToPlaylistMutation.isPending,
|
||||
isRemovingFromPlaylist: removeVideoFromPlaylistMutation.isPending,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Heart, Clock, Play } from "lucide-react";
|
||||
import VideoModal from "@/components/video-modal";
|
||||
import { useState } from "react";
|
||||
import { Navigation } from "@/components/navigation";
|
||||
import type { UserFavorite, Video } from "@shared/schema";
|
||||
|
||||
const CURRENT_USER_ID = "test-user-123"; // For demo purposes
|
||||
|
||||
export function FavoritesPage() {
|
||||
const [selectedVideo, setSelectedVideo] = useState<Video | null>(null);
|
||||
|
||||
const { data: favorites, isLoading } = useQuery({
|
||||
queryKey: ["/api/favorites", CURRENT_USER_ID],
|
||||
});
|
||||
|
||||
const formatDuration = (seconds: number) => {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const formatViews = (views: number) => {
|
||||
if (views >= 1000000) {
|
||||
return `${(views / 1000000).toFixed(1)}M views`;
|
||||
} else if (views >= 1000) {
|
||||
return `${(views / 1000).toFixed(1)}K views`;
|
||||
}
|
||||
return `${views} views`;
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="container mx-auto p-6">
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{[...Array(8)].map((_, i) => (
|
||||
<Card key={i} className="animate-pulse">
|
||||
<div className="aspect-video bg-gray-300 rounded-t-lg"></div>
|
||||
<CardContent className="p-4">
|
||||
<div className="h-4 bg-gray-300 rounded w-3/4 mb-2"></div>
|
||||
<div className="h-3 bg-gray-200 rounded w-1/2"></div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800">
|
||||
<Navigation />
|
||||
<div className="container mx-auto p-6">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">My Favorites</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-2">
|
||||
Videos you've marked as favorites
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{favorites && Array.isArray(favorites) && favorites.length === 0 ? (
|
||||
<Card className="text-center p-12">
|
||||
<Heart className="w-16 h-16 mx-auto text-gray-400 mb-4" />
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||
No favorites yet
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Start adding videos to your favorites by clicking the heart icon on any video
|
||||
</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{Array.isArray(favorites) && favorites.map((favorite: UserFavorite & { video: Video }) => (
|
||||
<Card
|
||||
key={favorite.videoId}
|
||||
className="group cursor-pointer hover:shadow-lg transition-all duration-200 overflow-hidden"
|
||||
onClick={() => setSelectedVideo(favorite.video)}
|
||||
data-testid={`card-favorite-video-${favorite.videoId}`}
|
||||
>
|
||||
<div className="relative aspect-video overflow-hidden">
|
||||
<img
|
||||
src={`/thumbnail/${favorite.video.id}`}
|
||||
alt={favorite.video.title}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||||
data-testid={`img-favorite-thumbnail-${favorite.videoId}`}
|
||||
/>
|
||||
|
||||
{/* Play button overlay */}
|
||||
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-30 transition-all duration-200 flex items-center justify-center">
|
||||
<div className="bg-white bg-opacity-90 rounded-full p-3 transform scale-0 group-hover:scale-100 transition-transform duration-200">
|
||||
<Play className="w-6 h-6 text-gray-900" fill="currentColor" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Duration badge */}
|
||||
<div className="absolute bottom-2 right-2 bg-black bg-opacity-75 text-white text-xs px-2 py-1 rounded">
|
||||
{formatDuration(favorite.video.duration)}
|
||||
</div>
|
||||
|
||||
{/* Favorite indicator */}
|
||||
<div className="absolute top-2 right-2 bg-red-500 text-white p-1 rounded-full">
|
||||
<Heart className="w-4 h-4" fill="currentColor" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CardContent className="p-4">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-gray-100 line-clamp-2 text-sm mb-2 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors">
|
||||
{favorite.video.title}
|
||||
</h3>
|
||||
|
||||
<div className="flex items-center text-xs text-gray-600 dark:text-gray-400 space-x-2">
|
||||
<span data-testid={`text-favorite-views-${favorite.videoId}`}>
|
||||
{formatViews(favorite.video.views)}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<div className="flex items-center">
|
||||
<Clock className="w-3 h-3 mr-1" />
|
||||
<span>
|
||||
Added {new Date(favorite.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{favorite.video.description && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 line-clamp-2 mt-2">
|
||||
{favorite.video.description}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedVideo && (
|
||||
<VideoModal
|
||||
video={selectedVideo}
|
||||
isOpen={!!selectedVideo}
|
||||
onClose={() => setSelectedVideo(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { type Video } from "@shared/schema";
|
||||
import SearchHeader from "@/components/search-header";
|
||||
import VideoGrid from "@/components/video-grid";
|
||||
import { Navigation } from "@/components/navigation";
|
||||
|
||||
interface VideosResponse {
|
||||
videos: Video[];
|
||||
@@ -91,6 +92,7 @@ export default function Home() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-bunny-dark">
|
||||
<Navigation />
|
||||
<SearchHeader
|
||||
onSearch={handleSearch}
|
||||
onCategoryChange={handleCategoryChange}
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { apiRequest } from "@/lib/queryClient";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Plus, List, Lock, Unlock, Trash2, Edit } from "lucide-react";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Navigation } from "@/components/navigation";
|
||||
import type { Playlist } from "@shared/schema";
|
||||
|
||||
const CURRENT_USER_ID = "test-user-123"; // For demo purposes
|
||||
|
||||
export function PlaylistsPage() {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||
const [editingPlaylist, setEditingPlaylist] = useState<Playlist | null>(null);
|
||||
|
||||
const { data: playlists, isLoading } = useQuery({
|
||||
queryKey: ["/api/playlists", CURRENT_USER_ID],
|
||||
});
|
||||
|
||||
const createPlaylistMutation = useMutation({
|
||||
mutationFn: (data: { name: string; description?: string; isPublic: boolean }) =>
|
||||
apiRequest("/api/playlists", "POST", {
|
||||
...data,
|
||||
userId: CURRENT_USER_ID,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/playlists"] });
|
||||
setIsCreateDialogOpen(false);
|
||||
toast({ title: "Playlist created successfully!" });
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: "Failed to create playlist", variant: "destructive" });
|
||||
},
|
||||
});
|
||||
|
||||
const updatePlaylistMutation = useMutation({
|
||||
mutationFn: ({ id, ...data }: { id: string; name: string; description?: string; isPublic: boolean }) =>
|
||||
apiRequest(`/api/playlists/${id}?userId=${CURRENT_USER_ID}`, "PUT", data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/playlists"] });
|
||||
setEditingPlaylist(null);
|
||||
toast({ title: "Playlist updated successfully!" });
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: "Failed to update playlist", variant: "destructive" });
|
||||
},
|
||||
});
|
||||
|
||||
const deletePlaylistMutation = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiRequest(`/api/playlists/${id}?userId=${CURRENT_USER_ID}`, "DELETE"),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/playlists"] });
|
||||
toast({ title: "Playlist deleted successfully!" });
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: "Failed to delete playlist", variant: "destructive" });
|
||||
},
|
||||
});
|
||||
|
||||
const handleCreatePlaylist = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
createPlaylistMutation.mutate({
|
||||
name: formData.get("name") as string,
|
||||
description: formData.get("description") as string,
|
||||
isPublic: formData.get("isPublic") === "on",
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdatePlaylist = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (!editingPlaylist) return;
|
||||
|
||||
const formData = new FormData(e.currentTarget);
|
||||
updatePlaylistMutation.mutate({
|
||||
id: editingPlaylist.id,
|
||||
name: formData.get("name") as string,
|
||||
description: formData.get("description") as string,
|
||||
isPublic: formData.get("isPublic") === "on",
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="container mx-auto p-6">
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<Card key={i} className="animate-pulse">
|
||||
<CardHeader>
|
||||
<div className="h-4 bg-gray-300 rounded w-3/4"></div>
|
||||
<div className="h-3 bg-gray-200 rounded w-1/2"></div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-3 bg-gray-200 rounded w-full mb-2"></div>
|
||||
<div className="h-3 bg-gray-200 rounded w-2/3"></div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800">
|
||||
<Navigation />
|
||||
<div className="container mx-auto p-6">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">My Playlists</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-2">
|
||||
Organize your favorite videos into custom playlists
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button data-testid="button-create-playlist">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Create Playlist
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Playlist</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleCreatePlaylist} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="name" className="text-sm font-medium">
|
||||
Playlist Name
|
||||
</label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
required
|
||||
placeholder="Enter playlist name"
|
||||
data-testid="input-playlist-name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="description" className="text-sm font-medium">
|
||||
Description (optional)
|
||||
</label>
|
||||
<Textarea
|
||||
id="description"
|
||||
name="description"
|
||||
placeholder="Enter playlist description"
|
||||
data-testid="input-playlist-description"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="isPublic"
|
||||
name="isPublic"
|
||||
className="rounded"
|
||||
data-testid="checkbox-playlist-public"
|
||||
/>
|
||||
<label htmlFor="isPublic" className="text-sm">
|
||||
Make this playlist public
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setIsCreateDialogOpen(false)}
|
||||
data-testid="button-cancel"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={createPlaylistMutation.isPending}
|
||||
data-testid="button-save-playlist"
|
||||
>
|
||||
{createPlaylistMutation.isPending ? "Creating..." : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{playlists && Array.isArray(playlists) && playlists.length === 0 ? (
|
||||
<Card className="text-center p-12">
|
||||
<List className="w-16 h-16 mx-auto text-gray-400 mb-4" />
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||
No playlists yet
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
Create your first playlist to organize your favorite videos
|
||||
</p>
|
||||
<Button onClick={() => setIsCreateDialogOpen(true)} data-testid="button-create-first-playlist">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Create Your First Playlist
|
||||
</Button>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.isArray(playlists) && playlists.map((playlist: Playlist) => (
|
||||
<Card key={playlist.id} className="hover:shadow-lg transition-shadow">
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-lg" data-testid={`text-playlist-name-${playlist.id}`}>
|
||||
{playlist.name}
|
||||
</CardTitle>
|
||||
<div className="flex items-center space-x-2 mt-2">
|
||||
<Badge variant={playlist.isPublic ? "default" : "secondary"}>
|
||||
{playlist.isPublic ? (
|
||||
<>
|
||||
<Unlock className="w-3 h-3 mr-1" />
|
||||
Public
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Lock className="w-3 h-3 mr-1" />
|
||||
Private
|
||||
</>
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex space-x-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setEditingPlaylist(playlist)}
|
||||
data-testid={`button-edit-playlist-${playlist.id}`}
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => deletePlaylistMutation.mutate(playlist.id)}
|
||||
data-testid={`button-delete-playlist-${playlist.id}`}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{playlist.description && (
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm mb-4">
|
||||
{playlist.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="text-xs text-gray-500">
|
||||
Created {new Date(playlist.createdAt).toLocaleDateString()}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Playlist Dialog */}
|
||||
<Dialog open={!!editingPlaylist} onOpenChange={() => setEditingPlaylist(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Playlist</DialogTitle>
|
||||
</DialogHeader>
|
||||
{editingPlaylist && (
|
||||
<form onSubmit={handleUpdatePlaylist} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="edit-name" className="text-sm font-medium">
|
||||
Playlist Name
|
||||
</label>
|
||||
<Input
|
||||
id="edit-name"
|
||||
name="name"
|
||||
required
|
||||
defaultValue={editingPlaylist.name}
|
||||
data-testid="input-edit-playlist-name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="edit-description" className="text-sm font-medium">
|
||||
Description (optional)
|
||||
</label>
|
||||
<Textarea
|
||||
id="edit-description"
|
||||
name="description"
|
||||
defaultValue={editingPlaylist.description || ""}
|
||||
data-testid="input-edit-playlist-description"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="edit-isPublic"
|
||||
name="isPublic"
|
||||
defaultChecked={editingPlaylist.isPublic}
|
||||
className="rounded"
|
||||
data-testid="checkbox-edit-playlist-public"
|
||||
/>
|
||||
<label htmlFor="edit-isPublic" className="text-sm">
|
||||
Make this playlist public
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setEditingPlaylist(null)}
|
||||
data-testid="button-cancel-edit"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={updatePlaylistMutation.isPending}
|
||||
data-testid="button-save-edit-playlist"
|
||||
>
|
||||
{updatePlaylistMutation.isPending ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user