Files
PageManager/PageManager.Web/src/pages/AuthorDetail/AuthorDetail.tsx
T

161 lines
5.9 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { Button, Flex, Skeleton, Typography } from 'antd'
import { ArrowLeftOutlined } from '@ant-design/icons'
import type { AuthorDetail as IAuthorDetail, Book } from '../../types'
import { fetchAuthor } from '../../api/authors'
import BookCard from '../../components/BookCard/BookCard'
import s from './AuthorDetail.module.css'
export default function AuthorDetail() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const [author, setAuthor] = useState<IAuthorDetail | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
if (!id) return
setLoading(true)
fetchAuthor(Number(id))
.then(setAuthor)
.catch(() => setAuthor(null))
.finally(() => setLoading(false))
}, [id])
// Group books: series → Map<seriesName, Book[]>, standalone
const seriesGroups = useMemo(() => {
if (!author) return []
const map = new Map<string, Book[]>()
for (const book of author.books) {
if (book.series) {
const arr = map.get(book.series.name) ?? []
arr.push(book)
map.set(book.series.name, arr)
}
}
for (const [, books] of map)
books.sort((a, b) => a.series!.position - b.series!.position)
return [...map.entries()].sort((a, b) => a[0].localeCompare(b[0]))
}, [author])
const standalone = useMemo(() =>
author?.books
.filter(b => !b.series)
.sort((a, b) => a.title.localeCompare(b.title))
?? [], [author])
const initials = author?.name.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase() ?? ''
const color = avatarColor(author?.id ?? 0)
return (
<div className={s.page}>
<div className={s.content}>
{/* Back */}
<div style={{ marginBottom: 24 }}>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/authors')}>
Authors
</Button>
</div>
{loading && <AuthorSkeleton />}
{!loading && !author && (
<Flex justify="center" align="center" style={{ padding: '80px 0' }}>
<Typography.Text type="secondary">Author not found.</Typography.Text>
</Flex>
)}
{!loading && author && (
<>
{/* Author header */}
<Flex gap={28} align="flex-start" className={s.header}>
<div className={s.avatar} style={{ background: color }}>
{author.imageUrl
? <img src={author.imageUrl} alt={author.name} className={s.avatarImg} />
: <span className={s.avatarInitials}>{initials}</span>
}
</div>
<div className={s.authorInfo}>
<Typography.Title level={2} style={{ margin: 0 }}>{author.name}</Typography.Title>
<Typography.Text type="secondary" style={{ fontSize: 14 }}>
{author.books.length} {author.books.length === 1 ? 'book' : 'books'}
{author.bornYear ? ` · Born ${author.bornYear}` : ''}
</Typography.Text>
{author.bio && (
<Typography.Paragraph style={{ fontSize: 14, lineHeight: 1.7, color: 'rgba(0,0,0,.65)', marginTop: 10, marginBottom: 0, maxWidth: 600 }}>
{author.bio}
</Typography.Paragraph>
)}
</div>
</Flex>
{/* Collections / series */}
{seriesGroups.length > 0 && (
<section className={s.section}>
<Typography.Title level={4} style={{ marginBottom: 0 }}>Collections</Typography.Title>
{seriesGroups.map(([seriesName, books]) => (
<div key={seriesName} className={s.seriesGroup}>
<Flex align="baseline" gap={8} style={{ marginBottom: 10 }}>
<Typography.Title level={5} style={{ margin: 0 }}>{seriesName}</Typography.Title>
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
{books.length} {books.length === 1 ? 'book' : 'books'}
</Typography.Text>
</Flex>
<div className={s.bookGrid}>
{books.map(book => (
<BookCard
key={book.id}
book={book}
onClick={b => navigate(`/books/${b.id}`)}
/>
))}
</div>
</div>
))}
</section>
)}
{/* Standalone books */}
{standalone.length > 0 && (
<section className={s.section}>
<Flex align="baseline" gap={8} style={{ marginBottom: 16 }}>
<Typography.Title level={4} style={{ margin: 0 }}>Standalone</Typography.Title>
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
{standalone.length} {standalone.length === 1 ? 'book' : 'books'}
</Typography.Text>
</Flex>
<div className={s.bookGrid}>
{standalone.map(book => (
<BookCard
key={book.id}
book={book}
onClick={b => navigate(`/books/${b.id}`)}
/>
))}
</div>
</section>
)}
</>
)}
</div>
</div>
)
}
function avatarColor(id: number): string {
const palette = ['#6750A4', '#7B5EA7', '#5E35B1', '#4527A0', '#9575CD', '#7E57C2']
return palette[id % palette.length]
}
function AuthorSkeleton() {
return (
<Flex gap={28} align="flex-start" style={{ padding: '8px 0 32px' }}>
<Skeleton.Avatar active size={100} />
<div style={{ flex: 1 }}>
<Skeleton active paragraph={{ rows: 3 }} />
</div>
</Flex>
)
}