videofolxtv/client/src/pages/admin.tsx
sebastjanartic 6a53907814 Update branding to consistently use video.folx.tv across the platform
Adjusts various components and pages to display "video.folx.tv" instead of "FOLX.TV", with the exception of the home page header.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 946a0075-7e32-454b-b348-9e7f576d7f45
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/60d372ff-2c10-46c7-b01b-10c3435136b0/946a0075-7e32-454b-b348-9e7f576d7f45/o9BuTTJ
2025-09-04 13:47:39 +00:00

579 lines
22 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 video.folx.tv 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 || "music_video",
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 || "music_video",
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: async () => {
toast({
title: "Success",
description: "Video updated successfully",
});
// Invalidate cache to refresh the video list
await queryClient.invalidateQueries({ queryKey: ["/api/admin/videos"] });
await queryClient.invalidateQueries({ queryKey: ["/api/videos"] });
// Force immediate refetch to get updated data
await queryClient.refetchQueries({ queryKey: ["/api/admin/videos"] });
// Give some time for cache to update before closing modal
setTimeout(() => {
onOpenChange(false);
onSuccess();
}, 100);
},
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
// Update only the description field, keep all other current form data
setFormData(prev => ({
...prev,
description: data.description
}));
// For saving, use current formData but update description
const processedData = {
...formData, // Use current form data
description: data.description, // Only update description
episodeNumber: formData.episodeNumber ? parseInt(formData.episodeNumber.toString()) : null,
tags: Array.isArray(formData.tags) ? formData.tags : []
};
// Save without closing modal for AI generation
try {
console.log("About to save AI data:", JSON.stringify(processedData, null, 2));
const response = await apiRequest("PATCH", `/api/admin/videos/${video.id}`, processedData);
console.log("AI save response:", response.status);
// Refresh cache after save
await queryClient.invalidateQueries({ queryKey: ["/api/admin/videos"] });
await queryClient.refetchQueries({ queryKey: ["/api/admin/videos"] });
console.log("Cache refreshed after AI save");
} catch (error) {
console.error("Failed to save AI description:", error);
}
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' ? '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">
{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">Original Filename (from CDN)</Label>
<Input
value={video.title || ''}
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>
);
}