feat(artist): add page search and improve bio truncation
Page search: - Add search button in sticky jump-nav bar - Slide-in animation from right when opened - Filter albums, EPs, live, compilations, tributes, others, playlists - Show result count or "No results" message - Press Escape to close Bio improvements: - Reduce truncation to 530 chars (was 800, now 2/3) - Only show "Read more" if text actually needs truncation Live detection - additional languages: - German: Konzert, liveopname - Japanese: ライブ (raibu) - Korean: 라이브 (laibeu), 콘서트 (konseuteu) - Dutch: liveopname
This commit is contained in:
1 parent
80579e29fa
commit
98f1209e37
2 files changed
+231
-24
No files matched your search
@@ -165,7 +165,8 @@ function isLiveAlbum(album: QobuzAlbum): boolean {
|
|||||||
|
|
||||||
// Live album patterns in other languages
|
// Live album patterns in other languages
|
||||||
// Spanish: "en vivo", "en directo" | Portuguese: "ao vivo" | Italian: "dal vivo" | French: "en direct"
|
// Spanish: "en vivo", "en directo" | Portuguese: "ao vivo" | Italian: "dal vivo" | French: "en direct"
|
||||||
const liveMultilang = /\b(en vivo|en directo|ao vivo|dal vivo|en direct)\b/i;
|
// German: "Konzert", "Live in" | Dutch: "concert" | Japanese: ライブ | Korean: 라이브, 콘서트
|
||||||
|
const liveMultilang = /\b(en vivo|en directo|ao vivo|dal vivo|en direct|konzert|liveopname)\b|ライブ|라이브|콘서트/i;
|
||||||
|
|
||||||
// Tour recordings (but not "tour edition" which is a studio album variant)
|
// Tour recordings (but not "tour edition" which is a studio album variant)
|
||||||
const tourPatterns = /\b(tour|on tour)\b(?!.*\bedition\b)/;
|
const tourPatterns = /\b(tour|on tour)\b(?!.*\bedition\b)/;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { ArrowLeft, User, ChevronDown, ChevronUp, Play, Music, Heart } from 'lucide-svelte';
|
import { ArrowLeft, User, ChevronDown, ChevronUp, Play, Music, Heart, Search, X } from 'lucide-svelte';
|
||||||
import type { ArtistDetail, QobuzArtist } from '$lib/types';
|
import type { ArtistDetail, QobuzArtist } from '$lib/types';
|
||||||
import AlbumCard from '../AlbumCard.svelte';
|
import AlbumCard from '../AlbumCard.svelte';
|
||||||
import TrackMenu from '../TrackMenu.svelte';
|
import TrackMenu from '../TrackMenu.svelte';
|
||||||
@@ -120,6 +120,11 @@
|
|||||||
let activeJumpSection = $state('about');
|
let activeJumpSection = $state('about');
|
||||||
let jumpObserver: IntersectionObserver | null = null;
|
let jumpObserver: IntersectionObserver | null = null;
|
||||||
|
|
||||||
|
// Page search state
|
||||||
|
let searchOpen = $state(false);
|
||||||
|
let searchQuery = $state('');
|
||||||
|
let searchInputEl = $state<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
// Download status tracking
|
// Download status tracking
|
||||||
let albumDownloadStatuses = $state<Map<string, boolean>>(new Map());
|
let albumDownloadStatuses = $state<Map<string, boolean>>(new Map());
|
||||||
|
|
||||||
@@ -343,10 +348,14 @@
|
|||||||
artist.biography?.content || artist.biography?.summary || null
|
artist.biography?.content || artist.biography?.summary || null
|
||||||
);
|
);
|
||||||
|
|
||||||
// Truncate bio for collapsed view
|
// Truncate bio for collapsed view (reduced to ~530 chars, 2/3 of original 800)
|
||||||
|
const BIO_TRUNCATE_LENGTH = 530;
|
||||||
let truncatedBio = $derived(
|
let truncatedBio = $derived(
|
||||||
bioText && bioText.length > 800 ? bioText.slice(0, 800) + '...' : bioText
|
bioText && bioText.length > BIO_TRUNCATE_LENGTH
|
||||||
|
? bioText.slice(0, BIO_TRUNCATE_LENGTH) + '...'
|
||||||
|
: bioText
|
||||||
);
|
);
|
||||||
|
let bioNeedsTruncation = $derived(bioText ? bioText.length > BIO_TRUNCATE_LENGTH : false);
|
||||||
|
|
||||||
let hasMoreAlbums = $derived(!!onLoadMore && artist.albumsFetched < artist.totalAlbums);
|
let hasMoreAlbums = $derived(!!onLoadMore && artist.albumsFetched < artist.totalAlbums);
|
||||||
let hasTopTracks = $derived(topTracks.length > 0 || tracksLoading);
|
let hasTopTracks = $derived(topTracks.length > 0 || tracksLoading);
|
||||||
@@ -370,6 +379,64 @@
|
|||||||
|
|
||||||
let showJumpNav = $derived(jumpSections.length > 1);
|
let showJumpNav = $derived(jumpSections.length > 1);
|
||||||
|
|
||||||
|
// Search filtering
|
||||||
|
let searchLower = $derived(searchQuery.toLowerCase().trim());
|
||||||
|
let filteredAlbums = $derived(
|
||||||
|
searchLower
|
||||||
|
? artist.albums.filter(a => a.title.toLowerCase().includes(searchLower))
|
||||||
|
: artist.albums
|
||||||
|
);
|
||||||
|
let filteredEpsSingles = $derived(
|
||||||
|
searchLower
|
||||||
|
? artist.epsSingles.filter(a => a.title.toLowerCase().includes(searchLower))
|
||||||
|
: artist.epsSingles
|
||||||
|
);
|
||||||
|
let filteredLiveAlbums = $derived(
|
||||||
|
searchLower
|
||||||
|
? artist.liveAlbums.filter(a => a.title.toLowerCase().includes(searchLower))
|
||||||
|
: artist.liveAlbums
|
||||||
|
);
|
||||||
|
let filteredCompilations = $derived(
|
||||||
|
searchLower
|
||||||
|
? artist.compilations.filter(a => a.title.toLowerCase().includes(searchLower))
|
||||||
|
: artist.compilations
|
||||||
|
);
|
||||||
|
let filteredTributes = $derived(
|
||||||
|
searchLower
|
||||||
|
? artist.tributes.filter(a => a.title.toLowerCase().includes(searchLower))
|
||||||
|
: artist.tributes
|
||||||
|
);
|
||||||
|
let filteredOthers = $derived(
|
||||||
|
searchLower
|
||||||
|
? artist.others.filter(a => a.title.toLowerCase().includes(searchLower))
|
||||||
|
: artist.others
|
||||||
|
);
|
||||||
|
let filteredPlaylists = $derived(
|
||||||
|
searchLower
|
||||||
|
? artist.playlists.filter(p => p.title.toLowerCase().includes(searchLower))
|
||||||
|
: artist.playlists
|
||||||
|
);
|
||||||
|
let totalFilteredResults = $derived(
|
||||||
|
filteredAlbums.length + filteredEpsSingles.length + filteredLiveAlbums.length +
|
||||||
|
filteredCompilations.length + filteredTributes.length + filteredOthers.length +
|
||||||
|
filteredPlaylists.length
|
||||||
|
);
|
||||||
|
|
||||||
|
function toggleSearch() {
|
||||||
|
searchOpen = !searchOpen;
|
||||||
|
if (searchOpen) {
|
||||||
|
// Focus input after it opens
|
||||||
|
setTimeout(() => searchInputEl?.focus(), 100);
|
||||||
|
} else {
|
||||||
|
searchQuery = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSearch() {
|
||||||
|
searchQuery = '';
|
||||||
|
searchInputEl?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
function scrollToSection(target: HTMLDivElement | null, id: string) {
|
function scrollToSection(target: HTMLDivElement | null, id: string) {
|
||||||
activeJumpSection = id;
|
activeJumpSection = id;
|
||||||
target?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
target?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
@@ -476,7 +543,7 @@
|
|||||||
<div class="bio-text">
|
<div class="bio-text">
|
||||||
{@html bioExpanded ? bioText : truncatedBio}
|
{@html bioExpanded ? bioText : truncatedBio}
|
||||||
</div>
|
</div>
|
||||||
{#if bioText.length > 300}
|
{#if bioNeedsTruncation}
|
||||||
<button class="bio-toggle" onclick={() => bioExpanded = !bioExpanded}>
|
<button class="bio-toggle" onclick={() => bioExpanded = !bioExpanded}>
|
||||||
{#if bioExpanded}
|
{#if bioExpanded}
|
||||||
<ChevronUp size={16} />
|
<ChevronUp size={16} />
|
||||||
@@ -530,17 +597,50 @@
|
|||||||
|
|
||||||
{#if showJumpNav}
|
{#if showJumpNav}
|
||||||
<div class="jump-nav">
|
<div class="jump-nav">
|
||||||
<div class="jump-label">Jump to</div>
|
<div class="jump-nav-left">
|
||||||
<div class="jump-links">
|
<div class="jump-label">Jump to</div>
|
||||||
{#each jumpSections as section}
|
<div class="jump-links">
|
||||||
<button
|
{#each jumpSections as section}
|
||||||
class="jump-link"
|
<button
|
||||||
class:active={activeJumpSection === section.id}
|
class="jump-link"
|
||||||
onclick={() => scrollToSection(section.el, section.id)}
|
class:active={activeJumpSection === section.id}
|
||||||
>
|
onclick={() => scrollToSection(section.el, section.id)}
|
||||||
{section.label}
|
>
|
||||||
</button>
|
{section.label}
|
||||||
{/each}
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="page-search" class:open={searchOpen}>
|
||||||
|
{#if searchOpen}
|
||||||
|
<div class="search-input-wrapper">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="search-input"
|
||||||
|
placeholder="Search in this page..."
|
||||||
|
bind:value={searchQuery}
|
||||||
|
bind:this={searchInputEl}
|
||||||
|
onkeydown={(e) => e.key === 'Escape' && toggleSearch()}
|
||||||
|
/>
|
||||||
|
{#if searchQuery}
|
||||||
|
<button class="search-clear" onclick={clearSearch}>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if searchQuery && totalFilteredResults === 0}
|
||||||
|
<span class="search-no-results">No results</span>
|
||||||
|
{:else if searchQuery}
|
||||||
|
<span class="search-results-count">{totalFilteredResults} found</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<button class="search-toggle" onclick={toggleSearch} title="Search in this page">
|
||||||
|
{#if searchOpen}
|
||||||
|
<X size={18} />
|
||||||
|
{:else}
|
||||||
|
<Search size={18} />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -631,7 +731,7 @@
|
|||||||
<div class="no-albums">No albums found</div>
|
<div class="no-albums">No albums found</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="albums-grid">
|
<div class="albums-grid">
|
||||||
{#each artist.albums as album}
|
{#each filteredAlbums as album}
|
||||||
<AlbumCard
|
<AlbumCard
|
||||||
albumId={album.id}
|
albumId={album.id}
|
||||||
artwork={album.artwork}
|
artwork={album.artwork}
|
||||||
@@ -673,7 +773,7 @@
|
|||||||
<div class="discography section-anchor" bind:this={epsSinglesSection}>
|
<div class="discography section-anchor" bind:this={epsSinglesSection}>
|
||||||
<h2 class="section-title">EPs & Singles</h2>
|
<h2 class="section-title">EPs & Singles</h2>
|
||||||
<div class="albums-grid">
|
<div class="albums-grid">
|
||||||
{#each artist.epsSingles as album}
|
{#each filteredEpsSingles as album}
|
||||||
<AlbumCard
|
<AlbumCard
|
||||||
albumId={album.id}
|
albumId={album.id}
|
||||||
artwork={album.artwork}
|
artwork={album.artwork}
|
||||||
@@ -703,7 +803,7 @@
|
|||||||
<div class="discography section-anchor" bind:this={liveAlbumsSection}>
|
<div class="discography section-anchor" bind:this={liveAlbumsSection}>
|
||||||
<h2 class="section-title">Live Albums</h2>
|
<h2 class="section-title">Live Albums</h2>
|
||||||
<div class="albums-grid">
|
<div class="albums-grid">
|
||||||
{#each artist.liveAlbums as album}
|
{#each filteredLiveAlbums as album}
|
||||||
<AlbumCard
|
<AlbumCard
|
||||||
albumId={album.id}
|
albumId={album.id}
|
||||||
artwork={album.artwork}
|
artwork={album.artwork}
|
||||||
@@ -733,7 +833,7 @@
|
|||||||
<div class="discography section-anchor" bind:this={compilationsSection}>
|
<div class="discography section-anchor" bind:this={compilationsSection}>
|
||||||
<h2 class="section-title">Compilations</h2>
|
<h2 class="section-title">Compilations</h2>
|
||||||
<div class="albums-grid">
|
<div class="albums-grid">
|
||||||
{#each artist.compilations as album}
|
{#each filteredCompilations as album}
|
||||||
<AlbumCard
|
<AlbumCard
|
||||||
albumId={album.id}
|
albumId={album.id}
|
||||||
artwork={album.artwork}
|
artwork={album.artwork}
|
||||||
@@ -763,7 +863,7 @@
|
|||||||
<div class="discography section-anchor" bind:this={tributesSection}>
|
<div class="discography section-anchor" bind:this={tributesSection}>
|
||||||
<h2 class="section-title">Tributes & Covers</h2>
|
<h2 class="section-title">Tributes & Covers</h2>
|
||||||
<div class="albums-grid">
|
<div class="albums-grid">
|
||||||
{#each artist.tributes as album}
|
{#each filteredTributes as album}
|
||||||
<AlbumCard
|
<AlbumCard
|
||||||
albumId={album.id}
|
albumId={album.id}
|
||||||
artwork={album.artwork}
|
artwork={album.artwork}
|
||||||
@@ -793,7 +893,7 @@
|
|||||||
<div class="discography section-anchor" bind:this={othersSection}>
|
<div class="discography section-anchor" bind:this={othersSection}>
|
||||||
<h2 class="section-title">Others</h2>
|
<h2 class="section-title">Others</h2>
|
||||||
<div class="albums-grid">
|
<div class="albums-grid">
|
||||||
{#each artist.others as album}
|
{#each filteredOthers as album}
|
||||||
<AlbumCard
|
<AlbumCard
|
||||||
albumId={album.id}
|
albumId={album.id}
|
||||||
artwork={album.artwork}
|
artwork={album.artwork}
|
||||||
@@ -823,7 +923,7 @@
|
|||||||
<div class="playlists-section section-anchor" bind:this={playlistsSection}>
|
<div class="playlists-section section-anchor" bind:this={playlistsSection}>
|
||||||
<h2 class="section-title">Playlists</h2>
|
<h2 class="section-title">Playlists</h2>
|
||||||
<div class="playlists-grid">
|
<div class="playlists-grid">
|
||||||
{#each artist.playlists as playlist}
|
{#each filteredPlaylists as playlist}
|
||||||
<button
|
<button
|
||||||
class="playlist-card"
|
class="playlist-card"
|
||||||
onclick={() => onPlaylistClick?.(playlist.id)}
|
onclick={() => onPlaylistClick?.(playlist.id)}
|
||||||
@@ -1028,7 +1128,7 @@
|
|||||||
top: 0;
|
top: 0;
|
||||||
z-index: 4;
|
z-index: 4;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 12px 24px;
|
padding: 12px 24px;
|
||||||
@@ -1037,6 +1137,13 @@
|
|||||||
margin: 0 -24px 16px;
|
margin: 0 -24px 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.jump-nav-left {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.jump-label {
|
.jump-label {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
@@ -1070,6 +1177,105 @@
|
|||||||
border-bottom-color: var(--accent-primary);
|
border-bottom-color: var(--accent-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Page Search */
|
||||||
|
.page-search {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: none;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-toggle:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-search.open .search-toggle {
|
||||||
|
background: var(--accent-primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
animation: slideInFromRight 200ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideInFromRight {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
width: 200px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border: 1px solid var(--bg-tertiary);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input:focus {
|
||||||
|
border-color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-clear {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: none;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-left: -32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-clear:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-results-count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-no-results {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--danger);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.similar-artists {
|
.similar-artists {
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user