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:
vicrodh committed 2026-01-15 18:06:01 -06:00
1 parent 80579e29fa
commit 98f1209e37
2 files changed
+231 -24

No files matched your search

+2 -1
View File
@@ -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)/;
+229 -23
View File
@@ -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;
} }