Files
sapiens-web/client/src/pages/AdminDashboard.tsx
kimjaehyeon0101 1ad56f715d Add sorting options for media outlets in the admin dashboard
Rearrange UI elements in AdminDashboard.tsx to place the sort by dropdown next to the header, and update the selection logic.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 069d4324-6c40-4355-955e-c714a50de1ea
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/3df548ff-50ae-432f-9be4-25d34eccc983/069d4324-6c40-4355-955e-c714a50de1ea/YptCfK0
2025-09-29 19:46:54 +00:00

401 lines
17 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";
export default function AdminDashboard() {
const { user, isLoading } = useAuth();
const { toast } = useToast();
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(() => {
window.location.href = "/";
}, 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 = () => {
window.location.href = "/api/logout";
};
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="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">
<a href="/" className="cursor-pointer" data-testid="logo-link">
<img
src="/attached_assets/logo_black_1759162717640.png"
alt="SAPIENS"
className="h-6 w-auto hover:opacity-80 transition-opacity"
data-testid="logo-sapiens"
/>
</a>
</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"
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="max-w-7xl mx-auto px-4 py-4">
<div className="mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">Admin Dashboard</h1>
<p className="text-gray-600">Search and select media outlets to manage</p>
</div>
</div>
{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="flex items-center justify-between mb-6">
<div className="text-sm text-gray-500">
{filteredOutlets.length} media outlets {searchTerm && `(search results for "${searchTerm}")`}
</div>
<div className="flex items-center space-x-2">
<ArrowUpDown className="h-4 w-4 text-gray-500" />
<Select value={sortBy} onValueChange={(value: "alphabetical" | "traffic") => setSortBy(value)}>
<SelectTrigger className="w-[180px]" data-testid="admin-sort-select">
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
<SelectItem value="alphabetical" data-testid="admin-sort-alphabetical">Alphabetical</SelectItem>
<SelectItem value="traffic" data-testid="admin-sort-traffic">Traffic</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* People Section */}
<div data-testid="admin-section-people">
<h2 className="text-xl font-bold text-gray-900 mb-2">
People
<span className="text-gray-400 text-base ml-2">({getOutletsByCategory("People").length})</span>
</h2>
<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">
<h2 className="text-xl font-bold text-gray-900 mb-2">
Topics
<span className="text-gray-400 text-base ml-2">({getOutletsByCategory("Topics").length})</span>
</h2>
<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">
<h2 className="text-xl font-bold text-gray-900 mb-2">
Companies
<span className="text-gray-400 text-base ml-2">({getOutletsByCategory("Companies").length})</span>
</h2>
<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>
</div>
);
}