feat(award): use /award/getAlbums for the grid + follow award as entity
Two behavior fixes for AwardView based on user feedback.
1. Grid content. The previous implementation rendered the releases
array from /award/page, which is user-scoped (only surfaces awards
on albums the user already favorited), so the grid came up empty
in almost every case. Switch to /award/getAlbums which is the
public catalog listing with pagination. /award/page is still
called, but only to hydrate the hero (name, image, magazine).
2. Follow-the-award. Awards themselves are followable on Qobuz mobile
(it's what feeds the 'Awards' tab of Release Watch). Add:
- favorite_awards table + store methods in favorites_cache.rs
- 4 V2 commands (get/sync/cache/uncache) mirroring the label ones
- awardFavoritesStore.ts — clone of labelFavoritesStore with string
ids (Qobuz mobile API passes award_ids as strings)
- Follow/Unfollow heart button in AwardView hero, matching the
ArtistDetailView / LabelView visual
- loadAwardFavorites() wired into the login bootstrap alongside
loadArtistFavorites / loadLabelFavorites
- i18n keys (award.follow / award.unfollow)
Also adds a Ver más button for paginated loading of the album grid.
cc
This commit is contained in:
11 files changed
+546
-109
No files matched your search
@@ -427,3 +427,75 @@ pub async fn v2_uncache_favorite_label(
|
||||
.remove_favorite_label(labelId)
|
||||
.map_err(|e| RuntimeError::Internal(e))
|
||||
}
|
||||
|
||||
// ============ Award favorites cache (mirrors label) ============
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn v2_get_cached_favorite_awards(
|
||||
cache_state: State<'_, crate::config::favorites_cache::FavoritesCacheState>,
|
||||
) -> Result<Vec<String>, RuntimeError> {
|
||||
let guard = cache_state
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|_| RuntimeError::Internal("Failed to lock favorites cache".to_string()))?;
|
||||
let store = guard
|
||||
.as_ref()
|
||||
.ok_or_else(|| RuntimeError::Internal("No active session".to_string()))?;
|
||||
store
|
||||
.get_favorite_award_ids()
|
||||
.map_err(|e| RuntimeError::Internal(e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[allow(non_snake_case)]
|
||||
pub async fn v2_sync_cached_favorite_awards(
|
||||
awardIds: Vec<String>,
|
||||
cache_state: State<'_, crate::config::favorites_cache::FavoritesCacheState>,
|
||||
) -> Result<(), RuntimeError> {
|
||||
let guard = cache_state
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|_| RuntimeError::Internal("Failed to lock favorites cache".to_string()))?;
|
||||
let store = guard
|
||||
.as_ref()
|
||||
.ok_or_else(|| RuntimeError::Internal("No active session".to_string()))?;
|
||||
store
|
||||
.sync_favorite_awards(&awardIds)
|
||||
.map_err(|e| RuntimeError::Internal(e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[allow(non_snake_case)]
|
||||
pub async fn v2_cache_favorite_award(
|
||||
awardId: String,
|
||||
cache_state: State<'_, crate::config::favorites_cache::FavoritesCacheState>,
|
||||
) -> Result<(), RuntimeError> {
|
||||
let guard = cache_state
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|_| RuntimeError::Internal("Failed to lock favorites cache".to_string()))?;
|
||||
let store = guard
|
||||
.as_ref()
|
||||
.ok_or_else(|| RuntimeError::Internal("No active session".to_string()))?;
|
||||
store
|
||||
.add_favorite_award(&awardId)
|
||||
.map_err(|e| RuntimeError::Internal(e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[allow(non_snake_case)]
|
||||
pub async fn v2_uncache_favorite_award(
|
||||
awardId: String,
|
||||
cache_state: State<'_, crate::config::favorites_cache::FavoritesCacheState>,
|
||||
) -> Result<(), RuntimeError> {
|
||||
let guard = cache_state
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|_| RuntimeError::Internal("Failed to lock favorites cache".to_string()))?;
|
||||
let store = guard
|
||||
.as_ref()
|
||||
.ok_or_else(|| RuntimeError::Internal("No active session".to_string()))?;
|
||||
store
|
||||
.remove_favorite_award(&awardId)
|
||||
.map_err(|e| RuntimeError::Internal(e))
|
||||
}
|
||||
@@ -95,6 +95,18 @@ impl FavoritesCacheStore {
|
||||
)
|
||||
.map_err(|e| format!("Failed to create favorite_labels table: {}", e))?;
|
||||
|
||||
// Awards — added as part of the Follow Award feature. award_id
|
||||
// is TEXT because /favorite/create?award_ids=... takes string
|
||||
// identifiers and the Android DTO declares id as String?.
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS favorite_awards (
|
||||
award_id TEXT PRIMARY KEY,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| format!("Failed to create favorite_awards table: {}", e))?;
|
||||
|
||||
Ok(Self { conn })
|
||||
}
|
||||
|
||||
@@ -382,6 +394,74 @@ impl FavoritesCacheStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============ Award favorites ============
|
||||
|
||||
pub fn get_favorite_award_ids(&self) -> Result<Vec<String>, String> {
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare("SELECT award_id FROM favorite_awards")
|
||||
.map_err(|e| format!("Failed to prepare query: {}", e))?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map([], |row| row.get::<_, String>(0))
|
||||
.map_err(|e| format!("Failed to query favorite awards: {}", e))?;
|
||||
|
||||
let mut ids = Vec::new();
|
||||
for row in rows {
|
||||
ids.push(row.map_err(|e| format!("Failed to read row: {}", e))?);
|
||||
}
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
pub fn is_award_favorite(&self, award_id: &str) -> Result<bool, String> {
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare("SELECT 1 FROM favorite_awards WHERE award_id = ?1")
|
||||
.map_err(|e| format!("Failed to prepare query: {}", e))?;
|
||||
|
||||
let exists = stmt
|
||||
.exists(params![award_id])
|
||||
.map_err(|e| format!("Failed to check favorite: {}", e))?;
|
||||
|
||||
Ok(exists)
|
||||
}
|
||||
|
||||
pub fn add_favorite_award(&self, award_id: &str) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"INSERT OR IGNORE INTO favorite_awards (award_id) VALUES (?1)",
|
||||
params![award_id],
|
||||
)
|
||||
.map_err(|e| format!("Failed to add favorite award: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn remove_favorite_award(&self, award_id: &str) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"DELETE FROM favorite_awards WHERE award_id = ?1",
|
||||
params![award_id],
|
||||
)
|
||||
.map_err(|e| format!("Failed to remove favorite award: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn sync_favorite_awards(&self, award_ids: &[String]) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute("DELETE FROM favorite_awards", [])
|
||||
.map_err(|e| format!("Failed to clear favorite awards: {}", e))?;
|
||||
|
||||
for award_id in award_ids {
|
||||
self.conn
|
||||
.execute(
|
||||
"INSERT INTO favorite_awards (award_id) VALUES (?1)",
|
||||
params![award_id],
|
||||
)
|
||||
.map_err(|e| format!("Failed to insert favorite award: {}", e))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============ Clear all (for logout) ============
|
||||
|
||||
pub fn clear_all(&self) -> Result<(), String> {
|
||||
@@ -397,6 +477,9 @@ impl FavoritesCacheStore {
|
||||
self.conn
|
||||
.execute("DELETE FROM favorite_labels", [])
|
||||
.map_err(|e| format!("Failed to clear favorite labels: {}", e))?;
|
||||
self.conn
|
||||
.execute("DELETE FROM favorite_awards", [])
|
||||
.map_err(|e| format!("Failed to clear favorite awards: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1694,6 +1694,10 @@ pub fn run() {
|
||||
commands_v2::v2_sync_cached_favorite_labels,
|
||||
commands_v2::v2_cache_favorite_label,
|
||||
commands_v2::v2_uncache_favorite_label,
|
||||
commands_v2::v2_get_cached_favorite_awards,
|
||||
commands_v2::v2_sync_cached_favorite_awards,
|
||||
commands_v2::v2_cache_favorite_award,
|
||||
commands_v2::v2_uncache_favorite_award,
|
||||
commands_v2::v2_share_track_songlink,
|
||||
commands_v2::v2_share_album_songlink,
|
||||
commands_v2::v2_library_backfill_downloads,
|
||||
|
||||
@@ -5,14 +5,19 @@
|
||||
* hero info and uses the embedded releases arrays for the album
|
||||
* grids. Matches LabelView's overall structure.
|
||||
*/
|
||||
import { onMount } from 'svelte';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { t } from '$lib/i18n';
|
||||
import { ArrowLeft, Award as AwardIcon, LoaderCircle } from 'lucide-svelte';
|
||||
import { ArrowLeft, Award as AwardIcon, Heart, LoaderCircle } from 'lucide-svelte';
|
||||
import AlbumCard from '../AlbumCard.svelte';
|
||||
import HorizontalScrollRow from '../HorizontalScrollRow.svelte';
|
||||
import type { AwardPageData, QobuzAlbum } from '$lib/types';
|
||||
import { formatQuality } from '$lib/adapters/qobuzAdapters';
|
||||
import { formatQuality, getQobuzImage } from '$lib/adapters/qobuzAdapters';
|
||||
import {
|
||||
subscribe as subscribeAwardFavorites,
|
||||
isAwardFavorite,
|
||||
isAwardToggling,
|
||||
toggleAwardFavorite,
|
||||
} from '$lib/stores/awardFavoritesStore';
|
||||
|
||||
interface AlbumCardData {
|
||||
id: string;
|
||||
@@ -25,11 +30,6 @@
|
||||
releaseDate?: string;
|
||||
}
|
||||
|
||||
interface ReleasesSection {
|
||||
id: string;
|
||||
items: AlbumCardData[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
awardId: string;
|
||||
awardName?: string;
|
||||
@@ -68,82 +68,109 @@
|
||||
onArtistClick,
|
||||
}: Props = $props();
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
let page = $state<AwardPageData | null>(null);
|
||||
let sections = $state<ReleasesSection[]>([]);
|
||||
let albums = $state<AlbumCardData[]>([]);
|
||||
let offset = $state(0);
|
||||
let totalEstimate = $state<number | null>(null);
|
||||
let hasMore = $state(true);
|
||||
let loading = $state(true);
|
||||
let loadingMore = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let heroImageFailed = $state(false);
|
||||
|
||||
function toAlbumCard(item: Record<string, unknown>): AlbumCardData | null {
|
||||
if (!item || typeof item !== 'object') return null;
|
||||
const id = (item.id as string) ?? '';
|
||||
if (!id) return null;
|
||||
const image = item.image as { small?: string; thumbnail?: string; large?: string } | undefined;
|
||||
const artists = (item.artists as { id: number; name: string }[]) ?? [];
|
||||
const artist = (item.artist as { id?: number; name?: string }) ?? artists[0] ?? {};
|
||||
const genre = item.genre as { name?: string } | undefined;
|
||||
const audioInfo = item.audio_info as { maximum_bit_depth?: number; maximum_sampling_rate?: number } | undefined;
|
||||
const dates = item.dates as { original?: string } | undefined;
|
||||
const bitDepth = audioInfo?.maximum_bit_depth ?? 16;
|
||||
const quality = formatQuality(
|
||||
bitDepth > 16,
|
||||
audioInfo?.maximum_bit_depth,
|
||||
audioInfo?.maximum_sampling_rate
|
||||
);
|
||||
/** Adapts a QobuzAlbum (shape returned by /award/getAlbums) into
|
||||
* what AlbumCard consumes. */
|
||||
function qobuzToCard(album: QobuzAlbum): AlbumCardData {
|
||||
const hires = (album.maximum_bit_depth ?? 16) > 16;
|
||||
return {
|
||||
id: String(id),
|
||||
artwork: image?.large || image?.small || image?.thumbnail || '',
|
||||
title: (item.title as string) ?? '',
|
||||
artist: artist?.name ?? 'Unknown Artist',
|
||||
artistId: artist?.id,
|
||||
genre: genre?.name ?? '',
|
||||
quality,
|
||||
releaseDate: dates?.original,
|
||||
id: album.id,
|
||||
artwork: getQobuzImage(album.image),
|
||||
title: album.title,
|
||||
artist: album.artist?.name ?? 'Unknown Artist',
|
||||
artistId: album.artist?.id,
|
||||
genre: album.genre?.name ?? '',
|
||||
quality: formatQuality(hires, album.maximum_bit_depth, album.maximum_sampling_rate),
|
||||
releaseDate: album.release_date_original,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadPage() {
|
||||
loading = true;
|
||||
error = null;
|
||||
async function loadHero() {
|
||||
try {
|
||||
// /award/page is user-scoped (only returns releases from awards
|
||||
// the user has favorited) so we only use it to hydrate the hero
|
||||
// — name, image, magazine. The album grid below fills from
|
||||
// /award/getAlbums which is the public catalog listing.
|
||||
const data = await invoke<AwardPageData>('v2_get_award_page', { awardId });
|
||||
page = data;
|
||||
|
||||
const builtSections: ReleasesSection[] = [];
|
||||
for (const container of data.releases ?? []) {
|
||||
const items = (container.data?.items ?? [])
|
||||
.map(toAlbumCard)
|
||||
.filter((a): a is AlbumCardData => a !== null);
|
||||
if (items.length > 0) {
|
||||
builtSections.push({
|
||||
id: container.id ?? 'releases',
|
||||
items,
|
||||
});
|
||||
}
|
||||
}
|
||||
sections = builtSections;
|
||||
} catch (err) {
|
||||
console.error('[AwardView] failed to load:', err);
|
||||
error = String(err);
|
||||
} finally {
|
||||
loading = false;
|
||||
console.warn('[AwardView] /award/page failed, falling back to passed name:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAlbums(reset: boolean) {
|
||||
if (reset) {
|
||||
offset = 0;
|
||||
albums = [];
|
||||
hasMore = true;
|
||||
totalEstimate = null;
|
||||
loading = true;
|
||||
} else {
|
||||
loadingMore = true;
|
||||
}
|
||||
try {
|
||||
const result = await invoke<{ items: QobuzAlbum[]; total: number; offset: number; limit: number }>(
|
||||
'v2_get_award_albums',
|
||||
{ awardId, limit: PAGE_SIZE, offset }
|
||||
);
|
||||
const mapped = (result.items ?? []).map(qobuzToCard);
|
||||
albums = reset ? mapped : [...albums, ...mapped];
|
||||
offset = albums.length;
|
||||
totalEstimate = result.total;
|
||||
hasMore = mapped.length >= PAGE_SIZE && offset < result.total;
|
||||
} catch (err) {
|
||||
console.error('[AwardView] failed to load albums:', err);
|
||||
error = String(err);
|
||||
} finally {
|
||||
loading = false;
|
||||
loadingMore = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleLoadMore() {
|
||||
if (!loading && !loadingMore && hasMore) loadAlbums(false);
|
||||
}
|
||||
|
||||
// Subscribe to the favorites store so the Follow button's visual
|
||||
// state updates in real time (and from other views that may mutate it).
|
||||
let awardFavoritesVersion = $state(0);
|
||||
const unsubAwardFavorites = subscribeAwardFavorites(() => {
|
||||
awardFavoritesVersion += 1;
|
||||
});
|
||||
onDestroy(() => unsubAwardFavorites());
|
||||
|
||||
const awardIsFavorite = $derived.by(() => {
|
||||
void awardFavoritesVersion;
|
||||
return isAwardFavorite(awardId);
|
||||
});
|
||||
const awardIsToggling = $derived.by(() => {
|
||||
void awardFavoritesVersion;
|
||||
return isAwardToggling(awardId);
|
||||
});
|
||||
|
||||
async function handleToggleAwardFavorite() {
|
||||
await toggleAwardFavorite(awardId);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadPage();
|
||||
loadHero();
|
||||
loadAlbums(true);
|
||||
});
|
||||
|
||||
const displayName = $derived(page?.name ?? awardName ?? '');
|
||||
const magazineName = $derived(page?.magazine?.name ?? '');
|
||||
const heroImage = $derived(page?.image || page?.magazine?.image || '');
|
||||
|
||||
function getContainerTitle(id: string): string {
|
||||
const key = `award.section.${id}`;
|
||||
const translated = $t(key);
|
||||
if (translated && !translated.startsWith('award.section.')) return translated;
|
||||
return $t('award.section.releases');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="award-detail-view">
|
||||
@@ -175,10 +202,34 @@
|
||||
{#if magazineName}
|
||||
<div class="award-magazine">{magazineName}</div>
|
||||
{/if}
|
||||
|
||||
<div class="award-actions">
|
||||
<button
|
||||
class="favorite-btn"
|
||||
class:is-favorite={awardIsFavorite}
|
||||
onclick={handleToggleAwardFavorite}
|
||||
disabled={awardIsToggling}
|
||||
title={awardIsFavorite ? $t('award.unfollow') : $t('award.follow')}
|
||||
aria-label={awardIsFavorite ? $t('award.unfollow') : $t('award.follow')}
|
||||
>
|
||||
{#if awardIsFavorite}
|
||||
<Heart size={24} fill="var(--accent-primary)" color="var(--accent-primary)" />
|
||||
{:else}
|
||||
<Heart size={24} />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="content">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">{$t('award.section.releases')}</h2>
|
||||
{#if totalEstimate}
|
||||
<span class="section-count">{totalEstimate}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="loading">
|
||||
<LoaderCircle size={28} class="spinner" />
|
||||
@@ -188,48 +239,51 @@
|
||||
<div class="error">
|
||||
<p>{$t('favorites.failedLoadFavorites')}</p>
|
||||
<p class="error-detail">{error}</p>
|
||||
<button class="retry-btn" onclick={loadPage}>{$t('actions.retry')}</button>
|
||||
<button class="retry-btn" onclick={() => loadAlbums(true)}>{$t('actions.retry')}</button>
|
||||
</div>
|
||||
{:else if sections.length === 0}
|
||||
{:else if albums.length === 0}
|
||||
<div class="empty">
|
||||
<p>{$t('award.empty')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#each sections as section (section.id)}
|
||||
<section class="rail">
|
||||
<HorizontalScrollRow title={getContainerTitle(section.id)}>
|
||||
{#snippet children()}
|
||||
{#each section.items as album (album.id)}
|
||||
<AlbumCard
|
||||
albumId={album.id}
|
||||
artwork={album.artwork}
|
||||
title={album.title}
|
||||
artist={album.artist}
|
||||
artistId={album.artistId}
|
||||
onArtistClick={onArtistClick}
|
||||
genre={album.genre}
|
||||
releaseDate={album.releaseDate}
|
||||
size="large"
|
||||
quality={album.quality}
|
||||
onPlay={onAlbumPlay ? () => onAlbumPlay(album.id) : undefined}
|
||||
onPlayNext={onAlbumPlayNext ? () => onAlbumPlayNext(album.id) : undefined}
|
||||
onPlayLater={onAlbumPlayLater ? () => onAlbumPlayLater(album.id) : undefined}
|
||||
onAddAlbumToPlaylist={onAddAlbumToPlaylist ? () => onAddAlbumToPlaylist(album.id) : undefined}
|
||||
onShareQobuz={onAlbumShareQobuz ? () => onAlbumShareQobuz(album.id) : undefined}
|
||||
onShareSonglink={onAlbumShareSonglink ? () => onAlbumShareSonglink(album.id) : undefined}
|
||||
onDownload={onAlbumDownload ? () => onAlbumDownload(album.id) : undefined}
|
||||
isAlbumFullyDownloaded={isAlbumDownloaded?.(album.id) ?? false}
|
||||
onOpenContainingFolder={onOpenAlbumFolder ? () => onOpenAlbumFolder(album.id) : undefined}
|
||||
onReDownloadAlbum={onReDownloadAlbum ? () => onReDownloadAlbum(album.id) : undefined}
|
||||
{downloadStateVersion}
|
||||
onclick={() => onAlbumClick?.(album.id)}
|
||||
/>
|
||||
{/each}
|
||||
<div class="spacer"></div>
|
||||
{/snippet}
|
||||
</HorizontalScrollRow>
|
||||
</section>
|
||||
{/each}
|
||||
<div class="album-grid">
|
||||
{#each albums as album (album.id)}
|
||||
<AlbumCard
|
||||
albumId={album.id}
|
||||
artwork={album.artwork}
|
||||
title={album.title}
|
||||
artist={album.artist}
|
||||
artistId={album.artistId}
|
||||
onArtistClick={onArtistClick}
|
||||
genre={album.genre}
|
||||
releaseDate={album.releaseDate}
|
||||
size="large"
|
||||
quality={album.quality}
|
||||
onPlay={onAlbumPlay ? () => onAlbumPlay(album.id) : undefined}
|
||||
onPlayNext={onAlbumPlayNext ? () => onAlbumPlayNext(album.id) : undefined}
|
||||
onPlayLater={onAlbumPlayLater ? () => onAlbumPlayLater(album.id) : undefined}
|
||||
onAddAlbumToPlaylist={onAddAlbumToPlaylist ? () => onAddAlbumToPlaylist(album.id) : undefined}
|
||||
onShareQobuz={onAlbumShareQobuz ? () => onAlbumShareQobuz(album.id) : undefined}
|
||||
onShareSonglink={onAlbumShareSonglink ? () => onAlbumShareSonglink(album.id) : undefined}
|
||||
onDownload={onAlbumDownload ? () => onAlbumDownload(album.id) : undefined}
|
||||
isAlbumFullyDownloaded={isAlbumDownloaded?.(album.id) ?? false}
|
||||
onOpenContainingFolder={onOpenAlbumFolder ? () => onOpenAlbumFolder(album.id) : undefined}
|
||||
onReDownloadAlbum={onReDownloadAlbum ? () => onReDownloadAlbum(album.id) : undefined}
|
||||
{downloadStateVersion}
|
||||
onclick={() => onAlbumClick?.(album.id)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{#if hasMore}
|
||||
<div class="load-more-wrapper">
|
||||
<button class="load-more-btn" onclick={handleLoadMore} disabled={loadingMore}>
|
||||
{#if loadingMore}
|
||||
<LoaderCircle size={16} class="spinner" />
|
||||
{/if}
|
||||
<span>{loadingMore ? $t('album.loading') : $t('home.seeMore')}</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
@@ -288,12 +342,69 @@
|
||||
font-size: 14px; color: var(--text-secondary); line-height: 1.4;
|
||||
}
|
||||
|
||||
.content {
|
||||
/* Follow button — matches LabelView / ArtistDetailView visual */
|
||||
.award-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 48px;
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.rail { display: flex; flex-direction: column; }
|
||||
.favorite-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
background: var(--bg-tertiary);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
transition: color 150ms ease, background-color 150ms ease, opacity 150ms ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.favorite-btn:hover:not(:disabled) {
|
||||
background: var(--bg-hover);
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
.favorite-btn.is-favorite {
|
||||
background: rgba(var(--accent-primary-rgb, 139, 92, 246), 0.15);
|
||||
}
|
||||
.favorite-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.content { display: flex; flex-direction: column; gap: 20px; }
|
||||
.section-header {
|
||||
display: flex; align-items: baseline; gap: 12px;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 20px; font-weight: 600; color: var(--text-primary); margin: 0;
|
||||
}
|
||||
.section-count {
|
||||
font-size: 12px; color: var(--text-muted);
|
||||
}
|
||||
.album-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 24px 16px;
|
||||
}
|
||||
.load-more-wrapper {
|
||||
display: flex; justify-content: center; margin-top: 8px;
|
||||
}
|
||||
.load-more-btn {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
padding: 8px 20px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms ease;
|
||||
}
|
||||
.load-more-btn:hover:not(:disabled) { background: var(--bg-secondary); }
|
||||
.load-more-btn:disabled { opacity: 0.6; cursor: wait; }
|
||||
|
||||
.loading,
|
||||
.error,
|
||||
|
||||
@@ -2638,6 +2638,8 @@
|
||||
"releases": "Gefeierte Veröffentlichungen",
|
||||
"new-releases": "Neuerscheinungen",
|
||||
"classics": "Klassiker"
|
||||
}
|
||||
},
|
||||
"follow": "Auszeichnung folgen",
|
||||
"unfollow": "Nicht mehr folgen"
|
||||
}
|
||||
}
|
||||
@@ -2638,6 +2638,8 @@
|
||||
"releases": "Acclaimed Releases",
|
||||
"new-releases": "New Releases",
|
||||
"classics": "Classics"
|
||||
}
|
||||
},
|
||||
"follow": "Follow award",
|
||||
"unfollow": "Unfollow award"
|
||||
}
|
||||
}
|
||||
@@ -2638,6 +2638,8 @@
|
||||
"releases": "Lanzamientos aclamados",
|
||||
"new-releases": "Novedades",
|
||||
"classics": "Clásicos"
|
||||
}
|
||||
},
|
||||
"follow": "Seguir premio",
|
||||
"unfollow": "Dejar de seguir"
|
||||
}
|
||||
}
|
||||
@@ -2638,6 +2638,8 @@
|
||||
"releases": "Sorties saluées",
|
||||
"new-releases": "Nouveautés",
|
||||
"classics": "Classiques"
|
||||
}
|
||||
},
|
||||
"follow": "Suivre la récompense",
|
||||
"unfollow": "Ne plus suivre"
|
||||
}
|
||||
}
|
||||
@@ -2638,6 +2638,8 @@
|
||||
"releases": "Lançamentos aclamados",
|
||||
"new-releases": "Novidades",
|
||||
"classics": "Clássicos"
|
||||
}
|
||||
},
|
||||
"follow": "Seguir prêmio",
|
||||
"unfollow": "Deixar de seguir"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Award Favorites Store
|
||||
*
|
||||
* Centralized store for tracking favorite (followed) awards. Clone of
|
||||
* labelFavoritesStore — same SQLite-backed cache, same sync strategy,
|
||||
* same toggle flow. Award ids come through as strings (the Qobuz
|
||||
* mobile API stringifies them on favorite/create?award_ids=).
|
||||
*
|
||||
* This is what feeds the "Awards" tab in Release Watch — the set of
|
||||
* awards the user is following is used to fetch
|
||||
* /favorite/getNewReleases?type=awards.
|
||||
*/
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { skipIfRemote } from '$lib/services/commandRouter';
|
||||
|
||||
let favoriteAwardIds = new Set<string>();
|
||||
let togglingAwardIds = new Set<string>();
|
||||
let isLoaded = false;
|
||||
let isLoading = false;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function notifyListeners(): void {
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
export function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
export function isAwardFavorite(awardId: string | number): boolean {
|
||||
return favoriteAwardIds.has(String(awardId));
|
||||
}
|
||||
|
||||
export function isAwardToggling(awardId: string | number): boolean {
|
||||
return togglingAwardIds.has(String(awardId));
|
||||
}
|
||||
|
||||
export function getFavoriteAwardIds(): string[] {
|
||||
return Array.from(favoriteAwardIds);
|
||||
}
|
||||
|
||||
export function isFavoritesLoaded(): boolean {
|
||||
return isLoaded;
|
||||
}
|
||||
|
||||
export async function loadAwardFavorites(): Promise<void> {
|
||||
if (skipIfRemote()) return;
|
||||
if (isLoading) return;
|
||||
|
||||
isLoading = true;
|
||||
try {
|
||||
try {
|
||||
const cachedIds = await invoke<string[]>('v2_get_cached_favorite_awards');
|
||||
if (cachedIds.length > 0) {
|
||||
favoriteAwardIds = new Set(cachedIds);
|
||||
isLoaded = true;
|
||||
notifyListeners();
|
||||
console.log(`[AwardFavorites] Loaded ${cachedIds.length} awards from local cache`);
|
||||
}
|
||||
} catch (cacheErr) {
|
||||
console.debug('[AwardFavorites] No local cache available:', cacheErr);
|
||||
}
|
||||
|
||||
await syncFromApi();
|
||||
} catch (err) {
|
||||
console.error('[AwardFavorites] Failed to load favorites:', err);
|
||||
if (!isLoaded) {
|
||||
favoriteAwardIds = new Set();
|
||||
isLoaded = true;
|
||||
notifyListeners();
|
||||
}
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncFromApi(): Promise<void> {
|
||||
if (skipIfRemote()) return;
|
||||
try {
|
||||
const allAwardIds: string[] = [];
|
||||
let offset = 0;
|
||||
const limit = 500;
|
||||
|
||||
while (true) {
|
||||
const result = await invoke<{ awards?: { items: Array<{ id: string | number }>; total?: number } }>(
|
||||
'v2_get_favorites',
|
||||
{ favType: 'awards', limit, offset }
|
||||
);
|
||||
|
||||
const items = result.awards?.items ?? [];
|
||||
if (!items.length) break;
|
||||
|
||||
allAwardIds.push(...items.map(item => String(item.id)));
|
||||
offset += items.length;
|
||||
|
||||
if (items.length < limit) break;
|
||||
if (result.awards?.total && offset >= result.awards.total) break;
|
||||
}
|
||||
|
||||
await invoke('v2_sync_cached_favorite_awards', { awardIds: allAwardIds });
|
||||
|
||||
favoriteAwardIds = new Set(allAwardIds);
|
||||
isLoaded = true;
|
||||
notifyListeners();
|
||||
|
||||
console.log(`[AwardFavorites] Synced ${allAwardIds.length} awards from API`);
|
||||
} catch (err) {
|
||||
console.error('[AwardFavorites] Failed to sync from API:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function toggleAwardFavorite(awardId: string | number): Promise<boolean> {
|
||||
const key = String(awardId);
|
||||
if (skipIfRemote()) return favoriteAwardIds.has(key);
|
||||
if (togglingAwardIds.has(key)) return favoriteAwardIds.has(key);
|
||||
|
||||
const wasFavorite = favoriteAwardIds.has(key);
|
||||
const newState = !wasFavorite;
|
||||
|
||||
togglingAwardIds.add(key);
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
if (newState) {
|
||||
await invoke('v2_add_favorite', { favType: 'award', itemId: key });
|
||||
await invoke('v2_cache_favorite_award', { awardId: key });
|
||||
favoriteAwardIds.add(key);
|
||||
} else {
|
||||
await invoke('v2_remove_favorite', { favType: 'award', itemId: key });
|
||||
await invoke('v2_uncache_favorite_award', { awardId: key });
|
||||
favoriteAwardIds.delete(key);
|
||||
}
|
||||
return newState;
|
||||
} catch (err) {
|
||||
console.error('[AwardFavorites] Failed to toggle favorite:', err);
|
||||
return wasFavorite;
|
||||
} finally {
|
||||
togglingAwardIds.delete(key);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncCache(awardIds: string[]): Promise<void> {
|
||||
if (skipIfRemote()) return;
|
||||
try {
|
||||
await invoke('v2_sync_cached_favorite_awards', { awardIds });
|
||||
favoriteAwardIds = new Set(awardIds);
|
||||
notifyListeners();
|
||||
} catch (err) {
|
||||
console.error('[AwardFavorites] Failed to sync cache:', err);
|
||||
}
|
||||
}
|
||||
@@ -154,6 +154,7 @@
|
||||
import { loadAlbumFavorites } from '$lib/stores/albumFavoritesStore';
|
||||
import { loadArtistFavorites } from '$lib/stores/artistFavoritesStore';
|
||||
import { loadLabelFavorites } from '$lib/stores/labelFavoritesStore';
|
||||
import { loadAwardFavorites } from '$lib/stores/awardFavoritesStore';
|
||||
import { getDefaultFavoritesTab } from '$lib/utils/favorites';
|
||||
import { platform } from '$lib/utils/platform';
|
||||
import type { FavoritesPreferences, ResolvedMusician } from '$lib/types';
|
||||
@@ -3679,6 +3680,7 @@
|
||||
loadAlbumFavorites(); // Album favorites
|
||||
loadArtistFavorites(); // Artist favorites
|
||||
loadLabelFavorites(); // Label favorites
|
||||
loadAwardFavorites(); // Award favorites
|
||||
|
||||
// Refresh offline status now that we're logged in
|
||||
refreshOfflineStatus().catch(err => console.debug('[Offline] Status refresh deferred:', err));
|
||||
|
||||
Reference in new issue
Block a user