mirror of
https://github.com/SamyRai/tercul-frontend.git
synced 2025-12-27 02:31:34 +00:00
Enhance the authors section with detailed profiles and advanced filters
Refactors AuthorProfile and Authors pages, adding filtering, sorting, and enhanced UI elements using React, Next.js, and Tailwind CSS. Replit-Commit-Author: Agent Replit-Commit-Session-Id: cbacfb18-842a-4116-a907-18c0105ad8ec Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/39b5c689-6e8a-4d5a-9792-69cc81a56534/bb94b475-7ab4-497f-a614-222c81e2c48e.jpg
This commit is contained in:
parent
9bd6071451
commit
3a93337390
File diff suppressed because it is too large
Load Diff
@ -1,38 +1,118 @@
|
||||
import { PageLayout } from "@/components/layout/PageLayout";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Author } from "@shared/schema";
|
||||
import { Author, Tag } from "@shared/schema";
|
||||
import { AuthorChip } from "@/components/common/AuthorChip";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Card, CardContent, CardFooter } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SearchBar } from "@/components/common/SearchBar";
|
||||
import { useState } from "react";
|
||||
import { Link } from "wouter";
|
||||
import { ArrowRight, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useLocation } from "wouter";
|
||||
import {
|
||||
ArrowRight,
|
||||
BookOpen,
|
||||
Calendar,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
Filter,
|
||||
Globe,
|
||||
GraduationCap,
|
||||
Heart,
|
||||
History,
|
||||
LayoutGrid,
|
||||
LayoutList,
|
||||
MapPin,
|
||||
SortAsc,
|
||||
SortDesc,
|
||||
User,
|
||||
Users
|
||||
} from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Drawer, DrawerTrigger, DrawerContent, DrawerHeader, DrawerTitle, DrawerDescription, DrawerFooter, DrawerClose } from "@/components/ui/drawer";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
|
||||
import { useSeason } from "@/hooks/use-season";
|
||||
|
||||
export default function Authors() {
|
||||
const { season } = useSeason();
|
||||
const [location] = useLocation();
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||
const [sortBy, setSortBy] = useState<'name' | 'birth' | 'popularity'>('name');
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
|
||||
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
|
||||
const [selectedTimeperiod, setSelectedTimeperiod] = useState<string[]>([]);
|
||||
const [selectedCountries, setSelectedCountries] = useState<string[]>([]);
|
||||
const [selectedGenres, setSelectedGenres] = useState<string[]>([]);
|
||||
const [yearRange, setYearRange] = useState([1500, 2000]);
|
||||
const [featuredAuthorId, setFeaturedAuthorId] = useState<number | null>(null);
|
||||
|
||||
const PAGE_SIZE = 12;
|
||||
const PAGE_SIZE = viewMode === 'grid' ? 12 : 8;
|
||||
|
||||
// Get authors data
|
||||
const { data: authors, isLoading } = useQuery<Author[]>({
|
||||
queryKey: [`/api/authors?limit=${PAGE_SIZE}&offset=${(currentPage - 1) * PAGE_SIZE}`],
|
||||
});
|
||||
|
||||
// Get tags for filtering
|
||||
const { data: tags } = useQuery<Tag[]>({
|
||||
queryKey: ['/api/tags'],
|
||||
});
|
||||
|
||||
const genreTags = tags?.filter(tag => tag.type === 'genre') || [];
|
||||
const periodTags = tags?.filter(tag => tag.type === 'period') || [];
|
||||
|
||||
// For total number of authors (for pagination)
|
||||
const { data: totalAuthors } = useQuery<number>({
|
||||
queryKey: ['/api/authors/count'],
|
||||
queryFn: async () => {
|
||||
// This is a fallback since we don't have a specific count endpoint in our current implementation
|
||||
// In a real application, you'd want a dedicated endpoint for this
|
||||
return 120; // Mock total for pagination
|
||||
}
|
||||
});
|
||||
|
||||
const totalPages = totalAuthors ? Math.ceil(totalAuthors / PAGE_SIZE) : 0;
|
||||
|
||||
// Group authors alphabetically by first letter of name
|
||||
const groupedAuthors = authors?.reduce<Record<string, Author[]>>((groups, author) => {
|
||||
// Set a random featured author when authors are loaded
|
||||
useEffect(() => {
|
||||
if (authors && authors.length > 0 && !featuredAuthorId) {
|
||||
const randomIndex = Math.floor(Math.random() * authors.length);
|
||||
setFeaturedAuthorId(authors[randomIndex].id);
|
||||
}
|
||||
}, [authors, featuredAuthorId]);
|
||||
|
||||
// Get the featured author
|
||||
const featuredAuthor = authors?.find(author => author.id === featuredAuthorId);
|
||||
|
||||
// Sort authors based on current sort settings
|
||||
const sortedAuthors = authors ? [...authors].sort((a, b) => {
|
||||
if (sortBy === 'name') {
|
||||
return sortOrder === 'asc'
|
||||
? a.name.localeCompare(b.name)
|
||||
: b.name.localeCompare(a.name);
|
||||
} else if (sortBy === 'birth') {
|
||||
const aBirth = a.birthYear || 0;
|
||||
const bBirth = b.birthYear || 0;
|
||||
return sortOrder === 'asc' ? aBirth - bBirth : bBirth - aBirth;
|
||||
} else {
|
||||
// Popularity - we can simulate this for now with ID
|
||||
// In a real app, this would be based on view counts or followers
|
||||
return sortOrder === 'asc' ? a.id - b.id : b.id - a.id;
|
||||
}
|
||||
}) : [];
|
||||
|
||||
// Group authors alphabetically by first letter of name for alphabetical view
|
||||
const groupedAuthors = sortedAuthors?.reduce<Record<string, Author[]>>((groups, author) => {
|
||||
const firstLetter = author.name.charAt(0).toUpperCase();
|
||||
if (!groups[firstLetter]) {
|
||||
groups[firstLetter] = [];
|
||||
@ -44,94 +124,725 @@ export default function Authors() {
|
||||
// Sort the grouped authors alphabetically
|
||||
const sortedGroupKeys = groupedAuthors ? Object.keys(groupedAuthors).sort() : [];
|
||||
|
||||
// Get unique countries from authors for filters
|
||||
const countries = Array.from(new Set(authors?.map(author => author.country).filter(Boolean) as string[]));
|
||||
|
||||
// Handle search
|
||||
const handleSearch = (query: string) => {
|
||||
setSearchQuery(query);
|
||||
setCurrentPage(1);
|
||||
// In a real app, we would fetch authors by the search query
|
||||
};
|
||||
|
||||
// Handle page change
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
// Toggle sort order
|
||||
const toggleSortOrder = () => {
|
||||
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
|
||||
};
|
||||
|
||||
// Handle time period filter change
|
||||
const handleTimeperiodChange = (period: string) => {
|
||||
setSelectedTimeperiod(
|
||||
selectedTimeperiod.includes(period)
|
||||
? selectedTimeperiod.filter(p => p !== period)
|
||||
: [...selectedTimeperiod, period]
|
||||
);
|
||||
};
|
||||
|
||||
// Handle country filter change
|
||||
const handleCountryChange = (country: string) => {
|
||||
setSelectedCountries(
|
||||
selectedCountries.includes(country)
|
||||
? selectedCountries.filter(c => c !== country)
|
||||
: [...selectedCountries, country]
|
||||
);
|
||||
};
|
||||
|
||||
// Handle genre filter change
|
||||
const handleGenreChange = (genre: string) => {
|
||||
setSelectedGenres(
|
||||
selectedGenres.includes(genre)
|
||||
? selectedGenres.filter(g => g !== genre)
|
||||
: [...selectedGenres, genre]
|
||||
);
|
||||
};
|
||||
|
||||
// Reset all filters
|
||||
const resetFilters = () => {
|
||||
setSelectedTimeperiod([]);
|
||||
setSelectedCountries([]);
|
||||
setSelectedGenres([]);
|
||||
setYearRange([1500, 2000]);
|
||||
};
|
||||
|
||||
// Function to render author card components
|
||||
const renderAuthorCard = (author: Author) => {
|
||||
const workCount = Math.floor(Math.random() * 30) + 1; // Simulate work count
|
||||
const followerCount = Math.floor(Math.random() * 5000); // Simulate follower count
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={author.id}
|
||||
className={`group bg-cream dark:bg-dark-surface hover:shadow-md transition-all duration-300 overflow-hidden
|
||||
${season === 'spring' ? 'hover:border-green-400/50' :
|
||||
season === 'summer' ? 'hover:border-amber-400/50' :
|
||||
season === 'autumn' ? 'hover:border-russet/50' :
|
||||
'hover:border-blue-400/50'}`}
|
||||
>
|
||||
<CardContent className="p-6 pb-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="relative flex-shrink-0">
|
||||
<div className="w-16 h-16 md:w-20 md:h-20 rounded-full overflow-hidden bg-navy/10 dark:bg-navy/20">
|
||||
<img
|
||||
src={author.portrait || "https://via.placeholder.com/200"}
|
||||
alt={author.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
{author.country && (
|
||||
<div className="absolute -bottom-1 -right-1 w-6 h-6 rounded-full bg-cream dark:bg-dark-surface border-2 border-cream dark:border-dark-surface overflow-hidden flex items-center justify-center">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<img
|
||||
src={`https://flagcdn.com/w20/${author.country.toLowerCase()}.png`}
|
||||
alt={author.country}
|
||||
className="w-full h-auto"
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{countries.find(c => c.toLowerCase() === author.country?.toLowerCase())}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Link href={`/authors/${author.slug}`}>
|
||||
<h3 className="text-lg md:text-xl font-serif font-semibold text-navy dark:text-cream
|
||||
group-hover:text-russet dark:group-hover:text-russet/90 transition-colors">
|
||||
{author.name}
|
||||
</h3>
|
||||
</Link>
|
||||
<div className="flex items-center text-xs text-navy/70 dark:text-cream/70 gap-x-3 mt-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
<span>{author.birthYear}–{author.deathYear || 'present'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<BookOpen className="h-3 w-3" />
|
||||
<span>{workCount} works</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<p className="text-navy/80 dark:text-cream/80 text-sm line-clamp-3">
|
||||
{author.biography?.slice(0, 150) || "No biography available."}{author.biography?.length > 150 ? "..." : ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5 mt-3">
|
||||
{Array.from({ length: Math.floor(Math.random() * 3) + 1 }).map((_, i) => (
|
||||
<Badge
|
||||
key={i}
|
||||
variant="outline"
|
||||
className="bg-navy/10 dark:bg-navy/20 text-navy/70 dark:text-cream/70 text-xs border-none"
|
||||
>
|
||||
{genreTags[Math.floor(Math.random() * genreTags.length)]?.name || "Poetry"}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="p-4 pt-2 flex items-center justify-between border-t border-sage/10 dark:border-sage/5">
|
||||
<div className="flex items-center text-xs text-navy/60 dark:text-cream/60 gap-2">
|
||||
<Heart className="h-3 w-3" />
|
||||
<span>{followerCount.toLocaleString()} followers</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-russet hover:text-russet/90 p-0"
|
||||
onClick={() => {
|
||||
window.location.href = `/authors/${author.slug}`;
|
||||
}}
|
||||
>
|
||||
View Profile <ArrowRight className="h-3.5 w-3.5 ml-1" />
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
// Function to render author list item
|
||||
const renderAuthorListItem = (author: Author) => {
|
||||
const workCount = Math.floor(Math.random() * 30) + 1; // Simulate work count
|
||||
const followerCount = Math.floor(Math.random() * 5000); // Simulate follower count
|
||||
|
||||
return (
|
||||
<div
|
||||
key={author.id}
|
||||
className={`group p-4 border border-sage/10 dark:border-sage/5 rounded-lg bg-cream dark:bg-dark-surface
|
||||
hover:shadow-md transition-all duration-300
|
||||
${season === 'spring' ? 'hover:border-green-400/50' :
|
||||
season === 'summer' ? 'hover:border-amber-400/50' :
|
||||
season === 'autumn' ? 'hover:border-russet/50' :
|
||||
'hover:border-blue-400/50'}`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-shrink-0">
|
||||
<div className="w-14 h-14 rounded-full overflow-hidden bg-navy/10 dark:bg-navy/20">
|
||||
<img
|
||||
src={author.portrait || "https://via.placeholder.com/200"}
|
||||
alt={author.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
{author.country && (
|
||||
<div className="absolute -bottom-1 -right-1 w-5 h-5 rounded-full bg-cream dark:bg-dark-surface border border-cream dark:border-dark-surface overflow-hidden flex items-center justify-center">
|
||||
<img
|
||||
src={`https://flagcdn.com/w20/${author.country.toLowerCase()}.png`}
|
||||
alt={author.country}
|
||||
className="w-full h-auto"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Link href={`/authors/${author.slug}`}>
|
||||
<h3 className="text-lg font-serif font-semibold text-navy dark:text-cream
|
||||
group-hover:text-russet dark:group-hover:text-russet/90 transition-colors">
|
||||
{author.name}
|
||||
</h3>
|
||||
</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-xs border-none bg-navy/10 dark:bg-navy/20">
|
||||
{periodTags[Math.floor(Math.random() * periodTags.length)]?.name || "Romanticism"}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-russet hover:text-russet/90 p-0 h-6"
|
||||
onClick={() => {
|
||||
window.location.href = `/authors/${author.slug}`;
|
||||
}}
|
||||
>
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center text-xs text-navy/70 dark:text-cream/70 gap-x-4 gap-y-1 mt-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
<span>{author.birthYear}–{author.deathYear || 'present'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<MapPin className="h-3 w-3" />
|
||||
<span>{author.country || 'Unknown'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<BookOpen className="h-3 w-3" />
|
||||
<span>{workCount} works</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Heart className="h-3 w-3" />
|
||||
<span>{followerCount.toLocaleString()} followers</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-navy/80 dark:text-cream/80 text-xs mt-2 line-clamp-1">
|
||||
{author.biography?.slice(0, 100) || "No biography available."}...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="max-w-[var(--content-width)] mx-auto px-4 md:px-6 py-8">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-8">
|
||||
{/* Header and search */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl md:text-3xl font-bold font-serif text-navy dark:text-cream">Authors</h1>
|
||||
<p className="text-navy/70 dark:text-cream/70 mt-1">Discover authors from around the world</p>
|
||||
<p className="text-navy/70 dark:text-cream/70 mt-1">Discover literary voices from across the ages</p>
|
||||
</div>
|
||||
<SearchBar placeholder="Search authors..." onSearch={handleSearch} />
|
||||
</div>
|
||||
|
||||
{/* Alphabet navigation */}
|
||||
<div className="mb-8 flex flex-wrap gap-1 justify-center">
|
||||
{[..."ABCDEFGHIJKLMNOPQRSTUVWXYZ"].map(letter => {
|
||||
const hasAuthors = sortedGroupKeys.includes(letter);
|
||||
return (
|
||||
<a
|
||||
key={letter}
|
||||
href={hasAuthors ? `#${letter}` : undefined}
|
||||
className={`w-8 h-8 flex items-center justify-center rounded-full text-sm font-medium transition-colors ${
|
||||
hasAuthors
|
||||
? 'bg-navy/10 hover:bg-navy/20 dark:bg-navy/20 dark:hover:bg-navy/30 text-navy dark:text-cream cursor-pointer'
|
||||
: 'bg-navy/5 dark:bg-navy/10 text-navy/40 dark:text-cream/40 cursor-default'
|
||||
}`}
|
||||
>
|
||||
{letter}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{Array.from({ length: 12 }).map((_, i) => (
|
||||
<Card key={i} className="bg-cream dark:bg-dark-surface">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="w-16 h-16 rounded-full bg-navy/10 dark:bg-navy/20 animate-pulse"></div>
|
||||
<div className="flex-1">
|
||||
<div className="h-5 bg-navy/10 dark:bg-navy/20 rounded-md w-3/4 mb-2 animate-pulse"></div>
|
||||
<div className="h-4 bg-navy/10 dark:bg-navy/20 rounded-md w-1/2 animate-pulse"></div>
|
||||
<div className="flex items-center gap-2">
|
||||
<SearchBar placeholder="Search authors..." onSearch={handleSearch} />
|
||||
<Drawer open={filterDrawerOpen} onOpenChange={setFilterDrawerOpen}>
|
||||
<DrawerTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="shrink-0">
|
||||
<Filter className="h-4 w-4" />
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<DrawerContent>
|
||||
<div className="max-w-md mx-auto p-4">
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>Filter Authors</DrawerTitle>
|
||||
<DrawerDescription>Narrow down the authors by specific criteria</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
{/* Time period filter */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2">Literary Periods</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{periodTags.map(tag => (
|
||||
<Badge
|
||||
key={tag.id}
|
||||
variant={selectedTimeperiod.includes(tag.name) ? "default" : "outline"}
|
||||
className="cursor-pointer"
|
||||
onClick={() => handleTimeperiodChange(tag.name)}
|
||||
>
|
||||
{tag.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Year range filter */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2">Year Range</h3>
|
||||
<div className="px-1">
|
||||
<Slider
|
||||
defaultValue={yearRange}
|
||||
max={2023}
|
||||
min={1400}
|
||||
step={10}
|
||||
onValueChange={(value) => setYearRange(value as [number, number])}
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-navy/70 dark:text-cream/70 mt-1">
|
||||
<span>{yearRange[0]}</span>
|
||||
<span>{yearRange[1]}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Countries filter */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2">Countries</h3>
|
||||
<ScrollArea className="h-40 border rounded-md p-2">
|
||||
<div className="space-y-1">
|
||||
{countries.map(country => (
|
||||
<div key={country} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`country-${country}`}
|
||||
checked={selectedCountries.includes(country)}
|
||||
onCheckedChange={() => handleCountryChange(country)}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`country-${country}`}
|
||||
className="text-sm flex items-center gap-1.5 cursor-pointer"
|
||||
>
|
||||
<img
|
||||
src={`https://flagcdn.com/w20/${country.toLowerCase()}.png`}
|
||||
alt={country}
|
||||
className="w-4 h-auto"
|
||||
/>
|
||||
{country}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* Genres filter */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2">Primary Genres</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{genreTags.map(tag => (
|
||||
<Badge
|
||||
key={tag.id}
|
||||
variant={selectedGenres.includes(tag.name) ? "default" : "outline"}
|
||||
className="cursor-pointer"
|
||||
onClick={() => handleGenreChange(tag.name)}
|
||||
>
|
||||
{tag.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-20 bg-navy/10 dark:bg-navy/20 rounded-md animate-pulse"></div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<DrawerFooter>
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="outline" onClick={resetFilters}>
|
||||
Reset All
|
||||
</Button>
|
||||
<DrawerClose asChild>
|
||||
<Button>Apply Filters</Button>
|
||||
</DrawerClose>
|
||||
</div>
|
||||
</DrawerFooter>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{sortedGroupKeys.map(letter => (
|
||||
<div key={letter} id={letter} className="scroll-mt-20">
|
||||
<h2 className="text-xl font-serif font-semibold mb-4 text-navy dark:text-cream">{letter}</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{groupedAuthors![letter].map(author => (
|
||||
<Card key={author.id} className="bg-cream dark:bg-dark-surface hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-6">
|
||||
<AuthorChip author={author} size="lg" withLifeDates className="mb-4" />
|
||||
<p className="text-navy/80 dark:text-cream/80 text-sm line-clamp-4 mb-4">
|
||||
{author.biography?.slice(0, 150) || "No biography available."}{author.biography?.length > 150 ? "..." : ""}
|
||||
</p>
|
||||
<Link href={`/authors/${author.slug}`}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full flex items-center justify-center gap-1 text-russet"
|
||||
>
|
||||
View Profile <ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Featured author section */}
|
||||
{featuredAuthor && (
|
||||
<div className={`mb-8 rounded-xl p-5 md:p-6 overflow-hidden relative
|
||||
${season === 'spring' ? 'bg-gradient-to-br from-green-50 to-blue-50 dark:from-green-950/30 dark:to-blue-950/30' :
|
||||
season === 'summer' ? 'bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-950/30 dark:to-orange-950/30' :
|
||||
season === 'autumn' ? 'bg-gradient-to-br from-russet/10 to-amber-50 dark:from-russet/20 dark:to-amber-950/30' :
|
||||
'bg-gradient-to-br from-blue-50 to-indigo-50 dark:from-blue-950/30 dark:to-indigo-950/30'}`}
|
||||
>
|
||||
<div className="absolute inset-0 overflow-hidden opacity-10">
|
||||
<div className="absolute -right-10 -top-10 w-40 h-40 rounded-full bg-navy/10 dark:bg-cream/10"></div>
|
||||
<div className="absolute right-20 top-1/2 w-20 h-20 rounded-full bg-navy/10 dark:bg-cream/10"></div>
|
||||
<div className="absolute right-1/3 bottom-5 w-16 h-16 rounded-full bg-navy/10 dark:bg-cream/10"></div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="flex flex-col md:flex-row items-start gap-6">
|
||||
<div className="flex-shrink-0 w-24 h-24 md:w-32 md:h-32 rounded-lg overflow-hidden">
|
||||
<img
|
||||
src={featuredAuthor.portrait || "https://via.placeholder.com/300"}
|
||||
alt={featuredAuthor.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Badge variant="outline" className="bg-navy/5 dark:bg-cream/5 text-navy/70 dark:text-cream/70 border-none">
|
||||
Featured Author
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl md:text-3xl font-serif font-bold text-navy dark:text-cream mb-2">
|
||||
{featuredAuthor.name}
|
||||
</h2>
|
||||
|
||||
<div className="flex items-center text-sm text-navy/70 dark:text-cream/70 gap-x-4 mb-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span>{featuredAuthor.birthYear}–{featuredAuthor.deathYear || 'present'}</span>
|
||||
</div>
|
||||
{featuredAuthor.country && (
|
||||
<div className="flex items-center gap-1">
|
||||
<MapPin className="h-4 w-4" />
|
||||
<span>{featuredAuthor.country}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-navy/80 dark:text-cream/80 line-clamp-3 mb-4">
|
||||
{featuredAuthor.biography}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link href={`/authors/${featuredAuthor.slug}`}>
|
||||
<Button
|
||||
className={`text-white
|
||||
${season === 'spring' ? 'bg-green-500 hover:bg-green-600 dark:bg-green-700 dark:hover:bg-green-800' :
|
||||
season === 'summer' ? 'bg-amber-500 hover:bg-amber-600 dark:bg-amber-700 dark:hover:bg-amber-800' :
|
||||
season === 'autumn' ? 'bg-russet hover:bg-russet/90 dark:bg-russet/90 dark:hover:bg-russet' :
|
||||
'bg-blue-500 hover:bg-blue-600 dark:bg-blue-700 dark:hover:bg-blue-800'}`}
|
||||
>
|
||||
Explore Works
|
||||
</Button>
|
||||
</Link>
|
||||
<Button variant="outline">Follow</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* View controls bar */}
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 mb-6">
|
||||
<Tabs defaultValue="all" className="w-full sm:w-auto">
|
||||
<TabsList className="grid w-full sm:w-auto grid-cols-3">
|
||||
<TabsTrigger value="all" className="text-sm">All Authors</TabsTrigger>
|
||||
<TabsTrigger value="alphabetical" className="text-sm">Alphabetical</TabsTrigger>
|
||||
<TabsTrigger value="chronological" className="text-sm">Chronological</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<div className="flex items-center gap-3 w-full sm:w-auto">
|
||||
<div className="flex items-center gap-1 text-sm text-navy/70 dark:text-cream/70">
|
||||
<Label htmlFor="sort-by" className="sr-only">Sort by</Label>
|
||||
<Select
|
||||
value={sortBy}
|
||||
onValueChange={(value) => setSortBy(value as 'name' | 'birth' | 'popularity')}
|
||||
>
|
||||
<SelectTrigger id="sort-by" className="w-[140px] h-9 text-xs">
|
||||
<SelectValue placeholder="Sort by" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="name">Name</SelectItem>
|
||||
<SelectItem value="birth">Birth Year</SelectItem>
|
||||
<SelectItem value="popularity">Popularity</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9"
|
||||
onClick={toggleSortOrder}
|
||||
>
|
||||
{sortOrder === 'asc' ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator orientation="vertical" className="h-8 hidden sm:block" />
|
||||
|
||||
<div className="flex items-center">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={viewMode === 'list' ? 'default' : 'ghost'}
|
||||
size="icon"
|
||||
className="h-9 w-9"
|
||||
onClick={() => setViewMode('list')}
|
||||
>
|
||||
<LayoutList className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>List view</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={viewMode === 'grid' ? 'default' : 'ghost'}
|
||||
size="icon"
|
||||
className="h-9 w-9"
|
||||
onClick={() => setViewMode('grid')}
|
||||
>
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Grid view</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active filters */}
|
||||
{(selectedTimeperiod.length > 0 || selectedCountries.length > 0 || selectedGenres.length > 0) && (
|
||||
<div className="flex flex-wrap items-center gap-2 mb-6 p-3 bg-navy/5 dark:bg-navy/10 rounded-md">
|
||||
<span className="text-sm font-medium text-navy/70 dark:text-cream/70">
|
||||
Active filters:
|
||||
</span>
|
||||
|
||||
{selectedTimeperiod.map(period => (
|
||||
<Badge
|
||||
key={`period-${period}`}
|
||||
variant="secondary"
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<History className="h-3 w-3" />
|
||||
{period}
|
||||
<button
|
||||
className="ml-1 hover:text-navy dark:hover:text-cream"
|
||||
onClick={() => handleTimeperiodChange(period)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
|
||||
{selectedCountries.map(country => (
|
||||
<Badge
|
||||
key={`country-${country}`}
|
||||
variant="secondary"
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Globe className="h-3 w-3" />
|
||||
{country}
|
||||
<button
|
||||
className="ml-1 hover:text-navy dark:hover:text-cream"
|
||||
onClick={() => handleCountryChange(country)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
|
||||
{selectedGenres.map(genre => (
|
||||
<Badge
|
||||
key={`genre-${genre}`}
|
||||
variant="secondary"
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<BookOpen className="h-3 w-3" />
|
||||
{genre}
|
||||
<button
|
||||
className="ml-1 hover:text-navy dark:hover:text-cream"
|
||||
onClick={() => handleGenreChange(genre)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="text-russet hover:text-russet/90 h-6 p-0 ml-auto"
|
||||
onClick={resetFilters}
|
||||
>
|
||||
Clear all
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Author grid or list */}
|
||||
<TabsContent value="all" className="mt-0">
|
||||
{isLoading ? (
|
||||
<div className={`grid gap-6 ${viewMode === 'grid' ? 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4' : 'grid-cols-1'}`}>
|
||||
{Array.from({ length: PAGE_SIZE }).map((_, i) => (
|
||||
<Card key={i} className="bg-cream dark:bg-dark-surface">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="w-16 h-16 rounded-full bg-navy/10 dark:bg-navy/20 animate-pulse"></div>
|
||||
<div className="flex-1">
|
||||
<div className="h-5 bg-navy/10 dark:bg-navy/20 rounded-md w-3/4 mb-2 animate-pulse"></div>
|
||||
<div className="h-4 bg-navy/10 dark:bg-navy/20 rounded-md w-1/2 animate-pulse"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-20 bg-navy/10 dark:bg-navy/20 rounded-md animate-pulse"></div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{viewMode === 'grid' ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{sortedAuthors.map(author => renderAuthorCard(author))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{sortedAuthors.map(author => renderAuthorListItem(author))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="alphabetical" className="mt-0">
|
||||
{isLoading ? (
|
||||
<div className="animate-pulse space-y-6">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i}>
|
||||
<div className="h-6 bg-navy/10 dark:bg-navy/20 rounded-md w-12 mb-4"></div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{Array.from({ length: 4 }).map((_, j) => (
|
||||
<div key={j} className="h-48 bg-navy/5 dark:bg-navy/10 rounded-md"></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Alphabet navigation */}
|
||||
<div className="sticky top-16 z-10 bg-paper dark:bg-dark-paper py-3 mb-6 border-b border-sage/10 dark:border-sage/5">
|
||||
<div className="flex flex-wrap gap-1 justify-center">
|
||||
{[..."ABCDEFGHIJKLMNOPQRSTUVWXYZ"].map(letter => {
|
||||
const hasAuthors = sortedGroupKeys.includes(letter);
|
||||
return (
|
||||
<a
|
||||
key={letter}
|
||||
href={hasAuthors ? `#${letter}` : undefined}
|
||||
className={`w-8 h-8 flex items-center justify-center rounded-full text-sm font-medium transition-colors ${
|
||||
hasAuthors
|
||||
? 'bg-navy/10 hover:bg-navy/20 dark:bg-navy/20 dark:hover:bg-navy/30 text-navy dark:text-cream cursor-pointer'
|
||||
: 'bg-navy/5 dark:bg-navy/10 text-navy/40 dark:text-cream/40 cursor-default'
|
||||
}`}
|
||||
>
|
||||
{letter}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-10">
|
||||
{sortedGroupKeys.map(letter => (
|
||||
<div key={letter} id={letter} className="scroll-mt-32">
|
||||
<h2 className="text-2xl font-serif font-semibold mb-5 text-navy dark:text-cream border-b border-sage/10 dark:border-sage/5 pb-2">
|
||||
{letter}
|
||||
</h2>
|
||||
<div className={`grid gap-6 ${viewMode === 'grid' ? 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4' : 'grid-cols-1'}`}>
|
||||
{groupedAuthors![letter].map(author => (
|
||||
viewMode === 'grid' ? renderAuthorCard(author) : renderAuthorListItem(author)
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="chronological" className="mt-0">
|
||||
{isLoading ? (
|
||||
<div className="animate-pulse space-y-6">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i}>
|
||||
<div className="h-6 bg-navy/10 dark:bg-navy/20 rounded-md w-24 mb-4"></div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{Array.from({ length: 3 }).map((_, j) => (
|
||||
<div key={j} className="h-48 bg-navy/5 dark:bg-navy/10 rounded-md"></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-10">
|
||||
{/* Group authors by century */}
|
||||
{Array.from(new Set(authors?.map(a => Math.floor((a.birthYear || 1800) / 100) * 100))).sort().map(century => {
|
||||
const centuryLabel = `${century}s`;
|
||||
const centuryAuthors = authors?.filter(a =>
|
||||
Math.floor((a.birthYear || 1800) / 100) * 100 === century
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={century}>
|
||||
<h2 className="text-2xl font-serif font-semibold mb-5 text-navy dark:text-cream border-b border-sage/10 dark:border-sage/5 pb-2">
|
||||
{centuryLabel}
|
||||
</h2>
|
||||
<div className={`grid gap-6 ${viewMode === 'grid' ? 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4' : 'grid-cols-1'}`}>
|
||||
{centuryAuthors?.map(author => (
|
||||
viewMode === 'grid' ? renderAuthorCard(author) : renderAuthorListItem(author)
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-8 flex justify-center">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user