feat(library): show series and number in list view (#4593) (#4612)

In the library list view, surface each book's series and series number on
their own line, in addition to the description. Previously series info was
only visible by grouping by series or opening a book's details.

- Add `formatSeries(series, seriesIndex)` helper ("Series #N", trims the
  name, omits a zero/NaN/negative index) with unit tests.
- In list mode, render a dedicated single-line "Series #N" line above the
  description when the book has series metadata.
- Clamp every list line (incl. title) to one line and tighten the row gap
  to `gap-1` so the extra line fits the fixed-height row without clipping.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-16 23:07:27 +08:00
committed by GitHub
parent a30a310a17
commit 757ed8066b
3 changed files with 51 additions and 4 deletions
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import { formatSeries } from '@/utils/book';
describe('formatSeries', () => {
it('returns an empty string when there is no series name', () => {
expect(formatSeries(undefined)).toBe('');
expect(formatSeries('')).toBe('');
expect(formatSeries(' ')).toBe('');
});
it('returns an empty string when only an index is present', () => {
expect(formatSeries(undefined, 3)).toBe('');
});
it('returns the trimmed series name when no index is present', () => {
expect(formatSeries('Harry Potter')).toBe('Harry Potter');
expect(formatSeries(' The Expanse ')).toBe('The Expanse');
});
it('appends the series number when a positive index is present', () => {
expect(formatSeries('Harry Potter', 3)).toBe('Harry Potter #3');
expect(formatSeries(' The Expanse ', 2)).toBe('The Expanse #2');
});
it('supports fractional series indices', () => {
expect(formatSeries('The Expanse', 1.5)).toBe('The Expanse #1.5');
});
it('omits the number when the index is zero or not a finite number', () => {
expect(formatSeries('Harry Potter', 0)).toBe('Harry Potter');
expect(formatSeries('Harry Potter', Number.NaN)).toBe('Harry Potter');
});
});
@@ -16,7 +16,7 @@ import { useSettingsStore } from '@/store/settingsStore';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { LibraryCoverFitType, LibraryViewModeType } from '@/types/settings';
import { navigateToLogin } from '@/utils/nav';
import { formatAuthors, formatDescription } from '@/utils/book';
import { formatAuthors, formatDescription, formatSeries } from '@/utils/book';
import ReadingProgress from './ReadingProgress';
import BookCover from '@/components/BookCover';
@@ -65,6 +65,8 @@ const BookItem: React.FC<BookItemProps> = ({
}
: undefined;
const seriesText = formatSeries(book.metadata?.series, book.metadata?.seriesIndex);
return (
<div
role='none'
@@ -112,15 +114,15 @@ const BookItem: React.FC<BookItemProps> = ({
className={clsx(
'flex w-full flex-col p-0',
mode === 'grid' && 'pt-2',
mode === 'list' && 'gap-2 py-0',
mode === 'list' && 'gap-1 py-0',
)}
>
<div className={clsx('min-w-0 flex-1', mode === 'list' && 'flex flex-col gap-2')}>
<div className={clsx('min-w-0 flex-1', mode === 'list' && 'flex flex-col gap-1')}>
<h4
className={clsx(
'overflow-hidden text-ellipsis font-semibold',
mode === 'grid' && 'block whitespace-nowrap text-[0.6em] text-xs',
mode === 'list' && 'line-clamp-2 text-base',
mode === 'list' && 'line-clamp-1 text-base',
)}
>
{book.title}
@@ -131,6 +133,9 @@ const BookItem: React.FC<BookItemProps> = ({
</p>
)}
</div>
{mode === 'list' && seriesText && (
<p className='text-neutral-content line-clamp-1 text-sm'>{seriesText}</p>
)}
{mode === 'list' && (
<h4 className='text-neutral-content line-clamp-1 text-sm'>
{formatDescription(book.metadata?.description)}
+8
View File
@@ -158,6 +158,14 @@ export const formatDescription = (description?: string | LanguageMap) => {
.trim();
};
export const formatSeries = (series?: string, seriesIndex?: number) => {
const name = series?.trim();
if (!name) return '';
const hasIndex =
typeof seriesIndex === 'number' && Number.isFinite(seriesIndex) && seriesIndex > 0;
return hasIndex ? `${name} #${seriesIndex}` : name;
};
export const formatPublisher = (publisher: string | LanguageMap) => {
return typeof publisher === 'string' ? publisher : formatLanguageMap(publisher);
};