feat(album-view): right sidebar with label and full awards stack
Ports the Qobuz web 'label + awards' sidebar pattern to AlbumView. Track list and sidebar sit side-by-side above 1100px and collapse vertically below it. Label card navigates to the existing LabelView. Award cards call onAwardClick when provided (disabled until Phase 3 lands AwardView). Wiring: - Adds awards (Vec<PageArtistAward>) to both qbz-models Album and src-tauri api::models Album so /album/get payloads hydrate the field instead of silently dropping it. - Adds awards to QobuzAlbum and AlbumDetail frontend types plus the convertQobuzAlbum adapter. - New album.sidebar i18n keys in all five locales. Card ribbon still shows only the LAST award (Phase 1 behavior); sidebar renders every award as a tap target — mobile-style. cc
This commit is contained in:
10 files changed
+198
-7
No files matched your search
@@ -265,6 +265,9 @@ pub struct Album {
|
||||
/// Album goodies (booklets, liner notes PDFs)
|
||||
#[serde(default)]
|
||||
pub goodies: Option<Vec<Goody>>,
|
||||
/// Editorial awards (Qobuzissime, Album of the Week, press accolades).
|
||||
#[serde(default)]
|
||||
pub awards: Option<Vec<PageArtistAward>>,
|
||||
}
|
||||
|
||||
/// A downloadable extra bundled with an album (e.g. PDF booklet)
|
||||
|
||||
@@ -230,6 +230,9 @@ pub struct Album {
|
||||
pub upc: Option<String>,
|
||||
/// Editorial description/review of the album
|
||||
pub description: Option<String>,
|
||||
/// Editorial awards attached to the album.
|
||||
#[serde(default)]
|
||||
pub awards: Option<Vec<PageArtistAward>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
@@ -390,7 +390,8 @@ export function convertQobuzAlbum(album: QobuzAlbum): AlbumDetail {
|
||||
parental_warning: track.parental_warning ?? false
|
||||
})) || [],
|
||||
upc: album.upc,
|
||||
goodies: album.goodies
|
||||
goodies: album.goodies,
|
||||
awards: album.awards?.map(a => ({ id: a.id, name: a.name, awardedAt: a.awarded_at }))
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
setCustomAlbumCover,
|
||||
removeCustomAlbumCover as removeCustomCoverFromStore
|
||||
} from '$lib/stores/customAlbumCoverStore';
|
||||
import { ArrowLeft, Play, Shuffle, Heart, Radio, CloudDownload, ChevronLeft, ChevronRight, LoaderCircle, SquareCheckBig, BookOpen } from 'lucide-svelte';
|
||||
import { ArrowLeft, Play, Shuffle, Heart, Radio, CloudDownload, ChevronLeft, ChevronRight, LoaderCircle, SquareCheckBig, BookOpen, Award, Disc3 } from 'lucide-svelte';
|
||||
import { cachedSrc } from '$lib/actions/cachedImage';
|
||||
import AlbumCard from '../AlbumCard.svelte';
|
||||
import TrackRow from '../TrackRow.svelte';
|
||||
@@ -57,6 +57,12 @@
|
||||
releaseDate?: string;
|
||||
}
|
||||
|
||||
interface Award {
|
||||
id: number;
|
||||
name: string;
|
||||
awardedAt?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
album: {
|
||||
id: string;
|
||||
@@ -74,10 +80,12 @@
|
||||
duration: string;
|
||||
tracks: Track[];
|
||||
goodies?: QobuzGoody[];
|
||||
awards?: Award[];
|
||||
};
|
||||
onBack: () => void;
|
||||
onArtistClick?: () => void;
|
||||
onLabelClick?: (labelId: number, labelName: string) => void;
|
||||
onAwardClick?: (awardId: number, awardName: string) => void;
|
||||
onTrackPlay?: (track: Track) => void;
|
||||
onTrackPlayNext?: (track: Track) => void;
|
||||
onTrackPlayLater?: (track: Track) => void;
|
||||
@@ -127,6 +135,7 @@
|
||||
onBack,
|
||||
onArtistClick,
|
||||
onLabelClick,
|
||||
onAwardClick,
|
||||
onTrackPlay,
|
||||
onTrackPlayNext,
|
||||
onTrackPlayLater,
|
||||
@@ -628,6 +637,8 @@
|
||||
<!-- Divider -->
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- Track list + right-side metadata sidebar (label + awards) -->
|
||||
<div class="track-sidebar-layout">
|
||||
<!-- Track List -->
|
||||
<div class="track-list">
|
||||
<!-- Table Header -->
|
||||
@@ -723,6 +734,51 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Album metadata sidebar (label + awards stack). Matches Qobuz web
|
||||
where the label and press accolades sit to the right of the track
|
||||
list; collapses under the tracks below 1100px. -->
|
||||
{#if (album.labelId && album.label) || (album.awards && album.awards.length > 0)}
|
||||
<aside class="album-sidebar">
|
||||
{#if album.labelId && album.label}
|
||||
<section class="sidebar-section">
|
||||
<h3 class="sidebar-section-title">{$t('album.sidebar.label')}</h3>
|
||||
<button
|
||||
class="sidebar-entity-card"
|
||||
type="button"
|
||||
onclick={() => onLabelClick?.(album.labelId!, album.label)}
|
||||
disabled={!onLabelClick}
|
||||
>
|
||||
<div class="sidebar-entity-avatar label-avatar">
|
||||
<Disc3 size={24} />
|
||||
</div>
|
||||
<div class="sidebar-entity-name">{album.label}</div>
|
||||
</button>
|
||||
</section>
|
||||
{/if}
|
||||
{#if album.awards && album.awards.length > 0}
|
||||
<section class="sidebar-section">
|
||||
<h3 class="sidebar-section-title">{$t('album.sidebar.awards')}</h3>
|
||||
<div class="sidebar-awards-list">
|
||||
{#each album.awards as award (award.id + ':' + award.name)}
|
||||
<button
|
||||
class="sidebar-entity-card"
|
||||
type="button"
|
||||
onclick={() => onAwardClick?.(award.id, award.name)}
|
||||
disabled={!onAwardClick}
|
||||
>
|
||||
<div class="sidebar-entity-avatar award-avatar">
|
||||
<Award size={22} />
|
||||
</div>
|
||||
<div class="sidebar-entity-name">{award.name}</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</aside>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- By the same artist Section -->
|
||||
{#if filteredArtistAlbums.length > 0 && !isVariousArtists}
|
||||
<div class="same-artist-section">
|
||||
@@ -1287,4 +1343,104 @@
|
||||
background: var(--border-subtle);
|
||||
margin: 4px 8px;
|
||||
}
|
||||
|
||||
/* ---- Album metadata sidebar (label + awards) ---- */
|
||||
.track-sidebar-layout {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 32px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.track-sidebar-layout .track-list {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.album-sidebar {
|
||||
flex: 0 0 280px;
|
||||
width: 280px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
.sidebar-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.sidebar-section-title {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
.sidebar-awards-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.sidebar-entity-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 10px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background-color 150ms ease;
|
||||
color: var(--text-primary);
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.sidebar-entity-card:hover:not(:disabled) {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
.sidebar-entity-card:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
.sidebar-entity-avatar {
|
||||
flex: 0 0 44px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
}
|
||||
.sidebar-entity-avatar.label-avatar {
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||
}
|
||||
.sidebar-entity-avatar.award-avatar {
|
||||
background: linear-gradient(135deg, #b45309 0%, #eab308 100%);
|
||||
}
|
||||
.sidebar-entity-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
line-height: 1.3;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Collapse sidebar under tracks on narrow windows */
|
||||
@media (max-width: 1100px) {
|
||||
.track-sidebar-layout {
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
.album-sidebar {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -320,7 +320,11 @@
|
||||
"seeFullDiscography": "Vollständige Diskografie ansehen",
|
||||
"loadError": "Albumtitel konnten nicht geladen werden. Bitte versuchen Sie es erneut.",
|
||||
"releasedOn": "am",
|
||||
"releasedBy": "Erschienen bei"
|
||||
"releasedBy": "Erschienen bei",
|
||||
"sidebar": {
|
||||
"label": "Label",
|
||||
"awards": "Auszeichnungen"
|
||||
}
|
||||
},
|
||||
"artist": {
|
||||
"albums": "Alben",
|
||||
|
||||
@@ -320,7 +320,11 @@
|
||||
"bookletRotate": "Rotate",
|
||||
"bookletDownload": "Save booklet",
|
||||
"sameArtist": "By the same artist",
|
||||
"seeFullDiscography": "See full discography"
|
||||
"seeFullDiscography": "See full discography",
|
||||
"sidebar": {
|
||||
"label": "Label",
|
||||
"awards": "Awards"
|
||||
}
|
||||
},
|
||||
"artist": {
|
||||
"albums": "Albums",
|
||||
|
||||
@@ -320,7 +320,11 @@
|
||||
"sameArtist": "Del mismo artista...",
|
||||
"seeFullDiscography": "Ver discografía completa",
|
||||
"releasedOn": "el",
|
||||
"releasedBy": "Publicado en"
|
||||
"releasedBy": "Publicado en",
|
||||
"sidebar": {
|
||||
"label": "Sello",
|
||||
"awards": "Premios"
|
||||
}
|
||||
},
|
||||
"artist": {
|
||||
"albums": "Álbumes",
|
||||
|
||||
@@ -320,7 +320,11 @@
|
||||
"seeFullDiscography": "Voir la discographie complète",
|
||||
"releasedOn": "le",
|
||||
"releasedBy": "Parution chez",
|
||||
"loadError": "Impossible de charger les pistes de l'album. Veuillez réessayer."
|
||||
"loadError": "Impossible de charger les pistes de l'album. Veuillez réessayer.",
|
||||
"sidebar": {
|
||||
"label": "Label",
|
||||
"awards": "Récompenses"
|
||||
}
|
||||
},
|
||||
"artist": {
|
||||
"albums": "Albums",
|
||||
|
||||
@@ -320,7 +320,11 @@
|
||||
"sameArtist": "Do mesmo artista",
|
||||
"seeFullDiscography": "Ver discografia completa",
|
||||
"releasedOn": "em",
|
||||
"releasedBy": "Lançado pela"
|
||||
"releasedBy": "Lançado pela",
|
||||
"sidebar": {
|
||||
"label": "Selo",
|
||||
"awards": "Prêmios"
|
||||
}
|
||||
},
|
||||
"artist": {
|
||||
"albums": "Álbuns",
|
||||
|
||||
@@ -106,6 +106,7 @@ export interface QobuzAlbum {
|
||||
parental_warning?: boolean;
|
||||
upc?: string;
|
||||
goodies?: QobuzGoody[];
|
||||
awards?: { id: number; name: string; awarded_at?: string }[];
|
||||
}
|
||||
|
||||
export interface QobuzGoody {
|
||||
@@ -180,6 +181,12 @@ export interface Track {
|
||||
parental_warning?: boolean;
|
||||
}
|
||||
|
||||
export interface AlbumAward {
|
||||
id: number;
|
||||
name: string;
|
||||
awardedAt?: string;
|
||||
}
|
||||
|
||||
export interface AlbumDetail {
|
||||
id: string;
|
||||
artwork: string;
|
||||
@@ -197,6 +204,7 @@ export interface AlbumDetail {
|
||||
tracks: Track[];
|
||||
upc?: string; // Universal Product Code for album.link sharing
|
||||
goodies?: QobuzGoody[];
|
||||
awards?: AlbumAward[];
|
||||
}
|
||||
|
||||
export interface ArtistDetail {
|
||||
|
||||
Reference in new issue
Block a user