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:
sebastjanartic
2025-08-04 20:04:23 +00:00
parent 60cf545f79
commit e02d52998a
19 changed files with 1957 additions and 20 deletions
+81
View File
@@ -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]);
+133
View File
@@ -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>
);
}
+15 -11
View File
@@ -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>
+5 -2
View File
@@ -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) => {