Compare commits
63
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e85af2b3b | ||
|
|
2d23eb87c6 | ||
|
|
e5ce63a192 | ||
|
|
b5c9658d65 | ||
|
|
ba0a2373b5 | ||
|
|
b0163f6a4d | ||
|
|
0a192645d6 | ||
|
|
351b333529 | ||
|
|
155f0e2b6a | ||
|
|
a54d1ef077 | ||
|
|
2361b88e0a | ||
|
|
e7c2fd5571 | ||
|
|
3126cb6dd6 | ||
|
|
03afcc8a90 | ||
|
|
f210fa4702 | ||
|
|
4b708aa987 | ||
|
|
bd07b1134e | ||
|
|
c041daf649 | ||
|
|
975fa7e4fd | ||
|
|
1a2ae37f49 | ||
|
|
1cad36b929 | ||
|
|
8cb9386447 | ||
|
|
5cda7cb4c1 | ||
|
|
e89463fb83 | ||
|
|
b011ccdd5b | ||
|
|
1df7f6ccf6 | ||
|
|
2915737f03 | ||
|
|
5b2a0e4d46 | ||
|
|
37fc229bf6 | ||
|
|
bc43b4b6eb | ||
|
|
dda8c30d45 | ||
|
|
906f38140a | ||
|
|
3e546d4a1e | ||
|
|
b2f450dcf7 | ||
|
|
87c3f97529 | ||
|
|
302dd1e0f8 | ||
|
|
421fc929e8 | ||
|
|
430c711ff2 | ||
|
|
cfdc61a4b8 | ||
|
|
0f28e19c1d | ||
|
|
7316e355ee | ||
|
|
b39e55fd87 | ||
|
|
cb046bba9e | ||
|
|
b9a40186b1 | ||
|
|
fd0a39b879 | ||
|
|
cc7afe4326 | ||
|
|
72cbc18a48 | ||
|
|
84408b842b | ||
|
|
339fcb56ad | ||
|
|
74cf534be5 | ||
|
|
89820c1377 | ||
|
|
2fe26b15dd | ||
|
|
2cb560fda4 | ||
|
|
d8530b9209 | ||
|
|
5049b0ba91 | ||
|
|
1b98a75c0e | ||
|
|
cbbde95773 | ||
|
|
26bb8a50a3 | ||
|
|
bf19bb31f9 | ||
|
|
55cc803708 | ||
|
|
805e521b1d | ||
|
|
f8d431a0d7 | ||
|
|
7b72db8a24 |
@@ -86,11 +86,23 @@ jobs:
|
|||||||
git fetch origin master
|
git fetch origin master
|
||||||
git reset --hard origin/master
|
git reset --hard origin/master
|
||||||
|
|
||||||
- name: Migrate database, generate keys
|
- name: Generate stream-ticket keys
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$DEPLOY_PATH"
|
cd "$DEPLOY_PATH"
|
||||||
$COMPOSE up -d wait-for-postgres keys-init migrate
|
$COMPOSE run --rm --no-deps keys-init
|
||||||
|
|
||||||
|
# The migrations are baked into the migrator image, so it is rebuilt on
|
||||||
|
# every deploy (plain `up` would reuse the stale image and re-apply the
|
||||||
|
# old migrations). `run --rm` starts wait-for-postgres, blocks until the
|
||||||
|
# migration finishes and propagates its exit code, so a failed migration
|
||||||
|
# aborts the deploy before the new API is started against an old schema.
|
||||||
|
- name: Migrate database
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$DEPLOY_PATH"
|
||||||
|
$COMPOSE build migrate
|
||||||
|
$COMPOSE run --rm migrate
|
||||||
|
|
||||||
# Both jobs verify themselves through the public auth./s3. URLs, served
|
# Both jobs verify themselves through the public auth./s3. URLs, served
|
||||||
# by the shared edge nginx (Infrastructure's edge/), and are idempotent.
|
# by the shared edge nginx (Infrastructure's edge/), and are idempotent.
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
using ErrorOr;
|
||||||
|
using Mediator;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.PlayLists.Responses;
|
||||||
|
using Musify.Domain.Entities;
|
||||||
|
|
||||||
|
namespace Musify.Application.PlayLists
|
||||||
|
{
|
||||||
|
public record AddAlbumToPlayListCommand(long UserId, Guid PlayListId, Guid AlbumId)
|
||||||
|
: ICommand<ErrorOr<AddAlbumToPlayListResponse>>;
|
||||||
|
|
||||||
|
public class AddAlbumToPlayListCommandHandler(
|
||||||
|
IDatabase database,
|
||||||
|
ILogger<AddAlbumToPlayListCommandHandler> logger)
|
||||||
|
: ICommandHandler<AddAlbumToPlayListCommand, ErrorOr<AddAlbumToPlayListResponse>>
|
||||||
|
{
|
||||||
|
public async ValueTask<ErrorOr<AddAlbumToPlayListResponse>> Handle(AddAlbumToPlayListCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var playList = await database.PlayLists
|
||||||
|
.SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken);
|
||||||
|
if (playList == null)
|
||||||
|
{
|
||||||
|
logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId);
|
||||||
|
return Error.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playList.UserId != request.UserId)
|
||||||
|
{
|
||||||
|
logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId);
|
||||||
|
return Error.Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
var albumExists = await database.Albums
|
||||||
|
.AsNoTracking()
|
||||||
|
.AnyAsync(album => album.Id == request.AlbumId, cancellationToken);
|
||||||
|
if (!albumExists)
|
||||||
|
{
|
||||||
|
logger.LogInformation("Album {AlbumId} not found", request.AlbumId);
|
||||||
|
return Error.NotFound(description: $"Album {request.AlbumId} not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
var albumTrackIds = await database.AlbumHasTracks
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId)
|
||||||
|
.OrderBy(albumTrack => albumTrack.TrackNumber)
|
||||||
|
.Select(albumTrack => albumTrack.TrackId)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var playListTracksQuery = database.PlayListHasTracks
|
||||||
|
.Where(plt => plt.PlayListId == request.PlayListId);
|
||||||
|
|
||||||
|
var existingTrackIds = (await playListTracksQuery
|
||||||
|
.Select(plt => plt.TrackId)
|
||||||
|
.ToListAsync(cancellationToken)).ToHashSet();
|
||||||
|
|
||||||
|
var newTrackIds = albumTrackIds
|
||||||
|
.Where(trackId => !existingTrackIds.Contains(trackId))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (newTrackIds.Count == 0)
|
||||||
|
return new AddAlbumToPlayListResponse(0);
|
||||||
|
|
||||||
|
var nextPosition = existingTrackIds.Count > 0
|
||||||
|
? await playListTracksQuery.MaxAsync(plt => plt.Position, cancellationToken) + 1
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
await database.PlayListHasTracks.AddRangeAsync(
|
||||||
|
newTrackIds.Select((trackId, index) => new PlayListHasTrack
|
||||||
|
{
|
||||||
|
PlayListId = request.PlayListId,
|
||||||
|
TrackId = trackId,
|
||||||
|
Position = nextPosition + index
|
||||||
|
}),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
await database.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
logger.LogInformation("Added {Count} tracks from album {AlbumId} to playlist {PlayListId}",
|
||||||
|
newTrackIds.Count, request.AlbumId, request.PlayListId);
|
||||||
|
|
||||||
|
return new AddAlbumToPlayListResponse(newTrackIds.Count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,8 @@ namespace Musify.Application.PlayLists
|
|||||||
.Select(p => new
|
.Select(p => new
|
||||||
{
|
{
|
||||||
PlayList = p,
|
PlayList = p,
|
||||||
|
TrackCount = p.PlayListTracks.Count,
|
||||||
|
DurationSeconds = p.PlayListTracks.Sum(plt => (double?)plt.Track.DurationSeconds) ?? 0,
|
||||||
CoverTrackIds = p.PlayListTracks
|
CoverTrackIds = p.PlayListTracks
|
||||||
.OrderBy(plt => plt.Position)
|
.OrderBy(plt => plt.Position)
|
||||||
.Take(PlayListApplicationResponse.CoverTrackCount)
|
.Take(PlayListApplicationResponse.CoverTrackCount)
|
||||||
@@ -36,7 +38,7 @@ namespace Musify.Application.PlayLists
|
|||||||
&& entry.PlayList.UserId != request.RequestingUserId)
|
&& entry.PlayList.UserId != request.RequestingUserId)
|
||||||
return Error.NotFound();
|
return Error.NotFound();
|
||||||
|
|
||||||
return PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds);
|
return PlayListApplicationResponse.FromEntity(entry.PlayList, entry.TrackCount, entry.DurationSeconds, entry.CoverTrackIds);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ namespace Musify.Application.PlayLists
|
|||||||
.Select(p => new
|
.Select(p => new
|
||||||
{
|
{
|
||||||
PlayList = p,
|
PlayList = p,
|
||||||
|
TrackCount = p.PlayListTracks.Count,
|
||||||
|
DurationSeconds = p.PlayListTracks.Sum(plt => (double?)plt.Track.DurationSeconds) ?? 0,
|
||||||
CoverTrackIds = p.PlayListTracks
|
CoverTrackIds = p.PlayListTracks
|
||||||
.OrderBy(plt => plt.Position)
|
.OrderBy(plt => plt.Position)
|
||||||
.Take(PlayListApplicationResponse.CoverTrackCount)
|
.Take(PlayListApplicationResponse.CoverTrackCount)
|
||||||
@@ -34,7 +36,7 @@ namespace Musify.Application.PlayLists
|
|||||||
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
||||||
|
|
||||||
var pagedPlayLists = new StaticPagedList<PlayListApplicationResponse>(
|
var pagedPlayLists = new StaticPagedList<PlayListApplicationResponse>(
|
||||||
pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds)),
|
pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.TrackCount, entry.DurationSeconds, entry.CoverTrackIds)),
|
||||||
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
|
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
|
||||||
|
|
||||||
return PaginatedResponse<PlayListApplicationResponse>.FromPagedList(pagedPlayLists);
|
return PaginatedResponse<PlayListApplicationResponse>.FromPagedList(pagedPlayLists);
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ namespace Musify.Application.PlayLists
|
|||||||
.Select(p => new
|
.Select(p => new
|
||||||
{
|
{
|
||||||
PlayList = p,
|
PlayList = p,
|
||||||
|
TrackCount = p.PlayListTracks.Count,
|
||||||
|
DurationSeconds = p.PlayListTracks.Sum(plt => (double?)plt.Track.DurationSeconds) ?? 0,
|
||||||
CoverTrackIds = p.PlayListTracks
|
CoverTrackIds = p.PlayListTracks
|
||||||
.OrderBy(plt => plt.Position)
|
.OrderBy(plt => plt.Position)
|
||||||
.Take(PlayListApplicationResponse.CoverTrackCount)
|
.Take(PlayListApplicationResponse.CoverTrackCount)
|
||||||
@@ -55,7 +57,7 @@ namespace Musify.Application.PlayLists
|
|||||||
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
||||||
|
|
||||||
var pagedPlayLists = new StaticPagedList<PlayListApplicationResponse>(
|
var pagedPlayLists = new StaticPagedList<PlayListApplicationResponse>(
|
||||||
pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds)),
|
pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.TrackCount, entry.DurationSeconds, entry.CoverTrackIds)),
|
||||||
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
|
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
|
||||||
|
|
||||||
return PaginatedResponse<PlayListApplicationResponse>.FromPagedList(pagedPlayLists);
|
return PaginatedResponse<PlayListApplicationResponse>.FromPagedList(pagedPlayLists);
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
namespace Musify.Application.PlayLists.Responses
|
||||||
|
{
|
||||||
|
public record AddAlbumToPlayListResponse(int AddedCount);
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using Musify.Application.Serialization;
|
||||||
using Musify.Domain.Entities;
|
using Musify.Domain.Entities;
|
||||||
using Musify.Domain.ValueObjects;
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
@@ -10,7 +12,10 @@ namespace Musify.Application.PlayLists.Responses
|
|||||||
string? SmallImageKeyName,
|
string? SmallImageKeyName,
|
||||||
string? MediumImageKeyName,
|
string? MediumImageKeyName,
|
||||||
string? LargeImageKeyName,
|
string? LargeImageKeyName,
|
||||||
|
[property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId,
|
||||||
PlaylistVisibility Visibility,
|
PlaylistVisibility Visibility,
|
||||||
|
int TrackCount,
|
||||||
|
double DurationSeconds,
|
||||||
DateTime CreatedAt,
|
DateTime CreatedAt,
|
||||||
DateTime UpdatedAt,
|
DateTime UpdatedAt,
|
||||||
IReadOnlyList<Guid> CoverTrackIds)
|
IReadOnlyList<Guid> CoverTrackIds)
|
||||||
@@ -19,6 +24,8 @@ namespace Musify.Application.PlayLists.Responses
|
|||||||
|
|
||||||
public static PlayListApplicationResponse FromEntity(
|
public static PlayListApplicationResponse FromEntity(
|
||||||
PlayList playList,
|
PlayList playList,
|
||||||
|
int trackCount = 0,
|
||||||
|
double durationSeconds = 0,
|
||||||
IReadOnlyList<Guid>? coverTrackIds = null)
|
IReadOnlyList<Guid>? coverTrackIds = null)
|
||||||
{
|
{
|
||||||
return new PlayListApplicationResponse(
|
return new PlayListApplicationResponse(
|
||||||
@@ -28,7 +35,10 @@ namespace Musify.Application.PlayLists.Responses
|
|||||||
playList.Pictures?.SmallName,
|
playList.Pictures?.SmallName,
|
||||||
playList.Pictures?.MediumName,
|
playList.Pictures?.MediumName,
|
||||||
playList.Pictures?.LargeName,
|
playList.Pictures?.LargeName,
|
||||||
|
playList.UserId,
|
||||||
playList.Visibility,
|
playList.Visibility,
|
||||||
|
trackCount,
|
||||||
|
durationSeconds,
|
||||||
playList.CreatedAt,
|
playList.CreatedAt,
|
||||||
playList.UpdatedAt,
|
playList.UpdatedAt,
|
||||||
coverTrackIds ?? []);
|
coverTrackIds ?? []);
|
||||||
|
|||||||
@@ -112,7 +112,22 @@ namespace Musify.Application.PlayLists
|
|||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
logger.LogInformation("Updated playlist {PlayListId}", playListEntity.Id);
|
logger.LogInformation("Updated playlist {PlayListId}", playListEntity.Id);
|
||||||
return PlayListApplicationResponse.FromEntity(playListEntity, coverTrackIds);
|
var totals = await database.PlayListHasTracks
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(plt => plt.PlayListId == playListEntity.Id)
|
||||||
|
.GroupBy(plt => plt.PlayListId)
|
||||||
|
.Select(group => new
|
||||||
|
{
|
||||||
|
TrackCount = group.Count(),
|
||||||
|
DurationSeconds = group.Sum(plt => plt.Track.DurationSeconds)
|
||||||
|
})
|
||||||
|
.SingleOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
return PlayListApplicationResponse.FromEntity(
|
||||||
|
playListEntity,
|
||||||
|
totals?.TrackCount ?? 0,
|
||||||
|
totals?.DurationSeconds ?? 0,
|
||||||
|
coverTrackIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<ErrorOr<Success>> PublishUpdatePlayListPictureSourceEventAsync(
|
private async Task<ErrorOr<Success>> PublishUpdatePlayListPictureSourceEventAsync(
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ namespace Musify.Application.Tracks
|
|||||||
string Title,
|
string Title,
|
||||||
Guid PictureIntentId,
|
Guid PictureIntentId,
|
||||||
Guid AudioIntentId,
|
Guid AudioIntentId,
|
||||||
IReadOnlyCollection<Genre> Tags)
|
IReadOnlyCollection<Genre> Tags,
|
||||||
|
bool IsExplicit = false)
|
||||||
: ICommand<ErrorOr<TrackApplicationResponse>>;
|
: ICommand<ErrorOr<TrackApplicationResponse>>;
|
||||||
|
|
||||||
public class CreateTrackCommandHandler(
|
public class CreateTrackCommandHandler(
|
||||||
@@ -83,6 +84,7 @@ namespace Musify.Application.Tracks
|
|||||||
NormalizedTitle = request.Title.ToUpperInvariant(),
|
NormalizedTitle = request.Title.ToUpperInvariant(),
|
||||||
OwnerUserId = user.Id,
|
OwnerUserId = user.Id,
|
||||||
Owner = user,
|
Owner = user,
|
||||||
|
IsExplicit = request.IsExplicit,
|
||||||
Pictures = new TrackPictures
|
Pictures = new TrackPictures
|
||||||
{
|
{
|
||||||
OriginalName = pictureIntent.ObjectName,
|
OriginalName = pictureIntent.ObjectName,
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ namespace Musify.Application.Tracks.Responses
|
|||||||
DateTime CreatedAt,
|
DateTime CreatedAt,
|
||||||
DateTime UpdatedAt,
|
DateTime UpdatedAt,
|
||||||
[property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId,
|
[property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId,
|
||||||
IReadOnlyCollection<Genre> Tags)
|
IReadOnlyCollection<Genre> Tags,
|
||||||
|
bool IsExplicit)
|
||||||
{
|
{
|
||||||
public static TrackApplicationResponse FromEntity(Track track, int listensCount) =>
|
public static TrackApplicationResponse FromEntity(Track track, int listensCount) =>
|
||||||
new(
|
new(
|
||||||
@@ -28,6 +29,7 @@ namespace Musify.Application.Tracks.Responses
|
|||||||
track.CreatedAt,
|
track.CreatedAt,
|
||||||
track.UpdatedAt,
|
track.UpdatedAt,
|
||||||
track.OwnerUserId,
|
track.OwnerUserId,
|
||||||
track.Tags.Select(tag => tag.Tag).ToList());
|
track.Tags.Select(tag => tag.Tag).ToList(),
|
||||||
|
track.IsExplicit);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Musify.Application.Contracts;
|
||||||
|
|
||||||
|
namespace Musify.Application.Users
|
||||||
|
{
|
||||||
|
public static class FollowVisibility
|
||||||
|
{
|
||||||
|
public static async Task<bool> CanViewFollowersAsync(
|
||||||
|
IDatabase database,
|
||||||
|
long? viewerId,
|
||||||
|
long userId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (viewerId is not { } viewer)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (viewer == userId)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
var mutualRows = await database.UserFollows
|
||||||
|
.AsNoTracking()
|
||||||
|
.CountAsync(f =>
|
||||||
|
(f.FollowerId == viewer && f.FollowedId == userId) ||
|
||||||
|
(f.FollowerId == userId && f.FollowedId == viewer),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
return mutualRows == 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using ErrorOr;
|
||||||
|
using Mediator;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Shared;
|
||||||
|
using Musify.Application.Users.Responses;
|
||||||
|
using X.PagedList.EF;
|
||||||
|
|
||||||
|
namespace Musify.Application.Users
|
||||||
|
{
|
||||||
|
public record GetUserFollowersQuery(long? ViewerId, long UserId, int PageNumber, int PageSize)
|
||||||
|
: IQuery<ErrorOr<PaginatedResponse<UserSummaryResponse>>>;
|
||||||
|
|
||||||
|
public class GetUserFollowersQueryHandler(IDatabase database)
|
||||||
|
: IQueryHandler<GetUserFollowersQuery, ErrorOr<PaginatedResponse<UserSummaryResponse>>>
|
||||||
|
{
|
||||||
|
public async ValueTask<ErrorOr<PaginatedResponse<UserSummaryResponse>>> Handle(GetUserFollowersQuery request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!await database.Users.AnyAsync(u => u.Id == request.UserId, cancellationToken))
|
||||||
|
return Error.NotFound();
|
||||||
|
|
||||||
|
if (!await FollowVisibility.CanViewFollowersAsync(database, request.ViewerId, request.UserId, cancellationToken))
|
||||||
|
return Error.Forbidden();
|
||||||
|
|
||||||
|
var followsQuery = database.UserFollows
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(f => f.FollowedId == request.UserId);
|
||||||
|
|
||||||
|
var totalCount = await followsQuery.CountAsync(cancellationToken);
|
||||||
|
var viewerId = request.ViewerId;
|
||||||
|
|
||||||
|
var page = await followsQuery
|
||||||
|
.OrderByDescending(f => f.CreatedAt)
|
||||||
|
.Select(f => new UserSummaryResponse(
|
||||||
|
f.Follower.Id,
|
||||||
|
f.Follower.Name,
|
||||||
|
f.Follower.FirstName,
|
||||||
|
f.Follower.SecondName,
|
||||||
|
f.Follower.ProfilePictureUrl,
|
||||||
|
viewerId != null && database.UserFollows.Any(v => v.FollowerId == viewerId && v.FollowedId == f.FollowerId)))
|
||||||
|
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
||||||
|
|
||||||
|
return PaginatedResponse<UserSummaryResponse>.FromPagedList(page);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
using ErrorOr;
|
||||||
|
using Mediator;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Shared;
|
||||||
|
using Musify.Application.Users.Responses;
|
||||||
|
using X.PagedList.EF;
|
||||||
|
|
||||||
|
namespace Musify.Application.Users
|
||||||
|
{
|
||||||
|
public record GetUserFollowingQuery(long? ViewerId, long UserId, int PageNumber, int PageSize)
|
||||||
|
: IQuery<ErrorOr<PaginatedResponse<UserSummaryResponse>>>;
|
||||||
|
|
||||||
|
public class GetUserFollowingQueryHandler(IDatabase database)
|
||||||
|
: IQueryHandler<GetUserFollowingQuery, ErrorOr<PaginatedResponse<UserSummaryResponse>>>
|
||||||
|
{
|
||||||
|
public async ValueTask<ErrorOr<PaginatedResponse<UserSummaryResponse>>> Handle(GetUserFollowingQuery request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!await database.Users.AnyAsync(u => u.Id == request.UserId, cancellationToken))
|
||||||
|
return Error.NotFound();
|
||||||
|
|
||||||
|
var followsQuery = database.UserFollows
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(f => f.FollowerId == request.UserId);
|
||||||
|
|
||||||
|
var totalCount = await followsQuery.CountAsync(cancellationToken);
|
||||||
|
var viewerId = request.ViewerId;
|
||||||
|
|
||||||
|
var page = await followsQuery
|
||||||
|
.OrderByDescending(f => f.CreatedAt)
|
||||||
|
.Select(f => new UserSummaryResponse(
|
||||||
|
f.Followed.Id,
|
||||||
|
f.Followed.Name,
|
||||||
|
f.Followed.FirstName,
|
||||||
|
f.Followed.SecondName,
|
||||||
|
f.Followed.ProfilePictureUrl,
|
||||||
|
viewerId != null && database.UserFollows.Any(v => v.FollowerId == viewerId && v.FollowedId == f.FollowedId)))
|
||||||
|
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
||||||
|
|
||||||
|
return PaginatedResponse<UserSummaryResponse>.FromPagedList(page);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ using Musify.Application.Serialization;
|
|||||||
|
|
||||||
namespace Musify.Application.Users
|
namespace Musify.Application.Users
|
||||||
{
|
{
|
||||||
public record GetUserProfileQuery(long UserId) : IQuery<ErrorOr<UserProfileResponse>>;
|
public record GetUserProfileQuery(long UserId, long? ViewerId = null) : IQuery<ErrorOr<UserProfileResponse>>;
|
||||||
|
|
||||||
public record UserProfileResponse(
|
public record UserProfileResponse(
|
||||||
[property: JsonConverter(typeof(LongAsStringConverter))] long Id,
|
[property: JsonConverter(typeof(LongAsStringConverter))] long Id,
|
||||||
@@ -17,7 +17,9 @@ namespace Musify.Application.Users
|
|||||||
string? ProfilePictureUrl,
|
string? ProfilePictureUrl,
|
||||||
DateTime CreatedAt,
|
DateTime CreatedAt,
|
||||||
int FollowersCount,
|
int FollowersCount,
|
||||||
int FollowingCount);
|
int FollowingCount,
|
||||||
|
bool IsFollowing,
|
||||||
|
bool CanViewFollowers);
|
||||||
|
|
||||||
public class GetUserProfileQueryHandler(IDatabase database)
|
public class GetUserProfileQueryHandler(IDatabase database)
|
||||||
: IQueryHandler<GetUserProfileQuery, ErrorOr<UserProfileResponse>>
|
: IQueryHandler<GetUserProfileQuery, ErrorOr<UserProfileResponse>>
|
||||||
@@ -49,6 +51,14 @@ namespace Musify.Application.Users
|
|||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.CountAsync(f => f.FollowerId == request.UserId, cancellationToken);
|
.CountAsync(f => f.FollowerId == request.UserId, cancellationToken);
|
||||||
|
|
||||||
|
var isFollowing = request.ViewerId is { } viewerId && viewerId != request.UserId &&
|
||||||
|
await database.UserFollows
|
||||||
|
.AsNoTracking()
|
||||||
|
.AnyAsync(f => f.FollowerId == viewerId && f.FollowedId == request.UserId, cancellationToken);
|
||||||
|
|
||||||
|
var canViewFollowers = await FollowVisibility.CanViewFollowersAsync(
|
||||||
|
database, request.ViewerId, request.UserId, cancellationToken);
|
||||||
|
|
||||||
return new UserProfileResponse(
|
return new UserProfileResponse(
|
||||||
user.Id,
|
user.Id,
|
||||||
user.Name,
|
user.Name,
|
||||||
@@ -57,7 +67,9 @@ namespace Musify.Application.Users
|
|||||||
user.ProfilePictureUrl,
|
user.ProfilePictureUrl,
|
||||||
user.CreatedAt,
|
user.CreatedAt,
|
||||||
followersCount,
|
followersCount,
|
||||||
followingCount);
|
followingCount,
|
||||||
|
isFollowing,
|
||||||
|
canViewFollowers);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ using Musify.Application.Contracts;
|
|||||||
|
|
||||||
namespace Musify.Application.Users
|
namespace Musify.Application.Users
|
||||||
{
|
{
|
||||||
public record GetUsersQuery(int PageNumber, int PageSize, string? UsernameSearch)
|
public record GetUsersQuery(int PageNumber, int PageSize, string? UsernameSearch, long? ViewerId = null)
|
||||||
: IQuery<ErrorOr<PaginatedResponse<UserApplicationResponse>>>;
|
: IQuery<ErrorOr<PaginatedResponse<UserSummaryResponse>>>;
|
||||||
|
|
||||||
public class GetUsersQueryHandler(IDatabase database)
|
public class GetUsersQueryHandler(IDatabase database)
|
||||||
: IQueryHandler<GetUsersQuery, ErrorOr<PaginatedResponse<UserApplicationResponse>>>
|
: IQueryHandler<GetUsersQuery, ErrorOr<PaginatedResponse<UserSummaryResponse>>>
|
||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<PaginatedResponse<UserApplicationResponse>>> Handle(GetUsersQuery request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<PaginatedResponse<UserSummaryResponse>>> Handle(GetUsersQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var usersQuery = database.Users
|
var usersQuery = database.Users
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
@@ -27,11 +27,19 @@ namespace Musify.Application.Users
|
|||||||
}
|
}
|
||||||
|
|
||||||
var totalCount = await usersQuery.CountAsync(cancellationToken);
|
var totalCount = await usersQuery.CountAsync(cancellationToken);
|
||||||
|
var viewerId = request.ViewerId;
|
||||||
var pagedUsers = await usersQuery
|
var pagedUsers = await usersQuery
|
||||||
.Select(u => UserApplicationResponse.FromEntity(u))
|
.OrderBy(u => u.Name)
|
||||||
|
.Select(u => new UserSummaryResponse(
|
||||||
|
u.Id,
|
||||||
|
u.Name,
|
||||||
|
u.FirstName,
|
||||||
|
u.SecondName,
|
||||||
|
u.ProfilePictureUrl,
|
||||||
|
viewerId != null && database.UserFollows.Any(f => f.FollowerId == viewerId && f.FollowedId == u.Id)))
|
||||||
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
||||||
|
|
||||||
return PaginatedResponse<UserApplicationResponse>.FromPagedList(pagedUsers);
|
return PaginatedResponse<UserSummaryResponse>.FromPagedList(pagedUsers);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using Musify.Application.Serialization;
|
||||||
|
|
||||||
|
namespace Musify.Application.Users.Responses
|
||||||
|
{
|
||||||
|
public record UserSummaryResponse(
|
||||||
|
[property: JsonConverter(typeof(LongAsStringConverter))] long Id,
|
||||||
|
string Name,
|
||||||
|
string? FirstName,
|
||||||
|
string? SecondName,
|
||||||
|
string? ProfilePictureUrl,
|
||||||
|
bool IsFollowedByViewer);
|
||||||
|
}
|
||||||
@@ -31,6 +31,9 @@ namespace Musify.Domain.Entities
|
|||||||
[Required]
|
[Required]
|
||||||
public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active;
|
public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public bool IsExplicit { get; set; }
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
|||||||
+1167
File diff suppressed because it is too large
Load Diff
+29
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Musify.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddTrackIsExplicit : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "IsExplicit",
|
||||||
|
table: "Tracks",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "IsExplicit",
|
||||||
|
table: "Tracks");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -436,6 +436,9 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
|||||||
b.Property<double>("DurationSeconds")
|
b.Property<double>("DurationSeconds")
|
||||||
.HasColumnType("double precision");
|
.HasColumnType("double precision");
|
||||||
|
|
||||||
|
b.Property<bool>("IsExplicit")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
b.Property<int>("LifeCycleStatus")
|
b.Property<int>("LifeCycleStatus")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
|||||||
@@ -6,4 +6,5 @@ public record CreateTrackRequest(
|
|||||||
string Title,
|
string Title,
|
||||||
Guid PictureIntentId,
|
Guid PictureIntentId,
|
||||||
Guid AudioIntentId,
|
Guid AudioIntentId,
|
||||||
IReadOnlyCollection<Genre> Tags);
|
IReadOnlyCollection<Genre> Tags,
|
||||||
|
bool IsExplicit = false);
|
||||||
|
|||||||
@@ -94,6 +94,14 @@ public static class PlayListEndpoints
|
|||||||
.Produces(StatusCodes.Status404NotFound)
|
.Produces(StatusCodes.Status404NotFound)
|
||||||
.Produces(StatusCodes.Status409Conflict);
|
.Produces(StatusCodes.Status409Conflict);
|
||||||
|
|
||||||
|
group.MapPost("/{playlistId}/albums/{albumId}", AddAlbumToPlayList)
|
||||||
|
.WithName("AddAlbumToPlayList")
|
||||||
|
.WithSummary("Add All The Tracks Of An Album To One Of Your PlayLists, Skipping Those Already In It.")
|
||||||
|
.RequireAuthorization()
|
||||||
|
.Produces<AddAlbumToPlayListResponse>()
|
||||||
|
.Produces(StatusCodes.Status401Unauthorized)
|
||||||
|
.Produces(StatusCodes.Status404NotFound);
|
||||||
|
|
||||||
group.MapDelete("/{playlistId}/tracks/{trackId}", RemoveTrackFromPlayList)
|
group.MapDelete("/{playlistId}/tracks/{trackId}", RemoveTrackFromPlayList)
|
||||||
.WithName("RemoveTrackFromPlayList")
|
.WithName("RemoveTrackFromPlayList")
|
||||||
.WithSummary("Remove A Track From A PlayList.")
|
.WithSummary("Remove A Track From A PlayList.")
|
||||||
@@ -234,6 +242,20 @@ public static class PlayListEndpoints
|
|||||||
return trackResult.ToCreatedResult(track => $"/tracks/{track.Id}");
|
return trackResult.ToCreatedResult(track => $"/tracks/{track.Id}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> AddAlbumToPlayList(
|
||||||
|
IMediator mediator,
|
||||||
|
CurrentUser currentUser,
|
||||||
|
Guid playlistId,
|
||||||
|
Guid albumId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await mediator.Send(
|
||||||
|
new AddAlbumToPlayListCommand(currentUser.RequiredId, playlistId, albumId),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<IResult> RemoveTrackFromPlayList(
|
private static async Task<IResult> RemoveTrackFromPlayList(
|
||||||
IMediator mediator,
|
IMediator mediator,
|
||||||
CurrentUser currentUser,
|
CurrentUser currentUser,
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ public static class TrackEndpoints
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var result = await mediator.Send(
|
var result = await mediator.Send(
|
||||||
new CreateTrackCommand(currentUser.RequiredId, request.Title, request.PictureIntentId, request.AudioIntentId, request.Tags),
|
new CreateTrackCommand(currentUser.RequiredId, request.Title, request.PictureIntentId, request.AudioIntentId, request.Tags, request.IsExplicit),
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
return result.ToCreatedResult(track => $"/tracks/{track.Id}");
|
return result.ToCreatedResult(track => $"/tracks/{track.Id}");
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ public static class UserEndpoints
|
|||||||
group.MapGet("/", GetUsers)
|
group.MapGet("/", GetUsers)
|
||||||
.WithName("GetUsers")
|
.WithName("GetUsers")
|
||||||
.WithSummary("Get Paginated Users.")
|
.WithSummary("Get Paginated Users.")
|
||||||
.Produces<PaginatedResponse<UserApplicationResponse>>();
|
.Produces<PaginatedResponse<UserSummaryResponse>>();
|
||||||
|
|
||||||
group.MapGet("/{id}", GetUserById)
|
group.MapGet("/{id}", GetUserById)
|
||||||
.WithName("GetUserById")
|
.WithName("GetUserById")
|
||||||
@@ -52,6 +52,19 @@ public static class UserEndpoints
|
|||||||
.Produces<UserProfileResponse>()
|
.Produces<UserProfileResponse>()
|
||||||
.Produces(StatusCodes.Status404NotFound);
|
.Produces(StatusCodes.Status404NotFound);
|
||||||
|
|
||||||
|
group.MapGet("/{id}/followers", GetFollowers)
|
||||||
|
.WithName("GetUserFollowers")
|
||||||
|
.WithSummary("Get The Users Following A User. Visible To The User And To Mutual Followers.")
|
||||||
|
.Produces<PaginatedResponse<UserSummaryResponse>>()
|
||||||
|
.Produces(StatusCodes.Status403Forbidden)
|
||||||
|
.Produces(StatusCodes.Status404NotFound);
|
||||||
|
|
||||||
|
group.MapGet("/{id}/following", GetFollowing)
|
||||||
|
.WithName("GetUserFollowing")
|
||||||
|
.WithSummary("Get The Users A User Follows.")
|
||||||
|
.Produces<PaginatedResponse<UserSummaryResponse>>()
|
||||||
|
.Produces(StatusCodes.Status404NotFound);
|
||||||
|
|
||||||
group.MapGet("/{id}/is-following", IsFollowing)
|
group.MapGet("/{id}/is-following", IsFollowing)
|
||||||
.WithName("IsFollowingUser")
|
.WithName("IsFollowingUser")
|
||||||
.WithSummary("Check Whether The Current User Follows Another User.")
|
.WithSummary("Check Whether The Current User Follows Another User.")
|
||||||
@@ -88,12 +101,13 @@ public static class UserEndpoints
|
|||||||
|
|
||||||
private static async Task<IResult> GetUsers(
|
private static async Task<IResult> GetUsers(
|
||||||
IMediator mediator,
|
IMediator mediator,
|
||||||
|
CurrentUser currentUser,
|
||||||
CancellationToken cancellationToken,
|
CancellationToken cancellationToken,
|
||||||
string? usernameSearch,
|
string? usernameSearch,
|
||||||
int pageNumber = 1,
|
int pageNumber = 1,
|
||||||
int pageSize = 10)
|
int pageSize = 10)
|
||||||
{
|
{
|
||||||
var result = await mediator.Send(new GetUsersQuery(pageNumber, pageSize, usernameSearch), cancellationToken);
|
var result = await mediator.Send(new GetUsersQuery(pageNumber, pageSize, usernameSearch, currentUser.Id), cancellationToken);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,10 +147,35 @@ public static class UserEndpoints
|
|||||||
|
|
||||||
private static async Task<IResult> GetUserProfile(
|
private static async Task<IResult> GetUserProfile(
|
||||||
IMediator mediator,
|
IMediator mediator,
|
||||||
|
CurrentUser currentUser,
|
||||||
long id,
|
long id,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var result = await mediator.Send(new GetUserProfileQuery(id), cancellationToken);
|
var result = await mediator.Send(new GetUserProfileQuery(id, currentUser.Id), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> GetFollowers(
|
||||||
|
IMediator mediator,
|
||||||
|
CurrentUser currentUser,
|
||||||
|
long id,
|
||||||
|
CancellationToken cancellationToken,
|
||||||
|
int pageNumber = 1,
|
||||||
|
int pageSize = 20)
|
||||||
|
{
|
||||||
|
var result = await mediator.Send(new GetUserFollowersQuery(currentUser.Id, id, pageNumber, pageSize), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> GetFollowing(
|
||||||
|
IMediator mediator,
|
||||||
|
CurrentUser currentUser,
|
||||||
|
long id,
|
||||||
|
CancellationToken cancellationToken,
|
||||||
|
int pageNumber = 1,
|
||||||
|
int pageSize = 20)
|
||||||
|
{
|
||||||
|
var result = await mediator.Send(new GetUserFollowingQuery(currentUser.Id, id, pageNumber, pageSize), cancellationToken);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+91
@@ -0,0 +1,91 @@
|
|||||||
|
using ErrorOr;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Musify.Application.PlayLists;
|
||||||
|
using Musify.Application.Tests.TestSupport;
|
||||||
|
using Musify.Domain.Entities;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Musify.Application.Tests.PlayLists
|
||||||
|
{
|
||||||
|
public sealed class AddAlbumToPlayListCommandHandlerTests : HandlerTestBase
|
||||||
|
{
|
||||||
|
private AddAlbumToPlayListCommandHandler CreateHandler() => new(Database, NoOpLogger<AddAlbumToPlayListCommandHandler>());
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_AddsAlbumTracksInAlbumOrder()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User();
|
||||||
|
var playList = TestEntities.PlayList(owner.Id);
|
||||||
|
var album = TestEntities.Album(owner.Id);
|
||||||
|
var first = TestEntities.Track(owner, "First");
|
||||||
|
var second = TestEntities.Track(owner, "Second");
|
||||||
|
await SeedAsync(
|
||||||
|
owner, playList, album, first, second,
|
||||||
|
new AlbumHasTrack { AlbumId = album.Id, TrackId = second.Id, TrackNumber = 2 },
|
||||||
|
new AlbumHasTrack { AlbumId = album.Id, TrackId = first.Id, TrackNumber = 1 });
|
||||||
|
|
||||||
|
var result = await CreateHandler().Handle(new AddAlbumToPlayListCommand(owner.Id, playList.Id, album.Id), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.False(result.IsError);
|
||||||
|
Assert.Equal(2, result.Value.AddedCount);
|
||||||
|
var links = await Database.PlayListHasTracks.OrderBy(link => link.Position).ToListAsync(TestContext.Current.CancellationToken);
|
||||||
|
Assert.Equal([first.Id, second.Id], links.Select(link => link.TrackId));
|
||||||
|
Assert.Equal([0, 1], links.Select(link => link.Position));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_SkipsTracksAlreadyInPlayList_AndAppendsAfterThem()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User();
|
||||||
|
var playList = TestEntities.PlayList(owner.Id);
|
||||||
|
var album = TestEntities.Album(owner.Id);
|
||||||
|
var existing = TestEntities.Track(owner, "Existing");
|
||||||
|
var fresh = TestEntities.Track(owner, "Fresh");
|
||||||
|
await SeedAsync(
|
||||||
|
owner, playList, album, existing, fresh,
|
||||||
|
new AlbumHasTrack { AlbumId = album.Id, TrackId = existing.Id, TrackNumber = 1 },
|
||||||
|
new AlbumHasTrack { AlbumId = album.Id, TrackId = fresh.Id, TrackNumber = 2 },
|
||||||
|
new PlayListHasTrack { PlayListId = playList.Id, TrackId = existing.Id, Position = 4 });
|
||||||
|
|
||||||
|
var result = await CreateHandler().Handle(new AddAlbumToPlayListCommand(owner.Id, playList.Id, album.Id), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(1, result.Value.AddedCount);
|
||||||
|
var link = await Database.PlayListHasTracks.SingleAsync(l => l.TrackId == fresh.Id, TestContext.Current.CancellationToken);
|
||||||
|
Assert.Equal(5, link.Position);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_PlayListMissing_ReturnsNotFound()
|
||||||
|
{
|
||||||
|
var result = await CreateHandler().Handle(new AddAlbumToPlayListCommand(1, Guid.NewGuid(), Guid.NewGuid()), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_NotOwner_ReturnsUnauthorized()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User(1, "owner");
|
||||||
|
var stranger = TestEntities.User(2, "stranger");
|
||||||
|
var playList = TestEntities.PlayList(owner.Id);
|
||||||
|
var album = TestEntities.Album(owner.Id);
|
||||||
|
await SeedAsync(owner, stranger, playList, album);
|
||||||
|
|
||||||
|
var result = await CreateHandler().Handle(new AddAlbumToPlayListCommand(stranger.Id, playList.Id, album.Id), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_AlbumMissing_ReturnsNotFound()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User();
|
||||||
|
var playList = TestEntities.PlayList(owner.Id);
|
||||||
|
await SeedAsync(owner, playList);
|
||||||
|
|
||||||
|
var result = await CreateHandler().Handle(new AddAlbumToPlayListCommand(owner.Id, playList.Id, Guid.NewGuid()), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,17 @@ namespace Musify.Application.Tests.Shared
|
|||||||
Assert.Equal("The Owner", response.Artist);
|
Assert.Equal("The Owner", response.Artist);
|
||||||
Assert.Equal(owner.Id, response.OwnerUserId);
|
Assert.Equal(owner.Id, response.OwnerUserId);
|
||||||
Assert.Equal(3, response.ListensCount);
|
Assert.Equal(3, response.ListensCount);
|
||||||
|
Assert.False(response.IsExplicit);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FromEntity_CopiesExplicitFlag()
|
||||||
|
{
|
||||||
|
var track = TestEntities.Track(TestEntities.User(), isExplicit: true);
|
||||||
|
|
||||||
|
var response = TrackApplicationResponse.FromEntity(track, listensCount: 0);
|
||||||
|
|
||||||
|
Assert.True(response.IsExplicit);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,8 @@ namespace Musify.Application.Tests.TestSupport
|
|||||||
TrackPictures? pictures = null,
|
TrackPictures? pictures = null,
|
||||||
TrackAudio? audio = null,
|
TrackAudio? audio = null,
|
||||||
LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active,
|
LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active,
|
||||||
IEnumerable<Genre>? tags = null)
|
IEnumerable<Genre>? tags = null,
|
||||||
|
bool isExplicit = false)
|
||||||
{
|
{
|
||||||
var track = new Track
|
var track = new Track
|
||||||
{
|
{
|
||||||
@@ -64,6 +65,7 @@ namespace Musify.Application.Tests.TestSupport
|
|||||||
NormalizedTitle = title.ToUpperInvariant(),
|
NormalizedTitle = title.ToUpperInvariant(),
|
||||||
DurationSeconds = durationSeconds,
|
DurationSeconds = durationSeconds,
|
||||||
LifeCycleStatus = lifeCycleStatus,
|
LifeCycleStatus = lifeCycleStatus,
|
||||||
|
IsExplicit = isExplicit,
|
||||||
OwnerUserId = owner.Id,
|
OwnerUserId = owner.Id,
|
||||||
Owner = owner,
|
Owner = owner,
|
||||||
Pictures = pictures ?? ProcessedPictures(),
|
Pictures = pictures ?? ProcessedPictures(),
|
||||||
|
|||||||
@@ -58,6 +58,26 @@ namespace Musify.Application.Tests.Tracks
|
|||||||
Assert.Equal(ProcessingStatus.Pending, stored.Audio.TranscodeStatus);
|
Assert.Equal(ProcessingStatus.Pending, stored.Audio.TranscodeStatus);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(true)]
|
||||||
|
[InlineData(false)]
|
||||||
|
public async Task Handle_StoresExplicitFlag(bool isExplicit)
|
||||||
|
{
|
||||||
|
var user = TestEntities.User();
|
||||||
|
var (picture, audio) = SeedIntents(user.Id);
|
||||||
|
await SeedAsync(user, picture, audio);
|
||||||
|
|
||||||
|
var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, [Genre.Pop], isExplicit);
|
||||||
|
|
||||||
|
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.False(result.IsError);
|
||||||
|
Assert.Equal(isExplicit, result.Value.IsExplicit);
|
||||||
|
var stored = await Database.Tracks.FindAsync([result.Value.Id], TestContext.Current.CancellationToken);
|
||||||
|
Assert.NotNull(stored);
|
||||||
|
Assert.Equal(isExplicit, stored.IsExplicit);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Handle_NoTags_ReturnsValidationError()
|
public async Task Handle_NoTags_ReturnsValidationError()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
using ErrorOr;
|
||||||
|
using Musify.Application.Shared;
|
||||||
|
using Musify.Application.Tests.TestSupport;
|
||||||
|
using Musify.Application.Users;
|
||||||
|
using Musify.Application.Users.Responses;
|
||||||
|
using Musify.Domain.Entities;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Musify.Application.Tests.Users
|
||||||
|
{
|
||||||
|
public sealed class GetUserFollowersQueryHandlerTests : HandlerTestBase
|
||||||
|
{
|
||||||
|
private GetUserFollowersQueryHandler CreateHandler() => new(Database);
|
||||||
|
|
||||||
|
private async Task<ErrorOr<PaginatedResponse<UserSummaryResponse>>> Send(long? viewerId, long userId) =>
|
||||||
|
await CreateHandler().Handle(new GetUserFollowersQuery(viewerId, userId, 1, 20), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_OwnProfile_ReturnsFollowers()
|
||||||
|
{
|
||||||
|
var user = TestEntities.User(1, "user");
|
||||||
|
var follower = TestEntities.User(2, "follower");
|
||||||
|
await SeedAsync(user, follower, new UserFollow { FollowerId = follower.Id, FollowedId = user.Id });
|
||||||
|
|
||||||
|
var result = await Send(user.Id, user.Id);
|
||||||
|
|
||||||
|
Assert.False(result.IsError);
|
||||||
|
var item = Assert.Single(result.Value.Items);
|
||||||
|
Assert.Equal(follower.Id, item.Id);
|
||||||
|
Assert.False(item.IsFollowedByViewer);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_MutualFollow_ReturnsFollowersWithViewerFollowState()
|
||||||
|
{
|
||||||
|
var viewer = TestEntities.User(1, "viewer");
|
||||||
|
var user = TestEntities.User(2, "user");
|
||||||
|
var other = TestEntities.User(3, "other");
|
||||||
|
await SeedAsync(
|
||||||
|
viewer, user, other,
|
||||||
|
new UserFollow { FollowerId = viewer.Id, FollowedId = user.Id },
|
||||||
|
new UserFollow { FollowerId = user.Id, FollowedId = viewer.Id },
|
||||||
|
new UserFollow { FollowerId = other.Id, FollowedId = user.Id },
|
||||||
|
new UserFollow { FollowerId = viewer.Id, FollowedId = other.Id });
|
||||||
|
|
||||||
|
var result = await Send(viewer.Id, user.Id);
|
||||||
|
|
||||||
|
Assert.False(result.IsError);
|
||||||
|
Assert.Equal(2, result.Value.TotalItemCount);
|
||||||
|
Assert.All(result.Value.Items, item => Assert.Equal(item.Id == other.Id, item.IsFollowedByViewer));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_ViewerFollowsButNotFollowedBack_ReturnsForbidden()
|
||||||
|
{
|
||||||
|
var viewer = TestEntities.User(1, "viewer");
|
||||||
|
var user = TestEntities.User(2, "user");
|
||||||
|
await SeedAsync(viewer, user, new UserFollow { FollowerId = viewer.Id, FollowedId = user.Id });
|
||||||
|
|
||||||
|
var result = await Send(viewer.Id, user.Id);
|
||||||
|
|
||||||
|
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_FollowedByUserButNotFollowingBack_ReturnsForbidden()
|
||||||
|
{
|
||||||
|
var viewer = TestEntities.User(1, "viewer");
|
||||||
|
var user = TestEntities.User(2, "user");
|
||||||
|
await SeedAsync(viewer, user, new UserFollow { FollowerId = user.Id, FollowedId = viewer.Id });
|
||||||
|
|
||||||
|
var result = await Send(viewer.Id, user.Id);
|
||||||
|
|
||||||
|
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_Anonymous_ReturnsForbidden()
|
||||||
|
{
|
||||||
|
await SeedAsync(TestEntities.User(1, "user"));
|
||||||
|
|
||||||
|
var result = await Send(null, 1);
|
||||||
|
|
||||||
|
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_UserMissing_ReturnsNotFound()
|
||||||
|
{
|
||||||
|
var result = await Send(1, 404);
|
||||||
|
|
||||||
|
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
using ErrorOr;
|
||||||
|
using Musify.Application.Tests.TestSupport;
|
||||||
|
using Musify.Application.Users;
|
||||||
|
using Musify.Domain.Entities;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Musify.Application.Tests.Users
|
||||||
|
{
|
||||||
|
public sealed class GetUserFollowingQueryHandlerTests : HandlerTestBase
|
||||||
|
{
|
||||||
|
private GetUserFollowingQueryHandler CreateHandler() => new(Database);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_Anonymous_ReturnsFollowedUsers()
|
||||||
|
{
|
||||||
|
var user = TestEntities.User(1, "user");
|
||||||
|
var followedA = TestEntities.User(2, "followedA");
|
||||||
|
var followedB = TestEntities.User(3, "followedB");
|
||||||
|
await SeedAsync(
|
||||||
|
user, followedA, followedB,
|
||||||
|
new UserFollow { FollowerId = user.Id, FollowedId = followedA.Id },
|
||||||
|
new UserFollow { FollowerId = user.Id, FollowedId = followedB.Id });
|
||||||
|
|
||||||
|
var result = await CreateHandler().Handle(new GetUserFollowingQuery(null, user.Id, 1, 20), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.False(result.IsError);
|
||||||
|
Assert.Equal(2, result.Value.TotalItemCount);
|
||||||
|
Assert.All(result.Value.Items, item => Assert.False(item.IsFollowedByViewer));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_Viewer_MarksUsersTheViewerFollows()
|
||||||
|
{
|
||||||
|
var viewer = TestEntities.User(1, "viewer");
|
||||||
|
var user = TestEntities.User(2, "user");
|
||||||
|
var followedA = TestEntities.User(3, "followedA");
|
||||||
|
var followedB = TestEntities.User(4, "followedB");
|
||||||
|
await SeedAsync(
|
||||||
|
viewer, user, followedA, followedB,
|
||||||
|
new UserFollow { FollowerId = user.Id, FollowedId = followedA.Id },
|
||||||
|
new UserFollow { FollowerId = user.Id, FollowedId = followedB.Id },
|
||||||
|
new UserFollow { FollowerId = viewer.Id, FollowedId = followedA.Id });
|
||||||
|
|
||||||
|
var result = await CreateHandler().Handle(new GetUserFollowingQuery(viewer.Id, user.Id, 1, 20), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.False(result.IsError);
|
||||||
|
Assert.All(result.Value.Items, item => Assert.Equal(item.Id == followedA.Id, item.IsFollowedByViewer));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_Pagination_ReturnsRequestedPage()
|
||||||
|
{
|
||||||
|
await SeedAsync(
|
||||||
|
TestEntities.User(1, "user"), TestEntities.User(2, "a"), TestEntities.User(3, "b"), TestEntities.User(4, "c"),
|
||||||
|
new UserFollow { FollowerId = 1, FollowedId = 2 },
|
||||||
|
new UserFollow { FollowerId = 1, FollowedId = 3 },
|
||||||
|
new UserFollow { FollowerId = 1, FollowedId = 4 });
|
||||||
|
|
||||||
|
var result = await CreateHandler().Handle(new GetUserFollowingQuery(null, 1, 2, 2), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.False(result.IsError);
|
||||||
|
Assert.Single(result.Value.Items);
|
||||||
|
Assert.Equal(3, result.Value.TotalItemCount);
|
||||||
|
Assert.False(result.Value.HasNextPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_UserMissing_ReturnsNotFound()
|
||||||
|
{
|
||||||
|
var result = await CreateHandler().Handle(new GetUserFollowingQuery(null, 404, 1, 20), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,46 @@ namespace Musify.Application.Tests.Users
|
|||||||
Assert.Equal(1, result.Value.FollowingCount);
|
Assert.Equal(1, result.Value.FollowingCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_MutualFollow_ViewerCanSeeFollowersAndIsFollowing()
|
||||||
|
{
|
||||||
|
var viewer = TestEntities.User(1, "viewer");
|
||||||
|
var user = TestEntities.User(2, "user");
|
||||||
|
await SeedAsync(
|
||||||
|
viewer, user,
|
||||||
|
new UserFollow { FollowerId = viewer.Id, FollowedId = user.Id },
|
||||||
|
new UserFollow { FollowerId = user.Id, FollowedId = viewer.Id });
|
||||||
|
|
||||||
|
var result = await CreateHandler().Handle(new GetUserProfileQuery(user.Id, viewer.Id), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.Value.IsFollowing);
|
||||||
|
Assert.True(result.Value.CanViewFollowers);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_OneWayFollow_ViewerCannotSeeFollowers()
|
||||||
|
{
|
||||||
|
var viewer = TestEntities.User(1, "viewer");
|
||||||
|
var user = TestEntities.User(2, "user");
|
||||||
|
await SeedAsync(viewer, user, new UserFollow { FollowerId = viewer.Id, FollowedId = user.Id });
|
||||||
|
|
||||||
|
var result = await CreateHandler().Handle(new GetUserProfileQuery(user.Id, viewer.Id), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.True(result.Value.IsFollowing);
|
||||||
|
Assert.False(result.Value.CanViewFollowers);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_OwnProfile_CanSeeFollowersButIsNotFollowing()
|
||||||
|
{
|
||||||
|
await SeedAsync(TestEntities.User(1, "user"));
|
||||||
|
|
||||||
|
var result = await CreateHandler().Handle(new GetUserProfileQuery(1, 1), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.False(result.Value.IsFollowing);
|
||||||
|
Assert.True(result.Value.CanViewFollowers);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Handle_UserMissing_ReturnsNotFound()
|
public async Task Handle_UserMissing_ReturnsNotFound()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Musify.Application.Tests.TestSupport;
|
using Musify.Application.Tests.TestSupport;
|
||||||
using Musify.Application.Users;
|
using Musify.Application.Users;
|
||||||
|
using Musify.Domain.Entities;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Application.Tests.Users
|
namespace Musify.Application.Tests.Users
|
||||||
@@ -30,5 +31,18 @@ namespace Musify.Application.Tests.Users
|
|||||||
var user = Assert.Single(result.Value.Items);
|
var user = Assert.Single(result.Value.Items);
|
||||||
Assert.Equal(1, user.Id);
|
Assert.Equal(1, user.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_Viewer_MarksFollowedUsers()
|
||||||
|
{
|
||||||
|
await SeedAsync(
|
||||||
|
TestEntities.User(1, "viewer"), TestEntities.User(2, "alice"), TestEntities.User(3, "bob"),
|
||||||
|
new UserFollow { FollowerId = 1, FollowedId = 2 });
|
||||||
|
|
||||||
|
var result = await CreateHandler().Handle(new GetUsersQuery(PageNumber: 1, PageSize: 10, UsernameSearch: null, ViewerId: 1), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.False(result.IsError);
|
||||||
|
Assert.All(result.Value.Items, item => Assert.Equal(item.Id == 2, item.IsFollowedByViewer));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ ZITADEL_CLIENT_SECRET=your-web-app-client-secret
|
|||||||
AUTH_REDIRECT_URI=http://localhost:5173/auth/callback
|
AUTH_REDIRECT_URI=http://localhost:5173/auth/callback
|
||||||
AUTH_POST_LOGOUT_URI=http://localhost:5173/
|
AUTH_POST_LOGOUT_URI=http://localhost:5173/
|
||||||
|
|
||||||
# Secret used to derive the session cookie encryption key (any long random string)
|
# Secret used to derive the session cookie encryption key. Required, at least
|
||||||
SESSION_SECRET=change-me-to-a-long-random-string
|
# 32 characters (e.g. `openssl rand -base64 48`).
|
||||||
|
SESSION_SECRET=change-me-to-a-long-random-string-of-32-chars-or-more
|
||||||
|
|
||||||
# Backend API base URL
|
# Backend API base URL
|
||||||
API_BASE_URL=http://localhost:5111
|
API_BASE_URL=http://localhost:5111
|
||||||
|
|||||||
+50
-21
@@ -1,6 +1,6 @@
|
|||||||
# Musify: web-player
|
# Musify: web-player
|
||||||
|
|
||||||
Musify's public web app (`web-player/` folder). For design and UI recipes, use the **`musify-web`** skill.
|
Musify's public web app (`web-player/` folder). Design and UI recipes live in this file and in `src/routes/layout.css`.
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
@@ -15,7 +15,8 @@ npm run dev # dev server (port 5173)
|
|||||||
npm run build
|
npm run build
|
||||||
npm run check # svelte-check, must exit 0 errors before closing a change
|
npm run check # svelte-check, must exit 0 errors before closing a change
|
||||||
npm run format # prettier --write .
|
npm run format # prettier --write .
|
||||||
npm run lint # prettier --check . && eslint . && npm run lint:tokens
|
npm run test # vitest run (tests live next to the file they test)
|
||||||
|
npm run lint # prettier --check . && eslint . && npm run lint:tokens && npm run test
|
||||||
npm run lint:tokens # scripts/check-tokens.mjs, the design system guardrail (see below)
|
npm run lint:tokens # scripts/check-tokens.mjs, the design system guardrail (see below)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ npm run lint:tokens # scripts/check-tokens.mjs, the design system guardrail
|
|||||||
|
|
||||||
- **No comments** in code unless essential.
|
- **No comments** in code unless essential.
|
||||||
- `npm run lint` and `npm run check` must exit clean before closing a change.
|
- `npm run lint` and `npm run check` must exit clean before closing a change.
|
||||||
|
- **Imports:** always `$lib/...`, never `../` (ESLint enforces it). Non-null assertions are forbidden too.
|
||||||
- **Always Svelte 5 runes:** `$props()` with `interface Props`, `$state`, `$derived`, `$effect`. No `export let` or `$:`. `{#each}` always keyed.
|
- **Always Svelte 5 runes:** `$props()` with `interface Props`, `$state`, `$derived`, `$effect`. No `export let` or `$:`. `{#each}` always keyed.
|
||||||
- **Server only code goes under `$lib/server/`**, and it's never imported from client code.
|
- **Server only code goes under `$lib/server/`**, and it's never imported from client code.
|
||||||
- SSR data comes through `load` in `+page.server.ts`/`+layout.server.ts` and lands in the `data` prop.
|
- SSR data comes through `load` in `+page.server.ts`/`+layout.server.ts` and lands in the `data` prop.
|
||||||
@@ -31,39 +33,57 @@ npm run lint:tokens # scripts/check-tokens.mjs, the design system guardrail
|
|||||||
|
|
||||||
## Folder structure
|
## Folder structure
|
||||||
|
|
||||||
- `src/lib/state/` holds reactive rune based state (`*.svelte.ts` files that don't have a more specific home). Player state stays in `$lib/player/`, theme mode in `$lib/theme/`.
|
- `src/lib/types.ts` holds the domain types (`Track`, `Album`, `Playlist`, `Mix`, `ListeningStats`, `Paged<T>`). Pages and the player only ever see these, never raw API DTOs.
|
||||||
- `src/lib/data/` holds client side domain helpers: albums, collections, genres, mixes, recentlyPlayed, search.
|
- `src/lib/player/` is the player: `player.svelte.ts` (state and orchestration), `queue.ts` (pure queue operations), `stream.ts`, `mediaSession.ts`, `progressClock.ts`, `listenTracker.ts`, `recentlyPlayed.ts` (merges `player.recentlyPlayed` with the server history), and `actions.ts` (`playAllOrToggle`, `playShuffled`, `isQueueCurrent`).
|
||||||
- `src/lib/utils/` holds generic utilities with no domain knowledge, like `format.ts`.
|
- `src/lib/state/` holds reactive rune state: `panels.svelte.ts` (queue panel, create playlist modal), `history.svelte.ts` (previous page for `BackLink`), `menu.svelte.ts` (`createMenu<T>()`), `pagedList.svelte.ts` (`createPagedList`), `dialog.svelte.ts`, `liked.svelte.ts` (the user's liked tracks), `toggle.svelte.ts` (`createToggle`), and `scroll.ts` (`restoreScroll`). Nothing in `state/` imports from `components/`.
|
||||||
- `src/lib/components/layout/` holds the app chrome: Sidebar, TopBar, TabsBar, MobileHeader.
|
- `src/lib/theme/` holds the theme: `accent.svelte.ts` (accent hue per track and the hue fade), `mode.svelte.ts`, `palette.ts`, `tokens.ts`, and `color.ts` (`ACCENT_HUE`). The player never imports from `theme/`: `(app)/+layout.svelte` follows `player.currentId` and calls `accent.follow`.
|
||||||
|
- `src/lib/data/` holds client side domain helpers: albums, genres, search. It never imports from `player/`.
|
||||||
|
- `src/lib/utils/` holds generic utilities: `format.ts`, `collections.ts`, `hrefs.ts` (search, genre and cover URLs), `menuPosition.ts`, `storage.ts`, `transitions.ts`.
|
||||||
|
- `src/lib/actions/` holds Svelte actions: `clickOutside`, `onEscape`, `pressable`.
|
||||||
|
- `src/lib/validation.ts` holds the form limits (`LIMITS`) shared by client forms and server parsers.
|
||||||
|
- `src/lib/server/` is server only code (see below).
|
||||||
|
- `src/lib/components/shell/` holds the app shell: Sidebar, TopBar, TabsBar, MobileHeader, AccountMenu, and `navLinks.ts`.
|
||||||
- `src/lib/components/player/` holds the player UI: PlayerBar, PlayerDock, Queue, TrackInfo, TransportControls.
|
- `src/lib/components/player/` holds the player UI: PlayerBar, PlayerDock, Queue, TrackInfo, TransportControls.
|
||||||
- `src/lib/components/ui/` holds the design system components, split into five categories:
|
- `src/lib/components/music/` holds the components that connect the UI to app state (the player, album data): TrackList, PlayAllButton, TrackContextMenu, AlbumContextMenu.
|
||||||
|
- `src/lib/components/ui/` holds the design system, which is presentation only. ESLint forbids importing `$lib/player/*`, `$lib/data/*`, `$lib/server/*`, `components/music`, `components/shell` and `components/player` from it; it can use `$lib/state/*`, `$lib/utils/*`, `$lib/actions/*`, `$lib/theme/*` and `$lib/types`. It is split into five categories:
|
||||||
- `primitives/` for generic controls with no music domain knowledge (Button, Input, Chip, Slider, and so on).
|
- `primitives/` for generic controls with no music domain knowledge (Button, Input, Chip, Slider, and so on).
|
||||||
- `overlay/` for anything that floats above the content (Modal, ContextMenu, AccountMenu, and so on).
|
- `overlay/` for anything that floats above the content (Modal, ContextMenu, GlassMenu, ConfirmDialog, and so on).
|
||||||
- `media/` for music domain display components (Artwork, TrackList, MediaCard, PlayButton, and so on).
|
- `media/` for stateless music display components that receive props (Artwork, MediaCard, ListRow, PlayButton, and so on).
|
||||||
- `forms/` for composite entity forms (AlbumForm, PlaylistForm, CoverForm, ImageDropzone).
|
- `forms/` for composite entity forms (AlbumForm, PlaylistForm, CoverForm, ImageDropzone).
|
||||||
- `layout/` for page level scaffolding (Page, PageHeader, SectionHeading). This is different from `components/layout/` above, which is the global app chrome.
|
- `layout/` for page level scaffolding (Page, PageHeader, SectionHeading).
|
||||||
- Auth routes live under `src/routes/auth/`: `+page.svelte` is the login screen, and `login/`, `logout/`, `callback/` are the OIDC endpoints. The public user profile route is `src/routes/(app)/user/[id]/`.
|
- The root `+layout.svelte` only holds the globals (fonts, theme, `DialogHost`). Everything the user sees after signing in lives under `src/routes/(app)/`, including Home (`(app)/+page.svelte`); `(app)/+layout.server.ts` guards the group and loads the shell data, and `(app)/+error.svelte` renders inside the shell. Unknown URLs hit the catch-all `(app)/[...rest]/` (it throws a 404) so they also get the shell (unknown `/api/...` URLs hit `api/[...rest]/`, a JSON 404); the root `+error.svelte` only covers errors outside the group or in the group layout load. Auth routes live under `src/routes/auth/`: `+page.svelte` is the login screen, and `login/`, `logout/`, `callback/` are the OIDC endpoints. The public user profile route is `src/routes/(app)/user/[id]/`, and its followers/following lists share one route, `user/[id]/[list=followList]/` (matcher in `src/params/followList.ts`).
|
||||||
|
|
||||||
|
## Naming conventions and glossary
|
||||||
|
|
||||||
|
- Variant maps are module level constants in upper case (`SIZE`, `VARIANT`).
|
||||||
|
- Errors: destructure as `const { data, error } = ...` and name the caught value `err` in `catch (err)`.
|
||||||
|
- Play handlers: `playFrom(index)` and `playAll()`. Form actions: `create`, `edit`, `delete`, `addTrack`, `removeTrack`.
|
||||||
|
- UI copy: "Playlist" (never "Lista"), "Mezcla" (never "Mix"), "Modo claro". Total durations always go through `fmtDurationLong`.
|
||||||
|
|
||||||
## Styles
|
## Styles
|
||||||
|
|
||||||
The design system lives in `src/routes/layout.css` (`@theme` tokens plus `@utility` recipes) and in `src/lib/theme/` (`theme.css` for the static neutrals, `tokens.ts` for the dynamic tokens that depend on the user's hue). `scripts/check-tokens.mjs` (run as `npm run lint:tokens`, part of `lint`) fails on a stock radius, an `h-N w-N` pair, fractional quarter spacing, a literal hex/`rgba()`, or an arbitrary `-[…]` value outside its whitelist, so check that script before adding one.
|
The design system lives in `src/routes/layout.css` (`@theme` tokens plus `@utility` recipes) and in `src/lib/theme/` (`theme.css` for the static neutrals, `tokens.ts` for the dynamic tokens that depend on the user's hue). `scripts/check-tokens.mjs` (run as `npm run lint:tokens`, part of `lint`) fails on a stock radius, an `h-N w-N` pair, fractional quarter spacing, a literal hex/`rgba()`, or an arbitrary `-[…]` value outside its whitelist, so check that script before adding one.
|
||||||
|
|
||||||
- **Radii:** always `rounded-tag/thumb/control/art/art-lg/panel/panel-lg` (or `rounded-full` for pills and circles). Never `rounded-sm/md/lg/xl/2xl/3xl` or a bare `rounded`.
|
- **Radii:** always `rounded-tag/thumb/control/art/art-lg/panel/panel-lg` (or `rounded-full` for pills and circles). Never `rounded-sm/md/lg/xl/2xl/3xl` or a bare `rounded`.
|
||||||
- **Square sizes:** use `size-*` instead of repeating `h-N w-N` (icons: `size-icon-xs/sm/md/lg/xl`; covers: `size-cover-xs` through `size-cover-hero`).
|
- **Square sizes:** use `size-*` instead of repeating `h-N w-N` (icons: `size-icon-xs/sm/md/lg/xl/2xl/3xl`; covers: `size-cover-xs` through `size-cover-hero`).
|
||||||
- **Typography:** use the recipes `text-display-1/2/3/4`, `text-eyebrow`, `text-body`, `text-count`, with tracking through `tracking-display`/`tracking-eyebrow` rather than a loose `tracking-[…]`.
|
- **Typography:** use the recipes `text-display-1/2/3/4`, `text-eyebrow`, `text-body`, with tracking through `tracking-display`/`tracking-eyebrow` rather than a loose `tracking-[…]`.
|
||||||
- **Spacing:** stick to the Tailwind scale in `.5` steps and avoid quarters like `.25`/`.75`, unless the semantic token calls for it (for example `mb-4.5` in `SectionHeading`).
|
- **Spacing:** stick to the Tailwind scale in `.5` steps and avoid quarters like `.25`/`.75`, unless the semantic token calls for it (for example `mb-4.5` in `SectionHeading`).
|
||||||
- **Colors:** always use `--mf-*`/`text-*`/`bg-*`/`border-*` from `theme.css`/`tokens.ts`. Never a literal hex or `rgba()` inside a `.svelte` file; those literals only belong in `theme.css`/`tokens.ts` as the source of truth.
|
- **Colors:** always use `--mf-*`/`text-*`/`bg-*`/`border-*` from `theme.css`/`tokens.ts`. Never a literal hex or `rgba()` inside a `.svelte` file; those literals only belong in `theme.css`/`tokens.ts` as the source of truth.
|
||||||
- **Inline `style="…"` and arbitrary `-[…]` values:** only the ones whitelisted in `scripts/check-tokens.mjs` (the stagger variable `--i`, tile hue, menu position, progress width, `text-[clamp(…)]`, `leading-[…]` on login's display type, `grid-cols-[auto_1fr]`, `max-h-[85dvh]`, `my-`/`py-[Ndvh]` for vertical centering, `transition-[filter]` on genre tiles, and `bg-[image:var(--mf-*)]`). Everything else should become a token.
|
- **Inline `style="…"` and arbitrary `-[…]` values:** only the ones whitelisted in `scripts/check-tokens.mjs` (the stagger variable `--i`, tile hue, menu position, progress width, `text-[clamp(…)]`, `leading-[…]` on login's display type, `grid-cols-[auto_1fr]`, `max-h-[85dvh]`, `my-`/`py-[Ndvh]` for vertical centering, `transition-[filter]` on genre tiles, and `bg-[image:var(--mf-*)]`). Everything else should become a token.
|
||||||
- Reach for these base components before creating a new one: `Artwork`, `Avatar`, `ListRow`, `MediaIdentity`, `TrackList`, `MediaCard`, `PageHeader`, `ContextMenu`, `ConfirmDialog`, `PlayButton`, `Slider`, `Logo`, `Chip`, `SegmentedControl`, `CoverForm`.
|
- Reach for these base components before creating a new one: `Artwork`, `Avatar`, `ListRow`, `MediaIdentity`, `TrackList`, `MediaCard`, `PageHeader`, `ContextMenu`, `ConfirmDialog`, `PlayButton`, `Slider`, `Logo`, `Chip`, `SegmentedControl`, `CoverForm`.
|
||||||
- Known and accepted gap (documented in `scripts/check-tokens.mjs`): icon `strokeWidth={n}` isn't unified yet. There are 13 different values spread across almost every component, and migrating them to `stroke-thin/regular/bold` recipes safely would require visually confirming that `lucide-svelte` actually respects `stroke-width` through CSS, so that was left out of the last pass.
|
- **Icon stroke:** one global rule (`svg.lucide { stroke-width: 1.5 }` in `layout.css`) sets the stroke for every icon. Never pass `strokeWidth` to an icon, since any CSS rule overrides the SVG attribute and it would do nothing. For a different stroke on one icon, use a Tailwind `stroke-*` utility.
|
||||||
|
|
||||||
|
## App shell
|
||||||
|
|
||||||
|
The shell in `src/routes/(app)/+layout.svelte` is a fixed `h-dvh` frame: Sidebar | (content column over PlayerDock) | Queue. Only the content column (`app-scroll`) scrolls, so nothing ever passes under the dock and the chrome can stay transparent over `app-backdrop`. Because the window never scrolls, scroll reset/restore on navigation is handled by `restoreScroll()` in `$lib/state/scroll.ts`; don't use `window.scrollTo`/`scrollY`.
|
||||||
|
|
||||||
## Motion
|
## Motion
|
||||||
|
|
||||||
Every animation follows one scale of three durations and one easing curve, all defined in `theme.css`. Fast (`--mf-motion-fast`, 150ms) is for hover, focus and press feedback: color, opacity and small scale changes. Plain `transition` or `transition-colors` already uses it because it is the Tailwind default, so never write `duration-150`. Base (`--mf-motion-base`, 200ms) is for things that appear on top of the page or move inside a control: modals, menus, the queue panel, `animate-pop`, `animate-fade` and the segmented control thumb. Slow (`--mf-motion-slow`, 300ms) is for content entering the page and larger movement: `animate-enter`, cover zoom on hover and the player dock padding.
|
Every animation follows one scale of three durations and one easing curve, all defined in `theme.css`. Fast (`--mf-motion-fast`, 150ms) is for hover, focus and press feedback: color, opacity and small scale changes. Plain `transition` or `transition-colors` already uses it because it is the Tailwind default, so never write `duration-150`. Base (`--mf-motion-base`, 200ms) is for things that appear on top of the page or move inside a control: modals, menus, the queue panel, `animate-pop`, `animate-fade` and the segmented control thumb. Slow (`--mf-motion-slow`, 300ms) is for content entering the page and larger movement: `animate-enter`, cover zoom on hover.
|
||||||
|
|
||||||
Entrances and movement always use `--mf-ease` (`ease-snappy` in Tailwind, `expoOut` in Svelte transitions), which feels immediate because most of the motion happens at the start. Color transitions keep the Tailwind default curve. Overlays must feel instant: the modal backdrop uses `transition:fade={{ duration: 200 }}` and the panel uses a local `pop` transition (opacity + scale + translateY, `expoOut`, 200ms) mirroring `animate-pop`'s keyframes — both directives so the modal fades and pops on close too, not just on open, and the whole thing settles in 200ms. Staggered lists use `--i` with 40ms steps capped at ten items.
|
Entrances and movement always use `--mf-ease` (`ease-snappy` in Tailwind, `expoOut` in Svelte transitions), which feels immediate because most of the motion happens at the start. Color transitions keep the Tailwind default curve. Overlays must feel instant: the modal backdrop uses `transition:fade={{ duration: 200 }}` and the panel uses a local `pop` transition (opacity + scale + translateY, `expoOut`, 200ms) mirroring `animate-pop`'s keyframes — both directives so the modal fades and pops on close too, not just on open, and the whole thing settles in 200ms. Staggered lists use `--i` with 40ms steps capped at ten items.
|
||||||
|
|
||||||
The only exceptions are looping indicators (equalizer bars, pulse, spin, sheen) and the accent hue crossfade in `+layout.svelte` (600ms), which is ambient and meant to be slow. Do not add new durations or curves; if something needs a different feel, pick the closest step of the scale. `prefers-reduced-motion` disables everything globally in `layout.css`.
|
The only exceptions are looping indicators (equalizer bars, pulse, spin, sheen) and the accent hue crossfade in the root `+layout.svelte` (a random 2 to 4 seconds per song change), which is ambient and meant to be slow. Do not add new durations or curves; if something needs a different feel, pick the closest step of the scale. `prefers-reduced-motion` disables everything globally in `layout.css`.
|
||||||
|
|
||||||
## Auth
|
## Auth
|
||||||
|
|
||||||
@@ -73,12 +93,21 @@ Environment variables live in `.env` (see `.env.example`): `ZITADEL_*`, `AUTH_RE
|
|||||||
|
|
||||||
## Backend API
|
## Backend API
|
||||||
|
|
||||||
Typed client via `openapi-fetch` over the `Musify.Api` OpenAPI spec. Inside a server `load` or action:
|
Typed client via `openapi-fetch` over the `Musify.Api` OpenAPI spec. Inside a server `load` or action use the client in `$lib/server/api.ts`:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { createApiClient } from '$lib/server/api';
|
const api = apiFor(event); // or apiFor({ fetch, locals }); adds the bearer token when there is a session
|
||||||
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
|
const { data, error } = await api.GET('/tracks');
|
||||||
const { data, error: err } = await api.GET('/tracks');
|
|
||||||
```
|
```
|
||||||
|
|
||||||
It returns `{ data, error }` (ErrorOr style) and never throws. The types live in `src/lib/api/schema.d.ts` and get regenerated with `npm run gen:api` (this needs the dev API running at `API_BASE_URL`, which only exposes `/openapi/v1.json` in Development).
|
It returns `{ data, error }` (ErrorOr style) and never throws except on network errors. The types live in `src/lib/api/schema.d.ts` and get regenerated with `npm run gen:api` (this needs the dev API running at `API_BASE_URL`, which only exposes `/openapi/v1.json` in Development).
|
||||||
|
|
||||||
|
Server helpers:
|
||||||
|
|
||||||
|
- `api.ts` holds the client and error handling: `apiFor`, `unwrapOrError`, `failOnError` (turns an API error into a `fail(...)`), `requireData` (also narrows the response data) and `apiErrorDetail`.
|
||||||
|
- `guards.ts` holds `requireUser`, `optionalUser` and `loginRedirect`. Keep them in pages and actions even though the `(app)` layout also redirects: page loads run in parallel with the layout load, and form actions skip it.
|
||||||
|
- `mappers.ts` (`toTrack`, `toAlbum`, `toPlaylist`, `toMix`, `toPage`, `toCount`, `toListeningStats`, ...) turn DTOs into the domain types. Every load and `/api` endpoint goes through them, so `Number()` conversions on DTOs never reach routes or the client.
|
||||||
|
- `actions/` holds `authedAction(handler)` (returns 401 without a token, reads the form and hands `{ api, form, params, locals, url }` to the handler) plus the shared `follow` and `playlist` actions.
|
||||||
|
- `forms/` holds `fields.ts` (`formString`, `formFile`) and the form parsers (`parseAlbumForm`, `parsePlaylistForm`, `parseTrackUploadForm`); they validate with `LIMITS` from `$lib/validation`. `uploadOptionalCover` handles the optional cover upload.
|
||||||
|
- `genres.ts` caches the genre list; `playbackConfig.ts` caches the anonymous listening setting.
|
||||||
|
- Page sizes come from `$lib/config`; do not hardcode them.
|
||||||
|
|||||||
@@ -31,5 +31,4 @@ Built and run as a container by the production stack — see
|
|||||||
[../deploy/README.md](../deploy/README.md). There the same variables come from
|
[../deploy/README.md](../deploy/README.md). There the same variables come from
|
||||||
`deploy/.env.prod` through compose instead of from `.env`.
|
`deploy/.env.prod` through compose instead of from `.env`.
|
||||||
|
|
||||||
Conventions and the design system are documented in [CLAUDE.md](CLAUDE.md) and
|
Conventions and the design system are documented in [CLAUDE.md](CLAUDE.md).
|
||||||
in the `musify-web` skill.
|
|
||||||
|
|||||||
@@ -0,0 +1,315 @@
|
|||||||
|
# Plan de refactor tras la auditoría del frontend
|
||||||
|
|
||||||
|
> **Cerrado.** Todas las partes están hechas; el plan activo es `frontend-structure-plan.md`.
|
||||||
|
|
||||||
|
Plan de trabajo derivado de la auditoría del `web-player` (2026-09-24). Cada parte es un PR independiente, revisable por separado, que deja `npm run check` y `npm run lint` en verde. Antes de empezar una parte, mira el **Registro** del final.
|
||||||
|
|
||||||
|
Leyenda de tamaño: **S** (< 1 h) · **M** (medio día) · **L** (1 día o más). Las partes marcadas con 🔗 dependen de cambios en `Musify.Api`.
|
||||||
|
|
||||||
|
## Orden y dependencias
|
||||||
|
|
||||||
|
```
|
||||||
|
P0 Red de seguridad
|
||||||
|
└─ P1 Seguridad ─ P2 Reproductor ─ P3 Bugs de páginas ─ P4 Código muerto
|
||||||
|
└─ P5 Modelo Track único
|
||||||
|
├─ P6 Capa de servidor ─ P7 Formularios y validación
|
||||||
|
│ └─ P8 Rendimiento 🔗
|
||||||
|
└─ P9 Responsabilidades ─ P10 Componentes y páginas
|
||||||
|
└─ P11 Consistencia ─ P12 Guardarraíles y docs
|
||||||
|
```
|
||||||
|
|
||||||
|
P1 a P4 son arreglos acotados y se pueden mergear en cualquier orden. De P5 en adelante el orden importa: cada parte simplifica la siguiente.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P0 · Red de seguridad (M)
|
||||||
|
|
||||||
|
Objetivo: tener tests de las funciones puras antes de tocar lógica.
|
||||||
|
|
||||||
|
- Añadir Vitest (`vitest`, script `npm run test`, incluido en `lint` o en CI).
|
||||||
|
- Tests para lo que ya es puro: `fmtTime`, `fmtDurationLong`, `plural`, `albumMeta` (`utils/format.ts`), `findTopResult` (`data/search.ts`), `findConflict`, `genreHue` (`data/genres.ts`), `pickGreeting` (`server/greeting.ts`), `parseAlbumForm` (`server/albumForm.ts`), `appendUnique`, `shuffle`.
|
||||||
|
- Extraer las operaciones de cola de `PlayerState` a funciones puras en `player/queue.ts` (`insertNext`, `append`, `move`, `nextIndex`, `previousIndex`) y testearlas. `PlayerState` pasa a llamarlas. Esto prepara P2.
|
||||||
|
|
||||||
|
**Cierre:** `npm run test` en verde y cubre las funciones anteriores.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P1 · Seguridad (S)
|
||||||
|
|
||||||
|
1. **Redirección abierta con `returnTo`.**
|
||||||
|
- Nueva función `safeReturnTo(value: string | null | undefined): string` en `$lib/server/auth.ts`: acepta solo rutas que empiecen por `/` y no por `//` ni `/\`; si no, devuelve `/`.
|
||||||
|
- Aplicarla en `routes/auth/+page.server.ts` (ambos usos), en `routes/auth/login/+server.ts` (antes de guardar la cookie) y en `routes/auth/callback/+server.ts` (al leer la cookie).
|
||||||
|
2. **`SESSION_SECRET` obligatorio.** En `$lib/server/config.ts`, `sessionSecret` pasa por `required()` y exige una longitud mínima de 32 caracteres. Actualizar el comentario de `.env.example`.
|
||||||
|
3. **Validar las entradas de los endpoints `/api`.**
|
||||||
|
- `api/likes/+server.ts`: no reenviar el body tal cual; leer `trackId`, comprobar que es un string no vacío y devolver 400 si no.
|
||||||
|
- `api/listens/[id]/progress/+server.ts`: envolver `request.json()` para devolver 400 con JSON inválido en vez de 500.
|
||||||
|
- `server/image.ts`: `encodeURIComponent(id)` al construir la URL de upstream.
|
||||||
|
- `api/tracks/+server.ts`: acotar `pageSize` con un máximo (`MAX_PAGE_SIZE` en `config.ts`).
|
||||||
|
|
||||||
|
**Cierre:** `/auth?returnTo=https://example.com` acaba en `/`; arrancar sin `SESSION_SECRET` falla con un mensaje claro; tests de `safeReturnTo`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P2 · Reproductor y cola (M)
|
||||||
|
|
||||||
|
1. **Duplicados en la cola** (`player.svelte.ts` `playNext`/`appendToQueue`, `Queue.svelte`).
|
||||||
|
- `playNext` mueve la pista si ya estaba en la cola, en vez de duplicarla.
|
||||||
|
- `appendToQueue` omite las pistas que ya están pendientes.
|
||||||
|
- Con esto las claves `(track.id)` vuelven a ser únicas y `#index()` es fiable.
|
||||||
|
2. **Fin de cola.** Con repetir desactivado, al terminar la última pista el reproductor se detiene en vez de volver al principio. Si más adelante se quiere "repetir todo", `repeat` pasará a `'off' | 'all' | 'one'` (queda fuera de este plan).
|
||||||
|
3. **La cola se abre sola** (`+layout.svelte:44`). Abrirla solo la primera vez que empieza a sonar algo en la sesión (`currentId` pasa de `null` a un valor) y no volver a abrirla si el usuario la cierra.
|
||||||
|
4. **Rollback de "Me gusta"** (`liked.svelte.ts:69`). En caso de error, revertir solo la entrada de ese id, no sustituir el mapa entero.
|
||||||
|
5. **localStorage seguro.**
|
||||||
|
- Nuevo `$lib/utils/storage.ts` con `readStorage(key, fallback)` y `writeStorage(key, value)` envueltos en try/catch.
|
||||||
|
- Volumen: validar y acotar a 0–100 (hoy puede quedar `NaN`).
|
||||||
|
- Unificar claves: `musify.player.volume`, `musify.player.muted`, `musify.theme`.
|
||||||
|
- Persistir el volumen siempre, no solo cuando existe el elemento `<audio>`.
|
||||||
|
|
||||||
|
**Cierre:** añadir la misma pista dos veces a la cola no rompe nada; la cola termina; tests de `player/queue.ts` actualizados; prueba manual en el Browser pane.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P3 · Bugs de páginas y datos (M)
|
||||||
|
|
||||||
|
1. **Permisos en la playlist** (`playlists/[id]`).
|
||||||
|
- El `load` devuelve `isOwner`, comparando el propietario de la playlist con `locals.user.sub`. Si la API no expone el propietario, añadirlo al DTO 🔗.
|
||||||
|
- Mostrar Editar, Eliminar y "Quitar" solo al propietario. Añadir `BackLink` como en el álbum y la mezcla, y `section: '/playlists'` cuando sea propia.
|
||||||
|
2. **Una sola fuente para las playlists del usuario.**
|
||||||
|
- `+layout.server.ts` carga las playlists del usuario con `PLAYLIST_PICKER_PAGE_SIZE` y devuelve también `totalItemCount`.
|
||||||
|
- La barra lateral muestra las 8 primeras y usa el total real en el contador.
|
||||||
|
- Los menús contextuales (inicio, explorar, mezcla) usan esa lista desde `page.data` y así explorar y mezcla dejan de pedirla aparte.
|
||||||
|
3. **Playlist destacada de inicio.**
|
||||||
|
- Nuevo endpoint `api/playlists/[id]/tracks/+server.ts`, análogo al de álbumes. "Reproducir" y "Aleatorio" cargan la lista completa en ese momento.
|
||||||
|
- La meta muestra el total real. La duración se quita hasta que la API la devuelva (P8).
|
||||||
|
- La promesa se resuelve con `{#await}` en vez de con un `$effect` que copia a estado.
|
||||||
|
4. **Borrar canción de la biblioteca con confirmación.** `ConfirmDialog` acepta `fields` (inputs ocultos) y `library` lo usa, igual que álbumes y playlists.
|
||||||
|
5. **Explorar.**
|
||||||
|
- Usar `plural()` para el número de canciones de un álbum.
|
||||||
|
- Contadores de chips y "N coincidencias" a partir de `totalItemCount` de cada respuesta, no de lo cargado.
|
||||||
|
6. **Enlace `/terms`** en la subida: quitar el enlace hasta que exista una página de términos (o crearla, si se decide).
|
||||||
|
7. **`playbackConfig.ts`:** no guardar en caché cuando la respuesta trae `error`.
|
||||||
|
8. **`ImageDropzone`:** liberar la URL anterior con `URL.revokeObjectURL` al cambiar de archivo y al desmontar.
|
||||||
|
9. **Textos.**
|
||||||
|
- "Populares esta semana" duplica "Continuar escuchando": quitar la sección hasta que haya un endpoint real de más escuchadas (o renombrarla a "Escuchado recientemente").
|
||||||
|
- `+error.svelte`: quitar la promesa de modo sin conexión.
|
||||||
|
- "Retomalo" → "Retómalo"; "Modo blanco" → "Modo claro".
|
||||||
|
|
||||||
|
**Cierre:** un no propietario no ve acciones de edición; la barra lateral cuenta bien; la destacada reproduce la lista completa.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P4 · Código muerto (S)
|
||||||
|
|
||||||
|
Borrar sin sustituto:
|
||||||
|
|
||||||
|
- `player.svelte.ts`: `playPlaylist`, `playlistTracks`, `playlists`, interfaz `Playlist`, getter `isPlaying`, `playNextItem` (usar `playNext([item])`).
|
||||||
|
- `theme/palette.ts`: `Accent.gradient` (y con ello el tipo `Accent`, que queda en `string`).
|
||||||
|
- `IconButton`: el mapa `TEXT` y la prop `tone` pasan a `plain` o por defecto; `muted` y `subtle` son idénticos.
|
||||||
|
- `Artwork`: prop `fallback` y sus ramas `gradient` y `none`.
|
||||||
|
- `MediaIdentity.badge`, `FollowButton.userId`, `EqBars.size` y `EqBars.barWidth`.
|
||||||
|
- Acciones `follow` y `unfollow` en `explore/+page.server.ts` y en `user/[id]/[list=followList]/+page.server.ts`, con el `form?.message` que solo servía para ellas.
|
||||||
|
- Utilidades `media-grid` y `text-count` de `layout.css` (y su mención en `CLAUDE.md`).
|
||||||
|
- `TrackList.svelte`: el `use:enhance` redundante (queda `use:enhance`) y los `stopPropagation` de la línea 141.
|
||||||
|
- `+page.server.ts` (inicio): el `redirect` previo a `requireUser`. `+layout.server.ts`: la rama `accountUrl = null`.
|
||||||
|
- Clases que no hacen nada: `hover:text-fg-2` sobre `text-fg-2` (SegmentedControl, Sidebar, ArtistLink) y el `hidden sm:flex` anidado de `PlayerBar`.
|
||||||
|
- Separadores vacíos (`<span></span>`, `<div class="flex-1">`) en `CoverForm` y `TopBar`: sustituir por `ml-auto`.
|
||||||
|
- `sessionCookieOptions` deja de exportarse.
|
||||||
|
|
||||||
|
**Cierre:** `grep` de cada símbolo sin resultados; la app se comporta igual.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P5 · Modelo `Track` único (L)
|
||||||
|
|
||||||
|
Objetivo: una sola forma de pista, normalizada en la frontera del servidor. Es el cambio que más código quita.
|
||||||
|
|
||||||
|
1. **Tipos de dominio** en `$lib/types.ts`:
|
||||||
|
```ts
|
||||||
|
export interface Track {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
artist: string | null;
|
||||||
|
ownerUserId: string | null;
|
||||||
|
explicit: boolean;
|
||||||
|
duration: number;
|
||||||
|
listensCount: number;
|
||||||
|
}
|
||||||
|
export interface LikedTrack extends Track {
|
||||||
|
likedAt: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Más `Album` y `Playlist` normalizados (`trackCount: number`, `releaseYear: number | null`, ids como `string`).
|
||||||
|
2. **Mappers de servidor** en `$lib/server/mappers.ts`: `toTrack`, `toAlbum`, `toPlaylist`, `toMixTrack` (sustituye a `mixItemTrack`). Hacen todas las conversiones `Number()` y `String()`. Todos los `load` y los endpoints `/api` que devuelven pistas los usan.
|
||||||
|
3. **El reproductor usa `Track`.**
|
||||||
|
- Se eliminan `PlayerTrack`, `QueueItem`, `ApiTrackLike`, `LikeToggleInput` y `TrackListTrack` (que pasa a ser `Track & { date?: string | number }`).
|
||||||
|
- Se eliminan `toQueueItems`, `toQueueItem`, `trackFromQueueItem` y `toTrack` del cliente.
|
||||||
|
- `current` pasa a ser `Track | null` en vez del centinela `EMPTY` con `id: ''`.
|
||||||
|
4. **Páginas.** Quitar todos los `Number(...)`, los `?? undefined` y los mapeos intermedios (`listTracks`, `songRows`, `spotlightRows`, `popularTracks`…).
|
||||||
|
|
||||||
|
**Cierre:** `grep -rn "Number(" src/routes --include=*.svelte` vacío; `isExplicit` solo aparece en `mappers.ts`; tests de los mappers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P6 · Capa de servidor (M)
|
||||||
|
|
||||||
|
1. **Helpers en `$lib/server/api.ts`.**
|
||||||
|
- `apiFor(event)` sustituye los 15 `createApiClient({ fetch, accessToken: locals.accessToken ?? undefined })`.
|
||||||
|
- Exportar `type ApiClient` (hoy se redefine en `follows.ts`).
|
||||||
|
- `loginRedirect(url)`, usado por `requireUser`, `optionalUser` y `+layout.server.ts`.
|
||||||
|
2. **Envoltorio de acciones** `authedAction(handler)`: comprueba el token (401 si falta), crea `api`, lee `form` y pasa `{ api, form, params, locals }`. Sustituye el bloque `requireAccessTokenAction` + `typeof` + `formData` + `createApiClient` de las ~10 acciones.
|
||||||
|
3. **Lectura de formularios:** `formString(form, key)` y `formFile(form, key)` (devuelve `null` si el archivo viene vacío). Sustituyen los 17 `String(form.get(...) ?? '')` y los `instanceof File && size > 0`.
|
||||||
|
4. **Nombres.**
|
||||||
|
- `unwrapOrFail` → `failOnError` (no desenvuelve nada).
|
||||||
|
- Desestructurar siempre como `const { data, error } = ...` y usar `catch (err)`.
|
||||||
|
- La acción `rename` de playlist pasa a `edit` y devuelve `{ edited: true }`, como la de álbum.
|
||||||
|
5. **Sin aserciones no nulas:** quitar `urlsResult.data!` y `trackResult.data!` de `upload/+page.server.ts` usando el patrón de `failOnError` que estrecha el tipo.
|
||||||
|
6. **`+layout.server.ts`:** una sola función para las tres cargas con fallback, sin `try/catch` duplicado (openapi-fetch solo lanza con error de red).
|
||||||
|
7. **Tamaños de página:** todos los `pageSize` salen de `config.ts` (quitar `50`, `200`, `500` sueltos y el `PAGE_SIZE` local de `library`). Quitar el alias `EXPLORE_PAGE_SIZE as PAGE_SIZE`.
|
||||||
|
|
||||||
|
**Cierre:** ninguna acción repite la plantilla de autenticación; `grep "createApiClient(" src/routes` vacío.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P7 · Formularios y validación compartida (M)
|
||||||
|
|
||||||
|
1. **Límites en un único sitio:** `$lib/validation.ts` (compartido entre cliente y servidor):
|
||||||
|
```ts
|
||||||
|
export const LIMITS = {
|
||||||
|
playlistName: 100,
|
||||||
|
playlistDescription: 300,
|
||||||
|
albumTitle: 200,
|
||||||
|
albumDescription: 256,
|
||||||
|
trackTitle: 100
|
||||||
|
} as const;
|
||||||
|
```
|
||||||
|
Antes de fijarlos, comprobar los validadores de `Musify.Api` y usar sus valores. `ALBUM_EARLIEST_YEAR` se mueve aquí.
|
||||||
|
2. **Parsers de servidor** en `$lib/server/forms/`: `parseAlbumForm` (existente), `parsePlaylistForm` (nuevo, sustituye el código duplicado de `create` y `edit`), `parseTrackUploadForm` (sacado de `upload/+page.server.ts`). Todos validan con `LIMITS`.
|
||||||
|
3. **Portadas:** `uploadOptionalCover(api, endpoint, file)` sustituye los 4 bloques `if (cover ...) uploadPresignedImage(...)`.
|
||||||
|
4. **Formularios de cliente:** `AlbumForm`, `PlaylistForm` y la subida usan `LIMITS` en `maxlength` y en el contador. Añadir el contador que falta en `AlbumForm` para que los tres se comporten igual.
|
||||||
|
5. `formatSize` de la subida pasa a `utils/format.ts`.
|
||||||
|
|
||||||
|
**Cierre:** cambiar un límite en `LIMITS` actualiza cliente y servidor; tests de los parsers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P8 · Rendimiento 🔗 (M, parte en backend)
|
||||||
|
|
||||||
|
1. **Backend:** añadir `trackCount` y `durationSeconds` a la respuesta de playlist.
|
||||||
|
2. **Frontend:**
|
||||||
|
- `playlists/+page.server.ts` deja de pedir hasta 500 pistas por playlist.
|
||||||
|
- `user/[id]/+page.server.ts` deja de hacer una petición por playlist.
|
||||||
|
- La destacada de inicio recupera la duración real.
|
||||||
|
3. **Explorar:** cachear `/genres` en `$lib/server/genres.ts` (como `playbackConfig`, con TTL de unos minutos) y lanzar todas las peticiones en paralelo en vez de esperar primero a los géneros.
|
||||||
|
4. **Añadir álbum a playlist** (`playlistActions.ts`): hoy hace un POST por pista en serie y se corta en 200. Pedir un endpoint de inserción múltiple 🔗; mientras tanto, paralelizar con un límite de concurrencia.
|
||||||
|
|
||||||
|
**Cierre:** la página de playlists hace una sola petición a la API; el perfil hace dos.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P9 · Separación de responsabilidades en `lib/` (L)
|
||||||
|
|
||||||
|
1. **Dividir `player.svelte.ts`:**
|
||||||
|
- `player/queue.ts`: operaciones de cola (de P0).
|
||||||
|
- `player/stream.ts`: `resolveStream(id)` (fetch de `/stream`, ticket, `listenId`) y `readErrorMessage`.
|
||||||
|
- `player/mediaSession.ts`: registro de acciones y sincronización de metadatos y estado.
|
||||||
|
- `player/progressClock.ts`: el bucle `requestAnimationFrame` de progreso suave.
|
||||||
|
- `player/actions.ts`: `playAllOrToggle`, `playShuffled`, `isQueueCurrent`.
|
||||||
|
- `player/player.svelte.ts`: solo el estado y la orquestación.
|
||||||
|
2. **Acento como número.**
|
||||||
|
- Nuevo `theme/accent.svelte.ts`: caché, `extractAccent` y el tono actual como `number`.
|
||||||
|
- Ahí mismo vive el fundido de tono que hoy está en `+layout.svelte` (`animateThemeHue()`).
|
||||||
|
- Se elimina `parseHue` (ya no hay que leer el tono de una cadena `oklch(...)`).
|
||||||
|
3. **Dividir `state/navigation.svelte.ts`:**
|
||||||
|
- `state/history.svelte.ts`: `trackNavigation`, `previousPage`.
|
||||||
|
- `state/scroll.ts`: `restoreScroll`.
|
||||||
|
- `utils/hrefs.ts`: `searchHref`, `genreHref` y las URLs de portada (`trackCover(id, size)`, `playlistCover(playlist, size)` con el `v=updatedAt`, `albumCover(id, size)`), que hoy se escriben a mano en más de 15 sitios.
|
||||||
|
- `components/layout/navLinks.ts`: `appNavLinks` e `isNavActive` (sustituye el `isActive` duplicado de Sidebar y TabsBar).
|
||||||
|
4. **Menús.** `contextMenuPosition` y `MenuPosition` pasan a `utils/menuPosition.ts`, sin `preventDefault()` dentro (lo hace quien llama). `state/menus.svelte.ts` deja de importar de un componente.
|
||||||
|
5. **Movimientos de archivos:**
|
||||||
|
- `data/collections.ts` → `utils/collections.ts`.
|
||||||
|
- `player/queuePanel.svelte.ts` y `state/playlists.svelte.ts` → `state/panels.svelte.ts` (`queuePanel`, `createPlaylistModal`).
|
||||||
|
- `data/search.ts`: claves internas (`tracks`, `albums`, `playlists`, `users`) y etiquetas aparte en un mapa de textos.
|
||||||
|
6. Tipar `+layout.svelte` con `interface Props`.
|
||||||
|
|
||||||
|
**Cierre:** `player.svelte.ts` por debajo de ~250 líneas; ningún archivo de `state/` importa de `components/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P10 · Componentes extraídos y páginas más finas (L)
|
||||||
|
|
||||||
|
1. **Menú contextual.**
|
||||||
|
- `state/menu.svelte.ts` con `createMenu<T>()` genérico: sustituye `createTrackMenu` y el `albumMenu` hecho a mano de explorar.
|
||||||
|
- Componente `TrackContextMenu` que encapsula el bloque de 15 líneas repetido en inicio, explorar y mezcla. `AlbumContextMenu` para explorar.
|
||||||
|
2. **`PlayAllButton {items}`** con el "Reproducir/Pausar" de álbum, playlist, mezcla, me gusta y la portada de inicio.
|
||||||
|
3. **`createPagedList(fetcher)`** en `state/pagedList.svelte.ts` para la paginación infinita de explorar y de seguidores (estado, `appendUnique`, `hasMore` y un estado de error visible en vez de tragarse el fallo).
|
||||||
|
4. **Primitivas.**
|
||||||
|
- `Button`, `IconButton`, `Chip` y `MenuItem` usan `<svelte:element this={href ? 'a' : 'button'}>` en vez de duplicar las dos ramas.
|
||||||
|
- Acción `use:onEscape` para `Modal`, `ContextMenu` y `AccountMenu`.
|
||||||
|
- `AccountMenu` incluye su propio disparador con el `Avatar`; `TopBar` y `MobileHeader` dejan de duplicarlo.
|
||||||
|
- `Artwork` y `Avatar` usan `{#key src}` en vez del `$effect` que reinicia `failed`.
|
||||||
|
5. **Páginas.**
|
||||||
|
- Quitar los alias `const x = $derived(data.x)` y las funciones de una línea (`playSpotlight`, `shuffleSpotlight`, `openContextMenu`, `stepClass`…).
|
||||||
|
- Sustituir los `$effect` que copian `data` a estado local por `$derived` escribible cuando sea posible.
|
||||||
|
- Navegación con enlaces reales: `ListRow href` en vez de `onclick={() => goto(...)}`, y la destacada de inicio como `<a>`.
|
||||||
|
- `upload`: el indicador de pasos solo refleja estados reales (o se simplifica a "subiendo / listo").
|
||||||
|
|
||||||
|
**Cierre:** ninguna página repite el bloque del menú ni el botón de reproducir; las tarjetas se pueden abrir con clic central.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P11 · Consistencia (M, mecánico)
|
||||||
|
|
||||||
|
1. **Imports:** siempre `$lib/...`, nunca `../`. Se hace cumplir en P12.
|
||||||
|
2. **Convenciones de nombres** (se documentan en `CLAUDE.md`):
|
||||||
|
- Mapas de variantes en constantes de módulo en mayúsculas (`SIZE`, `VARIANT`).
|
||||||
|
- Errores: `error` al desestructurar y `err` en `catch`.
|
||||||
|
- Manejadores de reproducción: `playFrom(index)`, `playAll()`.
|
||||||
|
- Acciones: `create`, `edit`, `delete`, `addTrack`, `removeTrack`.
|
||||||
|
3. **Glosario de interfaz:** "Playlist" (nunca "Lista"), "Mezcla" (nunca "Mix"), "Modo claro". Duraciones totales siempre con `fmtDurationLong`.
|
||||||
|
4. **Tokens.**
|
||||||
|
- Iconos siempre con `size-icon-*`; añadir `size-icon-2xs` si hace falta para los de 14 px.
|
||||||
|
- Nada de `size={16}` en iconos Lucide.
|
||||||
|
- No usar tokens de icono como espaciado (`pb-icon-md`, `gap-icon-md`).
|
||||||
|
- Anchos siempre con clase (`w-(--mf-sidebar-w)`), no con `style`.
|
||||||
|
- Nueva utilidad `focus-ring` usada por `Button`, `IconButton`, `Chip` y los enlaces interactivos.
|
||||||
|
5. **Rutas:** mover `routes/+page.svelte` y `+page.server.ts` (inicio) dentro de `(app)`, para que el grupo tenga sentido: todo lo de la app dentro y `auth/` fuera.
|
||||||
|
|
||||||
|
**Cierre:** `npm run lint` en verde con las reglas nuevas de P12.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P12 · Guardarraíles y documentación (S)
|
||||||
|
|
||||||
|
1. **ESLint:**
|
||||||
|
- `no-restricted-imports` que prohíba `../*`.
|
||||||
|
- `@typescript-eslint/no-non-null-assertion`.
|
||||||
|
- `svelte/no-unused-props`.
|
||||||
|
2. **`scripts/check-tokens.mjs`:** fallar con `size={N}` en iconos y con `size-N` sobre componentes Lucide.
|
||||||
|
3. **Opcional:** `knip` para detectar exportaciones y archivos sin uso.
|
||||||
|
4. **Documentación:**
|
||||||
|
- `CLAUDE.md`: nueva estructura de carpetas, helpers de servidor (`apiFor`, `authedAction`, mappers), convenciones de nombres y glosario.
|
||||||
|
- `docs/paginas.md`: está desactualizado (menciona el grupo `(marketing)` y la landing, que ya no existen).
|
||||||
|
|
||||||
|
**Cierre:** las reglas nuevas fallan si se reintroduce cualquiera de los patrones eliminados.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Registro
|
||||||
|
|
||||||
|
| Parte | Estado | PR / notas |
|
||||||
|
| --------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| P0 Red de seguridad | ✅ | Vitest + 37 tests; `player/queue.ts` extraído. `lint` ahora ejecuta `test` |
|
||||||
|
| P1 Seguridad | ✅ | Hecho en la rama `claude/frontend-audit-plan-4f9432` |
|
||||||
|
| P2 Reproductor y cola | ✅ | Sin probar en navegador (API caída) |
|
||||||
|
| P3 Bugs de páginas | ✅ | Backend: `ownerUserId` en playlist. Sin probar en navegador |
|
||||||
|
| P4 Código muerto | ✅ | Se mantiene el redirect a `/explore` de inicio (cubre escucha anónima) |
|
||||||
|
| P5 Modelo Track único | ✅ | `Paged<T>`, `Mix`, `PlaylistSummary` añadidos. Sin probar en navegador |
|
||||||
|
| P6 Capa de servidor | ✅ | `failOnError` + `requireData`; `parseAlbumForm` usa `formString` |
|
||||||
|
| P7 Formularios y validación | ✅ | Límites según las columnas de BD del backend (playlist 50/256, pista 200) |
|
||||||
|
| P8 Rendimiento 🔗 | ✅ | Backend: `trackCount` y `durationSeconds` en playlist. Añadir álbum usa `POST /playlists/{id}/albums/{albumId}` (bulk) |
|
||||||
|
| P9 Responsabilidades | ✅ | `player.svelte.ts` queda en ~320 líneas |
|
||||||
|
| P10 Componentes y páginas | ✅ | Quedan algunos alias `$derived(data.x)`; `{#key}` sustituido por `failedSrc` |
|
||||||
|
| P11 Consistencia | ✅ | |
|
||||||
|
| P12 Guardarraíles y docs | ✅ | `knip` no añadido (opcional) |
|
||||||
|
|
||||||
|
Leyenda: ⬜ pendiente · 🟡 en curso · ✅ hecho.
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
# Plan de estructura del frontend
|
||||||
|
|
||||||
|
Plan derivado de la revisión de estructura del `web-player` (2026-09-24), hecha después de cerrar `frontend-audit-plan.md` (P0–P12). Cada parte es un PR independiente que deja `npm run check` y `npm run lint` en verde y no cambia el comportamiento visible. Antes de empezar una parte, mira el **Registro** del final.
|
||||||
|
|
||||||
|
Leyenda de tamaño: **S** (< 1 h) · **M** (medio día) · **L** (1 día o más).
|
||||||
|
|
||||||
|
## Orden y dependencias
|
||||||
|
|
||||||
|
```
|
||||||
|
E1 Layout del grupo (app)
|
||||||
|
└─ E2 Carpeta shell
|
||||||
|
└─ E3 Frontera de ui/ ─ E6 Explorar en componentes
|
||||||
|
|
||||||
|
E4 Ubicación de estados (independiente)
|
||||||
|
E5 Capa de servidor (independiente)
|
||||||
|
E7 Acento fuera del reproductor (después de E1)
|
||||||
|
|
||||||
|
E9 Tests (antes o junto con E1 y E5)
|
||||||
|
|
||||||
|
E8 Documentación (al final)
|
||||||
|
```
|
||||||
|
|
||||||
|
E1 → E2 → E3 van en orden porque tocan los mismos imports. E4 y E5 se pueden hacer en cualquier momento. Conviene hacer E9 primero, o al menos sus puntos 1 y 2, para que E1 y E5 muevan el código de sesión con tests que detecten si algo se rompe.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## E1 · Layout del grupo `(app)` (M)
|
||||||
|
|
||||||
|
Problema: el grupo `(app)` no tiene `+layout`. El shell y las cargas de la app están en la raíz, que distingue la pantalla de login a mano (`isAuthPage` en `+layout.svelte`, `PUBLIC_PATHS` en `+layout.server.ts`). Por eso `/auth` también consulta `playbackConfig`, y `+error.svelte` se pinta fuera del shell (un 404 dentro de la app pierde la barra lateral y el reproductor).
|
||||||
|
|
||||||
|
1. **`routes/+layout.svelte`** se queda con lo global: fuentes, `theme.css`, `layout.css`, favicon, el `<style>` inicial del tema, `animateThemeHue` y `<DialogHost />`. Renderiza `children` sin envoltorio.
|
||||||
|
2. **`routes/+layout.server.ts`** desaparece (o queda vacío). Si alguna ruta fuera de `(app)` necesita `user`, lo lee de `locals` en su propio `load`.
|
||||||
|
3. **Nuevo `routes/(app)/+layout.server.ts`** con todo lo que hoy hay en la raíz: `getAllowAnonymousListening`, el guard (`loginRedirect` si no hay usuario ni escucha anónima), `accountUrl`, playlists del usuario, me gusta y última pista. Sin `PUBLIC_PATHS`.
|
||||||
|
4. **Nuevo `routes/(app)/+layout.svelte`** con el shell: backdrop, `Sidebar`, `MobileHeader`, `TopBar`, `<main>` con el `{#key}`, `Queue`, `PlayerDock`, el modal de crear playlist, la hidratación de `liked` y `player`, la apertura automática de la cola y el atajo de la barra espaciadora. Desaparecen `isAuthPage` y la rama `!data.user && !data.allowAnonymousListening`, porque el guard ya ha redirigido.
|
||||||
|
5. **Nuevo `routes/(app)/+error.svelte`** (el contenido del actual) para que los errores dentro de la app se vean con el shell. El `+error.svelte` de la raíz se queda como fallback mínimo para errores fuera del grupo.
|
||||||
|
6. **No quitar** los `requireUser`/`optionalUser` de las páginas ni `authedAction`. Los `load` de página se ejecutan en paralelo con los del layout, y las acciones de formulario no ejecutan el `load` del layout, así que el guard del layout no basta.
|
||||||
|
|
||||||
|
**Cierre:** `grep -rn "isAuthPage\|PUBLIC_PATHS" src` vacío; `/auth` no llama a la API; un 404 en `/albums/xxx` se ve dentro del shell; prueba manual con sesión, sin sesión y con escucha anónima.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## E2 · Carpeta `shell` (S)
|
||||||
|
|
||||||
|
Problema: hay dos carpetas `layout` con significados distintos (`components/layout/` es el shell de la app y `components/ui/layout/` son piezas para montar páginas). `AccountMenu` está en `ui/overlay/`, pero solo lo usan `TopBar` y `MobileHeader`.
|
||||||
|
|
||||||
|
1. Renombrar `components/layout/` a `components/shell/` (`Sidebar`, `TopBar`, `TabsBar`, `MobileHeader`, `navLinks.ts`).
|
||||||
|
2. Mover `components/ui/overlay/AccountMenu.svelte` a `components/shell/AccountMenu.svelte`.
|
||||||
|
3. Actualizar imports en `(app)/+layout.svelte` y `components/player/PlayerDock.svelte`.
|
||||||
|
|
||||||
|
**Cierre:** `grep -rn "components/layout/\|overlay/AccountMenu" src` vacío.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## E3 · Frontera de `components/ui/` (M)
|
||||||
|
|
||||||
|
Problema: `ui/` se presenta como el design system, pero cuatro componentes dependen del estado global de la app. `TrackList` y `PlayAllButton` importan `player`. `TrackContextMenu` y `AlbumContextMenu` importan `player`, y `AlbumContextMenu` también importa `$lib/data/albums`. El resto de `ui/media/` son componentes de presentación sin estado (reciben props), y así deben quedarse.
|
||||||
|
|
||||||
|
1. **Nueva carpeta `components/music/`** para los componentes que conectan la interfaz con el estado de la app:
|
||||||
|
- `ui/media/TrackList.svelte` → `music/TrackList.svelte`
|
||||||
|
- `ui/media/PlayAllButton.svelte` → `music/PlayAllButton.svelte`
|
||||||
|
- `ui/overlay/TrackContextMenu.svelte` → `music/TrackContextMenu.svelte`
|
||||||
|
- `ui/overlay/AlbumContextMenu.svelte` → `music/AlbumContextMenu.svelte`
|
||||||
|
2. **Regla de ESLint:** un bloque con `files: ['src/lib/components/ui/**']` y `no-restricted-imports` que prohíba `$lib/player/*`, `$lib/data/*`, `$lib/server/*`, `$lib/components/music/*`, `$lib/components/shell/*` y `$lib/components/player/*`. Hay que repetir en ese bloque el patrón `../*` del bloque general, porque la configuración de un override sustituye a la del bloque general, no se suma.
|
||||||
|
3. Comprobar que la regla pasa sin excepciones. `ui/` puede seguir importando `$lib/state/*` (historial, diálogos), `$lib/utils/*`, `$lib/actions/*`, `$lib/theme/*` y `$lib/types`.
|
||||||
|
|
||||||
|
**Cierre:** `npm run lint` en verde; si un componente de `ui/` importa `player`, el lint falla.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## E4 · Ubicación de estados (S)
|
||||||
|
|
||||||
|
1. `player/liked.svelte.ts` → `state/liked.svelte.ts`. Guarda los me gusta del usuario, no el estado del reproductor, y lo usan `Sidebar`, `TrackInfo`, el layout y las páginas.
|
||||||
|
2. `data/recentlyPlayed.ts` → `player/recentlyPlayed.ts`. Solo mezcla `player.recentlyPlayed` con el historial, y hoy es el único archivo de `data/` que depende del reproductor.
|
||||||
|
|
||||||
|
**Cierre:** `grep -rn "player/liked\|data/recentlyPlayed" src` vacío; `data/` no importa de `player/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## E5 · Capa de servidor (S–M)
|
||||||
|
|
||||||
|
Problema: `server/api.ts` mezcla el cliente de la API, los guards de sesión, la lectura de formularios y el envoltorio de acciones. Los parsers de `server/forms/` importan `api.ts` solo para usar `formString`/`formFile`.
|
||||||
|
|
||||||
|
1. **`server/api.ts`** se queda con el cliente y el manejo de errores: `createApiClient`, `ApiClient`, `apiFor`, `unwrapOrError`, `apiErrorDetail`, `failOnError`, `requireData`.
|
||||||
|
2. **Nuevo `server/guards.ts`:** `loginRedirect`, `requireUser`, `optionalUser` (14 imports en `routes/` más `(app)/+layout.server.ts`).
|
||||||
|
3. **Nuevo `server/forms/fields.ts`:** `formString`, `formFile`. Los parsers de `forms/` lo importan desde ahí.
|
||||||
|
4. **Nueva carpeta `server/actions/`:**
|
||||||
|
- `authedAction.ts` con `authedAction` y `ActionContext`.
|
||||||
|
- `followActions.ts` → `actions/follow.ts`.
|
||||||
|
- `playlistActions.ts` → `actions/playlist.ts`.
|
||||||
|
|
||||||
|
**Cierre:** `server/forms/` no importa `server/api`; `api.ts` no importa `redirect` ni `RequestEvent` salvo para `apiFor`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## E6 · Explorar en componentes (M)
|
||||||
|
|
||||||
|
Problema: `(app)/explore/+page.svelte` (393 líneas) resuelve tres vistas en una: la rejilla de géneros, la vista de un género y los resultados de búsqueda (mejor resultado y cuatro grupos).
|
||||||
|
|
||||||
|
1. Sacar componentes junto a la página (SvelteKit ignora los archivos sin `+` dentro de `routes/`):
|
||||||
|
- `explore/GenreGrid.svelte`: la rejilla de géneros y el estado vacío.
|
||||||
|
- `explore/TopResult.svelte`: el snippet `topResultBody` y `playTopResult`.
|
||||||
|
- `explore/SearchResults.svelte`: los cuatro grupos, `capped`, `showGroup` y el `InfiniteScroll` de pistas.
|
||||||
|
2. `+page.svelte` queda como selector de vista: cabecera, chips, mensaje de "sin resultados" y los dos menús contextuales.
|
||||||
|
3. El estado compartido (`createPagedList`, `trackMenu`, `albumMenu`) se crea en la página y se pasa por props; los componentes no leen `page.data`.
|
||||||
|
|
||||||
|
**Cierre:** `explore/+page.svelte` por debajo de ~150 líneas; ninguna vista nueva supera las ~150.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## E7 · Acento fuera del reproductor (S)
|
||||||
|
|
||||||
|
Problema: `player.svelte.ts` importa `theme/accent.svelte` y en `#announce` llama a `accent.follow(...)`, así que el estado del reproductor depende del tema visual.
|
||||||
|
|
||||||
|
1. Quitar `accent` y `trackCover` de `player.svelte.ts`; `#announce` solo actualiza `mediaSession`.
|
||||||
|
2. En `(app)/+layout.svelte`, un `$effect` que siga a `player.currentId` y llame a `accent.follow(trackCover(id, 'small'), () => player.currentId === id)`.
|
||||||
|
3. Comprobar que la hidratación de la última pista también actualiza el acento (el efecto depende de `currentId`, así que debería).
|
||||||
|
|
||||||
|
**Cierre:** `player/` no importa de `theme/`; el color cambia al cambiar de canción y al cargar la página con una última pista.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## E8 · Documentación y restos (S)
|
||||||
|
|
||||||
|
1. **`CLAUDE.md`:**
|
||||||
|
- Estructura de carpetas con `components/shell/`, `components/music/`, la regla de frontera de `ui/`, `state/liked.svelte.ts`, `server/guards.ts`, `server/forms/fields.ts` y `server/actions/`.
|
||||||
|
- Documentar lo que falta: `lib/actions/` (`clickOutside`, `onEscape`, `pressable`), `state/toggle.svelte.ts` y `theme/color.ts`.
|
||||||
|
- Corregir la referencia a `$lib/state/navigation.svelte.ts` en "App shell" (ahora es `restoreScroll` en `$lib/state/scroll.ts`) y describir el shell en `(app)/+layout.svelte`.
|
||||||
|
- La skill `musify-web` que cita la introducción no está en `web-player/.claude/skills`: añadirla o quitar la referencia.
|
||||||
|
2. **Conversiones fuera de los mappers.** La regla de P5 dice que los `Number(...)` sobre DTOs solo se hacen en `server/mappers.ts`, pero quedan varios en `routes/`:
|
||||||
|
- `(app)/+page.server.ts`: las estadísticas de escucha (nuevo `toListeningStats`, con test) y `totalItemCount` de las playlists.
|
||||||
|
- `(app)/explore/+page.server.ts`: `albumsTotal` y `usersTotal`.
|
||||||
|
- `(app)/user/[id]/+page.server.ts`: `followersCount`.
|
||||||
|
- La carga de playlists del layout (que tras E1 está en `(app)/+layout.server.ts`): `totalItemCount`.
|
||||||
|
|
||||||
|
Los totales pueden salir de `toPage(...).totalItemCount` o de un helper `toCount` en los mappers.
|
||||||
|
|
||||||
|
3. **Planes:** marcar `frontend-audit-plan.md` como cerrado (o archivarlo) y dejar este como el plan activo.
|
||||||
|
|
||||||
|
**Cierre:** cada ruta citada en `CLAUDE.md` existe; `grep -rn "Number(" src/routes` solo devuelve lecturas de parámetros o formularios.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## E9 · Tests de los arreglos y del código con efectos (M)
|
||||||
|
|
||||||
|
Los tests se quedan junto al archivo que prueban (`x.ts` + `x.test.ts`); no se crea una carpeta `tests/`. Hoy cubren bien la lógica pura (formatos, mappers, parsers, cola, búsqueda), pero no el código con fetch, estado o tiempo, ni los arreglos de seguridad de P1 y P2, que pueden volver sin que nada falle.
|
||||||
|
|
||||||
|
1. **Sesión y guards.**
|
||||||
|
- `server/auth.ts`: `encodeSession` → `decodeSession` devuelve la misma sesión; un token manipulado, cifrado con otra clave o vacío devuelve `null`. Para el secreto, usar `vi.mock('$env/dynamic/private')` con un `SESSION_SECRET` de prueba, no el `.env` local (en CI no existe).
|
||||||
|
- Guards (`requireUser`, `optionalUser`, `loginRedirect`; en `server/guards.ts` tras E5): redirigen a `/auth?returnTo=…` con la ruta y la query codificadas, y dejan pasar cuando toca.
|
||||||
|
- Tras E1, el `load` de `(app)/+layout.server.ts`: redirige sin sesión ni escucha anónima, devuelve datos vacíos en modo anónimo y no llama a la API sin usuario.
|
||||||
|
2. **Endpoints `/api` (arreglos de P1).** Llamar al `RequestHandler` con un evento falso y un `fetch` simulado:
|
||||||
|
- `api/likes`: 401 sin token, 400 con `trackId` ausente, vacío o no string, 502 si la API falla.
|
||||||
|
- `api/listens/[id]/progress`: 400 con JSON inválido, con `playedSeconds` negativo, no finito o no numérico; 404 si la API devuelve 404.
|
||||||
|
- `api/tracks`: `pageSize` se limita a `MAX_PAGE_SIZE` y `pageNumber` inválido pasa a 1.
|
||||||
|
3. **Rollback de "Me gusta" (`liked.svelte.ts`, arreglo de P2).** Con `fetch` simulado que falla: se revierte solo esa pista y se conservan los cambios hechos mientras tanto en otras. También los casos de dar y quitar el me gusta, y que `hydrate` solo se aplica una vez.
|
||||||
|
4. **`ListenTracker`.** Con `vi.useFakeTimers()` y `fetch` simulado:
|
||||||
|
- Suma solo los avances de hasta 2,5 s; un salto mayor (seek) o hacia atrás no cuenta.
|
||||||
|
- `interrupt()` hace que el siguiente `tick` no sume.
|
||||||
|
- Envía a los 30 s y en `flush()`; no reenvía si no hay progreso nuevo.
|
||||||
|
- Si el envío falla, el siguiente `flush()` vuelve a enviar el total.
|
||||||
|
- `begin()` envía lo pendiente de la escucha anterior antes de empezar la nueva.
|
||||||
|
5. **`createPagedList`.** Añade la página siguiente sin duplicados, actualiza `hasMore`, ignora un `loadMore` con otro en curso y, si falla, marca `error` y conserva los elementos. `reset` limpia el error.
|
||||||
|
6. **`apiErrorDetail`.** Mensajes de `errors` (varios campos, arrays), después `detail`, después `title`, y `undefined` para valores que no son objeto o sin texto.
|
||||||
|
|
||||||
|
Fuera de alcance, a propósito: tests de componentes (sobre todo presentación, ya vigilada por `check-tokens`), utilidades triviales (`storage`, `menuPosition`, `navLinks`), `mediaSession`, `progressClock`, `palette` (necesita canvas) y `player.svelte.ts` entero (depende de `<audio>`; su lógica está en `queue.ts`, que ya tiene tests). E2E con Playwright queda para cuando CI pueda levantar el backend y Zitadel.
|
||||||
|
|
||||||
|
**Cierre:** cada punto anterior tiene su `*.test.ts` junto al módulo; `npm run test` en verde sin `.env` local; volver a introducir cualquiera de los bugs de P1/P2 cubiertos hace fallar un test.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Registro
|
||||||
|
|
||||||
|
| Parte | Estado | PR / notas |
|
||||||
|
| ------------------------------- | ------ | ---------------------------------------- |
|
||||||
|
| E1 Layout del grupo (app) | ✅ | |
|
||||||
|
| E2 Carpeta shell | ✅ | |
|
||||||
|
| E3 Frontera de ui/ | ✅ | Regla de ESLint sobre `components/ui/**` |
|
||||||
|
| E4 Ubicación de estados | ✅ | |
|
||||||
|
| E5 Capa de servidor | ✅ | |
|
||||||
|
| E6 Explorar en componentes | ✅ | `+page.svelte` queda en ~180 líneas |
|
||||||
|
| E7 Acento fuera del reproductor | ✅ | |
|
||||||
|
| E8 Documentación y restos | ✅ | |
|
||||||
|
| E9 Tests | ✅ | 121 tests, sin `.env` local |
|
||||||
|
|
||||||
|
Leyenda: ⬜ pendiente · 🟡 en curso · ✅ hecho.
|
||||||
+34
-112
@@ -1,128 +1,50 @@
|
|||||||
# Musify, page guide
|
# Musify, page guide
|
||||||
|
|
||||||
Living document describing what each page of the web frontend should contain. It works as a map for prioritizing features. The design system (tokens, recipes, components) lives in the `musify-web` skill; here we describe **content and structure**, not styling.
|
Living document describing what each page of the web frontend contains. The design system (tokens, recipes, components) lives in the `musify-web` skill and in `CLAUDE.md`; here we describe **content and structure**, not styling.
|
||||||
|
|
||||||
Status legend: ✅ done · 🟡 partial / mockup · ⬜ pending.
|
|
||||||
|
|
||||||
> **Note (app redesign):** the `Musify.dc.html` prototype is being ported over (an app with a sidebar, views, and a persistent player with a dynamic accent per track). The marketing landing page was removed. The sections below still describe the older design and are pending an update to match the app views (Home, Search, Library, Upload, Import, Downloads, Playlists). The **player** is already built as components in `$lib/components/player/`, with its theme in `$lib/theme/` (`theme.css` for the palette, `color.ts` for the accent and gradient).
|
|
||||||
|
|
||||||
## Home (app) `/` 🟡
|
|
||||||
|
|
||||||
- **Hero**: a greeting plus "Your music. No limits." with a color wash taken from the current track's hue.
|
|
||||||
- **Quick access, meaning recently played** (`GET /users/{id}/listening-history` merged with `player.recentlyPlayed` via `mergeRecentlyPlayed`). Empty state with an icon and an "Explore" CTA.
|
|
||||||
- **Your playlists**: mosaic cards for the user's playlists (sample data for now). Has an empty state.
|
|
||||||
- **Resume on load**: the root layout fetches `GET /users/{id}/last-listened-track` and hydrates the player (`player.hydrate`) with it, paused, so the player bar isn't empty on a fresh session. Only applies when nothing is already loaded (won't override an in-progress session).
|
|
||||||
|
|
||||||
### Pending backend work (ASP.NET) for Home
|
|
||||||
|
|
||||||
- **Playlists by last listen**: add `lastPlayedAt` to the playlist (the schema currently only has `createdAt`/`updatedAt`) and allow sorting `GET /playlists/users/{userId}` by that field (for example `?sort=lastPlayed`), to show "your most recently played" playlists.
|
|
||||||
- Tracks will need an **artist** and a **color/hue** (or a real cover to extract one from) for the artwork and the dynamic accent. Right now `TrackApplicationResponse` carries neither artist nor color.
|
|
||||||
|
|
||||||
## Route structure
|
## Route structure
|
||||||
|
|
||||||
Routes are grouped to separate the public experience from the authenticated one (the groups don't change the URL):
|
Everything the user sees after signing in lives under the `(app)` group (the group doesn't change the URL). `auth/` and `api/` sit outside it.
|
||||||
|
|
||||||
```
|
```
|
||||||
src/routes/
|
src/routes/
|
||||||
(marketing)/ Public, no session required
|
(app)/
|
||||||
+page.svelte / Landing ✅
|
+page.svelte / Home
|
||||||
(app)/ The app experience
|
explore/ /explore Search and genre discovery
|
||||||
explore/ /explore Explore / search ✅
|
liked/ /liked Liked songs
|
||||||
upload/ /upload Upload music 🟡 mockup
|
library/ /library Your uploaded songs
|
||||||
library/ /library Your library ⬜
|
upload/ /upload Upload a song
|
||||||
playlists/ /playlists Your playlists ⬜
|
albums/ /albums Your albums
|
||||||
[id]/ /playlists/:id Playlist detail ⬜
|
[id]/ /albums/:id Album detail
|
||||||
auth auth/login auth/logout auth/callback Auth (page + endpoints) ✅
|
playlists/ /playlists Your playlists
|
||||||
api/tracks/[id]/stream Stream proxy ✅
|
[id]/ /playlists/:id Playlist detail
|
||||||
|
mixes/[id]/ /mixes/:id Generated mix
|
||||||
|
user/[id]/ /user/:id Public profile
|
||||||
|
[list=followList]/ /user/:id/followers|following
|
||||||
|
auth/ auth/login auth/logout auth/callback Login screen and OIDC endpoints
|
||||||
|
api/ Same origin JSON endpoints used by the client
|
||||||
```
|
```
|
||||||
|
|
||||||
Global components (present on every page): **Navbar**, **Footer**, and **PlayerBar** (the fixed playback bar, shown while something is playing).
|
Global chrome on every app page: sidebar (desktop) or tab bar (mobile), top bar with search and account menu, the persistent player dock, and the queue panel.
|
||||||
|
|
||||||
---
|
## Pages
|
||||||
|
|
||||||
## 1. Landing `/` ✅ (marketing)
|
- **Home `/`**: a greeting, weekly listening stats, "continue listening" (recently played merged with the in-session history), the featured playlist with its first tracks (play and shuffle load the full list on demand), the user's mixes and playlists. Anonymous visitors are redirected to `/explore`.
|
||||||
|
- **Explore `/explore`**: genre tiles when there is no query, otherwise search results grouped by songs, albums, users and playlists with filter chips and counts taken from the API totals. Songs paginate with infinite scroll. Track and album context menus offer play next, add to queue and add to playlist.
|
||||||
|
- **Liked `/liked`**: the songs the user liked, newest first, with unlike per row.
|
||||||
|
- **Library `/library`**: the songs the user uploaded, with a confirmed delete.
|
||||||
|
- **Upload `/upload`**: audio, cover, title, genres (incompatible genres are blocked) and explicit flag. The server reserves presigned URLs, uploads both files and creates the track; processing happens asynchronously in the backend.
|
||||||
|
- **Albums `/albums`, `/albums/:id`**: list and create albums. The owner can edit, delete and add or remove tracks from their library.
|
||||||
|
- **Playlists `/playlists`, `/playlists/:id`**: list and create playlists. The owner can edit, delete and remove tracks; everyone else gets a read only view.
|
||||||
|
- **Mix `/mixes/:id`**: a generated mix with play, shuffle and a track menu.
|
||||||
|
- **Profile `/user/:id`**: public playlists, follow button and links to the followers and following lists, which respect the profile's privacy.
|
||||||
|
- **Auth `/auth`**: the login screen. Its buttons lead to `/auth/login`, which starts the Zitadel OIDC flow; `?mode=register` opens the sign up variant and `?returnTo=` (local paths only) sends the user back afterwards. `/auth/logout` ends the session and `/auth/callback` creates the encrypted `mf_session` cookie.
|
||||||
|
|
||||||
The entry page for logged out visitors. Its job is to sell the product and drive people to sign up or explore.
|
## Data flow
|
||||||
|
|
||||||
- **Hero**: a status badge (beta), a title, a subtitle, a primary CTA (Start for free, leading to sign up) and a secondary one (Explore songs).
|
Each page loads its data in `+page.server.ts` through the typed API client, and normalizes it with the mappers in `$lib/server/mappers.ts` so the client only ever sees the domain types in `$lib/types.ts`. Mutations are form actions built with `authedAction`. Client side pagination goes through the `/api/*` endpoints.
|
||||||
- **Features**: three cards covering listening, playlists, and uploading.
|
|
||||||
- **Suggested next steps**: a screenshots or demo section, social proof, a short FAQ.
|
|
||||||
|
|
||||||
## 2. Login / Auth ✅ (endpoints)
|
## Pending backend work
|
||||||
|
|
||||||
`/auth` is Musify's login screen; its buttons lead to `/auth/login`, which kicks off the Zitadel OIDC flow and redirects. `?mode=register` starts on the sign up variant, and `?returnTo=` sends the user back to the route they came from once they're in. `/auth/logout` ends the session. `/auth/callback` exchanges the code and creates the encrypted `mf_session` cookie. The credentials UI itself is served by Zitadel, not by Musify.
|
- Sorting playlists by last listen (`lastPlayedAt`) for a "recently played playlists" shelf.
|
||||||
|
- An endpoint returning the most listened tracks, to bring back a "popular" section on Home.
|
||||||
## 3. Explore / Search `/explore` ✅ (app)
|
|
||||||
|
|
||||||
Discovery and search over the catalog.
|
|
||||||
|
|
||||||
- **Search box**: a text input (`?q=`) that filters by name on the backend.
|
|
||||||
- **Results count** and a **track grid** (2 to 4 columns): cover, title, date, and a play/pause button on hover that queues and plays through the global player.
|
|
||||||
- **Pagination**: previous/next (`?page=`, 24 per page).
|
|
||||||
- **Empty state**: no results, or an empty catalog.
|
|
||||||
- **Suggested next steps**: filters (most recent, by user), loading skeletons, a real track cover (the listing API doesn't currently return the image key).
|
|
||||||
|
|
||||||
## 4. Upload music `/upload` 🟡 mockup (app)
|
|
||||||
|
|
||||||
Adding a new song. The real backend flow, for when it gets wired up:
|
|
||||||
|
|
||||||
1. `POST /tracks/upload-urls` returns presigned URLs for cover and audio, plus `intentId`s.
|
|
||||||
2. `PUT` each file to its presigned URL (a direct upload to storage).
|
|
||||||
3. `POST /tracks` with `title`, `pictureIntentId`, `audioIntentId`.
|
|
||||||
4. The backend processes the audio asynchronously (transcoding it to DASH).
|
|
||||||
|
|
||||||
What the page needs:
|
|
||||||
|
|
||||||
- **Audio zone**: drag and drop or a file picker, showing the file's name and size, with type (audio) and size validation.
|
|
||||||
- **Cover**: an image picker with a square preview.
|
|
||||||
- **Metadata**: title (required), with room for future fields like a description.
|
|
||||||
- **Visible pipeline**: the Upload, Process, Publish steps and their status.
|
|
||||||
- **Progress bar** for the upload and the processing state.
|
|
||||||
- **States**: empty, file selected, uploading, processing, published, error.
|
|
||||||
- **Requires a session** (once wired up: redirect to `/auth?returnTo=/upload`).
|
|
||||||
|
|
||||||
## 5. Your library `/library` ⬜ (app)
|
|
||||||
|
|
||||||
The authenticated user's personal panel, everything of theirs in one place.
|
|
||||||
|
|
||||||
- **Tabs / sections**: "Your songs" (`GET /tracks/users/{userId}`) and "Your playlists" (`GET /playlists/users/{userId}`).
|
|
||||||
- **Your songs**: a list with play, plus management actions (delete via `DELETE /tracks/{id}`, add to a playlist).
|
|
||||||
- **Your playlists**: a grid of covers with a "Create playlist" button.
|
|
||||||
- **Empty state** with a CTA to upload or create.
|
|
||||||
- **Requires a session.**
|
|
||||||
|
|
||||||
## 6. Playlists `/playlists` ⬜ (app)
|
|
||||||
|
|
||||||
A listing of the user's playlists.
|
|
||||||
|
|
||||||
- **Grid** of playlists (cover, name, track count), linking to the detail page.
|
|
||||||
- **Create playlist**: a modal or page with a name, description, and cover (`POST /playlists`, cover via `POST /playlists/upload-picture`).
|
|
||||||
- **Empty state** with a CTA.
|
|
||||||
- **Requires a session.**
|
|
||||||
|
|
||||||
## 7. Playlist detail `/playlists/[id]` ⬜ (app)
|
|
||||||
|
|
||||||
- **Header**: a large cover, name, description, track count, and a "Play all" button.
|
|
||||||
- **Track list** (`GET /playlists/{id}/tracks`, paginated): index, title, date, play, and remove from the playlist (`DELETE /playlists/{id}/tracks/{trackId}`).
|
|
||||||
- **Owner actions**: edit (`PUT`), delete (`DELETE`) the playlist, reorder (a future addition).
|
|
||||||
- **Add tracks**: from explore or the library (`POST /playlists/{id}/tracks/{trackId}`).
|
|
||||||
|
|
||||||
## 8. User profile `/user/[id]` ⬜ (future)
|
|
||||||
|
|
||||||
A public view of a user: avatar, name, their public songs and playlists (`GET /users/{id}`, `GET /tracks/users/{id}`, `GET /playlists/users/{id}`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Global player (PlayerBar) ✅
|
|
||||||
|
|
||||||
A fixed bottom bar, visible while something is playing. Shows track info, transport controls (previous, play/pause, next), a progress bar, and volume control. Queues lists from any grid (`player.playQueue`). Plays DASH through the `/api/tracks/:id/stream` proxy, which requests a manifest and a signed ticket from the backend.
|
|
||||||
|
|
||||||
## Available backend (summary)
|
|
||||||
|
|
||||||
| Resource | Endpoints |
|
|
||||||
| --------- | ----------------------------------------------------------------- |
|
|
||||||
| Tracks | list/search, detail, by user, stream, create, delete, upload-urls |
|
|
||||||
| Playlists | CRUD, playlist tracks, add/remove track, upload-picture |
|
|
||||||
| Users | list/search, detail, create |
|
|
||||||
|
|
||||||
Typed client in `$lib/server/api.ts` (`openapi-fetch`, ErrorOr style). Use it inside `load`/server actions, never from the client. Types live in `src/lib/api/schema.d.ts`.
|
|
||||||
|
|||||||
@@ -19,6 +19,19 @@ export default ts.config(
|
|||||||
'error',
|
'error',
|
||||||
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' }
|
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' }
|
||||||
],
|
],
|
||||||
|
'@typescript-eslint/no-non-null-assertion': 'error',
|
||||||
|
'no-restricted-imports': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
patterns: [
|
||||||
|
{
|
||||||
|
group: ['../*'],
|
||||||
|
message: 'Use $lib/... instead of relative parent imports.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'svelte/no-unused-props': 'error',
|
||||||
'svelte/no-navigation-without-resolve': 'off',
|
'svelte/no-navigation-without-resolve': 'off',
|
||||||
'svelte/prefer-svelte-reactivity': 'off'
|
'svelte/prefer-svelte-reactivity': 'off'
|
||||||
}
|
}
|
||||||
@@ -35,6 +48,34 @@ export default ts.config(
|
|||||||
'@typescript-eslint/no-unused-expressions': 'off'
|
'@typescript-eslint/no-unused-expressions': 'off'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
files: ['src/lib/components/ui/**'],
|
||||||
|
rules: {
|
||||||
|
'no-restricted-imports': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
patterns: [
|
||||||
|
{
|
||||||
|
group: ['../*'],
|
||||||
|
message: 'Use $lib/... instead of relative parent imports.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: [
|
||||||
|
'$lib/player/*',
|
||||||
|
'$lib/data/*',
|
||||||
|
'$lib/server/*',
|
||||||
|
'$lib/components/music/*',
|
||||||
|
'$lib/components/shell/*',
|
||||||
|
'$lib/components/player/*'
|
||||||
|
],
|
||||||
|
message:
|
||||||
|
'components/ui is the presentation layer: it cannot depend on app state, data or other component layers.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
ignores: ['build/', '.svelte-kit/', 'static/', 'src/lib/api/schema.d.ts']
|
ignores: ['build/', '.svelte-kit/', 'static/', 'src/lib/api/schema.d.ts']
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+300
-32
@@ -36,7 +36,8 @@
|
|||||||
"tailwindcss": "^4.3.0",
|
"tailwindcss": "^4.3.0",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"typescript-eslint": "^8.64.0",
|
"typescript-eslint": "^8.64.0",
|
||||||
"vite": "^8.0.16"
|
"vite": "^8.0.16",
|
||||||
|
"vitest": "^5.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/core": {
|
"node_modules/@emnapi/core": {
|
||||||
@@ -51,17 +52,6 @@
|
|||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/core/node_modules/@emnapi/wasi-threads": {
|
|
||||||
"version": "1.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz",
|
|
||||||
"integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"tslib": "^2.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@emnapi/runtime": {
|
"node_modules/@emnapi/runtime": {
|
||||||
"version": "1.11.3",
|
"version": "1.11.3",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
|
||||||
@@ -74,11 +64,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@emnapi/wasi-threads": {
|
"node_modules/@emnapi/wasi-threads": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz",
|
||||||
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
|
"integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
@@ -343,9 +334,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@jridgewell/sourcemap-codec": {
|
"node_modules/@jridgewell/sourcemap-codec": {
|
||||||
"version": "1.5.5",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
|
||||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
"integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@jridgewell/trace-mapping": {
|
"node_modules/@jridgewell/trace-mapping": {
|
||||||
@@ -368,21 +359,24 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@napi-rs/wasm-runtime": {
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
"version": "1.1.6",
|
"version": "1.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.4.tgz",
|
||||||
"integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
|
"integrity": "sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tybys/wasm-util": "^0.10.3"
|
"@tybys/wasm-util": "^0.10.3"
|
||||||
},
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.13.0 || >=23.5.0"
|
||||||
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@emnapi/core": "^1.7.1",
|
"@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4",
|
||||||
"@emnapi/runtime": "^1.7.1"
|
"@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxc-project/types": {
|
"node_modules/@oxc-project/types": {
|
||||||
@@ -631,6 +625,16 @@
|
|||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz",
|
||||||
@@ -1556,21 +1560,39 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tybys/wasm-util": {
|
"node_modules/@tybys/wasm-util": {
|
||||||
"version": "0.10.3",
|
"version": "0.10.4",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz",
|
||||||
"integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
|
"integrity": "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/chai": {
|
||||||
|
"version": "5.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||||
|
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/deep-eql": "*",
|
||||||
|
"assertion-error": "^2.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/cookie": {
|
"node_modules/@types/cookie": {
|
||||||
"version": "0.6.0",
|
"version": "0.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
|
||||||
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
|
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/deep-eql": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/esrecurse": {
|
"node_modules/@types/esrecurse": {
|
||||||
"version": "4.3.1",
|
"version": "4.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
|
||||||
@@ -1843,6 +1865,64 @@
|
|||||||
"url": "https://opencollective.com/typescript-eslint"
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@vitest/mocker": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-6K1DoBNAPGvuOcSsGA4D6x+5zEEff/KmOOP3uetT2TrGpVfI+HRHRnJJfKi5ib/g1vx8IYHQD8s0pbJz8WQI7Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/trace-mapping": "0.3.31",
|
||||||
|
"@vitest/spy": "5.0.1",
|
||||||
|
"estree-walker": "^3.0.3",
|
||||||
|
"magic-string": "^1.2.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"msw": "^2.4.9",
|
||||||
|
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"msw": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"vite": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/mocker/node_modules/estree-walker": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/estree": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/mocker/node_modules/magic-string": {
|
||||||
|
"version": "1.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz",
|
||||||
|
"integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/sourcemap-codec": "^1.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/spy": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-rbto/mF/SGERxEgYOek7Xm6B9b+y+mVoo+f4b2LymYO8zM1b7uB5nHuhVMTP2hxdzgxvGiZYGxGIaMvL5y180Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/acorn": {
|
"node_modules/acorn": {
|
||||||
"version": "8.17.0",
|
"version": "8.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
|
||||||
@@ -1891,6 +1971,16 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/assertion-error": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/axobject-query": {
|
"node_modules/axobject-query": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||||
@@ -1923,6 +2013,16 @@
|
|||||||
"node": "18 || 20 || >=22"
|
"node": "18 || 20 || >=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/chai": {
|
||||||
|
"version": "6.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||||
|
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chokidar": {
|
"node_modules/chokidar": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||||
@@ -2063,6 +2163,13 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/es-module-lexer": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/escape-string-regexp": {
|
"node_modules/escape-string-regexp": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||||
@@ -2323,6 +2430,16 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expect-type": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fast-deep-equal": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
@@ -3007,9 +3124,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/obug": {
|
"node_modules/obug": {
|
||||||
"version": "2.1.3",
|
"version": "2.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz",
|
||||||
"integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==",
|
"integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==",
|
||||||
"funding": [
|
"funding": [
|
||||||
"https://github.com/sponsors/sxzz",
|
"https://github.com/sponsors/sxzz",
|
||||||
"https://opencollective.com/debug"
|
"https://opencollective.com/debug"
|
||||||
@@ -3130,9 +3247,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
"version": "4.0.5",
|
"version": "4.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
|
||||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
@@ -3573,6 +3690,13 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/siginfo": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/sirv": {
|
"node_modules/sirv": {
|
||||||
"version": "3.0.2",
|
"version": "3.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
|
||||||
@@ -3596,6 +3720,20 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/stackback": {
|
||||||
|
"version": "0.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||||
|
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/std-env": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/supports-preserve-symlinks-flag": {
|
"node_modules/supports-preserve-symlinks-flag": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
|
||||||
@@ -3760,6 +3898,26 @@
|
|||||||
"url": "https://opencollective.com/webpack"
|
"url": "https://opencollective.com/webpack"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tinybench": {
|
||||||
|
"version": "6.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz",
|
||||||
|
"integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tinyexec": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.17",
|
"version": "0.2.17",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||||
@@ -3976,6 +4134,99 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vitest": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/chai": "^5.2.2",
|
||||||
|
"@vitest/mocker": "5.0.1",
|
||||||
|
"chai": "^6.2.2",
|
||||||
|
"es-module-lexer": "^2.3.2",
|
||||||
|
"expect-type": "^1.4.0",
|
||||||
|
"magic-string": "^1.2.3",
|
||||||
|
"obug": "^2.1.4",
|
||||||
|
"picomatch": "^4.0.7",
|
||||||
|
"std-env": "^4.2.0",
|
||||||
|
"tinybench": "6.1.4",
|
||||||
|
"tinyexec": "1.3.0",
|
||||||
|
"tinyglobby": "^0.2.17",
|
||||||
|
"why-is-node-running": "^2.3.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"vitest": "vitest.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^22.12.0 || ^24.0.0 || >=26.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@edge-runtime/vm": "*",
|
||||||
|
"@opentelemetry/api": "^1.9.0",
|
||||||
|
"@types/node": "^22.0.0 || >=24.0.0",
|
||||||
|
"@vitest/browser-playwright": "5.0.1",
|
||||||
|
"@vitest/browser-preview": "5.0.1",
|
||||||
|
"@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0",
|
||||||
|
"@vitest/coverage-istanbul": "5.0.1",
|
||||||
|
"@vitest/coverage-v8": "5.0.1",
|
||||||
|
"@vitest/ui": "5.0.1",
|
||||||
|
"happy-dom": "*",
|
||||||
|
"jsdom": "*",
|
||||||
|
"vite": "^6.4.0 || ^7.0.0 || ^8.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@edge-runtime/vm": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@opentelemetry/api": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/node": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/browser-playwright": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/browser-preview": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/browser-webdriverio": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/coverage-istanbul": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/coverage-v8": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/ui": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"happy-dom": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"jsdom": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"vite": {
|
||||||
|
"optional": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vitest/node_modules/magic-string": {
|
||||||
|
"version": "1.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz",
|
||||||
|
"integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/sourcemap-codec": "^1.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
@@ -3992,6 +4243,23 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/why-is-node-running": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"siginfo": "^2.0.0",
|
||||||
|
"stackback": "0.0.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"why-is-node-running": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/word-wrap": {
|
"node_modules/word-wrap": {
|
||||||
"version": "1.2.5",
|
"version": "1.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||||
|
|||||||
@@ -11,9 +11,11 @@
|
|||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
"gen:api": "npx -y openapi-typescript@7 http://localhost:5111/openapi/v1.json -o src/lib/api/schema.d.ts",
|
"gen:api": "npx -y openapi-typescript@7 http://localhost:5111/openapi/v1.json -o src/lib/api/schema.d.ts",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"lint:tokens": "node scripts/check-tokens.mjs",
|
"lint:tokens": "node scripts/check-tokens.mjs",
|
||||||
"lint": "prettier --check . && eslint . && npm run lint:tokens"
|
"lint": "prettier --check . && eslint . && npm run lint:tokens && npm run test"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
@@ -33,7 +35,8 @@
|
|||||||
"tailwindcss": "^4.3.0",
|
"tailwindcss": "^4.3.0",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"typescript-eslint": "^8.64.0",
|
"typescript-eslint": "^8.64.0",
|
||||||
"vite": "^8.0.16"
|
"vite": "^8.0.16",
|
||||||
|
"vitest": "^5.0.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/bricolage-grotesque": "^5.3.0",
|
"@fontsource-variable/bricolage-grotesque": "^5.3.0",
|
||||||
|
|||||||
@@ -24,12 +24,16 @@ const PAIRED_HW = /\b(?:([a-z0-9-]+:))?h-([\d.]+)\s+\1w-\2\b/g;
|
|||||||
const QUARTER_STEP_SPACING = /\b[a-z-]+-\d+\.(25|75)\b/g;
|
const QUARTER_STEP_SPACING = /\b[a-z-]+-\d+\.(25|75)\b/g;
|
||||||
const HEX_COLOR = /#[0-9a-fA-F]{3,8}\b/g;
|
const HEX_COLOR = /#[0-9a-fA-F]{3,8}\b/g;
|
||||||
const RGBA_LITERAL = /\brgba?\(/g;
|
const RGBA_LITERAL = /\brgba?\(/g;
|
||||||
|
const LUCIDE_IMPORT = /import (\w+) from '@lucide\/svelte\/icons\/[\w-]+'/g;
|
||||||
|
const ICON_PIXEL_SIZE = /\bsize=\{\d+\}/;
|
||||||
|
const ICON_RAW_SIZE = /\bclass="[^"]*\bsize-\d[\w.]*/;
|
||||||
|
|
||||||
let violations = [];
|
let violations = [];
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const content = readFileSync(file, 'utf8');
|
const content = readFileSync(file, 'utf8');
|
||||||
const lines = content.split('\n');
|
const lines = content.split('\n');
|
||||||
|
const icons = [...content.matchAll(LUCIDE_IMPORT)].map((m) => m[1]);
|
||||||
|
|
||||||
lines.forEach((line, i) => {
|
lines.forEach((line, i) => {
|
||||||
const lineNo = i + 1;
|
const lineNo = i + 1;
|
||||||
@@ -40,6 +44,17 @@ for (const file of files) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const icon of icons) {
|
||||||
|
const tag = line.match(new RegExp(`<${icon}\\s[^>]*`));
|
||||||
|
if (!tag) continue;
|
||||||
|
if (ICON_PIXEL_SIZE.test(tag[0])) {
|
||||||
|
violations.push(`${file}:${lineNo} — icono con size={N}: usa una clase size-icon-*`);
|
||||||
|
}
|
||||||
|
if (ICON_RAW_SIZE.test(tag[0])) {
|
||||||
|
violations.push(`${file}:${lineNo} — icono con size-N: usa una clase size-icon-*`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const match of line.matchAll(RAW_ROUNDED)) {
|
for (const match of line.matchAll(RAW_ROUNDED)) {
|
||||||
violations.push(`${file}:${lineNo} — radio de Tailwind sin tokenizar: \`${match[0]}\``);
|
violations.push(`${file}:${lineNo} — radio de Tailwind sin tokenizar: \`${match[0]}\``);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en" class="scrollbar-stable">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { Action } from 'svelte/action';
|
||||||
|
|
||||||
|
export const onEscape: Action<HTMLElement, (() => void) | undefined> = (_node, handler) => {
|
||||||
|
function onKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape') handler?.();
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKeydown);
|
||||||
|
|
||||||
|
return {
|
||||||
|
update(next) {
|
||||||
|
handler = next;
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
window.removeEventListener('keydown', onKeydown);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
Vendored
+195
-1
@@ -331,6 +331,23 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/playlists/{playlistId}/albums/{albumId}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/** Add All The Tracks Of An Album To One Of Your PlayLists, Skipping Those Already In It. */
|
||||||
|
post: operations["AddAlbumToPlayList"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/playlists/{playlistId}/tracks": {
|
"/playlists/{playlistId}/tracks": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -658,6 +675,40 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/users/{id}/followers": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** Get The Users Following A User. Visible To The User And To Mutual Followers. */
|
||||||
|
get: operations["GetUserFollowers"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/users/{id}/following": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** Get The Users A User Follows. */
|
||||||
|
get: operations["GetUserFollowing"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/users/{id}/is-following": {
|
"/users/{id}/is-following": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -697,6 +748,10 @@ export interface paths {
|
|||||||
export type webhooks = Record<string, never>;
|
export type webhooks = Record<string, never>;
|
||||||
export interface components {
|
export interface components {
|
||||||
schemas: {
|
schemas: {
|
||||||
|
AddAlbumToPlayListResponse: {
|
||||||
|
/** Format: int32 */
|
||||||
|
addedCount: number | string;
|
||||||
|
};
|
||||||
AddPlayListTrackRequest: {
|
AddPlayListTrackRequest: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
trackId: string;
|
trackId: string;
|
||||||
@@ -773,6 +828,8 @@ export interface components {
|
|||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
audioIntentId: string;
|
audioIntentId: string;
|
||||||
tags: components["schemas"]["Genre"][];
|
tags: components["schemas"]["Genre"][];
|
||||||
|
/** @default false */
|
||||||
|
isExplicit: boolean;
|
||||||
};
|
};
|
||||||
CreateUserRequest: {
|
CreateUserRequest: {
|
||||||
/** Format: int64 */
|
/** Format: int64 */
|
||||||
@@ -865,6 +922,19 @@ export interface components {
|
|||||||
hasNextPage: boolean;
|
hasNextPage: boolean;
|
||||||
hasPreviousPage: boolean;
|
hasPreviousPage: boolean;
|
||||||
};
|
};
|
||||||
|
PaginatedResponseOfUserSummaryResponse: {
|
||||||
|
items: components["schemas"]["UserSummaryResponse"][];
|
||||||
|
/** Format: int32 */
|
||||||
|
pageNumber: number | string;
|
||||||
|
/** Format: int32 */
|
||||||
|
pageSize: number | string;
|
||||||
|
/** Format: int32 */
|
||||||
|
pageCount: number | string;
|
||||||
|
/** Format: int32 */
|
||||||
|
totalItemCount: number | string;
|
||||||
|
hasNextPage: boolean;
|
||||||
|
hasPreviousPage: boolean;
|
||||||
|
};
|
||||||
PaginatedResponseOfUserApplicationResponse: {
|
PaginatedResponseOfUserApplicationResponse: {
|
||||||
items: components["schemas"]["UserApplicationResponse"][];
|
items: components["schemas"]["UserApplicationResponse"][];
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
@@ -891,7 +961,12 @@ export interface components {
|
|||||||
smallImageKeyName: null | string;
|
smallImageKeyName: null | string;
|
||||||
mediumImageKeyName: null | string;
|
mediumImageKeyName: null | string;
|
||||||
largeImageKeyName: null | string;
|
largeImageKeyName: null | string;
|
||||||
|
ownerUserId: string;
|
||||||
visibility: components["schemas"]["PlaylistVisibility"];
|
visibility: components["schemas"]["PlaylistVisibility"];
|
||||||
|
/** Format: int32 */
|
||||||
|
trackCount: number | string;
|
||||||
|
/** Format: double */
|
||||||
|
durationSeconds: number | string;
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
@@ -959,6 +1034,7 @@ export interface components {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
ownerUserId: string;
|
ownerUserId: string;
|
||||||
tags: components["schemas"]["Genre"][];
|
tags: components["schemas"]["Genre"][];
|
||||||
|
isExplicit: boolean;
|
||||||
};
|
};
|
||||||
TrackSearchItemResponse: {
|
TrackSearchItemResponse: {
|
||||||
track: components["schemas"]["TrackApplicationResponse"];
|
track: components["schemas"]["TrackApplicationResponse"];
|
||||||
@@ -1039,6 +1115,16 @@ export interface components {
|
|||||||
followersCount: number | string;
|
followersCount: number | string;
|
||||||
/** Format: int32 */
|
/** Format: int32 */
|
||||||
followingCount: number | string;
|
followingCount: number | string;
|
||||||
|
isFollowing: boolean;
|
||||||
|
canViewFollowers: boolean;
|
||||||
|
};
|
||||||
|
UserSummaryResponse: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
firstName: null | string;
|
||||||
|
secondName: null | string;
|
||||||
|
profilePictureUrl: null | string;
|
||||||
|
isFollowedByViewer: boolean;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
responses: never;
|
responses: never;
|
||||||
@@ -1827,6 +1913,43 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
AddAlbumToPlayList: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
playlistId: string;
|
||||||
|
albumId: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["AddAlbumToPlayListResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Unauthorized */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Not Found */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
AddPlayListTrack: {
|
AddPlayListTrack: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -2428,7 +2551,7 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["PaginatedResponseOfUserApplicationResponse"];
|
"application/json": components["schemas"]["PaginatedResponseOfUserSummaryResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -2611,6 +2734,77 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
GetUserFollowers: {
|
||||||
|
parameters: {
|
||||||
|
query?: {
|
||||||
|
pageNumber?: number | string;
|
||||||
|
pageSize?: number | string;
|
||||||
|
};
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: number | string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["PaginatedResponseOfUserSummaryResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Forbidden */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Not Found */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
GetUserFollowing: {
|
||||||
|
parameters: {
|
||||||
|
query?: {
|
||||||
|
pageNumber?: number | string;
|
||||||
|
pageSize?: number | string;
|
||||||
|
};
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: number | string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["PaginatedResponseOfUserSummaryResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Not Found */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
IsFollowingUser: {
|
IsFollowingUser: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ListEnd from '@lucide/svelte/icons/list-end';
|
||||||
|
import ListPlus from '@lucide/svelte/icons/list-plus';
|
||||||
|
import { player } from '$lib/player/player.svelte';
|
||||||
|
import { fetchAlbumTracks } from '$lib/data/albums';
|
||||||
|
import type { Menu } from '$lib/state/menu.svelte';
|
||||||
|
import type { Playlist } from '$lib/types';
|
||||||
|
import ContextMenu from '$lib/components/ui/overlay/ContextMenu.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
menu: Menu<string>;
|
||||||
|
playlists: Playlist[];
|
||||||
|
}
|
||||||
|
|
||||||
|
let { menu, playlists }: Props = $props();
|
||||||
|
|
||||||
|
async function playNext(albumId: string) {
|
||||||
|
menu.close();
|
||||||
|
player.playNext(await fetchAlbumTracks(albumId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addToQueue(albumId: string) {
|
||||||
|
menu.close();
|
||||||
|
player.appendToQueue(await fetchAlbumTracks(albumId));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if menu.state}
|
||||||
|
{@const { target: albumId, x, y, openLeft } = menu.state}
|
||||||
|
<ContextMenu
|
||||||
|
{x}
|
||||||
|
{y}
|
||||||
|
{openLeft}
|
||||||
|
onClose={() => menu.close()}
|
||||||
|
items={[
|
||||||
|
{ icon: ListPlus, label: 'Reproducir a continuación', onclick: () => playNext(albumId) },
|
||||||
|
{ icon: ListEnd, label: 'Añadir a la cola', onclick: () => addToQueue(albumId) }
|
||||||
|
]}
|
||||||
|
playlistAction={{
|
||||||
|
action: '?/addAlbumToPlaylist',
|
||||||
|
fields: { albumId },
|
||||||
|
label: 'Añadir álbum a una playlist'
|
||||||
|
}}
|
||||||
|
{playlists}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { player } from '$lib/player/player.svelte';
|
||||||
|
import { isQueueCurrent, playAllOrToggle } from '$lib/player/actions';
|
||||||
|
import type { Track } from '$lib/types';
|
||||||
|
import Button from '$lib/components/ui/primitives/Button.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
items: Track[];
|
||||||
|
load?: () => Promise<Track[]>;
|
||||||
|
variant?: 'primary' | 'secondary' | 'accent';
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
idleLabel?: string;
|
||||||
|
activeLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
items,
|
||||||
|
load,
|
||||||
|
variant = 'primary',
|
||||||
|
size = 'sm',
|
||||||
|
idleLabel = 'Reproducir',
|
||||||
|
activeLabel = 'Pausar'
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const active = $derived(isQueueCurrent(items) && player.playing);
|
||||||
|
|
||||||
|
async function play() {
|
||||||
|
if (load && !isQueueCurrent(items)) playAllOrToggle(await load());
|
||||||
|
else playAllOrToggle(items);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Button {variant} {size} onclick={play} disabled={items.length === 0 && !load}>
|
||||||
|
{active ? activeLabel : idleLabel}
|
||||||
|
</Button>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ListPlus from '@lucide/svelte/icons/list-plus';
|
||||||
|
import { player } from '$lib/player/player.svelte';
|
||||||
|
import type { Menu } from '$lib/state/menu.svelte';
|
||||||
|
import type { Playlist, Track } from '$lib/types';
|
||||||
|
import ContextMenu from '$lib/components/ui/overlay/ContextMenu.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
menu: Menu<Track>;
|
||||||
|
playlists: Playlist[];
|
||||||
|
}
|
||||||
|
|
||||||
|
let { menu, playlists }: Props = $props();
|
||||||
|
|
||||||
|
function playNext(track: Track) {
|
||||||
|
player.playNext([track]);
|
||||||
|
menu.close();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if menu.state}
|
||||||
|
{@const { target: track, x, y, openLeft } = menu.state}
|
||||||
|
<ContextMenu
|
||||||
|
{x}
|
||||||
|
{y}
|
||||||
|
{openLeft}
|
||||||
|
onClose={() => menu.close()}
|
||||||
|
items={[{ icon: ListPlus, label: 'Reproducir a continuación', onclick: () => playNext(track) }]}
|
||||||
|
playlistAction={{
|
||||||
|
action: '?/addTrack',
|
||||||
|
fields: { trackId: track.id },
|
||||||
|
label: 'Añadir a una playlist'
|
||||||
|
}}
|
||||||
|
{playlists}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
+14
-33
@@ -1,18 +1,9 @@
|
|||||||
<script lang="ts" module>
|
<script lang="ts" module>
|
||||||
|
import type { Track } from '$lib/types';
|
||||||
|
|
||||||
export type TrackListColumn = 'added' | 'uploaded' | 'plays';
|
export type TrackListColumn = 'added' | 'uploaded' | 'plays';
|
||||||
|
|
||||||
export interface TrackListTrack {
|
export type TrackListTrack = Track & { date?: string | number };
|
||||||
id: string | number;
|
|
||||||
title: string;
|
|
||||||
artist?: string | null;
|
|
||||||
ownerUserId?: string | number | null;
|
|
||||||
isExplicit?: boolean;
|
|
||||||
explicit?: boolean;
|
|
||||||
duration?: number | string;
|
|
||||||
listensCount?: number | string;
|
|
||||||
addedAt?: string | number;
|
|
||||||
uploadedAt?: string | number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const COLUMN_PRESET: Record<TrackListColumn, { label: string; width: string }> = {
|
const COLUMN_PRESET: Record<TrackListColumn, { label: string; width: string }> = {
|
||||||
added: { label: 'Añadida', width: '124px' },
|
added: { label: 'Añadida', width: '124px' },
|
||||||
@@ -34,9 +25,9 @@
|
|||||||
import { player } from '$lib/player/player.svelte';
|
import { player } from '$lib/player/player.svelte';
|
||||||
import { fmtTime, fmtDate } from '$lib/utils/format';
|
import { fmtTime, fmtDate } from '$lib/utils/format';
|
||||||
import { pressable } from '$lib/actions/pressable';
|
import { pressable } from '$lib/actions/pressable';
|
||||||
import MediaIdentity from './MediaIdentity.svelte';
|
import MediaIdentity from '$lib/components/ui/media/MediaIdentity.svelte';
|
||||||
import EqBars from './EqBars.svelte';
|
import EqBars from '$lib/components/ui/media/EqBars.svelte';
|
||||||
import IconButton from '../primitives/IconButton.svelte';
|
import IconButton from '$lib/components/ui/primitives/IconButton.svelte';
|
||||||
|
|
||||||
interface RowAction {
|
interface RowAction {
|
||||||
action?: string;
|
action?: string;
|
||||||
@@ -95,7 +86,7 @@
|
|||||||
style="--mf-track-cols:{gridColumns}; --mf-track-cols-mobile:{gridColumnsMobile}"
|
style="--mf-track-cols:{gridColumns}; --mf-track-cols-mobile:{gridColumnsMobile}"
|
||||||
>
|
>
|
||||||
{#if index}
|
{#if index}
|
||||||
<Hash size={16} strokeWidth={1.5} />
|
<Hash class="size-icon-sm" />
|
||||||
{/if}
|
{/if}
|
||||||
<span>Título</span>
|
<span>Título</span>
|
||||||
{#each columns as column (column)}
|
{#each columns as column (column)}
|
||||||
@@ -108,7 +99,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mt-1 flex flex-col gap-0.5" role="list">
|
<div class="mt-1 flex flex-col gap-0.5" role="list">
|
||||||
{#each tracks as track, i (track.id)}
|
{#each tracks as track, i (track.id)}
|
||||||
{@const active = player.current.id === track.id}
|
{@const active = player.currentId === track.id}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
role="listitem"
|
role="listitem"
|
||||||
@@ -138,8 +129,6 @@
|
|||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
use:pressable={() => onPlay(i)}
|
use:pressable={() => onPlay(i)}
|
||||||
onclick={(event) => event.stopPropagation()}
|
|
||||||
onkeydown={(event) => event.stopPropagation()}
|
|
||||||
class="flex min-w-0 flex-1 text-left"
|
class="flex min-w-0 flex-1 text-left"
|
||||||
>
|
>
|
||||||
<MediaIdentity
|
<MediaIdentity
|
||||||
@@ -147,7 +136,7 @@
|
|||||||
title={track.title}
|
title={track.title}
|
||||||
artist={track.artist}
|
artist={track.artist}
|
||||||
ownerUserId={track.ownerUserId}
|
ownerUserId={track.ownerUserId}
|
||||||
explicit={track.isExplicit ?? track.explicit ?? false}
|
explicit={track.explicit}
|
||||||
{active}
|
{active}
|
||||||
>
|
>
|
||||||
{#snippet overlay()}
|
{#snippet overlay()}
|
||||||
@@ -160,26 +149,18 @@
|
|||||||
{#each columns as column (column)}
|
{#each columns as column (column)}
|
||||||
<span class="hidden truncate text-center text-xs text-fg-2 tabular-nums sm:block">
|
<span class="hidden truncate text-center text-xs text-fg-2 tabular-nums sm:block">
|
||||||
{#if column === 'plays'}
|
{#if column === 'plays'}
|
||||||
{Number(track.listensCount ?? 0)}
|
{track.listensCount}
|
||||||
{:else if column === 'added' && track.addedAt}
|
{:else if track.date}
|
||||||
{fmtDate(track.addedAt)}
|
{fmtDate(track.date)}
|
||||||
{:else if column === 'uploaded' && track.uploadedAt}
|
|
||||||
{fmtDate(track.uploadedAt)}
|
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
{/each}
|
{/each}
|
||||||
<span class="truncate text-center text-xs text-fg-2 tabular-nums">
|
<span class="truncate text-center text-xs text-fg-2 tabular-nums">
|
||||||
{fmtTime(Number(track.duration))}
|
{fmtTime(track.duration)}
|
||||||
</span>
|
</span>
|
||||||
{#if rowAction}
|
{#if rowAction}
|
||||||
{#if rowAction.action}
|
{#if rowAction.action}
|
||||||
<form
|
<form method="POST" action={rowAction.action} use:enhance>
|
||||||
method="POST"
|
|
||||||
action={rowAction.action}
|
|
||||||
use:enhance={() =>
|
|
||||||
async ({ update }) =>
|
|
||||||
update()}
|
|
||||||
>
|
|
||||||
<input type="hidden" name="trackId" value={track.id} />
|
<input type="hidden" name="trackId" value={track.id} />
|
||||||
<IconButton type="submit" label={rowAction.label} size="xs" revealOnHover>
|
<IconButton type="submit" label={rowAction.label} size="xs" revealOnHover>
|
||||||
<rowAction.icon class="size-icon-sm" />
|
<rowAction.icon class="size-icon-sm" />
|
||||||
@@ -4,18 +4,20 @@
|
|||||||
import Slider from '$lib/components/ui/primitives/Slider.svelte';
|
import Slider from '$lib/components/ui/primitives/Slider.svelte';
|
||||||
import IconButton from '$lib/components/ui/primitives/IconButton.svelte';
|
import IconButton from '$lib/components/ui/primitives/IconButton.svelte';
|
||||||
import { player } from '$lib/player/player.svelte';
|
import { player } from '$lib/player/player.svelte';
|
||||||
import { queuePanel } from '$lib/player/queuePanel.svelte';
|
import { queuePanel } from '$lib/state/panels.svelte';
|
||||||
import { fmtTime } from '$lib/utils/format';
|
import { fmtTime } from '$lib/utils/format';
|
||||||
|
import Volume1 from '@lucide/svelte/icons/volume-1';
|
||||||
import Volume2 from '@lucide/svelte/icons/volume-2';
|
import Volume2 from '@lucide/svelte/icons/volume-2';
|
||||||
|
import VolumeX from '@lucide/svelte/icons/volume-x';
|
||||||
import ListMusic from '@lucide/svelte/icons/list-music';
|
import ListMusic from '@lucide/svelte/icons/list-music';
|
||||||
|
|
||||||
function seek(value: number) {
|
function seek(value: number) {
|
||||||
if (player.current.duration > 0) player.seekFraction(value / player.current.duration);
|
if (player.duration > 0) player.seekFraction(value / player.duration);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="animate-enter pointer-events-auto flex items-center gap-icon-md glass-bar px-4 py-3 sm:hidden"
|
class="animate-enter pointer-events-auto flex items-center gap-4.5 px-4 py-3 glass-bar sm:hidden"
|
||||||
>
|
>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<TrackInfo compact />
|
<TrackInfo compact />
|
||||||
@@ -24,7 +26,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="animate-enter pointer-events-auto hidden items-center gap-5 glass-bar px-4 py-3 sm:flex"
|
class="animate-enter pointer-events-auto hidden items-center gap-5 px-4 py-3 glass-bar sm:flex"
|
||||||
>
|
>
|
||||||
<div class="w-(--mf-player-side-w) min-w-0 shrink-0">
|
<div class="w-(--mf-player-side-w) min-w-0 shrink-0">
|
||||||
<TrackInfo />
|
<TrackInfo />
|
||||||
@@ -37,20 +39,33 @@
|
|||||||
</span>
|
</span>
|
||||||
<Slider
|
<Slider
|
||||||
value={player.progress}
|
value={player.progress}
|
||||||
max={player.current.duration}
|
max={player.duration}
|
||||||
label="Barra de progreso"
|
label="Barra de progreso"
|
||||||
oninput={seek}
|
oninput={seek}
|
||||||
variant="seek"
|
variant="seek"
|
||||||
/>
|
/>
|
||||||
<span class="min-w-8 text-xs text-fg-2 tabular-nums">
|
<span class="min-w-8 text-xs text-fg-2 tabular-nums">
|
||||||
{fmtTime(player.current.duration)}
|
{fmtTime(player.duration)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="hidden w-(--mf-player-side-w) shrink-0 items-center justify-end gap-3.5 sm:flex">
|
<div class="flex w-(--mf-player-side-w) shrink-0 items-center justify-end gap-3.5">
|
||||||
<Volume2 class="size-icon-sm shrink-0 text-fg-2" strokeWidth={1.8} />
|
<IconButton
|
||||||
|
label={player.muted ? 'Activar sonido' : 'Silenciar'}
|
||||||
|
plain
|
||||||
|
size="xs"
|
||||||
|
onclick={() => player.toggleMute()}
|
||||||
|
>
|
||||||
|
{#if player.muted || player.volume === 0}
|
||||||
|
<VolumeX class="size-icon-sm" />
|
||||||
|
{:else if player.volume < 50}
|
||||||
|
<Volume1 class="size-icon-sm" />
|
||||||
|
{:else}
|
||||||
|
<Volume2 class="size-icon-sm" />
|
||||||
|
{/if}
|
||||||
|
</IconButton>
|
||||||
<Slider
|
<Slider
|
||||||
value={player.volume}
|
value={player.muted ? 0 : player.volume}
|
||||||
max={100}
|
max={100}
|
||||||
label="Volumen"
|
label="Volumen"
|
||||||
oninput={(value) => player.setVolume(value)}
|
oninput={(value) => player.setVolume(value)}
|
||||||
@@ -58,12 +73,12 @@
|
|||||||
/>
|
/>
|
||||||
<IconButton
|
<IconButton
|
||||||
label="Cola de reproducción"
|
label="Cola de reproducción"
|
||||||
tone="plain"
|
plain
|
||||||
size="xs"
|
size="xs"
|
||||||
pressed={queuePanel.open}
|
pressed={queuePanel.open}
|
||||||
onclick={() => queuePanel.toggle()}
|
onclick={() => queuePanel.toggle()}
|
||||||
>
|
>
|
||||||
<ListMusic class="size-icon-sm" strokeWidth={1.8} />
|
<ListMusic class="size-icon-sm" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import PlayerBar from './PlayerBar.svelte';
|
import PlayerBar from './PlayerBar.svelte';
|
||||||
import TabsBar from '../layout/TabsBar.svelte';
|
import TabsBar from '$lib/components/shell/TabsBar.svelte';
|
||||||
import { player } from '$lib/player/player.svelte';
|
import { player } from '$lib/player/player.svelte';
|
||||||
import type { SessionUser } from '$lib/types';
|
import type { SessionUser } from '$lib/types';
|
||||||
|
|
||||||
@@ -13,9 +13,7 @@
|
|||||||
const hasTrack = $derived(player.currentId !== null);
|
const hasTrack = $derived(player.currentId !== null);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div class="flex shrink-0 flex-col">
|
||||||
class="pointer-events-none fixed inset-x-0 bottom-0 z-(--z-sticky) flex flex-col lg:left-(--mf-sidebar-w)"
|
|
||||||
>
|
|
||||||
{#if hasTrack}
|
{#if hasTrack}
|
||||||
<PlayerBar />
|
<PlayerBar />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,41 +1,66 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { player } from '$lib/player/player.svelte';
|
import { player } from '$lib/player/player.svelte';
|
||||||
import { queuePanel } from '$lib/player/queuePanel.svelte';
|
import { queuePanel } from '$lib/state/panels.svelte';
|
||||||
import MediaIdentity from '$lib/components/ui/media/MediaIdentity.svelte';
|
import MediaIdentity from '$lib/components/ui/media/MediaIdentity.svelte';
|
||||||
import ListRow from '$lib/components/ui/media/ListRow.svelte';
|
import ListRow from '$lib/components/ui/media/ListRow.svelte';
|
||||||
import IconButton from '$lib/components/ui/primitives/IconButton.svelte';
|
import IconButton from '$lib/components/ui/primitives/IconButton.svelte';
|
||||||
import { fmtTime } from '$lib/utils/format';
|
import { fmtTime } from '$lib/utils/format';
|
||||||
import X from '@lucide/svelte/icons/x';
|
import { flip } from 'svelte/animate';
|
||||||
import { fly } from 'svelte/transition';
|
|
||||||
import { expoOut } from 'svelte/easing';
|
import { expoOut } from 'svelte/easing';
|
||||||
|
import X from '@lucide/svelte/icons/x';
|
||||||
|
|
||||||
const upcoming = $derived.by(() => {
|
const upcoming = $derived.by(() => {
|
||||||
const idx = player.tracks.findIndex((t) => t.id === player.currentId);
|
const idx = player.tracks.findIndex((t) => t.id === player.currentId);
|
||||||
return idx === -1 ? player.tracks : player.tracks.slice(idx + 1);
|
return idx === -1 ? player.tracks : player.tracks.slice(idx + 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let dragFrom = $state<number | null>(null);
|
||||||
|
let dropAt = $state<number | null>(null);
|
||||||
|
|
||||||
|
function onDragOver(event: DragEvent, index: number) {
|
||||||
|
if (dragFrom === null) return;
|
||||||
|
event.preventDefault();
|
||||||
|
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
|
||||||
|
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
|
||||||
|
dropAt = event.clientY < rect.top + rect.height / 2 ? index : index + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrop(event: DragEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (dragFrom !== null && dropAt !== null) player.moveQueueItem(dragFrom, dropAt);
|
||||||
|
endDrag();
|
||||||
|
}
|
||||||
|
|
||||||
|
function endDrag() {
|
||||||
|
dragFrom = null;
|
||||||
|
dropAt = null;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if queuePanel.open}
|
<div
|
||||||
|
class="queue-shell max-sm:hidden"
|
||||||
|
data-open={queuePanel.open ? '' : undefined}
|
||||||
|
inert={!queuePanel.open}
|
||||||
|
>
|
||||||
<aside
|
<aside
|
||||||
transition:fly={{ x: 24, duration: 200, easing: expoOut }}
|
class="flex min-h-0 w-(--mf-queue-w) shrink-0 flex-col border-l border-hairline px-4 pt-5 pb-5"
|
||||||
class="hidden shrink-0 flex-col border-l border-hairline px-4 pt-5 sm:flex"
|
|
||||||
style="width:var(--mf-queue-w); padding-bottom:calc(var(--mf-player-h) + 2rem)"
|
|
||||||
>
|
>
|
||||||
<div class="mb-5 flex items-center justify-between">
|
<div class="mb-5 flex items-center justify-between">
|
||||||
<h2 class="font-display text-base font-medium tracking-tight text-fg">En cola</h2>
|
<h2 class="font-display text-base font-medium tracking-tight text-fg">En cola</h2>
|
||||||
<IconButton label="Cerrar cola" tone="plain" size="xs" onclick={() => queuePanel.close()}>
|
<IconButton label="Cerrar cola" plain size="xs" onclick={() => queuePanel.close()}>
|
||||||
<X class="size-icon-md" />
|
<X class="size-icon-md" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if player.current.id}
|
{#if player.current}
|
||||||
<p class="mb-3 text-eyebrow text-fg-2">Reproduciendo</p>
|
<p class="mb-3 text-eyebrow text-fg-2">Reproduciendo</p>
|
||||||
<div class="mb-6 rounded-control surface-active p-2.5">
|
<div class="mb-6 surface-active rounded-control p-2.5">
|
||||||
<MediaIdentity
|
<MediaIdentity
|
||||||
trackId={player.current.id}
|
trackId={player.current.id}
|
||||||
title={player.current.title}
|
title={player.current.title}
|
||||||
artist={player.current.artist || '—'}
|
artist={player.current.artist || '—'}
|
||||||
ownerUserId={player.current.ownerUserId}
|
ownerUserId={player.current.ownerUserId}
|
||||||
|
explicit={player.current.explicit}
|
||||||
active
|
active
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -54,27 +79,66 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-1 flex-col gap-px overflow-y-auto">
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto"
|
||||||
|
ondragover={(event) => dragFrom !== null && event.preventDefault()}
|
||||||
|
ondrop={onDrop}
|
||||||
|
>
|
||||||
{#each upcoming as track, i (track.id)}
|
{#each upcoming as track, i (track.id)}
|
||||||
{@const index = player.tracks.length - upcoming.length + i}
|
{@const index = player.tracks.length - upcoming.length + i}
|
||||||
<ListRow
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
onclick={() => player.playQueueIndex(index)}
|
<div
|
||||||
size="sm"
|
draggable="true"
|
||||||
title={track.title}
|
ondragstart={(event) => {
|
||||||
subtitle={track.artist || '—'}
|
dragFrom = index;
|
||||||
subtitleHref={track.ownerUserId}
|
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
|
||||||
trackId={track.id}
|
}}
|
||||||
class="p-2"
|
ondragover={(event) => onDragOver(event, index)}
|
||||||
|
ondrop={onDrop}
|
||||||
|
ondragend={endDrag}
|
||||||
|
class="queue-row"
|
||||||
|
data-dragging={dragFrom === index ? '' : undefined}
|
||||||
|
data-drop={dragFrom === null
|
||||||
|
? undefined
|
||||||
|
: dropAt === index
|
||||||
|
? 'before'
|
||||||
|
: dropAt === index + 1 && index === player.tracks.length - 1
|
||||||
|
? 'after'
|
||||||
|
: undefined}
|
||||||
|
animate:flip={{ duration: 300, easing: expoOut }}
|
||||||
>
|
>
|
||||||
{#snippet trailing()}
|
{#if dropAt === index && dragFrom !== null}
|
||||||
{#if track.duration}
|
<div
|
||||||
<span class="shrink-0 text-xs text-fg-2 tabular-nums">{fmtTime(track.duration)}</span>
|
class="pointer-events-none absolute inset-x-2 -top-7 h-0.5 rounded-full bg-accent"
|
||||||
{/if}
|
></div>
|
||||||
{/snippet}
|
{:else if dropAt === index + 1 && dropAt === player.tracks.length && dragFrom !== null}
|
||||||
</ListRow>
|
<div
|
||||||
|
class="pointer-events-none absolute inset-x-2 -bottom-7 h-0.5 rounded-full bg-accent"
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
|
<ListRow
|
||||||
|
onclick={() => player.playQueueIndex(index)}
|
||||||
|
size="sm"
|
||||||
|
title={track.title}
|
||||||
|
subtitle={track.artist || '—'}
|
||||||
|
subtitleHref={track.ownerUserId}
|
||||||
|
explicit={track.explicit}
|
||||||
|
trackId={track.id}
|
||||||
|
class="p-2"
|
||||||
|
>
|
||||||
|
{#snippet trailing()}
|
||||||
|
{#if track.duration}
|
||||||
|
<span class="shrink-0 text-xs text-fg-2 tabular-nums"
|
||||||
|
>{fmtTime(track.duration)}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</ListRow>
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="px-2 py-6 text-center text-xs text-fg-2">No hay más canciones en la cola.</p>
|
<p class="px-2 py-6 text-center text-xs text-fg-2">No hay más canciones en la cola.</p>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
{/if}
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { player } from '$lib/player/player.svelte';
|
import { player } from '$lib/player/player.svelte';
|
||||||
import { liked } from '$lib/player/liked.svelte';
|
import { liked } from '$lib/state/liked.svelte';
|
||||||
import Artwork from '$lib/components/ui/media/Artwork.svelte';
|
import Artwork from '$lib/components/ui/media/Artwork.svelte';
|
||||||
import ExplicitBadge from '$lib/components/ui/media/ExplicitBadge.svelte';
|
import ExplicitBadge from '$lib/components/ui/media/ExplicitBadge.svelte';
|
||||||
import ArtistLink from '$lib/components/ui/media/ArtistLink.svelte';
|
import ArtistLink from '$lib/components/ui/media/ArtistLink.svelte';
|
||||||
@@ -13,54 +13,47 @@
|
|||||||
|
|
||||||
let { compact = false }: Props = $props();
|
let { compact = false }: Props = $props();
|
||||||
|
|
||||||
const isLiked = $derived(player.current.id !== '' && liked.isLiked(player.current.id));
|
const current = $derived(player.current);
|
||||||
|
const isLiked = $derived(current !== null && liked.isLiked(current.id));
|
||||||
|
|
||||||
function toggleLike() {
|
function toggleLike() {
|
||||||
if (!player.current.id) return;
|
if (current) liked.toggle(current);
|
||||||
liked.toggle({
|
|
||||||
id: player.current.id,
|
|
||||||
title: player.current.title,
|
|
||||||
artist: player.current.artist,
|
|
||||||
explicit: player.current.explicit,
|
|
||||||
ownerUserId: player.current.ownerUserId,
|
|
||||||
duration: player.current.duration
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex min-w-0 items-center gap-2.5 md:gap-3">
|
<div class="flex min-w-0 items-center gap-2.5 md:gap-3">
|
||||||
{#if player.current.id}
|
{#if current}
|
||||||
<Artwork
|
<Artwork
|
||||||
trackIds={[player.current.id]}
|
trackIds={[current.id]}
|
||||||
size="sm"
|
size="sm"
|
||||||
alt={player.current.title}
|
alt={current.title}
|
||||||
class="shrink-0 md:size-cover-md"
|
class="shrink-0 md:size-cover-md"
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="size-cover-sm shrink-0 rounded-art bg-surface md:size-cover-md"></div>
|
<div class="size-cover-sm shrink-0 rounded-art bg-surface md:size-cover-md"></div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0">
|
||||||
<div class="flex min-w-0 items-center gap-1.5">
|
<div class="flex min-w-0 items-center gap-1.5">
|
||||||
{#if player.current.explicit}
|
{#if current?.explicit}
|
||||||
<ExplicitBadge />
|
<ExplicitBadge />
|
||||||
{/if}
|
{/if}
|
||||||
<span class="truncate text-sm text-fg">{player.current.title}</span>
|
<span class="truncate text-sm text-fg">{current?.title ?? ''}</span>
|
||||||
</div>
|
</div>
|
||||||
<ArtistLink
|
<ArtistLink
|
||||||
name={player.current.artist || '—'}
|
name={current?.artist || '—'}
|
||||||
ownerUserId={player.current.ownerUserId}
|
ownerUserId={current?.ownerUserId}
|
||||||
class="mt-0.5 text-xs text-fg-2"
|
class="mt-0.5 text-xs text-fg-2"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{#if !compact}
|
{#if !compact}
|
||||||
<IconButton
|
<IconButton
|
||||||
label={isLiked ? 'Quitar de Me gusta' : 'Añadir a Me gusta'}
|
label={isLiked ? 'Quitar de Me gusta' : 'Añadir a Me gusta'}
|
||||||
tone="plain"
|
plain
|
||||||
size="xs"
|
size="md"
|
||||||
pressed={isLiked}
|
pressed={isLiked}
|
||||||
onclick={toggleLike}
|
onclick={toggleLike}
|
||||||
>
|
>
|
||||||
<Heart class="size-icon-md" fill={isLiked ? 'currentColor' : 'none'} strokeWidth={1.7} />
|
<Heart class="size-icon-md" fill={isLiked ? 'currentColor' : 'none'} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
aria-label="Aleatorio"
|
aria-label="Aleatorio"
|
||||||
class="transition-colors hover:text-fg {player.shuffle ? 'text-accent' : 'text-fg-2'}"
|
class="transition-colors hover:text-fg {player.shuffle ? 'text-accent' : 'text-fg-2'}"
|
||||||
>
|
>
|
||||||
<Shuffle class="size-4" strokeWidth={1.8} />
|
<Shuffle class="size-icon-sm" />
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
aria-label="Repetir"
|
aria-label="Repetir"
|
||||||
class="transition-colors hover:text-fg {player.repeat ? 'text-accent' : 'text-fg-2'}"
|
class="transition-colors hover:text-fg {player.repeat ? 'text-accent' : 'text-fg-2'}"
|
||||||
>
|
>
|
||||||
<Repeat class="size-4" strokeWidth={1.8} />
|
<Repeat class="size-icon-sm" />
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+21
-13
@@ -6,20 +6,21 @@
|
|||||||
import Settings from '@lucide/svelte/icons/settings';
|
import Settings from '@lucide/svelte/icons/settings';
|
||||||
import Sun from '@lucide/svelte/icons/sun';
|
import Sun from '@lucide/svelte/icons/sun';
|
||||||
import Moon from '@lucide/svelte/icons/moon';
|
import Moon from '@lucide/svelte/icons/moon';
|
||||||
import MenuItem from './MenuItem.svelte';
|
import MenuItem from '$lib/components/ui/overlay/MenuItem.svelte';
|
||||||
import GlassMenu from './GlassMenu.svelte';
|
import GlassMenu from '$lib/components/ui/overlay/GlassMenu.svelte';
|
||||||
import { themeMode } from '$lib/theme/mode.svelte';
|
import { themeMode } from '$lib/theme/mode.svelte';
|
||||||
import { clickOutside } from '$lib/actions/clickOutside';
|
import { clickOutside } from '$lib/actions/clickOutside';
|
||||||
|
import { onEscape } from '$lib/actions/onEscape';
|
||||||
|
import Avatar from '$lib/components/ui/media/Avatar.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: SessionUser;
|
user: SessionUser;
|
||||||
accountUrl?: string | null;
|
accountUrl?: string | null;
|
||||||
width?: 'sm' | 'md';
|
width?: 'sm' | 'md';
|
||||||
extraItems?: Snippet<[{ close: () => void }]>;
|
extraItems?: Snippet<[{ close: () => void }]>;
|
||||||
trigger: Snippet<[{ toggle: () => void; open: boolean }]>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let { user, accountUrl, width = 'sm', extraItems, trigger }: Props = $props();
|
let { user, accountUrl, width = 'sm', extraItems }: Props = $props();
|
||||||
|
|
||||||
const WIDTH: Record<'sm' | 'md', string> = { sm: 'w-52', md: 'w-56' };
|
const WIDTH: Record<'sm' | 'md', string> = { sm: 'w-52', md: 'w-56' };
|
||||||
|
|
||||||
@@ -32,16 +33,23 @@
|
|||||||
function close() {
|
function close() {
|
||||||
open = false;
|
open = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function onWindowKeydown(event: KeyboardEvent) {
|
|
||||||
if (event.key === 'Escape') close();
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window onkeydown={onWindowKeydown} />
|
<div class="relative" use:clickOutside={close} use:onEscape={open ? close : undefined}>
|
||||||
|
<button
|
||||||
<div class="relative" use:clickOutside={close}>
|
type="button"
|
||||||
{@render trigger({ toggle, open })}
|
onclick={toggle}
|
||||||
|
aria-label="Tu cuenta"
|
||||||
|
aria-expanded={open}
|
||||||
|
class="block shrink-0 rounded-full transition active:scale-95"
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
name={user.name}
|
||||||
|
src={user.picture}
|
||||||
|
size="sm"
|
||||||
|
class="border border-line-strong opacity-90"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
{#if open}
|
{#if open}
|
||||||
<GlassMenu
|
<GlassMenu
|
||||||
@@ -61,7 +69,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<MenuItem
|
<MenuItem
|
||||||
icon={themeMode.current === 'dark' ? Sun : Moon}
|
icon={themeMode.current === 'dark' ? Sun : Moon}
|
||||||
label={themeMode.current === 'dark' ? 'Modo blanco' : 'Modo oscuro'}
|
label={themeMode.current === 'dark' ? 'Modo claro' : 'Modo oscuro'}
|
||||||
onclick={() => themeMode.toggle()}
|
onclick={() => themeMode.toggle()}
|
||||||
/>
|
/>
|
||||||
<form method="POST" action="/auth/logout" data-sveltekit-reload>
|
<form method="POST" action="/auth/logout" data-sveltekit-reload>
|
||||||
+5
-17
@@ -3,11 +3,10 @@
|
|||||||
import Upload from '@lucide/svelte/icons/upload';
|
import Upload from '@lucide/svelte/icons/upload';
|
||||||
import Folder from '@lucide/svelte/icons/folder';
|
import Folder from '@lucide/svelte/icons/folder';
|
||||||
import LogIn from '@lucide/svelte/icons/log-in';
|
import LogIn from '@lucide/svelte/icons/log-in';
|
||||||
import MenuItem from '../ui/overlay/MenuItem.svelte';
|
import MenuItem from '$lib/components/ui/overlay/MenuItem.svelte';
|
||||||
import AccountMenu from '../ui/overlay/AccountMenu.svelte';
|
import AccountMenu from '$lib/components/shell/AccountMenu.svelte';
|
||||||
import Avatar from '../ui/media/Avatar.svelte';
|
import IconButton from '$lib/components/ui/primitives/IconButton.svelte';
|
||||||
import IconButton from '../ui/primitives/IconButton.svelte';
|
import Logo from '$lib/components/ui/primitives/Logo.svelte';
|
||||||
import Logo from '../ui/primitives/Logo.svelte';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: SessionUser | null;
|
user: SessionUser | null;
|
||||||
@@ -26,17 +25,6 @@
|
|||||||
|
|
||||||
{#if user}
|
{#if user}
|
||||||
<AccountMenu {user} {accountUrl} width="md">
|
<AccountMenu {user} {accountUrl} width="md">
|
||||||
{#snippet trigger({ toggle, open })}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={toggle}
|
|
||||||
aria-label="Tu cuenta"
|
|
||||||
aria-expanded={open}
|
|
||||||
class="flex items-center rounded-full"
|
|
||||||
>
|
|
||||||
<Avatar name={user.name} src={user.picture} size="sm" />
|
|
||||||
</button>
|
|
||||||
{/snippet}
|
|
||||||
{#snippet extraItems({ close })}
|
{#snippet extraItems({ close })}
|
||||||
<MenuItem icon={Folder} label="Canciones subidas" href="/library" onclick={close} />
|
<MenuItem icon={Folder} label="Canciones subidas" href="/library" onclick={close} />
|
||||||
<MenuItem icon={Upload} label="Subir música" href="/upload" onclick={close} />
|
<MenuItem icon={Upload} label="Subir música" href="/upload" onclick={close} />
|
||||||
@@ -44,7 +32,7 @@
|
|||||||
</AccountMenu>
|
</AccountMenu>
|
||||||
{:else}
|
{:else}
|
||||||
<IconButton href="/auth/login" reload label="Iniciar sesión" shape="round" size="md">
|
<IconButton href="/auth/login" reload label="Iniciar sesión" shape="round" size="md">
|
||||||
<LogIn class="size-icon-lg" strokeWidth={2} />
|
<LogIn class="size-icon-lg" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
{/if}
|
{/if}
|
||||||
</header>
|
</header>
|
||||||
+26
-27
@@ -1,38 +1,39 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { isSectionActive, appNavLinks } from '$lib/state/navigation.svelte';
|
import { appNavLinks, isNavActive } from './navLinks';
|
||||||
import { liked } from '$lib/player/liked.svelte';
|
import { playlistCover } from '$lib/utils/hrefs';
|
||||||
import { createPlaylistModal } from '$lib/state/playlists.svelte';
|
import { liked } from '$lib/state/liked.svelte';
|
||||||
|
import { createPlaylistModal } from '$lib/state/panels.svelte';
|
||||||
import type { SessionUser } from '$lib/types';
|
import type { SessionUser } from '$lib/types';
|
||||||
import ListRow from '../ui/media/ListRow.svelte';
|
import ListRow from '$lib/components/ui/media/ListRow.svelte';
|
||||||
import IconButton from '../ui/primitives/IconButton.svelte';
|
import IconButton from '$lib/components/ui/primitives/IconButton.svelte';
|
||||||
import Plus from '@lucide/svelte/icons/plus';
|
import Plus from '@lucide/svelte/icons/plus';
|
||||||
import Logo from '../ui/primitives/Logo.svelte';
|
import Logo from '$lib/components/ui/primitives/Logo.svelte';
|
||||||
|
|
||||||
|
const SIDEBAR_PLAYLISTS_LIMIT = 8;
|
||||||
|
|
||||||
interface SidebarPlaylist {
|
interface SidebarPlaylist {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
coverTrackIds: (string | number)[];
|
coverTrackIds: string[];
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: SessionUser | null;
|
user: SessionUser | null;
|
||||||
playlists: SidebarPlaylist[];
|
playlists: SidebarPlaylist[];
|
||||||
|
playlistCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { user, playlists }: Props = $props();
|
let { user, playlists, playlistCount }: Props = $props();
|
||||||
|
|
||||||
const navLinks = $derived(appNavLinks(user, { playlists: playlists.length, liked: liked.count }));
|
const visiblePlaylists = $derived(playlists.slice(0, SIDEBAR_PLAYLISTS_LIMIT));
|
||||||
|
|
||||||
function isActive(href: string) {
|
const navLinks = $derived(appNavLinks(user, { playlists: playlistCount, liked: liked.count }));
|
||||||
return isSectionActive(href, page.url.pathname, page.data.section);
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<aside
|
<aside
|
||||||
class="hidden shrink-0 flex-col border-r border-hairline p-5 lg:flex"
|
class="hidden min-h-0 w-(--mf-sidebar-w) shrink-0 flex-col border-r border-hairline p-5 lg:flex"
|
||||||
style="width:var(--mf-sidebar-w)"
|
|
||||||
>
|
>
|
||||||
<a href="/" class="mb-4">
|
<a href="/" class="mb-4">
|
||||||
<Logo size="sm" />
|
<Logo size="sm" />
|
||||||
@@ -40,7 +41,7 @@
|
|||||||
|
|
||||||
<nav class="flex flex-col gap-1">
|
<nav class="flex flex-col gap-1">
|
||||||
{#each navLinks as link (link.href)}
|
{#each navLinks as link (link.href)}
|
||||||
{@const active = isActive(link.href)}
|
{@const active = isNavActive(link.href)}
|
||||||
<a
|
<a
|
||||||
href={link.href}
|
href={link.href}
|
||||||
aria-current={active ? 'page' : undefined}
|
aria-current={active ? 'page' : undefined}
|
||||||
@@ -48,7 +49,7 @@
|
|||||||
? 'bg-surface-2 text-fg'
|
? 'bg-surface-2 text-fg'
|
||||||
: 'text-fg-2 hover:bg-hover hover:text-fg'}"
|
: 'text-fg-2 hover:bg-hover hover:text-fg'}"
|
||||||
>
|
>
|
||||||
<link.icon class="size-icon-md shrink-0" strokeWidth={1.5} />
|
<link.icon class="size-icon-md shrink-0" />
|
||||||
<span class="flex-1 truncate">{link.label}</span>
|
<span class="flex-1 truncate">{link.label}</span>
|
||||||
{#if link.count}
|
{#if link.count}
|
||||||
<span class="text-xs text-fg-2 tabular-nums">{link.count}</span>
|
<span class="text-xs text-fg-2 tabular-nums">{link.count}</span>
|
||||||
@@ -61,29 +62,27 @@
|
|||||||
<div class="mx-3 mt-6 mb-3.5 h-px bg-line"></div>
|
<div class="mx-3 mt-6 mb-3.5 h-px bg-line"></div>
|
||||||
<div class="flex items-center justify-between px-3 pb-1.5">
|
<div class="flex items-center justify-between px-3 pb-1.5">
|
||||||
<span class="text-eyebrow text-fg-2">Tus playlists</span>
|
<span class="text-eyebrow text-fg-2">Tus playlists</span>
|
||||||
{#if playlists.length > 0}
|
{#if visiblePlaylists.length > 0}
|
||||||
<IconButton
|
<IconButton
|
||||||
label="Crear playlist"
|
label="Crear playlist"
|
||||||
tone="plain"
|
plain
|
||||||
size="xs"
|
size="xs"
|
||||||
onclick={() => createPlaylistModal.show()}
|
onclick={() => createPlaylistModal.show()}
|
||||||
>
|
>
|
||||||
<Plus class="size-icon-sm text-fg-2" strokeWidth={1.25} />
|
<Plus class="size-icon-sm text-fg-2" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-1 flex-col gap-px overflow-y-auto pb-icon-md">
|
<div class="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto pb-4.5">
|
||||||
{#each playlists as playlist (playlist.id)}
|
{#each visiblePlaylists as playlist (playlist.id)}
|
||||||
{@const active = page.url.pathname === `/playlists/${playlist.id}`}
|
{@const active = page.url.pathname === `/playlists/${playlist.id}`}
|
||||||
<ListRow
|
<ListRow
|
||||||
href="/playlists/{playlist.id}"
|
href="/playlists/{playlist.id}"
|
||||||
{active}
|
{active}
|
||||||
size="xs"
|
size="xs"
|
||||||
title={playlist.name}
|
title={playlist.name}
|
||||||
coverSrc="/api/playlists/{playlist.id}/cover?size=small&v={encodeURIComponent(
|
coverSrc={playlistCover(playlist, 'small')}
|
||||||
playlist.updatedAt
|
|
||||||
)}"
|
|
||||||
trackIds={playlist.coverTrackIds}
|
trackIds={playlist.coverTrackIds}
|
||||||
class="px-3 py-2"
|
class="px-3 py-2"
|
||||||
/>
|
/>
|
||||||
@@ -91,10 +90,10 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => createPlaylistModal.show()}
|
onclick={() => createPlaylistModal.show()}
|
||||||
class="flex items-center gap-2 rounded-control px-3 py-2 text-xs text-fg-2 transition-colors hover:bg-hover hover:text-fg-2"
|
class="flex items-center gap-2 rounded-control px-3 py-2 text-xs text-fg-2 transition-colors hover:bg-hover"
|
||||||
>
|
>
|
||||||
<Plus class="size-3.5" strokeWidth={1.8} />
|
<Plus class="size-icon-xs" />
|
||||||
Crear tu primera lista
|
Crear tu primera playlist
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
+4
-9
@@ -1,6 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import { appNavLinks, isNavActive } from './navLinks';
|
||||||
import { isSectionActive, appNavLinks } from '$lib/state/navigation.svelte';
|
|
||||||
import type { SessionUser } from '$lib/types';
|
import type { SessionUser } from '$lib/types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -11,18 +10,14 @@
|
|||||||
let { user, class: klass = '' }: Props = $props();
|
let { user, class: klass = '' }: Props = $props();
|
||||||
|
|
||||||
const links = $derived(appNavLinks(user).filter((link) => link.primary));
|
const links = $derived(appNavLinks(user).filter((link) => link.primary));
|
||||||
|
|
||||||
function isActive(href: string) {
|
|
||||||
return isSectionActive(href, page.url.pathname, page.data.section);
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<nav
|
<nav
|
||||||
class="pointer-events-auto flex items-center justify-around glass-bar px-4 py-2 lg:hidden {klass}"
|
class="pointer-events-auto flex items-center justify-around px-4 py-2 glass-bar lg:hidden {klass}"
|
||||||
style="padding-bottom:calc(0.4375rem + var(--mf-safe-b))"
|
style="padding-bottom:calc(0.4375rem + var(--mf-safe-b))"
|
||||||
>
|
>
|
||||||
{#each links as link (link.href)}
|
{#each links as link (link.href)}
|
||||||
{@const active = isActive(link.href)}
|
{@const active = isNavActive(link.href)}
|
||||||
<a
|
<a
|
||||||
href={link.href}
|
href={link.href}
|
||||||
aria-current={active ? 'page' : undefined}
|
aria-current={active ? 'page' : undefined}
|
||||||
@@ -30,7 +25,7 @@
|
|||||||
? 'text-fg'
|
? 'text-fg'
|
||||||
: 'text-fg-2'}"
|
: 'text-fg-2'}"
|
||||||
>
|
>
|
||||||
<link.icon class="size-4" strokeWidth={1.6} />
|
<link.icon class="size-icon-sm" />
|
||||||
<span class="text-xs font-medium">{link.label}</span>
|
<span class="text-xs font-medium">{link.label}</span>
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
+10
-37
@@ -3,14 +3,13 @@
|
|||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import type { SessionUser } from '$lib/types';
|
import type { SessionUser } from '$lib/types';
|
||||||
import { SEARCH_DEBOUNCE_MS, SEARCH_MIN_LENGTH } from '$lib/config';
|
import { SEARCH_DEBOUNCE_MS, SEARCH_MIN_LENGTH } from '$lib/config';
|
||||||
import { searchHref } from '$lib/state/navigation.svelte';
|
import { searchHref } from '$lib/utils/hrefs';
|
||||||
import Search from '@lucide/svelte/icons/search';
|
import Search from '@lucide/svelte/icons/search';
|
||||||
import X from '@lucide/svelte/icons/x';
|
import X from '@lucide/svelte/icons/x';
|
||||||
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
|
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
|
||||||
import AccountMenu from '../ui/overlay/AccountMenu.svelte';
|
import AccountMenu from '$lib/components/shell/AccountMenu.svelte';
|
||||||
import Avatar from '../ui/media/Avatar.svelte';
|
import IconButton from '$lib/components/ui/primitives/IconButton.svelte';
|
||||||
import IconButton from '../ui/primitives/IconButton.svelte';
|
import Button from '$lib/components/ui/primitives/Button.svelte';
|
||||||
import Button from '../ui/primitives/Button.svelte';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: SessionUser | null;
|
user: SessionUser | null;
|
||||||
@@ -54,22 +53,15 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<header class="hidden items-center gap-3.5 px-6.5 pt-4 pb-3 sm:flex">
|
<header class="hidden items-center gap-3.5 px-6.5 pt-4 pb-3 sm:flex">
|
||||||
<IconButton
|
<IconButton label="Atrás" shape="round" surface size="xs" onclick={() => history.back()}>
|
||||||
label="Atrás"
|
<ChevronLeft class="size-icon-sm" />
|
||||||
shape="round"
|
|
||||||
tone="subtle"
|
|
||||||
surface
|
|
||||||
size="xs"
|
|
||||||
onclick={() => history.back()}
|
|
||||||
>
|
|
||||||
<ChevronLeft class="size-icon-sm" strokeWidth={1.9} />
|
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|
||||||
<form onsubmit={onSubmit} class="max-w-search min-w-0 flex-1">
|
<form onsubmit={onSubmit} class="max-w-search min-w-0 flex-1">
|
||||||
<label
|
<label
|
||||||
class="flex h-9.5 w-full items-center gap-2.5 rounded-control bg-surface-2 px-3.5 transition focus-within:bg-surface-hover"
|
class="flex h-9.5 w-full items-center gap-2.5 rounded-control bg-surface-2 px-3.5 transition focus-within:bg-surface-hover"
|
||||||
>
|
>
|
||||||
<Search class="size-4 shrink-0 text-fg-2" strokeWidth={1.8} />
|
<Search class="size-icon-sm shrink-0 text-fg-2" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
bind:this={searchInput}
|
bind:this={searchInput}
|
||||||
@@ -85,34 +77,15 @@
|
|||||||
aria-label="Borrar búsqueda"
|
aria-label="Borrar búsqueda"
|
||||||
class="grid size-5 shrink-0 place-items-center rounded-full text-fg-2 transition hover:text-fg"
|
class="grid size-5 shrink-0 place-items-center rounded-full text-fg-2 transition hover:text-fg"
|
||||||
>
|
>
|
||||||
<X class="size-3.5" />
|
<X class="size-icon-xs" />
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</label>
|
</label>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="flex-1"></div>
|
<div class="ml-auto shrink-0">
|
||||||
|
|
||||||
<div class="shrink-0">
|
|
||||||
{#if user}
|
{#if user}
|
||||||
<AccountMenu {user} {accountUrl}>
|
<AccountMenu {user} {accountUrl} />
|
||||||
{#snippet trigger({ toggle, open })}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={toggle}
|
|
||||||
aria-label="Tu cuenta"
|
|
||||||
aria-expanded={open}
|
|
||||||
class="block shrink-0 rounded-full transition active:scale-95"
|
|
||||||
>
|
|
||||||
<Avatar
|
|
||||||
name={user.name}
|
|
||||||
src={user.picture}
|
|
||||||
size="sm"
|
|
||||||
class="border border-line-strong opacity-90"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
{/snippet}
|
|
||||||
</AccountMenu>
|
|
||||||
{:else}
|
{:else}
|
||||||
<Button href="/auth/login" reload variant="accent" size="sm" class="shrink-0"
|
<Button href="/auth/login" reload variant="accent" size="sm" class="shrink-0"
|
||||||
>Iniciar sesión</Button
|
>Iniciar sesión</Button
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { page } from '$app/state';
|
||||||
|
import type { LucideIcon } from '@lucide/svelte';
|
||||||
|
import Home from '@lucide/svelte/icons/house';
|
||||||
|
import Compass from '@lucide/svelte/icons/compass';
|
||||||
|
import ListMusic from '@lucide/svelte/icons/list-music';
|
||||||
|
import Heart from '@lucide/svelte/icons/heart';
|
||||||
|
import Disc from '@lucide/svelte/icons/disc-2';
|
||||||
|
import Folder from '@lucide/svelte/icons/folder';
|
||||||
|
import Upload from '@lucide/svelte/icons/upload';
|
||||||
|
import type { SessionUser } from '$lib/types';
|
||||||
|
|
||||||
|
export interface AppNavLink {
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
count?: number;
|
||||||
|
primary: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppNavCounts {
|
||||||
|
playlists?: number;
|
||||||
|
liked?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isNavActive(href: string): boolean {
|
||||||
|
const section: string | null | undefined = page.data.section;
|
||||||
|
if (section !== undefined) return section === href;
|
||||||
|
return href === '/' ? page.url.pathname === '/' : page.url.pathname.startsWith(href);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appNavLinks(user: SessionUser | null, counts: AppNavCounts = {}): AppNavLink[] {
|
||||||
|
if (!user) return [{ href: '/explore', label: 'Descubrir', icon: Compass, primary: true }];
|
||||||
|
return [
|
||||||
|
{ href: '/', label: 'Inicio', icon: Home, primary: true },
|
||||||
|
{ href: '/explore', label: 'Descubrir', icon: Compass, primary: true },
|
||||||
|
{
|
||||||
|
href: '/playlists',
|
||||||
|
label: 'Playlists',
|
||||||
|
icon: ListMusic,
|
||||||
|
count: counts.playlists,
|
||||||
|
primary: true
|
||||||
|
},
|
||||||
|
{ href: '/liked', label: 'Me gusta', icon: Heart, count: counts.liked, primary: true },
|
||||||
|
{ href: '/albums', label: 'Mis álbumes', icon: Disc, primary: false },
|
||||||
|
{ href: '/library', label: 'Canciones subidas', icon: Folder, primary: false },
|
||||||
|
{ href: '/upload', label: 'Subir música', icon: Upload, primary: false }
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -2,10 +2,10 @@
|
|||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import Disc3 from '@lucide/svelte/icons/disc-3';
|
import Disc3 from '@lucide/svelte/icons/disc-3';
|
||||||
import CoverForm from './CoverForm.svelte';
|
import CoverForm from './CoverForm.svelte';
|
||||||
import Field from '../primitives/Field.svelte';
|
import Field from '$lib/components/ui/primitives/Field.svelte';
|
||||||
import Input from '../primitives/Input.svelte';
|
import Input from '$lib/components/ui/primitives/Input.svelte';
|
||||||
import Textarea from '../primitives/Textarea.svelte';
|
import Textarea from '$lib/components/ui/primitives/Textarea.svelte';
|
||||||
import { ALBUM_EARLIEST_YEAR } from '$lib/config';
|
import { ALBUM_EARLIEST_YEAR, LIMITS } from '$lib/validation';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
action: string;
|
action: string;
|
||||||
@@ -58,10 +58,13 @@
|
|||||||
>
|
>
|
||||||
{#snippet fields()}
|
{#snippet fields()}
|
||||||
<Field label="Título" for="album-title">
|
<Field label="Título" for="album-title">
|
||||||
|
{#snippet hint()}
|
||||||
|
<span class="ml-auto tabular-nums">{title.length}/{LIMITS.albumTitle}</span>
|
||||||
|
{/snippet}
|
||||||
<Input
|
<Input
|
||||||
id="album-title"
|
id="album-title"
|
||||||
name="title"
|
name="title"
|
||||||
maxlength={200}
|
maxlength={LIMITS.albumTitle}
|
||||||
required
|
required
|
||||||
bind:value={title}
|
bind:value={title}
|
||||||
placeholder={titlePlaceholder}
|
placeholder={titlePlaceholder}
|
||||||
@@ -85,7 +88,7 @@
|
|||||||
<Textarea
|
<Textarea
|
||||||
id="album-description"
|
id="album-description"
|
||||||
name="description"
|
name="description"
|
||||||
maxlength={256}
|
maxlength={LIMITS.albumDescription}
|
||||||
bind:value={description}
|
bind:value={description}
|
||||||
placeholder="¿De qué va este álbum?"
|
placeholder="¿De qué va este álbum?"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import type { LucideIcon } from '@lucide/svelte';
|
import type { LucideIcon } from '@lucide/svelte';
|
||||||
import ImageDropzone from './ImageDropzone.svelte';
|
import ImageDropzone from './ImageDropzone.svelte';
|
||||||
import Button from '../primitives/Button.svelte';
|
import Button from '$lib/components/ui/primitives/Button.svelte';
|
||||||
import Field from '../primitives/Field.svelte';
|
import Field from '$lib/components/ui/primitives/Field.svelte';
|
||||||
import Alert from '../primitives/Alert.svelte';
|
import Alert from '$lib/components/ui/primitives/Alert.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
action: string;
|
action: string;
|
||||||
@@ -81,10 +81,8 @@
|
|||||||
<div class="flex items-center justify-between gap-4">
|
<div class="flex items-center justify-between gap-4">
|
||||||
{#if helperText}
|
{#if helperText}
|
||||||
<p class="text-sm text-fg-2">{helperText}</p>
|
<p class="text-sm text-fg-2">{helperText}</p>
|
||||||
{:else}
|
|
||||||
<span></span>
|
|
||||||
{/if}
|
{/if}
|
||||||
<div class="flex shrink-0 items-center gap-3">
|
<div class="ml-auto flex shrink-0 items-center gap-3">
|
||||||
<Button type="button" variant="secondary" onclick={onCancel}>Cancelar</Button>
|
<Button type="button" variant="secondary" onclick={onCancel}>Cancelar</Button>
|
||||||
<Button type="submit" disabled={submitting || !canSubmit}>
|
<Button type="submit" disabled={submitting || !canSubmit}>
|
||||||
{submitting ? submittingLabel : submitLabel}
|
{submitting ? submittingLabel : submitLabel}
|
||||||
|
|||||||
@@ -37,9 +37,16 @@
|
|||||||
function onInput(event: Event) {
|
function onInput(event: Event) {
|
||||||
const file = (event.currentTarget as HTMLInputElement).files?.[0];
|
const file = (event.currentTarget as HTMLInputElement).files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
releasePreview();
|
||||||
preview = URL.createObjectURL(file);
|
preview = URL.createObjectURL(file);
|
||||||
onselect?.(file);
|
onselect?.(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function releasePreview() {
|
||||||
|
if (preview !== '') URL.revokeObjectURL(preview);
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => releasePreview);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<label
|
<label
|
||||||
@@ -68,6 +75,6 @@
|
|||||||
class="h-full w-full object-cover"
|
class="h-full w-full object-cover"
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<Icon class="size-icon-xl {gradient ? 'text-on-art/70' : 'text-fg-2'}" strokeWidth={1.3} />
|
<Icon class="size-icon-xl {gradient ? 'text-on-art/70' : 'text-fg-2'}" />
|
||||||
{/if}
|
{/if}
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -2,13 +2,12 @@
|
|||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import Music from '@lucide/svelte/icons/music';
|
import Music from '@lucide/svelte/icons/music';
|
||||||
import CoverForm from './CoverForm.svelte';
|
import CoverForm from './CoverForm.svelte';
|
||||||
import Field from '../primitives/Field.svelte';
|
import Field from '$lib/components/ui/primitives/Field.svelte';
|
||||||
import Input from '../primitives/Input.svelte';
|
import Input from '$lib/components/ui/primitives/Input.svelte';
|
||||||
import Textarea from '../primitives/Textarea.svelte';
|
import Textarea from '$lib/components/ui/primitives/Textarea.svelte';
|
||||||
import SegmentedControl from '../primitives/SegmentedControl.svelte';
|
import SegmentedControl from '$lib/components/ui/primitives/SegmentedControl.svelte';
|
||||||
|
|
||||||
const NAME_MAX_LENGTH = 60;
|
import { LIMITS } from '$lib/validation';
|
||||||
const DESCRIPTION_MAX_LENGTH = 300;
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
action: string;
|
action: string;
|
||||||
@@ -61,12 +60,12 @@
|
|||||||
<input type="hidden" name="visibility" value={visibility} />
|
<input type="hidden" name="visibility" value={visibility} />
|
||||||
<Field label="Nombre" for="playlist-name">
|
<Field label="Nombre" for="playlist-name">
|
||||||
{#snippet hint()}
|
{#snippet hint()}
|
||||||
<span class="ml-auto tabular-nums">{name.length}/{NAME_MAX_LENGTH}</span>
|
<span class="ml-auto tabular-nums">{name.length}/{LIMITS.playlistName}</span>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
<Input
|
<Input
|
||||||
id="playlist-name"
|
id="playlist-name"
|
||||||
name="name"
|
name="name"
|
||||||
maxlength={NAME_MAX_LENGTH}
|
maxlength={LIMITS.playlistName}
|
||||||
required
|
required
|
||||||
bind:value={name}
|
bind:value={name}
|
||||||
placeholder={namePlaceholder}
|
placeholder={namePlaceholder}
|
||||||
@@ -77,7 +76,7 @@
|
|||||||
<Textarea
|
<Textarea
|
||||||
id="playlist-description"
|
id="playlist-description"
|
||||||
name="description"
|
name="description"
|
||||||
maxlength={DESCRIPTION_MAX_LENGTH}
|
maxlength={LIMITS.playlistDescription}
|
||||||
bind:value={description}
|
bind:value={description}
|
||||||
placeholder="Para qué sirve esta playlist, cuándo la escuchas…"
|
placeholder="Para qué sirve esta playlist, cuándo la escuchas…"
|
||||||
class="flex-1"
|
class="flex-1"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
title: string;
|
title: string;
|
||||||
eyebrow?: string;
|
eyebrow?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
meta?: string;
|
meta?: string | Snippet;
|
||||||
cover?: Snippet;
|
cover?: Snippet;
|
||||||
actions?: Snippet;
|
actions?: Snippet;
|
||||||
align?: 'center' | 'end';
|
align?: 'center' | 'end';
|
||||||
@@ -45,8 +45,10 @@
|
|||||||
{#if description}
|
{#if description}
|
||||||
<p class="mt-3 max-w-2xl text-body text-fg-2">{description}</p>
|
<p class="mt-3 max-w-2xl text-body text-fg-2">{description}</p>
|
||||||
{/if}
|
{/if}
|
||||||
{#if meta}
|
{#if typeof meta === 'string'}
|
||||||
<p class="mt-3 text-sm text-fg-2">{meta}</p>
|
<p class="mt-3 text-sm text-fg-2">{meta}</p>
|
||||||
|
{:else if meta}
|
||||||
|
<p class="mt-3 text-sm text-fg-2">{@render meta()}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if actions && !cover}
|
{#if actions && !cover}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
interface Props {
|
interface Props {
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
ownerUserId?: string | number | null;
|
ownerUserId?: string | null;
|
||||||
class?: string;
|
class?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
<a
|
<a
|
||||||
href="/user/{ownerUserId}"
|
href="/user/{ownerUserId}"
|
||||||
onclick={(e) => e.stopPropagation()}
|
onclick={(e) => e.stopPropagation()}
|
||||||
class="block max-w-full truncate hover:text-fg-2 hover:underline {klass}"
|
class="block max-w-full truncate hover:underline {klass}"
|
||||||
>
|
>
|
||||||
{name}
|
{name}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
|
import { trackCover } from '$lib/utils/hrefs';
|
||||||
|
|
||||||
type Size = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'hero' | 'fill';
|
type Size = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'hero' | 'fill';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
src?: string | null;
|
src?: string | null;
|
||||||
trackIds?: (string | number)[];
|
trackIds?: string[];
|
||||||
size: Size;
|
size: Size;
|
||||||
shape?: 'auto' | 'round';
|
shape?: 'auto' | 'round';
|
||||||
imageSize?: 'small' | 'medium' | 'large';
|
imageSize?: 'small' | 'medium' | 'large';
|
||||||
fallback?: 'music' | 'gradient' | 'none';
|
|
||||||
alt?: string;
|
alt?: string;
|
||||||
class?: string;
|
class?: string;
|
||||||
children?: Snippet;
|
children?: Snippet;
|
||||||
@@ -21,7 +21,6 @@
|
|||||||
size,
|
size,
|
||||||
shape = 'auto',
|
shape = 'auto',
|
||||||
imageSize,
|
imageSize,
|
||||||
fallback = 'music',
|
|
||||||
alt = '',
|
alt = '',
|
||||||
class: klass = '',
|
class: klass = '',
|
||||||
children
|
children
|
||||||
@@ -63,22 +62,12 @@
|
|||||||
const resolvedImageSize = $derived(imageSize ?? AUTO_IMAGE_SIZE[size]);
|
const resolvedImageSize = $derived(imageSize ?? AUTO_IMAGE_SIZE[size]);
|
||||||
const radius = $derived(shape === 'round' ? 'rounded-full' : RADIUS[size]);
|
const radius = $derived(shape === 'round' ? 'rounded-full' : RADIUS[size]);
|
||||||
|
|
||||||
let srcFailed = $state(false);
|
let failedSrc = $state<string | null>(null);
|
||||||
$effect(() => {
|
let failedSingle = $state<string | null>(null);
|
||||||
src;
|
|
||||||
srcFailed = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
let singleFailed = $state(false);
|
const showSrc = $derived(!!src && failedSrc !== src);
|
||||||
$effect(() => {
|
|
||||||
trackIds[0];
|
|
||||||
singleFailed = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
const showSrc = $derived(!!src && !srcFailed);
|
|
||||||
const showMosaic = $derived(!showSrc && trackIds.length >= 4);
|
const showMosaic = $derived(!showSrc && trackIds.length >= 4);
|
||||||
const singleId = $derived(!showSrc && !showMosaic && trackIds.length > 0 ? trackIds[0] : null);
|
const singleId = $derived(!showSrc && !showMosaic && trackIds.length > 0 ? trackIds[0] : null);
|
||||||
const showSingle = $derived(singleId !== null && !singleFailed);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="relative overflow-hidden {DIMENSION[size]} {radius} {klass}">
|
<div class="relative overflow-hidden {DIMENSION[size]} {radius} {klass}">
|
||||||
@@ -89,14 +78,14 @@
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
fetchpriority="low"
|
fetchpriority="low"
|
||||||
onerror={() => (srcFailed = true)}
|
onerror={() => (failedSrc = src ?? null)}
|
||||||
class="h-full w-full object-cover"
|
class="h-full w-full object-cover"
|
||||||
/>
|
/>
|
||||||
{:else if showMosaic}
|
{:else if showMosaic}
|
||||||
<div class="grid h-full w-full grid-cols-2 grid-rows-2">
|
<div class="grid h-full w-full grid-cols-2 grid-rows-2">
|
||||||
{#each trackIds.slice(0, 4) as id (id)}
|
{#each trackIds.slice(0, 4) as id (id)}
|
||||||
<img
|
<img
|
||||||
src="/api/tracks/{id}/cover?size=small"
|
src={trackCover(id, 'small')}
|
||||||
alt=""
|
alt=""
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
@@ -106,17 +95,17 @@
|
|||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else if showSingle}
|
{:else if singleId !== null && failedSingle !== singleId}
|
||||||
<img
|
<img
|
||||||
src="/api/tracks/{singleId}/cover?size={resolvedImageSize}"
|
src={trackCover(singleId, resolvedImageSize)}
|
||||||
{alt}
|
{alt}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
fetchpriority="low"
|
fetchpriority="low"
|
||||||
onerror={() => (singleFailed = true)}
|
onerror={() => (failedSingle = singleId)}
|
||||||
class="h-full w-full object-cover"
|
class="h-full w-full object-cover"
|
||||||
/>
|
/>
|
||||||
{:else if fallback === 'music'}
|
{:else}
|
||||||
<div class="grid h-full w-full place-items-center bg-surface">
|
<div class="grid h-full w-full place-items-center bg-surface">
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor" class="h-2/5 w-2/5 text-fg-2">
|
<svg viewBox="0 0 24 24" fill="currentColor" class="h-2/5 w-2/5 text-fg-2">
|
||||||
<path
|
<path
|
||||||
@@ -124,10 +113,6 @@
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
{:else if fallback === 'gradient'}
|
|
||||||
<div class="h-full w-full bg-[image:var(--mf-cover-grad)]"></div>
|
|
||||||
{:else}
|
|
||||||
<div class="h-full w-full bg-surface"></div>
|
|
||||||
{/if}
|
{/if}
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -33,11 +33,7 @@
|
|||||||
.join('')
|
.join('')
|
||||||
);
|
);
|
||||||
|
|
||||||
let failed = $state(false);
|
let failedSrc = $state<string | null>(null);
|
||||||
$effect(() => {
|
|
||||||
src;
|
|
||||||
failed = false;
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -45,13 +41,13 @@
|
|||||||
size
|
size
|
||||||
]} {klass}"
|
]} {klass}"
|
||||||
>
|
>
|
||||||
{#if src && !failed}
|
{#if src && failedSrc !== src}
|
||||||
<img
|
<img
|
||||||
{src}
|
{src}
|
||||||
alt=""
|
alt=""
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
onerror={() => (failed = true)}
|
onerror={() => (failedSrc = src ?? null)}
|
||||||
class="absolute inset-0 h-full w-full object-cover"
|
class="absolute inset-0 h-full w-full object-cover"
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -1,19 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
interface Props {
|
interface Props {
|
||||||
size?: number;
|
|
||||||
barWidth?: number;
|
|
||||||
paused?: boolean;
|
paused?: boolean;
|
||||||
overlay?: boolean;
|
overlay?: boolean;
|
||||||
class?: string;
|
class?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let { paused = false, overlay = false, class: klass = '' }: Props = $props();
|
||||||
size = 13,
|
|
||||||
barWidth = 2,
|
|
||||||
paused = false,
|
|
||||||
overlay = false,
|
|
||||||
class: klass = ''
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
const delays = [0, 0.3, 0.6];
|
const delays = [0, 0.3, 0.6];
|
||||||
</script>
|
</script>
|
||||||
@@ -30,11 +22,11 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex items-end justify-center gap-0.5 {klass}" style="height:{size}px">
|
<div class="flex h-3.5 items-end justify-center gap-0.5 {klass}">
|
||||||
{#each delays as delay (delay)}
|
{#each delays as delay (delay)}
|
||||||
<span
|
<span
|
||||||
class="rounded-full bg-accent"
|
class="h-full w-0.5 rounded-full bg-accent"
|
||||||
style="width:{barWidth}px; height:100%; animation:equalize .9s ease-in-out {delay}s infinite; animation-play-state:{paused
|
style="animation:equalize .9s ease-in-out {delay}s infinite; animation-play-state:{paused
|
||||||
? 'paused'
|
? 'paused'
|
||||||
: 'running'}"
|
: 'running'}"
|
||||||
></span>
|
></span>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
class="grid size-4 shrink-0 place-items-center rounded-tag bg-fg-2 text-xs leading-4 font-bold text-bg {klass}"
|
class="grid size-4 shrink-0 place-items-center rounded-tag border border-accent/30 bg-accent-tint pt-px text-xs leading-none font-normal text-accent-soft {klass}"
|
||||||
title="Contenido explícito"
|
title="Contenido explícito"
|
||||||
aria-label="Contenido explícito">E</span
|
aria-label="Contenido explícito">E</span
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { untrack } from 'svelte';
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import Button from '$lib/components/ui/primitives/Button.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
following: boolean;
|
||||||
|
invalidateAll?: boolean;
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { following: initial, invalidateAll = false, class: klass = '' }: Props = $props();
|
||||||
|
|
||||||
|
let following = $state(untrack(() => initial));
|
||||||
|
let submitting = $state(false);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action={following ? '?/unfollow' : '?/follow'}
|
||||||
|
class={klass}
|
||||||
|
use:enhance={() => {
|
||||||
|
submitting = true;
|
||||||
|
return async ({ result, update }) => {
|
||||||
|
if (result.type === 'success' && typeof result.data?.following === 'boolean')
|
||||||
|
following = result.data.following;
|
||||||
|
await update({ reset: false, invalidateAll });
|
||||||
|
submitting = false;
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="sm"
|
||||||
|
variant={following ? 'secondary' : 'primary'}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
{following ? 'Dejar de seguir' : 'Seguir'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
@@ -12,10 +12,10 @@
|
|||||||
variant?: 'row' | 'card';
|
variant?: 'row' | 'card';
|
||||||
title: string;
|
title: string;
|
||||||
subtitle?: string | null;
|
subtitle?: string | null;
|
||||||
subtitleHref?: string | number | null;
|
subtitleHref?: string | null;
|
||||||
explicit?: boolean;
|
explicit?: boolean;
|
||||||
trackId?: string | number;
|
trackId?: string;
|
||||||
trackIds?: (string | number)[];
|
trackIds?: string[];
|
||||||
coverSrc?: string | null;
|
coverSrc?: string | null;
|
||||||
art?: Snippet;
|
art?: Snippet;
|
||||||
overlay?: Snippet;
|
overlay?: Snippet;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
title: string;
|
title: string;
|
||||||
subtitle?: string | null;
|
subtitle?: string | null;
|
||||||
src?: string | null;
|
src?: string | null;
|
||||||
trackIds?: (string | number)[];
|
trackIds?: string[];
|
||||||
onPlay?: (event: MouseEvent) => void;
|
onPlay?: (event: MouseEvent) => void;
|
||||||
art?: Snippet;
|
art?: Snippet;
|
||||||
oncontextmenu?: (event: MouseEvent) => void;
|
oncontextmenu?: (event: MouseEvent) => void;
|
||||||
|
|||||||
@@ -5,11 +5,11 @@
|
|||||||
import ArtistLink from './ArtistLink.svelte';
|
import ArtistLink from './ArtistLink.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
trackId?: string | number;
|
trackId?: string;
|
||||||
trackIds?: (string | number)[];
|
trackIds?: string[];
|
||||||
title: string;
|
title: string;
|
||||||
artist?: string | null;
|
artist?: string | null;
|
||||||
ownerUserId?: string | number | null;
|
ownerUserId?: string | null;
|
||||||
coverSrc?: string | null;
|
coverSrc?: string | null;
|
||||||
size?: 'xs' | 'sm' | 'lg';
|
size?: 'xs' | 'sm' | 'lg';
|
||||||
explicit?: boolean;
|
explicit?: boolean;
|
||||||
@@ -18,7 +18,6 @@
|
|||||||
class?: string;
|
class?: string;
|
||||||
art?: Snippet;
|
art?: Snippet;
|
||||||
overlay?: Snippet;
|
overlay?: Snippet;
|
||||||
badge?: Snippet;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -34,8 +33,7 @@
|
|||||||
titleClass = '',
|
titleClass = '',
|
||||||
class: klass = '',
|
class: klass = '',
|
||||||
art,
|
art,
|
||||||
overlay,
|
overlay
|
||||||
badge
|
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -61,7 +59,6 @@
|
|||||||
<span class="min-w-0 truncate text-sm {active ? 'text-accent-soft' : 'text-fg'} {titleClass}"
|
<span class="min-w-0 truncate text-sm {active ? 'text-accent-soft' : 'text-fg'} {titleClass}"
|
||||||
>{title}</span
|
>{title}</span
|
||||||
>
|
>
|
||||||
{@render badge?.()}
|
|
||||||
</div>
|
</div>
|
||||||
<ArtistLink name={artist} {ownerUserId} class="mt-0.5 text-xs text-fg-2" />
|
<ArtistLink name={artist} {ownerUserId} class="mt-0.5 text-xs text-fg-2" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ListRow from './ListRow.svelte';
|
||||||
|
import Avatar from './Avatar.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
profilePictureUrl: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let { user }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ListRow title={user.name} href="/user/{user.id}" size="lg" class="min-w-0 flex-1">
|
||||||
|
{#snippet art()}
|
||||||
|
<Avatar name={user.name} src={user.profilePictureUrl} size="md" />
|
||||||
|
{/snippet}
|
||||||
|
</ListRow>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Dialog from './Dialog.svelte';
|
import Dialog from './Dialog.svelte';
|
||||||
import Button from '../primitives/Button.svelte';
|
import Button from '$lib/components/ui/primitives/Button.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -9,15 +9,19 @@
|
|||||||
description: string;
|
description: string;
|
||||||
confirmLabel: string;
|
confirmLabel: string;
|
||||||
action: string;
|
action: string;
|
||||||
|
fields?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { open, onClose, title, description, confirmLabel, action }: Props = $props();
|
let { open, onClose, title, description, confirmLabel, action, fields = {} }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Dialog {open} {onClose} {title} {description}>
|
<Dialog {open} {onClose} {title} {description}>
|
||||||
{#snippet actions()}
|
{#snippet actions()}
|
||||||
<Button variant="secondary" onclick={onClose}>Cancelar</Button>
|
<Button variant="secondary" onclick={onClose}>Cancelar</Button>
|
||||||
<form method="POST" {action}>
|
<form method="POST" {action}>
|
||||||
|
{#each Object.entries(fields) as [name, value] (name)}
|
||||||
|
<input type="hidden" {name} {value} />
|
||||||
|
{/each}
|
||||||
<Button type="submit" variant="danger">{confirmLabel}</Button>
|
<Button type="submit" variant="danger">{confirmLabel}</Button>
|
||||||
</form>
|
</form>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|||||||
@@ -1,27 +1,10 @@
|
|||||||
<script lang="ts" module>
|
|
||||||
import { MENU_WIDTH, SUBMENU_WIDTH } from '$lib/config';
|
|
||||||
|
|
||||||
export interface MenuPosition {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
openLeft: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function contextMenuPosition(event: MouseEvent): MenuPosition {
|
|
||||||
event.preventDefault();
|
|
||||||
return {
|
|
||||||
x: Math.max(8, Math.min(event.clientX, window.innerWidth - MENU_WIDTH - 8)),
|
|
||||||
y: Math.max(8, Math.min(event.clientY, window.innerHeight - 60)),
|
|
||||||
openLeft: event.clientX + MENU_WIDTH + SUBMENU_WIDTH + 16 > window.innerWidth
|
|
||||||
};
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
|
import { onEscape } from '$lib/actions/onEscape';
|
||||||
|
import { playlistCover } from '$lib/utils/hrefs';
|
||||||
import type { LucideIcon } from '@lucide/svelte';
|
import type { LucideIcon } from '@lucide/svelte';
|
||||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||||
import MediaIdentity from '../media/MediaIdentity.svelte';
|
import MediaIdentity from '$lib/components/ui/media/MediaIdentity.svelte';
|
||||||
import GlassMenu from './GlassMenu.svelte';
|
import GlassMenu from './GlassMenu.svelte';
|
||||||
import MenuItem from './MenuItem.svelte';
|
import MenuItem from './MenuItem.svelte';
|
||||||
|
|
||||||
@@ -32,21 +15,16 @@
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
items: { icon: LucideIcon; label: string; onclick: () => void }[];
|
items: { icon: LucideIcon; label: string; onclick: () => void }[];
|
||||||
playlistAction?: { action: string; fields: Record<string, string>; label: string };
|
playlistAction?: { action: string; fields: Record<string, string>; label: string };
|
||||||
playlists?: { id: string; name: string }[];
|
playlists?: { id: string; name: string; updatedAt: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
let { x, y, openLeft, onClose, items, playlistAction, playlists = [] }: Props = $props();
|
let { x, y, openLeft, onClose, items, playlistAction, playlists = [] }: Props = $props();
|
||||||
|
|
||||||
function onWindowKeydown(event: KeyboardEvent) {
|
|
||||||
if (event.key === 'Escape') onClose();
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window onkeydown={onWindowKeydown} />
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="fixed inset-0 z-(--z-backdrop)"
|
class="fixed inset-0 z-(--z-backdrop)"
|
||||||
role="presentation"
|
role="presentation"
|
||||||
|
use:onEscape={onClose}
|
||||||
onclick={onClose}
|
onclick={onClose}
|
||||||
oncontextmenu={(e) => {
|
oncontextmenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -97,7 +75,7 @@
|
|||||||
>
|
>
|
||||||
<MediaIdentity
|
<MediaIdentity
|
||||||
title={playlist.name}
|
title={playlist.name}
|
||||||
coverSrc="/api/playlists/{playlist.id}/cover?size=small"
|
coverSrc={playlistCover(playlist, 'small')}
|
||||||
size="xs"
|
size="xs"
|
||||||
titleClass="text-fg-2"
|
titleClass="text-fg-2"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
>
|
>
|
||||||
{#if tone === 'danger'}
|
{#if tone === 'danger'}
|
||||||
<span class="mb-4 grid size-10 place-items-center rounded-full bg-danger/12 text-danger-fg">
|
<span class="mb-4 grid size-10 place-items-center rounded-full bg-danger/12 text-danger-fg">
|
||||||
<CircleAlert class="size-icon-md" strokeWidth={2} />
|
<CircleAlert class="size-icon-md" />
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
<h2 class="text-display-3 text-fg">{title}</h2>
|
<h2 class="text-display-3 text-fg">{title}</h2>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Dialog from './Dialog.svelte';
|
import Dialog from './Dialog.svelte';
|
||||||
import Button from '../primitives/Button.svelte';
|
import Button from '$lib/components/ui/primitives/Button.svelte';
|
||||||
import { dialog, type DialogAction, type DialogOptions } from '$lib/state/dialog.svelte';
|
import { dialog, type DialogAction, type DialogOptions } from '$lib/state/dialog.svelte';
|
||||||
|
|
||||||
// Keep the last dialog's content so the exit transition doesn't render an empty panel.
|
// Keep the last dialog's content so the exit transition doesn't render an empty panel.
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
import { pop } from '$lib/utils/transitions';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
class?: string;
|
class?: string;
|
||||||
style?: string;
|
style?: string;
|
||||||
@@ -12,9 +14,8 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="glass-panel animate-pop rounded-panel p-1.5 {border
|
transition:pop|global
|
||||||
? 'border border-hairline'
|
class="glass-panel rounded-panel p-1.5 {border ? 'border border-hairline' : ''} {klass}"
|
||||||
: ''} {klass}"
|
|
||||||
{style}
|
{style}
|
||||||
>
|
>
|
||||||
{@render children()}
|
{@render children()}
|
||||||
|
|||||||
@@ -28,21 +28,17 @@
|
|||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if href}
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<a
|
<svelte:element
|
||||||
{href}
|
this={href ? 'a' : 'button'}
|
||||||
{target}
|
{href}
|
||||||
rel={target === '_blank' ? 'noopener noreferrer' : undefined}
|
{target}
|
||||||
data-sveltekit-reload={reload ? '' : undefined}
|
rel={href && target === '_blank' ? 'noopener noreferrer' : undefined}
|
||||||
{onclick}
|
type={href ? undefined : type}
|
||||||
class={classes}
|
data-sveltekit-reload={href && reload ? '' : undefined}
|
||||||
>
|
{onclick}
|
||||||
<Icon class="size-icon-md shrink-0" />
|
class={classes}
|
||||||
{label}
|
>
|
||||||
</a>
|
<Icon class="size-icon-md shrink-0" />
|
||||||
{:else}
|
{label}
|
||||||
<button {type} {onclick} class={classes}>
|
</svelte:element>
|
||||||
<Icon class="size-icon-md shrink-0" />
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import X from '@lucide/svelte/icons/x';
|
import X from '@lucide/svelte/icons/x';
|
||||||
import IconButton from '../primitives/IconButton.svelte';
|
import IconButton from '$lib/components/ui/primitives/IconButton.svelte';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
import { expoOut } from 'svelte/easing';
|
import { pop } from '$lib/utils/transitions';
|
||||||
import type { TransitionConfig } from 'svelte/transition';
|
import { onEscape } from '$lib/actions/onEscape';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -48,22 +48,8 @@
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function pop(node: Element, { duration = 200 }: { duration?: number } = {}): TransitionConfig {
|
|
||||||
return {
|
|
||||||
duration,
|
|
||||||
easing: expoOut,
|
|
||||||
css: (t) =>
|
|
||||||
`opacity: ${t}; transform: scale(${0.98 + 0.02 * t}) translateY(${0.25 * (1 - t)}rem);`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function onWindowKeydown(event: KeyboardEvent) {
|
function onWindowKeydown(event: KeyboardEvent) {
|
||||||
if (!open) return;
|
if (!open || event.key !== 'Tab') return;
|
||||||
if (event.key === 'Escape') {
|
|
||||||
onClose();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (event.key !== 'Tab') return;
|
|
||||||
const elements = focusableElements();
|
const elements = focusableElements();
|
||||||
if (elements.length === 0) return;
|
if (elements.length === 0) return;
|
||||||
const first = elements[0];
|
const first = elements[0];
|
||||||
@@ -89,8 +75,9 @@
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
bind:this={panel}
|
bind:this={panel}
|
||||||
|
use:onEscape={onClose}
|
||||||
transition:pop={{ duration: 200 }}
|
transition:pop={{ duration: 200 }}
|
||||||
class="glass-panel max-h-[85dvh] w-full overflow-y-auto {maxWidth} rounded-panel p-5 outline-none sm:p-6 {panelClass}"
|
class="glass-panel max-h-[85dvh] w-full glow-drift overflow-y-auto {maxWidth} rounded-panel p-5 outline-none sm:p-6 {panelClass}"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label={title}
|
aria-label={title}
|
||||||
@@ -109,7 +96,7 @@
|
|||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<IconButton label="Cerrar" onclick={onClose} class="shrink-0">
|
<IconButton label="Cerrar" onclick={onClose} class="shrink-0">
|
||||||
<X class="size-4" />
|
<X class="size-icon-sm" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
|
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
|
||||||
import { previousPage } from '$lib/state/navigation.svelte';
|
import { previousPage } from '$lib/state/history.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
href: string;
|
href: string;
|
||||||
|
|||||||
@@ -28,13 +28,13 @@
|
|||||||
children
|
children
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const sizes = {
|
const SIZE = {
|
||||||
sm: 'rounded-control px-4 py-2 text-xs',
|
sm: 'rounded-control px-4 py-2 text-xs',
|
||||||
md: 'rounded-btn-sm px-4.5 py-2.5 text-control-sm',
|
md: 'rounded-btn-sm px-4.5 py-2.5 text-control-sm',
|
||||||
lg: 'rounded-btn px-6 py-3.5 text-sm'
|
lg: 'rounded-btn px-6 py-3.5 text-sm'
|
||||||
};
|
};
|
||||||
|
|
||||||
const variants = {
|
const VARIANT = {
|
||||||
primary: 'bg-cta-strong text-ink hover:bg-cta-strong-hover',
|
primary: 'bg-cta-strong text-ink hover:bg-cta-strong-hover',
|
||||||
secondary: 'bg-btn text-fg hover:bg-btn-hover',
|
secondary: 'bg-btn text-fg hover:bg-btn-hover',
|
||||||
accent: 'surface-active text-accent-soft hover:surface-active-hover',
|
accent: 'surface-active text-accent-soft hover:surface-active-hover',
|
||||||
@@ -42,19 +42,22 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const classes = $derived(
|
const classes = $derived(
|
||||||
`inline-flex items-center justify-center gap-2 font-medium btn-transition focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-bg focus-visible:outline-none disabled:opacity-40 ${sizes[size]} ${variants[variant]} ${klass}`
|
`inline-flex items-center justify-center gap-2 font-medium btn-transition focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-bg disabled:opacity-40 ${SIZE[size]} ${VARIANT[variant]} ${klass}`
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if href}
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<a {href} data-sveltekit-reload={reload ? '' : undefined} class={classes}>
|
<svelte:element
|
||||||
{@render children()}
|
this={href ? 'a' : 'button'}
|
||||||
</a>
|
{href}
|
||||||
{:else}
|
type={href ? undefined : type}
|
||||||
<button {type} disabled={disabled || loading} {onclick} class={classes}>
|
disabled={href ? undefined : disabled || loading}
|
||||||
{#if loading}
|
data-sveltekit-reload={href && reload ? '' : undefined}
|
||||||
<LoaderCircle class="size-4 animate-spin" />
|
{onclick}
|
||||||
{/if}
|
class={classes}
|
||||||
{@render children()}
|
>
|
||||||
</button>
|
{#if loading && !href}
|
||||||
{/if}
|
<LoaderCircle class="size-icon-sm animate-spin" />
|
||||||
|
{/if}
|
||||||
|
{@render children()}
|
||||||
|
</svelte:element>
|
||||||
|
|||||||
@@ -21,8 +21,7 @@
|
|||||||
class="peer size-5 cursor-pointer appearance-none rounded-tag border border-line bg-surface transition checked:border-accent-soft checked:bg-accent-soft focus-visible:border-accent/60 focus-visible:outline-none"
|
class="peer size-5 cursor-pointer appearance-none rounded-tag border border-line bg-surface transition checked:border-accent-soft checked:bg-accent-soft focus-visible:border-accent/60 focus-visible:outline-none"
|
||||||
/>
|
/>
|
||||||
<Check
|
<Check
|
||||||
class="pointer-events-none absolute size-3.5 text-ink opacity-0 transition peer-checked:opacity-100"
|
class="pointer-events-none absolute size-icon-xs text-ink opacity-0 transition peer-checked:opacity-100"
|
||||||
strokeWidth={3}
|
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
<span class="cursor-pointer">{@render children()}</span>
|
<span class="cursor-pointer">{@render children()}</span>
|
||||||
|
|||||||
@@ -14,22 +14,24 @@
|
|||||||
|
|
||||||
const classes = $derived(
|
const classes = $derived(
|
||||||
href
|
href
|
||||||
? 'rounded-full border border-line-strong px-3.5 py-2 text-xs text-fg-2 transition-colors hover:bg-hover hover:text-fg'
|
? 'focus-ring rounded-full border border-line-strong px-3.5 py-2 text-xs text-fg-2 transition-colors hover:bg-hover hover:text-fg'
|
||||||
: `flex items-center gap-2 rounded-full px-3.5 py-2 text-xs font-medium transition ${
|
: `focus-ring flex items-center gap-2 rounded-full px-3.5 py-2 text-xs font-medium transition ${
|
||||||
selected ? 'bg-cta-strong text-ink' : 'bg-btn text-fg-2 hover:bg-btn-hover hover:text-fg'
|
selected ? 'bg-cta-strong text-ink' : 'bg-btn text-fg-2 hover:bg-btn-hover hover:text-fg'
|
||||||
} ${disabled ? 'cursor-not-allowed opacity-40' : ''}`
|
} ${disabled ? 'cursor-not-allowed opacity-40' : ''}`
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet content()}
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<svelte:element
|
||||||
|
this={href ? 'a' : 'button'}
|
||||||
|
{href}
|
||||||
|
type={href ? undefined : 'button'}
|
||||||
|
disabled={href ? undefined : disabled}
|
||||||
|
onclick={href ? undefined : onclick}
|
||||||
|
class={classes}
|
||||||
|
>
|
||||||
{@render children()}
|
{@render children()}
|
||||||
{#if count !== undefined}
|
{#if count !== undefined}
|
||||||
<span class="tabular-nums {selected ? 'opacity-55' : 'text-fg-2'}">{count}</span>
|
<span class="tabular-nums {selected ? 'opacity-55' : 'text-fg-2'}">{count}</span>
|
||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
</svelte:element>
|
||||||
|
|
||||||
{#if href}
|
|
||||||
<a {href} class={classes}>{@render content()}</a>
|
|
||||||
{:else}
|
|
||||||
<button type="button" {onclick} {disabled} class={classes}>{@render content()}</button>
|
|
||||||
{/if}
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
<div class="flex flex-col items-center rounded-panel px-6 py-16 text-center {klass}">
|
<div class="flex flex-col items-center rounded-panel px-6 py-16 text-center {klass}">
|
||||||
{#if Icon}
|
{#if Icon}
|
||||||
<Icon class="size-icon-xl text-fg-2" strokeWidth={1.25} />
|
<Icon class="size-icon-xl text-fg-2" />
|
||||||
{/if}
|
{/if}
|
||||||
{#if title}
|
{#if title}
|
||||||
<h3 class="mt-6 text-display-3 text-fg">
|
<h3 class="mt-6 text-display-3 text-fg">
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
type?: 'button' | 'submit';
|
type?: 'button' | 'submit';
|
||||||
size?: 'xs' | 'sm' | 'md' | 'lg';
|
size?: 'xs' | 'sm' | 'md' | 'lg';
|
||||||
shape?: 'square' | 'round';
|
shape?: 'square' | 'round';
|
||||||
tone?: 'muted' | 'subtle' | 'plain';
|
plain?: boolean;
|
||||||
pressed?: boolean;
|
pressed?: boolean;
|
||||||
surface?: boolean;
|
surface?: boolean;
|
||||||
revealOnHover?: boolean;
|
revealOnHover?: boolean;
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
type = 'button',
|
type = 'button',
|
||||||
size = 'sm',
|
size = 'sm',
|
||||||
shape = 'square',
|
shape = 'square',
|
||||||
tone = 'muted',
|
plain = false,
|
||||||
pressed,
|
pressed,
|
||||||
surface = false,
|
surface = false,
|
||||||
revealOnHover = false,
|
revealOnHover = false,
|
||||||
@@ -40,35 +40,28 @@
|
|||||||
lg: 'size-10'
|
lg: 'size-10'
|
||||||
};
|
};
|
||||||
|
|
||||||
const TEXT: Record<'muted' | 'subtle' | 'plain', string> = {
|
|
||||||
muted: 'text-fg-2 hover:text-fg',
|
|
||||||
subtle: 'text-fg-2 hover:text-fg',
|
|
||||||
plain: 'text-fg-2 hover:text-fg'
|
|
||||||
};
|
|
||||||
|
|
||||||
const background = $derived(
|
const background = $derived(
|
||||||
surface ? 'bg-surface-2 hover:bg-surface-hover' : tone === 'plain' ? '' : 'hover:bg-hover'
|
surface ? 'bg-surface-2 hover:bg-surface-hover' : plain ? '' : 'hover:bg-hover'
|
||||||
);
|
);
|
||||||
|
|
||||||
const classes = $derived(
|
const classes = $derived(
|
||||||
`grid place-items-center transition focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none ${SIZE[size]} ${shape === 'round' ? 'rounded-full' : 'rounded-control'} ${pressed ? 'text-accent' : TEXT[tone]} ${background} ${
|
`grid place-items-center transition focus-ring ${SIZE[size]} ${shape === 'round' ? 'rounded-full' : 'rounded-control'} ${pressed ? 'text-accent' : 'text-fg-2 hover:text-fg'} ${background} ${
|
||||||
revealOnHover ? 'sm:opacity-0 sm:group-hover:opacity-100 sm:focus-visible:opacity-100' : ''
|
revealOnHover ? 'sm:opacity-0 sm:group-hover:opacity-100 sm:focus-visible:opacity-100' : ''
|
||||||
} ${klass}`
|
} ${klass}`
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if href}
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<a
|
<svelte:element
|
||||||
{href}
|
this={href ? 'a' : 'button'}
|
||||||
data-sveltekit-reload={reload ? '' : undefined}
|
{href}
|
||||||
aria-label={label}
|
type={href ? undefined : type}
|
||||||
title={label}
|
data-sveltekit-reload={href && reload ? '' : undefined}
|
||||||
class={classes}
|
aria-label={label}
|
||||||
>
|
aria-pressed={href ? undefined : pressed}
|
||||||
{@render children()}
|
title={label}
|
||||||
</a>
|
{onclick}
|
||||||
{:else}
|
class={classes}
|
||||||
<button {type} {onclick} aria-label={label} title={label} aria-pressed={pressed} class={classes}>
|
>
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</button>
|
</svelte:element>
|
||||||
{/if}
|
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
|
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
|
||||||
|
import Button from './Button.svelte';
|
||||||
import { INFINITE_SCROLL_ROOT_MARGIN } from '$lib/config';
|
import { INFINITE_SCROLL_ROOT_MARGIN } from '$lib/config';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onLoadMore: () => void;
|
onLoadMore: () => void;
|
||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
error?: boolean;
|
||||||
rootMargin?: string;
|
rootMargin?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { onLoadMore, hasMore, loading, rootMargin = INFINITE_SCROLL_ROOT_MARGIN }: Props = $props();
|
let {
|
||||||
|
onLoadMore,
|
||||||
|
hasMore,
|
||||||
|
loading,
|
||||||
|
error = false,
|
||||||
|
rootMargin = INFINITE_SCROLL_ROOT_MARGIN
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
let sentinel = $state<HTMLDivElement>();
|
let sentinel = $state<HTMLDivElement>();
|
||||||
let visible = $state(false);
|
let visible = $state(false);
|
||||||
@@ -28,12 +36,17 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (visible && hasMore && !loading) onLoadMore();
|
if (visible && hasMore && !loading && !error) onLoadMore();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div bind:this={sentinel} class="flex min-h-12 items-center justify-center py-8">
|
<div bind:this={sentinel} class="flex min-h-12 items-center justify-center py-8">
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<LoaderCircle class="size-5 animate-spin text-fg-2" />
|
<LoaderCircle class="size-icon-lg animate-spin text-fg-2" />
|
||||||
|
{:else if error}
|
||||||
|
<div class="flex items-center gap-3 text-sm text-fg-2">
|
||||||
|
No se pudo cargar más.
|
||||||
|
<Button variant="secondary" size="sm" onclick={onLoadMore}>Reintentar</Button>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
class="relative z-(--z-raised) flex-1 px-4 py-1.5 text-center text-xs font-medium transition-colors {value ===
|
class="relative z-(--z-raised) flex-1 px-4 py-1.5 text-center text-xs font-medium transition-colors {value ===
|
||||||
option.value
|
option.value
|
||||||
? 'text-ink'
|
? 'text-ink'
|
||||||
: 'text-fg-2 hover:text-fg-2'}"
|
: 'text-fg-2'}"
|
||||||
>
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
oninput={handleInput}
|
oninput={handleInput}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
class="mf-slider mf-slider-{variant}"
|
class="mf-slider mf-slider-{variant}"
|
||||||
style="background:linear-gradient(to right, var(--mf-text-2) {percent}%, var(--mf-track) {percent}%)"
|
style="--p:{percent}%"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -35,6 +35,17 @@
|
|||||||
border-radius: 100px;
|
border-radius: 100px;
|
||||||
outline: none;
|
outline: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(
|
||||||
|
to right,
|
||||||
|
transparent var(--p),
|
||||||
|
color-mix(in srgb, var(--mf-text) 12%, var(--mf-bg)) var(--p)
|
||||||
|
),
|
||||||
|
var(--mf-slider-fill);
|
||||||
|
background-size:
|
||||||
|
100% 100%,
|
||||||
|
300% 100%;
|
||||||
|
animation: gradient-drift 10s linear infinite alternate;
|
||||||
}
|
}
|
||||||
.mf-slider-seek {
|
.mf-slider-seek {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -1,21 +1,24 @@
|
|||||||
export const EXPLORE_PAGE_SIZE = 27;
|
export const EXPLORE_PAGE_SIZE = 27;
|
||||||
|
export const MAX_PAGE_SIZE = 100;
|
||||||
export const SEARCH_DEBOUNCE_MS = 450;
|
export const SEARCH_DEBOUNCE_MS = 450;
|
||||||
export const SEARCH_MIN_LENGTH = 2;
|
export const SEARCH_MIN_LENGTH = 2;
|
||||||
export const INFINITE_SCROLL_ROOT_MARGIN = '300px';
|
export const INFINITE_SCROLL_ROOT_MARGIN = '300px';
|
||||||
export const ALBUMS_PAGE_SIZE = 50;
|
export const ALBUMS_PAGE_SIZE = 50;
|
||||||
export const ALBUM_TRACKS_PAGE_SIZE = 200;
|
export const ALBUM_TRACKS_PAGE_SIZE = 200;
|
||||||
export const ALBUM_EARLIEST_YEAR = 1877;
|
export const PLAYLIST_TRACKS_PAGE_SIZE = 200;
|
||||||
export const LIBRARY_PICKER_PAGE_SIZE = 50;
|
export const LIBRARY_PICKER_PAGE_SIZE = 50;
|
||||||
|
export const LIBRARY_PAGE_SIZE = 50;
|
||||||
|
export const PLAYLISTS_PAGE_SIZE = 50;
|
||||||
|
export const PROFILE_PLAYLISTS_PAGE_SIZE = 50;
|
||||||
export const EXPLORE_ALBUMS_PAGE_SIZE = 10;
|
export const EXPLORE_ALBUMS_PAGE_SIZE = 10;
|
||||||
export const EXPLORE_ALBUMS_LIMIT = 18;
|
export const EXPLORE_ALBUMS_LIMIT = 18;
|
||||||
export const HOME_LATEST_PAGE_SIZE = 10;
|
|
||||||
export const HOME_MIXES_LIMIT = 6;
|
export const HOME_MIXES_LIMIT = 6;
|
||||||
export const HOME_SHELF_LIMIT = 6;
|
export const HOME_SHELF_LIMIT = 6;
|
||||||
export const HOME_CONTINUE_LIMIT = 10;
|
export const HOME_CONTINUE_LIMIT = 10;
|
||||||
export const HOME_SPOTLIGHT_TRACKS_LIMIT = 5;
|
export const HOME_SPOTLIGHT_TRACKS_LIMIT = 5;
|
||||||
export const HOME_POPULAR_MAX = 8;
|
|
||||||
export const PLAYLIST_PICKER_PAGE_SIZE = 50;
|
export const PLAYLIST_PICKER_PAGE_SIZE = 50;
|
||||||
export const LIKED_TRACKS_PAGE_SIZE = 500;
|
export const LIKED_TRACKS_PAGE_SIZE = 500;
|
||||||
export const EXPLORE_USERS_LIMIT = 12;
|
export const EXPLORE_USERS_LIMIT = 12;
|
||||||
|
export const FOLLOW_LIST_PAGE_SIZE = 30;
|
||||||
export const MENU_WIDTH = 240;
|
export const MENU_WIDTH = 240;
|
||||||
export const SUBMENU_WIDTH = 256;
|
export const SUBMENU_WIDTH = 256;
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { toQueueItems, type ApiTrackLike, type QueueItem } from '$lib/player/player.svelte';
|
import type { Paged, Track } from '$lib/types';
|
||||||
|
|
||||||
export async function fetchAlbumQueueItems(albumId: string): Promise<QueueItem[]> {
|
async function fetchTracks(url: string): Promise<Track[]> {
|
||||||
const res = await fetch(`/api/albums/${albumId}/tracks`);
|
const res = await fetch(url);
|
||||||
if (!res.ok) return [];
|
if (!res.ok) return [];
|
||||||
const data = (await res.json()) as { items: ApiTrackLike[] };
|
return ((await res.json()) as Paged<Track>).items;
|
||||||
return toQueueItems(data.items ?? []);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const fetchAlbumTracks = (albumId: string) => fetchTracks(`/api/albums/${albumId}/tracks`);
|
||||||
|
|
||||||
|
export const fetchPlaylistTracks = (playlistId: string) =>
|
||||||
|
fetchTracks(`/api/playlists/${playlistId}/tracks`);
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { findConflict, genreHue } from './genres';
|
||||||
|
|
||||||
|
describe('genreHue', () => {
|
||||||
|
it('is stable and within 0-359', () => {
|
||||||
|
for (const genre of ['rock', 'something-unknown', 'ñandú']) {
|
||||||
|
const hue = genreHue(genre);
|
||||||
|
expect(hue).toBe(genreHue(genre));
|
||||||
|
expect(hue).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(hue).toBeLessThan(360);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findConflict', () => {
|
||||||
|
const options = [
|
||||||
|
{ genre: 'a', incompatibleWith: ['b'] },
|
||||||
|
{ genre: 'b', incompatibleWith: ['a'] },
|
||||||
|
{ genre: 'c', incompatibleWith: [] }
|
||||||
|
];
|
||||||
|
it('returns the first incompatible pair', () => {
|
||||||
|
expect(findConflict(['c', 'a', 'b'], options)).toEqual(['a', 'b']);
|
||||||
|
});
|
||||||
|
it('returns undefined when compatible or unknown', () => {
|
||||||
|
expect(findConflict(['a', 'c'], options)).toBeUndefined();
|
||||||
|
expect(findConflict(['x', 'y'], options)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import type { components } from '$lib/api/schema';
|
|
||||||
import type { ApiTrackLike } from '$lib/player/player.svelte';
|
|
||||||
|
|
||||||
export type Mix = components['schemas']['MixApplicationResponse'];
|
|
||||||
export type MixItem = components['schemas']['MixItemApplicationResponse'];
|
|
||||||
|
|
||||||
export function mixItemKey(item: MixItem): string {
|
|
||||||
return `mf:${item.trackId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mixItemTrack(item: MixItem): ApiTrackLike {
|
|
||||||
return {
|
|
||||||
id: item.trackId,
|
|
||||||
title: item.title,
|
|
||||||
artist: item.artist,
|
|
||||||
duration: item.durationSeconds,
|
|
||||||
listensCount: item.listensCount
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import { player, toQueueItems, type ApiTrackLike, type QueueItem } from '$lib/player/player.svelte';
|
|
||||||
|
|
||||||
export function mergeRecentlyPlayed(history: ApiTrackLike[], limit: number): QueueItem[] {
|
|
||||||
const seen = new Set<string>();
|
|
||||||
const merged: QueueItem[] = [];
|
|
||||||
|
|
||||||
for (const t of player.recentlyPlayed) {
|
|
||||||
const id = String(t.id);
|
|
||||||
if (seen.has(id)) continue;
|
|
||||||
seen.add(id);
|
|
||||||
merged.push({ ...t, id });
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const item of toQueueItems(history)) {
|
|
||||||
const id = String(item.id);
|
|
||||||
if (seen.has(id)) continue;
|
|
||||||
seen.add(id);
|
|
||||||
merged.push({ ...item, id });
|
|
||||||
}
|
|
||||||
|
|
||||||
return merged.slice(0, limit);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { findTopResult, searchChips } from './search';
|
||||||
|
|
||||||
|
const empty = { tracks: [], albums: [], playlists: [], users: [] };
|
||||||
|
|
||||||
|
describe('findTopResult', () => {
|
||||||
|
it('prefers prefix matches by kind: user, track, album, playlist', () => {
|
||||||
|
const result = findTopResult({
|
||||||
|
query: 'Ab',
|
||||||
|
users: [{ name: 'Zed' }, { name: 'abel' }],
|
||||||
|
tracks: [{ title: 'Abc' }],
|
||||||
|
albums: [{ title: 'Abd' }],
|
||||||
|
playlists: [{ name: 'Abe' }]
|
||||||
|
});
|
||||||
|
expect(result).toEqual({ kind: 'user', user: { name: 'abel' } });
|
||||||
|
});
|
||||||
|
it('matches case-insensitively and ignores surrounding spaces', () => {
|
||||||
|
const result = findTopResult({ ...empty, query: ' SO ', tracks: [{ title: 'song' }] });
|
||||||
|
expect(result).toEqual({ kind: 'track', track: { title: 'song' } });
|
||||||
|
});
|
||||||
|
it('falls back to the first available result', () => {
|
||||||
|
const result = findTopResult({ ...empty, query: 'zzz', albums: [{ title: 'Other' }] });
|
||||||
|
expect(result).toEqual({ kind: 'album', album: { title: 'Other' } });
|
||||||
|
});
|
||||||
|
it('returns null without results', () => {
|
||||||
|
expect(findTopResult({ ...empty, query: 'x' })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('searchChips', () => {
|
||||||
|
it('adds an "all" chip with the total', () => {
|
||||||
|
const chips = searchChips({ tracks: 3, albums: 2, playlists: 0, users: 1 });
|
||||||
|
expect(chips.map((chip) => [chip.filter, chip.count])).toEqual([
|
||||||
|
['all', 6],
|
||||||
|
['tracks', 3],
|
||||||
|
['albums', 2],
|
||||||
|
['playlists', 0],
|
||||||
|
['users', 1]
|
||||||
|
]);
|
||||||
|
expect(chips[0].label).toBe('Todo');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,38 +1,40 @@
|
|||||||
export const SEARCH_FILTERS = ['Todo', 'Canciones', 'Álbumes', 'Playlists', 'Usuarios'] as const;
|
export const SEARCH_KINDS = ['tracks', 'albums', 'playlists', 'users'] as const;
|
||||||
export type SearchFilter = (typeof SEARCH_FILTERS)[number];
|
export type SearchKind = (typeof SEARCH_KINDS)[number];
|
||||||
|
export type SearchFilter = 'all' | SearchKind;
|
||||||
export const SEARCH_GROUP_PREVIEW = 4;
|
export const SEARCH_GROUP_PREVIEW = 4;
|
||||||
|
|
||||||
export function showGroup(filter: SearchFilter, label: Exclude<SearchFilter, 'Todo'>) {
|
export const SEARCH_LABELS: Record<SearchFilter, string> = {
|
||||||
return filter === 'Todo' || filter === label;
|
all: 'Todo',
|
||||||
|
tracks: 'Canciones',
|
||||||
|
albums: 'Álbumes',
|
||||||
|
playlists: 'Playlists',
|
||||||
|
users: 'Usuarios'
|
||||||
|
};
|
||||||
|
|
||||||
|
export function showGroup(filter: SearchFilter, kind: SearchKind) {
|
||||||
|
return filter === 'all' || filter === kind;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function capped<T>(filter: SearchFilter, list: T[]) {
|
export function capped<T>(filter: SearchFilter, list: T[]) {
|
||||||
return filter === 'Todo' ? list.slice(0, SEARCH_GROUP_PREVIEW) : list;
|
return filter === 'all' ? list.slice(0, SEARCH_GROUP_PREVIEW) : list;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function matchPlaylists<T extends { name: string }>(playlists: T[], query: string): T[] {
|
export function matchPlaylists<T extends { name: string }>(playlists: T[], query: string): T[] {
|
||||||
return query ? playlists.filter((p) => p.name.toLowerCase().includes(query.toLowerCase())) : [];
|
return query ? playlists.filter((p) => p.name.toLowerCase().includes(query.toLowerCase())) : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function searchCounts(counts: {
|
export type SearchCounts = Record<SearchKind, number>;
|
||||||
tracks: number;
|
|
||||||
albums: number;
|
export function searchTotal(counts: SearchCounts): number {
|
||||||
playlists: number;
|
return SEARCH_KINDS.reduce((sum, kind) => sum + counts[kind], 0);
|
||||||
users: number;
|
|
||||||
}) {
|
|
||||||
return {
|
|
||||||
Canciones: counts.tracks,
|
|
||||||
Álbumes: counts.albums,
|
|
||||||
Playlists: counts.playlists,
|
|
||||||
Usuarios: counts.users
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function searchChips(counts: ReturnType<typeof searchCounts>) {
|
export function searchChips(counts: SearchCounts) {
|
||||||
const total = counts.Canciones + counts.Álbumes + counts.Playlists + counts.Usuarios;
|
const filters: SearchFilter[] = ['all', ...SEARCH_KINDS];
|
||||||
return SEARCH_FILTERS.map((label) => ({
|
return filters.map((filter) => ({
|
||||||
label,
|
filter,
|
||||||
count: label === 'Todo' ? total : counts[label]
|
label: SEARCH_LABELS[filter],
|
||||||
|
count: filter === 'all' ? searchTotal(counts) : counts[filter]
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { shuffle } from '$lib/utils/collections';
|
||||||
|
import type { Track } from '$lib/types';
|
||||||
|
import { player } from './player.svelte';
|
||||||
|
|
||||||
|
export function isQueueCurrent(items: { id: string }[]): boolean {
|
||||||
|
return items.some((item) => item.id === player.currentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function playAllOrToggle(items: Track[]) {
|
||||||
|
if (items.length === 0) return;
|
||||||
|
if (isQueueCurrent(items)) player.toggle();
|
||||||
|
else player.playQueue(items, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function playShuffled(items: Track[]) {
|
||||||
|
if (items.length === 0) return;
|
||||||
|
player.playQueue(shuffle(items), 0);
|
||||||
|
}
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
import { toQueueItems, type ApiTrackLike } from './player.svelte';
|
|
||||||
|
|
||||||
export interface LikedTrack {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
artist?: string;
|
|
||||||
explicit?: boolean;
|
|
||||||
ownerUserId?: string | number | null;
|
|
||||||
listensCount?: number | string;
|
|
||||||
duration?: number | string;
|
|
||||||
likedAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
type LikeToggleInput = {
|
|
||||||
id: string | number;
|
|
||||||
title: string;
|
|
||||||
artist?: string;
|
|
||||||
explicit?: boolean;
|
|
||||||
ownerUserId?: string | number | null;
|
|
||||||
duration?: number | string;
|
|
||||||
};
|
|
||||||
|
|
||||||
class LikedStore {
|
|
||||||
entries = $state<Record<string, LikedTrack>>({});
|
|
||||||
private hydrated = false;
|
|
||||||
|
|
||||||
count = $derived(Object.keys(this.entries).length);
|
|
||||||
list = $derived(Object.values(this.entries).sort((a, b) => b.likedAt - a.likedAt));
|
|
||||||
|
|
||||||
hydrate(tracks: (ApiTrackLike & { createdAt: string; duration: number | string })[]) {
|
|
||||||
if (this.hydrated) return;
|
|
||||||
this.hydrated = true;
|
|
||||||
|
|
||||||
const items = toQueueItems(tracks);
|
|
||||||
const next: Record<string, LikedTrack> = {};
|
|
||||||
items.forEach((item, i) => {
|
|
||||||
const id = String(item.id);
|
|
||||||
next[id] = {
|
|
||||||
...item,
|
|
||||||
id,
|
|
||||||
duration: tracks[i].duration,
|
|
||||||
likedAt: new Date(tracks[i].createdAt).getTime()
|
|
||||||
};
|
|
||||||
});
|
|
||||||
this.entries = next;
|
|
||||||
}
|
|
||||||
|
|
||||||
isLiked(id: string | number): boolean {
|
|
||||||
return String(id) in this.entries;
|
|
||||||
}
|
|
||||||
|
|
||||||
async toggle(track: LikeToggleInput) {
|
|
||||||
const id = String(track.id);
|
|
||||||
const before = this.entries;
|
|
||||||
const wasLiked = id in before;
|
|
||||||
const next = { ...before };
|
|
||||||
if (wasLiked) delete next[id];
|
|
||||||
else next[id] = { ...track, id, likedAt: Date.now() };
|
|
||||||
this.entries = next;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/likes', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
body: JSON.stringify({ trackId: id })
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error('Failed to toggle like');
|
|
||||||
} catch {
|
|
||||||
this.entries = before;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const liked = new LikedStore();
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user