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 { export function migrateUpgrades(db: DB): void {
const version = getSchemaVersion(db) const version = getSchemaVersion(db)
if (version < 2) { if (version < 2) {
db.exec(` const hasColumn = (db.prepare('PRAGMA table_info(digital_albums)').all() as { name: string }[]).some(
ALTER TABLE digital_albums ADD COLUMN last_played_at TEXT; (c) => c.name === 'last_played_at'
CREATE TABLE IF NOT EXISTS loans ( )
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, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
item_id INTEGER NOT NULL REFERENCES collection_items(id) ON DELETE CASCADE, item_id INTEGER NOT NULL REFERENCES collection_items(id) ON DELETE CASCADE,
borrower TEXT NOT NULL, borrower TEXT NOT NULL,
lent_at TEXT NOT NULL DEFAULT (datetime('now')), lent_at TEXT NOT NULL DEFAULT (datetime('now')),
returned_at TEXT returned_at TEXT
); );`)
`) setSchemaVersion(db, 2)
setSchemaVersion(db, 2) })
apply()
} }
} }

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom' 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 { usePlayer } from '../player/PlayerContext.js'
import type { DigitalAlbum, Item, ItemDetail } from '../types.js' import type { DigitalAlbum, Item, ItemDetail } from '../types.js'
import Cover from '../components/Cover.js' import Cover from '../components/Cover.js'
@@ -249,7 +249,18 @@ export default function ItemPage() {
</p> </p>
<button <button
type="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" className="rounded-lg border border-neutral-700 px-3 py-1.5 text-sm text-neutral-300"
> >
Mark returned Mark returned
@@ -260,7 +271,16 @@ export default function ItemPage() {
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault() e.preventDefault()
const borrower = (e.currentTarget.elements.namedItem('borrower') as HTMLInputElement).value 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" className="flex gap-2"
> >

View File

@@ -239,16 +239,16 @@ export default function SettingsPage() {
</button> </button>
</Section> </Section>
{user?.isAdmin && ( <Section title="Data">
<Section title="Data"> <div className="flex flex-wrap gap-2">
<div className="flex flex-wrap gap-2"> <a
<a href={api.exportUrl()}
href={api.exportUrl()} download
download className="rounded-xl border border-neutral-700 px-3 py-1.5 text-sm text-neutral-300"
className="rounded-xl border border-neutral-700 px-3 py-1.5 text-sm text-neutral-300" >
> Export JSON
Export JSON </a>
</a> {user?.isAdmin && (
<button <button
type="button" type="button"
onClick={() => onClick={() =>
@@ -263,21 +263,21 @@ export default function SettingsPage() {
> >
Back up now Back up now
</button> </button>
</div>
{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">
<span className="truncate">{b.file}</span>
<span className="shrink-0">
{(b.sizeBytes / 1024).toFixed(0)} KB · {new Date(b.createdAt).toLocaleString()}
</span>
</li>
))}
</ul>
)} )}
</Section> </div>
)} {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">
<span className="truncate">{b.file}</span>
<span className="shrink-0">
{(b.sizeBytes / 1024).toFixed(0)} KB · {new Date(b.createdAt).toLocaleString()}
</span>
</li>
))}
</ul>
)}
</Section>
{user?.isAdmin && ( {user?.isAdmin && (
<Section title="Users"> <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 font-medium">{state.album.title}</p>
<p className="truncate text-sm text-neutral-400">{state.album.artist}</p> <p className="truncate text-sm text-neutral-400">{state.album.artist}</p>
</div> </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 type="button" onClick={() => setExpanded(false)} aria-label="collapse player" className="text-neutral-400">
</button> </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 = { const item: ItemDetail = {
id: 1, id: 1,
@@ -227,4 +227,12 @@ describe('ItemPage', () => {
await userEvent.click(screen.getByRole('button', { name: /mark returned/i })) await userEvent.click(screen.getByRole('button', { name: /mark returned/i }))
await waitFor(() => expect(api.returnLoan).toHaveBeenCalledWith(9)) 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 userEvent.click(screen.getByRole('button', { name: /close player/i }))
await waitFor(() => expect(screen.queryByText('Motion')).toBeNull()) 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 userEvent.click(await screen.findByRole('button', { name: /back up now/i }))
await waitFor(() => expect(screen.getByText(/backup failed/i)).toBeTruthy()) 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()
})
}) })