From e02d52998a6f7b6c1b73a6be9851719a137f75f3 Mon Sep 17 00:00:00 2001 From: sebastjanartic <45803536-sebastjanartic@users.noreply.replit.com> Date: Mon, 4 Aug 2025 20:04:23 +0000 Subject: [PATCH] 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 --- .replit | 3 + client/src/App.tsx | 4 + client/src/components/navigation.tsx | 81 +++ client/src/components/thumbnail-generator.tsx | 4 +- client/src/components/video-actions.tsx | 133 +++++ client/src/components/video-card.tsx | 26 +- client/src/components/video-modal.tsx | 7 +- client/src/hooks/use-favorites.ts | 64 ++ client/src/hooks/use-playlists.ts | 77 +++ client/src/pages/favorites.tsx | 147 +++++ client/src/pages/home.tsx | 2 + client/src/pages/playlists.tsx | 336 +++++++++++ package-lock.json | 198 +++++++ package.json | 3 + replit.md | 9 + server/db.ts | 15 + server/routes.ts | 192 ++++++ server/storage.ts | 555 +++++++++++++++++- shared/schema.ts | 121 +++- 19 files changed, 1957 insertions(+), 20 deletions(-) create mode 100644 client/src/components/navigation.tsx create mode 100644 client/src/components/video-actions.tsx create mode 100644 client/src/hooks/use-favorites.ts create mode 100644 client/src/hooks/use-playlists.ts create mode 100644 client/src/pages/favorites.tsx create mode 100644 client/src/pages/playlists.tsx create mode 100644 server/db.ts diff --git a/.replit b/.replit index 990e492..c25f461 100644 --- a/.replit +++ b/.replit @@ -38,3 +38,6 @@ author = "agent" task = "shell.exec" args = "npm run dev" waitForPort = 5000 + +[agent] +integrations = ["javascript_database==1.0.0", "javascript_log_in_with_replit==1.0.0"] diff --git a/client/src/App.tsx b/client/src/App.tsx index 57750f6..47f094e 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -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() { + + ); diff --git a/client/src/components/navigation.tsx b/client/src/components/navigation.tsx new file mode 100644 index 0000000..911707e --- /dev/null +++ b/client/src/components/navigation.tsx @@ -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 ( + + ); +} \ No newline at end of file diff --git a/client/src/components/thumbnail-generator.tsx b/client/src/components/thumbnail-generator.tsx index 0a16d5c..b554cec 100644 --- a/client/src/components/thumbnail-generator.tsx +++ b/client/src/components/thumbnail-generator.tsx @@ -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]); diff --git a/client/src/components/video-actions.tsx b/client/src/components/video-actions.tsx new file mode 100644 index 0000000..60fae23 --- /dev/null +++ b/client/src/components/video-actions.tsx @@ -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 ( +
+ {/* Favorite Button */} + + + {/* Add to Playlist Button */} + + + + + + + Add to Playlist + +
+
+ Select a playlist to add "{video.title}" to: +
+ + {playlistsArray.length > 0 ? ( +
+ {playlistsArray.map((playlist: Playlist) => ( + handleAddToPlaylist(playlist.id)} + data-testid={`card-playlist-option-${playlist.id}`} + > + +
+
+

{playlist.name}

+ {playlist.description && ( +

+ {playlist.description} +

+ )} +
+ +
+
+
+ ))} +
+ ) : ( +
+ +

+ You don't have any playlists yet. +

+ +
+ )} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/client/src/components/video-card.tsx b/client/src/components/video-card.tsx index fb22be8..b29d30b 100644 --- a/client/src/components/video-card.tsx +++ b/client/src/components/video-card.tsx @@ -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)} - {onShare && ( - - )} +
+ + {onShare && ( + + )} +
diff --git a/client/src/components/video-modal.tsx b/client/src/components/video-modal.tsx index af63423..28fa35c 100644 --- a/client/src/components/video-modal.tsx +++ b/client/src/components/video-modal.tsx @@ -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(null); const hlsRef = useRef(null); + const [activeTab, setActiveTab] = useState("video"); useEffect(() => { const handleEscape = (e: KeyboardEvent) => { diff --git a/client/src/hooks/use-favorites.ts b/client/src/hooks/use-favorites.ts new file mode 100644 index 0000000..78cc098 --- /dev/null +++ b/client/src/hooks/use-favorites.ts @@ -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, + }; +} \ No newline at end of file diff --git a/client/src/hooks/use-playlists.ts b/client/src/hooks/use-playlists.ts new file mode 100644 index 0000000..8df076d --- /dev/null +++ b/client/src/hooks/use-playlists.ts @@ -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, + }; +} \ No newline at end of file diff --git a/client/src/pages/favorites.tsx b/client/src/pages/favorites.tsx new file mode 100644 index 0000000..8cfaeaf --- /dev/null +++ b/client/src/pages/favorites.tsx @@ -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