Commit Graph

130 Commits

Author SHA1 Message Date
bvandeusen 8da4a3f9c0 feat(web): TrackMenu overflow + FlagPopover for the quarantine flow 2026-04-30 20:29:07 -04:00
bvandeusen b653e83478 feat(web): API client modules for quarantine + admin quarantine 2026-04-30 20:26:07 -04:00
bvandeusen ab9be40bb2 feat(web): add /admin/requests approval queue with override modal
Pending/Approved/Completed/Rejected tabs (active count only). Override modal
collapsed by default; reject modal with notes textarea. Lidarr-unreachable
toast surfaces sync-Approve failures. Cancel buttons in both modals are
Pewter ghost; Reject/Confirm reject use Bronze (the design system reserves
Oxblood for irreversible actions like Disconnect).
2026-04-29 23:31:18 -04:00
bvandeusen b895e9ef7e feat(web): add /admin/requests approval queue with override modal
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 23:24:34 -04:00
bvandeusen b1b50187b3 fix(web): integrations panel review fixes
- Trim whitespace before checking the typed-confirm value (paste-with-spaces
  was silently keeping the button disabled).
- Wrap onConfirmDisconnect in try/catch and surface disconnect_failed inline
  in the modal — operators on flaky networks now see why nothing happened.
- Invalidate quality-profile + root-folder caches alongside config on both
  save and disconnect, so a base_url change refetches the lists immediately.
2026-04-29 23:11:32 -04:00
bvandeusen c281c8b5dd feat(web): add /admin/integrations Lidarr connection panel
Connection management for Lidarr — base URL, API key, default quality
profile, default root folder. Empty api_key on save preserves the
masked-saved key per backend semantics. Disconnect requires typed
"DISCONNECT" confirmation. Includes a placeholder MusicBrainz overrides
section for future integrations.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 23:06:19 -04:00
bvandeusen 041e63744d fix(web): promote bg-accent-tint to a real Tailwind utility
Replaces the :global scoped-style workaround in AdminSidebar with a proper
accent.tint color tier in tailwind.config.js. Tailwind generates bg-accent-tint
deterministically; the global class no longer leaks from a single component.
Future consumers (e.g. /admin/requests tab counts) get the same utility.
2026-04-29 22:54:30 -04:00
bvandeusen bfd48f5a02 feat(web): add /admin layout with role-gated load + sidebar
Hard route gate in admin/+layout.ts redirects non-admins to / before
the layout (or any child) renders. AdminSidebar reads the active route
from $app/state and applies a 12% accent-tinted bg + 2px forest-teal
left strip on the active item. Overview landing shows pending-request
count and Lidarr connected/unset, each linking to its sub-page. Shell
nav exposes the Admin link only to is_admin users.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 22:48:26 -04:00
bvandeusen dd86ffca94 fix(web): a11y on /requests aria-labels when titles are absent
Album- and track-kind requests can have null album_title/track_title (the
type allows it). Previously aria-label rendered 'Cancel request for —' which
is meaningless to a screen reader. Fall back to 'this album by <artist>' /
'this track by <artist>' so the cancel/listen buttons stay disambiguable.
2026-04-29 22:29:07 -04:00
bvandeusen ad904afaf6 feat(web): add /requests user-facing request history
Renders the caller's Lidarr requests as rows with kind pill,
StatusPill, and per-status actions: Cancel on pending (which
calls cancelRequest then invalidates qk.myRequests()), Listen
link on completed (deepest match wins: track > album > artist),
admin notes on rejected. Empty state uses the voice-rule
"Nothing requested yet." copy. Shell nav gains /requests
between /discover and /playlists, visible to all authed users
since the view is per-user.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 22:24:17 -04:00
bvandeusen a7506d9413 fix(web): drop dead onkeydown stopPropagation on discover modal 2026-04-29 22:20:35 -04:00
bvandeusen 8a12b8c571 feat(web): add /discover route with search + request flow
Local-input + 250ms debounce drives Lidarr search; tab switch refetches
with new kind. Track-kind Request opens a confirm modal explaining the
album that will be added; Confirm fires createRequest, Cancel is a no-op.
Successful requests flip the card to 'requested' optimistically.
Shell nav now exposes /discover between Search and Playlists.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 22:15:52 -04:00
bvandeusen d27b381250 feat(web): add StatusPill semantic-color status indicator
Single-prop component mapping LidarrRequestStatus to voice-rule labels and
semantic tones (warning/info/success/error). Used by /requests and /admin/requests
in subsequent tasks.
2026-04-29 22:08:09 -04:00
bvandeusen dcb49e9687 fix(web): a11y on DiscoverResultCard + drop test-only inline styles
aria-labels now include the card title on all three button variants so
SR users navigating a grid can tell which card a button belongs to:
  - "Request <title>" / "<title> is already in library" / "<title>
    already requested"
The Kept pill gets role="status" so SR users hear the badge when it
appears (without aria-live's announce-on-mount noise).

The reserved-slot CSS (.badge-row { min-height: 22px } and
.actions { margin-top: auto }) was already in the scoped <style>
block; we drop the duplicate inline style="" attributes that existed
purely to satisfy jsdom's getComputedStyle. Tried stylesheet
introspection (document.styleSheets) as a replacement assertion, but
vitest's @testing-library/svelte renderer doesn't inject the scoped
<style> tag into jsdom (styleSheets.length === 0), so the two CSS
assertions are dropped with an explanatory comment. The layout
discipline is enforced visually at the consumer page rather than as
a "CSS exists in CSS" unit test.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 21:05:27 -04:00
bvandeusen c8bd19d6f1 feat(web): add DiscoverResultCard with reserved badge slot + anchored button
T14 of the M5a Lidarr plan. The Discover grid in M5a renders mixed
requestable / kept / requested cards from Lidarr search. Without
layout discipline the cards in a row land their titles at different
Y coordinates whenever the badge presence varies, which reads as
visual noise. DiscoverResultCard enforces:

  - Flex column with `margin-top: auto` on `.actions`, so the action
    button is anchored to the bottom of the card body regardless of
    title/subtitle wrap differences.
  - `.badge-row` always rendered with `min-height: 22px`, so the
    title baseline holds even when no Kept pill is present.

Both load-bearing CSS values use inline style attributes — jsdom's
getComputedStyle does not resolve scoped <style> blocks, so the tests
verify positioning via inline styles directly. The remaining decorative
CSS (kept-pill background = accent at 15%, flex-direction, gap) lives
in a scoped <style> block.

State -> action mapping (sentence case per FabledSword voice):
  requestable -> bg-action-primary "Request" with Plus icon
  kept        -> disabled ghost "In library" + Kept pill
  requested   -> disabled ghost "Requested"

Cover art falls back to a Lucide glyph (Disc3 / Album / Music2) when
imageUrl is absent. Adds lucide-svelte@^1.0.1 dependency.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 20:27:58 -04:00
bvandeusen ce7424219c feat(web): adminRequests cache key + staleTime polish
- qk.adminRequests defaults to status='all' when no filter is set; the unfiltered
  endpoint returns every status, so caching it as 'pending' was a latent mismatch.
- 60s staleTime on lidarrConfig + myRequests — both are session-stable enough
  that refetching on every consumer-page mount produced needless flicker.
2026-04-29 20:04:51 -04:00
bvandeusen d7eaa189e2 feat(web): add API client modules for Lidarr, requests, admin
T13 of the M5a Lidarr plan. Three new client modules wrap api.get/post/put/del
helpers and the existing TanStack Query qk namespace:

  lidarr.ts   - searchLidarr() + createLidarrSearchQuery()
  requests.ts - createRequest, listMyRequests, getRequest, cancelRequest;
                createMyRequestsQuery(); cancel goes through apiFetch
                directly because the backend returns the cancelled row body
                (api.del's return type is fixed to null).
  admin.ts    - getLidarrConfig, putLidarrConfig, testLidarrConnection,
                listQualityProfiles, listRootFolders, listAdminRequests,
                approveRequest, rejectRequest; query factories for each
                read; quality profiles + root folders take an enabled prop
                so the call site decides when Lidarr is configured.

Shared LidarrRequestStatus / LidarrRequestKind enums and request/config/
search-result shapes added to types.ts. Per-module helpers (CreateRequestParams)
stay in their module files. testLidarrConnection returns a discriminated union
({ok:true,version} | {ok:false,error}) and never throws on ok:false so the
SPA can render either branch.

qk extended with lidarrSearch, myRequests, lidarrConfig,
lidarrQualityProfiles, lidarrRootFolders, adminRequests.

Tests mirror likes.test.ts (vi.mock('./client')) and cover URL construction,
query-param encoding, body shapes, the not-ok testLidarrConnection branch,
and qk additions. 32 new tests, 217 total passing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 19:55:27 -04:00
bvandeusen 29968ae8da feat(web): wire Tailwind fontFamily to FS font tokens
Removes duplication between fabledsword-tokens.css and tailwind.config.js;
font stacks now live in one place so per-app overrides reach Tailwind utilities.
2026-04-29 19:42:53 -04:00
bvandeusen a7090c3768 feat(web): introduce FabledSword design system tokens + Tailwind aliases
Add the canonical FS token palette (surfaces, text, action, semantic,
accent, radii, fonts) as CSS custom properties under web/src/lib/styles/
fabledsword-tokens.css, with a [data-fs-app="minstrel"] hook reserved
for future per-app accent overrides. Wire the tokens through Tailwind by
aliasing semantic colour utilities (bg-background, bg-surface,
bg-surface-hover, text-text-primary/secondary/muted, border-border,
bg-action-primary/secondary/destructive, accent, warning/error/info) to
the new variables, plus rounded-sm..xl and font-display/sans/mono. Load
Fraunces, Inter, and JetBrains Mono (400/500 only) via Google Fonts and
default the body to Inter. Existing components inherit the new palette
without per-page changes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 18:48:20 -04:00
bvandeusen d8b955ff0c feat(web): radio queue auto-refreshes at 80% via ?exclude= param
Adds _radioSeedId state and a module-level $effect.root that triggers a
/api/radio?seed_track=…&exclude=… fetch when queue consumption reaches
80%, appending new tracks without resetting the radio seed. Clears seed
on any non-radio enqueue (playQueue/enqueueTrack/enqueueTracks). Five
new vitest cases cover the trigger threshold, append logic, in-flight
guard, and manual-enqueue reset path.
2026-04-29 08:50:24 -04:00
bvandeusen 13e12d97ae feat(web): add /settings page with ListenBrainz section
Adds api.put helper, ListenBrainz API module with query/mutation
helpers, a /settings route with token + enable/disable UI, and 5
tests. Adds Settings to the Shell nav.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 15:45:42 -04:00
bvandeusen 95d68e3d3d feat: M3.5 polish — genre splitting + radio button surface
Two carry-overs from M3 verification, bundled with the search-input
fix already on dev (b7a59a9).

1. Genre splitting in BuildSessionVector
   The library has many tracks whose genre tag is a denormalized
   multi-genre string ("Indie Pop; Pop; Alternative Pop"). Reading them
   as one opaque tag means a single-genre "Pop" track and a multi-genre
   track listing Pop both fail to share the Pop key, so the tags axis
   in similarity scoring (weight 0.7!) returned 0 in nearly all cases
   on real libraries. Result: contextual scoring couldn't differentiate.

   Add splitGenres() that splits on ; and , and trims whitespace;
   strings without a delimiter come back as a single-element slice
   (so the existing single-genre cases keep working unchanged).
   Concatenated-without-separator output ("ElectronicComplextroGlitch
   Hop") stays opaque — that needs a genre dictionary, out of scope.

2. Play-radio button on TrackRow
   playRadio() was only wired to the click handler on /search and
   /search/tracks, leaving /library/liked, album pages, and search
   results' inner clicks with no way to start a radio. Adds a small
   radio button to TrackRow (between LikeButton and the +queue button).
   Click → playRadio(track.id), with stopPropagation so the row's own
   activate handler doesn't also fire.

Tests:
- 3 new sessionvector tests: TestSplitGenres, multi-genre semicolon
  split, no-separator stays opaque. Existing single-genre tests pass
  unchanged.
- 1 new TrackRow test: radio button calls playRadio with track id and
  does NOT trigger row play.

Verified locally: go -short -race ./... clean, golangci-lint clean,
svelte-check 0/0, 175 vitest tests (was 174), web build succeeds.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 23:50:00 -04:00
bvandeusen b7a59a9473 fix(web): SearchInput passes keepFocus to goto() to prevent blur on debounce
The actual cause of the search-field focus loss: SvelteKit's `goto()`
defaults to resetting focus on navigation, so each debounced URL update
blurred the input mid-typing. Pass `keepFocus: true` to both `goto()`
calls in `navigate()`.

The earlier `bind:value` change in 6931358 didn't address this — it was
solving a hypothetical DOM-write-side cursor-jump issue rather than the
real focus-reset behavior. Leaving `bind:value` in place since it's
still cleaner Svelte 5.

Tests updated to match the new goto args.
2026-04-27 23:37:21 -04:00
bvandeusen 693135896e fix(web): SearchInput keeps focus across debounced URL updates
The previous one-way `{value}` + manual `oninput` rewrite let Svelte
re-set the input's DOM value whenever navigation completed, which moved
the cursor (and on some browsers, blurred the input) mid-typing. Switch
to Svelte 5's `bind:value` — it skips DOM writes when the JS value
already matches the DOM, so the user's typing isn't disturbed.

The URL-resync `\$effect` is unchanged: it still updates `value` for
back/forward navigation but is a no-op during normal typing because the
debounce already wrote the matching URL.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 23:25:30 -04:00
bvandeusen 1f08ee39bd fix(web): add likes/QueryClient mocks to route tests rendering LikeButton
Pages that render TrackRow/AlbumCard/ArtistRow/PlayerBar now indirectly
mount LikeButton, which calls useQueryClient(). Route tests need the
same mocks the component-level tests have.

- Added createLikedIdsQuery mock to 7 route test files
- Added useQueryClient mock to all affected route tests
- Added readable store import where needed
2026-04-26 17:18:39 -04:00
bvandeusen 457b94005f feat(web): add /library/liked page with three sections
Each section is its own infinite query against /api/likes/{type}.
Empty sections show 'No liked X yet' (gated on total === 0). Reuses
ArtistRow / AlbumCard / TrackRow for rendering — same components as
the search overflow pages.
2026-04-26 17:08:06 -04:00
bvandeusen de5320a7b4 feat(web): wire LikeButton into TrackRow, AlbumCard, ArtistRow, PlayerBar
Heart appears on every entity rendering surface. ArtistRow restructured
to a <div> with the link as a positioned overlay so the heart button
can nest without invalid <button>-in-<a> markup. Shell nav grows a
'Liked' entry pointing at /library/liked.
2026-04-26 16:56:07 -04:00
bvandeusen 6ab1fef07e feat(web): add LikeButton component
Heart icon reading from createLikedIdsQuery; click toggles via
likeEntity/unlikeEntity. Click stops propagation so it can nest
inside TrackRow's role=button div without triggering row activation.
2026-04-26 16:47:51 -04:00
bvandeusen dd2b9318a0 feat(web): add likes TanStack Query helpers + mutations
createLikedIdsQuery is the single source for heart-button state across
the app (track_ids/album_ids/artist_ids Sets). likeEntity / unlikeEntity
do optimistic setQueryData updates with rollback on error. Per-entity
infinite queries back the /library/liked sections.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 16:46:39 -04:00
bvandeusen 6ff00ea24d feat(web): mount useEventsDispatcher() in root layout
Single mount point alongside useMediaSession. Once called, the
dispatcher's \$effect watchers run for the lifetime of the SPA.
2026-04-26 09:44:16 -04:00
bvandeusen b6f8a0c390 feat(web): events dispatcher posts to /api/events on player transitions
useEventsDispatcher subscribes to player.current/state via \$effect:
on first transition to playing for a new track, POSTs play_started
and caches the id; on track-id change while a play is open, closes
the prior row as play_skipped; on natural completion (state
playing→paused with position >= duration > 0), POSTs play_ended;
on pagehide, navigator.sendBeacon fires a final play_skipped.
Stable per-tab client_id from sessionStorage.
2026-04-26 09:42:11 -04:00
bvandeusen 7fff293054 feat(web): add three search overflow pages
/search/artists, /search/albums, /search/tracks each read ?q= and run
their own createSearchInfiniteQuery (limit=50). Load more pulls the
next offset; back link returns to /search?q=. Empty q shows a 'no
query' prompt and fires no request. Tracks overflow uses the onPlay
prop on TrackRow to call playRadio(track.id).
2026-04-25 16:19:09 -04:00
bvandeusen e55520dab6 feat(web): /search page with three sections + see-all overflow links
Reads ?q= reactively; runs createSearchQuery (limit=10) when q is
non-empty. Renders Artists/Albums/Tracks sections reusing ArtistRow,
AlbumCard, TrackRow. Track click uses onPlay prop to call
playRadio(track.id) instead of the default playQueue. Empty facets
hide; 'See all N →' link points to overflow pages when total > items.
Empty q shows 'Start typing'; error → ApiErrorBanner; loading →
SearchSkeleton via useDelayed.
2026-04-25 16:16:55 -04:00
bvandeusen ad9c984b62 feat(web): mount SearchInput in Shell header
Header grows a flex-1 column between the brand and the user menu.
Existing Shell tests remain green (the search input has no impact on
nav/menu behavior).
2026-04-25 16:12:19 -04:00
bvandeusen 3be0dc241c feat(web): add SearchSkeleton placeholder for the search page 2026-04-25 16:12:19 -04:00
bvandeusen 251d165e0b feat(web): add SearchInput header component (debounced URL sync)
Pre-fills from page.url.searchParams.get('q'); typing debounces 250ms
then goto('/search?q=…'); first nav from elsewhere pushes, subsequent
typing on /search replaces. Empty input on /search drops the param.
Escape clears the input. URL → value re-sync via \$effect uses
untrack() on the value comparison so it doesn't loop with typing.
2026-04-25 16:01:25 -04:00
bvandeusen a4a5a20308 feat(web): AlbumCard + queue overlay button
Wraps the existing link in a relative container; adds an absolutely
positioned + button. On click stops propagation, fetches /api/albums/:id
once, and feeds tracks into enqueueTracks.
2026-04-25 15:54:18 -04:00
bvandeusen 80b39f1713 feat(web): TrackRow div+role button with onPlay prop and + queue button
Outer <button> becomes <div role="button" tabindex="0"> with keyboard
handlers (Enter/Space) so a real <button> for + queue can nest without
invalid HTML. New optional onPlay prop overrides the default
playQueue(tracks, index); used by the search Tracks section to call
playRadio(track.id) instead. + queue button calls enqueueTrack and
stops propagation so the row click does not fire.
2026-04-25 15:37:11 -04:00
bvandeusen 7eb051c6f7 feat(web): add search query helpers (summary + 3 facet infinite queries)
createSearchQuery hits /api/search with limit=10 (the main /search
page summary). createSearchArtistsInfiniteQuery /
createSearchAlbumsInfiniteQuery / createSearchTracksInfiniteQuery each
hit /api/search with limit=50 and project to their own facet, used by
the per-facet overflow pages. All four are gated by q.length > 0 so
empty queries don't fire.
2026-04-25 15:30:06 -04:00
bvandeusen 7dd77a4723 feat(web): add enqueueTrack, enqueueTracks, playRadio to player store
enqueueTrack/enqueueTracks append to the queue without disturbing
playback state (no auto-play on append, no state mutation when queue
was non-empty). playRadio fetches /api/radio?seed_track=<id> and
feeds the response into playQueue. Empty response leaves queue alone.
2026-04-25 15:22:03 -04:00
bvandeusen 8e80b6e0de feat(web): add SearchResponse and RadioResponse types 2026-04-25 15:02:56 -04:00
bvandeusen 4264ecf538 fix(web): trigger audio.play() during 'loading' state, not only 'playing'
The layout's play-intent effect gated audioEl.play() on player.isPlaying
(state === 'playing'), but the state progression is loading → DOM play()
→ 'playing' event → state='playing'. Without calling play() during
loading, the DOM never starts, the event never fires, and state is
pinned to loading forever. Call play() for the intent-to-play union
(loading + playing), matching the store's design.
2026-04-24 22:12:34 -04:00
bvandeusen 571eb2ec53 feat(web): mount <audio> at root layout and wire it to the player store
Single hidden <audio> survives navigation. Effects drive it from the
store (src, volume, play/pause) and DOM events report back (timeupdate,
loadedmetadata, playing, pause, waiting, ended, error). useMediaSession
is called once here so OS media controls light up as soon as the user
signs in.
2026-04-24 21:43:03 -04:00
bvandeusen ccef9be2d3 feat(web): add PlayerBar slot to Shell
Grid grows to 3 rows (auto / 1fr / auto); PlayerBar spans both columns
in the last row, rendering only when player.current is defined so the
row collapses before first play.
2026-04-24 21:42:10 -04:00
bvandeusen ed082226a0 feat(web): add PlayerBar component
Bottom-bar UI with left: cover+title+linked artist, center: stacked
seek row (elapsed / slider / duration) + transport (prev/play/next),
right: shuffle/repeat/volume. Loading shows spinner; error replaces
transport with retry card.
2026-04-24 21:41:24 -04:00
bvandeusen 4bff8cc99a feat(web): make TrackRow click-to-play; pass tracks+index from album page
TrackRow becomes a <button> that dispatches playQueue(tracks, index).
Album page supplies the full track list + each index so the rest of
the album enqueues automatically when the user clicks a track.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 21:39:34 -04:00
bvandeusen 4189ef9899 feat(web): add MediaSession glue for OS media controls
useMediaSession() wires navigator.mediaSession metadata + action
handlers (play/pause/previous/next/seekto) + positionState to the
player store via $effect. Exports nothing stateful — callers just
invoke it once at root-layout mount.
2026-04-24 21:03:01 -04:00
bvandeusen 1476d5081b feat(web): add shuffle, repeat, and audio-event reporting to player store
toggleShuffle (Fisher-Yates over queue[index+1..end] with injectable
RNG for test determinism), cycleRepeat (off → all → one → off),
reportStateFromAudio with full repeat-mode semantics on 'ended',
plus simple state mirroring for playing/paused/waiting/error. Extends
skipNext to wrap when repeat='all'.
2026-04-24 17:56:00 -04:00
bvandeusen 431f333e41 feat(web): add player rune store with basic actions
Core state (queue, index, state, position, duration, volume, shuffle,
repeat, error) plus actions: playQueue, togglePlay, linear skipNext,
skipPrev 3-second rule, seekTo, setVolume (localStorage-persisted),
registerAudioEl, reportTimeUpdate, reportDuration. Shuffle/repeat
logic and audio-event reporting land in follow-up commits.

Also install an in-memory Storage polyfill in vitest.setup.ts: Node 25
exposes a partial localStorage global that lacks getItem/setItem/clear
(requires --localstorage-file with a path), and jsdom defers to the
Node global when present, leaving tests unable to exercise storage.
2026-04-24 17:54:07 -04:00
bvandeusen 9b67bb7521 fix(web): restore album-page back link; loosen test matchers
The initial Task 13 pass dropped the back-chevron link above the hero
to satisfy a strict getByRole('link', {name: /Miles Davis/}) matcher
that was finding both the back link and the inline hero-artist link.

Better fix: keep the back affordance users expect, and use matchers
that accept ambiguity — getAllByRole for the hero check, and a
strict '^← …' regex for the back-link assertion.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 20:52:28 -04:00