refactor(ui): add CollectionView component and unify track listing views

This commit is contained in:
2025-10-01 16:01:54 -04:00
parent dfdb236b2e
commit 56f909b243
4 changed files with 270 additions and 359 deletions

View File

@@ -0,0 +1,232 @@
<script lang="ts">
import type { Track } from '$lib/types/track';
import { convertFileSrc } from '@tauri-apps/api/core';
interface Props {
title: string;
subtitle?: string;
metadata?: string;
coverArtPath?: string;
tracks: Track[];
selectedTrackIndex?: number | null;
onTrackClick?: (index: number) => void;
showAlbumColumn?: boolean;
}
let {
title,
subtitle,
metadata,
coverArtPath,
tracks,
selectedTrackIndex = null,
onTrackClick,
showAlbumColumn = false
}: Props = $props();
function getThumbnailUrl(coverPath?: string): string {
if (!coverPath) {
return '';
}
return convertFileSrc(coverPath);
}
function handleTrackClick(index: number) {
if (onTrackClick) {
onTrackClick(index);
}
}
</script>
<!-- Header -->
<div class="collection-header">
{#if coverArtPath}
<img
src={getThumbnailUrl(coverArtPath)}
alt="{title} cover"
class="collection-cover"
/>
{:else}
<div class="collection-cover-placeholder"></div>
{/if}
<div class="collection-info">
<h2>{title}</h2>
{#if subtitle}
<p class="collection-subtitle">{subtitle}</p>
{/if}
{#if metadata}
<p class="collection-metadata">{metadata}</p>
{/if}
</div>
</div>
<section class="collection-content">
<!-- Tabs (single tab for tracks) -->
<!--
svelte-ignore a11y_no_noninteractive_element_to_interactive_role
Reason: 98.css library requires <menu role="tablist"> for proper tab styling.
The role="tablist" selector is used by 98.css CSS rules (menu[role="tablist"]).
The <menu> element IS interactive (contains clickable <button> elements) and the
role="tablist" properly describes the semantic purpose to assistive technology.
This is the documented pattern from 98.css and matches WAI-ARIA tab widget patterns.
-->
<menu role="tablist">
<li role="tab" aria-selected={true}>
<button>Tracks</button>
</li>
</menu>
<!-- Track Listing -->
<div class="window tab-content" role="tabpanel">
<div class="window-body">
<div class="sunken-panel table-container">
<table class="interactive">
<thead>
<tr>
<th style="width: 50px;">#</th>
<th>Title</th>
{#if showAlbumColumn}
<th>Artist</th>
<th>Album</th>
{/if}
<th>Duration</th>
<th>Format</th>
</tr>
</thead>
<tbody>
{#each tracks as track, i}
<tr
class:highlighted={selectedTrackIndex === i}
onclick={() => handleTrackClick(i)}
>
<td class="track-number">
{track.metadata.trackNumber ?? i + 1}
</td>
<td>{track.metadata.title || track.filename}</td>
{#if showAlbumColumn}
<td>{track.metadata.artist || '—'}</td>
<td>{track.metadata.album || '—'}</td>
{/if}
<td class="duration">
{#if track.metadata.duration}
{Math.floor(track.metadata.duration / 60)}:{String(Math.floor(track.metadata.duration % 60)).padStart(2, '0')}
{:else}
{/if}
</td>
<td class="format">{track.format.toUpperCase()}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
</div>
</section>
<style>
.collection-header {
display: flex;
gap: 16px;
padding: 8px;
margin-bottom: 6px;
flex-shrink: 0;
}
.collection-cover {
width: 152px;
height: 152px;
object-fit: cover;
image-rendering: auto;
flex-shrink: 0;
}
.collection-cover-placeholder {
width: 152px;
height: 152px;
background: linear-gradient(135deg, #c0c0c0 25%, #808080 25%, #808080 50%, #c0c0c0 50%, #c0c0c0 75%, #808080 75%);
background-size: 8px 8px;
flex-shrink: 0;
}
.collection-info {
display: flex;
flex-direction: column;
justify-content: center;
}
h2 {
margin: 0 0 4px 0;
font-size: 1.5em;
}
.collection-subtitle {
margin: 0 0 8px 0;
font-size: 1.1em;
opacity: 0.8;
}
.collection-metadata {
margin: 0;
opacity: 0.6;
font-size: 0.9em;
}
.collection-content {
margin: 0;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.tab-content {
margin-top: -2px;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.window-body {
padding: 0;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.table-container {
flex: 1;
overflow-y: auto;
min-height: 0;
}
table {
width: 100%;
}
th {
text-align: left;
}
.track-number {
text-align: center;
opacity: 0.6;
}
.duration {
font-family: monospace;
font-size: 0.9em;
text-align: center;
width: 80px;
}
.format {
font-family: monospace;
font-size: 0.85em;
text-transform: uppercase;
text-align: center;
width: 80px;
}
</style>

View File

@@ -1,9 +1,9 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { convertFileSrc } from '@tauri-apps/api/core';
import { settings, loadSettings } from '$lib/stores/settings'; import { settings, loadSettings } from '$lib/stores/settings';
import { loadAlbumTracks, findAlbumArt } from '$lib/library/album'; import { loadAlbumTracks, findAlbumArt } from '$lib/library/album';
import CollectionView from '$lib/components/CollectionView.svelte';
import type { Track } from '$lib/types/track'; import type { Track } from '$lib/types/track';
let tracks = $state<Track[]>([]); let tracks = $state<Track[]>([]);
@@ -52,13 +52,6 @@
} }
} }
function getThumbnailUrl(coverPath?: string): string {
if (!coverPath) {
return '';
}
return convertFileSrc(coverPath);
}
function handleTrackClick(index: number) { function handleTrackClick(index: number) {
selectedTrackIndex = index; selectedTrackIndex = index;
} }
@@ -67,86 +60,31 @@
const year = tracks.find(t => t.metadata.year)?.metadata.year; const year = tracks.find(t => t.metadata.year)?.metadata.year;
return year ? year.toString() : '—'; return year ? year.toString() : '—';
} }
let metadata = $derived(`${getAlbumYear()}${tracks.length} track${tracks.length !== 1 ? 's' : ''}`);
</script> </script>
<div class="album-wrapper"> <div class="wrapper">
{#if loading} {#if loading}
<p style="padding: 8px;">Loading album...</p> <p style="padding: 8px;">Loading album...</p>
{:else if error} {:else if error}
<p class="error" style="padding: 8px;">{error}</p> <p class="error" style="padding: 8px;">{error}</p>
{:else} {:else}
<!-- Album Header --> <CollectionView
<div class="album-header"> title={albumName}
{#if coverArtPath} subtitle={artistName}
<img {metadata}
src={getThumbnailUrl(coverArtPath)} {coverArtPath}
alt="{albumName} cover" {tracks}
class="album-cover" {selectedTrackIndex}
/> onTrackClick={handleTrackClick}
{:else} showAlbumColumn={false}
<div class="album-cover-placeholder"></div> />
{/if}
<div class="album-info">
<h2>{albumName}</h2>
<p class="album-artist">{artistName}</p>
<p class="album-meta">
{getAlbumYear()}{tracks.length} track{tracks.length !== 1 ? 's' : ''}
</p>
</div>
</div>
<section class="album-content">
<!-- Tabs (single tab for tracks) -->
<menu role="tablist">
<li role="tab" aria-selected={true}>
<button>Tracks</button>
</li>
</menu>
<!-- Track Listing -->
<div class="window tab-content" role="tabpanel">
<div class="window-body">
<div class="sunken-panel table-container">
<table class="interactive">
<thead>
<tr>
<th style="width: 50px;">#</th>
<th>Title</th>
<th>Duration</th>
<th>Format</th>
</tr>
</thead>
<tbody>
{#each tracks as track, i}
<tr
class:highlighted={selectedTrackIndex === i}
onclick={() => handleTrackClick(i)}
>
<td class="track-number">
{track.metadata.trackNumber ?? i + 1}
</td>
<td>{track.metadata.title || track.filename}</td>
<td class="duration">
{#if track.metadata.duration}
{Math.floor(track.metadata.duration / 60)}:{String(Math.floor(track.metadata.duration % 60)).padStart(2, '0')}
{:else}
{/if}
</td>
<td class="format">{track.format.toUpperCase()}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
</div>
</section>
{/if} {/if}
</div> </div>
<style> <style>
.album-wrapper { .wrapper {
height: 100%; height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -155,109 +93,4 @@
.error { .error {
color: #ff6b6b; color: #ff6b6b;
} }
.album-header {
display: flex;
gap: 16px;
padding: 8px 8px 0 8px;
margin-bottom: 8px;
flex-shrink: 0;
}
.album-cover {
width: 160px;
height: 160px;
object-fit: cover;
image-rendering: auto;
flex-shrink: 0;
}
.album-cover-placeholder {
width: 160px;
height: 160px;
background: linear-gradient(135deg, #c0c0c0 25%, #808080 25%, #808080 50%, #c0c0c0 50%, #c0c0c0 75%, #808080 75%);
background-size: 8px 8px;
flex-shrink: 0;
}
.album-info {
display: flex;
flex-direction: column;
justify-content: center;
}
h2 {
margin: 0 0 4px 0;
font-size: 1.5em;
}
.album-artist {
margin: 0 0 8px 0;
font-size: 1.1em;
opacity: 0.8;
}
.album-meta {
margin: 0;
opacity: 0.6;
font-size: 0.9em;
}
.album-content {
margin: 0;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.tab-content {
margin-top: -2px;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.window-body {
padding: 0;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.table-container {
flex: 1;
overflow-y: auto;
min-height: 0;
}
table {
width: 100%;
}
th {
text-align: left;
}
.track-number {
text-align: center;
opacity: 0.6;
}
.duration {
font-family: monospace;
font-size: 0.9em;
text-align: center;
width: 80px;
}
.format {
font-family: monospace;
font-size: 0.85em;
text-transform: uppercase;
text-align: center;
width: 80px;
}
</style> </style>

View File

@@ -81,6 +81,14 @@
{:else} {:else}
<section class="library-content"> <section class="library-content">
<!-- Tabs --> <!-- Tabs -->
<!--
svelte-ignore a11y_no_noninteractive_element_to_interactive_role
Reason: 98.css library requires <menu role="tablist"> for proper tab styling.
The role="tablist" selector is used by 98.css CSS rules (menu[role="tablist"]).
The <menu> element IS interactive (contains clickable <button> elements) and the
role="tablist" properly describes the semantic purpose to assistive technology.
This is the documented pattern from 98.css and matches WAI-ARIA tab widget patterns.
-->
<menu role="tablist"> <menu role="tablist">
<li role="tab" aria-selected={viewMode === 'artists'}> <li role="tab" aria-selected={viewMode === 'artists'}>
<button onclick={() => viewMode = 'artists'}>Artists</button> <button onclick={() => viewMode = 'artists'}>Artists</button>

View File

@@ -1,10 +1,10 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { convertFileSrc } from '@tauri-apps/api/core';
import { settings, loadSettings } from '$lib/stores/settings'; import { settings, loadSettings } from '$lib/stores/settings';
import { scanPlaylists } from '$lib/library/scanner'; import { scanPlaylists } from '$lib/library/scanner';
import { loadPlaylistTracks, findPlaylistArt } from '$lib/library/playlist'; import { loadPlaylistTracks, findPlaylistArt } from '$lib/library/playlist';
import CollectionView from '$lib/components/CollectionView.svelte';
import type { Track, PlaylistWithTracks } from '$lib/types/track'; import type { Track, PlaylistWithTracks } from '$lib/types/track';
let playlistData = $state<PlaylistWithTracks | null>(null); let playlistData = $state<PlaylistWithTracks | null>(null);
@@ -57,19 +57,15 @@
} }
} }
function getThumbnailUrl(coverPath?: string): string {
if (!coverPath) {
return '';
}
return convertFileSrc(coverPath);
}
function handleTrackClick(index: number) { function handleTrackClick(index: number) {
selectedTrackIndex = index; selectedTrackIndex = index;
} }
let metadata = $derived(playlistData ? `${playlistData.tracks.length} track${playlistData.tracks.length !== 1 ? 's' : ''}` : '');
let tracks = $derived(playlistData?.tracks ?? []);
</script> </script>
<div class="playlist-wrapper"> <div class="wrapper">
{#if loading} {#if loading}
<p style="padding: 8px;">Loading playlist...</p> <p style="padding: 8px;">Loading playlist...</p>
{:else if error} {:else if error}
@@ -77,79 +73,20 @@
{:else if playlistData && playlistData.tracks.length === 0} {:else if playlistData && playlistData.tracks.length === 0}
<p style="padding: 8px;">No tracks in this playlist.</p> <p style="padding: 8px;">No tracks in this playlist.</p>
{:else if playlistData} {:else if playlistData}
<!-- Playlist Header --> <CollectionView
<div class="playlist-header"> title={playlistName}
{#if coverArtPath} {metadata}
<img coverArtPath={coverArtPath}
src={getThumbnailUrl(coverArtPath)} {tracks}
alt="{playlistName} cover" {selectedTrackIndex}
class="playlist-cover" onTrackClick={handleTrackClick}
/> showAlbumColumn={true}
{:else} />
<div class="playlist-cover-placeholder"></div>
{/if}
<div class="playlist-info">
<h2>{playlistName}</h2>
<p class="playlist-meta">
{playlistData.tracks.length} track{playlistData.tracks.length !== 1 ? 's' : ''}
</p>
</div>
</div>
<section class="playlist-content">
<!-- Tabs (single tab for tracks) -->
<menu role="tablist">
<li role="tab" aria-selected={true}>
<button>Tracks</button>
</li>
</menu>
<!-- Track Listing -->
<div class="window tab-content" role="tabpanel">
<div class="window-body">
<div class="sunken-panel table-container">
<table class="interactive">
<thead>
<tr>
<th style="width: 50px;">#</th>
<th>Title</th>
<th>Artist</th>
<th>Album</th>
<th>Duration</th>
<th>Format</th>
</tr>
</thead>
<tbody>
{#each playlistData.tracks as track, index}
<tr
class:highlighted={selectedTrackIndex === index}
onclick={() => handleTrackClick(index)}
>
<td class="track-number">{index + 1}</td>
<td>{track.metadata.title || track.filename}</td>
<td>{track.metadata.artist || '—'}</td>
<td>{track.metadata.album || '—'}</td>
<td class="duration">
{#if track.metadata.duration}
{Math.floor(track.metadata.duration / 60)}:{String(Math.floor(track.metadata.duration % 60)).padStart(2, '0')}
{:else}
{/if}
</td>
<td class="format">{track.format.toUpperCase()}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
</div>
</section>
{/if} {/if}
</div> </div>
<style> <style>
.playlist-wrapper { .wrapper {
height: 100%; height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -158,103 +95,4 @@
.error { .error {
color: #ff6b6b; color: #ff6b6b;
} }
.playlist-header {
display: flex;
gap: 16px;
padding: 8px 8px 0 8px;
margin-bottom: 8px;
flex-shrink: 0;
}
.playlist-cover {
width: 160px;
height: 160px;
object-fit: cover;
image-rendering: auto;
flex-shrink: 0;
}
.playlist-cover-placeholder {
width: 160px;
height: 160px;
background: linear-gradient(135deg, #c0c0c0 25%, #808080 25%, #808080 50%, #c0c0c0 50%, #c0c0c0 75%, #808080 75%);
background-size: 8px 8px;
flex-shrink: 0;
}
.playlist-info {
display: flex;
flex-direction: column;
justify-content: center;
}
h2 {
margin: 0 0 8px 0;
font-size: 1.5em;
}
.playlist-meta {
margin: 0;
opacity: 0.6;
font-size: 0.9em;
}
.playlist-content {
margin: 0;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.tab-content {
margin-top: -2px;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.window-body {
padding: 0;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.table-container {
flex: 1;
overflow-y: auto;
min-height: 0;
}
table {
width: 100%;
}
th {
text-align: left;
}
.track-number {
text-align: center;
opacity: 0.6;
}
.duration {
font-family: monospace;
font-size: 0.9em;
text-align: center;
width: 80px;
}
.format {
font-family: monospace;
font-size: 0.85em;
text-transform: uppercase;
text-align: center;
width: 80px;
}
</style> </style>