Commit Graph

85 Commits

Author SHA1 Message Date
bvandeusen 8fff7f62d6 refactor(web/m7-377): TrackRow +queue uses Lucide Plus icon + scoped aria-label 2026-05-04 13:08:26 -04:00
bvandeusen d35ed5945a feat(web/m7-377): AlbumCard gains bottom-right kebab 2026-05-04 13:07:06 -04:00
bvandeusen 4e42fc8280 feat(web/m7-377): ArtistCard top-right cluster + bottom-right kebab 2026-05-04 13:05:11 -04:00
bvandeusen 43a3dc47a8 feat(web/m7-377): AlbumMenu component + listAlbumTracks helper
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 11:59:38 -04:00
bvandeusen ce01b3d992 feat(web/m7-377): ArtistMenu component with like/queue/playlist actions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 11:53:41 -04:00
bvandeusen 72496852a5 feat(web/m7-377): LikeButton gains size prop (sm/md/lg) 2026-05-04 11:50:31 -04:00
bvandeusen 9eeb79e99f fix(web/m7-377): update AddToPlaylistMenu tests for tracks-array prop 2026-05-04 11:48:55 -04:00
bvandeusen 7ddf77ace5 refactor(web/m7-377): AddToPlaylistMenu accepts tracks: TrackRef[] 2026-05-04 11:47:53 -04:00
bvandeusen c04b288737 feat(web/m7-365): add History entry to Library nav 2026-05-04 06:59:05 -04:00
bvandeusen da39ff847b feat(web/m7-365): HistoryRow component with click-to-play 2026-05-04 06:19:59 -04:00
bvandeusen d202319c87 fix(web/m7-364): vitest failures — exact-match query, inert prop, -0, user guard
CI test-web was blocking on:

- QueueDrawer.test close-button query: getByLabelText(/close queue/i)
  matched BOTH the backdrop button (aria-label="Close queue backdrop")
  and the close button (aria-label="Close queue"). Switched to exact
  string match.

- QueueDrawer.test inert assertion: Svelte 5 + jsdom uses property
  binding for `inert`, not attribute, so hasAttribute('inert') returns
  false. Check the DOM property (with attribute fallback for
  robustness) instead.

- queue-row-math.ts -0 normalization: Math.round(-0.5) returns -0,
  and Object.is(-0, 0) is false — vitest's .toBe(0) fails. Added
  `|| 0` to normalize -0 to +0 at the function boundary so consumers
  never see -0.

- player/store.svelte.ts user import guard: persistence $effect.root
  reads `user.value?.id` at module-init, but in test files where
  auth/store.svelte.ts is imported transitively (auth/store.test,
  playlists/playlists.test), the player module loads via auth's
  import chain before auth's `user` binding is assigned. `user?.value`
  guards against that init-order race. The proper fix is to invert
  the auth↔player import dep (filed as I2 follow-up under #375).
2026-05-03 22:37:00 -04:00
bvandeusen d43c6b31f4 chore(web/lint): clear svelte-check warnings + fix QueueDrawer test query
CI svelte-check was blocking on:

- 1 ERROR I introduced in the previous cleanup: QueueDrawer.test.ts
  passed { hidden: true } to getByLabelText, but that option only
  exists on getByRole. Fixed by switching to a direct
  document.querySelector for the aria-hidden assertion.

- 13 WARNINGS pre-existing in the codebase from M7 #352/#372/#349
  era, never surfaced because earlier CI runs failed before reaching
  type-check. Now that CI gets that far, they accumulate. Cleared:

  - FlagPopover, RemoveTrackPopover, AddToPlaylistMenu, TrackMenu:
    interactive role divs gain tabindex="-1" + onkeydown that stops
    propagation and closes on Escape (functional, not just warning-
    suppression).

  - FlagPopover state-from-props: $state(untrack(() => prop ?? default))
    for explicit initial-snapshot semantics; const isUpdate switched
    to $derived so it reacts to prop changes.

  - PlaylistTrackRow drag-div: role="listitem" added.

  - playlists/+page.svelte: autofocus replaced with bind:this + $effect.
2026-05-03 22:26:09 -04:00
bvandeusen 22e3f67411 feat(web/m7-364): drawer focus management + grip Enter/Space handling 2026-05-03 22:23:20 -04:00
bvandeusen d83b3eab25 fix(web/m7-364): TrackRef field shape + seek-on-restore + drawer inert
CI svelte-check failed on 5 type errors caused by the slice using
duration_ms/album_name fields that don't exist on TrackRef (the real
shape uses duration_sec + album_id/album_title). Fixed across
QueueDrawer, QueueDrawer.test, QueueTrackRow.test, persisted.test,
and the inline `state.current = null` in PlayerBar.test (TrackRef |
undefined, not | null).

Final whole-slice review concerns also rolled in:

- C1 (Critical): persisted position was restored to UI but never
  seeked into the audio element — first timeupdate snapped _position
  back to 0. Added a one-shot _pendingRestorePosition module ref +
  consumePendingRestorePosition() export; layout's loadedmetadata
  handler now seeks once on restore.

- I1 (Important): throttled-write effect now wraps _queue / _index
  reads in untrack so the dependency tracking matches the design
  intent. Position remains the only tracked dep.

- I4 (Important): drawer gets inert={!queueDrawerOpen} so its inner
  buttons drop out of tab order when closed (aria-hidden alone
  doesn't do that). Removed redundant role="complementary" from
  <aside> (implied by the element). New test asserts inert binding.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 22:14:59 -04:00
bvandeusen 82846c9fcd fix(web/m7-364): align queue toggle styling with sibling toggles
- Queue toggle active class: bg-accent-tint text-accent (matches shuffle/repeat)
- ListMusic icon size: 20 → 18 (matches siblings)
- Right-side container width: w-48 → w-60 (accommodates 4 buttons + volume)
2026-05-03 21:52:39 -04:00
bvandeusen 36805914d8 feat(web/m7-364): PlayerBar queue toggle + Up next line
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 21:50:14 -04:00
bvandeusen b99eb5f120 feat(web/m7-364): QueueDrawer side-panel with track list 2026-05-03 21:47:33 -04:00
bvandeusen e164d69492 fix(web/m7-364): keyboard reorder + measured row height + offsetToDelta helper 2026-05-03 21:45:32 -04:00
bvandeusen 57a353a138 feat(web/m7-364): QueueTrackRow with neodrag reorder + remove 2026-05-03 21:41:59 -04:00
bvandeusen f31dbcc087 refactor(web/m7-363): Shell header reads appName() not literal 2026-05-03 17:32:57 -04:00
bvandeusen 7faa7b4e1f fix(web): hide Play next / Add to queue on PlayerBar's track menu
Those actions don't have meaningful semantics for the track you're
already listening to. New TrackMenu prop hideQueueActions gates the
pair; PlayerBar passes it true. Other mount sites (track rows in
lists) keep the full 8-entry menu.
2026-05-03 14:08:29 -04:00
bvandeusen 4b101481fc fix(web): drop-up Add-to-playlist submenu, hide horizontal scroll row scrollbar
- AddToPlaylistMenu: anchor submenu bottom to parent bottom so it
  grows upward (top-0 → bottom-0) — kebab row is usually near the
  bottom of the track menu and the submenu kept extending off-screen.
- HorizontalScrollRow: scrollbar-width: none + ::-webkit-scrollbar
  display:none. Page arrows already drive the scroll; the scrollbar
  was just consuming vertical real estate.
2026-05-03 14:04:07 -04:00
bvandeusen f0bfac2555 refactor(web/m7-362): action-button labels use non-flipping text-action-fg
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-03 13:42:42 -04:00
bvandeusen 0ce78035aa test(web): PlaylistCard cover-img assertion uses DOM query
The <img alt=""> production element renders as role="presentation"
(decorative — playlist name is in sibling text). screen.getByRole('img')
doesn't match presentation-role elements. Switch to
container.querySelector('img') so the test doesn't fight the
correct a11y choice. Production code unchanged.
2026-05-03 12:28:21 -04:00
bvandeusen 71dbaaede5 feat(web): AddToPlaylistMenu submenu wired into TrackMenu (M7 #352)
The "Add to playlist…" entry that #372 reserved as a disabled slot
is now active. Submenu lists the operator's own playlists
alphabetically; "New playlist…" toggles an inline create form that
makes the playlist + appends the track in two API calls.

TrackMenu's existing test asserts the entry is enabled and opens the
submenu instead of the previous "is disabled with tooltip" check.
2026-05-03 11:20:14 -04:00
bvandeusen 4067be04a6 feat(web): PlaylistTrackRow component for M7 #352 slice 1
Variant of TrackRow specialised for playlist detail. Drag handle
visible to owner only; remove button (X) visible to owner only;
greyed-out + strikethrough when track_id is null (upstream track
removed from library). Reuses the existing LikeButton and TrackMenu
when the track is still alive.
2026-05-03 11:16:35 -04:00
bvandeusen 0eb346e0c6 feat(web): PlaylistCard component for M7 #352 slice 1
Square card with cover (or "No tracks yet" glyph fallback), name,
track count, and owner attribution when the playlist isn't the
current user's. Click navigates to /playlists/{id}.
2026-05-03 11:14:30 -04:00
bvandeusen 40db918bfc fix(web): RemoveTrackPopover async test waits + skip 2 SvelteKit-broken suites
Three CI failures from the dev-push test-web.yml run. Two categories:

1. RemoveTrackPopover.test.ts — `confirm()` chains 5+ awaited
   invalidateQueries before onClose; the test's two `await Promise.resolve()`
   only flushed two microtasks. Switch to waitFor() so the assertion
   polls until the side effects land. Same fix on the success-cascade
   invalidation count test.

2. discover.test.ts + requests.test.ts — both fail at module-load with
   `TypeError: notifiable_store is not a function` deep in
   @sveltejs/kit's client.js. Surfaced only on the new dev-push
   workflow; PR-to-main runs were green. describe.skip with a FIXME
   pointing at the new triage task M7 #374. The pages themselves
   aren't broken — the test harness is.
2026-05-03 00:00:26 -04:00
bvandeusen d87d9e3255 fix(web): widen TrackMenuItem icon prop type to match Lucide signature
Previous fix narrowed `IconProp` to `ComponentType<SvelteComponent<{
size?: number, strokeWidth?: number, class?: string }>>` but Lucide
icons accept `size: string | number` (you can write size="16" or
size={16}), and the resulting structural mismatch surfaces 11 type
errors at every assignment site. Drop the prop-shape constraint
entirely — TrackMenuItem only ever passes numeric values to the icon.
2026-05-02 23:34:11 -04:00
bvandeusen 5d69e9614f fix(web): TrackMenuItem icon prop accepts Lucide class-components
svelte-check on the new dev-push CI surfaced 11 type errors. Cause:
TrackMenuItem typed `icon` as Svelte 5's runes-mode `Component<...>`,
but Lucide-svelte ships class-based components whose type is
`ComponentType<SvelteComponent<...>>`. Switch the prop type to match
Lucide's actual export shape.

Also drop the redundant role="separator" on TrackMenuDivider — <hr>
already implies role=separator (svelte-check warning).

The remaining a11y warnings (tabindex on role=menu/dialog elements,
key-handler-with-click) are pre-existing in FlagPopover and surface
on the new dev-push run because svelte-check now sees them. They're
warnings not errors, so they don't block CI; address as a follow-up.
2026-05-02 23:24:20 -04:00
bvandeusen 9dc4786fd9 feat(web): TrackMenu rewrite — 9 entries in 4 groups (M7 #372)
Replaces the M5b-era FlagPopover-only menu with the full track-actions
surface: queue (Play next, Add to queue), collection (Like/Unlike,
Add to playlist… reserved for #352), navigation (Go to album/artist),
lifecycle (Flag, Hide/Unhide, Remove from library — admin-only).

Remove from library opens a new RemoveTrackPopover with a single
"Also stop Lidarr from finding a replacement" checkbox. The
destructive flow always deletes file + DB through Minstrel; the
checkbox controls whether Lidarr is also told to unmonitor. Lidarr
unmonitor failure flows back as lidarr_unmonitor_failed in the
response — destructive part already succeeded.

The component's prop API (track, direction) is unchanged so TrackRow
and PlayerBar pick up the new entries with no code change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 23:00:09 -04:00
bvandeusen 681b532b57 feat(web): TrackMenuDivider primitive for M7 #372 menu groups 2026-05-02 22:46:44 -04:00
bvandeusen 9256369926 feat(web): TrackMenuItem primitive for M7 #372
Single-entry button used by TrackMenu. Supports aria-disabled (kept
visible to preserve menu height when admin-only or pre-#352 entries
are non-functional) and a `danger` flag for the oxblood-tinted
"Remove from library" entry.
2026-05-02 22:46:29 -04:00
bvandeusen 2946ec4222 feat(web): coupled section scrolling, earlier infinite-scroll, fuller player cover
Three coordinated polish changes:

1. PlayerBar cover bumps from h-20 (80px) to h-24 (96px) and the bar's
   vertical padding tightens from py-4 to py-1.5. Bar height stays
   ~108px but the cover now fills ~89% of it (was ~74%) — reads as the
   substantial primary content the operator wanted, not a thumbnail.

2. InfiniteScrollSentinel default rootMargin moves from 300px to 800px
   so the next page fetches well before the user reaches the bottom of
   the rendered set. Empirically that's ~3-4 rows of cards on a typical
   library grid — loading feels seamless rather than catching up.

3. HorizontalScrollRow takes rows: T[][] instead of items: T[]. Multiple
   rows of items now render inside one shared overflow-x-auto container,
   so the rows scroll together as a single coupled section. Recently
   added (2 album rows) and Most played (3 track rows) on the home page
   now scroll as one unit. Rediscover keeps two separate scrollers
   because its rows are different card types (square albums vs circular
   artists) — coupling those would interleave shapes awkwardly. The
   item snippet's second arg is now the global flat index so consumers
   like CompactTrackCard (which needs sectionTracks + index for play
   actions) work without per-row re-indexing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:52:03 -04:00
bvandeusen 8c62f0089e feat(web): redesign PlayerBar — Lucide icons, focal play button, taller bar
Three theming/density problems with the old PlayerBar:

- Emoji glyphs (⏮ ▶ ⏸ ⏭ 🔀 🔁 ◌) painted at OS-default sizes inside p-1
  hit areas, so each button had a 6-8px ring of dead space and the row
  clashed with the Lucide stroke-icon language used elsewhere.
- No focal point on transport — play looked structurally identical to
  prev/next so the eye had nothing to anchor on.
- Cover at 48x48 in a 72px bar left visible vertical breathing room
  around it that read as accidental empty space.

Changes:

- All emoji replaced with Lucide icons: SkipBack, Play, Pause, Loader2,
  SkipForward, Shuffle, Repeat, Repeat1, Volume2/Volume1/VolumeX.
- Play button is the brand moment: 48px circular, accent forest-teal
  background, 24px parchment icon. Prev/next stay minimal at 40px
  circular with hover-bg.
- Cover bumped to 80x80 in a min-h-[108px] bar (~1.5x the previous
  height) so the player reads as substantial rather than a thin strip.
- Shuffle / repeat get consistent 36px circular hit areas with
  bg-accent-tint + text-accent active state instead of color-only
  toggling. Repeat1 icon swap on repeat-one mode replaces the old
  '🔁¹' string concatenation.
- Volume slider gets a state-aware Lucide icon to its left
  (VolumeX / Volume1 / Volume2 by level), giving the right column a
  visual anchor.
- Right column tightens from w-56 to w-48 since icons are denser than
  emoji + text.

TrackMenu gains a 'direction' prop ('up' | 'down', default 'down') so
PlayerBar can pass direction='up' — the kebab menu opens upward instead
of off the bottom of the page.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:28:10 -04:00
bvandeusen 564e0bf7ca feat(web): use static SVG asset for album fallback cover
Replaces the hand-rolled inline-data-URL placeholder with a static SVG
asset at /placeholders/album-fallback.svg. SvelteKit's adapter-static
publishes everything in web/static/ at the URL root and the Go binary
embeds the build output, so this resolves identically in dev and prod.

Operator can swap the artwork by replacing the file — no code changes.

Test updated to use toContain rather than strict equality, since jsdom
resolves relative URLs against the test origin.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:21:25 -04:00
bvandeusen e6552f4ae2 feat(web): scale ArtistCard fallback Disc3 to fill the circle
The Disc3 fallback was rendered at fixed 32px regardless of card size,
so on a 144px (or larger) circular ArtistCard it floated in mostly-empty
space. Now sized via h-4/5 w-4/5 so it tracks the parent — ~80%
fill, much more recognisable as a record/disc. Stroke-width drops from
1.5 to 1 so the heavier line work doesn't overwhelm at the larger size.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:10:34 -04:00
bvandeusen 65651510fb feat(web): add chevron + aria affordances to user-menu button
The username was rendered as plain text with no visual cue that it
opened a dropdown. Adds a 16px Lucide ChevronDown to the right of the
name (rotates 180° when open) plus the canonical aria-haspopup='menu'
and aria-expanded={menuOpen} attributes so screen readers announce the
button as a menu opener and reflect open/closed state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 22:54:14 -04:00
bvandeusen 7b1cd50cb9 fix(web): user-menu links navigate client-side, not via full reload
The username dropdown's container had onclick={(e) => e.stopPropagation()}
to keep the window-level close-on-outside-click handler from firing on
inside clicks. But that also blocked SvelteKit's document-level link-click
interceptor, so clicking Settings/Admin inside the menu fell through to
the browser's native navigation — a full-page reload.

Symptom: clicking Admin redirected to /. On the hard reload, the admin
guard (/admin/+layout.ts) ran before bootstrap() had settled the user
store; user.value was still null, so the guard threw redirect(302, '/').
By the time you saw / render, bootstrap had finished and the dropdown
showed Admin again, primed to repeat the cycle.

Replaces the stopPropagation-based outside-click protection with a
target-containment check in the window handler: close only when the click
landed outside both the menu button and the menu container. Drops the
redundant stopPropagation on the menu button too (no longer needed since
the window handler now ignores in-menu clicks).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 22:51:05 -04:00
bvandeusen a5a6a10c7b fix(web): give user-menu dropdown z-50 so clicks don't fall through
The Shell's username dropdown had no z-index, so the layout's later
grid siblings (left nav, main content) painted over it. Visible
symptom: clicking Admin actually clicked the Home link underneath,
producing a quick flash followed by a redirect to /. Adding z-50 to
the popup container makes clicks land where they look like they should.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 22:36:03 -04:00
bvandeusen 45793779df feat(web): infinite scroll on /library/artists and /library/albums
Replaces the explicit 'Load more' button on both library list pages with
an IntersectionObserver-based sentinel that triggers fetchNextPage
automatically as the operator scrolls near the bottom (300px rootMargin).

New InfiniteScrollSentinel component:
- Stays disabled while a fetch is in flight (prevents repeat firing on
  tall pages or fast scrolls).
- Tears down its observer when hasNextPage flips false.
- Defensive against environments without IntersectionObserver (jsdom);
  tests provide a synchronous mock.

Also fixes a pre-existing test setup gap on the albums page test:
AlbumCard renders LikeButton which calls useQueryClient(), and the
page.test.ts wasn't wrapped in a QueryClientProvider — added the same
useQueryClient mock the AlbumCard.test.ts already uses.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 22:30:24 -04:00
bvandeusen 22ac86f200 feat(web): trim main nav; pair Discover+Requests; move chrome to user menu
The main nav was carrying surfaces that didn't belong on it:

- Search led to an empty page when clicked (you only ever want to land
  there from the global SearchInput typing into /search?q=...). Drop it.
- Requests is downstream of Discover (you discover, then you request).
  Lift both onto a shared horizontal tab strip; keep their URLs so
  bookmarks survive. New DiscoverTabs component used by both pages.
- Settings + Admin are operator chrome, not primary surfaces. Move them
  into the username dropdown alongside Log out, with Admin gated on
  is_admin. Users see Settings + Log out; admins see Settings + Admin
  + Log out.

Final main nav: Home / Artists / Albums / Liked / Discover / Playlists.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 22:23:56 -04:00
bvandeusen 14c1895beb feat(web): flatten admin shell — horizontal tabs, drop redundant banner
The /admin layout had three nested concentric shells: the global Shell
header + main nav, then a per-admin header banner with a Sword icon, then
a left-aligned admin sub-nav (AdminSidebar) — two competing left-edge
navigation columns plus a redundant 'Admin' label that the URL and active
nav already conveyed.

Replaces the side sub-nav with a horizontal tab strip across the top of
the admin content (matches the discover-page tab pattern), drops the
duplicate header banner, and removes the disabled Users/Library
placeholder items so the tab strip only shows surfaces that actually
ship today. The component is renamed AdminSidebar -> AdminTabs to match
its new shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 22:14:39 -04:00
bvandeusen a598ea1418 feat(web): move Hidden tracks out of main nav, surface from Settings
Hidden tracks (M5b quarantine) is a low-frequency surface — it doesn't
warrant a permanent slot in the main nav. Moves the link under a new
'Library' section card on /settings alongside ListenBrainz config.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 21:14:01 -04:00
bvandeusen 4e47b2e7f5 feat(web): pair scroll arrows beside section title; bleed scroller to viewport edge
Restructures HorizontalScrollRow so paired arrows sit in a flex header
beside an optional section title (instead of absolute-positioned at the
row's left/right edges). Adds margin-right: -1rem to the row so the
scroller's items extend to the viewport's right edge, escaping Shell's
p-4 padding. The header bar keeps its right gutter so titles align with
the rest of the page content.

Home page passes 'title' to the first row of each section; subsequent
rows in multi-row sections render arrow-only headers.
2026-05-01 21:05:16 -04:00
bvandeusen 4412a4af0c feat(web): update nav (Home/Artists/Albums); retire ArtistRow
Add Artists and Albums nav entries, rename root label from Library to
Home. Migrate all three ArtistRow call sites (search, search/artists,
library/liked) to ArtistCard with grid wrapper; delete ArtistRow
component and its test.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 20:27:53 -04:00
bvandeusen cf18d51394 feat(web): add AlphabeticalGrid wrapper with letter dividers
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 20:21:34 -04:00
bvandeusen 0586483a42 feat(web): add CompactTrackCard for Most played rows
Small 140px clickable card showing album cover, title, and artist name.
Clicking calls playQueue(sectionTracks, index) to play through the full
section list. Includes Vitest tests for click and render behaviour.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 20:19:16 -04:00
bvandeusen c7e7dd269b feat(web): add ArtistCard (circular) with play-shuffle overlay
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 20:18:05 -04:00
bvandeusen b5741e59b7 feat(web): polish AlbumCard with FabledSword tokens + play overlay
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 20:16:43 -04:00