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">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { convertFileSrc } from '@tauri-apps/api/core';
import { settings, loadSettings } from '$lib/stores/settings';
import { loadAlbumTracks, findAlbumArt } from '$lib/library/album';
import CollectionView from '$lib/components/CollectionView.svelte';
import type { Track } from '$lib/types/track';
let tracks = $state<Track[]>([]);
@@ -52,13 +52,6 @@
}
}
function getThumbnailUrl(coverPath?: string): string {
if (!coverPath) {
return '';
}
return convertFileSrc(coverPath);
}
function handleTrackClick(index: number) {
selectedTrackIndex = index;
}
@@ -67,86 +60,31 @@
const year = tracks.find(t => t.metadata.year)?.metadata.year;
return year ? year.toString() : '—';
}
let metadata = $derived(`${getAlbumYear()}${tracks.length} track${tracks.length !== 1 ? 's' : ''}`);
</script>
<div class="album-wrapper">
<div class="wrapper">
{#if loading}
<p style="padding: 8px;">Loading album...</p>
{:else if error}
<p class="error" style="padding: 8px;">{error}</p>
{:else}
<!-- Album Header -->
<div class="album-header">
{#if coverArtPath}
<img
src={getThumbnailUrl(coverArtPath)}
alt="{albumName} cover"
class="album-cover"
<CollectionView
title={albumName}
subtitle={artistName}
{metadata}
{coverArtPath}
{tracks}
{selectedTrackIndex}
onTrackClick={handleTrackClick}
showAlbumColumn={false}
/>
{:else}
<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}
</div>
<style>
.album-wrapper {
.wrapper {
height: 100%;
display: flex;
flex-direction: column;
@@ -155,109 +93,4 @@
.error {
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>

View File

@@ -81,6 +81,14 @@
{:else}
<section class="library-content">
<!-- 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">
<li role="tab" aria-selected={viewMode === 'artists'}>
<button onclick={() => viewMode = 'artists'}>Artists</button>

View File

@@ -1,10 +1,10 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { convertFileSrc } from '@tauri-apps/api/core';
import { settings, loadSettings } from '$lib/stores/settings';
import { scanPlaylists } from '$lib/library/scanner';
import { loadPlaylistTracks, findPlaylistArt } from '$lib/library/playlist';
import CollectionView from '$lib/components/CollectionView.svelte';
import type { Track, PlaylistWithTracks } from '$lib/types/track';
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) {
selectedTrackIndex = index;
}
let metadata = $derived(playlistData ? `${playlistData.tracks.length} track${playlistData.tracks.length !== 1 ? 's' : ''}` : '');
let tracks = $derived(playlistData?.tracks ?? []);
</script>
<div class="playlist-wrapper">
<div class="wrapper">
{#if loading}
<p style="padding: 8px;">Loading playlist...</p>
{:else if error}
@@ -77,79 +73,20 @@
{:else if playlistData && playlistData.tracks.length === 0}
<p style="padding: 8px;">No tracks in this playlist.</p>
{:else if playlistData}
<!-- Playlist Header -->
<div class="playlist-header">
{#if coverArtPath}
<img
src={getThumbnailUrl(coverArtPath)}
alt="{playlistName} cover"
class="playlist-cover"
<CollectionView
title={playlistName}
{metadata}
coverArtPath={coverArtPath}
{tracks}
{selectedTrackIndex}
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}
</div>
<style>
.playlist-wrapper {
.wrapper {
height: 100%;
display: flex;
flex-direction: column;
@@ -158,103 +95,4 @@
.error {
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>