- /login route did not exist, so /admin redirected to /api/login -> /login -> 404 - /api/auth/user still ran the Replit-era deterministic UUID hash on the session id, so it never found the user; now reads the session directly - admin logout posts to /api/auth/logout and returns to /login
587 lines
23 KiB
TypeScript
587 lines
23 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";
|
|
import HeaderAd from "@/components/HeaderAd";
|
|
import VideoUpload from "@/components/video-upload";
|
|
import InventoryPanel from "@/components/inventory-panel";
|
|
|
|
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 = "/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={async () => { await fetch("/api/auth/logout", { method: "POST", credentials: "include" }); window.location.href = "/login"; }}
|
|
className="text-white border-white/20 hover:bg-white/10"
|
|
>
|
|
Logout
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Fixed Header Ad */}
|
|
<HeaderAd />
|
|
|
|
{/* 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">
|
|
<VideoUpload />
|
|
<InventoryPanel />
|
|
<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: "Erfolg!",
|
|
description: `KI-Beschreibung wurde erstellt und gespeichert (${data.characterCount || data.description.length}/500 Zeichen)`,
|
|
});
|
|
} else {
|
|
console.error("No description in response:", data);
|
|
toast({
|
|
title: "Fehler",
|
|
description: "KI hat keine Beschreibung zurückgegeben",
|
|
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>
|
|
);
|
|
} |