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

View File

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

View File

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

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

View File

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

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

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>

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) => {

View File

@ -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,
};
}

View File

@ -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,
};
}

View File

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

View File

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

View File

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

198
package-lock.json generated
View File

@ -40,6 +40,7 @@
"@radix-ui/react-toggle-group": "^1.1.3",
"@radix-ui/react-tooltip": "^1.2.0",
"@tanstack/react-query": "^5.60.5",
"@types/memoizee": "^0.4.12",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@ -54,8 +55,10 @@
"hls.js": "^1.6.7",
"input-otp": "^1.4.2",
"lucide-react": "^0.453.0",
"memoizee": "^0.4.17",
"memorystore": "^1.6.7",
"next-themes": "^0.4.6",
"openid-client": "^6.6.2",
"passport": "^0.7.0",
"passport-local": "^1.0.0",
"react": "^18.3.1",
@ -3465,6 +3468,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/memoizee": {
"version": "0.4.12",
"resolved": "https://registry.npmjs.org/@types/memoizee/-/memoizee-0.4.12.tgz",
"integrity": "sha512-EdtpwNYNhe3kZ+4TlXj/++pvBoU0KdrAICMzgI7vjWgu9sIvvUhu9XR8Ks4L6Wh3sxpZ22wkZR7yCLAqUjnZuQ==",
"license": "MIT"
},
"node_modules/@types/mime": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
@ -4112,6 +4121,19 @@
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT"
},
"node_modules/d": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz",
"integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==",
"license": "ISC",
"dependencies": {
"es5-ext": "^0.10.64",
"type": "^2.7.2"
},
"engines": {
"node": ">=0.12"
}
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
@ -5010,6 +5032,58 @@
"node": ">= 0.4"
}
},
"node_modules/es5-ext": {
"version": "0.10.64",
"resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz",
"integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==",
"hasInstallScript": true,
"license": "ISC",
"dependencies": {
"es6-iterator": "^2.0.3",
"es6-symbol": "^3.1.3",
"esniff": "^2.0.1",
"next-tick": "^1.1.0"
},
"engines": {
"node": ">=0.10"
}
},
"node_modules/es6-iterator": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz",
"integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==",
"license": "MIT",
"dependencies": {
"d": "1",
"es5-ext": "^0.10.35",
"es6-symbol": "^3.1.1"
}
},
"node_modules/es6-symbol": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.4.tgz",
"integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==",
"license": "ISC",
"dependencies": {
"d": "^1.0.2",
"ext": "^1.7.0"
},
"engines": {
"node": ">=0.12"
}
},
"node_modules/es6-weak-map": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/es6-weak-map/-/es6-weak-map-2.0.3.tgz",
"integrity": "sha512-p5um32HOTO1kP+w7PRnB+5lQ43Z6muuMuIMffvDN8ZB4GcnjLBV6zGStpbASIMk4DCAvEaamhe2zhyCb/QXXsA==",
"license": "ISC",
"dependencies": {
"d": "1",
"es5-ext": "^0.10.46",
"es6-iterator": "^2.0.3",
"es6-symbol": "^3.1.1"
}
},
"node_modules/esbuild": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz",
@ -5080,6 +5154,21 @@
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/esniff": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz",
"integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==",
"license": "ISC",
"dependencies": {
"d": "^1.0.1",
"es5-ext": "^0.10.62",
"event-emitter": "^0.3.5",
"type": "^2.7.2"
},
"engines": {
"node": ">=0.10"
}
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
@ -5089,6 +5178,16 @@
"node": ">= 0.6"
}
},
"node_modules/event-emitter": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz",
"integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==",
"license": "MIT",
"dependencies": {
"d": "1",
"es5-ext": "~0.10.14"
}
},
"node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
@ -5205,6 +5304,15 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/ext": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz",
"integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==",
"license": "ISC",
"dependencies": {
"type": "^2.7.2"
}
},
"node_modules/fast-equals": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.2.2.tgz",
@ -5684,6 +5792,12 @@
"node": ">=0.12.0"
}
},
"node_modules/is-promise": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz",
"integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==",
"license": "MIT"
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@ -5714,6 +5828,15 @@
"jiti": "bin/jiti.js"
}
},
"node_modules/jose": {
"version": "6.0.12",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.0.12.tgz",
"integrity": "sha512-T8xypXs8CpmiIi78k0E+Lk7T2zlK4zDyg+o1CZ4AkOHgDg98ogdP2BeZ61lTFKFyoEwJ9RgAgN+SdM3iPgNonQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@ -6040,6 +6163,15 @@
"yallist": "^3.0.2"
}
},
"node_modules/lru-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/lru-queue/-/lru-queue-0.1.0.tgz",
"integrity": "sha512-BpdYkt9EvGl8OfWHDQPISVpcl5xZthb+XPsbELj5AQXxIC8IriDZIQYjBJPEm5rS420sjZ0TLEzRcq5KdBhYrQ==",
"license": "MIT",
"dependencies": {
"es5-ext": "~0.10.2"
}
},
"node_modules/lucide-react": {
"version": "0.453.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.453.0.tgz",
@ -6066,6 +6198,25 @@
"node": ">= 0.6"
}
},
"node_modules/memoizee": {
"version": "0.4.17",
"resolved": "https://registry.npmjs.org/memoizee/-/memoizee-0.4.17.tgz",
"integrity": "sha512-DGqD7Hjpi/1or4F/aYAspXKNm5Yili0QDAFAY4QYvpqpgiY6+1jOfqpmByzjxbWd/T9mChbCArXAbDAsTm5oXA==",
"license": "ISC",
"dependencies": {
"d": "^1.0.2",
"es5-ext": "^0.10.64",
"es6-weak-map": "^2.0.3",
"event-emitter": "^0.3.5",
"is-promise": "^2.2.2",
"lru-queue": "^0.1.0",
"next-tick": "^1.1.0",
"timers-ext": "^0.1.7"
},
"engines": {
"node": ">=0.12"
}
},
"node_modules/memorystore": {
"version": "1.6.7",
"resolved": "https://registry.npmjs.org/memorystore/-/memorystore-1.6.7.tgz",
@ -6266,6 +6417,12 @@
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/next-tick": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz",
"integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==",
"license": "ISC"
},
"node_modules/node-gyp-build": {
"version": "4.8.3",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.3.tgz",
@ -6304,6 +6461,15 @@
"node": ">=0.10.0"
}
},
"node_modules/oauth4webapi": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.6.1.tgz",
"integrity": "sha512-b39+drVyA4aNUptFOhkkmGWnG/BE7dT29SW/8PVYElqp7j/DBqzm5SS1G+MUD07XlTcBOAG+6Cb/35Cx2kHIuQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@ -6361,6 +6527,19 @@
"node": ">= 0.8"
}
},
"node_modules/openid-client": {
"version": "6.6.2",
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.6.2.tgz",
"integrity": "sha512-Xya5TNMnnZuTM6DbHdB4q0S3ig2NTAELnii/ASie1xDEr8iiB8zZbO871OWBdrw++sd3hW6bqWjgcmSy1RTWHA==",
"license": "MIT",
"dependencies": {
"jose": "^6.0.11",
"oauth4webapi": "^3.5.4"
},
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
@ -7731,6 +7910,19 @@
"node": ">=0.8"
}
},
"node_modules/timers-ext": {
"version": "0.1.8",
"resolved": "https://registry.npmjs.org/timers-ext/-/timers-ext-0.1.8.tgz",
"integrity": "sha512-wFH7+SEAcKfJpfLPkrgMPvvwnEtj8W4IurvEyrKsDleXnKLCDw71w8jltvfLa8Rm4qQxxT4jmDBYbJG/z7qoww==",
"license": "ISC",
"dependencies": {
"es5-ext": "^0.10.64",
"next-tick": "^1.1.0"
},
"engines": {
"node": ">=0.12"
}
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
@ -8246,6 +8438,12 @@
"url": "https://github.com/sponsors/Wombosvideo"
}
},
"node_modules/type": {
"version": "2.7.3",
"resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz",
"integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==",
"license": "ISC"
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",

View File

@ -42,6 +42,7 @@
"@radix-ui/react-toggle-group": "^1.1.3",
"@radix-ui/react-tooltip": "^1.2.0",
"@tanstack/react-query": "^5.60.5",
"@types/memoizee": "^0.4.12",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@ -56,8 +57,10 @@
"hls.js": "^1.6.7",
"input-otp": "^1.4.2",
"lucide-react": "^0.453.0",
"memoizee": "^0.4.17",
"memorystore": "^1.6.7",
"next-themes": "^0.4.6",
"openid-client": "^6.6.2",
"passport": "^0.7.0",
"passport-local": "^1.0.0",
"react": "^18.3.1",

View File

@ -32,6 +32,15 @@ Preferred communication style: Simple, everyday language.
- Added share buttons on video cards and in video modal
- SVG thumbnails ensure consistent sharing experience across platforms
### User Management & Social Features ⚠️ (In Progress)
- Implemented PostgreSQL database schema for users, playlists, and favorites
- Created complete API routes for user registration, playlists, and favorites management
- Built frontend pages for playlists (/playlists) and favorites (/favorites) with React components
- Added navigation component with Home, Playlists, and Favorites sections
- Created video action buttons (favorite/playlist) integrated into video cards
- Developed custom hooks for favorites and playlists management
- Note: Database integration pending - tables need to be created and storage implementation completed
## System Architecture
### Frontend Architecture

15
server/db.ts Normal file
View File

@ -0,0 +1,15 @@
import { Pool, neonConfig } from '@neondatabase/serverless';
import { drizzle } from 'drizzle-orm/neon-serverless';
import ws from "ws";
import * as schema from "@shared/schema";
neonConfig.webSocketConstructor = ws;
if (!process.env.DATABASE_URL) {
throw new Error(
"DATABASE_URL must be set. Did you forget to provision a database?",
);
}
export const pool = new Pool({ connectionString: process.env.DATABASE_URL });
export const db = drizzle({ client: pool, schema });

View File

@ -247,6 +247,198 @@ export async function registerRoutes(app: Express): Promise<Server> {
}
});
// User routes
app.get("/api/users/:id", async (req, res) => {
try {
const user = await storage.getUser(req.params.id);
if (!user) {
return res.status(404).json({ message: "User not found" });
}
// Don't send password hash
const { passwordHash, ...safeUser } = user;
res.json(safeUser);
} catch (error) {
res.status(500).json({ message: "Failed to fetch user" });
}
});
app.post("/api/users", async (req, res) => {
try {
const userData = req.body;
const user = await storage.createUser(userData);
const { passwordHash, ...safeUser } = user;
res.status(201).json(safeUser);
} catch (error) {
res.status(500).json({ message: "Failed to create user" });
}
});
// Playlist routes
app.get("/api/playlists", async (req, res) => {
try {
const userId = req.query.userId as string;
const limit = parseInt(req.query.limit as string) || 20;
const offset = parseInt(req.query.offset as string) || 0;
if (!userId) {
return res.status(400).json({ message: "userId is required" });
}
const playlists = await storage.getPlaylists(userId, limit, offset);
res.json(playlists);
} catch (error) {
res.status(500).json({ message: "Failed to fetch playlists" });
}
});
app.get("/api/playlists/:id", async (req, res) => {
try {
const userId = req.query.userId as string;
const playlist = await storage.getPlaylist(req.params.id, userId);
if (!playlist) {
return res.status(404).json({ message: "Playlist not found" });
}
res.json(playlist);
} catch (error) {
res.status(500).json({ message: "Failed to fetch playlist" });
}
});
app.post("/api/playlists", async (req, res) => {
try {
const playlist = await storage.createPlaylist(req.body);
res.status(201).json(playlist);
} catch (error) {
res.status(500).json({ message: "Failed to create playlist" });
}
});
app.put("/api/playlists/:id", async (req, res) => {
try {
const userId = req.query.userId as string;
if (!userId) {
return res.status(400).json({ message: "userId is required" });
}
const playlist = await storage.updatePlaylist(req.params.id, req.body, userId);
if (!playlist) {
return res.status(404).json({ message: "Playlist not found or access denied" });
}
res.json(playlist);
} catch (error) {
res.status(500).json({ message: "Failed to update playlist" });
}
});
app.delete("/api/playlists/:id", async (req, res) => {
try {
const userId = req.query.userId as string;
if (!userId) {
return res.status(400).json({ message: "userId is required" });
}
const success = await storage.deletePlaylist(req.params.id, userId);
if (!success) {
return res.status(404).json({ message: "Playlist not found or access denied" });
}
res.json({ success: true });
} catch (error) {
res.status(500).json({ message: "Failed to delete playlist" });
}
});
// Playlist videos routes
app.get("/api/playlists/:id/videos", async (req, res) => {
try {
const videos = await storage.getPlaylistVideos(req.params.id);
res.json(videos);
} catch (error) {
res.status(500).json({ message: "Failed to fetch playlist videos" });
}
});
app.post("/api/playlists/:id/videos", async (req, res) => {
try {
const playlistVideo = await storage.addVideoToPlaylist({
playlistId: req.params.id,
videoId: req.body.videoId,
position: req.body.position || 0
});
res.status(201).json(playlistVideo);
} catch (error) {
if (error instanceof Error && error.message.includes("already in playlist")) {
return res.status(409).json({ message: error.message });
}
res.status(500).json({ message: "Failed to add video to playlist" });
}
});
app.delete("/api/playlists/:playlistId/videos/:videoId", async (req, res) => {
try {
const success = await storage.removeVideoFromPlaylist(req.params.playlistId, req.params.videoId);
if (!success) {
return res.status(404).json({ message: "Video not found in playlist" });
}
res.json({ success: true });
} catch (error) {
res.status(500).json({ message: "Failed to remove video from playlist" });
}
});
// Favorites routes
app.get("/api/favorites", async (req, res) => {
try {
const userId = req.query.userId as string;
const limit = parseInt(req.query.limit as string) || 20;
const offset = parseInt(req.query.offset as string) || 0;
if (!userId) {
return res.status(400).json({ message: "userId is required" });
}
const favorites = await storage.getUserFavorites(userId, limit, offset);
res.json(favorites);
} catch (error) {
res.status(500).json({ message: "Failed to fetch favorites" });
}
});
app.post("/api/favorites", async (req, res) => {
try {
const favorite = await storage.addToFavorites({
userId: req.body.userId,
videoId: req.body.videoId
});
res.status(201).json(favorite);
} catch (error) {
if (error instanceof Error && error.message.includes("already in favorites")) {
return res.status(409).json({ message: error.message });
}
res.status(500).json({ message: "Failed to add to favorites" });
}
});
app.delete("/api/favorites/:userId/:videoId", async (req, res) => {
try {
const success = await storage.removeFromFavorites(req.params.userId, req.params.videoId);
if (!success) {
return res.status(404).json({ message: "Favorite not found" });
}
res.json({ success: true });
} catch (error) {
res.status(500).json({ message: "Failed to remove from favorites" });
}
});
app.get("/api/favorites/:userId/:videoId", async (req, res) => {
try {
const isFavorited = await storage.isVideoFavorited(req.params.userId, req.params.videoId);
res.json({ isFavorited });
} catch (error) {
res.status(500).json({ message: "Failed to check favorite status" });
}
});
const httpServer = createServer(app);
return httpServer;
}

View File

@ -1,23 +1,69 @@
import { type Video, type InsertVideo } from "@shared/schema";
import {
type Video,
type InsertVideo,
type User,
type InsertUser,
type Playlist,
type InsertPlaylist,
type PlaylistVideo,
type InsertPlaylistVideo,
type UserFavorite,
type InsertUserFavorite
} from "@shared/schema";
import { randomUUID } from "crypto";
import { BunnyService } from "./bunny";
export interface IStorage {
// Video operations
getVideos(limit?: number, offset?: number, search?: string, category?: string): Promise<Video[]>;
getVideo(id: string): Promise<Video | undefined>;
createVideo(video: InsertVideo): Promise<Video>;
updateVideoViews(id: string): Promise<void>;
getVideoCount(search?: string, category?: string): Promise<number>;
// User operations
getUser(id: string): Promise<User | undefined>;
getUserByEmail(email: string): Promise<User | undefined>;
getUserByUsername(username: string): Promise<User | undefined>;
createUser(user: InsertUser): Promise<User>;
updateUser(id: string, user: Partial<InsertUser>): Promise<User | undefined>;
// Playlist operations
getPlaylists(userId: string, limit?: number, offset?: number): Promise<Playlist[]>;
getPlaylist(id: string, userId?: string): Promise<Playlist | undefined>;
createPlaylist(playlist: InsertPlaylist): Promise<Playlist>;
updatePlaylist(id: string, playlist: Partial<InsertPlaylist>, userId: string): Promise<Playlist | undefined>;
deletePlaylist(id: string, userId: string): Promise<boolean>;
// Playlist video operations
getPlaylistVideos(playlistId: string): Promise<(PlaylistVideo & { video: Video })[]>;
addVideoToPlaylist(playlistVideo: InsertPlaylistVideo): Promise<PlaylistVideo>;
removeVideoFromPlaylist(playlistId: string, videoId: string): Promise<boolean>;
// Favorites operations
getUserFavorites(userId: string, limit?: number, offset?: number): Promise<(UserFavorite & { video: Video })[]>;
addToFavorites(favorite: InsertUserFavorite): Promise<UserFavorite>;
removeFromFavorites(userId: string, videoId: string): Promise<boolean>;
isVideoFavorited(userId: string, videoId: string): Promise<boolean>;
}
export class MemStorage implements IStorage {
private videos: Map<string, Video>;
private users: Map<string, User>;
private playlists: Map<string, Playlist>;
private playlistVideos: Map<string, PlaylistVideo[]>;
private userFavorites: Map<string, UserFavorite[]>;
constructor() {
this.videos = new Map();
this.users = new Map();
this.playlists = new Map();
this.playlistVideos = new Map();
this.userFavorites = new Map();
// Initialize with some sample videos for demonstration
// In production, these would be fetched from bunny.net API
this.initializeSampleVideos();
this.initializeSampleUsers();
}
private initializeSampleVideos() {
@ -145,6 +191,222 @@ export class MemStorage implements IStorage {
const videos = await this.getVideos(1000, 0, search, category);
return videos.length;
}
private initializeSampleUsers() {
// Sample user for testing
const sampleUser: User = {
id: "test-user-123",
username: "testuser",
email: "test@example.com",
passwordHash: "hashed_password",
firstName: "Test",
lastName: "User",
avatar: null,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
};
this.users.set(sampleUser.id, sampleUser);
}
// User operations
async getUser(id: string): Promise<User | undefined> {
return this.users.get(id);
}
async getUserByEmail(email: string): Promise<User | undefined> {
for (const user of this.users.values()) {
if (user.email === email) {
return user;
}
}
return undefined;
}
async getUserByUsername(username: string): Promise<User | undefined> {
for (const user of this.users.values()) {
if (user.username === username) {
return user;
}
}
return undefined;
}
async createUser(userData: InsertUser): Promise<User> {
const user: User = {
id: randomUUID(),
...userData,
createdAt: new Date(),
updatedAt: new Date(),
};
this.users.set(user.id, user);
return user;
}
async updateUser(id: string, userData: Partial<InsertUser>): Promise<User | undefined> {
const user = this.users.get(id);
if (!user) return undefined;
const updatedUser: User = {
...user,
...userData,
updatedAt: new Date(),
};
this.users.set(id, updatedUser);
return updatedUser;
}
// Playlist operations
async getPlaylists(userId: string, limit = 20, offset = 0): Promise<Playlist[]> {
const userPlaylists = Array.from(this.playlists.values())
.filter(playlist => playlist.userId === userId)
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
.slice(offset, offset + limit);
return userPlaylists;
}
async getPlaylist(id: string, userId?: string): Promise<Playlist | undefined> {
const playlist = this.playlists.get(id);
if (!playlist) return undefined;
// If userId is provided, check ownership or public status
if (userId && playlist.userId !== userId && !playlist.isPublic) {
return undefined;
}
return playlist;
}
async createPlaylist(playlistData: InsertPlaylist): Promise<Playlist> {
const playlist: Playlist = {
id: randomUUID(),
...playlistData,
createdAt: new Date(),
updatedAt: new Date(),
};
this.playlists.set(playlist.id, playlist);
this.playlistVideos.set(playlist.id, []);
return playlist;
}
async updatePlaylist(id: string, playlistData: Partial<InsertPlaylist>, userId: string): Promise<Playlist | undefined> {
const playlist = this.playlists.get(id);
if (!playlist || playlist.userId !== userId) return undefined;
const updatedPlaylist: Playlist = {
...playlist,
...playlistData,
updatedAt: new Date(),
};
this.playlists.set(id, updatedPlaylist);
return updatedPlaylist;
}
async deletePlaylist(id: string, userId: string): Promise<boolean> {
const playlist = this.playlists.get(id);
if (!playlist || playlist.userId !== userId) return false;
this.playlists.delete(id);
this.playlistVideos.delete(id);
return true;
}
// Playlist video operations
async getPlaylistVideos(playlistId: string): Promise<(PlaylistVideo & { video: Video })[]> {
const videos = this.playlistVideos.get(playlistId) || [];
const result = [];
for (const playlistVideo of videos) {
const video = this.videos.get(playlistVideo.videoId);
if (video) {
result.push({ ...playlistVideo, video });
}
}
return result.sort((a, b) => a.position - b.position);
}
async addVideoToPlaylist(playlistVideoData: InsertPlaylistVideo): Promise<PlaylistVideo> {
const videos = this.playlistVideos.get(playlistVideoData.playlistId) || [];
// Check if video is already in playlist
const existingIndex = videos.findIndex(v => v.videoId === playlistVideoData.videoId);
if (existingIndex !== -1) {
throw new Error("Video is already in playlist");
}
const playlistVideo: PlaylistVideo = {
...playlistVideoData,
addedAt: new Date(),
};
videos.push(playlistVideo);
this.playlistVideos.set(playlistVideoData.playlistId, videos);
return playlistVideo;
}
async removeVideoFromPlaylist(playlistId: string, videoId: string): Promise<boolean> {
const videos = this.playlistVideos.get(playlistId) || [];
const filteredVideos = videos.filter(v => v.videoId !== videoId);
if (filteredVideos.length === videos.length) {
return false; // Video not found
}
this.playlistVideos.set(playlistId, filteredVideos);
return true;
}
// Favorites operations
async getUserFavorites(userId: string, limit = 20, offset = 0): Promise<(UserFavorite & { video: Video })[]> {
const favorites = this.userFavorites.get(userId) || [];
const result = [];
for (const favorite of favorites.slice(offset, offset + limit)) {
const video = this.videos.get(favorite.videoId);
if (video) {
result.push({ ...favorite, video });
}
}
return result.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
}
async addToFavorites(favoriteData: InsertUserFavorite): Promise<UserFavorite> {
const favorites = this.userFavorites.get(favoriteData.userId) || [];
// Check if already favorited
const existing = favorites.find(f => f.videoId === favoriteData.videoId);
if (existing) {
throw new Error("Video is already in favorites");
}
const favorite: UserFavorite = {
...favoriteData,
createdAt: new Date(),
};
favorites.push(favorite);
this.userFavorites.set(favoriteData.userId, favorites);
return favorite;
}
async removeFromFavorites(userId: string, videoId: string): Promise<boolean> {
const favorites = this.userFavorites.get(userId) || [];
const filteredFavorites = favorites.filter(f => f.videoId !== videoId);
if (filteredFavorites.length === favorites.length) {
return false; // Favorite not found
}
this.userFavorites.set(userId, filteredFavorites);
return true;
}
async isVideoFavorited(userId: string, videoId: string): Promise<boolean> {
const favorites = this.userFavorites.get(userId) || [];
return favorites.some(f => f.videoId === videoId);
}
}
// Use Bunny.net storage if API keys are available, otherwise fallback to memory storage
@ -220,6 +482,291 @@ class BunnyStorage implements IStorage {
return 0;
}
}
// User operations - Not implemented for BunnyStorage (video-only service)
async getUser(id: string): Promise<User | undefined> {
throw new Error("User operations are not supported with Bunny.net storage");
}
async getUserByEmail(email: string): Promise<User | undefined> {
throw new Error("User operations are not supported with Bunny.net storage");
}
async getUserByUsername(username: string): Promise<User | undefined> {
throw new Error("User operations are not supported with Bunny.net storage");
}
async createUser(user: InsertUser): Promise<User> {
throw new Error("User operations are not supported with Bunny.net storage");
}
async updateUser(id: string, user: Partial<InsertUser>): Promise<User | undefined> {
throw new Error("User operations are not supported with Bunny.net storage");
}
// Playlist operations - Not implemented for BunnyStorage (video-only service)
async getPlaylists(userId: string, limit?: number, offset?: number): Promise<Playlist[]> {
throw new Error("Playlist operations are not supported with Bunny.net storage");
}
async getPlaylist(id: string, userId?: string): Promise<Playlist | undefined> {
throw new Error("Playlist operations are not supported with Bunny.net storage");
}
async createPlaylist(playlist: InsertPlaylist): Promise<Playlist> {
throw new Error("Playlist operations are not supported with Bunny.net storage");
}
async updatePlaylist(id: string, playlist: Partial<InsertPlaylist>, userId: string): Promise<Playlist | undefined> {
throw new Error("Playlist operations are not supported with Bunny.net storage");
}
async deletePlaylist(id: string, userId: string): Promise<boolean> {
throw new Error("Playlist operations are not supported with Bunny.net storage");
}
async getPlaylistVideos(playlistId: string): Promise<(PlaylistVideo & { video: Video })[]> {
throw new Error("Playlist operations are not supported with Bunny.net storage");
}
async addVideoToPlaylist(playlistVideo: InsertPlaylistVideo): Promise<PlaylistVideo> {
throw new Error("Playlist operations are not supported with Bunny.net storage");
}
async removeVideoFromPlaylist(playlistId: string, videoId: string): Promise<boolean> {
throw new Error("Playlist operations are not supported with Bunny.net storage");
}
// Favorites operations - Not implemented for BunnyStorage (video-only service)
async getUserFavorites(userId: string, limit?: number, offset?: number): Promise<(UserFavorite & { video: Video })[]> {
throw new Error("Favorites operations are not supported with Bunny.net storage");
}
async addToFavorites(favorite: InsertUserFavorite): Promise<UserFavorite> {
throw new Error("Favorites operations are not supported with Bunny.net storage");
}
async removeFromFavorites(userId: string, videoId: string): Promise<boolean> {
throw new Error("Favorites operations are not supported with Bunny.net storage");
}
async isVideoFavorited(userId: string, videoId: string): Promise<boolean> {
throw new Error("Favorites operations are not supported with Bunny.net storage");
}
}
// Create a hybrid storage that combines Bunny.net videos with database user features
import { db } from "./db";
import { eq, and, desc, asc } from "drizzle-orm";
import { users, playlists, playlistVideos, userFavorites } from "@shared/schema";
class HybridStorage implements IStorage {
private bunnyStorage: BunnyStorage;
constructor() {
this.bunnyStorage = new BunnyStorage();
}
// Video operations - delegate to Bunny.net
async getVideos(limit = 20, offset = 0, search?: string, category?: string): Promise<Video[]> {
return this.bunnyStorage.getVideos(limit, offset, search, category);
}
async getVideo(id: string): Promise<Video | undefined> {
return this.bunnyStorage.getVideo(id);
}
async createVideo(video: InsertVideo): Promise<Video> {
return this.bunnyStorage.createVideo(video);
}
async updateVideoViews(id: string): Promise<void> {
return this.bunnyStorage.updateVideoViews(id);
}
async getVideoCount(search?: string, category?: string): Promise<number> {
return this.bunnyStorage.getVideoCount(search, category);
}
// User operations - use database
async getUser(id: string): Promise<User | undefined> {
const [user] = await db.select().from(users).where(eq(users.id, id));
return user;
}
async getUserByEmail(email: string): Promise<User | undefined> {
const [user] = await db.select().from(users).where(eq(users.email, email));
return user;
}
async getUserByUsername(username: string): Promise<User | undefined> {
const [user] = await db.select().from(users).where(eq(users.username, username));
return user;
}
async createUser(userData: InsertUser): Promise<User> {
const [user] = await db.insert(users).values({
...userData,
createdAt: new Date(),
updatedAt: new Date()
}).returning();
return user;
}
async updateUser(id: string, userData: Partial<InsertUser>): Promise<User | undefined> {
const [user] = await db.update(users)
.set({ ...userData, updatedAt: new Date() })
.where(eq(users.id, id))
.returning();
return user;
}
// Playlist operations - use database
async getPlaylists(userId: string, limit = 20, offset = 0): Promise<Playlist[]> {
const userPlaylists = await db.select()
.from(playlists)
.where(eq(playlists.userId, userId))
.orderBy(desc(playlists.createdAt))
.limit(limit)
.offset(offset);
return userPlaylists;
}
async getPlaylist(id: string, userId?: string): Promise<Playlist | undefined> {
let query = db.select().from(playlists).where(eq(playlists.id, id));
if (userId) {
query = query.where(eq(playlists.userId, userId));
}
const [playlist] = await query;
return playlist;
}
async createPlaylist(playlistData: InsertPlaylist): Promise<Playlist> {
const [playlist] = await db.insert(playlists).values({
...playlistData,
createdAt: new Date(),
updatedAt: new Date()
}).returning();
return playlist;
}
async updatePlaylist(id: string, updates: Partial<InsertPlaylist>, userId: string): Promise<Playlist | undefined> {
const [playlist] = await db.update(playlists)
.set({ ...updates, updatedAt: new Date() })
.where(and(eq(playlists.id, id), eq(playlists.userId, userId)))
.returning();
return playlist;
}
async deletePlaylist(id: string, userId: string): Promise<boolean> {
const result = await db.delete(playlists)
.where(and(eq(playlists.id, id), eq(playlists.userId, userId)));
return result.rowCount > 0;
}
// Playlist video operations - use database
async getPlaylistVideos(playlistId: string): Promise<(PlaylistVideo & { video: Video })[]> {
const playlistVideoRecords = await db.select()
.from(playlistVideos)
.where(eq(playlistVideos.playlistId, playlistId))
.orderBy(asc(playlistVideos.position));
const results = [];
for (const record of playlistVideoRecords) {
const video = await this.getVideo(record.videoId);
if (video) {
results.push({ ...record, video });
}
}
return results;
}
async addVideoToPlaylist(data: InsertPlaylistVideo): Promise<PlaylistVideo> {
// Check if video already exists in playlist
const existing = await db.select()
.from(playlistVideos)
.where(and(
eq(playlistVideos.playlistId, data.playlistId),
eq(playlistVideos.videoId, data.videoId)
));
if (existing.length > 0) {
throw new Error("Video is already in playlist");
}
const [playlistVideo] = await db.insert(playlistVideos).values({
...data,
addedAt: new Date()
}).returning();
return playlistVideo;
}
async removeVideoFromPlaylist(playlistId: string, videoId: string): Promise<boolean> {
const result = await db.delete(playlistVideos)
.where(and(
eq(playlistVideos.playlistId, playlistId),
eq(playlistVideos.videoId, videoId)
));
return result.rowCount > 0;
}
// Favorites operations - use database
async getUserFavorites(userId: string, limit = 20, offset = 0): Promise<(UserFavorite & { video: Video })[]> {
const favoriteRecords = await db.select()
.from(userFavorites)
.where(eq(userFavorites.userId, userId))
.orderBy(desc(userFavorites.createdAt))
.limit(limit)
.offset(offset);
const results = [];
for (const record of favoriteRecords) {
const video = await this.getVideo(record.videoId);
if (video) {
results.push({ ...record, video });
}
}
return results;
}
async addToFavorites(data: InsertUserFavorite): Promise<UserFavorite> {
// Check if video already favorited
const existing = await db.select()
.from(userFavorites)
.where(and(
eq(userFavorites.userId, data.userId),
eq(userFavorites.videoId, data.videoId)
));
if (existing.length > 0) {
throw new Error("Video is already in favorites");
}
const [favorite] = await db.insert(userFavorites).values({
...data,
createdAt: new Date()
}).returning();
return favorite;
}
async removeFromFavorites(userId: string, videoId: string): Promise<boolean> {
const result = await db.delete(userFavorites)
.where(and(
eq(userFavorites.userId, userId),
eq(userFavorites.videoId, videoId)
));
return result.rowCount > 0;
}
async isVideoFavorited(userId: string, videoId: string): Promise<boolean> {
const [favorite] = await db.select()
.from(userFavorites)
.where(and(
eq(userFavorites.userId, userId),
eq(userFavorites.videoId, videoId)
));
return !!favorite;
}
}
// Try to use Bunny.net storage, fallback to memory storage if not configured
@ -230,10 +777,10 @@ const hasBunnyConfig = process.env.BUNNY_API_KEY && process.env.BUNNY_LIBRARY_ID
if (hasBunnyConfig) {
try {
storage = new BunnyStorage();
console.log('✅ Using Bunny.net storage with library ID:', process.env.BUNNY_LIBRARY_ID);
storage = new HybridStorage();
console.log('✅ Using Hybrid storage (Bunny.net + Database) with library ID:', process.env.BUNNY_LIBRARY_ID);
} catch (error) {
console.error('❌ Failed to initialize Bunny.net storage:', error);
console.error('❌ Failed to initialize Hybrid storage:', error);
console.log('📁 Falling back to memory storage');
storage = new MemStorage();
}

View File

@ -1,5 +1,6 @@
import { sql } from "drizzle-orm";
import { pgTable, text, varchar, integer, timestamp } from "drizzle-orm/pg-core";
import { pgTable, text, varchar, integer, timestamp, boolean, primaryKey, uuid } from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm";
import { createInsertSchema } from "drizzle-zod";
import { z } from "zod";
@ -22,3 +23,121 @@ export const insertVideoSchema = createInsertSchema(videos).omit({
export type InsertVideo = z.infer<typeof insertVideoSchema>;
export type Video = typeof videos.$inferSelect;
// Users table for authentication and user management
export const users = pgTable("users", {
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
username: varchar("username", { length: 50 }).notNull().unique(),
email: varchar("email", { length: 255 }).notNull().unique(),
passwordHash: varchar("password_hash", { length: 255 }).notNull(),
firstName: varchar("first_name", { length: 100 }),
lastName: varchar("last_name", { length: 100 }),
avatar: text("avatar"),
isActive: boolean("is_active").notNull().default(true),
createdAt: timestamp("created_at").notNull().default(sql`CURRENT_TIMESTAMP`),
updatedAt: timestamp("updated_at").notNull().default(sql`CURRENT_TIMESTAMP`),
});
export const insertUserSchema = createInsertSchema(users).omit({
id: true,
createdAt: true,
updatedAt: true,
});
export type InsertUser = z.infer<typeof insertUserSchema>;
export type User = typeof users.$inferSelect;
// Playlists table
export const playlists = pgTable("playlists", {
id: uuid("id").primaryKey().default(sql`gen_random_uuid()`),
userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
description: text("description"),
isPublic: boolean("is_public").notNull().default(false),
thumbnailUrl: text("thumbnail_url"),
createdAt: timestamp("created_at").notNull().default(sql`CURRENT_TIMESTAMP`),
updatedAt: timestamp("updated_at").notNull().default(sql`CURRENT_TIMESTAMP`),
});
export const insertPlaylistSchema = createInsertSchema(playlists).omit({
id: true,
createdAt: true,
updatedAt: true,
});
export type InsertPlaylist = z.infer<typeof insertPlaylistSchema>;
export type Playlist = typeof playlists.$inferSelect;
// Playlist videos junction table
export const playlistVideos = pgTable("playlist_videos", {
playlistId: uuid("playlist_id").notNull().references(() => playlists.id, { onDelete: "cascade" }),
videoId: varchar("video_id").notNull().references(() => videos.id, { onDelete: "cascade" }),
position: integer("position").notNull().default(0),
addedAt: timestamp("added_at").notNull().default(sql`CURRENT_TIMESTAMP`),
}, (table) => ({
pk: primaryKey({ columns: [table.playlistId, table.videoId] }),
}));
export const insertPlaylistVideoSchema = createInsertSchema(playlistVideos).omit({
addedAt: true,
});
export type InsertPlaylistVideo = z.infer<typeof insertPlaylistVideoSchema>;
export type PlaylistVideo = typeof playlistVideos.$inferSelect;
// User favorites table
export const userFavorites = pgTable("user_favorites", {
userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
videoId: varchar("video_id").notNull().references(() => videos.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at").notNull().default(sql`CURRENT_TIMESTAMP`),
}, (table) => ({
pk: primaryKey({ columns: [table.userId, table.videoId] }),
}));
export const insertUserFavoriteSchema = createInsertSchema(userFavorites).omit({
createdAt: true,
});
export type InsertUserFavorite = z.infer<typeof insertUserFavoriteSchema>;
export type UserFavorite = typeof userFavorites.$inferSelect;
// Relations
export const usersRelations = relations(users, ({ many }) => ({
playlists: many(playlists),
favorites: many(userFavorites),
}));
export const playlistsRelations = relations(playlists, ({ one, many }) => ({
user: one(users, {
fields: [playlists.userId],
references: [users.id],
}),
playlistVideos: many(playlistVideos),
}));
export const videosRelations = relations(videos, ({ many }) => ({
playlistVideos: many(playlistVideos),
userFavorites: many(userFavorites),
}));
export const playlistVideosRelations = relations(playlistVideos, ({ one }) => ({
playlist: one(playlists, {
fields: [playlistVideos.playlistId],
references: [playlists.id],
}),
video: one(videos, {
fields: [playlistVideos.videoId],
references: [videos.id],
}),
}));
export const userFavoritesRelations = relations(userFavorites, ({ one }) => ({
user: one(users, {
fields: [userFavorites.userId],
references: [users.id],
}),
video: one(videos, {
fields: [userFavorites.videoId],
references: [videos.id],
}),
}));