feat: Settings is one page per section down a left rail, Account first, every setting a row; the profile form no longer saves blanks over your name and email (M503 #5595 #5596 #5597)
release / govulncheck (push) Successful in 18s
release / web (push) Successful in 1m45s
release / go (push) Successful in 1m58s
release / integration (push) Successful in 5m39s
release / android (push) Successful in 5m43s
release / Build signed APK (releases and dev) (push) Successful in 5m58s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 26s
release / Verify release artifacts (tag releases only) (push) Skipped

- /settings lands on Account: the Android app, profile, password, signed-in
  devices. Then Playback, Appearance, Notifications, Connections, About.
- Each setting is a row (name, one short line, control at the end) in a
  capped-width column; one-of-N choices are a shared Segmented control.
- Collection and Settings share one SectionRail.
- Connections leads ListenBrainz with a status line: not connected,
  connected and not sending, or sending with the last scrobble's age.
- Notifications prints its column labels once; the digest email's
  settings link points at /settings/notifications.
- #5596: the profile form now loads what is stored and sends only the
  fields that changed. It used to start blank, and the server reads a
  blank as "clear", so one Save wiped the display name and email.
- Recommendation metrics moves to Library › Your listening, and Hidden
  tracks gets a Library tab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 23:34:51 -04:00
co-authored by Claude Opus 5.5
parent c0c0f8a71f
commit c94c09aeae
32 changed files with 1552 additions and 1408 deletions
@@ -1,20 +1,18 @@
<script lang="ts">
import { page } from '$app/state';
import SectionRail from '#lib/components/SectionRail.svelte';
import type { WantCounts } from '#lib/api/types.js';
// The Collection's sections, down the left like Lidarr's (#5572): the
// catalogue of artists first, then the sections that show state. On a
// narrow screen the rail runs across the top. Activity carries a second,
// accented count when downloads are waiting on a person. Unmatched, like
// Lidarr's Unmapped Files, holds the library albums no MusicBrainz album
// matched (#5579).
// catalogue of artists first, then the sections that show state. Activity
// carries a second, accented count when downloads are waiting on a person.
// Unmatched, like Lidarr's Unmapped Files, holds the library albums no
// MusicBrainz album matched (#5579).
let { counts }: { counts?: WantCounts } = $props();
type Item = { href: string; label: string; count?: number; attention?: number };
const items = $derived<Item[]>([
{ href: '/collection', label: 'Catalogue', count: counts?.artists },
// An artist's page belongs to the catalogue.
const items = $derived([
{ href: '/collection', label: 'Catalogue', count: counts?.artists, exact: true, also: ['/collection/artist/'] },
{ href: '/collection/wanted', label: 'Wanted', count: counts?.wanted },
{ href: '/collection/missing', label: 'Missing files', count: counts?.missing },
{ href: '/collection/activity', label: 'Activity', count: counts?.activity, attention: counts?.attention },
@@ -22,40 +20,6 @@
{ href: '/collection/blocked', label: 'Blocked', count: counts?.blocked },
{ href: '/collection/unmatched', label: 'Unmatched', count: counts?.unmatched }
]);
// An artist's page belongs to the catalogue.
function isActive(href: string): boolean {
const path = page.url.pathname;
if (href === '/collection') return path === '/collection' || path.startsWith('/collection/artist/');
return path.startsWith(href);
}
</script>
<nav aria-label="Collection sections" class="border-b border-border md:border-b-0 md:border-r md:pr-3">
<ul class="flex gap-1 overflow-x-auto md:flex-col md:overflow-visible">
{#each items as item (item.href)}
{@const active = isActive(item.href)}
<li>
<a
href={item.href}
aria-current={active ? 'page' : undefined}
class="flex items-center gap-1.5 whitespace-nowrap border-b-2 px-3 py-2 text-sm transition-colors md:rounded-md md:border-b-0 md:border-l-2 {active
? 'border-accent text-text-primary md:bg-surface-hover'
: 'border-transparent text-text-secondary hover:text-text-primary md:hover:bg-surface-hover'}"
>
<span class="md:flex-1">{item.label}</span>
{#if item.attention}
<span
class="rounded-full bg-accent px-1.5 text-xs text-text-on-accent"
aria-label="{item.attention} need you"
data-testid="tab-attention">{item.attention}</span
>
{/if}
{#if item.count !== undefined}
<span class="text-xs tabular-nums text-text-muted">{item.count}</span>
{/if}
</a>
</li>
{/each}
</ul>
</nav>
<SectionRail label="Collection sections" {items} />