Refactors client-side navigation to use `wouter`'s `setLocation` instead of `window.location.href` for smoother transitions and improves the logout process by making it an asynchronous POST request with proper error handling and state invalidation. Also adds an "Auctions" button to the main navigation bar on multiple pages. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 069d4324-6c40-4355-955e-c714a50de1ea Replit-Commit-Checkpoint-Type: intermediate_checkpoint Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/3df548ff-50ae-432f-9be4-25d34eccc983/069d4324-6c40-4355-955e-c714a50de1ea/bLfICpO
446 lines
18 KiB
TypeScript
446 lines
18 KiB
TypeScript
import { useQuery } from "@tanstack/react-query";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
import { useAuth } from "@/hooks/useAuth";
|
|
import { useEffect, useState } from "react";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import { isUnauthorizedError } from "@/lib/authUtils";
|
|
import { Search, Settings, ArrowUpDown } from "lucide-react";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import type { MediaOutlet } from "@shared/schema";
|
|
import MediaOutletManagement from "@/components/MediaOutletManagement";
|
|
import Footer from "@/components/Footer";
|
|
import { useLocation } from "wouter";
|
|
import { queryClient } from "@/lib/queryClient";
|
|
|
|
export default function AdminDashboard() {
|
|
const { user, isLoading } = useAuth();
|
|
const { toast } = useToast();
|
|
const [, setLocation] = useLocation();
|
|
const [searchTerm, setSearchTerm] = useState("");
|
|
const [selectedOutlet, setSelectedOutlet] = useState<MediaOutlet | null>(null);
|
|
const [managingOutlet, setManagingOutlet] = useState<MediaOutlet | null>(null);
|
|
const [sortBy, setSortBy] = useState<"alphabetical" | "traffic">("alphabetical");
|
|
|
|
// Redirect if not authenticated or not admin/superadmin
|
|
useEffect(() => {
|
|
if (!isLoading && (!user || (user.role !== 'admin' && user.role !== 'superadmin'))) {
|
|
toast({
|
|
title: "Unauthorized",
|
|
description: "You don't have permission to access this page.",
|
|
variant: "destructive",
|
|
});
|
|
setTimeout(() => {
|
|
setLocation("/");
|
|
}, 500);
|
|
}
|
|
}, [isLoading, user, toast]);
|
|
|
|
const { data: mediaOutlets = [], isLoading: outletsLoading } = useQuery<MediaOutlet[]>({
|
|
queryKey: ["/api/media-outlets"],
|
|
queryFn: async () => {
|
|
const res = await fetch("/api/media-outlets", {
|
|
credentials: "include",
|
|
});
|
|
if (!res.ok) {
|
|
throw new Error(`${res.status}: ${res.statusText}`);
|
|
}
|
|
return res.json();
|
|
},
|
|
});
|
|
|
|
// Filter and sort outlets based on search term and sort option
|
|
const filteredOutlets = mediaOutlets
|
|
.filter(outlet =>
|
|
outlet.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
(outlet.description && outlet.description.toLowerCase().includes(searchTerm.toLowerCase()))
|
|
)
|
|
.sort((a, b) => {
|
|
if (sortBy === "alphabetical") {
|
|
return a.name.localeCompare(b.name);
|
|
} else {
|
|
// Sort by traffic score (descending - highest traffic first)
|
|
return (b.trafficScore || 0) - (a.trafficScore || 0);
|
|
}
|
|
});
|
|
|
|
// Group outlets by category and sort
|
|
const getOutletsByCategory = (category: string) => {
|
|
const filtered = filteredOutlets.filter(outlet =>
|
|
outlet.category.toLowerCase() === category.toLowerCase()
|
|
);
|
|
|
|
return filtered.sort((a, b) => {
|
|
if (sortBy === "alphabetical") {
|
|
return a.name.localeCompare(b.name);
|
|
} else {
|
|
// Sort by traffic score (descending - highest traffic first)
|
|
return (b.trafficScore || 0) - (a.trafficScore || 0);
|
|
}
|
|
});
|
|
};
|
|
|
|
const handleLogout = async () => {
|
|
try {
|
|
const response = await fetch("/api/logout", {
|
|
method: "POST",
|
|
credentials: "include",
|
|
});
|
|
|
|
if (response.ok) {
|
|
toast({
|
|
title: "Logged Out",
|
|
description: "You have been successfully logged out.",
|
|
});
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["/api/auth/user"] });
|
|
setLocation("/");
|
|
}
|
|
} catch (error) {
|
|
toast({
|
|
title: "Logout Error",
|
|
description: "An error occurred while logging out.",
|
|
variant: "destructive",
|
|
});
|
|
}
|
|
};
|
|
|
|
if (isLoading || !user || (user.role !== 'admin' && user.role !== 'superadmin')) {
|
|
return (
|
|
<div className="min-h-screen bg-background flex items-center justify-center">
|
|
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary"></div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// If managing an outlet, show MediaOutletManagement
|
|
if (managingOutlet) {
|
|
return (
|
|
<MediaOutletManagement
|
|
outlet={managingOutlet}
|
|
onBack={() => setManagingOutlet(null)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col min-h-screen bg-gray-50">
|
|
{/* Header */}
|
|
<header className="bg-white border-b border-gray-200 sticky top-0 z-50">
|
|
<div className="max-w-7xl mx-auto px-6 py-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center space-x-4">
|
|
<img
|
|
src="/attached_assets/logo_black_1759162717640.png"
|
|
alt="SAPIENS"
|
|
className="h-6 w-auto hover:opacity-80 transition-opacity cursor-pointer"
|
|
data-testid="logo-sapiens"
|
|
onClick={() => setLocation("/")}
|
|
/>
|
|
<div className="border-l border-gray-300 h-6"></div>
|
|
<div>
|
|
<h1 className="text-lg font-bold text-gray-900">Admin Dashboard</h1>
|
|
<p className="text-xs text-gray-600">Search and select media outlets to manage</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center space-x-4">
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
|
<Input
|
|
type="text"
|
|
placeholder="Search media outlets..."
|
|
className="w-80 pl-10 bg-gray-50 border-gray-200"
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
data-testid="input-admin-search"
|
|
/>
|
|
</div>
|
|
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setLocation("/auctions")}
|
|
data-testid="button-auctions"
|
|
>
|
|
Auctions
|
|
</Button>
|
|
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
data-testid="button-settings"
|
|
>
|
|
<Settings className="h-4 w-4" />
|
|
</Button>
|
|
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleLogout}
|
|
data-testid="button-logout"
|
|
>
|
|
Logout
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<main className="flex-1 max-w-7xl mx-auto px-4 py-4 w-full">
|
|
{outletsLoading ? (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
|
{Array.from({ length: 12 }).map((_, i) => (
|
|
<Card key={i} className="animate-pulse">
|
|
<CardContent className="p-4">
|
|
<div className="flex items-center space-x-3">
|
|
<div className="w-12 h-12 bg-gray-200 rounded-full"></div>
|
|
<div className="flex-1">
|
|
<div className="h-4 bg-gray-200 rounded mb-2"></div>
|
|
<div className="h-3 bg-gray-200 rounded w-2/3"></div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
{/* People Section */}
|
|
<div data-testid="admin-section-people">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<h2 className="text-xl font-bold text-gray-900">
|
|
People
|
|
<span className="text-gray-400 text-base ml-2">({getOutletsByCategory("People").length})</span>
|
|
</h2>
|
|
<button
|
|
onClick={() => setSortBy(sortBy === "alphabetical" ? "traffic" : "alphabetical")}
|
|
className="p-1 rounded hover:bg-gray-100"
|
|
title={`Sort by ${sortBy === "alphabetical" ? "traffic" : "alphabetical"}`}
|
|
data-testid="people-sort-toggle"
|
|
>
|
|
<ArrowUpDown className="h-4 w-4 text-gray-500" />
|
|
</button>
|
|
</div>
|
|
<div className="space-y-2">
|
|
{getOutletsByCategory("People").map((outlet) => (
|
|
<Card
|
|
key={outlet.id}
|
|
className="hover:shadow-md transition-shadow cursor-pointer bg-white"
|
|
onClick={() => setSelectedOutlet(outlet)}
|
|
data-testid={`admin-card-outlet-${outlet.id}`}
|
|
>
|
|
<CardContent className="p-2">
|
|
<div className="flex items-center space-x-2">
|
|
<div className="w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center overflow-hidden">
|
|
{outlet.imageUrl ? (
|
|
<img
|
|
src={outlet.imageUrl}
|
|
alt={outlet.name}
|
|
className="w-full h-full object-cover"
|
|
style={{objectPosition: 'center top'}}
|
|
/>
|
|
) : (
|
|
<div className="w-6 h-6 bg-gray-300 rounded-full flex items-center justify-center">
|
|
<span className="text-gray-600 text-sm font-medium">
|
|
{outlet.name.charAt(0)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<h3 className="font-medium text-gray-900 truncate">
|
|
{outlet.name}
|
|
</h3>
|
|
<p className="text-sm text-gray-500 truncate">
|
|
{outlet.description || "Media Outlet"}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Topics Section */}
|
|
<div data-testid="admin-section-topics">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<h2 className="text-xl font-bold text-gray-900">
|
|
Topics
|
|
<span className="text-gray-400 text-base ml-2">({getOutletsByCategory("Topics").length})</span>
|
|
</h2>
|
|
<button
|
|
onClick={() => setSortBy(sortBy === "alphabetical" ? "traffic" : "alphabetical")}
|
|
className="p-1 rounded hover:bg-gray-100"
|
|
title={`Sort by ${sortBy === "alphabetical" ? "traffic" : "alphabetical"}`}
|
|
data-testid="topics-sort-toggle"
|
|
>
|
|
<ArrowUpDown className="h-4 w-4 text-gray-500" />
|
|
</button>
|
|
</div>
|
|
<div className="space-y-2">
|
|
{getOutletsByCategory("Topics").map((outlet) => (
|
|
<Card
|
|
key={outlet.id}
|
|
className="hover:shadow-md transition-shadow cursor-pointer bg-white"
|
|
onClick={() => setSelectedOutlet(outlet)}
|
|
data-testid={`admin-card-outlet-${outlet.id}`}
|
|
>
|
|
<CardContent className="p-2">
|
|
<div className="flex items-center space-x-2">
|
|
<div className="w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center overflow-hidden">
|
|
{outlet.imageUrl ? (
|
|
<img
|
|
src={outlet.imageUrl}
|
|
alt={outlet.name}
|
|
className="w-full h-full object-cover"
|
|
style={{objectPosition: 'center top'}}
|
|
/>
|
|
) : (
|
|
<div className="w-6 h-6 bg-gray-300 rounded-full flex items-center justify-center">
|
|
<span className="text-gray-600 text-sm font-medium">
|
|
{outlet.name.charAt(0)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<h3 className="font-medium text-gray-900 truncate">
|
|
{outlet.name}
|
|
</h3>
|
|
<p className="text-sm text-gray-500 truncate">
|
|
{outlet.description || "Media Outlet"}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Companies Section */}
|
|
<div data-testid="admin-section-companies">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<h2 className="text-xl font-bold text-gray-900">
|
|
Companies
|
|
<span className="text-gray-400 text-base ml-2">({getOutletsByCategory("Companies").length})</span>
|
|
</h2>
|
|
<button
|
|
onClick={() => setSortBy(sortBy === "alphabetical" ? "traffic" : "alphabetical")}
|
|
className="p-1 rounded hover:bg-gray-100"
|
|
title={`Sort by ${sortBy === "alphabetical" ? "traffic" : "alphabetical"}`}
|
|
data-testid="companies-sort-toggle"
|
|
>
|
|
<ArrowUpDown className="h-4 w-4 text-gray-500" />
|
|
</button>
|
|
</div>
|
|
<div className="space-y-2">
|
|
{getOutletsByCategory("Companies").map((outlet) => (
|
|
<Card
|
|
key={outlet.id}
|
|
className="hover:shadow-md transition-shadow cursor-pointer bg-white"
|
|
onClick={() => setSelectedOutlet(outlet)}
|
|
data-testid={`admin-card-outlet-${outlet.id}`}
|
|
>
|
|
<CardContent className="p-2">
|
|
<div className="flex items-center space-x-2">
|
|
<div className="w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center overflow-hidden">
|
|
{outlet.imageUrl ? (
|
|
<img
|
|
src={outlet.imageUrl}
|
|
alt={outlet.name}
|
|
className="w-full h-full object-cover"
|
|
style={{objectPosition: 'center top'}}
|
|
/>
|
|
) : (
|
|
<div className="w-6 h-6 bg-gray-300 rounded-full flex items-center justify-center">
|
|
<span className="text-gray-600 text-sm font-medium">
|
|
{outlet.name.charAt(0)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<h3 className="font-medium text-gray-900 truncate">
|
|
{outlet.name}
|
|
</h3>
|
|
<p className="text-sm text-gray-500 truncate">
|
|
{outlet.description || "Media Outlet"}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{filteredOutlets.length === 0 && searchTerm && (
|
|
<div className="text-center py-12">
|
|
<div className="text-gray-400 text-lg mb-2">No results found</div>
|
|
<div className="text-gray-500 text-sm">Try a different search term</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* Selected Outlet Modal/Preview */}
|
|
{selectedOutlet && (
|
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
|
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
|
|
<div className="flex items-center space-x-4 mb-4">
|
|
{selectedOutlet.imageUrl ? (
|
|
<img
|
|
src={selectedOutlet.imageUrl}
|
|
alt={selectedOutlet.name}
|
|
className="w-16 h-16 rounded-full object-cover"
|
|
/>
|
|
) : (
|
|
<div className="w-16 h-16 bg-gray-200 rounded-full flex items-center justify-center">
|
|
<span className="text-gray-600 text-lg font-medium">
|
|
{selectedOutlet.name.charAt(0)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
<div>
|
|
<h2 className="text-xl font-bold text-gray-900">{selectedOutlet.name}</h2>
|
|
<p className="text-sm text-gray-500">{selectedOutlet.description}</p>
|
|
<span className="inline-block px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 rounded-full mt-1">
|
|
{selectedOutlet.category}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="flex space-x-3">
|
|
<Button
|
|
className="flex-1"
|
|
onClick={() => {
|
|
setManagingOutlet(selectedOutlet);
|
|
setSelectedOutlet(null);
|
|
}}
|
|
data-testid="button-manage-outlet"
|
|
>
|
|
Manage
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setSelectedOutlet(null)}
|
|
data-testid="button-cancel"
|
|
>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</main>
|
|
<Footer />
|
|
</div>
|
|
);
|
|
}
|