diff --git a/server/src/db.ts b/server/src/db.ts index bc2ffdd..439a8f6 100644 --- a/server/src/db.ts +++ b/server/src/db.ts @@ -95,18 +95,24 @@ function setSchemaVersion(db: DB, version: number): void { export function migrateUpgrades(db: DB): void { const version = getSchemaVersion(db) if (version < 2) { - db.exec(` - ALTER TABLE digital_albums ADD COLUMN last_played_at TEXT; - CREATE TABLE IF NOT EXISTS loans ( + const hasColumn = (db.prepare('PRAGMA table_info(digital_albums)').all() as { name: string }[]).some( + (c) => c.name === 'last_played_at' + ) + const apply = db.transaction(() => { + if (!hasColumn) { + db.exec('ALTER TABLE digital_albums ADD COLUMN last_played_at TEXT') + } + db.exec(`CREATE TABLE IF NOT EXISTS loans ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, item_id INTEGER NOT NULL REFERENCES collection_items(id) ON DELETE CASCADE, borrower TEXT NOT NULL, lent_at TEXT NOT NULL DEFAULT (datetime('now')), returned_at TEXT - ); - `) - setSchemaVersion(db, 2) + );`) + setSchemaVersion(db, 2) + }) + apply() } } diff --git a/web/src/pages/ItemPage.tsx b/web/src/pages/ItemPage.tsx index 731181c..8bc2a4d 100644 --- a/web/src/pages/ItemPage.tsx +++ b/web/src/pages/ItemPage.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useState } from 'react' import { Link, useNavigate, useParams } from 'react-router-dom' -import { api } from '../api.js' +import { api, ApiError } from '../api.js' import { usePlayer } from '../player/PlayerContext.js' import type { DigitalAlbum, Item, ItemDetail } from '../types.js' import Cover from '../components/Cover.js' @@ -249,7 +249,18 @@ export default function ItemPage() {

- {user?.isAdmin && ( -
-
- - Export JSON - +
+
+ + Export JSON + + {user?.isAdmin && ( -
- {backups && backups.length > 0 && ( -
    - {backups.map((b) => ( -
  • - {b.file} - - {(b.sizeBytes / 1024).toFixed(0)} KB · {new Date(b.createdAt).toLocaleString()} - -
  • - ))} -
)} -
- )} +
+ {user?.isAdmin && backups && backups.length > 0 && ( + + )} +
{user?.isAdmin && (
diff --git a/web/src/player/MiniBar.tsx b/web/src/player/MiniBar.tsx index e0f78ab..e33061d 100644 --- a/web/src/player/MiniBar.tsx +++ b/web/src/player/MiniBar.tsx @@ -17,7 +17,21 @@ export default function MiniBar() {

{state.album.title}

{state.album.artist}

-
+
+ + + diff --git a/web/test/item.test.tsx b/web/test/item.test.tsx index b385479..518366c 100644 --- a/web/test/item.test.tsx +++ b/web/test/item.test.tsx @@ -25,7 +25,7 @@ vi.mock('../src/api.js', async (importOriginal) => { } }) -import { api } from '../src/api.js' +import { api, ApiError } from '../src/api.js' const item: ItemDetail = { id: 1, @@ -227,4 +227,12 @@ describe('ItemPage', () => { await userEvent.click(screen.getByRole('button', { name: /mark returned/i })) await waitFor(() => expect(api.returnLoan).toHaveBeenCalledWith(9)) }) + + it('lend failure surfaces the inline message', async () => { + vi.mocked(api.lendItem).mockRejectedValue(new ApiError(409, 'already_on_loan')) + renderItem() + await userEvent.type(await screen.findByLabelText(/borrower/i), 'Bob') + await userEvent.click(screen.getByRole('button', { name: /^lend$/i })) + await waitFor(() => expect(screen.getByText(/already out to someone/i)).toBeTruthy()) + }) }) diff --git a/web/test/minibar.test.tsx b/web/test/minibar.test.tsx index 62a8241..d0c71c4 100644 --- a/web/test/minibar.test.tsx +++ b/web/test/minibar.test.tsx @@ -69,4 +69,13 @@ describe('MiniBar', () => { await userEvent.click(screen.getByRole('button', { name: /close player/i })) await waitFor(() => expect(screen.queryByText('Motion')).toBeNull()) }) + + it('expanded view has prev/next/pause controls', async () => { + renderBar() + await userEvent.click(screen.getByRole('button', { name: 'load' })) + await userEvent.click(await screen.findByRole('button', { name: /expand/i })) + expect(screen.getByRole('button', { name: /previous track/i })).toBeTruthy() + expect(screen.getByRole('button', { name: /next track/i })).toBeTruthy() + expect(screen.getByRole('button', { name: /pause/i })).toBeTruthy() + }) }) diff --git a/web/test/settings.test.tsx b/web/test/settings.test.tsx index 0735de6..01b2fb1 100644 --- a/web/test/settings.test.tsx +++ b/web/test/settings.test.tsx @@ -201,4 +201,11 @@ describe('SettingsPage', () => { await userEvent.click(await screen.findByRole('button', { name: /back up now/i })) await waitFor(() => expect(screen.getByText(/backup failed/i)).toBeTruthy()) }) + + it('non-admin sees the Export link but not backups', async () => { + stubAuthFetch({ id: 2, username: 'bob', isAdmin: false }) + renderSettings() + expect(await screen.findByRole('link', { name: /export json/i })).toBeTruthy() + expect(screen.queryByRole('button', { name: /back up now/i })).toBeNull() + }) })