Integrates the media outlet's header information, including its logo and name, into the Comprehensive Report page. The header is now sticky and displays dynamically based on the current report's media outlet. Includes new navigation and utility icons in the header, along with search modal functionality and user authentication hooks. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 0fb68265-c270-4198-9584-3d9be9bddb41 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/3df548ff-50ae-432f-9be4-25d34eccc983/0fb68265-c270-4198-9584-3d9be9bddb41/16cZmxV
225 lines
8.3 KiB
TypeScript
225 lines
8.3 KiB
TypeScript
import { useQuery } from "@tanstack/react-query";
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import type { MediaOutlet, Article } from "@shared/schema";
|
|
import { useAuth } from "@/hooks/useAuth";
|
|
import Footer from "@/components/Footer";
|
|
import { useState } from "react";
|
|
import { ArrowUpDown, Sparkles } from "lucide-react";
|
|
import { Link } from "wouter";
|
|
|
|
const categories = [
|
|
{ id: "people", name: "People", key: "People" },
|
|
{ id: "topics", name: "Topics", key: "Topics" },
|
|
{ id: "companies", name: "Companies", key: "Companies" }
|
|
];
|
|
|
|
export default function MainContent() {
|
|
const { user } = useAuth();
|
|
const [sortBy, setSortBy] = useState<"alphabetical" | "traffic">("alphabetical");
|
|
const [enlargedImage, setEnlargedImage] = useState<string | null>(null);
|
|
|
|
const { data: allOutlets = [], isLoading } = 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();
|
|
},
|
|
});
|
|
|
|
// Fetch all articles to count per outlet
|
|
const { data: allArticles = [] } = useQuery<Article[]>({
|
|
queryKey: ["/api/articles"],
|
|
queryFn: async () => {
|
|
const res = await fetch("/api/articles", {
|
|
credentials: "include",
|
|
});
|
|
if (!res.ok) {
|
|
throw new Error(`${res.status}: ${res.statusText}`);
|
|
}
|
|
return res.json();
|
|
},
|
|
});
|
|
|
|
// Count articles per outlet
|
|
const articleCountByOutlet = allArticles.reduce((acc, article) => {
|
|
acc[article.mediaOutletId] = (acc[article.mediaOutletId] || 0) + 1;
|
|
return acc;
|
|
}, {} as Record<string, number>);
|
|
|
|
// Group outlets by category and sort
|
|
const getOutletsByCategory = (category: string) => {
|
|
const filtered = allOutlets.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 renderOutletCard = (outlet: MediaOutlet) => {
|
|
const articleCount = articleCountByOutlet[outlet.id] || 0;
|
|
const hasArticles = articleCount > 0;
|
|
|
|
return (
|
|
<Card
|
|
key={outlet.id}
|
|
className={`hover:shadow-md transition-all cursor-pointer bg-white relative ${
|
|
hasArticles ? 'animate-float ring-2 ring-blue-400/30 shadow-lg' : ''
|
|
}`}
|
|
data-testid={`card-outlet-${outlet.id}`}
|
|
>
|
|
{hasArticles && (
|
|
<div className="absolute -top-2 -right-2 z-10">
|
|
<Badge className="bg-gradient-to-r from-blue-500 to-purple-500 text-white shadow-lg animate-pulse">
|
|
<Sparkles className="h-3 w-3 mr-1" />
|
|
NEW
|
|
</Badge>
|
|
</div>
|
|
)}
|
|
<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 cursor-pointer hover:ring-2 hover:ring-blue-400 transition-all ${
|
|
hasArticles ? 'ring-2 ring-blue-400/50' : ''
|
|
}`}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
if (outlet.imageUrl) {
|
|
setEnlargedImage(outlet.imageUrl);
|
|
}
|
|
}}
|
|
data-testid={`image-profile-${outlet.id}`}
|
|
>
|
|
{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>
|
|
<Link href={`/media/${outlet.slug}`} className="flex-1 min-w-0" data-testid={`link-outlet-${outlet.id}`}>
|
|
<div className="flex-1 min-w-0">
|
|
<h3 className="font-medium text-gray-900 truncate">
|
|
{outlet.name}
|
|
{hasArticles && (
|
|
<span className="ml-2 text-xs text-blue-600 font-bold">
|
|
({articleCount})
|
|
</span>
|
|
)}
|
|
</h3>
|
|
<p className="text-sm text-gray-500 truncate">
|
|
{outlet.description || "Media Outlet"}
|
|
</p>
|
|
</div>
|
|
</Link>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-col min-h-screen bg-gray-50">
|
|
<div className="flex-1 max-w-7xl mx-auto px-4 py-4 pb-32 w-full">
|
|
{isLoading ? (
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
{categories.map((category) => (
|
|
<div key={category.id}>
|
|
<h2 className="text-xl font-bold text-gray-900 mb-2">{category.name}</h2>
|
|
<div className="space-y-2">
|
|
{Array.from({ length: 8 }).map((_, i) => (
|
|
<Card key={i} className="animate-pulse">
|
|
<CardContent className="p-2">
|
|
<div className="flex items-center space-x-2">
|
|
<div className="w-10 h-10 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>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
{/* People Section */}
|
|
<div data-testid="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(renderOutletCard)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Topics Section */}
|
|
<div data-testid="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(renderOutletCard)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Companies Section */}
|
|
<div data-testid="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(renderOutletCard)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<Footer />
|
|
|
|
{/* Image Enlargement Dialog */}
|
|
<Dialog open={!!enlargedImage} onOpenChange={() => setEnlargedImage(null)}>
|
|
<DialogContent className="max-w-3xl p-0 bg-transparent border-none">
|
|
{enlargedImage && (
|
|
<img
|
|
src={enlargedImage}
|
|
alt="Enlarged profile"
|
|
className="w-full h-auto rounded-lg"
|
|
data-testid="dialog-enlarged-image"
|
|
/>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
} |