Add admin dashboard and Replit authentication integration
Integrates Replit authentication using OpenID Connect, adds an admin dashboard route with video management and thumbnail upload capabilities, and updates dependencies. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 890577b1-c154-40a4-a177-a0c6d55320c3 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/8cc42625-c1f5-4e43-99bd-77f2c4dedee2/890577b1-c154-40a4-a177-a0c6d55320c3/1jMBtLj
This commit is contained in:
@@ -8,6 +8,7 @@ import VideoPage from "@/pages/VideoPage";
|
||||
import FolxStadlPage from "@/pages/FolxStadlPage";
|
||||
import GeschichteLiedPage from "@/pages/GeschichteLiedPage";
|
||||
import GipfelstammtischPage from "@/pages/GipfelstammtischPage";
|
||||
import AdminPage from "@/pages/admin";
|
||||
import NotFound from "@/pages/not-found";
|
||||
|
||||
function Router() {
|
||||
@@ -18,6 +19,7 @@ function Router() {
|
||||
<Route path="/folx-stadl" component={FolxStadlPage} />
|
||||
<Route path="/geschichte-lied" component={GeschichteLiedPage} />
|
||||
<Route path="/gipfelstammtisch" component={GipfelstammtischPage} />
|
||||
<Route path="/admin" component={AdminPage} />
|
||||
<Route component={NotFound} />
|
||||
</Switch>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,7 @@ interface LoadingSpinnerProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function LoadingSpinner({ size = 'md', text = 'Loading...', className = '' }: LoadingSpinnerProps) {
|
||||
export function LoadingSpinner({ size = 'md', text = 'Loading...', className = '' }: LoadingSpinnerProps) {
|
||||
const sizeClasses = {
|
||||
sm: 'w-6 h-6',
|
||||
md: 'w-10 h-10',
|
||||
@@ -25,4 +25,6 @@ export default function LoadingSpinner({ size = 'md', text = 'Loading...', class
|
||||
<div className={`text-white ${textSizeClasses[size]} font-medium`}>{text}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default LoadingSpinner;
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
export function useAuth() {
|
||||
const { data: user, isLoading } = useQuery({
|
||||
queryKey: ["/api/auth/user"],
|
||||
retry: false,
|
||||
});
|
||||
|
||||
return {
|
||||
user,
|
||||
isLoading,
|
||||
isAuthenticated: !!user,
|
||||
isAdmin: user?.isAdmin || false,
|
||||
isSuperAdmin: user?.isSuperAdmin || false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
import { useState } 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 } 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?.firstName} {user?.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: 50 }],
|
||||
queryFn: () => apiRequest(`/api/admin/videos?limit=50&search=${encodeURIComponent(search)}`),
|
||||
});
|
||||
|
||||
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})</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">
|
||||
<h3 className="font-semibold text-white truncate">{video.title}</h3>
|
||||
<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 [formData, setFormData] = useState({
|
||||
title: video.title,
|
||||
description: video.description,
|
||||
contentType: video.contentType,
|
||||
genre: video.genre,
|
||||
customThumbnailUrl: video.customThumbnailUrl || "",
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: any) => apiRequest(`/api/admin/videos/${video.id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Video updated successfully",
|
||||
});
|
||||
onSuccess();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: error.message || "Failed to update video",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
updateMutation.mutate(formData);
|
||||
};
|
||||
|
||||
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">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="md:col-span-2">
|
||||
<Label className="text-white/90">Title</Label>
|
||||
<Input
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
className="bg-white/10 border-white/20 text-white"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<Label className="text-white/90">Description</Label>
|
||||
<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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-white/90">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">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#2D1B69] border-white/20">
|
||||
<SelectItem value="video">Video</SelectItem>
|
||||
<SelectItem value="oddaja">Oddaja</SelectItem>
|
||||
<SelectItem value="music_video">Music Video</SelectItem>
|
||||
<SelectItem value="documentary">Documentary</SelectItem>
|
||||
<SelectItem value="live">Live</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-white/90">Genre</Label>
|
||||
<Select
|
||||
value={formData.genre}
|
||||
onValueChange={(value) => setFormData({ ...formData, genre: value as any })}
|
||||
>
|
||||
<SelectTrigger className="bg-white/10 border-white/20 text-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#2D1B69] border-white/20">
|
||||
<SelectItem value="volksmusik">Volksmusik</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 className="md:col-span-2">
|
||||
<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>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user