Commit Graph

61 Commits

Author SHA1 Message Date
bvandeusen 2f3326aee6 feat(web): suggestion feed on /discover (search-empty default) 2026-05-01 06:51:41 -04:00
bvandeusen 7f18a04161 feat(web): DiscoverResultCard attribution prop for M5c suggestions 2026-05-01 06:25:42 -04:00
bvandeusen 95b706836d feat(web): API client for /api/discover/suggestions 2026-05-01 06:24:21 -04:00
bvandeusen 3bfec944c7 feat(web): /admin/quarantine aggregated queue with resolution actions
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 20:40:34 -04:00
bvandeusen 0d7a65cffb feat(web): /library/hidden user-facing quarantine view
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 20:35:11 -04:00
bvandeusen 88ff997af7 feat(web): mount TrackMenu in TrackRow + PlayerBar 2026-04-30 20:31:56 -04:00
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 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 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 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 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 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 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 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 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 f94bf26f02 feat(web): add ApiErrorBanner retry-capable error card 2026-04-23 18:42:22 -04:00
bvandeusen 9ed8e261d4 feat(web): add LibrarySkeleton with list/grid/album variants
Shimmer placeholders that mirror the layout of each library page so
the real content slides in without shifting.
2026-04-23 18:41:32 -04:00
bvandeusen 12d1cb739b feat(web): add TrackRow component
Non-interactive album track row with number, title, duration.
Player plan will add click-to-play.
2026-04-23 18:40:40 -04:00
bvandeusen e9b482da0c feat(web): add AlbumCard component
Grid card used on the artist detail page. Cover image, title, year.
Broken covers swap to FALLBACK_COVER via onerror.
2026-04-23 18:39:42 -04:00
bvandeusen a1252c1ef4 feat(web): add ArtistRow component
One row in the artists list: avatar initial, name, album count,
chevron. Entire row is an <a> for click + keyboard activation.
2026-04-23 18:18:52 -04:00
bvandeusen ce6b79ec95 feat(web): add TanStack Query helpers for library endpoints
qk.{artists,artist,album} key generators and create*Query wrappers
with shared page size, sort-aware keys, and infinite-query plumbing
for /api/artists.
2026-04-23 18:17:34 -04:00
bvandeusen 19d74d00d6 refactor(web): drop useDelayed sync-timeout hack; fix test timing instead
The synchronous pre-timeout in the implementation was a band-aid for a
missing flushSync() in the third test. $effect callbacks are scheduled
as microtasks and don't run until flushed — the test set source=true
before the initial effect had a chance to register the setTimeout, so
advanceTimersByTime fired nothing.

Proper fix: call flushSync() after $effect.root so the initial effect
runs synchronously, then advance fake timers. Removed the duplicate
setTimeout branch from useDelayed — one code path, no race.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 18:16:22 -04:00
bvandeusen 6ca877b3ad feat(web): add useDelayed rune helper
Boolean mirror that flips true only after the source stays true for
delayMs. Used to hide the skeleton on sub-100ms cache hits so
back-navigation feels instant.
2026-04-23 17:23:23 -04:00
bvandeusen 828273a78f feat(web): add cover URL helper + fallback SVG data URL 2026-04-23 08:23:59 -04:00