feat: rework the home layout
Recently played keeps its shape. Mixes and playlists now share one row, half and half on large screens with smaller cards, capped at six each with a link to the full list. Albums are the ones the user listened to most recently, which is a new query joining the listening history with the album tracks. The endless track wall is now "Descubrir".
This commit is contained in:
@@ -34,6 +34,13 @@ public static class AlbumEndpoints
|
|||||||
.Produces<PaginatedResponse<AlbumApplicationResponse>>()
|
.Produces<PaginatedResponse<AlbumApplicationResponse>>()
|
||||||
.Produces(StatusCodes.Status404NotFound);
|
.Produces(StatusCodes.Status404NotFound);
|
||||||
|
|
||||||
|
group.MapGet("/recent", GetRecentlyListenedAlbums)
|
||||||
|
.WithName("GetRecentlyListenedAlbums")
|
||||||
|
.WithSummary("Get The Albums The Current User Listened To Most Recently.")
|
||||||
|
.RequireAuthorization()
|
||||||
|
.Produces<IReadOnlyList<AlbumApplicationResponse>>()
|
||||||
|
.Produces(StatusCodes.Status401Unauthorized);
|
||||||
|
|
||||||
group.MapGet("/{albumId}/tracks", GetAlbumTracks)
|
group.MapGet("/{albumId}/tracks", GetAlbumTracks)
|
||||||
.WithName("GetAlbumTracks")
|
.WithName("GetAlbumTracks")
|
||||||
.WithSummary("Get The Tracks Of An Album Ordered By Track Number.")
|
.WithSummary("Get The Tracks Of An Album Ordered By Track Number.")
|
||||||
@@ -119,6 +126,16 @@ public static class AlbumEndpoints
|
|||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> GetRecentlyListenedAlbums(
|
||||||
|
IMediator mediator,
|
||||||
|
CurrentUser currentUser,
|
||||||
|
CancellationToken cancellationToken,
|
||||||
|
int limit = 12)
|
||||||
|
{
|
||||||
|
var result = await mediator.Send(new GetRecentlyListenedAlbumsQuery(currentUser.RequiredId, limit), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<IResult> GetAlbumTracks(
|
private static async Task<IResult> GetAlbumTracks(
|
||||||
IMediator mediator,
|
IMediator mediator,
|
||||||
Guid albumId,
|
Guid albumId,
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using ErrorOr;
|
||||||
|
using Mediator;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Musify.Application.Albums.Responses;
|
||||||
|
using Musify.Application.Contracts;
|
||||||
|
|
||||||
|
namespace Musify.Application.Albums
|
||||||
|
{
|
||||||
|
public record GetRecentlyListenedAlbumsQuery(long UserId, int Limit)
|
||||||
|
: IQuery<ErrorOr<IReadOnlyList<AlbumApplicationResponse>>>;
|
||||||
|
|
||||||
|
public class GetRecentlyListenedAlbumsQueryHandler(IDatabase database)
|
||||||
|
: IQueryHandler<GetRecentlyListenedAlbumsQuery, ErrorOr<IReadOnlyList<AlbumApplicationResponse>>>
|
||||||
|
{
|
||||||
|
public async ValueTask<ErrorOr<IReadOnlyList<AlbumApplicationResponse>>> Handle(GetRecentlyListenedAlbumsQuery request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var listenedTrackIds = database.ListeningHistories
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(history => history.UserId == request.UserId);
|
||||||
|
|
||||||
|
var ranked = await database.AlbumHasTracks
|
||||||
|
.AsNoTracking()
|
||||||
|
.Join(
|
||||||
|
listenedTrackIds,
|
||||||
|
albumTrack => albumTrack.TrackId,
|
||||||
|
history => history.TrackId,
|
||||||
|
(albumTrack, history) => new { albumTrack.AlbumId, history.ListenedAt })
|
||||||
|
.GroupBy(entry => entry.AlbumId)
|
||||||
|
.Select(group => new { AlbumId = group.Key, LastListenedAt = group.Max(entry => entry.ListenedAt) })
|
||||||
|
.OrderByDescending(entry => entry.LastListenedAt)
|
||||||
|
.Take(request.Limit)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (ranked.Count == 0)
|
||||||
|
return new List<AlbumApplicationResponse>();
|
||||||
|
|
||||||
|
var albumIds = ranked.Select(entry => entry.AlbumId).ToList();
|
||||||
|
|
||||||
|
var albums = await database.UserAlbums
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(album => albumIds.Contains(album.Id))
|
||||||
|
.Select(album => new
|
||||||
|
{
|
||||||
|
Album = album,
|
||||||
|
TrackCount = album.AlbumTracks.Count,
|
||||||
|
CoverTrackIds = album.AlbumTracks
|
||||||
|
.OrderBy(albumTrack => albumTrack.TrackNumber)
|
||||||
|
.Take(AlbumApplicationResponse.CoverTrackCount)
|
||||||
|
.Select(albumTrack => albumTrack.TrackId)
|
||||||
|
.ToList()
|
||||||
|
})
|
||||||
|
.ToDictionaryAsync(entry => entry.Album.Id, cancellationToken);
|
||||||
|
|
||||||
|
return ranked
|
||||||
|
.Where(entry => albums.ContainsKey(entry.AlbumId))
|
||||||
|
.Select(entry => albums[entry.AlbumId])
|
||||||
|
.Select(entry => AlbumApplicationResponse.FromEntity(entry.Album, entry.TrackCount, entry.CoverTrackIds))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+46
@@ -56,6 +56,23 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/albums/recent": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** Get The Albums The Current User Listened To Most Recently. */
|
||||||
|
get: operations["GetRecentlyListenedAlbums"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/albums/{albumId}/tracks": {
|
"/albums/{albumId}/tracks": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -991,6 +1008,35 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
GetRecentlyListenedAlbums: {
|
||||||
|
parameters: {
|
||||||
|
query?: {
|
||||||
|
limit?: number | string;
|
||||||
|
};
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["AlbumApplicationResponse"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Unauthorized */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
GetAlbumTracks: {
|
GetAlbumTracks: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const LIBRARY_PICKER_PAGE_SIZE = 50;
|
|||||||
export const EXPLORE_ALBUMS_PAGE_SIZE = 10;
|
export const EXPLORE_ALBUMS_PAGE_SIZE = 10;
|
||||||
export const EXPLORE_ALBUM_SLOTS = 10;
|
export const EXPLORE_ALBUM_SLOTS = 10;
|
||||||
export const HOME_LATEST_PAGE_SIZE = 12;
|
export const HOME_LATEST_PAGE_SIZE = 12;
|
||||||
export const HOME_ALBUMS_PAGE_SIZE = 12;
|
export const HOME_ALBUMS_LIMIT = 12;
|
||||||
|
export const HOME_SHELF_LIMIT = 6;
|
||||||
export const YOUTUBE_FILLER_LIMIT = 27;
|
export const YOUTUBE_FILLER_LIMIT = 27;
|
||||||
export const PLAYLIST_PICKER_PAGE_SIZE = 50;
|
export const PLAYLIST_PICKER_PAGE_SIZE = 50;
|
||||||
|
|||||||
@@ -3,7 +3,12 @@ import { createApiClient, requireUser } from '$lib/server/api';
|
|||||||
import { fetchYoutubeFiller } from '$lib/server/youtube';
|
import { fetchYoutubeFiller } from '$lib/server/youtube';
|
||||||
import { shuffle } from '$lib/collections';
|
import { shuffle } from '$lib/collections';
|
||||||
import { addTrackAction, addYouTubeToPlaylistAction } from '$lib/server/playlistActions';
|
import { addTrackAction, addYouTubeToPlaylistAction } from '$lib/server/playlistActions';
|
||||||
import { HOME_ALBUMS_PAGE_SIZE, HOME_LATEST_PAGE_SIZE, YOUTUBE_FILLER_LIMIT } from '$lib/config';
|
import {
|
||||||
|
HOME_ALBUMS_LIMIT,
|
||||||
|
HOME_LATEST_PAGE_SIZE,
|
||||||
|
HOME_SHELF_LIMIT,
|
||||||
|
YOUTUBE_FILLER_LIMIT
|
||||||
|
} from '$lib/config';
|
||||||
import { pickGreeting } from '$lib/server/greeting';
|
import { pickGreeting } from '$lib/server/greeting';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
||||||
@@ -14,14 +19,14 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
|||||||
params: { query: { pageNumber: 1, pageSize: HOME_LATEST_PAGE_SIZE } }
|
params: { query: { pageNumber: 1, pageSize: HOME_LATEST_PAGE_SIZE } }
|
||||||
});
|
});
|
||||||
const playlistsPromise = api.GET('/playlists/users/{userId}', {
|
const playlistsPromise = api.GET('/playlists/users/{userId}', {
|
||||||
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: 12 } }
|
|
||||||
});
|
|
||||||
const albumsPromise = api.GET('/albums/users/{userId}', {
|
|
||||||
params: {
|
params: {
|
||||||
path: { userId: user.sub },
|
path: { userId: user.sub },
|
||||||
query: { pageNumber: 1, pageSize: HOME_ALBUMS_PAGE_SIZE }
|
query: { pageNumber: 1, pageSize: HOME_SHELF_LIMIT + 1 }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
const albumsPromise = api.GET('/albums/recent', {
|
||||||
|
params: { query: { limit: HOME_ALBUMS_LIMIT } }
|
||||||
|
});
|
||||||
const mixesPromise = api.GET('/mixes');
|
const mixesPromise = api.GET('/mixes');
|
||||||
const recentlyPlayedPromise = api.GET('/users/{id}/listening-history', {
|
const recentlyPlayedPromise = api.GET('/users/{id}/listening-history', {
|
||||||
params: { path: { id: user.sub } }
|
params: { path: { id: user.sub } }
|
||||||
@@ -47,10 +52,10 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
|||||||
undefined,
|
undefined,
|
||||||
user.sub
|
user.sub
|
||||||
),
|
),
|
||||||
playlists: playlistItems,
|
playlists: playlistItems.slice(0, HOME_SHELF_LIMIT),
|
||||||
albums: albums.data?.items ?? [],
|
playlistsHasMore: playlistItems.length > HOME_SHELF_LIMIT,
|
||||||
albumsHasMore: Boolean(albums.data?.hasNextPage),
|
albums: albums.data ?? [],
|
||||||
mixes: mixes.data ?? [],
|
mixes: (mixes.data ?? []).slice(0, HOME_SHELF_LIMIT),
|
||||||
recentlyPlayed: recentlyPlayed.data ?? []
|
recentlyPlayed: recentlyPlayed.data ?? []
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -227,44 +227,52 @@
|
|||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if mixes.length > 0}
|
{#if mixes.length > 0 || playlists.length > 0}
|
||||||
<section class="page-x pt-5 pb-3 sm:pt-6">
|
<section class="grid gap-x-8 gap-y-5 page-x pt-5 pb-3 sm:pt-6 lg:grid-cols-2">
|
||||||
<SectionHeading title="Hechas para ti" />
|
{#if mixes.length > 0}
|
||||||
<MediaGrid>
|
<div class="min-w-0">
|
||||||
{#each mixes as mix, i (mix.id)}
|
<SectionHeading title="Hechas para ti" />
|
||||||
<MixCard {mix} index={i} />
|
<MediaGrid min="140px" minMobile="130px">
|
||||||
{/each}
|
{#each mixes as mix, i (mix.id)}
|
||||||
</MediaGrid>
|
<MixCard {mix} index={i} />
|
||||||
</section>
|
{/each}
|
||||||
{/if}
|
</MediaGrid>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if playlists.length > 0}
|
{#if playlists.length > 0}
|
||||||
<section class="page-x pt-5 pb-3 sm:pt-6">
|
<div class="min-w-0">
|
||||||
<SectionHeading title="Mis listas" />
|
<SectionHeading title="Mis listas">
|
||||||
<MediaGrid>
|
{#snippet actions()}
|
||||||
{#each playlists as playlist, i (playlist.id)}
|
{#if data.playlistsHasMore}
|
||||||
<PlaylistCard
|
<a href="/playlists" class="text-sm text-fg-3 transition hover:text-accent-soft">
|
||||||
id={playlist.id}
|
Ver todas
|
||||||
name={playlist.name}
|
</a>
|
||||||
description={playlist.description}
|
{/if}
|
||||||
trackIds={playlist.coverTrackIds}
|
{/snippet}
|
||||||
updatedAt={playlist.updatedAt}
|
</SectionHeading>
|
||||||
index={i}
|
<MediaGrid min="140px" minMobile="130px">
|
||||||
/>
|
{#each playlists as playlist, i (playlist.id)}
|
||||||
{/each}
|
<PlaylistCard
|
||||||
</MediaGrid>
|
id={playlist.id}
|
||||||
|
name={playlist.name}
|
||||||
|
description={playlist.description}
|
||||||
|
trackIds={playlist.coverTrackIds}
|
||||||
|
updatedAt={playlist.updatedAt}
|
||||||
|
index={i}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</MediaGrid>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if albums.length > 0}
|
{#if albums.length > 0}
|
||||||
<section class="page-x pt-5 pb-3 sm:pt-6">
|
<section class="page-x pt-5 pb-3 sm:pt-6">
|
||||||
<SectionHeading title="Tus álbumes">
|
<SectionHeading title="Álbumes escuchados recientemente">
|
||||||
{#snippet actions()}
|
{#snippet actions()}
|
||||||
{#if data.albumsHasMore}
|
<a href="/albums" class="text-sm text-fg-3 transition hover:text-accent-soft">Ver todos</a>
|
||||||
<a href="/albums" class="text-sm text-fg-3 transition hover:text-accent-soft">
|
|
||||||
Ver todos
|
|
||||||
</a>
|
|
||||||
{/if}
|
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</SectionHeading>
|
</SectionHeading>
|
||||||
<MediaGrid>
|
<MediaGrid>
|
||||||
@@ -283,7 +291,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<section class="page-x pt-5 pb-10 sm:pt-6">
|
<section class="page-x pt-5 pb-10 sm:pt-6">
|
||||||
<SectionHeading title="Novedades" />
|
<SectionHeading title="Descubrir" />
|
||||||
{#if latest.length > 0}
|
{#if latest.length > 0}
|
||||||
<MediaGrid as="ul">
|
<MediaGrid as="ul">
|
||||||
{#each latest as item, i (targetId(item))}
|
{#each latest as item, i (targetId(item))}
|
||||||
|
|||||||
Reference in New Issue
Block a user