feat: add search/album/genre filtering and /albums + /genres browse pages
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
@page "/albums"
|
||||
|
||||
<PageTitle>DeepDrft Albums</PageTitle>
|
||||
|
||||
<div>
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="albums-view-container">
|
||||
@if (_loading)
|
||||
{
|
||||
<MudGrid Spacing="6" Justify="Justify.Center">
|
||||
@foreach (var _ in Enumerable.Range(0, 8))
|
||||
{
|
||||
<MudItem xs="12" sm="6" md="4" lg="3" xl="3">
|
||||
<div class="album-card-center">
|
||||
<MudSkeleton Width="200px" Height="200px" SkeletonType="SkeletonType.Rectangle"/>
|
||||
</div>
|
||||
</MudItem>
|
||||
}
|
||||
</MudGrid>
|
||||
}
|
||||
else if (_albums.Count == 0)
|
||||
{
|
||||
<div class="albums-empty">
|
||||
<MudText Typo="Typo.h6">No albums yet</MudText>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudGrid Spacing="6" Justify="Justify.Center">
|
||||
@foreach (var album in _albums)
|
||||
{
|
||||
<MudItem xs="12" sm="6" md="4" lg="3" xl="3">
|
||||
<div class="album-card-center">
|
||||
<div class="album-card"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@onclick="@(() => OpenAlbum(album.Album))">
|
||||
@if (!string.IsNullOrEmpty(album.CoverImageKey))
|
||||
{
|
||||
<div class="album-card-cover"
|
||||
style="background-image: url('api/image/@Uri.EscapeDataString(album.CoverImageKey)');">
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="album-card-cover album-card-cover--fallback"></div>
|
||||
}
|
||||
|
||||
<div class="album-card-body">
|
||||
<MudText Typo="Typo.subtitle1" Class="album-card-title text-truncate">
|
||||
@album.Album
|
||||
</MudText>
|
||||
<MudText Typo="Typo.caption" Class="album-card-count">
|
||||
@album.TrackCount @(album.TrackCount == 1 ? "track" : "tracks")
|
||||
</MudText>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MudItem>
|
||||
}
|
||||
</MudGrid>
|
||||
}
|
||||
</MudContainer>
|
||||
</div>
|
||||
@@ -0,0 +1,26 @@
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftPublic.Client.Services;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace DeepDrftPublic.Client.Pages;
|
||||
|
||||
public partial class AlbumsView : ComponentBase
|
||||
{
|
||||
[Inject] public required ITrackDataService TrackData { get; set; }
|
||||
[Inject] public required NavigationManager Navigation { get; set; }
|
||||
|
||||
private bool _loading = true;
|
||||
private List<AlbumSummaryDto> _albums = [];
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var result = await TrackData.GetAlbums();
|
||||
if (result is { Success: true, Value: { } albums })
|
||||
_albums = albums;
|
||||
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private void OpenAlbum(string album)
|
||||
=> Navigation.NavigateTo($"/tracks?album={Uri.EscapeDataString(album)}");
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
.albums-view-container {
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.album-card-center {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.album-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 200px;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
transition: transform 120ms ease;
|
||||
}
|
||||
|
||||
.album-card:hover {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.album-card-cover {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.album-card-cover--fallback {
|
||||
background-color: var(--mud-palette-dark, #1a2238);
|
||||
}
|
||||
|
||||
.album-card-body {
|
||||
padding: 8px 4px 0 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
/* album-card-title / album-card-count ride on MudText, a child Razor component whose
|
||||
root Blazor isolation does not scope-stamp; ::deep pierces into its output. */
|
||||
::deep .album-card-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
::deep .album-card-count {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.albums-empty {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 48px 0;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
@page "/genres"
|
||||
|
||||
<PageTitle>DeepDrft Genres</PageTitle>
|
||||
|
||||
<div>
|
||||
<MudContainer MaxWidth="MaxWidth.Medium" Class="genres-view-container">
|
||||
@if (_loading)
|
||||
{
|
||||
<div class="genres-list">
|
||||
@foreach (var _ in Enumerable.Range(0, 8))
|
||||
{
|
||||
<MudSkeleton Height="48px" Width="100%" Class="mb-2"/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else if (_genres.Count == 0)
|
||||
{
|
||||
<div class="genres-empty">
|
||||
<MudText Typo="Typo.h6">No genres yet</MudText>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudList T="string" Class="genres-list">
|
||||
@foreach (var genre in _genres)
|
||||
{
|
||||
<MudListItem T="string"
|
||||
Icon="@Icons.Material.Filled.Category"
|
||||
OnClick="@(() => OpenGenre(genre.Genre))">
|
||||
<div class="genre-row">
|
||||
<MudText Typo="Typo.subtitle1">@genre.Genre</MudText>
|
||||
<MudText Typo="Typo.caption" Class="genre-count">
|
||||
@genre.TrackCount @(genre.TrackCount == 1 ? "track" : "tracks")
|
||||
</MudText>
|
||||
</div>
|
||||
</MudListItem>
|
||||
}
|
||||
</MudList>
|
||||
}
|
||||
</MudContainer>
|
||||
</div>
|
||||
@@ -0,0 +1,26 @@
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftPublic.Client.Services;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace DeepDrftPublic.Client.Pages;
|
||||
|
||||
public partial class GenresView : ComponentBase
|
||||
{
|
||||
[Inject] public required ITrackDataService TrackData { get; set; }
|
||||
[Inject] public required NavigationManager Navigation { get; set; }
|
||||
|
||||
private bool _loading = true;
|
||||
private List<GenreSummaryDto> _genres = [];
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var result = await TrackData.GetGenres();
|
||||
if (result is { Success: true, Value: { } genres })
|
||||
_genres = genres;
|
||||
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private void OpenGenre(string genre)
|
||||
=> Navigation.NavigateTo($"/tracks?genre={Uri.EscapeDataString(genre)}");
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
.genres-view-container {
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
/* genres-list rides on MudList, a child Razor component whose root Blazor isolation
|
||||
does not scope-stamp; ::deep is required. */
|
||||
::deep .genres-list {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.genre-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* genre-count rides on MudText (child Razor component); ::deep pierces into its output. */
|
||||
::deep .genre-count {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.genres-empty {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 48px 0;
|
||||
}
|
||||
@@ -5,6 +5,41 @@
|
||||
|
||||
<div>
|
||||
<div class="tracks-view-container">
|
||||
@* Search + filter affordances are interactive-only: the debounce timer and pill clear
|
||||
need WASM. During prerender/non-interactive they are hidden, matching the view-mode
|
||||
toggle's interactivity gate. *@
|
||||
@if (RendererInfo.IsInteractive)
|
||||
{
|
||||
<div class="tracks-search-row">
|
||||
<MudTextField T="string"
|
||||
Value="@ViewModel.SearchText"
|
||||
ValueChanged="@OnSearchInput"
|
||||
Immediate="true"
|
||||
DebounceInterval="400"
|
||||
Placeholder="Search tracks, artists, albums"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.Search"
|
||||
Variant="Variant.Outlined"
|
||||
Margin="Margin.Dense"
|
||||
Clearable="true"
|
||||
Class="tracks-search-field"/>
|
||||
</div>
|
||||
|
||||
@if (ViewModel.FilterAlbum is not null || ViewModel.FilterGenre is not null)
|
||||
{
|
||||
<div class="tracks-filter-pills">
|
||||
<MudChip T="string"
|
||||
Color="Color.Tertiary"
|
||||
Variant="Variant.Filled"
|
||||
OnClose="@(_ => ClearFilter())">
|
||||
@(ViewModel.FilterAlbum is not null
|
||||
? $"Album: {ViewModel.FilterAlbum}"
|
||||
: $"Genre: {ViewModel.FilterGenre}")
|
||||
</MudChip>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@if (ViewModel.Page != null)
|
||||
{
|
||||
<div class="tracks-view-header">
|
||||
|
||||
@@ -13,8 +13,15 @@ public partial class TracksView : ComponentBase, IDisposable
|
||||
|
||||
[Inject] public required TracksViewModel ViewModel { get; set; }
|
||||
[Inject] public required PersistentComponentState PersistentState { get; set; }
|
||||
[Inject] public required NavigationManager Navigation { get; set; }
|
||||
[CascadingParameter] public required IStreamingPlayerService PlayerService { get; set; }
|
||||
|
||||
// Filter params arrive on the URL: /tracks?album=X, /tracks?genre=Y, /tracks?q=Z. Copied into
|
||||
// the ViewModel on init before the first fetch so the gallery renders filtered on direct nav.
|
||||
[SupplyParameterFromQuery(Name = "album")] public string? AlbumQuery { get; set; }
|
||||
[SupplyParameterFromQuery(Name = "genre")] public string? GenreQuery { get; set; }
|
||||
[SupplyParameterFromQuery(Name = "q")] public string? SearchQuery { get; set; }
|
||||
|
||||
private IStreamingPlayerService? _subscribedService;
|
||||
private PersistingComponentStateSubscription _persistingSubscription;
|
||||
|
||||
@@ -23,6 +30,11 @@ public partial class TracksView : ComponentBase, IDisposable
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
// Seed filter state from the URL before any fetch or restore decision.
|
||||
ViewModel.FilterAlbum = string.IsNullOrWhiteSpace(AlbumQuery) ? null : AlbumQuery;
|
||||
ViewModel.FilterGenre = string.IsNullOrWhiteSpace(GenreQuery) ? null : GenreQuery;
|
||||
ViewModel.SearchText = string.IsNullOrWhiteSpace(SearchQuery) ? null : SearchQuery;
|
||||
|
||||
// Carry the prerendered page across the prerender -> interactive (WASM) seam.
|
||||
// Without this, the WASM pass gets a fresh scoped ViewModel (Page == null),
|
||||
// re-renders the skeleton, re-fetches, and replaces the gallery DOM a few
|
||||
@@ -31,7 +43,11 @@ public partial class TracksView : ComponentBase, IDisposable
|
||||
// restore on the interactive pass, and only fetch on a miss.
|
||||
_persistingSubscription = PersistentState.RegisterOnPersisting(PersistTracks);
|
||||
|
||||
if (PersistentState.TryTakeFromJson<PagedResult<TrackDto>>(PersistKey, out var restored) && restored is not null)
|
||||
// The prerendered page is always unfiltered. When the URL carries filter params, that
|
||||
// restored page is wrong for this view — skip the restore and fetch with the filter.
|
||||
if (!ViewModel.HasActiveFilter
|
||||
&& PersistentState.TryTakeFromJson<PagedResult<TrackDto>>(PersistKey, out var restored)
|
||||
&& restored is not null)
|
||||
{
|
||||
ViewModel.Page = restored;
|
||||
ViewModel.PageNumber = restored.Page;
|
||||
@@ -63,7 +79,9 @@ public partial class TracksView : ComponentBase, IDisposable
|
||||
|
||||
private Task PersistTracks()
|
||||
{
|
||||
if (ViewModel.Page is not null)
|
||||
// Only persist the unfiltered page. A filtered page restored onto a later plain /tracks
|
||||
// visit would show the wrong results, so a filtered render leaves the cache untouched.
|
||||
if (ViewModel.Page is not null && !ViewModel.HasActiveFilter)
|
||||
{
|
||||
PersistentState.PersistAsJson(PersistKey, ViewModel.Page);
|
||||
}
|
||||
@@ -72,7 +90,9 @@ public partial class TracksView : ComponentBase, IDisposable
|
||||
|
||||
private async Task SetPage(int newPage)
|
||||
{
|
||||
var result = await ViewModel.TrackData.GetPage(newPage, ViewModel.PageSize, ViewModel.SortBy, ViewModel.IsDescending);
|
||||
var result = await ViewModel.TrackData.GetPage(
|
||||
newPage, ViewModel.PageSize, ViewModel.SortBy, ViewModel.IsDescending,
|
||||
ViewModel.SearchText, ViewModel.FilterAlbum, ViewModel.FilterGenre);
|
||||
|
||||
if (result is { Success: true, Value: PagedResult<TrackDto> pageResult })
|
||||
{
|
||||
@@ -81,6 +101,34 @@ public partial class TracksView : ComponentBase, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
// Fired by MudTextField after its 400ms DebounceInterval, so only the trailing keystroke in a
|
||||
// burst reaches here. Resets to page 1 since the result set changes, then re-fetches with the
|
||||
// active filter (search + any album/genre pill compose).
|
||||
private async Task OnSearchInput(string? value)
|
||||
{
|
||||
ViewModel.SearchText = string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
ViewModel.PageNumber = 1;
|
||||
await SetPage(1);
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
// Clears the album/genre pill and returns to the unfiltered gallery. Updates the URL (drops the
|
||||
// query param) and re-fetches in place. SearchText is intentionally left intact — the pill only
|
||||
// represents FilterAlbum/FilterGenre, not free-text search, so clearing it must not discard an
|
||||
// active search term. Blazor reuses the component on a same-route query change and does not
|
||||
// re-run OnInitializedAsync, so the state reset + refetch happen here explicitly rather than
|
||||
// relying on re-init.
|
||||
private async Task ClearFilter()
|
||||
{
|
||||
ViewModel.FilterAlbum = null;
|
||||
ViewModel.FilterGenre = null;
|
||||
ViewModel.PageNumber = 1;
|
||||
|
||||
Navigation.NavigateTo("/tracks");
|
||||
await SetPage(1);
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task PlayTrack(TrackDto track)
|
||||
{
|
||||
// Resume the current track if it's merely paused; otherwise stream the new selection.
|
||||
|
||||
@@ -24,3 +24,22 @@
|
||||
justify-content: flex-end;
|
||||
padding: 0 0 12px 0;
|
||||
}
|
||||
|
||||
.tracks-search-row {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
padding: 0 0 12px 0;
|
||||
}
|
||||
|
||||
/* tracks-search-field rides on MudTextField, whose root is a child Razor component element.
|
||||
Blazor isolation does not stamp the scope attribute there, so ::deep is required. */
|
||||
::deep .tracks-search-field {
|
||||
max-width: 420px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tracks-filter-pills {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
padding: 0 0 12px 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user