1
0

fix: loan error surfacing, non-admin export, guarded atomic migration, expanded player controls

This commit is contained in:
2026-09-03 23:50:54 +02:00
parent 9b2bc22d59
commit 3edf7574f9
7 changed files with 99 additions and 35 deletions

View File

@@ -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)
})
apply()
}
}

View File

@@ -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() {
</p>
<button
type="button"
onClick={() => void api.returnLoan(item.loan!.id).then(refetch)}
onClick={() =>
void api
.returnLoan(item.loan!.id)
.then(refetch)
.catch((err) =>
setMutationError(
err instanceof ApiError && err.code === 'already_on_loan'
? 'That record is already out to someone.'
: "That didn't work — check your connection and try again."
)
)
}
className="rounded-lg border border-neutral-700 px-3 py-1.5 text-sm text-neutral-300"
>
Mark returned
@@ -260,7 +271,16 @@ export default function ItemPage() {
onSubmit={(e) => {
e.preventDefault()
const borrower = (e.currentTarget.elements.namedItem('borrower') as HTMLInputElement).value
void api.lendItem(item.id, borrower).then(refetch)
void api
.lendItem(item.id, borrower)
.then(refetch)
.catch((err) =>
setMutationError(
err instanceof ApiError && err.code === 'already_on_loan'
? 'That record is already out to someone.'
: "That didn't work — check your connection and try again."
)
)
}}
className="flex gap-2"
>

View File

@@ -239,7 +239,6 @@ export default function SettingsPage() {
</button>
</Section>
{user?.isAdmin && (
<Section title="Data">
<div className="flex flex-wrap gap-2">
<a
@@ -249,6 +248,7 @@ export default function SettingsPage() {
>
Export JSON
</a>
{user?.isAdmin && (
<button
type="button"
onClick={() =>
@@ -263,8 +263,9 @@ export default function SettingsPage() {
>
Back up now
</button>
)}
</div>
{backups && backups.length > 0 && (
{user?.isAdmin && backups && backups.length > 0 && (
<ul className="space-y-1 text-xs text-neutral-400">
{backups.map((b) => (
<li key={b.file} className="flex justify-between gap-2">
@@ -277,7 +278,6 @@ export default function SettingsPage() {
</ul>
)}
</Section>
)}
{user?.isAdmin && (
<Section title="Users">

View File

@@ -17,7 +17,21 @@ export default function MiniBar() {
<p className="truncate font-medium">{state.album.title}</p>
<p className="truncate text-sm text-neutral-400">{state.album.artist}</p>
</div>
<div className="flex gap-2">
<div className="flex items-center gap-2">
<button type="button" onClick={prev} aria-label="previous track" className="px-1 text-neutral-300">
</button>
<button
type="button"
onClick={toggle}
aria-label={state.playing ? 'pause' : 'play'}
className="rounded-full bg-emerald-500 px-3 py-1.5 text-neutral-950"
>
{state.playing ? '⏸' : '▶'}
</button>
<button type="button" onClick={next} aria-label="next track" className="px-1 text-neutral-300">
</button>
<button type="button" onClick={() => setExpanded(false)} aria-label="collapse player" className="text-neutral-400">
</button>

View File

@@ -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())
})
})

View File

@@ -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()
})
})

View File

@@ -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()
})
})