Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions changelog.d/series-search.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions docs/Hardcover-Series-Wiki.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions web/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
33 changes: 33 additions & 0 deletions web/src/pages/SeriesPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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([
{
Expand Down
21 changes: 20 additions & 1 deletion web/src/pages/SeriesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -15,6 +16,7 @@ export default function SeriesPage() {
const { confirm, confirmDialog } = useConfirmDialog()
const location = useLocation()
const [seriesList, setSeriesList] = useState<Series[]>([])
const [search, setSearch] = useState('')
const [loading, setLoading] = useState(true)
const [expanded, setExpanded] = useState<number | null>(null)
const [filling, setFilling] = useState<number | null>(null)
Expand Down Expand Up @@ -246,6 +248,8 @@ export default function SeriesPage() {
}
}

const filteredSeries = seriesList.filter(series => foldedIncludes(series.title, search))

return (
<div>
{confirmDialog}
Expand All @@ -262,19 +266,34 @@ export default function SeriesPage() {
</div>
</div>

<div className="flex flex-col sm:flex-row gap-3 mb-4">
<input
type="search"
value={search}
onChange={e => 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"
/>
</div>

{loading ? (
<div className="text-slate-600 dark:text-zinc-500">Loading...</div>
) : seriesList.length === 0 ? (
<div className="text-center py-16 text-slate-600 dark:text-zinc-500">
<p className="text-lg mb-2">No series found</p>
<p className="text-sm">Series are populated automatically from your monitored authors' books</p>
</div>
) : filteredSeries.length === 0 ? (
<div className="text-center py-16 text-slate-600 dark:text-zinc-500" role="status">
<p>{t('series.noMatch', { query: search })}</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 items-start">
{/* 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
Expand Down
Loading