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)
|
.remove_favorite_label(labelId)
|
||||||
.map_err(|e| RuntimeError::Internal(e))
|
.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))?;
|
.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 })
|
Ok(Self { conn })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,6 +394,74 @@ impl FavoritesCacheStore {
|
|||||||
Ok(())
|
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) ============
|
// ============ Clear all (for logout) ============
|
||||||
|
|
||||||
pub fn clear_all(&self) -> Result<(), String> {
|
pub fn clear_all(&self) -> Result<(), String> {
|
||||||
@@ -397,6 +477,9 @@ impl FavoritesCacheStore {
|
|||||||
self.conn
|
self.conn
|
||||||
.execute("DELETE FROM favorite_labels", [])
|
.execute("DELETE FROM favorite_labels", [])
|
||||||
.map_err(|e| format!("Failed to clear favorite labels: {}", e))?;
|
.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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1694,6 +1694,10 @@ pub fn run() {
|
|||||||
commands_v2::v2_sync_cached_favorite_labels,
|
commands_v2::v2_sync_cached_favorite_labels,
|
||||||
commands_v2::v2_cache_favorite_label,
|
commands_v2::v2_cache_favorite_label,
|
||||||
commands_v2::v2_uncache_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_track_songlink,
|
||||||
commands_v2::v2_share_album_songlink,
|
commands_v2::v2_share_album_songlink,
|
||||||
commands_v2::v2_library_backfill_downloads,
|
commands_v2::v2_library_backfill_downloads,
|
||||||
|
|||||||
@@ -5,14 +5,19 @@
|
|||||||
* hero info and uses the embedded releases arrays for the album
|
* hero info and uses the embedded releases arrays for the album
|
||||||
* grids. Matches LabelView's overall structure.
|
* grids. Matches LabelView's overall structure.
|
||||||
*/
|
*/
|
||||||
import { onMount } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { t } from '$lib/i18n';
|
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 AlbumCard from '../AlbumCard.svelte';
|
||||||
import HorizontalScrollRow from '../HorizontalScrollRow.svelte';
|
|
||||||
import type { AwardPageData, QobuzAlbum } from '$lib/types';
|
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 {
|
interface AlbumCardData {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -25,11 +30,6 @@
|
|||||||
releaseDate?: string;
|
releaseDate?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ReleasesSection {
|
|
||||||
id: string;
|
|
||||||
items: AlbumCardData[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
awardId: string;
|
awardId: string;
|
||||||
awardName?: string;
|
awardName?: string;
|
||||||
@@ -68,82 +68,109 @@
|
|||||||
onArtistClick,
|
onArtistClick,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
let page = $state<AwardPageData | null>(null);
|
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 loading = $state(true);
|
||||||
|
let loadingMore = $state(false);
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
let heroImageFailed = $state(false);
|
let heroImageFailed = $state(false);
|
||||||
|
|
||||||
function toAlbumCard(item: Record<string, unknown>): AlbumCardData | null {
|
/** Adapts a QobuzAlbum (shape returned by /award/getAlbums) into
|
||||||
if (!item || typeof item !== 'object') return null;
|
* what AlbumCard consumes. */
|
||||||
const id = (item.id as string) ?? '';
|
function qobuzToCard(album: QobuzAlbum): AlbumCardData {
|
||||||
if (!id) return null;
|
const hires = (album.maximum_bit_depth ?? 16) > 16;
|
||||||
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
|
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
id: String(id),
|
id: album.id,
|
||||||
artwork: image?.large || image?.small || image?.thumbnail || '',
|
artwork: getQobuzImage(album.image),
|
||||||
title: (item.title as string) ?? '',
|
title: album.title,
|
||||||
artist: artist?.name ?? 'Unknown Artist',
|
artist: album.artist?.name ?? 'Unknown Artist',
|
||||||
artistId: artist?.id,
|
artistId: album.artist?.id,
|
||||||
genre: genre?.name ?? '',
|
genre: album.genre?.name ?? '',
|
||||||
quality,
|
quality: formatQuality(hires, album.maximum_bit_depth, album.maximum_sampling_rate),
|
||||||
releaseDate: dates?.original,
|
releaseDate: album.release_date_original,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadPage() {
|
async function loadHero() {
|
||||||
loading = true;
|
|
||||||
error = null;
|
|
||||||
try {
|
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 });
|
const data = await invoke<AwardPageData>('v2_get_award_page', { awardId });
|
||||||
page = data;
|
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) {
|
} catch (err) {
|
||||||
console.error('[AwardView] failed to load:', err);
|
console.warn('[AwardView] /award/page failed, falling back to passed name:', err);
|
||||||
error = String(err);
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(() => {
|
onMount(() => {
|
||||||
loadPage();
|
loadHero();
|
||||||
|
loadAlbums(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
const displayName = $derived(page?.name ?? awardName ?? '');
|
const displayName = $derived(page?.name ?? awardName ?? '');
|
||||||
const magazineName = $derived(page?.magazine?.name ?? '');
|
const magazineName = $derived(page?.magazine?.name ?? '');
|
||||||
const heroImage = $derived(page?.image || page?.magazine?.image || '');
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="award-detail-view">
|
<div class="award-detail-view">
|
||||||
@@ -175,10 +202,34 @@
|
|||||||
{#if magazineName}
|
{#if magazineName}
|
||||||
<div class="award-magazine">{magazineName}</div>
|
<div class="award-magazine">{magazineName}</div>
|
||||||
{/if}
|
{/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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="content">
|
<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}
|
{#if loading}
|
||||||
<div class="loading">
|
<div class="loading">
|
||||||
<LoaderCircle size={28} class="spinner" />
|
<LoaderCircle size={28} class="spinner" />
|
||||||
@@ -188,48 +239,51 @@
|
|||||||
<div class="error">
|
<div class="error">
|
||||||
<p>{$t('favorites.failedLoadFavorites')}</p>
|
<p>{$t('favorites.failedLoadFavorites')}</p>
|
||||||
<p class="error-detail">{error}</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>
|
</div>
|
||||||
{:else if sections.length === 0}
|
{:else if albums.length === 0}
|
||||||
<div class="empty">
|
<div class="empty">
|
||||||
<p>{$t('award.empty')}</p>
|
<p>{$t('award.empty')}</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
{#each sections as section (section.id)}
|
<div class="album-grid">
|
||||||
<section class="rail">
|
{#each albums as album (album.id)}
|
||||||
<HorizontalScrollRow title={getContainerTitle(section.id)}>
|
<AlbumCard
|
||||||
{#snippet children()}
|
albumId={album.id}
|
||||||
{#each section.items as album (album.id)}
|
artwork={album.artwork}
|
||||||
<AlbumCard
|
title={album.title}
|
||||||
albumId={album.id}
|
artist={album.artist}
|
||||||
artwork={album.artwork}
|
artistId={album.artistId}
|
||||||
title={album.title}
|
onArtistClick={onArtistClick}
|
||||||
artist={album.artist}
|
genre={album.genre}
|
||||||
artistId={album.artistId}
|
releaseDate={album.releaseDate}
|
||||||
onArtistClick={onArtistClick}
|
size="large"
|
||||||
genre={album.genre}
|
quality={album.quality}
|
||||||
releaseDate={album.releaseDate}
|
onPlay={onAlbumPlay ? () => onAlbumPlay(album.id) : undefined}
|
||||||
size="large"
|
onPlayNext={onAlbumPlayNext ? () => onAlbumPlayNext(album.id) : undefined}
|
||||||
quality={album.quality}
|
onPlayLater={onAlbumPlayLater ? () => onAlbumPlayLater(album.id) : undefined}
|
||||||
onPlay={onAlbumPlay ? () => onAlbumPlay(album.id) : undefined}
|
onAddAlbumToPlaylist={onAddAlbumToPlaylist ? () => onAddAlbumToPlaylist(album.id) : undefined}
|
||||||
onPlayNext={onAlbumPlayNext ? () => onAlbumPlayNext(album.id) : undefined}
|
onShareQobuz={onAlbumShareQobuz ? () => onAlbumShareQobuz(album.id) : undefined}
|
||||||
onPlayLater={onAlbumPlayLater ? () => onAlbumPlayLater(album.id) : undefined}
|
onShareSonglink={onAlbumShareSonglink ? () => onAlbumShareSonglink(album.id) : undefined}
|
||||||
onAddAlbumToPlaylist={onAddAlbumToPlaylist ? () => onAddAlbumToPlaylist(album.id) : undefined}
|
onDownload={onAlbumDownload ? () => onAlbumDownload(album.id) : undefined}
|
||||||
onShareQobuz={onAlbumShareQobuz ? () => onAlbumShareQobuz(album.id) : undefined}
|
isAlbumFullyDownloaded={isAlbumDownloaded?.(album.id) ?? false}
|
||||||
onShareSonglink={onAlbumShareSonglink ? () => onAlbumShareSonglink(album.id) : undefined}
|
onOpenContainingFolder={onOpenAlbumFolder ? () => onOpenAlbumFolder(album.id) : undefined}
|
||||||
onDownload={onAlbumDownload ? () => onAlbumDownload(album.id) : undefined}
|
onReDownloadAlbum={onReDownloadAlbum ? () => onReDownloadAlbum(album.id) : undefined}
|
||||||
isAlbumFullyDownloaded={isAlbumDownloaded?.(album.id) ?? false}
|
{downloadStateVersion}
|
||||||
onOpenContainingFolder={onOpenAlbumFolder ? () => onOpenAlbumFolder(album.id) : undefined}
|
onclick={() => onAlbumClick?.(album.id)}
|
||||||
onReDownloadAlbum={onReDownloadAlbum ? () => onReDownloadAlbum(album.id) : undefined}
|
/>
|
||||||
{downloadStateVersion}
|
{/each}
|
||||||
onclick={() => onAlbumClick?.(album.id)}
|
</div>
|
||||||
/>
|
{#if hasMore}
|
||||||
{/each}
|
<div class="load-more-wrapper">
|
||||||
<div class="spacer"></div>
|
<button class="load-more-btn" onclick={handleLoadMore} disabled={loadingMore}>
|
||||||
{/snippet}
|
{#if loadingMore}
|
||||||
</HorizontalScrollRow>
|
<LoaderCircle size={16} class="spinner" />
|
||||||
</section>
|
{/if}
|
||||||
{/each}
|
<span>{loadingMore ? $t('album.loading') : $t('home.seeMore')}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
@@ -288,12 +342,69 @@
|
|||||||
font-size: 14px; color: var(--text-secondary); line-height: 1.4;
|
font-size: 14px; color: var(--text-secondary); line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
/* Follow button — matches LabelView / ArtistDetailView visual */
|
||||||
|
.award-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
gap: 12px;
|
||||||
gap: 48px;
|
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,
|
.loading,
|
||||||
.error,
|
.error,
|
||||||
|
|||||||
@@ -2638,6 +2638,8 @@
|
|||||||
"releases": "Gefeierte Veröffentlichungen",
|
"releases": "Gefeierte Veröffentlichungen",
|
||||||
"new-releases": "Neuerscheinungen",
|
"new-releases": "Neuerscheinungen",
|
||||||
"classics": "Klassiker"
|
"classics": "Klassiker"
|
||||||
}
|
},
|
||||||
|
"follow": "Auszeichnung folgen",
|
||||||
|
"unfollow": "Nicht mehr folgen"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2638,6 +2638,8 @@
|
|||||||
"releases": "Acclaimed Releases",
|
"releases": "Acclaimed Releases",
|
||||||
"new-releases": "New Releases",
|
"new-releases": "New Releases",
|
||||||
"classics": "Classics"
|
"classics": "Classics"
|
||||||
}
|
},
|
||||||
|
"follow": "Follow award",
|
||||||
|
"unfollow": "Unfollow award"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2638,6 +2638,8 @@
|
|||||||
"releases": "Lanzamientos aclamados",
|
"releases": "Lanzamientos aclamados",
|
||||||
"new-releases": "Novedades",
|
"new-releases": "Novedades",
|
||||||
"classics": "Clásicos"
|
"classics": "Clásicos"
|
||||||
}
|
},
|
||||||
|
"follow": "Seguir premio",
|
||||||
|
"unfollow": "Dejar de seguir"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2638,6 +2638,8 @@
|
|||||||
"releases": "Sorties saluées",
|
"releases": "Sorties saluées",
|
||||||
"new-releases": "Nouveautés",
|
"new-releases": "Nouveautés",
|
||||||
"classics": "Classiques"
|
"classics": "Classiques"
|
||||||
}
|
},
|
||||||
|
"follow": "Suivre la récompense",
|
||||||
|
"unfollow": "Ne plus suivre"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2638,6 +2638,8 @@
|
|||||||
"releases": "Lançamentos aclamados",
|
"releases": "Lançamentos aclamados",
|
||||||
"new-releases": "Novidades",
|
"new-releases": "Novidades",
|
||||||
"classics": "Clássicos"
|
"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 { loadAlbumFavorites } from '$lib/stores/albumFavoritesStore';
|
||||||
import { loadArtistFavorites } from '$lib/stores/artistFavoritesStore';
|
import { loadArtistFavorites } from '$lib/stores/artistFavoritesStore';
|
||||||
import { loadLabelFavorites } from '$lib/stores/labelFavoritesStore';
|
import { loadLabelFavorites } from '$lib/stores/labelFavoritesStore';
|
||||||
|
import { loadAwardFavorites } from '$lib/stores/awardFavoritesStore';
|
||||||
import { getDefaultFavoritesTab } from '$lib/utils/favorites';
|
import { getDefaultFavoritesTab } from '$lib/utils/favorites';
|
||||||
import { platform } from '$lib/utils/platform';
|
import { platform } from '$lib/utils/platform';
|
||||||
import type { FavoritesPreferences, ResolvedMusician } from '$lib/types';
|
import type { FavoritesPreferences, ResolvedMusician } from '$lib/types';
|
||||||
@@ -3679,6 +3680,7 @@
|
|||||||
loadAlbumFavorites(); // Album favorites
|
loadAlbumFavorites(); // Album favorites
|
||||||
loadArtistFavorites(); // Artist favorites
|
loadArtistFavorites(); // Artist favorites
|
||||||
loadLabelFavorites(); // Label favorites
|
loadLabelFavorites(); // Label favorites
|
||||||
|
loadAwardFavorites(); // Award favorites
|
||||||
|
|
||||||
// Refresh offline status now that we're logged in
|
// Refresh offline status now that we're logged in
|
||||||
refreshOfflineStatus().catch(err => console.debug('[Offline] Status refresh deferred:', err));
|
refreshOfflineStatus().catch(err => console.debug('[Offline] Status refresh deferred:', err));
|
||||||
|
|||||||
Reference in new issue
Block a user