Restored to '551aaee106c3dc6c34e10193d8683fa67e7f9bea'
Replit-Restored-To: 551aaee106
This commit is contained in:
parent
bb26fd3cdc
commit
9b192c6e1b
4
.replit
4
.replit
@ -4,7 +4,6 @@ hidden = [".config", ".git", "generated-icon.png", "node_modules", "dist"]
|
||||
|
||||
[nix]
|
||||
channel = "stable-24_05"
|
||||
packages = ["ffmpeg", "imagemagick"]
|
||||
|
||||
[deployment]
|
||||
deploymentTarget = "autoscale"
|
||||
@ -38,6 +37,3 @@ 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"]
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 622 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 325 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 MiB |
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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]);
|
||||
|
||||
|
||||
198
package-lock.json
generated
198
package-lock.json
generated
@ -40,7 +40,6 @@
|
||||
"@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",
|
||||
@ -55,10 +54,8 @@
|
||||
"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",
|
||||
@ -3468,12 +3465,6 @@
|
||||
"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",
|
||||
@ -4121,19 +4112,6 @@
|
||||
"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",
|
||||
@ -5032,58 +5010,6 @@
|
||||
"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",
|
||||
@ -5154,21 +5080,6 @@
|
||||
"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",
|
||||
@ -5178,16 +5089,6 @@
|
||||
"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",
|
||||
@ -5304,15 +5205,6 @@
|
||||
"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",
|
||||
@ -5792,12 +5684,6 @@
|
||||
"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",
|
||||
@ -5828,15 +5714,6 @@
|
||||
"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",
|
||||
@ -6163,15 +6040,6 @@
|
||||
"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",
|
||||
@ -6198,25 +6066,6 @@
|
||||
"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",
|
||||
@ -6417,12 +6266,6 @@
|
||||
"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",
|
||||
@ -6461,15 +6304,6 @@
|
||||
"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",
|
||||
@ -6527,19 +6361,6 @@
|
||||
"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",
|
||||
@ -7910,19 +7731,6 @@
|
||||
"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",
|
||||
@ -8438,12 +8246,6 @@
|
||||
"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",
|
||||
|
||||
@ -42,7 +42,6 @@
|
||||
"@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",
|
||||
@ -57,10 +56,8 @@
|
||||
"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",
|
||||
|
||||
34
replit.md
34
replit.md
@ -10,36 +10,18 @@ Preferred communication style: Simple, everyday language.
|
||||
|
||||
## Recent Updates (August 4, 2025)
|
||||
|
||||
### Video Streaming Implementation ✅
|
||||
- Successfully implemented Bunny.net iframe embed for private video library access
|
||||
- Videos stream reliably using https://iframe.mediadelivery.net/embed/ approach
|
||||
- Added fullscreen capabilities and proper video controls
|
||||
- Resolved authentication issues with private video library (ID: 476412)
|
||||
|
||||
### Advanced Thumbnail Generation System ✅
|
||||
- Implemented FFmpeg and ImageMagick for extracting real frames from videos
|
||||
- Added ability to specify exact timestamp for thumbnail generation (e.g., ?t=30)
|
||||
- Created comprehensive ThumbnailGenerator class with multiple extraction options
|
||||
- Added API endpoints for generating multiple thumbnails at once
|
||||
- Built React component for easy thumbnail selection and generation
|
||||
- Fallback to attractive SVG thumbnails when video extraction fails
|
||||
- Cache system for optimized performance and reduced server load
|
||||
|
||||
### Video Sharing Functionality ✅
|
||||
- Added comprehensive share functionality for social media platforms
|
||||
### Video Sharing Functionality
|
||||
- Added comprehensive share functionality for social media platforms (Facebook, Twitter, WhatsApp)
|
||||
- Created dedicated video pages with shareable URLs (/video/:id)
|
||||
- Implemented Open Graph meta tags for proper social media previews
|
||||
- Added share buttons on video cards and in video modal
|
||||
- SVG thumbnails ensure consistent sharing experience across platforms
|
||||
- Created ShareModal component with copy-to-clipboard functionality
|
||||
|
||||
### 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
|
||||
### Private Video Access
|
||||
- Resolved Bunny.net private video streaming issues using iframe embed approach
|
||||
- Implemented iframe.mediadelivery.net integration for private video libraries
|
||||
- Videos now properly stream using Bunny.net's secure embed system
|
||||
- Maintained thumbnail display from CDN while using iframe for video playback
|
||||
|
||||
## System Architecture
|
||||
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import { type Video, type InsertVideo } from "@shared/schema";
|
||||
import crypto from 'crypto';
|
||||
|
||||
interface BunnyVideo {
|
||||
guid: string;
|
||||
@ -23,13 +22,11 @@ export class BunnyService {
|
||||
private apiKey: string;
|
||||
private libraryId: string;
|
||||
private hostname: string;
|
||||
private securityKey: string;
|
||||
|
||||
constructor() {
|
||||
this.apiKey = process.env.BUNNY_API_KEY!;
|
||||
this.libraryId = process.env.BUNNY_LIBRARY_ID!;
|
||||
this.hostname = process.env.BUNNY_HOSTNAME!;
|
||||
this.securityKey = process.env.BUNNY_SECURITY_KEY || ''; // CDN security key for signing URLs
|
||||
|
||||
if (!this.apiKey || !this.libraryId || !this.hostname) {
|
||||
throw new Error("Missing Bunny.net configuration");
|
||||
@ -54,41 +51,14 @@ export class BunnyService {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
private generateSignedUrl(path: string, expiryHours: number = 1): string {
|
||||
if (!this.securityKey) {
|
||||
// If no security key, return iframe embed as fallback
|
||||
const videoId = path.split('/')[1];
|
||||
return `https://iframe.mediadelivery.net/embed/${this.libraryId}/${videoId}?controls=true&autoplay=false`;
|
||||
}
|
||||
|
||||
const expireTimestamp = Math.floor(Date.now() / 1000) + (expiryHours * 3600);
|
||||
|
||||
// Bunny.net uses SHA256 for token generation according to docs
|
||||
// hashableBase = securityKey + signaturePath + expires + userIp + parameterData
|
||||
const hashableBase = `${this.securityKey}${path}${expireTimestamp}`;
|
||||
|
||||
const hash = crypto.createHash('sha256').update(hashableBase).digest();
|
||||
const token = Buffer.from(hash).toString('base64')
|
||||
.replace(/\n/g, '')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=/g, '');
|
||||
|
||||
return `https://${this.hostname}${path}?token=${token}&expires=${expireTimestamp}`;
|
||||
}
|
||||
|
||||
// Public method for generating signed URLs for sharing
|
||||
generatePublicSignedUrl(path: string, expiryHours: number = 1): string {
|
||||
return this.generateSignedUrl(path, expiryHours);
|
||||
}
|
||||
|
||||
private bunnyVideoToVideo(bunnyVideo: BunnyVideo): Video {
|
||||
// Since Bunny.net private library token authentication is not working,
|
||||
// use iframe embed approach for reliable video playback
|
||||
const videoUrl = `https://iframe.mediadelivery.net/embed/${this.libraryId}/${bunnyVideo.guid}?controls=true&autoplay=false`;
|
||||
|
||||
// Use proxy endpoint that will try multiple thumbnail sources
|
||||
const thumbnailUrl = `/thumbnail/${bunnyVideo.guid}`;
|
||||
// For private videos, we'll generate a video poster frame from the iframe
|
||||
// This is the best approach for private Bunny.net videos
|
||||
const thumbnailUrl = `https://iframe.mediadelivery.net/embed/${this.libraryId}/${bunnyVideo.guid}?poster=true`;
|
||||
|
||||
// For private videos, we'll use an iframe embed URL which handles authentication
|
||||
// Enable controls, allow fullscreen, and ensure player functionality
|
||||
const videoUrl = `https://iframe.mediadelivery.net/embed/${this.libraryId}/${bunnyVideo.guid}?controls=true&autoplay=false&preload=metadata`;
|
||||
|
||||
return {
|
||||
id: bunnyVideo.guid,
|
||||
|
||||
15
server/db.ts
15
server/db.ts
@ -1,15 +0,0 @@
|
||||
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 });
|
||||
335
server/routes.ts
335
server/routes.ts
@ -2,11 +2,8 @@ import type { Express } from "express";
|
||||
import { createServer, type Server } from "http";
|
||||
import { storage } from "./storage";
|
||||
import { z } from "zod";
|
||||
import { BunnyService } from "./bunny";
|
||||
import { ThumbnailGenerator } from "./thumbnail-generator";
|
||||
|
||||
export async function registerRoutes(app: Express): Promise<Server> {
|
||||
const thumbnailGenerator = new ThumbnailGenerator();
|
||||
// Get videos with pagination and filtering
|
||||
app.get("/api/videos", async (req, res) => {
|
||||
try {
|
||||
@ -120,338 +117,6 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
}
|
||||
});
|
||||
|
||||
// Generate real thumbnail from video frame at specific time
|
||||
app.get("/thumbnail/:videoId", async (req, res) => {
|
||||
const timeStamp = req.query.t as string || "5"; // Default to 5 seconds
|
||||
|
||||
try {
|
||||
const { videoId } = req.params;
|
||||
|
||||
// Get video info
|
||||
const video = await storage.getVideo(videoId);
|
||||
if (!video) {
|
||||
return res.status(404).json({ message: "Video not found" });
|
||||
}
|
||||
|
||||
// Try to generate real thumbnail from video
|
||||
const thumbnailPath = await thumbnailGenerator.generateThumbnail({
|
||||
videoId,
|
||||
timeStamp,
|
||||
width: 400,
|
||||
height: 225,
|
||||
quality: 85
|
||||
});
|
||||
|
||||
if (thumbnailPath && require('fs').existsSync(thumbnailPath)) {
|
||||
// Serve the generated thumbnail
|
||||
res.setHeader('Content-Type', 'image/jpeg');
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
return res.sendFile(require('path').resolve(thumbnailPath));
|
||||
} else {
|
||||
// Fallback to SVG if thumbnail generation fails
|
||||
const displayTitle = video.title.replace('.mp4', '').substring(0, 40);
|
||||
const duration = video.duration ? Math.floor(video.duration / 60) + ':' + String(video.duration % 60).padStart(2, '0') : '';
|
||||
|
||||
const svg = `
|
||||
<svg width="400" height="225" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#374151;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#1f2937;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<filter id="shadow">
|
||||
<feDropShadow dx="2" dy="2" stdDeviation="3"/>
|
||||
</filter>
|
||||
</defs>
|
||||
<rect width="400" height="225" fill="url(#grad)" />
|
||||
|
||||
<!-- Video camera icon background -->
|
||||
<circle cx="120" cy="80" r="25" fill="rgba(255,255,255,0.1)" />
|
||||
<!-- Camera body -->
|
||||
<rect x="108" y="72" width="18" height="12" rx="2" fill="rgba(255,255,255,0.6)" />
|
||||
<!-- Camera lens -->
|
||||
<circle cx="114" cy="78" r="3" fill="#374151" />
|
||||
<!-- Camera viewfinder -->
|
||||
<rect x="126" y="75" width="6" height="4" fill="rgba(255,255,255,0.6)" />
|
||||
|
||||
<!-- Play button circle -->
|
||||
<circle cx="200" cy="112" r="35" fill="rgba(255,255,255,0.9)" filter="url(#shadow)" />
|
||||
<!-- Play button triangle -->
|
||||
<polygon points="188,97 188,127 218,112" fill="#374151" />
|
||||
|
||||
<!-- Title background -->
|
||||
<rect x="0" y="175" width="400" height="50" fill="rgba(0,0,0,0.8)" />
|
||||
|
||||
<!-- Title text -->
|
||||
<text x="20" y="200" fill="white" font-family="Arial, sans-serif" font-size="14" font-weight="bold">${displayTitle}</text>
|
||||
|
||||
<!-- Duration -->
|
||||
${duration ? `<rect x="350" y="185" width="35" height="20" rx="3" fill="rgba(0,0,0,0.8)"/>
|
||||
<text x="367" y="198" fill="white" font-family="Arial, sans-serif" font-size="11" text-anchor="middle">${duration}</text>` : ''}
|
||||
|
||||
<!-- Video type indicator -->
|
||||
<text x="20" y="25" fill="rgba(255,255,255,0.7)" font-family="Arial, sans-serif" font-size="11">VIDEO</text>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
res.setHeader('Content-Type', 'image/svg+xml; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
return res.send(svg);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error creating thumbnail:", error);
|
||||
res.status(500).json({ message: "Error generating thumbnail" });
|
||||
}
|
||||
});
|
||||
|
||||
// API endpoint to generate multiple thumbnails for video preview
|
||||
app.post("/api/thumbnails/:videoId/generate", async (req, res) => {
|
||||
try {
|
||||
const { videoId } = req.params;
|
||||
const { timestamps = ["5", "15", "30", "60"] } = req.body;
|
||||
|
||||
const video = await storage.getVideo(videoId);
|
||||
if (!video) {
|
||||
return res.status(404).json({ message: "Video not found" });
|
||||
}
|
||||
|
||||
const thumbnailPaths = await thumbnailGenerator.generateMultipleThumbnails(videoId, timestamps);
|
||||
|
||||
const thumbnails = thumbnailPaths.map((path, index) => ({
|
||||
timestamp: timestamps[index],
|
||||
url: `/thumbnail/${videoId}?t=${timestamps[index]}`,
|
||||
path: path
|
||||
}));
|
||||
|
||||
res.json({ thumbnails });
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error generating thumbnails:", error);
|
||||
res.status(500).json({ message: "Error generating thumbnails" });
|
||||
}
|
||||
});
|
||||
|
||||
// API endpoint to list existing thumbnails for a video
|
||||
app.get("/api/thumbnails/:videoId", async (req, res) => {
|
||||
try {
|
||||
const { videoId } = req.params;
|
||||
const thumbnailPaths = thumbnailGenerator.listThumbnails(videoId);
|
||||
|
||||
const thumbnails = thumbnailPaths.map(path => {
|
||||
const filename = require('path').basename(path);
|
||||
const match = filename.match(new RegExp(`${videoId}_(.+)_\\d+x\\d+\\.jpg`));
|
||||
const timestamp = match ? match[1].replace(/-/g, ':') : 'unknown';
|
||||
|
||||
return {
|
||||
timestamp,
|
||||
url: `/thumbnail/${videoId}?t=${timestamp}`,
|
||||
path: path
|
||||
};
|
||||
});
|
||||
|
||||
res.json({ thumbnails });
|
||||
|
||||
} catch (error) {
|
||||
console.error("Error listing thumbnails:", error);
|
||||
res.status(500).json({ message: "Error listing thumbnails" });
|
||||
}
|
||||
});
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
@ -1,69 +1,23 @@
|
||||
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 { type Video, type InsertVideo } 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() {
|
||||
@ -191,222 +145,6 @@ 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
|
||||
@ -482,291 +220,6 @@ 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
|
||||
@ -777,10 +230,10 @@ const hasBunnyConfig = process.env.BUNNY_API_KEY && process.env.BUNNY_LIBRARY_ID
|
||||
|
||||
if (hasBunnyConfig) {
|
||||
try {
|
||||
storage = new HybridStorage();
|
||||
console.log('✅ Using Hybrid storage (Bunny.net + Database) with library ID:', process.env.BUNNY_LIBRARY_ID);
|
||||
storage = new BunnyStorage();
|
||||
console.log('✅ Using Bunny.net storage with library ID:', process.env.BUNNY_LIBRARY_ID);
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to initialize Hybrid storage:', error);
|
||||
console.error('❌ Failed to initialize Bunny.net storage:', error);
|
||||
console.log('📁 Falling back to memory storage');
|
||||
storage = new MemStorage();
|
||||
}
|
||||
|
||||
@ -1,158 +0,0 @@
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
export interface ThumbnailOptions {
|
||||
videoId: string;
|
||||
timeStamp?: string; // Format: "00:01:30" or "90" (seconds)
|
||||
width?: number;
|
||||
height?: number;
|
||||
quality?: number;
|
||||
}
|
||||
|
||||
export class ThumbnailGenerator {
|
||||
private thumbnailDir: string;
|
||||
|
||||
constructor() {
|
||||
this.thumbnailDir = path.join(process.cwd(), 'thumbnails');
|
||||
this.ensureThumbnailDir();
|
||||
}
|
||||
|
||||
private ensureThumbnailDir() {
|
||||
if (!fs.existsSync(this.thumbnailDir)) {
|
||||
fs.mkdirSync(this.thumbnailDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
async generateThumbnail(options: ThumbnailOptions): Promise<string | null> {
|
||||
const { videoId, timeStamp = "5", width = 400, height = 225, quality = 85 } = options;
|
||||
|
||||
// Create unique filename based on parameters
|
||||
const filename = `${videoId}_${timeStamp.replace(/:/g, '-')}_${width}x${height}.jpg`;
|
||||
const outputPath = path.join(this.thumbnailDir, filename);
|
||||
|
||||
// Check if thumbnail already exists
|
||||
if (fs.existsSync(outputPath)) {
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
try {
|
||||
// Use Bunny.net direct video URL for FFmpeg
|
||||
const videoUrl = `https://vz-7982dfc4-cc8.b-cdn.net/${videoId}/playlist.m3u8`;
|
||||
|
||||
// FFmpeg command to extract frame at specific time
|
||||
const ffmpegCommand = [
|
||||
'ffmpeg',
|
||||
'-i', `"${videoUrl}"`,
|
||||
'-ss', timeStamp,
|
||||
'-vframes', '1',
|
||||
'-vf', `scale=${width}:${height}`,
|
||||
'-q:v', quality.toString(),
|
||||
`"${outputPath}"`,
|
||||
'-y'
|
||||
].join(' ');
|
||||
|
||||
console.log(`Generating thumbnail: ${ffmpegCommand}`);
|
||||
|
||||
const { stdout, stderr } = await execAsync(ffmpegCommand);
|
||||
|
||||
if (fs.existsSync(outputPath)) {
|
||||
console.log(`Thumbnail generated successfully: ${outputPath}`);
|
||||
return outputPath;
|
||||
} else {
|
||||
console.error('Thumbnail file was not created');
|
||||
return null;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error generating thumbnail:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async generateMultipleThumbnails(videoId: string, timestamps: string[]): Promise<string[]> {
|
||||
const results: string[] = [];
|
||||
|
||||
for (const timestamp of timestamps) {
|
||||
const thumbnailPath = await this.generateThumbnail({
|
||||
videoId,
|
||||
timeStamp: timestamp
|
||||
});
|
||||
|
||||
if (thumbnailPath) {
|
||||
results.push(thumbnailPath);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async enhanceThumbnail(inputPath: string, outputPath: string): Promise<string | null> {
|
||||
try {
|
||||
// Use ImageMagick to enhance the thumbnail
|
||||
const magickCommand = [
|
||||
'convert',
|
||||
`"${inputPath}"`,
|
||||
'-auto-level',
|
||||
'-enhance',
|
||||
'-unsharp', '0x1',
|
||||
`"${outputPath}"`
|
||||
].join(' ');
|
||||
|
||||
await execAsync(magickCommand);
|
||||
|
||||
if (fs.existsSync(outputPath)) {
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Error enhancing thumbnail:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
listThumbnails(videoId: string): string[] {
|
||||
try {
|
||||
const files = fs.readdirSync(this.thumbnailDir);
|
||||
return files
|
||||
.filter(file => file.startsWith(videoId) && file.endsWith('.jpg'))
|
||||
.map(file => path.join(this.thumbnailDir, file));
|
||||
} catch (error) {
|
||||
console.error('Error listing thumbnails:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
deleteThumbnail(videoId: string, timestamp?: string): boolean {
|
||||
try {
|
||||
if (timestamp) {
|
||||
// Delete specific thumbnail
|
||||
const filename = `${videoId}_${timestamp.replace(/:/g, '-')}_400x225.jpg`;
|
||||
const filePath = path.join(this.thumbnailDir, filename);
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
// Delete all thumbnails for video
|
||||
const files = fs.readdirSync(this.thumbnailDir);
|
||||
const videoThumbnails = files.filter(file => file.startsWith(videoId));
|
||||
|
||||
for (const file of videoThumbnails) {
|
||||
fs.unlinkSync(path.join(this.thumbnailDir, file));
|
||||
}
|
||||
|
||||
return videoThumbnails.length > 0;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('Error deleting thumbnail:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
121
shared/schema.ts
121
shared/schema.ts
@ -1,6 +1,5 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { pgTable, text, varchar, integer, timestamp, boolean, primaryKey, uuid } from "drizzle-orm/pg-core";
|
||||
import { relations } from "drizzle-orm";
|
||||
import { pgTable, text, varchar, integer, timestamp } from "drizzle-orm/pg-core";
|
||||
import { createInsertSchema } from "drizzle-zod";
|
||||
import { z } from "zod";
|
||||
|
||||
@ -23,121 +22,3 @@ 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],
|
||||
}),
|
||||
}));
|
||||
|
||||
25
test.svg
25
test.svg
@ -1,25 +0,0 @@
|
||||
|
||||
<svg width="400" height="225" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#1e40af;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#1e3a8a;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="400" height="225" fill="url(#grad)" />
|
||||
|
||||
<!-- Play button circle -->
|
||||
<circle cx="200" cy="112" r="35" fill="rgba(255,255,255,0.9)" />
|
||||
<!-- Play button triangle -->
|
||||
<polygon points="188,97 188,127 218,112" fill="#1e40af" />
|
||||
|
||||
<!-- Title background -->
|
||||
<rect x="0" y="175" width="400" height="50" fill="rgba(0,0,0,0.7)" />
|
||||
|
||||
<!-- Title text -->
|
||||
<text x="20" y="200" fill="white" font-family="Arial, sans-serif" font-size="14" font-weight="bold">Alex Reichinger - Ciao mia bella</text>
|
||||
|
||||
<!-- Duration -->
|
||||
<text x="370" y="200" fill="white" font-family="Arial, sans-serif" font-size="12" text-anchor="end">3:02</text>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
@ -1,19 +0,0 @@
|
||||
|
||||
<svg width="400" height="225" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#1e40af;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#0f172a;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="400" height="225" fill="url(#grad)"/>
|
||||
<circle cx="200" cy="112.5" r="30" fill="rgba(255,255,255,0.8)"/>
|
||||
<polygon points="190,98 190,127 215,112.5" fill="#000"/>
|
||||
<text x="200" y="170" text-anchor="middle" fill="white" font-family="Arial, sans-serif" font-size="14" font-weight="bold">
|
||||
<tspan x="200" dy="0">Alex</tspan><tspan x="200" dy="18">Reichinger</tspan><tspan x="200" dy="18">-</tspan><tspan x="200" dy="18">Ciao</tspan><tspan x="200" dy="18">mia</tspan><tspan x="200" dy="18">bella</tspan>
|
||||
</text>
|
||||
<text x="350" y="20" text-anchor="middle" fill="white" font-family="Arial, sans-serif" font-size="12" font-weight="bold">
|
||||
3:02
|
||||
</text>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
Loading…
Reference in New Issue
Block a user