diff --git a/web/src/components/CoverGrid.tsx b/web/src/components/CoverGrid.tsx new file mode 100644 index 0000000..4daa5f8 --- /dev/null +++ b/web/src/components/CoverGrid.tsx @@ -0,0 +1,30 @@ +import { Link } from 'react-router-dom' +import type { Item } from '../types.js' +import Cover from './Cover.js' + +export default function CoverGrid({ items }: { items: Item[] }) { + return ( +
+ {items.map((item) => ( + +
+ + +
+

{item.title}

+

{item.artist}

+ + ))} +
+ ) +} diff --git a/web/src/pages/LibraryPage.tsx b/web/src/pages/LibraryPage.tsx index c785435..d998c7a 100644 --- a/web/src/pages/LibraryPage.tsx +++ b/web/src/pages/LibraryPage.tsx @@ -1,3 +1,121 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { Link } from 'react-router-dom' +import { api } from '../api.js' +import type { CollectionResponse } from '../types.js' +import CoverGrid from '../components/CoverGrid.js' + +const FORMATS = ['All', 'Vinyl', 'CD', 'Cassette'] as const +const RIP = ['All', 'Ripped', 'Not ripped'] as const + export default function LibraryPage() { - return

Library goes here.

+ const [format, setFormat] = useState<(typeof FORMATS)[number]>('All') + const [ripped, setRipped] = useState<(typeof RIP)[number]>('All') + const [q, setQ] = useState('') + const [data, setData] = useState(null) + const [error, setError] = useState(false) + const seq = useRef(0) + + useEffect(() => { + const mine = ++seq.current + const params = { + ...(format !== 'All' ? { format } : {}), + ...(ripped !== 'All' ? { ripped: ripped === 'Ripped' ? 'ripped' : 'not_ripped' } : {}), + ...(q.trim() ? { q: q.trim() } : {}), + } + void api + .listCollection(params) + .then((res) => { + if (seq.current === mine) { + setData(res) + setError(false) + } + }) + .catch(() => { + if (seq.current === mine) setError(true) + }) + }, [format, ripped, q]) + + const artists = useMemo(() => { + const set = new Set() + for (const item of data?.items ?? []) set.add(item.artist) + return [...set].sort((a, b) => a.localeCompare(b)) + }, [data]) + + return ( +
+ setQ(e.target.value)} + className="w-full rounded-xl border border-neutral-800 bg-neutral-900 px-3 py-2 text-sm" + /> + +
+ {FORMATS.map((f) => ( + + ))} + {RIP.map((r) => ( + + ))} +
+ + {error &&

Could not load your collection.

} + + {data && ( + <> +

+ {data.counts.total} in collection · {data.counts.ripped} ripped · {data.counts.notRipped} not ripped +

+ + {data.items.length === 0 ? ( +
+

Nothing here yet.

+ + Add your first record → + +
+ ) : ( + + )} + + {!q && artists.length > 1 && ( +
+ Artists +
+ {artists.map((artist) => ( + + ))} +
+
+ )} + + )} +
+ ) } diff --git a/web/test/library.test.tsx b/web/test/library.test.tsx new file mode 100644 index 0000000..7154e7e --- /dev/null +++ b/web/test/library.test.tsx @@ -0,0 +1,113 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter } from 'react-router-dom' +import LibraryPage from '../src/pages/LibraryPage.js' +import type { Item } from '../src/types.js' + +vi.mock('../src/api.js', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, api: { ...actual.api, listCollection: vi.fn() } } +}) + +import { api } from '../src/api.js' + +function item(overrides: Partial): Item { + return { + id: 1, + discogsReleaseId: 100, + title: 'Motion', + artist: 'The Cinematic Orchestra', + year: 1999, + formats: ['CD'], + genres: [], + labels: [], + tracklist: [], + catno: null, + country: null, + artworkUrl: null, + barcodes: [], + dateAdded: '2026-08-29', + ripOverride: null, + ripStatus: 'not_ripped', + ...overrides, + } +} + +const data = { + items: [ + item({ id: 1, ripStatus: 'not_ripped' }), + item({ id: 2, title: 'Blue Lines', artist: 'Massive Attack', formats: ['Vinyl'], ripStatus: 'ripped' }), + item({ id: 3, title: 'Mezzanine', artist: 'Massive Attack', formats: ['Cassette'], ripStatus: 'not_ripped' }), + ], + counts: { total: 3, ripped: 1, notRipped: 2 }, +} + +beforeEach(() => { + vi.mocked(api.listCollection).mockReset() + vi.mocked(api.listCollection).mockResolvedValue(data as never) +}) + +function renderLibrary() { + return render( + + + + ) +} + +describe('LibraryPage', () => { + it('renders covers with artist and rip badges', async () => { + renderLibrary() + await waitFor(() => expect(screen.getAllByRole('link', { name: /motion/i })).toHaveLength(1)) + expect(screen.getByRole('link', { name: /blue lines/i })).toBeTruthy() + expect(screen.getByRole('link', { name: /mezzanine/i })).toBeTruthy() + }) + + it('passes format and rip filters to the api', async () => { + renderLibrary() + await waitFor(() => expect(screen.getByRole('button', { name: /^vinyl$/i })).toBeTruthy()) + await userEvent.click(screen.getByRole('button', { name: /^vinyl$/i })) + await waitFor(() => + expect(api.listCollection).toHaveBeenLastCalledWith(expect.objectContaining({ format: 'Vinyl' })) + ) + await userEvent.click(screen.getByRole('button', { name: /^ripped$/i })) + await waitFor(() => + expect(api.listCollection).toHaveBeenLastCalledWith( + expect.objectContaining({ format: 'Vinyl', ripped: 'ripped' }) + ) + ) + }) + + it('searches by title/artist via the q filter', async () => { + renderLibrary() + await waitFor(() => expect(screen.getByPlaceholderText(/search/i)).toBeTruthy()) + await userEvent.type(screen.getByPlaceholderText(/search/i), 'mezz') + await waitFor(() => + expect(api.listCollection).toHaveBeenLastCalledWith(expect.objectContaining({ q: 'mezz' })) + ) + }) + + it('shows collection counts', async () => { + renderLibrary() + await waitFor(() => expect(screen.getByText(/3 in collection/i)).toBeTruthy()) + expect(screen.getByText(/1 ripped/i)).toBeTruthy() + expect(screen.getByText(/2 not ripped/i)).toBeTruthy() + }) + + it('artist index sets the search box to the artist name', async () => { + renderLibrary() + const artistBtn = await screen.findByRole('button', { name: /massive attack/i }) + await userEvent.click(artistBtn) + await waitFor(() => + expect(api.listCollection).toHaveBeenLastCalledWith(expect.objectContaining({ q: 'Massive Attack' })) + ) + }) + + it('shows an empty state when the collection is empty', async () => { + vi.mocked(api.listCollection).mockResolvedValue({ items: [], counts: { total: 0, ripped: 0, notRipped: 0 } } as never) + renderLibrary() + await waitFor(() => expect(screen.getByText(/nothing here yet/i)).toBeTruthy()) + expect(screen.getByRole('link', { name: /add your first record/i }).getAttribute('href')).toBe('/add') + }) +})