diff --git a/README.md b/README.md index 67d9e732b..f8f52c036 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,7 @@ Clean-room Go rewrite, modern React UI, MIT-licensed, actively developed. **Library management** - Author monitoring via OpenLibrary's author-works endpoint, configurable author monitor modes for all/future/latest/none defaults, per-book monitor toggles, and a `wanted → imported` workflow with live download progress on the Queue page. - Dual-format books — each title holds an ebook *and* an audiobook in independent slots, with separate search, grab, and import pipelines, and the audiobook side moves multi-part `.m4b` / `.mp3` folders as one unit. -- Series support with position tracking, edition tracking (format / ISBN / publisher / page count), Calendar view of upcoming releases, and multiple library roots. +- Series support with position tracking and search by series title, edition tracking (format / ISBN / publisher / page count), Calendar view of upcoming releases, and multiple library roots. - Library scan with four-tier matching: ASIN → title + author → series name + position → fuzzy title. Honours librarian sort-suffix form (`Title, The`) and series-annotated filenames (`[Mistborn, Book 1]`). - Author aliases (`RR Haywood` / `R.R. Haywood` / `R R Haywood` merge into one canonical row), and metadata re-bind to correct a wrong match without delete-and-re-add. - Explicit author-catalogue reconciliation with a selectable preview: remove chosen stale metadata-only Wanted rows after changing provider or metadata profile while always protecting imported books and every row with a tracked file. diff --git a/changelog.d/series-search.md b/changelog.d/series-search.md new file mode 100644 index 000000000..a85748e9a --- /dev/null +++ b/changelog.d/series-search.md @@ -0,0 +1,2 @@ +### Added +- Search the Series page by title with case- and accent-insensitive matching, using the same search field style as Authors and Books. diff --git a/docs/Hardcover-Series-Wiki.md b/docs/Hardcover-Series-Wiki.md index 6e3f81caa..7e4f8e581 100644 --- a/docs/Hardcover-Series-Wiki.md +++ b/docs/Hardcover-Series-Wiki.md @@ -30,6 +30,8 @@ If either requirement is missing, Bindery hides the enhanced controls and the en ## Series Workflow +Use **Search series...** at the top of the Series page to filter your local series by title instead of using browser Find in a large collection. Matching ignores case and accents; clear the field to show all series again. This local search is available without enhanced Hardcover data. + 1. Open **Series**. 2. Create a series manually, or open an existing series populated from your library metadata. 3. Use **Add Book** to attach existing Bindery books and set their series positions. diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 8cbb6636a..1524af2a7 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -503,6 +503,8 @@ } }, "series": { + "searchPlaceholder": "Search series...", + "noMatch": "No series match \"{{query}}\"", "deleteConfirm": "Delete \"{{title}}\" from Series? Linked books will stay in your library.", "deleteTitle": "Remove series" }, diff --git a/web/src/pages/SeriesPage.test.tsx b/web/src/pages/SeriesPage.test.tsx index 8fe526461..4dc577e82 100644 --- a/web/src/pages/SeriesPage.test.tsx +++ b/web/src/pages/SeriesPage.test.tsx @@ -61,6 +61,39 @@ describe('SeriesPage', () => { vi.mocked(api.searchHardcoverSeries).mockResolvedValue([]) }) + it('filters series by title and restores the list when search is cleared', async () => { + renderSeriesPage([ + { id: 1, foreignSeriesId: 'series-1', title: 'The Stormlight Archive', description: '', monitored: true, books: [] }, + { id: 2, foreignSeriesId: 'series-2', title: 'Café Chronicles', description: '', monitored: false, books: [] }, + ]) + + expect(await screen.findByRole('heading', { name: 'The Stormlight Archive' })).toBeInTheDocument() + const search = screen.getByRole('searchbox', { name: 'Search series...' }) + fireEvent.change(search, { target: { value: ' CAFE ' } }) + expect(screen.getByRole('heading', { name: 'Café Chronicles' })).toBeInTheDocument() + expect(screen.queryByRole('heading', { name: 'The Stormlight Archive' })).not.toBeInTheDocument() + + fireEvent.change(search, { target: { value: 'missing series' } }) + expect(screen.getByRole('status')).toHaveTextContent('No series match "missing series"') + expect(screen.queryByRole('heading', { level: 3 })).not.toBeInTheDocument() + expect(screen.queryByText(/Series are populated automatically/)).not.toBeInTheDocument() + + fireEvent.change(search, { target: { value: '' } }) + expect(screen.getByRole('heading', { name: 'The Stormlight Archive' })).toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'Café Chronicles' })).toBeInTheDocument() + expect(screen.queryByRole('status')).not.toBeInTheDocument() + expect(api.listSeries).toHaveBeenCalledTimes(1) + }) + + it('keeps the empty-library guidance when there are no series to search', async () => { + renderSeriesPage([]) + + expect(await screen.findByText('No series found')).toBeInTheDocument() + fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'Stormlight' } }) + expect(screen.getByText(/Series are populated automatically/)).toBeInTheDocument() + expect(screen.queryByRole('status')).not.toBeInTheDocument() + }) + it('hides Hardcover controls when enhanced Hardcover API is disabled', async () => { renderSeriesPage([ { diff --git a/web/src/pages/SeriesPage.tsx b/web/src/pages/SeriesPage.tsx index 196594e4e..a861e869d 100644 --- a/web/src/pages/SeriesPage.tsx +++ b/web/src/pages/SeriesPage.tsx @@ -3,6 +3,7 @@ import { Link, useLocation } from 'react-router' import { useTranslation } from 'react-i18next' import { api, MediaType, Series, SeriesHardcoverDiff, SeriesHardcoverDiffBook, SeriesHardcoverLink, SeriesHardcoverSearchResult, SystemStatus } from '../api/client' import { hardcoverSeriesUrl } from '../util/metadataSource' +import { foldedIncludes } from '../util/foldForSearch' import AddSeriesBookModal from '../components/AddSeriesBookModal' import HardcoverSeriesLinkModal from '../components/HardcoverSeriesLinkModal' import SeriesNameModal from '../components/SeriesNameModal' @@ -15,6 +16,7 @@ export default function SeriesPage() { const { confirm, confirmDialog } = useConfirmDialog() const location = useLocation() const [seriesList, setSeriesList] = useState([]) + const [search, setSearch] = useState('') const [loading, setLoading] = useState(true) const [expanded, setExpanded] = useState(null) const [filling, setFilling] = useState(null) @@ -246,6 +248,8 @@ export default function SeriesPage() { } } + const filteredSeries = seriesList.filter(series => foldedIncludes(series.title, search)) + return (
{confirmDialog} @@ -262,6 +266,17 @@ export default function SeriesPage() {
+
+ setSearch(e.target.value)} + aria-label={t('series.searchPlaceholder')} + placeholder={t('series.searchPlaceholder')} + className="flex-1 bg-slate-200 dark:bg-zinc-800 border border-slate-300 dark:border-zinc-700 rounded px-3 py-2 text-sm focus:outline-none focus:border-slate-400 dark:focus:border-zinc-600 placeholder-slate-400 dark:placeholder-zinc-600" + /> +
+ {loading ? (
Loading...
) : seriesList.length === 0 ? ( @@ -269,12 +284,16 @@ export default function SeriesPage() {

No series found

Series are populated automatically from your monitored authors' books

+ ) : filteredSeries.length === 0 ? ( +
+

{t('series.noMatch', { query: search })}

+
) : (
{/* items-start (#1682): CSS Grid defaults to align-items:stretch, so expanding one series card stretched every other card in the same row to match, making it hard to tell which one was actually open. */} - {seriesList.map(series => { + {filteredSeries.map(series => { const books = series.books ?? [] const bookCount = books.length // Excluded books are not a gap: counting them showed a "missing" pill