Web: Most Played horizontal tiles + nav centering + Library link + Search refinements #67
@@ -5,6 +5,11 @@
|
|||||||
import TrackMenu from './TrackMenu.svelte';
|
import TrackMenu from './TrackMenu.svelte';
|
||||||
import CardActionCluster from './CardActionCluster.svelte';
|
import CardActionCluster from './CardActionCluster.svelte';
|
||||||
|
|
||||||
|
// Horizontal compact track row — cover thumb on the left, title +
|
||||||
|
// artist on the right. Mirrors Android's CompactTrackTile so the
|
||||||
|
// Most Played section reads the same way across platforms (three
|
||||||
|
// narrow rows that scroll together).
|
||||||
|
|
||||||
let {
|
let {
|
||||||
track,
|
track,
|
||||||
sectionTracks,
|
sectionTracks,
|
||||||
@@ -32,24 +37,27 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="card relative w-36">
|
<div class="card group relative w-72">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={`Play ${track.title}`}
|
aria-label={`Play ${track.title}`}
|
||||||
onclick={onClick}
|
onclick={onClick}
|
||||||
class="group block w-full rounded text-left focus-visible:ring-2 focus-visible:ring-accent"
|
class="flex w-full items-center gap-2 rounded-md p-1 pr-12 text-left
|
||||||
|
hover:bg-surface-hover focus-visible:ring-2 focus-visible:ring-accent"
|
||||||
>
|
>
|
||||||
<div class="aspect-square w-full overflow-hidden rounded-md bg-surface-hover">
|
<div class="h-12 w-12 flex-shrink-0 overflow-hidden rounded bg-surface-hover">
|
||||||
<img
|
<img
|
||||||
src={cover}
|
src={cover}
|
||||||
alt=""
|
alt=""
|
||||||
class="h-full w-full object-cover transition-transform group-hover:scale-[1.03]"
|
class="h-full w-full object-cover"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
onerror={onImgError}
|
onerror={onImgError}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2 truncate text-xs font-medium text-text-primary">{track.title}</div>
|
<div class="min-w-0 flex-1">
|
||||||
<div class="truncate text-xs text-text-secondary">{track.artist_name}</div>
|
<div class="truncate text-sm font-medium text-text-primary">{track.title}</div>
|
||||||
|
<div class="truncate text-xs text-text-secondary">{track.artist_name}</div>
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<CardActionCluster
|
<CardActionCluster
|
||||||
likeEntityType="track"
|
likeEntityType="track"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
|
import { Search } from 'lucide-svelte';
|
||||||
|
|
||||||
let value = $state(page.url.searchParams.get('q') ?? '');
|
let value = $state(page.url.searchParams.get('q') ?? '');
|
||||||
let timeout: ReturnType<typeof setTimeout> | null = null;
|
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
@@ -51,12 +52,20 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<input
|
<label class="relative block w-full max-w-md">
|
||||||
type="search"
|
<Search
|
||||||
placeholder="Search artists, albums, tracks…"
|
size={14}
|
||||||
aria-label="Search"
|
strokeWidth={1.5}
|
||||||
bind:value
|
aria-hidden="true"
|
||||||
oninput={onInput}
|
class="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 text-text-secondary"
|
||||||
onkeydown={onKey}
|
/>
|
||||||
class="w-full max-w-md rounded border border-border bg-surface px-3 py-1 text-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
<input
|
||||||
/>
|
type="search"
|
||||||
|
placeholder="Search"
|
||||||
|
aria-label="Search"
|
||||||
|
bind:value
|
||||||
|
oninput={onInput}
|
||||||
|
onkeydown={onKey}
|
||||||
|
class="w-full rounded border border-border bg-surface pl-7 pr-3 py-1 text-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|||||||
@@ -36,9 +36,9 @@
|
|||||||
goto('/login', { replaceState: true });
|
goto('/login', { replaceState: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
function isActive(href: string): boolean {
|
function isActive(prefix: string): boolean {
|
||||||
if (href === '/') return page.url.pathname === '/';
|
if (prefix === '/') return page.url.pathname === '/';
|
||||||
return page.url.pathname.startsWith(href);
|
return page.url.pathname.startsWith(prefix);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Primary nav. Sits centered in the top bar (replaces the prior left
|
// Primary nav. Sits centered in the top bar (replaces the prior left
|
||||||
@@ -46,22 +46,33 @@
|
|||||||
// destinations, plus Library acting as a parent for Artists / Albums /
|
// destinations, plus Library acting as a parent for Artists / Albums /
|
||||||
// Liked / History / Playlists (tabs at /library/+layout.svelte).
|
// Liked / History / Playlists (tabs at /library/+layout.svelte).
|
||||||
// Search + user menu live on the right side of the header (unchanged).
|
// Search + user menu live on the right side of the header (unchanged).
|
||||||
|
// Library targets the Artists tab directly so SPA navigation doesn't
|
||||||
|
// bounce through the /library 308-redirect. isActive('/library')
|
||||||
|
// still matches all sub-routes by prefix so the active state covers
|
||||||
|
// every tab.
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ href: '/', label: 'Home', icon: House },
|
{ href: '/', label: 'Home', icon: House, matchPrefix: '/' },
|
||||||
{ href: '/library', label: 'Library', icon: LibraryBig },
|
{ href: '/library/artists', label: 'Library', icon: LibraryBig, matchPrefix: '/library' },
|
||||||
{ href: '/discover', label: 'Discover', icon: Compass }
|
{ href: '/discover', label: 'Discover', icon: Compass, matchPrefix: '/discover' }
|
||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window onclick={handleWindowClick} onkeydown={(e) => e.key === 'Escape' && (menuOpen = false)} />
|
<svelte:window onclick={handleWindowClick} onkeydown={(e) => e.key === 'Escape' && (menuOpen = false)} />
|
||||||
|
|
||||||
<div class="grid h-screen grid-rows-[auto_1fr_auto] bg-background text-text-primary">
|
<div class="grid h-screen grid-rows-[auto_1fr_auto] bg-background text-text-primary">
|
||||||
<header class="flex items-center gap-3 md:gap-6 border-b border-border bg-surface px-3 md:px-4 py-2">
|
<!-- 3-col grid so the middle column (nav) is centered on the page
|
||||||
<a href="/" class="font-semibold text-sm md:text-base whitespace-nowrap">{appName()}</a>
|
regardless of how wide the side columns are. With the prior
|
||||||
|
flex+justify-center layout the nav's "available space" shifted
|
||||||
|
whenever search or the user menu grew, so the nav drifted off
|
||||||
|
window-center. Grid pins each column to a fixed lane. -->
|
||||||
|
<header class="grid grid-cols-3 items-center border-b border-border bg-surface px-3 md:px-4 py-2 gap-3 md:gap-6">
|
||||||
|
<a href="/" class="font-semibold text-sm md:text-base whitespace-nowrap justify-self-start">
|
||||||
|
{appName()}
|
||||||
|
</a>
|
||||||
|
|
||||||
<nav aria-label="Primary" class="flex flex-1 items-center justify-center gap-1 md:gap-2">
|
<nav aria-label="Primary" class="flex items-center justify-center gap-1 md:gap-2 justify-self-center">
|
||||||
{#each navItems as item}
|
{#each navItems as item}
|
||||||
{@const active = isActive(item.href)}
|
{@const active = isActive(item.matchPrefix)}
|
||||||
<a
|
<a
|
||||||
href={item.href}
|
href={item.href}
|
||||||
class="group flex items-center gap-2 rounded px-2 md:px-3 py-2 min-h-[44px]
|
class="group flex items-center gap-2 rounded px-2 md:px-3 py-2 min-h-[44px]
|
||||||
@@ -76,6 +87,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3 md:gap-6 justify-self-end min-w-0">
|
||||||
<div class="hidden md:block min-w-0 max-w-xs flex-shrink">
|
<div class="hidden md:block min-w-0 max-w-xs flex-shrink">
|
||||||
<SearchInput />
|
<SearchInput />
|
||||||
</div>
|
</div>
|
||||||
@@ -133,6 +145,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Subtle 1200x600 radial highlight at the top of the scroll
|
<!-- Subtle 1200x600 radial highlight at the top of the scroll
|
||||||
|
|||||||
@@ -48,7 +48,9 @@ describe('Shell', () => {
|
|||||||
expect(screen.getByRole('link', { name: label })).toBeInTheDocument();
|
expect(screen.getByRole('link', { name: label })).toBeInTheDocument();
|
||||||
}
|
}
|
||||||
expect(screen.getByRole('link', { name: 'Home' })).toHaveAttribute('href', '/');
|
expect(screen.getByRole('link', { name: 'Home' })).toHaveAttribute('href', '/');
|
||||||
expect(screen.getByRole('link', { name: 'Library' })).toHaveAttribute('href', '/library');
|
// Library jumps to the Artists tab directly so SPA navigation
|
||||||
|
// bypasses the /library 308-redirect.
|
||||||
|
expect(screen.getByRole('link', { name: 'Library' })).toHaveAttribute('href', '/library/artists');
|
||||||
expect(screen.getByRole('link', { name: 'Discover' })).toHaveAttribute('href', '/discover');
|
expect(screen.getByRole('link', { name: 'Discover' })).toHaveAttribute('href', '/discover');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -198,12 +198,10 @@
|
|||||||
ariaLabel="Rediscover albums"
|
ariaLabel="Rediscover albums"
|
||||||
>
|
>
|
||||||
{#snippet item(album: import('$lib/api/types').AlbumRef)}
|
{#snippet item(album: import('$lib/api/types').AlbumRef)}
|
||||||
<!-- Rediscover uses the compact tile size — same visual
|
<!-- Rediscover sits below Recently Added in the size
|
||||||
weight as Most Played's CompactTrackCard. Smaller
|
hierarchy. w-40 keeps it readable without competing
|
||||||
than Recently Added (w-48) so the page hierarchy
|
with the freshest content above. -->
|
||||||
stays clear: fresh content gets visual real estate,
|
<div class="w-40"><AlbumCard {album} /></div>
|
||||||
throwbacks recede. -->
|
|
||||||
<div class="w-32"><AlbumCard {album} /></div>
|
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</HorizontalScrollRow>
|
</HorizontalScrollRow>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -214,7 +212,7 @@
|
|||||||
ariaLabel="Rediscover artists"
|
ariaLabel="Rediscover artists"
|
||||||
>
|
>
|
||||||
{#snippet item(artist: import('$lib/api/types').ArtistRef)}
|
{#snippet item(artist: import('$lib/api/types').ArtistRef)}
|
||||||
<div class="w-28"><ArtistCard {artist} /></div>
|
<div class="w-36"><ArtistCard {artist} /></div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</HorizontalScrollRow>
|
</HorizontalScrollRow>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user