Changed design language. Added editions, better support for authors. Base for file handling
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user