fix: loan error surfacing, non-admin export, guarded atomic migration, expanded player controls
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -239,16 +239,16 @@ export default function SettingsPage() {
|
||||
</button>
|
||||
</Section>
|
||||
|
||||
{user?.isAdmin && (
|
||||
<Section title="Data">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<a
|
||||
href={api.exportUrl()}
|
||||
download
|
||||
className="rounded-xl border border-neutral-700 px-3 py-1.5 text-sm text-neutral-300"
|
||||
>
|
||||
Export JSON
|
||||
</a>
|
||||
<Section title="Data">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<a
|
||||
href={api.exportUrl()}
|
||||
download
|
||||
className="rounded-xl border border-neutral-700 px-3 py-1.5 text-sm text-neutral-300"
|
||||
>
|
||||
Export JSON
|
||||
</a>
|
||||
{user?.isAdmin && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
@@ -263,21 +263,21 @@ export default function SettingsPage() {
|
||||
>
|
||||
Back up now
|
||||
</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 && (
|
||||
<Section title="Users">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user