Restored to '551aaee106c3dc6c34e10193d8683fa67e7f9bea'

Replit-Restored-To: 551aaee106
This commit is contained in:
sebastjanartic
2025-08-04 20:10:59 +00:00
parent bb26fd3cdc
commit 9b192c6e1b
27 changed files with 107 additions and 2599 deletions
-4
View File
@@ -5,8 +5,6 @@ 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() {
@@ -14,8 +12,6 @@ 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>
);
-81
View File
@@ -1,81 +0,0 @@
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,212 +0,0 @@
import React, { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Download, RefreshCw, Trash2 } from "lucide-react";
import { type Video } from "@shared/schema";
interface ThumbnailGeneratorProps {
video: Video;
onThumbnailGenerated?: (thumbnailUrl: string) => void;
}
export default function ThumbnailGenerator({ video, onThumbnailGenerated }: ThumbnailGeneratorProps) {
const [isGenerating, setIsGenerating] = useState(false);
const [customTime, setCustomTime] = useState("5");
const [generatedThumbnails, setGeneratedThumbnails] = useState<Array<{
timestamp: string;
url: string;
path: string;
}>>([]);
const generateThumbnail = async (timestamp: string) => {
setIsGenerating(true);
try {
const response = await fetch(`/api/thumbnails/${video.id}/generate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
timestamps: [timestamp]
}),
});
if (response.ok) {
const data = await response.json();
if (data.thumbnails && data.thumbnails.length > 0) {
const newThumbnail = data.thumbnails[0];
setGeneratedThumbnails(prev => [...prev, newThumbnail]);
if (onThumbnailGenerated) {
onThumbnailGenerated(newThumbnail.url);
}
}
}
} catch (error) {
console.error('Error generating thumbnail:', error);
} finally {
setIsGenerating(false);
}
};
const generateMultipleThumbnails = async () => {
setIsGenerating(true);
try {
const timestamps = ["5", "15", "30", "60", "90"];
const response = await fetch(`/api/thumbnails/${video.id}/generate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
timestamps
}),
});
if (response.ok) {
const data = await response.json();
setGeneratedThumbnails(data.thumbnails || []);
}
} catch (error) {
console.error('Error generating thumbnails:', error);
} finally {
setIsGenerating(false);
}
};
const loadExistingThumbnails = async () => {
try {
const response = await fetch(`/api/thumbnails/${video.id}`);
if (response.ok) {
const data = await response.json();
setGeneratedThumbnails(data.thumbnails || []);
}
} catch (error) {
console.error('Error loading thumbnails:', error);
}
};
React.useEffect(() => {
loadExistingThumbnails();
}, [video.id]);
return (
<Card className="w-full max-w-4xl">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<RefreshCw className="w-5 h-5" />
Ustvarjanje Thumbnail Slik
</CardTitle>
<p className="text-sm text-muted-foreground">
Generirajte thumbnail slike iz različnih trenutkov videa "{video.title}"
</p>
</CardHeader>
<CardContent className="space-y-6">
{/* Quick generate buttons */}
<div className="space-y-4">
<Label className="text-base font-medium">Hitro generiranje</Label>
<div className="flex flex-wrap gap-2">
<Button
onClick={generateMultipleThumbnails}
disabled={isGenerating}
className="bg-blue-600 hover:bg-blue-700"
>
{isGenerating ? "Generiranje..." : "Generiraj 5 slik (5s, 15s, 30s, 60s, 90s)"}
</Button>
</div>
</div>
{/* Custom time input */}
<div className="space-y-4">
<Label className="text-base font-medium">Določi čas</Label>
<div className="flex gap-2 max-w-sm">
<Input
type="number"
value={customTime}
onChange={(e) => setCustomTime(e.target.value)}
placeholder="Sekunde (npr. 30)"
min="0"
max={video.duration || 300}
/>
<Button
onClick={() => generateThumbnail(customTime)}
disabled={isGenerating}
variant="outline"
>
Generiraj
</Button>
</div>
<p className="text-xs text-muted-foreground">
Vnesite čas v sekundah (0 - {video.duration || 300}s)
</p>
</div>
{/* Generated thumbnails grid */}
{generatedThumbnails.length > 0 && (
<div className="space-y-4">
<Label className="text-base font-medium">Generirane slike</Label>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{generatedThumbnails.map((thumbnail, index) => (
<div key={index} className="relative group">
<div className="aspect-video bg-gray-100 rounded-lg overflow-hidden">
<img
src={thumbnail.url}
alt={`Thumbnail at ${thumbnail.timestamp}s`}
className="w-full h-full object-cover"
loading="lazy"
/>
</div>
<div className="absolute bottom-2 left-2 bg-black/80 px-2 py-1 rounded text-xs text-white">
{thumbnail.timestamp}s
</div>
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
<div className="flex gap-1">
<Button
size="sm"
variant="secondary"
className="h-8 w-8 p-0"
onClick={() => {
const link = document.createElement('a');
link.href = thumbnail.url;
link.download = `thumbnail-${video.id}-${thumbnail.timestamp}s.jpg`;
link.click();
}}
>
<Download className="w-3 h-3" />
</Button>
<Button
size="sm"
variant="secondary"
className="h-8 w-8 p-0"
onClick={() => {
if (onThumbnailGenerated) {
onThumbnailGenerated(thumbnail.url);
}
}}
>
✓
</Button>
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Instructions */}
<div className="text-sm text-muted-foreground space-y-2 border-l-4 border-blue-500 pl-4">
<p><strong>Navodila:</strong></p>
<ul className="space-y-1 list-disc list-inside ml-2">
<li>Kliknite "Generiraj 5 slik" za hitro ustvarjanje slik iz različnih trenutkov</li>
<li>Uporabite "Določi čas" za ustvarjanje slike iz določene sekunde</li>
<li>Kliknite ✓ da nastavite sliko kot glavno thumbnail</li>
<li>Kliknite ⬇ da prenesete sliko na svoj računalnik</li>
</ul>
</div>
</CardContent>
</Card>
);
}
-133
View File
@@ -1,133 +0,0 @@
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>
);
}
+11 -30
View File
@@ -1,7 +1,6 @@
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;
@@ -62,21 +61,6 @@ export default function VideoCard({ video, onClick, onShare }: VideoCardProps) {
alt={video.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
data-testid={`img-thumbnail-${video.id}`}
onLoad={() => {
console.log('Thumbnail loaded successfully:', video.thumbnailUrl);
}}
onError={(e) => {
console.error('Thumbnail failed to load:', video.thumbnailUrl);
// Use a data URL SVG as fallback
const target = e.target as HTMLImageElement;
const fallbackSvg = `
<svg width="400" height="225" xmlns="http://www.w3.org/2000/svg">
<rect width="400" height="225" fill="#dc2626"/>
<text x="200" y="112" text-anchor="middle" fill="white" font-family="Arial" font-size="16">Thumbnail Error</text>
</svg>
`;
target.src = 'data:image/svg+xml;base64,' + btoa(fallbackSvg);
}}
/>
<div className="absolute inset-0 bg-black/20 group-hover:bg-black/40 transition-colors duration-300 flex items-center justify-center">
@@ -122,20 +106,17 @@ export default function VideoCard({ video, onClick, onShare }: VideoCardProps) {
{formatDate(video.createdAt)}
</span>
</div>
<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>
{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>
</div>
</div>
+74 -17
View File
@@ -1,11 +1,9 @@
import { useEffect, useRef, useState } from "react";
import { X, Settings } from "lucide-react";
import { useEffect, useRef } from "react";
import { X } 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;
@@ -49,7 +47,6 @@ 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) => {
@@ -71,14 +68,74 @@ export default function VideoModal({ video, isOpen, onClose }: VideoModalProps)
};
}, [isOpen, onClose]);
// Since we're now using iframe embeds for Bunny.net videos, we don't need HLS.js
// Initialize HLS when video is available
useEffect(() => {
// Just log for debugging
if (isOpen && video) {
console.log('Video modal opened with:', video.videoUrl);
if (isOpen && video && videoRef.current) {
const videoElement = videoRef.current;
// Clean up previous HLS instance
if (hlsRef.current) {
hlsRef.current.destroy();
hlsRef.current = null;
}
const videoUrl = video.videoUrl;
console.log('Loading video:', videoUrl);
// Check if the video URL is HLS (.m3u8)
if (videoUrl.includes('.m3u8')) {
if (Hls.isSupported()) {
// Use HLS.js for browsers that don't support HLS natively
const hls = new Hls({
debug: true,
enableWorker: false,
lowLatencyMode: true,
backBufferLength: 90
});
hls.loadSource(videoUrl);
hls.attachMedia(videoElement);
hls.on(Hls.Events.MANIFEST_PARSED, () => {
console.log('HLS manifest loaded successfully');
});
hls.on(Hls.Events.ERROR, (event, data) => {
console.error('HLS error:', data);
if (data.fatal) {
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
console.log('Network error, trying to recover...');
hls.startLoad();
break;
case Hls.ErrorTypes.MEDIA_ERROR:
console.log('Media error, trying to recover...');
hls.recoverMediaError();
break;
default:
console.log('Fatal error, destroying HLS instance...');
hls.destroy();
break;
}
}
});
hlsRef.current = hls;
} else if (videoElement.canPlayType('application/vnd.apple.mpegurl')) {
// For Safari that supports HLS natively
videoElement.src = videoUrl;
console.log('Using native HLS support');
} else {
console.error('HLS is not supported in this browser');
}
} else {
// For regular MP4 videos
videoElement.src = videoUrl;
console.log('Using native video support for MP4');
}
}
// Cleanup if needed
// Cleanup when modal closes
return () => {
if (hlsRef.current) {
hlsRef.current.destroy();
@@ -127,21 +184,21 @@ export default function VideoModal({ video, isOpen, onClose }: VideoModalProps)
<iframe
src={video.videoUrl}
className="w-full h-auto max-h-[80vh] aspect-video"
frameBorder="0"
allow="accelerometer; gyroscope; autoplay; encrypted-media; picture-in-picture; fullscreen;"
allowFullScreen={true}
allow="accelerometer; gyroscope; autoplay; encrypted-media; picture-in-picture;"
data-testid="video-player-iframe"
></iframe>
frameBorder="0"
onLoad={handleVideoPlay}
data-testid="video-iframe"
/>
) : (
<video
ref={videoRef}
className="w-full h-auto max-h-[80vh] aspect-video"
className="w-full h-auto max-h-[80vh]"
controls
preload="metadata"
onPlay={handleVideoPlay}
data-testid="video-player"
crossOrigin="anonymous"
src={video.videoUrl}
>
Your browser does not support the video tag.
</video>
-64
View File
@@ -1,64 +0,0 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@/lib/queryClient";
import { useToast } from "@/hooks/use-toast";
const CURRENT_USER_ID = "5311f8eb-aba2-4f58-96bf-0ca99fe5907c"; // Test user from database
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,
};
}
-77
View File
@@ -1,77 +0,0 @@
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 = "5311f8eb-aba2-4f58-96bf-0ca99fe5907c"; // Test user from database
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,
};
}
-147
View File
@@ -1,147 +0,0 @@
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>
);
}
-2
View File
@@ -3,7 +3,6 @@ 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[];
@@ -92,7 +91,6 @@ export default function Home() {
return (
<div className="min-h-screen bg-bunny-dark">
<Navigation />
<SearchHeader
onSearch={handleSearch}
onCategoryChange={handleCategoryChange}
-336
View File
@@ -1,336 +0,0 @@
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>
);
}
+2 -5
View File
@@ -93,13 +93,10 @@ export default function VideoPage() {
const shareUrl = `${window.location.origin}/video/${video.id}`;
// Use server proxy endpoint for reliable thumbnail access in social sharing
const publicThumbnailUrl = `${window.location.origin}/thumbnail/${video.id}`;
// Open Graph tags for Facebook
updateMeta('og:title', video.title);
updateMeta('og:description', video.description || `Oglej si ta video na VideoStream`);
updateMeta('og:image', publicThumbnailUrl);
updateMeta('og:image', video.thumbnailUrl);
updateMeta('og:image:width', '1200');
updateMeta('og:image:height', '630');
updateMeta('og:image:type', 'image/jpeg');
@@ -114,7 +111,7 @@ export default function VideoPage() {
updateMeta('twitter:card', 'summary_large_image', false);
updateMeta('twitter:title', video.title, false);
updateMeta('twitter:description', video.description || `Oglej si ta video na VideoStream`, false);
updateMeta('twitter:image', publicThumbnailUrl, false);
updateMeta('twitter:image', video.thumbnailUrl, false);
}
}, [video]);