videofolxtv/client/src/pages/admin.tsx
sebastjanartic e2ec1209fe Improve video editing form with data type conversion and Slovenian localization
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
2025-09-02 14:04:03 +00:00

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