diff --git a/web/src/App.tsx b/web/src/App.tsx index 518f327..e30929d 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -11,6 +11,7 @@ import ItemPage from './pages/ItemPage' import ScanPage from './pages/ScanPage' import AddPage from './pages/AddPage' import SettingsPage from './pages/SettingsPage' +import QueuePage from './pages/QueuePage' function Gate({ children }: { children: ReactNode }) { const { status } = useAuth() @@ -44,6 +45,7 @@ export default function App() { } /> } /> } /> + } /> } /> diff --git a/web/src/pages/LibraryPage.tsx b/web/src/pages/LibraryPage.tsx index d998c7a..4fc2105 100644 --- a/web/src/pages/LibraryPage.tsx +++ b/web/src/pages/LibraryPage.tsx @@ -10,6 +10,7 @@ const RIP = ['All', 'Ripped', 'Not ripped'] as const export default function LibraryPage() { const [format, setFormat] = useState<(typeof FORMATS)[number]>('All') const [ripped, setRipped] = useState<(typeof RIP)[number]>('All') + const [onLoan, setOnLoan] = useState(false) const [q, setQ] = useState('') const [data, setData] = useState(null) const [error, setError] = useState(false) @@ -20,6 +21,7 @@ export default function LibraryPage() { const params = { ...(format !== 'All' ? { format } : {}), ...(ripped !== 'All' ? { ripped: ripped === 'Ripped' ? 'ripped' : 'not_ripped' } : {}), + ...(onLoan ? { onLoan: 'true' } : {}), ...(q.trim() ? { q: q.trim() } : {}), } void api @@ -33,7 +35,7 @@ export default function LibraryPage() { .catch(() => { if (seq.current === mine) setError(true) }) - }, [format, ripped, q]) + }, [format, ripped, onLoan, q]) const artists = useMemo(() => { const set = new Set() @@ -43,6 +45,15 @@ export default function LibraryPage() { return (
+
+ + Stats + + + Queue + +
+ ))} +
{error &&

Could not load your collection.

} diff --git a/web/src/pages/QueuePage.tsx b/web/src/pages/QueuePage.tsx new file mode 100644 index 0000000..83cd446 --- /dev/null +++ b/web/src/pages/QueuePage.tsx @@ -0,0 +1,61 @@ +import { useEffect, useState } from 'react' +import { Link } from 'react-router-dom' +import { api } from '../api.js' +import type { Item } from '../types.js' +import Cover from '../components/Cover.js' + +export default function QueuePage() { + const [items, setItems] = useState(null) + const [error, setError] = useState(false) + + useEffect(() => { + void api + .listCollection({ ripped: 'not_ripped' }) + .then((res) => setItems([...res.items].reverse())) + .catch(() => setError(true)) + }, []) + + function markRipped(id: number) { + void api + .setRip(id, true) + .then(() => setItems((list) => (list ?? []).filter((i) => i.id !== id))) + .catch(() => setError(true)) + } + + return ( +
+

Rip queue — oldest additions first.

+ {error &&

Could not load the queue.

} + {items && items.length === 0 && ( +
+

Nothing waiting to be ripped.

+ + Scan something → + +
+ )} + {items && + items.map((item) => ( +
+ +
+

{item.title}

+

+ {item.artist} · added {new Date(item.dateAdded + 'Z').toLocaleDateString()} +

+
+ + + details + +
+ ))} +
+ ) +} diff --git a/web/test/library.test.tsx b/web/test/library.test.tsx index 7154e7e..bd94669 100644 --- a/web/test/library.test.tsx +++ b/web/test/library.test.tsx @@ -110,4 +110,23 @@ describe('LibraryPage', () => { await waitFor(() => expect(screen.getByText(/nothing here yet/i)).toBeTruthy()) expect(screen.getByRole('link', { name: /add your first record/i }).getAttribute('href')).toBe('/add') }) + + it('header links to stats and queue', async () => { + renderLibrary() + expect(screen.getByRole('link', { name: /stats/i }).getAttribute('href')).toBe('/stats') + expect(screen.getByRole('link', { name: /queue/i }).getAttribute('href')).toBe('/queue') + }) + + it('on-loan chip filters by loan state', async () => { + renderLibrary() + await waitFor(() => expect(screen.getByRole('button', { name: /^on loan$/i })).toBeTruthy()) + await userEvent.click(screen.getByRole('button', { name: /^on loan$/i })) + await waitFor(() => + expect(api.listCollection).toHaveBeenLastCalledWith(expect.objectContaining({ onLoan: 'true' })) + ) + await userEvent.click(screen.getByRole('button', { name: /^on loan$/i })) + await waitFor(() => + expect(api.listCollection).toHaveBeenLastCalledWith(expect.not.objectContaining({ onLoan: expect.anything() })) + ) + }) }) diff --git a/web/test/queue.test.tsx b/web/test/queue.test.tsx new file mode 100644 index 0000000..2a3eb16 --- /dev/null +++ b/web/test/queue.test.tsx @@ -0,0 +1,56 @@ +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 QueuePage from '../src/pages/QueuePage.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(), setRip: vi.fn() } } +}) +import { api } from '../src/api.js' + +function qItem(id: number, title: string, added: string): Item { + return { + id, discogsReleaseId: 1, title, artist: 'Artist', year: 1999, formats: ['CD'], genres: [], labels: [], + tracklist: [], catno: null, country: null, artworkUrl: null, barcodes: [], dateAdded: added, + ripOverride: null, ripStatus: 'not_ripped', + } +} + +beforeEach(() => { + vi.mocked(api.listCollection).mockReset() + vi.mocked(api.setRip).mockReset() +}) + +describe('QueuePage', () => { + it('lists not-ripped items oldest first and marks ripped', async () => { + vi.mocked(api.listCollection).mockResolvedValue({ + items: [qItem(1, 'Newest', '2026-08-20'), qItem(2, 'Oldest', '2026-08-01')], + counts: { total: 2, ripped: 0, notRipped: 2 }, + } as never) + vi.mocked(api.setRip).mockResolvedValue({} as never) + render( + + + + ) + expect(await screen.findByText('Oldest')).toBeTruthy() // oldest first + const rows = screen.getAllByRole('button', { name: /mark ripped/i }) + await userEvent.click(rows[0]!) + await waitFor(() => expect(api.setRip).toHaveBeenCalledWith(2, true)) + await waitFor(() => expect(screen.queryByText('Oldest')).toBeNull()) + }) + + it('empty state points back to scanning', async () => { + vi.mocked(api.listCollection).mockResolvedValue({ items: [], counts: { total: 0, ripped: 0, notRipped: 0 } } as never) + render( + + + + ) + expect(await screen.findByText(/nothing waiting/i)).toBeTruthy() + expect(screen.getByRole('link', { name: /scan something/i }).getAttribute('href')).toBe('/scan') + }) +})