Update the video editing form to correctly process episode numbers and tags before submission, ensuring proper data types. Localize UI elements such as "Visibility Status" and its options to Slovenian. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 2cd2c0bc-434c-4bc9-ad3f-b99d3897a0d1 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/8cc42625-c1f5-4e43-99bd-77f2c4dedee2/2cd2c0bc-434c-4bc9-ad3f-b99d3897a0d1/ezyc5gl
561 lines
21 KiB
TypeScript
561 lines
21 KiB
TypeScript
import { useState, useEffect } from "react";
|
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { useAuth } from "@/hooks/useAuth";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import { LoadingSpinner } from "@/components/loading-spinner";
|
|
import { apiRequest } from "@/lib/queryClient";
|
|
import type { Video } from "@shared/schema";
|
|
import { Shield, Edit, Upload, Search, Filter, Save, X, Sparkles, Loader2 } from "lucide-react";
|
|
|
|
export default function AdminPage() {
|
|
const { user, isLoading: authLoading, isAuthenticated, isAdmin } = useAuth();
|
|
const { toast } = useToast();
|
|
const queryClient = useQueryClient();
|
|
|
|
const [search, setSearch] = useState("");
|
|
const [selectedVideo, setSelectedVideo] = useState<Video | null>(null);
|
|
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
|
|
|
// Redirect if not admin
|
|
if (!authLoading && (!isAuthenticated || !isAdmin)) {
|
|
window.location.href = "/api/login";
|
|
return null;
|
|
}
|
|
|
|
if (authLoading) {
|
|
return (
|
|
<div className="min-h-screen bg-gradient-to-br from-[#2D1B69] to-[#6366f1] flex items-center justify-center">
|
|
<LoadingSpinner size="lg" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gradient-to-br from-[#2D1B69] to-[#6366f1]">
|
|
<div className="container mx-auto px-4 py-8">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between mb-8">
|
|
<div className="flex items-center space-x-4">
|
|
<Shield className="w-8 h-8 text-white" />
|
|
<div>
|
|
<h1 className="text-3xl font-bold text-white">Admin Dashboard</h1>
|
|
<p className="text-white/80">Manage go4.video content</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center space-x-4">
|
|
<Badge variant="secondary" className="text-sm">
|
|
{(user as any)?.firstName} {(user as any)?.lastName}
|
|
</Badge>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => window.location.href = "/api/logout"}
|
|
className="text-white border-white/20 hover:bg-white/10"
|
|
>
|
|
Logout
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
|
{/* Sidebar */}
|
|
<div className="lg:col-span-1">
|
|
<Card className="bg-white/10 border-white/20 text-white">
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center space-x-2">
|
|
<Filter className="w-5 h-5" />
|
|
<span>Filters</span>
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div>
|
|
<Label className="text-white/90">Search Videos</Label>
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-3 w-4 h-4 text-white/60" />
|
|
<Input
|
|
placeholder="Search by title..."
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
className="pl-10 bg-white/10 border-white/20 text-white placeholder:text-white/60"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Main Content */}
|
|
<div className="lg:col-span-3">
|
|
<VideoManagement search={search} onEditVideo={setSelectedVideo} onOpenDialog={setEditDialogOpen} />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Edit Video Dialog */}
|
|
{selectedVideo && (
|
|
<EditVideoDialog
|
|
video={selectedVideo}
|
|
open={editDialogOpen}
|
|
onOpenChange={setEditDialogOpen}
|
|
onSuccess={() => {
|
|
setEditDialogOpen(false);
|
|
setSelectedVideo(null);
|
|
queryClient.invalidateQueries({ queryKey: ["/api/admin/videos"] });
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function VideoManagement({
|
|
search,
|
|
onEditVideo,
|
|
onOpenDialog
|
|
}: {
|
|
search: string;
|
|
onEditVideo: (video: Video) => void;
|
|
onOpenDialog: (open: boolean) => void;
|
|
}) {
|
|
const { data, isLoading } = useQuery({
|
|
queryKey: ["/api/admin/videos", { search, limit: 500 }],
|
|
queryFn: async () => {
|
|
const response = await apiRequest("GET", `/api/admin/videos?limit=500&search=${encodeURIComponent(search)}`);
|
|
return response.json();
|
|
},
|
|
});
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<Card className="bg-white/10 border-white/20">
|
|
<CardContent className="p-8 flex justify-center">
|
|
<LoadingSpinner />
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
const videos = data?.videos || [];
|
|
|
|
return (
|
|
<Card className="bg-white/10 border-white/20 text-white">
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center justify-between">
|
|
<span>Video Management ({videos.length} of {data?.total || videos.length})</span>
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-4">
|
|
{videos.map((video: Video) => (
|
|
<div
|
|
key={video.id}
|
|
className="flex items-center space-x-4 p-4 bg-white/5 rounded-lg border border-white/10 hover:bg-white/10 transition-colors"
|
|
>
|
|
<img
|
|
src={video.customThumbnailUrl || video.thumbnailUrl}
|
|
alt={video.title}
|
|
className="w-24 h-16 object-cover rounded"
|
|
/>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center space-x-2 mb-1">
|
|
<h3 className="font-semibold text-white truncate">{video.title}</h3>
|
|
<span className={`px-2 py-1 text-xs rounded-full ${
|
|
video.isPublic
|
|
? 'bg-green-500/20 text-green-300 border border-green-500/30'
|
|
: 'bg-orange-500/20 text-orange-300 border border-orange-500/30'
|
|
}`}>
|
|
{video.isPublic ? 'Published' : 'Draft'}
|
|
</span>
|
|
</div>
|
|
<p className="text-sm text-white/70 truncate">{video.description}</p>
|
|
<div className="flex items-center space-x-4 mt-1">
|
|
<Badge variant="outline" className="text-xs border-white/20 text-white/80">
|
|
{video.contentType}
|
|
</Badge>
|
|
<Badge variant="outline" className="text-xs border-white/20 text-white/80">
|
|
{video.genre}
|
|
</Badge>
|
|
<span className="text-xs text-white/60">{video.views} views</span>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
onEditVideo(video);
|
|
onOpenDialog(true);
|
|
}}
|
|
className="border-white/20 text-white hover:bg-white/10"
|
|
>
|
|
<Edit className="w-4 h-4 mr-2" />
|
|
Edit
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function EditVideoDialog({
|
|
video,
|
|
open,
|
|
onOpenChange,
|
|
onSuccess
|
|
}: {
|
|
video: Video;
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
onSuccess: () => void;
|
|
}) {
|
|
const { toast } = useToast();
|
|
const queryClient = useQueryClient();
|
|
const [formData, setFormData] = useState({
|
|
title: video.title,
|
|
artist: video.artist || "",
|
|
description: video.description,
|
|
filename: video.filename || "",
|
|
episodeNumber: video.episodeNumber || "",
|
|
episodeTitle: video.episodeTitle || "",
|
|
tags: video.tags || [],
|
|
contentType: video.contentType,
|
|
genre: video.genre,
|
|
customThumbnailUrl: video.customThumbnailUrl || "",
|
|
isPublic: video.isPublic !== undefined ? video.isPublic : true,
|
|
});
|
|
|
|
// Update form data when video prop changes
|
|
useEffect(() => {
|
|
setFormData({
|
|
title: video.title,
|
|
artist: video.artist || "",
|
|
description: video.description,
|
|
filename: video.filename || "",
|
|
episodeNumber: video.episodeNumber || "",
|
|
episodeTitle: video.episodeTitle || "",
|
|
tags: video.tags || [],
|
|
contentType: video.contentType,
|
|
genre: video.genre,
|
|
customThumbnailUrl: video.customThumbnailUrl || "",
|
|
isPublic: video.isPublic !== undefined ? video.isPublic : true,
|
|
});
|
|
}, [video]);
|
|
const [isGeneratingAI, setIsGeneratingAI] = useState(false);
|
|
|
|
const updateMutation = useMutation({
|
|
mutationFn: (data: any) => apiRequest("PATCH", `/api/admin/videos/${video.id}`, data),
|
|
onSuccess: () => {
|
|
toast({
|
|
title: "Success",
|
|
description: "Video updated successfully",
|
|
});
|
|
// Invalidate cache to refresh the video list - don't await since this function isn't async
|
|
queryClient.invalidateQueries({ queryKey: ["/api/admin/videos"] });
|
|
queryClient.invalidateQueries({ queryKey: ["/api/videos"] });
|
|
// Force immediate refetch
|
|
queryClient.refetchQueries({ queryKey: ["/api/admin/videos"] });
|
|
onOpenChange(false);
|
|
onSuccess();
|
|
},
|
|
onError: (error: any) => {
|
|
toast({
|
|
title: "Error",
|
|
description: error.message || "Failed to update video",
|
|
variant: "destructive",
|
|
});
|
|
},
|
|
});
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
|
|
// Convert string values to appropriate types for the API
|
|
const processedFormData = {
|
|
...formData,
|
|
episodeNumber: formData.episodeNumber ? parseInt(formData.episodeNumber.toString()) : null,
|
|
tags: Array.isArray(formData.tags) ? formData.tags : []
|
|
};
|
|
|
|
updateMutation.mutate(processedFormData);
|
|
};
|
|
|
|
const generateAIDescription = async () => {
|
|
if (!formData.title.trim()) {
|
|
toast({
|
|
title: "Error",
|
|
description: "Please enter a title first",
|
|
variant: "destructive",
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Use description field content as custom instructions
|
|
const instructions = formData.description.trim();
|
|
|
|
setIsGeneratingAI(true);
|
|
try {
|
|
const response = await apiRequest("POST", `/api/admin/videos/${video.id}/generate-description`, {
|
|
maxCharacters: 500,
|
|
includeArtistInfo: true,
|
|
includeLabelInfo: true,
|
|
customInstructions: instructions || undefined
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
console.log("AI Response:", data); // Debug log
|
|
|
|
if (data && data.description) {
|
|
console.log("Setting description:", data.description); // Debug log
|
|
const newFormData = {
|
|
...formData,
|
|
description: data.description
|
|
};
|
|
setFormData(newFormData);
|
|
|
|
// Automatically save the generated description with proper type conversion
|
|
const processedData = {
|
|
...newFormData,
|
|
episodeNumber: newFormData.episodeNumber ? parseInt(newFormData.episodeNumber.toString()) : null,
|
|
tags: Array.isArray(newFormData.tags) ? newFormData.tags : []
|
|
};
|
|
updateMutation.mutate(processedData);
|
|
|
|
toast({
|
|
title: "Uspeh!",
|
|
description: `AI opis je bil ustvarjen in shranjen (${data.characterCount || data.description.length}/500 znakov)`,
|
|
});
|
|
} else {
|
|
console.error("No description in response:", data);
|
|
toast({
|
|
title: "Napaka",
|
|
description: "AI ni vrnil opisa",
|
|
variant: "destructive",
|
|
});
|
|
}
|
|
} catch (error: any) {
|
|
toast({
|
|
title: "Error",
|
|
description: error.message || "Failed to generate AI description",
|
|
variant: "destructive",
|
|
});
|
|
} finally {
|
|
setIsGeneratingAI(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="max-w-2xl bg-[#2D1B69] border-white/20 text-white">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center space-x-2">
|
|
<Edit className="w-5 h-5" />
|
|
<span>Edit Video</span>
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<form onSubmit={handleSubmit} className="space-y-6">
|
|
{/* Content Type and Genre - Top Priority */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 p-3 bg-white/5 rounded-lg border border-white/10">
|
|
<div>
|
|
<Label className="text-white/90 text-sm">Content Type</Label>
|
|
<Select
|
|
value={formData.contentType}
|
|
onValueChange={(value) => setFormData({ ...formData, contentType: value as any })}
|
|
>
|
|
<SelectTrigger className="bg-white/10 border-white/20 text-white h-9">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent className="bg-[#2D1B69] border-white/20">
|
|
<SelectItem value="music_video">Music Video</SelectItem>
|
|
<SelectItem value="oddaja">Show/Episode</SelectItem>
|
|
<SelectItem value="video">Video</SelectItem>
|
|
<SelectItem value="documentary">Documentary</SelectItem>
|
|
<SelectItem value="live">Live Performance</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div>
|
|
<Label className="text-white/90 text-sm">Genre</Label>
|
|
<Select
|
|
value={formData.genre}
|
|
onValueChange={(value) => setFormData({ ...formData, genre: value as any })}
|
|
>
|
|
<SelectTrigger className="bg-white/10 border-white/20 text-white h-9">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent className="bg-[#2D1B69] border-white/20">
|
|
<SelectItem value="volksmusik">Folk Music</SelectItem>
|
|
<SelectItem value="schlager">Schlager</SelectItem>
|
|
<SelectItem value="pop">Pop</SelectItem>
|
|
<SelectItem value="rock">Rock</SelectItem>
|
|
<SelectItem value="country">Country</SelectItem>
|
|
<SelectItem value="instrumental">Instrumental</SelectItem>
|
|
<SelectItem value="dance">Dance</SelectItem>
|
|
<SelectItem value="other">Other</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<Label className="text-white/90">
|
|
{formData.contentType === 'oddaja' ? 'Show Name' : 'Song Title'}
|
|
</Label>
|
|
<Input
|
|
value={formData.title}
|
|
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
|
className="bg-white/10 border-white/20 text-white"
|
|
placeholder={formData.contentType === 'oddaja' ? "Show name (e.g., Die Geschichte des Liedes)..." : "Song title..."}
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Label className={`text-white/90 ${formData.contentType === 'oddaja' ? 'text-gray-500' : ''}`}>
|
|
Artist / Band
|
|
</Label>
|
|
<Input
|
|
value={formData.artist}
|
|
onChange={(e) => setFormData({ ...formData, artist: e.target.value })}
|
|
className={`bg-white/10 border-white/20 text-white ${formData.contentType === 'oddaja' ? 'bg-gray-600 text-gray-400 cursor-not-allowed' : ''}`}
|
|
placeholder="Artist or band name..."
|
|
disabled={formData.contentType === 'oddaja'}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Label className="text-white/90">Original Filename (from CDN)</Label>
|
|
<Input
|
|
value={formData.filename || ''}
|
|
className="bg-white/10 border-white/20 text-white"
|
|
placeholder="Automatically filled from CDN..."
|
|
readOnly
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Label className={`text-white/90 ${formData.contentType === 'music_video' || formData.contentType === 'video' ? 'text-gray-500' : ''}`}>
|
|
Episode Title / Guest Name
|
|
</Label>
|
|
<Input
|
|
value={formData.episodeTitle}
|
|
onChange={(e) => setFormData({ ...formData, episodeTitle: e.target.value })}
|
|
className={`bg-white/10 border-white/20 text-white ${formData.contentType === 'music_video' || formData.contentType === 'video' ? 'bg-gray-600 text-gray-400 cursor-not-allowed' : ''}`}
|
|
placeholder="e.g., Ansambel Zupan"
|
|
disabled={formData.contentType === 'music_video' || formData.contentType === 'video'}
|
|
/>
|
|
</div>
|
|
|
|
<div className="md:col-span-2">
|
|
<Label className="text-white/90">Tags/Hashtags</Label>
|
|
<Input
|
|
value={Array.isArray(formData.tags) ? formData.tags.join(', ') : ''}
|
|
onChange={(e) => {
|
|
const tagString = e.target.value;
|
|
const tagsArray = tagString.split(',').map(tag => tag.trim()).filter(tag => tag.length > 0);
|
|
setFormData({ ...formData, tags: tagsArray });
|
|
}}
|
|
className="bg-white/10 border-white/20 text-white mb-4"
|
|
placeholder="Enter tags separated by commas (e.g., volksmusik, austria, live)"
|
|
/>
|
|
</div>
|
|
|
|
<div className="md:col-span-2">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<Label className="text-white/90">Description</Label>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={generateAIDescription}
|
|
disabled={isGeneratingAI || !formData.title.trim()}
|
|
className="border-white/20 text-white hover:bg-white/10 disabled:opacity-50"
|
|
>
|
|
{isGeneratingAI ? (
|
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
) : (
|
|
<Sparkles className="w-4 h-4 mr-2" />
|
|
)}
|
|
{isGeneratingAI ? "Generating..." : "Generate AI Description"}
|
|
</Button>
|
|
</div>
|
|
<Textarea
|
|
value={formData.description}
|
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
|
className="bg-white/10 border-white/20 text-white min-h-[100px]"
|
|
rows={3}
|
|
placeholder="Enter description manually or type AI instructions (e.g., 'mention band members', 'focus on song history') and click Generate AI Description..."
|
|
/>
|
|
<div className="text-xs text-white/60 mt-1">
|
|
{formData.description.length}/500 characters
|
|
</div>
|
|
</div>
|
|
|
|
|
|
<div>
|
|
<Label className="text-white/90">Custom Thumbnail URL</Label>
|
|
<Input
|
|
value={formData.customThumbnailUrl}
|
|
onChange={(e) => setFormData({ ...formData, customThumbnailUrl: e.target.value })}
|
|
className="bg-white/10 border-white/20 text-white"
|
|
placeholder="https://example.com/thumbnail.jpg"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Label className="text-white/90">Status vidnosti</Label>
|
|
<Select
|
|
value={formData.isPublic ? "public" : "private"}
|
|
onValueChange={(value) => setFormData({ ...formData, isPublic: value === "public" })}
|
|
>
|
|
<SelectTrigger className="bg-white/10 border-white/20 text-white">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent className="bg-[#2D1B69] border-white/20">
|
|
<SelectItem value="public">Objavljeno (Javno)</SelectItem>
|
|
<SelectItem value="private">Osnutek (Zasebno)</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-end space-x-4">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() => onOpenChange(false)}
|
|
className="border-white/20 text-white hover:bg-white/10"
|
|
>
|
|
<X className="w-4 h-4 mr-2" />
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
type="submit"
|
|
disabled={updateMutation.isPending}
|
|
className="bg-gradient-to-r from-cyan-500 to-purple-600 hover:from-cyan-600 hover:to-purple-700 text-white"
|
|
>
|
|
{updateMutation.isPending ? (
|
|
<LoadingSpinner size="sm" className="mr-2" />
|
|
) : (
|
|
<Save className="w-4 h-4 mr-2" />
|
|
)}
|
|
Save Changes
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
} |