161 lines
5.9 KiB
TypeScript
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>
|
|
)
|
|
}
|