Add likes, follows and playlist visibility; redesign web-player UI

Backend:
- New Likes feature (toggle/get liked tracks) and endpoints
- User follow/unfollow, listening stats, public user profile
- Playlist visibility (public/private) support
- Various auth and DTO/response adjustments to support the above

Frontend (web-player):
- New design system pass: theme tokens/mode (light/dark), glass menu,
  tabs bar, player dock/extras/queue components, mix tiles, artist link
- New routes: Liked, public user profile (/u/[id]), likes API proxy
- Search refactor: shared SearchBox component used by TopBar and
  MobileHeader (search now reachable on mobile), live results as you
  type instead of requiring Enter, dropped the "Buscando…" quick-result
  preview
- Removed now-unused components (MobileNav, CoverBadge, MixBentoTile,
  PlayPauseOverlay, SuggestionsTable, userDirectory)
- Design handoff reference docs added under web-player/design_handoff_musify_player
  and web-player/docs/design-system-refactor-plan.md
This commit is contained in:
2026-09-14 23:42:45 +02:00
parent 389b3b9244
commit dc1ba89a6d
166 changed files with 14271 additions and 1982 deletions
+9 -2
View File
@@ -4,14 +4,21 @@
{ {
"name": "musify-web", "name": "musify-web",
"runtimeExecutable": "npm", "runtimeExecutable": "npm",
"runtimeArgs": ["--prefix", "web-player", "run", "dev", "--", "--port", "3000"], "runtimeArgs": ["--prefix", "web-player", "run", "dev"],
"port": 3000 "port": 3000,
"autoPort": true
}, },
{ {
"name": "musify-api", "name": "musify-api",
"runtimeExecutable": "dotnet", "runtimeExecutable": "dotnet",
"runtimeArgs": ["run", "--project", "backend/Hosts/Musify.Api"], "runtimeArgs": ["run", "--project", "backend/Hosts/Musify.Api"],
"port": 5111 "port": 5111
},
{
"name": "musify-streaming-gateway",
"runtimeExecutable": "dotnet",
"runtimeArgs": ["run", "--project", "backend/Hosts/Musify.StreamingGateway"],
"port": 8081
} }
] ]
} }
@@ -1,5 +1,6 @@
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Musify.Application.Serialization;
namespace Musify.Application.Albums.Responses namespace Musify.Application.Albums.Responses
{ {
@@ -17,7 +18,7 @@ namespace Musify.Application.Albums.Responses
string Title, string Title,
string? Description, string? Description,
int? ReleaseYear, int? ReleaseYear,
long OwnerUserId, [property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId,
int TrackCount, int TrackCount,
string? SmallImageKeyName, string? SmallImageKeyName,
string? MediumImageKeyName, string? MediumImageKeyName,
@@ -41,6 +41,10 @@ namespace Musify.Application.Contracts
DbSet<ListeningHistory> ListeningHistories { get; } DbSet<ListeningHistory> ListeningHistories { get; }
DbSet<TrackLike> TrackLikes { get; }
DbSet<UserFollow> UserFollows { get; }
Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken); Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken);
Task<int> SaveChangesAsync(CancellationToken cancellationToken); Task<int> SaveChangesAsync(CancellationToken cancellationToken);
@@ -0,0 +1,45 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Application.Tracks.Responses;
using Musify.Domain.Entities;
using X.PagedList;
using X.PagedList.EF;
namespace Musify.Application.Likes
{
public record GetLikedTracksQuery(
long UserId,
int PageNumber,
int PageSize)
: IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse>>>;
public class GetLikedTracksQueryHandler(IDatabase database)
: IQueryHandler<GetLikedTracksQuery, ErrorOr<PaginatedResponse<TrackApplicationResponse>>>
{
public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> Handle(GetLikedTracksQuery request, CancellationToken cancellationToken)
{
var likesQuery = database.TrackLikes
.AsNoTracking()
.Include(like => ((ExternalTrack)like.Track).TrackArtists)
.ThenInclude(trackArtist => trackArtist.Artist)
.Include(like => ((LocalTrack)like.Track).Owner)
.Where(like => like.UserId == request.UserId);
var totalCount = await likesQuery.CountAsync(cancellationToken);
var pagedEntities = await likesQuery
.OrderByDescending(like => like.CreatedAt)
.Select(like => new { like.Track, ListensCount = like.Track.ListeningHistories.Count })
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var pagedTracks = new StaticPagedList<TrackApplicationResponse>(
pagedEntities.Select(x => TrackApplicationResponse.FromEntity(x.Track, x.ListensCount)),
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
return PaginatedResponse<TrackApplicationResponse>.FromPagedList(pagedTracks);
}
}
}
@@ -0,0 +1,83 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Application.Services;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Likes
{
public record ToggleTrackLikeCommand(
long UserId,
Guid? TrackId,
TrackSource? Source,
string? ExternalId)
: ICommand<ErrorOr<bool>>;
public class ToggleTrackLikeCommandHandler(
IDatabase database,
YouTubeTrackProvisioner provisioner,
ILogger<ToggleTrackLikeCommandHandler> logger)
: ICommandHandler<ToggleTrackLikeCommand, ErrorOr<bool>>
{
public async ValueTask<ErrorOr<bool>> Handle(ToggleTrackLikeCommand request, CancellationToken cancellationToken)
{
var trackIdResult = await ResolveTrackIdAsync(request, cancellationToken);
if (trackIdResult.IsError)
return trackIdResult.Errors;
var trackId = trackIdResult.Value;
var existingLike = await database.TrackLikes
.SingleOrDefaultAsync(like => like.UserId == request.UserId && like.TrackId == trackId, cancellationToken);
bool nowLiked;
if (existingLike is not null)
{
database.TrackLikes.Remove(existingLike);
nowLiked = false;
}
else
{
await database.TrackLikes.AddAsync(new TrackLike
{
UserId = request.UserId,
TrackId = trackId
}, cancellationToken);
nowLiked = true;
}
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("User {UserId} {Action} track {TrackId}",
request.UserId, nowLiked ? "liked" : "unliked", trackId);
return nowLiked;
}
private async ValueTask<ErrorOr<Guid>> ResolveTrackIdAsync(ToggleTrackLikeCommand request, CancellationToken cancellationToken)
{
if (request.TrackId.HasValue)
{
var trackExists = await database.Tracks
.AsNoTracking()
.AnyAsync(t => t.Id == request.TrackId.Value, cancellationToken);
if (!trackExists)
{
logger.LogInformation("Track {TrackId} not found", request.TrackId);
return Error.NotFound(description: $"Track {request.TrackId} not found");
}
return request.TrackId.Value;
}
var provisionResult = await provisioner.GetOrCreateAsync(request.ExternalId!, cancellationToken);
if (provisionResult.IsError)
return provisionResult.Errors;
return provisionResult.Value.Track.Id;
}
}
}
@@ -17,7 +17,8 @@ namespace Musify.Application.PlayLists
long UserId, long UserId,
string Name, string Name,
string? Description, string? Description,
Guid? PictureIntentId) Guid? PictureIntentId,
PlaylistVisibility Visibility = PlaylistVisibility.Private)
: ICommand<ErrorOr<PlayListApplicationResponse>>; : ICommand<ErrorOr<PlayListApplicationResponse>>;
public class CreatePlayListCommandHandler( public class CreatePlayListCommandHandler(
@@ -55,7 +56,8 @@ namespace Musify.Application.PlayLists
Name = request.Name, Name = request.Name,
NormalizedName = request.Name.Trim().ToUpperInvariant(), NormalizedName = request.Name.Trim().ToUpperInvariant(),
Description = string.IsNullOrWhiteSpace(request.Description)? null: request.Description.Trim(), Description = string.IsNullOrWhiteSpace(request.Description)? null: request.Description.Trim(),
Pictures = resolvedPictures.Pictures Pictures = resolvedPictures.Pictures,
Visibility = request.Visibility
}; };
await database.PlayLists.AddAsync(playList, cancellationToken); await database.PlayLists.AddAsync(playList, cancellationToken);
@@ -3,10 +3,11 @@ using Microsoft.EntityFrameworkCore;
using Musify.Application.PlayLists.Responses; using Musify.Application.PlayLists.Responses;
using Mediator; using Mediator;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Domain.ValueObjects;
namespace Musify.Application.PlayLists namespace Musify.Application.PlayLists
{ {
public record GetPlayListByIdQuery(Guid Id) public record GetPlayListByIdQuery(Guid Id, long? RequestingUserId = null)
: IQuery<ErrorOr<PlayListApplicationResponse>>; : IQuery<ErrorOr<PlayListApplicationResponse>>;
public class GetPlayListByIdQueryHandler(IDatabase database) public class GetPlayListByIdQueryHandler(IDatabase database)
@@ -31,6 +32,10 @@ namespace Musify.Application.PlayLists
if (entry is null) if (entry is null)
return Error.NotFound(); return Error.NotFound();
if (entry.PlayList.Visibility == PlaylistVisibility.Private
&& entry.PlayList.UserId != request.RequestingUserId)
return Error.NotFound();
return PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds); return PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds);
} }
} }
@@ -14,7 +14,8 @@ namespace Musify.Application.PlayLists
long UserId, long UserId,
string? Name, string? Name,
int PageNumber, int PageNumber,
int PageSize) int PageSize,
bool OnlyPublic = false)
: IQuery<ErrorOr<PaginatedResponse<PlayListApplicationResponse>> >; : IQuery<ErrorOr<PaginatedResponse<PlayListApplicationResponse>> >;
public class GetPlayListsByUserIdQueryHandler(IDatabase database) public class GetPlayListsByUserIdQueryHandler(IDatabase database)
@@ -26,6 +27,11 @@ namespace Musify.Application.PlayLists
.AsNoTracking() .AsNoTracking()
.Where(p => p.UserId == request.UserId); .Where(p => p.UserId == request.UserId);
if (request.OnlyPublic)
{
playListsQuery = playListsQuery.Where(p => p.Visibility == PlaylistVisibility.Public);
}
if (!string.IsNullOrEmpty(request.Name)) if (!string.IsNullOrEmpty(request.Name))
{ {
var normalizedName = request.Name.Trim().ToUpperInvariant(); var normalizedName = request.Name.Trim().ToUpperInvariant();
@@ -1,4 +1,5 @@
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.PlayLists.Responses namespace Musify.Application.PlayLists.Responses
{ {
@@ -9,6 +10,7 @@ namespace Musify.Application.PlayLists.Responses
string? SmallImageKeyName, string? SmallImageKeyName,
string? MediumImageKeyName, string? MediumImageKeyName,
string? LargeImageKeyName, string? LargeImageKeyName,
PlaylistVisibility Visibility,
DateTime CreatedAt, DateTime CreatedAt,
DateTime UpdatedAt, DateTime UpdatedAt,
IReadOnlyList<Guid> CoverTrackIds) IReadOnlyList<Guid> CoverTrackIds)
@@ -26,6 +28,7 @@ namespace Musify.Application.PlayLists.Responses
playList.Pictures?.SmallName, playList.Pictures?.SmallName,
playList.Pictures?.MediumName, playList.Pictures?.MediumName,
playList.Pictures?.LargeName, playList.Pictures?.LargeName,
playList.Visibility,
playList.CreatedAt, playList.CreatedAt,
playList.UpdatedAt, playList.UpdatedAt,
coverTrackIds ?? []); coverTrackIds ?? []);
@@ -18,7 +18,8 @@ namespace Musify.Application.PlayLists
Guid PlayListId, Guid PlayListId,
string? NewName, string? NewName,
string? NewDescription, string? NewDescription,
Guid? NewPictureIntentId) Guid? NewPictureIntentId,
PlaylistVisibility? NewVisibility = null)
: ICommand<ErrorOr<PlayListApplicationResponse>>; : ICommand<ErrorOr<PlayListApplicationResponse>>;
public class UpdatePlayListCommandHandler( public class UpdatePlayListCommandHandler(
@@ -57,6 +58,11 @@ namespace Musify.Application.PlayLists
playListEntity.Description = request.NewDescription; playListEntity.Description = request.NewDescription;
} }
if (request.NewVisibility.HasValue)
{
playListEntity.Visibility = request.NewVisibility.Value;
}
UploadIntent? pictureIntent = null; UploadIntent? pictureIntent = null;
string? finalPictureKey = null; string? finalPictureKey = null;
@@ -0,0 +1,44 @@
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Musify.Application.Serialization;
// Zitadel-issued user ids routinely exceed Number.MAX_SAFE_INTEGER (2^53-1).
// Serialized as a raw JSON number, the frontend's JSON.parse silently rounds
// them to the nearest representable double, corrupting the id (it becomes a
// different, sometimes valid, user id). Applied via [property: JsonConverter(...)]
// on the specific id-shaped `long`/`long?` response properties (not globally —
// unrelated `long` fields like byte sizes must stay numbers), this serializes
// them as a JSON string instead, which round-trips exactly.
public sealed class LongAsStringConverter : JsonConverter<long>
{
public override long Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
reader.TokenType == JsonTokenType.String
? long.Parse(reader.GetString()!, CultureInfo.InvariantCulture)
: reader.GetInt64();
public override void Write(Utf8JsonWriter writer, long value, JsonSerializerOptions options) =>
writer.WriteStringValue(value.ToString(CultureInfo.InvariantCulture));
}
public sealed class NullableLongAsStringConverter : JsonConverter<long?>
{
public override long? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.Null)
return null;
return reader.TokenType == JsonTokenType.String
? string.IsNullOrEmpty(reader.GetString()) ? null : long.Parse(reader.GetString()!, CultureInfo.InvariantCulture)
: reader.GetInt64();
}
public override void Write(Utf8JsonWriter writer, long? value, JsonSerializerOptions options)
{
if (value.HasValue)
writer.WriteStringValue(value.Value.ToString(CultureInfo.InvariantCulture));
else
writer.WriteNullValue();
}
}
@@ -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;
@@ -12,7 +14,7 @@ namespace Musify.Application.Tracks.Responses
int ListensCount, int ListensCount,
DateTime CreatedAt, DateTime CreatedAt,
DateTime UpdatedAt, DateTime UpdatedAt,
long OwnerUserId) [property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId)
: TrackApplicationResponse(Id, Title, Artist, AudioStatus, Duration, ListensCount, CreatedAt, UpdatedAt) : TrackApplicationResponse(Id, Title, Artist, AudioStatus, Duration, ListensCount, CreatedAt, UpdatedAt)
{ {
public static LocalTrackApplicationResponse FromEntity(LocalTrack track, int listensCount) => public static LocalTrackApplicationResponse FromEntity(LocalTrack track, int listensCount) =>
@@ -0,0 +1,50 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Domain.Entities;
namespace Musify.Application.Users
{
public record FollowUserCommand(long FollowerId, long FollowedId) : ICommand<ErrorOr<Success>>;
public class FollowUserCommandHandler(
IDatabase database,
ILogger<FollowUserCommandHandler> logger)
: ICommandHandler<FollowUserCommand, ErrorOr<Success>>
{
public async ValueTask<ErrorOr<Success>> Handle(FollowUserCommand request, CancellationToken cancellationToken)
{
if (request.FollowerId == request.FollowedId)
return Error.Validation(description: "A user cannot follow themselves.");
var followedExists = await database.Users
.AsNoTracking()
.AnyAsync(u => u.Id == request.FollowedId, cancellationToken);
if (!followedExists)
{
logger.LogInformation("User {FollowedId} not found", request.FollowedId);
return Error.NotFound(description: $"User {request.FollowedId} not found");
}
var alreadyFollowing = await database.UserFollows
.AsNoTracking()
.AnyAsync(f => f.FollowerId == request.FollowerId && f.FollowedId == request.FollowedId, cancellationToken);
if (alreadyFollowing)
return new Success();
await database.UserFollows.AddAsync(new UserFollow
{
FollowerId = request.FollowerId,
FollowedId = request.FollowedId
}, cancellationToken);
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("User {FollowerId} followed user {FollowedId}", request.FollowerId, request.FollowedId);
return new Success();
}
}
}
@@ -0,0 +1,48 @@
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
namespace Musify.Application.Users
{
public record GetListeningStatsQuery(long UserId) : IQuery<ListeningStatsResponse>;
public record ListeningStatsResponse(int TracksThisWeek, int SecondsThisWeek, int StreakDays);
public class GetListeningStatsQueryHandler(IDatabase database)
: IQueryHandler<GetListeningStatsQuery, ListeningStatsResponse>
{
public async ValueTask<ListeningStatsResponse> Handle(GetListeningStatsQuery request, CancellationToken cancellationToken)
{
var weekAgo = DateTime.UtcNow.AddDays(-7);
var thisWeekHistory = database.ListeningHistories
.Where(l => l.UserId == request.UserId && l.ListenedAt >= weekAgo);
var tracksThisWeek = await thisWeekHistory
.Select(l => l.TrackId)
.Distinct()
.CountAsync(cancellationToken);
var secondsThisWeek = await thisWeekHistory
.Join(database.Tracks, l => l.TrackId, t => t.Id, (l, t) => t.DurationSeconds)
.SumAsync(cancellationToken);
var listenedDates = await database.ListeningHistories
.Where(l => l.UserId == request.UserId)
.Select(l => l.ListenedAt.Date)
.Distinct()
.ToListAsync(cancellationToken);
var listenedDateSet = listenedDates.ToHashSet();
var streakDays = 0;
var day = DateTime.UtcNow.Date;
while (listenedDateSet.Contains(day))
{
streakDays++;
day = day.AddDays(-1);
}
return new ListeningStatsResponse(tracksThisWeek, (int)Math.Round(secondsThisWeek), streakDays);
}
}
}
@@ -0,0 +1,63 @@
using System.Text.Json.Serialization;
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.Serialization;
namespace Musify.Application.Users
{
public record GetUserProfileQuery(long UserId) : IQuery<ErrorOr<UserProfileResponse>>;
public record UserProfileResponse(
[property: JsonConverter(typeof(LongAsStringConverter))] long Id,
string Name,
string? FirstName,
string? SecondName,
string? ProfilePictureUrl,
DateTime CreatedAt,
int FollowersCount,
int FollowingCount);
public class GetUserProfileQueryHandler(IDatabase database)
: IQueryHandler<GetUserProfileQuery, ErrorOr<UserProfileResponse>>
{
public async ValueTask<ErrorOr<UserProfileResponse>> Handle(GetUserProfileQuery request, CancellationToken cancellationToken)
{
var user = await database.Users
.AsNoTracking()
.Where(u => u.Id == request.UserId)
.Select(u => new
{
u.Id,
u.Name,
u.FirstName,
u.SecondName,
u.ProfilePictureUrl,
u.CreatedAt
})
.SingleOrDefaultAsync(cancellationToken);
if (user is null)
return Error.NotFound();
var followersCount = await database.UserFollows
.AsNoTracking()
.CountAsync(f => f.FollowedId == request.UserId, cancellationToken);
var followingCount = await database.UserFollows
.AsNoTracking()
.CountAsync(f => f.FollowerId == request.UserId, cancellationToken);
return new UserProfileResponse(
user.Id,
user.Name,
user.FirstName,
user.SecondName,
user.ProfilePictureUrl,
user.CreatedAt,
followersCount,
followingCount);
}
}
}
@@ -0,0 +1,17 @@
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
namespace Musify.Application.Users
{
public record IsFollowingUserQuery(long FollowerId, long FollowedId) : IQuery<bool>;
public class IsFollowingUserQueryHandler(IDatabase database)
: IQueryHandler<IsFollowingUserQuery, bool>
{
public ValueTask<bool> Handle(IsFollowingUserQuery request, CancellationToken cancellationToken) =>
new(database.UserFollows
.AsNoTracking()
.AnyAsync(f => f.FollowerId == request.FollowerId && f.FollowedId == request.FollowedId, cancellationToken));
}
}
@@ -1,9 +1,11 @@
using Musify.Domain.Entities; using System.Text.Json.Serialization;
using Musify.Application.Serialization;
using Musify.Domain.Entities;
namespace Musify.Application.Users.Responses namespace Musify.Application.Users.Responses
{ {
public record UserApplicationResponse( public record UserApplicationResponse(
long Id, [property: JsonConverter(typeof(LongAsStringConverter))] long Id,
string Name, string Name,
string? FirstName, string? FirstName,
string? SecondName, string? SecondName,
@@ -0,0 +1,32 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
namespace Musify.Application.Users
{
public record UnfollowUserCommand(long FollowerId, long FollowedId) : ICommand<ErrorOr<Success>>;
public class UnfollowUserCommandHandler(
IDatabase database,
ILogger<UnfollowUserCommandHandler> logger)
: ICommandHandler<UnfollowUserCommand, ErrorOr<Success>>
{
public async ValueTask<ErrorOr<Success>> Handle(UnfollowUserCommand request, CancellationToken cancellationToken)
{
var follow = await database.UserFollows
.SingleOrDefaultAsync(f => f.FollowerId == request.FollowerId && f.FollowedId == request.FollowedId, cancellationToken);
if (follow is null)
return new Success();
database.UserFollows.Remove(follow);
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("User {FollowerId} unfollowed user {FollowedId}", request.FollowerId, request.FollowedId);
return new Success();
}
}
}
@@ -33,6 +33,9 @@ namespace Musify.Domain.Entities
[Required] [Required]
public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active; public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active;
[Required]
public PlaylistVisibility Visibility { get; set; } = PlaylistVisibility.Private;
[Required] [Required]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
@@ -0,0 +1,28 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Musify.Domain.Entities
{
#pragma warning disable CS8618
[Table("TrackLikes")]
public class TrackLike
{
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
[Required]
public required long UserId { get; set; }
[Required]
public required Guid TrackId { get; set; }
[Required]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
[ForeignKey(nameof(UserId))]
public User User { get; set; }
[ForeignKey(nameof(TrackId))]
public Track Track { get; set; }
}
}
@@ -0,0 +1,28 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Musify.Domain.Entities
{
#pragma warning disable CS8618
[Table("UserFollows")]
public class UserFollow
{
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
[Required]
public required long FollowerId { get; set; }
[Required]
public required long FollowedId { get; set; }
[Required]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
[ForeignKey(nameof(FollowerId))]
public User Follower { get; set; }
[ForeignKey(nameof(FollowedId))]
public User Followed { get; set; }
}
}
@@ -0,0 +1,8 @@
namespace Musify.Domain.ValueObjects
{
public enum PlaylistVisibility
{
Private = 0,
Public = 1
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 68 KiB

@@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage; using Microsoft.EntityFrameworkCore.Storage;
using MassTransit; using MassTransit;
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using Musify.Infrastructure.Configuration; using Musify.Infrastructure.Configuration;
using Musify.Infrastructure.MassTransit.Sagas; using Musify.Infrastructure.MassTransit.Sagas;
using AppIDatabase = Musify.Application.Contracts.IDatabase; using AppIDatabase = Musify.Application.Contracts.IDatabase;
@@ -88,6 +89,10 @@ namespace Musify.Infrastructure.Persistence
.HasIndex(artist => artist.ExternalId) .HasIndex(artist => artist.ExternalId)
.IsUnique(); .IsUnique();
modelBuilder.Entity<PlayList>()
.Property(playList => playList.Visibility)
.HasDefaultValue(PlaylistVisibility.Private);
modelBuilder.Entity<PlayList>() modelBuilder.Entity<PlayList>()
.OwnsOne(playList => playList.Pictures, pictures => .OwnsOne(playList => playList.Pictures, pictures =>
{ {
@@ -159,6 +164,26 @@ namespace Musify.Infrastructure.Persistence
modelBuilder.Entity<MixItem>() modelBuilder.Entity<MixItem>()
.HasIndex(item => new { item.MixId, item.Position }); .HasIndex(item => new { item.MixId, item.Position });
modelBuilder.Entity<TrackLike>()
.HasIndex(like => new { like.UserId, like.TrackId })
.IsUnique();
modelBuilder.Entity<UserFollow>()
.HasOne(follow => follow.Follower)
.WithMany()
.HasForeignKey(follow => follow.FollowerId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<UserFollow>()
.HasOne(follow => follow.Followed)
.WithMany()
.HasForeignKey(follow => follow.FollowedId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<UserFollow>()
.HasIndex(follow => new { follow.FollowerId, follow.FollowedId })
.IsUnique();
var playListProcessing = modelBuilder.Entity<PlayListProcessingState>(); var playListProcessing = modelBuilder.Entity<PlayListProcessingState>();
playListProcessing.HasKey(state => state.CorrelationId); playListProcessing.HasKey(state => state.CorrelationId);
playListProcessing.Property(state => state.CorrelationId).ValueGeneratedNever(); playListProcessing.Property(state => state.CorrelationId).ValueGeneratedNever();
@@ -206,6 +231,10 @@ namespace Musify.Infrastructure.Persistence
public DbSet<ListeningHistory> ListeningHistories => Set<ListeningHistory>(); public DbSet<ListeningHistory> ListeningHistories => Set<ListeningHistory>();
public DbSet<TrackLike> TrackLikes => Set<TrackLike>();
public DbSet<UserFollow> UserFollows => Set<UserFollow>();
public async Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken) public async Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken)
{ {
var transaction = await Database.BeginTransactionAsync(isolationLevel, cancellationToken); var transaction = await Database.BeginTransactionAsync(isolationLevel, cancellationToken);
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Musify.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddPlayListVisibility : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "Visibility",
table: "PlayLists",
type: "integer",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Visibility",
table: "PlayLists");
}
}
}
@@ -0,0 +1,59 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Musify.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddTrackLikes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "TrackLikes",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<long>(type: "bigint", nullable: false),
TrackId = table.Column<Guid>(type: "uuid", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_TrackLikes", x => x.Id);
table.ForeignKey(
name: "FK_TrackLikes_Tracks_TrackId",
column: x => x.TrackId,
principalTable: "Tracks",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_TrackLikes_User_UserId",
column: x => x.UserId,
principalTable: "User",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_TrackLikes_TrackId",
table: "TrackLikes",
column: "TrackId");
migrationBuilder.CreateIndex(
name: "IX_TrackLikes_UserId_TrackId",
table: "TrackLikes",
columns: new[] { "UserId", "TrackId" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "TrackLikes");
}
}
}
@@ -0,0 +1,59 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Musify.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddUserFollows : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "UserFollows",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
FollowerId = table.Column<long>(type: "bigint", nullable: false),
FollowedId = table.Column<long>(type: "bigint", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_UserFollows", x => x.Id);
table.ForeignKey(
name: "FK_UserFollows_User_FollowedId",
column: x => x.FollowedId,
principalTable: "User",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_UserFollows_User_FollowerId",
column: x => x.FollowerId,
principalTable: "User",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_UserFollows_FollowedId",
table: "UserFollows",
column: "FollowedId");
migrationBuilder.CreateIndex(
name: "IX_UserFollows_FollowerId_FollowedId",
table: "UserFollows",
columns: new[] { "FollowerId", "FollowedId" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "UserFollows");
}
}
}
@@ -431,6 +431,11 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.Property<long>("UserId") b.Property<long>("UserId")
.HasColumnType("bigint"); .HasColumnType("bigint");
b.Property<int>("Visibility")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(0);
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("UserId"); b.HasIndex("UserId");
@@ -518,6 +523,31 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.ToTable("TrackArtist"); b.ToTable("TrackArtist");
}); });
modelBuilder.Entity("Musify.Domain.Entities.TrackLike", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("TrackId")
.HasColumnType("uuid");
b.Property<long>("UserId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("TrackId");
b.HasIndex("UserId", "TrackId")
.IsUnique();
b.ToTable("TrackLikes");
});
modelBuilder.Entity("Musify.Domain.Entities.Upload", b => modelBuilder.Entity("Musify.Domain.Entities.Upload", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -636,6 +666,31 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.ToTable("User"); b.ToTable("User");
}); });
modelBuilder.Entity("Musify.Domain.Entities.UserFollow", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<long>("FollowedId")
.HasColumnType("bigint");
b.Property<long>("FollowerId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("FollowedId");
b.HasIndex("FollowerId", "FollowedId")
.IsUnique();
b.ToTable("UserFollows");
});
modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b => modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -1105,6 +1160,25 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.Navigation("Track"); b.Navigation("Track");
}); });
modelBuilder.Entity("Musify.Domain.Entities.TrackLike", b =>
{
b.HasOne("Musify.Domain.Entities.Track", "Track")
.WithMany()
.HasForeignKey("TrackId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Musify.Domain.Entities.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Track");
b.Navigation("User");
});
modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b => modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b =>
{ {
b.HasOne("Musify.Domain.Entities.User", "User") b.HasOne("Musify.Domain.Entities.User", "User")
@@ -1116,6 +1190,25 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.Navigation("User"); b.Navigation("User");
}); });
modelBuilder.Entity("Musify.Domain.Entities.UserFollow", b =>
{
b.HasOne("Musify.Domain.Entities.User", "Followed")
.WithMany()
.HasForeignKey("FollowedId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Musify.Domain.Entities.User", "Follower")
.WithMany()
.HasForeignKey("FollowerId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Followed");
b.Navigation("Follower");
});
modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b => modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b =>
{ {
b.HasOne("Musify.Domain.Entities.Track", "Track") b.HasOne("Musify.Domain.Entities.Track", "Track")
@@ -13,8 +13,8 @@ namespace Musify.Infrastructure.Services
{ {
public class YouTubeMusicService(YouTubeConfiguration configuration, IMemoryCache cache, ILogger<YouTubeMusicService> logger) : IYouTubeMusicService public class YouTubeMusicService(YouTubeConfiguration configuration, IMemoryCache cache, ILogger<YouTubeMusicService> logger) : IYouTubeMusicService
{ {
private readonly YouTubeMusicClient client = new(geographicalLocation: configuration.GeographicalLocation); private readonly YouTubeMusicClient client = new YouTubeMusicClient(geographicalLocation: configuration.GeographicalLocation);
private readonly SingleFlightCache singleFlight = new(cache); private readonly SingleFlightCache singleFlight = new SingleFlightCache(cache);
private sealed record CachedStreamInfo(string Url, DateTime ExpiresAtUtc); private sealed record CachedStreamInfo(string Url, DateTime ExpiresAtUtc);
@@ -203,7 +203,7 @@ namespace Musify.Infrastructure.Services
{ {
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
if (cache.TryGetValue(StreamCacheKey(videoId), out CachedStreamInfo? cached) if (cache.TryGetValue(StreamCacheKey(videoId), out CachedStreamInfo? cached)
&& cached is not null && cached != null
&& cached.ExpiresAtUtc > now) && cached.ExpiresAtUtc > now)
{ {
return new YouTubeStreamInfo(cached.Url, (int)(cached.ExpiresAtUtc - now).TotalSeconds); return new YouTubeStreamInfo(cached.Url, (int)(cached.ExpiresAtUtc - now).TotalSeconds);
@@ -291,8 +291,7 @@ namespace Musify.Infrastructure.Services
var chosen = candidates.FirstOrDefault(thumbnail => thumbnail.Width >= configuration.ThumbnailSize) var chosen = candidates.FirstOrDefault(thumbnail => thumbnail.Width >= configuration.ThumbnailSize)
?? candidates.LastOrDefault(); ?? candidates.LastOrDefault();
return chosen is null return chosen ==null? string.Empty
? string.Empty
: YouTubeThumbNailHelper.WithSize(chosen.Url, configuration.ThumbnailSize); : YouTubeThumbNailHelper.WithSize(chosen.Url, configuration.ThumbnailSize);
} }
@@ -18,6 +18,7 @@ public static class AuthenticationExtensions
serviceProvider.GetRequiredService<IOptions<AuthenticationConfiguration>>().Value); serviceProvider.GetRequiredService<IOptions<AuthenticationConfiguration>>().Value);
services.AddMemoryCache(); services.AddMemoryCache();
services.AddHttpClient();
services.AddScoped<JwtBearerEventsHandler>(); services.AddScoped<JwtBearerEventsHandler>();
services.ConfigureOptions<JwtBearerOptionsSetup>(); services.ConfigureOptions<JwtBearerOptionsSetup>();
@@ -2,17 +2,30 @@ using Mediator;
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
using Musify.Application.Users; using Musify.Application.Users;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Security.Claims; using System.Security.Claims;
using System.Text.Json.Serialization;
namespace Musify.Api.Authentication; namespace Musify.Api.Authentication;
public sealed class JwtBearerEventsHandler( public sealed class JwtBearerEventsHandler(
IMediator mediator, IMediator mediator,
IMemoryCache cache, IMemoryCache cache,
IHttpClientFactory httpClientFactory,
AuthenticationConfiguration authenticationConfiguration,
ILogger<JwtBearerEventsHandler> logger) : JwtBearerEvents ILogger<JwtBearerEventsHandler> logger) : JwtBearerEvents
{ {
private static readonly TimeSpan UserSyncCacheTtl = TimeSpan.FromMinutes(15); private static readonly TimeSpan UserSyncCacheTtl = TimeSpan.FromMinutes(15);
private sealed record UserInfoResponse(
[property: JsonPropertyName("name")] string? Name,
[property: JsonPropertyName("preferred_username")] string? PreferredUsername,
[property: JsonPropertyName("email")] string? Email,
[property: JsonPropertyName("given_name")] string? GivenName,
[property: JsonPropertyName("family_name")] string? FamilyName,
[property: JsonPropertyName("picture")] string? Picture);
public override async Task TokenValidated(TokenValidatedContext tokenValidatedContext) public override async Task TokenValidated(TokenValidatedContext tokenValidatedContext)
{ {
var principal = tokenValidatedContext.Principal!; var principal = tokenValidatedContext.Principal!;
@@ -28,14 +41,30 @@ public sealed class JwtBearerEventsHandler(
if (cache.TryGetValue(cacheKey, out _)) if (cache.TryGetValue(cacheKey, out _))
return; return;
var username = principal.FindFirstValue(ClaimTypes.Name) // Access tokens from the IdP are minimal (just sub/scope/aud) — profile attributes live on
// the userinfo endpoint, so ask for them there instead of relying on token claims alone.
var userInfo = await FetchUserInfoAsync(tokenValidatedContext, tokenValidatedContext.HttpContext.RequestAborted);
// Zitadel also issues short-form JWT claim names ("name", "given_name", "family_name", "email"),
// but the newer JsonWebTokenHandler (default since .NET 8) no longer auto-maps those to the
// legacy ClaimTypes.* URIs the way JwtSecurityTokenHandler used to — so check both forms too.
var username = userInfo?.Name
?? userInfo?.PreferredUsername
?? principal.FindFirstValue("name")
?? principal.FindFirstValue(ClaimTypes.Name)
?? principal.FindFirstValue("preferred_username") ?? principal.FindFirstValue("preferred_username")
?? userInfo?.Email
?? principal.FindFirstValue("email")
?? principal.FindFirstValue(ClaimTypes.Email) ?? principal.FindFirstValue(ClaimTypes.Email)
?? rawId!; ?? rawId!;
var firstName = principal.FindFirstValue(ClaimTypes.GivenName); var firstName = userInfo?.GivenName
var lastName = principal.FindFirstValue(ClaimTypes.Surname); ?? principal.FindFirstValue("given_name")
var profilePictureUrl = principal.FindFirstValue("picture"); ?? principal.FindFirstValue(ClaimTypes.GivenName);
var lastName = userInfo?.FamilyName
?? principal.FindFirstValue("family_name")
?? principal.FindFirstValue(ClaimTypes.Surname);
var profilePictureUrl = userInfo?.Picture ?? principal.FindFirstValue("picture");
try try
{ {
@@ -51,6 +80,43 @@ public sealed class JwtBearerEventsHandler(
} }
} }
private async Task<UserInfoResponse?> FetchUserInfoAsync(
TokenValidatedContext tokenValidatedContext, CancellationToken cancellationToken)
{
var authorizationHeader = tokenValidatedContext.HttpContext.Request.Headers.Authorization.ToString();
if (!authorizationHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
return null;
var accessToken = authorizationHeader["Bearer ".Length..].Trim();
if (accessToken.Length == 0)
return null;
try
{
var client = httpClientFactory.CreateClient();
var request = new HttpRequestMessage(
HttpMethod.Get,
$"{authenticationConfiguration.IssuerAddress.TrimEnd('/')}/oidc/v1/userinfo")
{
Headers = { Authorization = new AuthenticationHeaderValue("Bearer", accessToken) }
};
var response = await client.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode)
{
logger.LogDebug("Userinfo request returned {StatusCode}", response.StatusCode);
return null;
}
return await response.Content.ReadFromJsonAsync<UserInfoResponse>(cancellationToken);
}
catch (Exception exception)
{
logger.LogWarning(exception, "Failed to fetch userinfo from identity provider");
return null;
}
}
public override Task AuthenticationFailed(AuthenticationFailedContext context) public override Task AuthenticationFailed(AuthenticationFailedContext context)
{ {
logger.LogWarning(context.Exception, "JWT authentication failed"); logger.LogWarning(context.Exception, "JWT authentication failed");
@@ -0,0 +1,8 @@
using Musify.Domain.ValueObjects;
namespace Musify.Api.DataTransferObjects.Likes;
/// <summary>Toggle a like on a track already in the catalog by <see cref="TrackId"/>, or one from an
/// external source by <see cref="Source"/> + <see cref="ExternalId"/> (provisioning it in the
/// background if needed) — exactly one of the two must be given.</summary>
public record ToggleLikeRequest(Guid? TrackId, TrackSource? Source, string? ExternalId);
@@ -1,6 +1,9 @@
using Musify.Domain.ValueObjects;
namespace Musify.Api.DataTransferObjects.PlayLists; namespace Musify.Api.DataTransferObjects.PlayLists;
public record CreatePlayListRequest( public record CreatePlayListRequest(
string Name, string Name,
string? Description, string? Description,
Guid? PictureIntentId); Guid? PictureIntentId,
PlaylistVisibility Visibility = PlaylistVisibility.Private);
@@ -1,6 +1,9 @@
using Musify.Domain.ValueObjects;
namespace Musify.Api.DataTransferObjects.PlayLists; namespace Musify.Api.DataTransferObjects.PlayLists;
public record UpdatePlayListRequest( public record UpdatePlayListRequest(
string? NewName, string? NewName,
string? NewDescription, string? NewDescription,
Guid? NewPictureIntentId); Guid? NewPictureIntentId,
PlaylistVisibility? NewVisibility = null);
@@ -0,0 +1,61 @@
using Mediator;
using Musify.Application.Likes;
using Musify.Application.Shared;
using Musify.Application.Tracks.Responses;
using Musify.Api.Authentication;
using Musify.Api.DataTransferObjects.Likes;
using Musify.Api.Extensions;
using Musify.Api.Filters;
namespace Musify.Api.Endpoints;
public static class LikeEndpoints
{
public static IEndpointRouteBuilder MapLikeEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/likes")
.WithTags("Likes")
.RequireAuthorization();
group.MapGet("/", GetLikedTracks)
.WithName("GetLikedTracks")
.WithSummary("Get The Current User'S Liked Tracks.")
.Produces<PaginatedResponse<TrackApplicationResponse>>()
.Produces(StatusCodes.Status401Unauthorized);
group.MapPost("/toggle", ToggleTrackLike)
.WithName("ToggleTrackLike")
.WithSummary("Like Or Unlike A Track, By Id Or By External Source, Provisioning It In The Background If Needed.")
.AddEndpointFilter<ValidationFilter<ToggleLikeRequest>>()
.Produces<bool>()
.ProducesValidationProblem()
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound);
return app;
}
private static async Task<IResult> GetLikedTracks(
IMediator mediator,
CurrentUser currentUser,
CancellationToken cancellationToken,
int pageNumber = 1,
int pageSize = 10)
{
var result = await mediator.Send(new GetLikedTracksQuery(currentUser.RequiredId, pageNumber, pageSize), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ToggleTrackLike(
IMediator mediator,
CurrentUser currentUser,
ToggleLikeRequest request,
CancellationToken cancellationToken)
{
var result = await mediator.Send(
new ToggleTrackLikeCommand(currentUser.RequiredId, request.TrackId, request.Source, request.ExternalId),
cancellationToken);
return result.ToHttpResult();
}
}
@@ -120,10 +120,11 @@ public static class PlayListEndpoints
private static async Task<IResult> GetPlayListById( private static async Task<IResult> GetPlayListById(
IMediator mediator, IMediator mediator,
CurrentUser currentUser,
Guid id, Guid id,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var result = await mediator.Send(new GetPlayListByIdQuery(id), cancellationToken); var result = await mediator.Send(new GetPlayListByIdQuery(id, currentUser.Id), cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
@@ -133,9 +134,10 @@ public static class PlayListEndpoints
CancellationToken cancellationToken, CancellationToken cancellationToken,
string? name, string? name,
int pageNumber = 1, int pageNumber = 1,
int pageSize = 10) int pageSize = 10,
bool onlyPublic = false)
{ {
var result = await mediator.Send(new GetPlayListsByUserIdQuery(userId, name, pageNumber, pageSize), cancellationToken); var result = await mediator.Send(new GetPlayListsByUserIdQuery(userId, name, pageNumber, pageSize, onlyPublic), cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
@@ -175,7 +177,7 @@ public static class PlayListEndpoints
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var result = await mediator.Send( var result = await mediator.Send(
new CreatePlayListCommand(currentUser.RequiredId, request.Name, request.Description, request.PictureIntentId), new CreatePlayListCommand(currentUser.RequiredId, request.Name, request.Description, request.PictureIntentId, request.Visibility),
cancellationToken); cancellationToken);
return result.ToCreatedResult(playList => $"/playlists/{playList.Id}"); return result.ToCreatedResult(playList => $"/playlists/{playList.Id}");
@@ -189,7 +191,7 @@ public static class PlayListEndpoints
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var result = await mediator.Send( var result = await mediator.Send(
new UpdatePlayListCommand(currentUser.RequiredId, playlistId, request.NewName, request.NewDescription, request.NewPictureIntentId), new UpdatePlayListCommand(currentUser.RequiredId, playlistId, request.NewName, request.NewDescription, request.NewPictureIntentId, request.NewVisibility),
cancellationToken); cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
@@ -1,4 +1,5 @@
using Mediator; using Mediator;
using Musify.Api.Authentication;
using Musify.Api.DataTransferObjects.Users; using Musify.Api.DataTransferObjects.Users;
using Musify.Api.Extensions; using Musify.Api.Extensions;
using Musify.Api.Filters; using Musify.Api.Filters;
@@ -34,6 +35,40 @@ public static class UserEndpoints
.Produces<IEnumerable<TrackApplicationResponse>>() .Produces<IEnumerable<TrackApplicationResponse>>()
.Produces(StatusCodes.Status404NotFound); .Produces(StatusCodes.Status404NotFound);
group.MapGet("/{id}/listening-stats", GetListeningStats)
.WithName("GetListeningStats")
.WithSummary("Get A User'S Listening Stats For The Current Week And Streak.")
.Produces<ListeningStatsResponse>();
group.MapGet("/{id}/profile", GetUserProfile)
.WithName("GetUserProfile")
.WithSummary("Get A User'S Public Profile, Including Follower Counts.")
.Produces<UserProfileResponse>()
.Produces(StatusCodes.Status404NotFound);
group.MapGet("/{id}/is-following", IsFollowing)
.WithName("IsFollowingUser")
.WithSummary("Check Whether The Current User Follows Another User.")
.RequireAuthorization()
.Produces<bool>()
.Produces(StatusCodes.Status401Unauthorized);
group.MapPost("/{id}/follow", FollowUser)
.WithName("FollowUser")
.WithSummary("Follow A User.")
.RequireAuthorization()
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound)
.ProducesValidationProblem();
group.MapDelete("/{id}/follow", UnfollowUser)
.WithName("UnfollowUser")
.WithSummary("Unfollow A User.")
.RequireAuthorization()
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status401Unauthorized);
group.MapPost("/", CreateUser) group.MapPost("/", CreateUser)
.WithName("CreateUser") .WithName("CreateUser")
.WithSummary("Create A New User.") .WithSummary("Create A New User.")
@@ -73,6 +108,52 @@ public static class UserEndpoints
return await mediator.Send(new GetListeningHistoryQuery(id), cancellationToken); return await mediator.Send(new GetListeningHistoryQuery(id), cancellationToken);
} }
private static async Task<ListeningStatsResponse> GetListeningStats(
IMediator mediator,
long id,
CancellationToken cancellationToken)
{
return await mediator.Send(new GetListeningStatsQuery(id), cancellationToken);
}
private static async Task<IResult> GetUserProfile(
IMediator mediator,
long id,
CancellationToken cancellationToken)
{
var result = await mediator.Send(new GetUserProfileQuery(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<bool> IsFollowing(
IMediator mediator,
CurrentUser currentUser,
long id,
CancellationToken cancellationToken)
{
return await mediator.Send(new IsFollowingUserQuery(currentUser.RequiredId, id), cancellationToken);
}
private static async Task<IResult> FollowUser(
IMediator mediator,
CurrentUser currentUser,
long id,
CancellationToken cancellationToken)
{
var result = await mediator.Send(new FollowUserCommand(currentUser.RequiredId, id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> UnfollowUser(
IMediator mediator,
CurrentUser currentUser,
long id,
CancellationToken cancellationToken)
{
var result = await mediator.Send(new UnfollowUserCommand(currentUser.RequiredId, id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> CreateUser( private static async Task<IResult> CreateUser(
IMediator mediator, IMediator mediator,
CreateUserRequest request, CreateUserRequest request,
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.OpenApi;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Microsoft.OpenApi; using Microsoft.OpenApi;
using Musify.Api.Authentication; using Musify.Api.Authentication;
using Musify.Application.Serialization;
namespace Musify.Api.OpenApi; namespace Musify.Api.OpenApi;
@@ -38,6 +39,24 @@ public sealed class OpenApiOptionsSetup(IOptions<AuthenticationConfiguration> op
return Task.CompletedTask; return Task.CompletedTask;
} ); } );
// Properties annotated with [JsonConverter(typeof(LongAsStringConverter))] (id-shaped
// `long` fields — see that converter for why) serialize as a JSON string, but the
// schema generator still reflects the declared CLR `long`/`long?` type as a bare
// int64 number. Reconcile the declared schema so generated clients (openapi-typescript)
// type exactly those properties as `string`, matching what actually goes over the wire —
// scoped to the attribute, not the CLR type, so unrelated `long` fields (byte sizes, …)
// are left alone.
options.AddSchemaTransformer((schema, context, _) =>
{
if (context.JsonPropertyInfo?.CustomConverter is LongAsStringConverter or NullableLongAsStringConverter)
{
schema.Type = JsonSchemaType.String;
schema.Format = null;
}
return Task.CompletedTask;
} );
options.AddOperationTransformer((operation, context, _) => options.AddOperationTransformer((operation, context, _) =>
{ {
var requiresAuthorization = context.Description.ActionDescriptor.EndpointMetadata var requiresAuthorization = context.Description.ActionDescriptor.EndpointMetadata
+1
View File
@@ -40,6 +40,7 @@ app.UseAuthorization();
app app
.MapAlbumEndpoints() .MapAlbumEndpoints()
.MapConfigEndpoints() .MapConfigEndpoints()
.MapLikeEndpoints()
.MapMixEndpoints() .MapMixEndpoints()
.MapPlayListEndpoints() .MapPlayListEndpoints()
.MapTrackEndpoints() .MapTrackEndpoints()
@@ -0,0 +1,27 @@
using FluentValidation;
using Musify.Api.DataTransferObjects.Likes;
using Musify.Domain.ValueObjects;
namespace Musify.Api.Validators.Likes;
public sealed class ToggleLikeRequestValidator : AbstractValidator<ToggleLikeRequest>
{
public ToggleLikeRequestValidator()
{
RuleFor(x => x)
.Must(x => x.TrackId.HasValue ^ (x.Source.HasValue && !string.IsNullOrEmpty(x.ExternalId)))
.WithMessage("Provide either trackId, or source and externalId, but not both.");
When(x => x.Source.HasValue, () =>
{
RuleFor(x => x.Source)
.Must(source => source == TrackSource.YouTube)
.WithMessage("Unsupported source.");
RuleFor(x => x.ExternalId)
.NotEmpty()
.Matches("^[A-Za-z0-9_-]{11}$")
.WithMessage("externalId must be a valid YouTube video id.");
});
}
}
@@ -0,0 +1,55 @@
using Musify.Application.Likes;
using Musify.Application.Tests.TestSupport;
using Musify.Domain.Entities;
using Xunit;
namespace Musify.Application.Tests.Likes
{
public sealed class GetLikedTracksQueryHandlerTests : HandlerTestBase
{
private GetLikedTracksQueryHandler CreateHandler() => new(Database);
[Fact]
public async Task Handle_MultipleLikes_ReturnsMostRecentFirst()
{
var owner = TestEntities.User();
var older = TestEntities.LocalTrack(owner, "Older like");
var newer = TestEntities.LocalTrack(owner, "Newer like");
await SeedAsync(
owner, older, newer,
new TrackLike { UserId = owner.Id, TrackId = older.Id, CreatedAt = DateTime.UtcNow.AddMinutes(-10) },
new TrackLike { UserId = owner.Id, TrackId = newer.Id, CreatedAt = DateTime.UtcNow });
var result = await CreateHandler().Handle(new GetLikedTracksQuery(owner.Id, 1, 10), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Equal(["Newer like", "Older like"], result.Value.Items.Select(track => track.Title));
}
[Fact]
public async Task Handle_NoLikes_ReturnsEmpty()
{
var owner = TestEntities.User();
await SeedAsync(owner);
var result = await CreateHandler().Handle(new GetLikedTracksQuery(owner.Id, 1, 10), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Empty(result.Value.Items);
}
[Fact]
public async Task Handle_OtherUsersLikes_AreExcluded()
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
var track = TestEntities.LocalTrack(owner);
await SeedAsync(owner, stranger, track, new TrackLike { UserId = stranger.Id, TrackId = track.Id });
var result = await CreateHandler().Handle(new GetLikedTracksQuery(owner.Id, 1, 10), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Empty(result.Value.Items);
}
}
}
@@ -0,0 +1,103 @@
using ErrorOr;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.Likes;
using Musify.Application.Services;
using Musify.Application.Tests.TestSupport;
using Musify.Domain.ValueObjects;
using NSubstitute;
using Xunit;
namespace Musify.Application.Tests.Likes
{
public sealed class ToggleTrackLikeCommandHandlerTests : HandlerTestBase
{
private readonly IYouTubeMusicService youTubeMusicService = Substitute.For<IYouTubeMusicService>();
private ToggleTrackLikeCommandHandler CreateHandler()
{
var provisioner = new YouTubeTrackProvisioner(
Database, youTubeMusicService, TestConfigurations.Track(), NoOpLogger<YouTubeTrackProvisioner>());
return new ToggleTrackLikeCommandHandler(Database, provisioner, NoOpLogger<ToggleTrackLikeCommandHandler>());
}
private static YouTubeSongResult BuildSong(string videoId) => new(
videoId, "Song Title", "Song Artist", "Song Album", 200, "https://img.example/thumb.jpg", false,
[new YouTubeArtistRef("artist-id", "Song Artist")]);
[Fact]
public async Task Handle_LocalTrackNotLiked_LikesIt()
{
var owner = TestEntities.User();
var track = TestEntities.LocalTrack(owner);
await SeedAsync(owner, track);
var result = await CreateHandler().Handle(
new ToggleTrackLikeCommand(owner.Id, track.Id, null, null), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.True(result.Value);
Assert.Single(await Database.TrackLikes.ToListAsync(TestContext.Current.CancellationToken));
}
[Fact]
public async Task Handle_LocalTrackAlreadyLiked_UnlikesIt()
{
var owner = TestEntities.User();
var track = TestEntities.LocalTrack(owner);
await SeedAsync(owner, track, new Musify.Domain.Entities.TrackLike { UserId = owner.Id, TrackId = track.Id });
var result = await CreateHandler().Handle(
new ToggleTrackLikeCommand(owner.Id, track.Id, null, null), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.False(result.Value);
Assert.Empty(await Database.TrackLikes.ToListAsync(TestContext.Current.CancellationToken));
}
[Fact]
public async Task Handle_TrackMissing_ReturnsNotFound()
{
var owner = TestEntities.User();
await SeedAsync(owner);
var result = await CreateHandler().Handle(
new ToggleTrackLikeCommand(owner.Id, Guid.NewGuid(), null, null), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
[Fact]
public async Task Handle_UnknownExternalTrack_ProvisionsAndLikesIt()
{
youTubeMusicService.GetSongAsync("new-video", Arg.Any<CancellationToken>()).Returns(BuildSong("new-video"));
var owner = TestEntities.User();
await SeedAsync(owner);
var result = await CreateHandler().Handle(
new ToggleTrackLikeCommand(owner.Id, null, TrackSource.YouTube, "new-video"), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.True(result.Value);
var track = await Database.ExternalTracks.SingleAsync(t => t.ExternalId == "new-video", TestContext.Current.CancellationToken);
Assert.True(await Database.TrackLikes.AnyAsync(like => like.TrackId == track.Id, TestContext.Current.CancellationToken));
}
[Fact]
public async Task Handle_ExistingExternalTrack_LikesItWithoutReprovisioning()
{
var owner = TestEntities.User();
var track = TestEntities.ExternalTrack(externalId: "abc123");
await SeedAsync(owner, track);
var result = await CreateHandler().Handle(
new ToggleTrackLikeCommand(owner.Id, null, TrackSource.YouTube, "abc123"), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.True(result.Value);
await youTubeMusicService.DidNotReceive().GetSongAsync(Arg.Any<string>(), Arg.Any<CancellationToken>());
}
}
}
@@ -60,6 +60,34 @@ namespace Musify.Application.Tests.PlayLists
await eventBus.Received(1).PublishAsync(Arg.Any<Musify.Application.Events.CreatePlayListResourcesEvent>(), Arg.Any<CancellationToken>()); await eventBus.Received(1).PublishAsync(Arg.Any<Musify.Application.Events.CreatePlayListResourcesEvent>(), Arg.Any<CancellationToken>());
} }
[Fact]
public async Task Handle_NoVisibilitySpecified_DefaultsToPrivate()
{
var user = TestEntities.User();
await SeedAsync(user);
var command = new CreatePlayListCommand(user.Id, "My Playlist", null, PictureIntentId: null);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Equal(Musify.Domain.ValueObjects.PlaylistVisibility.Private, result.Value.Visibility);
}
[Fact]
public async Task Handle_PublicVisibility_PersistsIt()
{
var user = TestEntities.User();
await SeedAsync(user);
var command = new CreatePlayListCommand(user.Id, "My Playlist", null, PictureIntentId: null, Visibility: Musify.Domain.ValueObjects.PlaylistVisibility.Public);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Equal(Musify.Domain.ValueObjects.PlaylistVisibility.Public, result.Value.Visibility);
}
[Fact] [Fact]
public async Task Handle_UserMissing_ReturnsNotFound() public async Task Handle_UserMissing_ReturnsNotFound()
{ {
@@ -14,7 +14,7 @@ namespace Musify.Application.Tests.PlayLists
public async Task Handle_ExistingPlayList_ReturnsItWithCoverTrackIds() public async Task Handle_ExistingPlayList_ReturnsItWithCoverTrackIds()
{ {
var owner = TestEntities.User(); var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id); var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlaylistVisibility.Public);
var track = TestEntities.LocalTrack(owner); var track = TestEntities.LocalTrack(owner);
await SeedAsync(owner, playList, track, new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 0 }); await SeedAsync(owner, playList, track, new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 0 });
@@ -32,5 +32,54 @@ namespace Musify.Application.Tests.PlayLists
Assert.Equal(ErrorType.NotFound, result.FirstError.Type); Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
} }
[Fact]
public async Task Handle_PrivatePlayList_RequestedByOwner_ReturnsIt()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlaylistVisibility.Private);
await SeedAsync(owner, playList);
var result = await CreateHandler().Handle(new GetPlayListByIdQuery(playList.Id, owner.Id), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
}
[Fact]
public async Task Handle_PrivatePlayList_RequestedByStranger_ReturnsNotFound()
{
var owner = TestEntities.User(1, "owner");
var stranger = TestEntities.User(2, "stranger");
var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlaylistVisibility.Private);
await SeedAsync(owner, stranger, playList);
var result = await CreateHandler().Handle(new GetPlayListByIdQuery(playList.Id, stranger.Id), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
[Fact]
public async Task Handle_PrivatePlayList_RequestedAnonymously_ReturnsNotFound()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlaylistVisibility.Private);
await SeedAsync(owner, playList);
var result = await CreateHandler().Handle(new GetPlayListByIdQuery(playList.Id, RequestingUserId: null), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
[Fact]
public async Task Handle_PublicPlayList_RequestedAnonymously_ReturnsIt()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlaylistVisibility.Public);
await SeedAsync(owner, playList);
var result = await CreateHandler().Handle(new GetPlayListByIdQuery(playList.Id, RequestingUserId: null), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
}
} }
} }
@@ -27,6 +27,22 @@ namespace Musify.Application.Tests.PlayLists
Assert.Equal("Mine", playList.Name); Assert.Equal("Mine", playList.Name);
} }
[Fact]
public async Task Handle_OnlyPublic_ReturnsOnlyPublicPlayLists()
{
var owner = TestEntities.User();
await SeedAsync(
owner,
TestEntities.PlayList(owner.Id, "Public one", visibility: PlaylistVisibility.Public),
TestEntities.PlayList(owner.Id, "Private one", visibility: PlaylistVisibility.Private));
var result = await CreateHandler().Handle(new GetPlayListsByUserIdQuery(owner.Id, Name: null, PageNumber: 1, PageSize: 10, OnlyPublic: true), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
var playList = Assert.Single(result.Value.Items);
Assert.Equal("Public one", playList.Name);
}
[Fact] [Fact]
public async Task Handle_NameFilter_ReturnsOnlyMatchingPlayLists() public async Task Handle_NameFilter_ReturnsOnlyMatchingPlayLists()
{ {
@@ -62,6 +62,36 @@ namespace Musify.Application.Tests.PlayLists
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type); Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
} }
[Fact]
public async Task Handle_NewVisibility_UpdatesIt()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id);
await SeedAsync(owner, playList);
var command = new UpdatePlayListCommand(owner.Id, playList.Id, null, null, null, Musify.Domain.ValueObjects.PlaylistVisibility.Public);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Equal(Musify.Domain.ValueObjects.PlaylistVisibility.Public, result.Value.Visibility);
}
[Fact]
public async Task Handle_NoVisibilitySpecified_LeavesItUnchanged()
{
var owner = TestEntities.User();
var playList = TestEntities.PlayList(owner.Id, visibility: Musify.Domain.ValueObjects.PlaylistVisibility.Public);
await SeedAsync(owner, playList);
var command = new UpdatePlayListCommand(owner.Id, playList.Id, "New Name", null, null);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Equal(Musify.Domain.ValueObjects.PlaylistVisibility.Public, result.Value.Visibility);
}
[Fact] [Fact]
public async Task Handle_BlankNameAndDescription_LeavesThemUnchanged() public async Task Handle_BlankNameAndDescription_LeavesThemUnchanged()
{ {
@@ -126,6 +126,26 @@ namespace Musify.Application.Tests.TestSupport
modelBuilder.Entity<MixItem>() modelBuilder.Entity<MixItem>()
.HasIndex(item => new { item.MixId, item.Position }); .HasIndex(item => new { item.MixId, item.Position });
modelBuilder.Entity<TrackLike>()
.HasIndex(like => new { like.UserId, like.TrackId })
.IsUnique();
modelBuilder.Entity<UserFollow>()
.HasOne(follow => follow.Follower)
.WithMany()
.HasForeignKey(follow => follow.FollowerId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<UserFollow>()
.HasOne(follow => follow.Followed)
.WithMany()
.HasForeignKey(follow => follow.FollowedId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<UserFollow>()
.HasIndex(follow => new { follow.FollowerId, follow.FollowedId })
.IsUnique();
} }
public DbSet<User> Users => Set<User>(); public DbSet<User> Users => Set<User>();
@@ -164,6 +184,10 @@ namespace Musify.Application.Tests.TestSupport
public DbSet<ListeningHistory> ListeningHistories => Set<ListeningHistory>(); public DbSet<ListeningHistory> ListeningHistories => Set<ListeningHistory>();
public DbSet<TrackLike> TrackLikes => Set<TrackLike>();
public DbSet<UserFollow> UserFollows => Set<UserFollow>();
public async Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken) public async Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken)
{ {
var transaction = await Database.BeginTransactionAsync(isolationLevel, cancellationToken); var transaction = await Database.BeginTransactionAsync(isolationLevel, cancellationToken);
@@ -111,7 +111,8 @@ namespace Musify.Application.Tests.TestSupport
string name = "Test Playlist", string name = "Test Playlist",
string? description = null, string? description = null,
PlayListPictures? pictures = null, PlayListPictures? pictures = null,
LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active) => LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active,
PlaylistVisibility visibility = PlaylistVisibility.Private) =>
new() new()
{ {
UserId = userId, UserId = userId,
@@ -119,7 +120,8 @@ namespace Musify.Application.Tests.TestSupport
NormalizedName = name.ToUpperInvariant(), NormalizedName = name.ToUpperInvariant(),
Description = description, Description = description,
Pictures = pictures ?? PlayListPictures(), Pictures = pictures ?? PlayListPictures(),
LifeCycleStatus = lifeCycleStatus LifeCycleStatus = lifeCycleStatus,
Visibility = visibility
}; };
public static AlbumPictures AlbumPictures() => new() public static AlbumPictures AlbumPictures() => new()
@@ -0,0 +1,61 @@
using ErrorOr;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Tests.TestSupport;
using Musify.Application.Users;
using Xunit;
namespace Musify.Application.Tests.Users
{
public sealed class FollowUserCommandHandlerTests : HandlerTestBase
{
private FollowUserCommandHandler CreateHandler() => new(Database, NoOpLogger<FollowUserCommandHandler>());
[Fact]
public async Task Handle_ValidTarget_CreatesFollow()
{
var follower = TestEntities.User(1, "follower");
var followed = TestEntities.User(2, "followed");
await SeedAsync(follower, followed);
var result = await CreateHandler().Handle(new FollowUserCommand(follower.Id, followed.Id), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Single(await Database.UserFollows.ToListAsync(TestContext.Current.CancellationToken));
}
[Fact]
public async Task Handle_AlreadyFollowing_IsIdempotent()
{
var follower = TestEntities.User(1, "follower");
var followed = TestEntities.User(2, "followed");
await SeedAsync(follower, followed, new Musify.Domain.Entities.UserFollow { FollowerId = follower.Id, FollowedId = followed.Id });
var result = await CreateHandler().Handle(new FollowUserCommand(follower.Id, followed.Id), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Single(await Database.UserFollows.ToListAsync(TestContext.Current.CancellationToken));
}
[Fact]
public async Task Handle_SelfFollow_ReturnsValidationError()
{
var user = TestEntities.User();
await SeedAsync(user);
var result = await CreateHandler().Handle(new FollowUserCommand(user.Id, user.Id), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
}
[Fact]
public async Task Handle_TargetMissing_ReturnsNotFound()
{
var follower = TestEntities.User();
await SeedAsync(follower);
var result = await CreateHandler().Handle(new FollowUserCommand(follower.Id, 404), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
}
}
@@ -0,0 +1,87 @@
using Musify.Application.Tests.TestSupport;
using Musify.Application.Users;
using Xunit;
namespace Musify.Application.Tests.Users
{
public sealed class GetListeningStatsQueryHandlerTests : HandlerTestBase
{
private GetListeningStatsQueryHandler CreateHandler() => new(Database);
[Fact]
public async Task Handle_NoHistory_ReturnsZeroes()
{
var user = TestEntities.User();
await SeedAsync(user);
var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken);
Assert.Equal(0, result.TracksThisWeek);
Assert.Equal(0, result.SecondsThisWeek);
Assert.Equal(0, result.StreakDays);
}
[Fact]
public async Task Handle_ListensThisWeek_CountsDistinctTracksAndTotalSeconds()
{
var user = TestEntities.User();
var trackA = TestEntities.LocalTrack(user, "Track A", durationSeconds: 200);
var trackB = TestEntities.LocalTrack(user, "Track B", durationSeconds: 100);
await SeedAsync(
user, trackA, trackB,
TestEntities.ListeningHistory(user.Id, trackA.Id, DateTime.UtcNow.AddDays(-1)),
TestEntities.ListeningHistory(user.Id, trackA.Id, DateTime.UtcNow.AddHours(-1)),
TestEntities.ListeningHistory(user.Id, trackB.Id, DateTime.UtcNow),
TestEntities.ListeningHistory(user.Id, trackB.Id, DateTime.UtcNow.AddDays(-10)));
var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken);
Assert.Equal(2, result.TracksThisWeek);
Assert.Equal(500, result.SecondsThisWeek);
}
[Fact]
public async Task Handle_ListenedTodayAndYesterday_StreakIsTwo()
{
var user = TestEntities.User();
var track = TestEntities.LocalTrack(user);
await SeedAsync(
user, track,
TestEntities.ListeningHistory(user.Id, track.Id, DateTime.UtcNow),
TestEntities.ListeningHistory(user.Id, track.Id, DateTime.UtcNow.AddDays(-1)));
var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken);
Assert.Equal(2, result.StreakDays);
}
[Fact]
public async Task Handle_GapBeforeToday_StopsStreakAtGap()
{
var user = TestEntities.User();
var track = TestEntities.LocalTrack(user);
await SeedAsync(
user, track,
TestEntities.ListeningHistory(user.Id, track.Id, DateTime.UtcNow),
TestEntities.ListeningHistory(user.Id, track.Id, DateTime.UtcNow.AddDays(-2)));
var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken);
Assert.Equal(1, result.StreakDays);
}
[Fact]
public async Task Handle_NoListenToday_StreakIsZero()
{
var user = TestEntities.User();
var track = TestEntities.LocalTrack(user);
await SeedAsync(
user, track,
TestEntities.ListeningHistory(user.Id, track.Id, DateTime.UtcNow.AddDays(-1)));
var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken);
Assert.Equal(0, result.StreakDays);
}
}
}
@@ -0,0 +1,41 @@
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 GetUserProfileQueryHandlerTests : HandlerTestBase
{
private GetUserProfileQueryHandler CreateHandler() => new(Database);
[Fact]
public async Task Handle_ExistingUser_ReturnsFollowerAndFollowingCounts()
{
var user = TestEntities.User(1, "user");
var followerA = TestEntities.User(2, "followerA");
var followerB = TestEntities.User(3, "followerB");
var followed = TestEntities.User(4, "followed");
await SeedAsync(
user, followerA, followerB, followed,
new UserFollow { FollowerId = followerA.Id, FollowedId = user.Id },
new UserFollow { FollowerId = followerB.Id, FollowedId = user.Id },
new UserFollow { FollowerId = user.Id, FollowedId = followed.Id });
var result = await CreateHandler().Handle(new GetUserProfileQuery(user.Id), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Equal(2, result.Value.FollowersCount);
Assert.Equal(1, result.Value.FollowingCount);
}
[Fact]
public async Task Handle_UserMissing_ReturnsNotFound()
{
var result = await CreateHandler().Handle(new GetUserProfileQuery(404), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
}
}
@@ -0,0 +1,36 @@
using Musify.Application.Tests.TestSupport;
using Musify.Application.Users;
using Musify.Domain.Entities;
using Xunit;
namespace Musify.Application.Tests.Users
{
public sealed class IsFollowingUserQueryHandlerTests : HandlerTestBase
{
private IsFollowingUserQueryHandler CreateHandler() => new(Database);
[Fact]
public async Task Handle_Following_ReturnsTrue()
{
var follower = TestEntities.User(1, "follower");
var followed = TestEntities.User(2, "followed");
await SeedAsync(follower, followed, new UserFollow { FollowerId = follower.Id, FollowedId = followed.Id });
var result = await CreateHandler().Handle(new IsFollowingUserQuery(follower.Id, followed.Id), TestContext.Current.CancellationToken);
Assert.True(result);
}
[Fact]
public async Task Handle_NotFollowing_ReturnsFalse()
{
var follower = TestEntities.User(1, "follower");
var followed = TestEntities.User(2, "followed");
await SeedAsync(follower, followed);
var result = await CreateHandler().Handle(new IsFollowingUserQuery(follower.Id, followed.Id), TestContext.Current.CancellationToken);
Assert.False(result);
}
}
}
@@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore;
using Musify.Application.Tests.TestSupport;
using Musify.Application.Users;
using Musify.Domain.Entities;
using Xunit;
namespace Musify.Application.Tests.Users
{
public sealed class UnfollowUserCommandHandlerTests : HandlerTestBase
{
private UnfollowUserCommandHandler CreateHandler() => new(Database, NoOpLogger<UnfollowUserCommandHandler>());
[Fact]
public async Task Handle_ExistingFollow_RemovesIt()
{
var follower = TestEntities.User(1, "follower");
var followed = TestEntities.User(2, "followed");
await SeedAsync(follower, followed, new UserFollow { FollowerId = follower.Id, FollowedId = followed.Id });
var result = await CreateHandler().Handle(new UnfollowUserCommand(follower.Id, followed.Id), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Empty(await Database.UserFollows.ToListAsync(TestContext.Current.CancellationToken));
}
[Fact]
public async Task Handle_NotFollowing_IsIdempotent()
{
var follower = TestEntities.User(1, "follower");
var followed = TestEntities.User(2, "followed");
await SeedAsync(follower, followed);
var result = await CreateHandler().Handle(new UnfollowUserCommand(follower.Id, followed.Id), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,325 @@
# Handoff: Musify — Web Music Player
## Overview
Musify es un reproductor de música web: un player permanente, navegación lateral, y vistas de Home / Descubrir / Playlists / Me gusta / Reciente, más estados de carga, error, vacío y sin resultados. Su rasgo de identidad es que **toda la paleta de acento y los fondos derivan de la canción en reproducción**: al cambiar de pista, el tinte del hero, el glow ambiental, el sidebar, la barra del player, el logo, la barra de progreso y los acentos viran en bloque con una transición de 0.6s.
## About the Design Files
Los archivos de este paquete son **referencias de diseño hechas en HTML** — prototipos que muestran aspecto y comportamiento previstos, **no código de producción para copiar tal cual**. La tarea es **recrear estos diseños en el entorno del codebase destino** (React, Vue, SwiftUI, nativo…) siguiendo sus patrones y librerías ya establecidos. Si todavía no hay entorno, elige el framework más apropiado para el proyecto e implementa allí.
El prototipo está escrito como un componente único con estado local y estilos inline. En producción conviene separarlo en componentes (Sidebar, TopBar, Player, Queue, TrackRow, MediaCard, sections de Home) y mover los tokens a la solución de estilos del proyecto.
## Fidelity
**Alta fidelidad (hifi).** Colores, tipografía, espaciado, radios, estados y microinteracciones son definitivos. Se espera una recreación fiel usando las librerías del codebase. Las carátulas son fotografías genéricas de marcador de posición; el contenido musical (artistas, álbumes, canciones, playlists, duraciones, reproducciones) es **mock data** y debe sustituirse por datos reales.
**Regla de producto:** la UI nunca menciona la procedencia de la música. Sin etiquetas de fuente, proveedor ni "powered by". Para el usuario esto es solo Musify.
---
## Layout global
Altura fija de viewport, sin scroll de página; solo el área de contenido y la cola scrollean.
```
┌──────────┬──────────────────────────────┬──────────┐
│ Sidebar │ TopBar │ Queue │
│ 254px ├──────────────────────────────┤ 304px │
│ │ Main (scroll) │ (258px │
│ │ │ <1180) │
└──────────┴──────────────────────────────┴──────────┘
Player flotante (absolute, left/right 0, bottom 0, padding 0 16px 16px)
```
- Raíz: `position:relative; height:100vh; overflow:hidden; background: pageBg; transition: background .6s ease`.
- Dos capas decorativas absolutas y no interactivas sobre el fondo: el **ambient glow** y un scrim inferior `radial-gradient(130% 85% at 50% 118%, rgba(0,0,0,.8), transparent 62%)`.
- Fila principal `display:flex; height:100%`.
- El main tiene `padding: 6px 26px` y `padding-bottom` dinámico: `126px` en desktop, `206px` cuando hay barra de pestañas móvil.
### Breakpoints
| Ancho | Comportamiento |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `>= 1180px` | Sidebar visible (254px), cola 304px, player completo |
| `< 1180px` (tablet) | Sidebar oculto → barra de pestañas bajo el player; cola 258px; padding inferior 206px |
| `< 760px` (móvil) | Cola oculta; se ocultan shuffle/repeat/volumen/letra/cola del player (queda carátula + título + transporte + progreso); el bloque de info del player pasa a `width:auto` |
---
## Sistema de color dinámico
Cada pista lleva un `hue` (0–360) asignado. Todo el color de la app se deriva de ese hue en oklch. Los valores están repartidos por la rueda para que el cambio entre pistas consecutivas sea evidente (paso mínimo ~50°).
```js
// hue = hue de la pista en reproducción
accent = oklch(0.74 0.145 H) // acción, barra de progreso, corazón activo, EQ
accentTitle = oklch(0.87 0.07 H) // títulos sobre superficie tintada
accentMuted = oklch(0.79 0.035 H) // título de la pista activa en tarjetas
accentSoft = oklch(0.74 0.145 H / .08) // fondo de fila/tarjeta activa
accentLine = oklch(0.74 0.145 H / .2) // borde de superficie acentuada
accentHair = oklch(0.74 0.145 H / .09) // borde de tarjeta activa
accentBtnBg = oklch(0.74 0.145 H / .15)
accentBtnBgHover = oklch(0.74 0.145 H / .24)
// superficies
pageBg = oklch(0.068 0.008 H)
sidebarBg = linear-gradient(180deg, oklch(0.095 0.012 H), oklch(0.068 0.007 H))
panelBg = oklch(0.09 0.010 H / .28) // panel de cola
barBg = oklch(0.105 0.012 H / .9) // player y tabs (con backdrop-filter: blur(22px))
hairline = oklch(0.62 0.030 H / .08) // todos los separadores
// gradientes: tc(a, l) = oklch(0.34 - l/380 , 0.11 - l/1600 , H / a)
ambient = radial-gradient(52% 38% at 14% 0%, tc(.26,30), transparent 54%),
radial-gradient(46% 34% at 78% 0%, tc(.20,40), transparent 56%)
heroBg = linear-gradient(105deg, tc(.34,42) 0%, tc(.11,70) 28%, tc(.03,88) 50%, rgba(6,6,9,0) 70%)
spotlightBg = linear-gradient(100deg, tc(.28,46) 0%, tc(.10,74) 30%, tc(.03,88) 52%, rgba(6,6,9,0) 72%)
logoGrad = linear-gradient(140deg, oklch(0.78 0.15 H), oklch(0.62 0.13 H+40) 92%)
logoGlow = 0 0 20px oklch(0.74 0.145 H / .3)
```
**Importante:** hero y spotlight terminan en `alpha 0` antes del borde derecho (70–72%) y **no llevan borde**, para fundirse con el fondo sin corte visible.
Los elementos que cambian de color reciben `transition: background .6s ease` (y `box-shadow .6s ease` en el logo): raíz, sidebar, panel de cola, barra del player, tabs.
### Grises fijos (no dependen del hue)
| Rol | Hex |
| ------------------------------------------- | ------------------------------------------------------------------------------------ |
| Texto principal (títulos de sección, h1/h2) | `#d3d2d6` – `#d9d8dc` |
| Texto de tarjeta / fila | `#cbcacf` / `#cdccd1` |
| Texto base del documento | `#c9c8cc` |
| Secundario | `#8a8a8e`, `#8f8f93`, `#919195` |
| Terciario | `#7f7f83`, `#7d7d81`, `#848488` |
| Cuaternario / contadores | `#6c6c70`, `#696a6d`, `#646468`, `#616165`, `#5c5c60` |
| Iconos inactivos player | `#747478` |
| Botón primario (fondo) | `#c6c4cb`, hover `#d8d7dc`; texto `#0a0a0d` |
| Superficies neutras | `rgba(255,255,255,.022)` base, `.045` hover; bordes `rgba(255,255,255,.04)` / `.075` |
Los grises son **neutros a propósito** (sin tinte), para que solo el acento lleve color.
---
## Tipografía
- Display / títulos: **Sora** 400/500/600/700.
- UI / texto: **Plus Jakarta Sans** 400/500/600/700.
- `-webkit-font-smoothing: antialiased`.
| Uso | Fuente | Tamaño | Peso | Tracking |
| ------------------------------------------ | ------- | ---------------- | ---- | ------------------------------------ |
| H1 hero | Sora | 36px / 1.1 | 600 | -0.035em |
| H1 vista (Descubrir, Playlists, Tracklist) | Sora | 28–33px | 600 | -0.03 / -0.035em |
| H2 sección | Sora | 17.5px | 600 | -0.025em |
| Título spotlight | Sora | 23px | 600 | -0.03em |
| Wordmark | Sora | 17.5px | 600 | -0.02em |
| Subtítulo de sección | Jakarta | 12px | 400 | — |
| Eyebrow / cabecera de columna | Jakarta | 10.5px uppercase | 600 | 0.12–0.17em |
| Título de tarjeta | Jakarta | 13–13.5px | 600 | -0.01em |
| Fila de canción (título) | Jakarta | 12.5–13px | 500 | — |
| Meta / artista | Jakarta | 11–11.5px | 400 | — |
| Números (duración, índice, reproducciones) | Jakarta | 11–12px | 400 | `font-variant-numeric: tabular-nums` |
Todo texto de una línea en tarjetas y filas usa `white-space:nowrap; overflow:hidden; text-overflow:ellipsis` con `min-width:0` en el contenedor flex. Los titulares llevan `text-wrap: pretty`.
---
## Escalas
**Radios:** 8px (miniaturas pequeñas, chips) · 9–11px (carátulas 36–48px, botones) · 12–13px (filas activas, carátulas de tarjeta) · 14–16px (tarjetas, portadas grandes) · 18px (player, tabs, portada de cabecera) · 22–24px (hero, spotlight) · 50% (avatares, botón de play, artistas) · 999px (chips de sugerencia).
**Sombras:** carátula de tarjeta `0 14px 32px rgba(0,0,0,.45)` · artista `0 12px 28px rgba(0,0,0,.4)` · portada de cabecera `0 24px 56px rgba(0,0,0,.55)` · player `0 20px 56px rgba(0,0,0,.6)` · botón primario hero `0 10px 28px rgba(0,0,0,.45)`.
**Gaps de grid:** tarjetas 20px · artistas 18px · playlists 14–22px · filas compactas 6–14px.
**Anchos mínimos de columna (auto-fill):** mixes/álbumes/descubrir 164px · artistas 132px · playlists (tarjeta ancha) 258px · playlists (índice) 196px · continuar escuchando 268px · populares 330px · géneros 228px.
---
## Pantallas
### 1. Sidebar (`>= 1180px`)
`width:254px; padding:22px 12px 0 20px; border-right:1px solid hairline; background: sidebarBg`.
- **Wordmark**: cuadrado 24px con `logoGrad` + `logoGlow`, gap 11px, texto "Musify". Padding inferior 26px.
- **Nav** (columna, gap 2px). Ítem: `padding:9px 12px; border-radius:10px; font-size:13.5px; weight:500`, icono SVG 17px stroke 1.6, etiqueta flexible, contador a la derecha (11px, `#5c5c60`, tabular).
- Activo: color `#d5d4d8`, fondo `rgba(255,255,255,.06)`.
- Inactivo: color `#8b8b8f`, fondo transparente. Hover: fondo `rgba(255,255,255,.04)`, color `#d5d4d8`. Transición `.16s`.
- Ítems: Inicio · Descubrir · Playlists (7) · Me gusta (312) · Reciente.
- **Separador**: `margin:24px 12px 14px; height:1px; background:rgba(255,255,255,.05)`.
- **Cabecera "TUS PLAYLISTS"** (10.5px uppercase, 0.13em, `#616165`) con un "+" a la derecha que navega al índice de playlists.
- **Lista de playlists** (scroll, gap 1px). Fila: miniatura 34px radio 8px (imagen `opacity:.88`), nombre 12.5px, meta 11px `#6c6c70`. Si esa playlist está sonando, un ecualizador de 3 barras de 2px a la derecha.
### 2. TopBar
`padding:16px 26px 12px; gap:14px`.
- Atrás / adelante: círculos de 30px, fondo `rgba(255,255,255,.035)`, borde `rgba(255,255,255,.05)`, icono 15px. Atrás activo (`#8b8b8f`, hover `#dbdadf`), adelante deshabilitado (`#555559`).
- Buscador: `flex:1; max-width:480px; height:38px; radius:11px`, fondo `rgba(255,255,255,.035)`, borde `rgba(255,255,255,.055)`, lupa 15px `#737377`, input 13px transparente. Placeholder: "Canciones, artistas, álbumes o playlists".
- Spacer.
- Botón "Cola": alto 33px, radio 10px, 12.5px/500, icono 15px. Activo: color `#d6d5da`, fondo `rgba(255,255,255,.06)`. Inactivo: `#8b8b8f`, fondo `rgba(255,255,255,.028)`.
- Avatar 33px circular con borde `rgba(255,255,255,.1)`.
### 3. Home
Ritmo editorial deliberado — no filas iguales encadenadas:
**a) Hero** — `border-radius:24px; margin-bottom:42px; background: heroBg`, sin borde, `padding:38px 36px 34px; max-width:620px`.
- Saludo H1 dinámico por hora y día (ver _Copy dinámico_).
- Blurb 13.5px `#8a8a8e`, `line-height:1.65`, `max-width:440px`.
- Botones: primario "Reproducir" (`#c6c4cb` sobre `#0a0a0d`, 12px 21px, radio 12px, icono play 14px) + secundario "Guardar" (borde `rgba(255,255,255,.11)`), y un meta a la derecha en 12px.
- Tres métricas: valor Sora 26px/600 y etiqueta 11.5px — "Canciones esta semana", "Tiempo de escucha", "Artistas nuevos".
**b) Continuar escuchando** — grid `auto-fill minmax(268px,1fr)`, gap 10px. Fila-tarjeta: `padding:9px 14px 9px 9px; radius:13px`, carátula 46px radio 9px, título 13px/600, sub 11.5px, y un icono play 15px en `accent` que aparece en hover. La pista en curso usa `accentSoft` de fondo, `accentHair` de borde y `accentMuted` en el título.
**c) Playlist destacada (spotlight)** — grid de dos columnas `auto-fit minmax(290px,1fr)`, `radius:22px`, `background: spotlightBg`, sin borde ni divisor central.
- Izquierda: portada 88px radio 14px, eyebrow "PLAYLIST DESTACADA", nombre Sora 23px, meta 12px, blurb 13px/1.7, botón "Reproducir" (`accentBtnBg` + `accentLine` + `accentTitle`, hover `accentBtnBgHover`) y "Mezclar" secundario.
- Derecha: 5 canciones con índice 20px, título 12.5px, artista 11px, duración tabular; fondo `rgba(0,0,0,.26)` que se desvanece a transparente hacia la derecha.
**d) Mixes** — H2 "Mixes" + sub "Generados a partir de lo que más repites". Grid `minmax(164px,1fr)` gap 20px. Tarjeta cuadrada radio 13px con scrim `linear-gradient(180deg,transparent 45%,rgba(0,0,0,.42))` y botón de play circular 38px abajo a la derecha (`rgba(10,10,13,.76)`, blur 8px, borde `rgba(255,255,255,.12)`) que aparece en hover.
**e) Artistas que sigues** — grid `minmax(132px,1fr)` gap 18px, retratos circulares, texto centrado, nombre 12.5px/600, oyentes 11px.
**f) Populares esta semana** — grid de dos columnas `auto-fit minmax(330px,1fr)`, `gap:6px 34px`. Fila: índice/EQ 18px, carátula 38px, título + artista, reproducciones 11px y duración 11.5px.
**g) Tus playlists** — tarjetas anchas `minmax(258px,1fr)` gap 14px: portada 62px radio 11px, nombre 13.5px/600, nº de canciones 11.5px, artistas principales 11px `#696a6d`, play en hover. Enlace "VER TODAS" en la cabecera.
### 4. Descubrir
H1 28px + sub. Mosaico de géneros `minmax(228px,1fr)` gap 14px: tarjetas de 112px, radio 16px, fondo `linear-gradient(140deg, oklch(0.24 0.07 Hgénero), rgba(9,9,12,.95))`, borde `rgba(255,255,255,.05)`, nombre Sora 15.5px, meta 11.5px, y una miniatura de 80px rotada 20° asomando en la esquina inferior derecha (`opacity:.82`). Debajo, "Fuera de tu burbuja" con la misma tarjeta cuadrada de Mixes.
### 5. Tracklist (Me gusta / Reciente / Playlist / Resultados)
Cabecera: portada 164px radio 18px + eyebrow (`Colección` / `Historial` / `Playlist` / `Búsqueda`), H1 33px, meta 12.5px, botón "Reproducir" y un botón cuadrado 40px de "aleatorio".
Tabla: `grid-template-columns: 32px 1fr 1fr 92px; gap:16px`. Cabecera 10.5px uppercase `#616165` con borde inferior `rgba(255,255,255,.05)`. Fila `padding:10px 14px; radius:12px`:
- Col 1: número o, si es la pista activa, EQ de 3 barras de 2.5px en `accent`.
- Col 2: carátula 40px radio 9px + título 13px/500 + artista 11.5px.
- Col 3: álbum (o `—` si falta).
- Col 4: corazón (14px; visible siempre si está marcado, si no solo en hover) + duración tabular.
- Activa: fondo `accentSoft`, título `accentTitle`. Hover: `rgba(255,255,255,.032)`.
### 6. Índice de playlists
H1 "Tus playlists" + meta agregada, botón "Nueva playlist" secundario, grid `minmax(196px,1fr)` gap 22px de tarjetas cuadradas con nombre, nº de canciones y artistas principales.
### 7. Cola (aside derecho)
`border-left:1px solid hairline; background: panelBg; padding:20px 16px` + padding inferior dinámico.
- Cabecera "En cola" (Sora 14.5px) + cerrar "×".
- "REPRODUCIENDO": tarjeta con fondo `accentSoft`, borde `accentLine`, carátula 40px, título en `accentTitle`.
- Cabecera "A CONTINUACIÓN · {playlist}" con `gap:12px`; la etiqueta trunca con elipsis (`flex:1 1 auto; min-width:0; nowrap`) y "Vaciar" es `flex:0 0 auto`.
- Lista scrollable: miniatura 36px, título 12px, artista 11px, duración.
### 8. Player (siempre visible)
Barra flotante: `radius:18px; padding:11px 16px; background: barBg; backdrop-filter: blur(22px); border:1px solid hairline; box-shadow:0 20px 56px rgba(0,0,0,.6)`; `pointer-events:none` en el contenedor y `auto` en la barra.
- **Izquierda (256px):** carátula 48px radio 11px, título 13px/600, artista · álbum 11.5px, corazón 16px (activo en `accent`).
- **Centro (flex:1):** shuffle · anterior · play/pausa · siguiente · repetir, gap 14px. Play: círculo 40px `#c6c4cb` con icono `#0a0a0d`, hover `#d6d5da` (**sin scale**). Shuffle/repeat activos en `accent`, inactivos `#747478`.
Progreso: tiempo transcurrido (10.5px tabular, ancho mín. 32px) · pista `rgba(255,255,255,.09)` de 3.5px con relleno en `accent` · duración. Clic en la pista = seek proporcional.
- **Derecha (256px):** letra, volumen (icono + pista de 84px con relleno `#8e8c98`), y botón de cola. Oculto por completo en móvil.
### 9. Barra de pestañas (`< 1180px`)
Bajo el player, `margin-top:10px; padding:7px; radius:18px`, mismo `barBg` y blur. Icono 17px + etiqueta 10px, distribuidos con `space-around`. Muestra los 5 ítems de navegación.
---
## Estados
- **Loading (skeleton):** bloque de hero de 260px + 2 secciones de 6 tarjetas. Gradiente `linear-gradient(90deg, rgba(255,255,255,.025), .055, .025)` con `background-size:420px 100%` y `animation: shim 1.25s linear infinite` (`-420px 0` → `420px 0`). Se muestra 900ms al arrancar.
- **Error:** cuadro 52px con "!" (`rgba(226,130,130,.08)` / borde `.18` / texto `#e09292`), título 20px, cuerpo 13px, botones "Reintentar" (primario) y "Ver descargas". Copy: "No se pudo cargar tu música" / "La conexión se interrumpió. Lo que tengas descargado sigue disponible sin conexión." Reintentar vuelve al skeleton 800ms.
- **Vacío:** icono 70px radio 19px con degradado tenue, título 20px, cuerpo 13px, CTA primario + "Volver al inicio". Dos variantes de copy: playlists sin crear y "me gusta" sin canciones.
- **Sin resultados:** título `Sin resultados para "{query}"`, cuerpo de ayuda y 4 chips de sugerencia (radio 999px, borde `rgba(255,255,255,.08)`, 12px).
- **Datos incompletos:** si falta el álbum, la columna muestra `—` y el player omite el `· álbum`; si falta el subtítulo de una tarjeta, "Sin información". Toda carátula tiene un degradado de respaldo detrás de la imagen.
---
## Interacciones
| Elemento | Comportamiento |
| ---------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| Nav / tabs | Cambia de vista y limpia la búsqueda |
| Playlist (sidebar, tarjeta, índice, spotlight) | Abre la vista de playlist con su contexto |
| Tarjeta / fila de canción | Fija la pista, `pos = 0`, `playing = true` |
| Play/pausa | Alterna reproducción (el reloj avanza 1s por segundo) |
| Siguiente / anterior | Cíclicos sobre el array de pistas, resetean la posición |
| Pista de progreso | Clic → `pos = (x / ancho) * duración` |
| Pista de volumen | Clic → `vol` entre 0 y 1 |
| Corazón (fila o player) | Alterna en el mapa `liked` |
| Shuffle / repeat | Alternan, se tiñen de `accent` |
| Botón "Cola" | Abre/cierra el aside |
| Buscar | Filtra por título, artista y álbum; vacío → vista normal; sin coincidencias → estado "sin resultados" |
**Animación — reglas:**
- **Nada de elevación en hover.** Se eliminó a propósito: el feedback es solo un cambio de fondo y la aparición del botón de play.
- Hover de fondo/color: `.15–.18s ease`. Play del player: `.14s` solo de color, sin `scale`.
- Cambio de tinte por canción: `.6s ease`.
- Entrada de vista: `rise` (`opacity 0 → 1`, `translateY(12px) → 0`) en `.3s ease`. Cola: `.22s`.
- EQ de pista activa: `eq` `.9s ease-in-out infinite` alternando `scaleY(.25)` y `scaleY(1)`, con desfases de 0.25/0.5s (0.3/0.6s en el sidebar).
---
## Estado necesario
```
view 'home' | 'explore' | 'playlists' | 'playlist' | 'liked' | 'recent'
listCtx nombre de la playlist abierta
idx índice de la pista en reproducción
playing boolean
pos segundos transcurridos (intervalo de 1s, vuelve a 0 al llegar a la duración)
vol 0..1
queueOpen boolean (forzado a false por debajo de 760px)
query texto de búsqueda
liked { [trackId]: true }
shuffle / repeat boolean
hover clave del elemento con hover (o null)
w ancho de ventana (listener de resize)
demoState 'normal' | 'loading' | 'error' | 'empty' (solo para demostrar estados)
```
### Datos esperados
`Track { id, title, artist, album?, duration, cover?, hue, plays? }` ·
`Album { title, artist, year, cover? }` ·
`Artist { name, listeners, photo? }` ·
`Playlist { name, trackCount, topArtists, cover?, isPlaying? }`
Todos los campos opcionales deben tener degradado o texto de respaldo. El `hue` debería calcularse en backend desde la carátula (color dominante, con saturación mínima garantizada) y venir en el payload; en el prototipo está precalculado por pista.
---
## Copy dinámico
Saludo del hero según hora y día de la semana:
- `< 5h` → "¿Sesión de madrugada, {nombre}?"
- `< 13h` → "Buenos días, {nombre}"
- `< 20h` → "Buenas tardes, {nombre}" (miércoles → "Feliz miércoles"; viernes → "Feliz viernes")
- `>= 20h` → "Buenas noches, {nombre}"
- Sábado/domingo → "Que tengas buen finde, {nombre}" (o "¿Madrugada de finde, {nombre}?" antes de las 5h)
---
## Assets
- **Carátulas y retratos:** marcadores de posición fotográficos servidos por `picsum.photos` con una semilla por elemento, pintados como `background-image` (no `<img>`) para evitar peticiones fallidas mientras se resuelven los datos. Sustituir por las imágenes reales del catálogo.
- **Iconos:** SVG inline, `stroke-width` 1.6–1.8, `stroke-linecap:round`. Reemplazables por el icon set del codebase.
- **Fuentes:** Sora y Plus Jakarta Sans (Google Fonts).
- Sin logotipos de terceros. El wordmark de Musify es tipográfico + el cuadrado con degradado.
## Files
- `Musify.dc.html` — prototipo completo (plantilla + lógica + datos de ejemplo). Contiene todas las vistas y estados; el selector `demoState` permite ver loading / error / empty.
- `support.js` — runtime del prototipo. **No portar**: solo hace funcionar el HTML de referencia.
- `screenshots/` — capturas de referencia (viewport ~924px, es decir en modo tablet: sidebar plegado a barra de pestañas inferior):
- `01-home-hero.png` … `04-home-populares-playlists.png` — recorrido vertical de Home
- `05-descubrir.png`, `06-playlists.png`, `07-tracklist-me-gusta.png`, `08-sin-resultados.png`
Todas con "Yankee" sonando, de ahí el tinte ámbar: con otra pista el mismo layout se ve en otro color.
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

File diff suppressed because it is too large Load Diff
@@ -0,0 +1,335 @@
# Plan — Unificación del sistema de diseño y limpieza del front (web-player)
> Este documento es un **plan de trabajo para otro agente/IA**, no un informe de lo ya
> hecho. Recoge conocimiento concreto (bugs reales, patrones, cifras) obtenido durante
> una sesión larga de ajustes de UI, para que quien continúe no tenga que re-descubrirlo.
> No es necesario ejecutar todo de una vez: está pensado para trabajarse página por
> página o componente por componente, en PRs pequeños y verificables.
## 0. Objetivo
Tres problemas, en orden de aparición histórica pero **no** de prioridad recomendada:
1. **Colores fijos en vez de variables**: muchas páginas y componentes escriben colores
literales (`text-[#8a8a8e]`, `bg-[#c6c4cb]`, tamaños `text-[13px]` sueltos, etc.) en
vez de usar los tokens del sistema (`text-fg-2`, `bg-surface`, la escala tipográfica
establecida). Esto es lo que impide que cosas como el modo claro/oscuro (ver §2)
funcionen de forma consistente, y hace que cada página tenga ligeras variaciones de
gris que deberían ser el mismo tono.
2. **Falta de componentización**: hay patrones (tarjeta de track, fila de "canción
reciente", menú desplegable tipo cristal, etc.) reimplementados a mano en varias
páginas en vez de reusar (o crear) un componente compartido. Algunos componentes ya
existentes ni siquiera se usan donde deberían.
3. **Código muerto**: imports sin usar, componentes sin importadores, clases residuales.
**Referencia canónica**: la página de Inicio (`src/routes/+page.svelte`) es el patrón a
seguir para el modo oscuro — sus tonos, jerarquía tipográfica y look general están bien
ajustados (fue la página más iterada). Cuando una página no tenga claro qué tono/tamaño
usar para algo, mirar cómo lo resuelve Inicio para lo mismo, **no inventar uno nuevo**.
Ojo: Inicio también usa hex sueltos en vez de tokens en varios sitios (ver §3) — es la
referencia de **valores/jerarquía visual**, no de "cómo escribir la clase". Cuando se
migre Inicio a tokens, los valores resultantes deben ser los mismos que ya tiene hoy.
---
## 1. Limitación conocida: modo claro ("Modo blanco") incompleto
Se añadió un toggle de tema (`$lib/theme/mode.svelte.ts`, menú de cuenta en
`TopBar.svelte` / `MobileHeader.svelte`) que pone `data-theme="light"` en `<html>` y
persiste la preferencia en `localStorage` (`musify.theme`).
**Lo que funciona:** los tokens semánticos definidos como CSS puro en
`$lib/theme/theme.css` (`--mf-text*`, `--mf-surface*`, `--mf-border*`, `--mf-track`,
`--mf-scrim`, `--mf-ink`) tienen su contrapartida en el bloque
`:root[data-theme='light'] { ... }` al final de ese archivo, y los tokens dinámicos por
hue (`--mf-bg`, `--mf-elevated`, `--mf-sidebar-bg`, `--mf-panel-bg`, `--mf-bar-bg`,
`--mf-hairline`) se regeneran correctamente en modo claro dentro de
`buildThemeTokens(hue, mode)` (`$lib/theme/tokens.ts`).
**Gotcha importante para quien toque esto:** `+layout.svelte` reaplica esos tokens
dinámicos como **estilos inline** sobre `document.documentElement` en un `$effect` que
corre en cada cambio de hue (cada canción). Un estilo inline siempre gana sobre
cualquier regla de una hoja de estilos, así que **una variable que se anima por hue no
se puede sobrescribir con `:root[data-theme='light'] { --mf-x: ... }`** — hay que
añadirla dentro de `buildThemeTokens()` con una rama `light ? ... : ...`, no en
`theme.css`. Si en el futuro se anima algo más por hue, aplica la misma regla.
**Lo que NO funciona:** todo componente que usa un color hardcodeado (ver §3) no se
entera del cambio de tema, porque no está leyendo ningún token. En la práctica esto
significa que hoy el modo claro cambia correctamente el lienzo de fondo, la sidebar, los
paneles tipo cristal (menús, modal) y cualquier texto/superficie que ya use
`text-fg`/`bg-surface`/etc., pero deja con contraste pobre o directamente ilegible
cualquier texto en hex literal (la mayoría de títulos, subtítulos y metadatos de la
app hoy).
**No hay forma barata de arreglar esto sin la migración de §3.** El modo claro real
depende, técnicamente, en un 100%, de terminar esa migración. No merece la pena
"parchear" el modo claro sin más — es la misma tarea.
---
## 2. Inventario de bugs/patrones ya encontrados (usar como guía de búsqueda)
Estos son bugs concretos que se repitieron varias veces en distintos componentes esta
sesión. Antes de tocar cada página, buscar estos mismos patrones ahí:
### 2.1 Tamaño de fuente que falta → hereda 16px del navegador
Varios componentes tenían un texto sin ninguna clase `text-*`, lo que hace que use el
tamaño por defecto del navegador (16px) en vez de la escala real de la app (que va de
`10.5px` a `14px` en casi todo). Ya se corrigió en `MediaCard.svelte`,
`TrackTitleCell.svelte` y `MenuItem.svelte`. **Sigue sin corregir** en
`TrackContextMenu.svelte` (usa `text-base`/`text-sm` sueltos igual que tenía
`MenuItem` antes de arreglarlo — mismo fix: bajar a la escala `13–13.5px` y los iconos
de `h-6 w-6`/`h-5 w-5` a `h-[18px] w-[18px]` o similar).
Cómo detectarlo: cualquier `<div>`/`<span>` que pinte texto de usuario (título, nombre,
label) sin una clase `text-[…]`/`text-sm`/`text-xs` explícita es sospechoso.
### 2.2 Conflicto de clases Tailwind que se resuelve por orden interno, no por el HTML
Tailwind no respeta el orden en que escribes las clases en el `class="…"`; resuelve
conflictos de la misma propiedad (`background-color`, `font-weight`, etc.) por el orden
en que esas utilidades existen en su propia hoja de estilos generada. Esto causó bugs
reales y confusos:
- Un botón con `bg-white/[6%] ... {isCurrent ? 'bg-accent-tint' : ''}` — con `isCurrent`
true, el fondo **seguía viéndose** `bg-white/[6%]` porque esa clase "ganaba" en el
cascade aunque apareciera antes en el HTML.
- `Button.svelte` fuerza `font-semibold` en su clase base; para poner un botón en peso
normal (`variant="secondary"` con texto normal) hay que usar `class="!font-normal"`
(con el prefijo `!` de Tailwind, que sí fuerza especificidad), un `class="font-normal"`
a secas no habría ganado.
**Regla a aplicar en la auditoría**: cualquier sitio donde dos clases de la misma
propiedad CSS convivan condicional + incondicionalmente en el mismo elemento (patrón
`class="base-x ... {cond ? 'other-x' : ''}"`) hay que reescribirlo para que sea
mutuamente excluyente (`class="{cond ? 'a' : 'b'}"`), nunca acumulativo. Y cualquier
override de una clase base de un componente compartido (`Button`, etc.) debe usar `!`
si toca la misma propiedad que ya fija la clase base.
### 2.3 Iconos con brillo verde/acento en `:hover` de forma inconsistente
Varias tarjetas tenían `group-hover:text-accent-soft` en el título para dar un hover
"bonito", pero era inconsistente con el resto de la app (la mayoría de textos no
cambian de color al hover, solo cuando están realmente "activos"/reproduciéndose). Se
quitó de `MediaCard.svelte` y de la fila de "Tus playlists" en Inicio. **Quedan
pendientes** los mismos `group-hover/card:text-accent-soft` /
`group-hover/row:text-accent-soft` en `src/routes/(app)/explore/+page.svelte` (líneas
con tarjetas de álbum y filas de resultado — grep por `group-hover.*text-accent-soft`
para localizarlos exactos, las líneas se mueven con cada edición).
---
## 3. Migración de colores/tamaños a tokens
### 3.1 Datos de partida (medidos en esta sesión, `src/` de web-player)
```
27 archivos .svelte con al menos un text-[#…]/bg-[#…]/border-[#…]
106 apariciones totales de color hex literal en clases Tailwind
```
Los 10 valores hex más repetidos como `text-[#…]` (comando usado:
`grep -roE "(text|bg|border)-\[#[0-9a-fA-F]{3,8}\]" --include="*.svelte" . | sort | uniq -c | sort -rn`):
| Ocurrencias | Valor | Coincide EXACTO con token |
| ----------- | --------- | ------------------------------------- |
| 13 | `#8a8a8e` | `--mf-text-2` → usar `text-fg-2` |
| 9 | `#747478` | ninguno exacto (cerca de text-3/4) |
| 7 | `#d5d4d8` | ninguno exacto (cerca de `--mf-text`) |
| 7 | `#7f7f83` | ninguno exacto (cerca de text-3) |
| 6 | `#d9d8dc` | `--mf-text` → usar `text-fg` |
| 6 | `#d4d3d7` | ninguno exacto |
| 4 | `#d0cfd4` | ninguno exacto |
| 4 | `#818185` | ninguno exacto |
| 3 | `#d8d7db` | ninguno exacto |
| 2 | `#7d7d81` | `--mf-text-3` → usar `text-fg-3` |
Interpretación: hay un grupo de valores que son **copias exactas** de un token ya
existente (probablemente porque alguien copió el valor en vez de la clase) — esos son
reemplazo mecánico, bajo riesgo. Y hay un grupo mucho más grande de valores "parecidos
pero no iguales" a los 4 tonos de texto (`--mf-text`, `-2`, `-3`, `-4`) — esto sugiere
que cada componente inventó su propio gris en vez de reusar la escala, y hay que decidir
para cada uno **a qué tier semántico pertenece** (¿es un título? ¿un metadato? ¿un
label?) y mapearlo al token de esa categoría, no crear un tier nuevo por cada valor.
Solo se encontraron 3 `bg-[#…]` (en `MixTile.svelte` y `TransportControls.svelte`,
este último repite el mismo `#c6c4cb`/`#d6d5da` que tenía el botón "Reanudar" de Inicio
antes de moverlo a `Button variant="accent"` con override — es el mismo "botón claro"
reinventado dos veces; candidato a token propio, ver §4.4) y 0 `border-[#…]`.
### 3.2 Metodología sugerida (por archivo)
1. `grep -n "text-\[#\|bg-\[#\|border-\[#" archivo.svelte` para listar todos los casos.
2. Para cada uno, decidir el tier semántico (título/cuerpo/metadato/label/borde) y
sustituir por la utilidad de Tailwind ya mapeada en
`src/routes/layout.css` (`@theme` → `--color-fg`, `--color-fg-2`, `--color-fg-3`,
`--color-muted`, `--color-surface`, `--color-surface-2`, `--color-line`,
`--color-accent*`, etc.) o, si el valor es realmente un tamaño de fuente en vez de
color, a la escala ya usada en el resto de la app (10.5 / 11 / 11.5 / 12 / 12.5 / 13 /
13.5 / 14px — no inventar un tamaño intermedio nuevo salvo que Inicio ya lo use para
ese mismo tipo de texto).
3. Ejecutar `npm run check` tras cada archivo — Tailwind con clases arbitrarias no da
error de compilación aunque el token no exista, así que la única validación real es
visual: abrir la página en el navegador (Browser pane) y comparar contra Inicio.
4. Si dos o más componentes usan **el mismo valor hex no-token** para el mismo propósito
(p. ej. `#c6c4cb` como "botón claro"), considerar promoverlo a un token nuevo en
`theme.css` en vez de simplemente sustituirlo por el más parecido — ver §4.4.
5. `npm run lint` (prettier + eslint) antes de dar por cerrado cada archivo, según las
reglas del `CLAUDE.md` del proyecto.
### 3.3 Prioridad sugerida de páginas/componentes a migrar
Por impacto (páginas que ve todo usuario, no solo estados de error/edge):
1. `Sidebar.svelte`, `TopBar.svelte`, `MobileHeader.svelte` (chrome global, visible
siempre) — MobileHeader/TopBar ya tienen el menú de cuenta arreglado, falta el resto.
2. `src/routes/(app)/playlists/[id]/components/PlaylistHeader.svelte` y
`src/routes/(app)/albums/[id]/components/AlbumHeader.svelte` (comparten patrón,
arreglar juntos para no divergir otra vez).
3. `src/routes/(app)/liked/+page.svelte`, `src/routes/(app)/explore/+page.svelte`,
`src/routes/(app)/upload/+page.svelte`, `src/routes/(app)/mixes/[id]/+page.svelte`.
4. Componentes del reproductor (`lib/components/player/*.svelte`) — muchos hex propios,
pero cambian menos visualmente entre páginas, así que menor prioridad de contraste
pero igual de importantes para el modo claro (la barra del reproductor es visible en
toda la app).
5. `src/routes/+page.svelte` (Inicio) al final — es la referencia, así que migrarla
última minimiza el riesgo de que un "acabado" a medio migrar se convierta en la
referencia visual equivocada mientras se trabaja el resto.
---
## 4. Componentización pendiente
### 4.1 Patrón de fila de track reimplementado en vez de reusar `TrackTitleCell`
`src/routes/+page.svelte` reimplementa a mano, con su propio marcado, **tres** variantes
de "fila de canción" distintas entre sí (Continuar escuchando, Populares esta semana,
filas de la Playlist destacada) en vez de usar los componentes ya existentes
`TrackTitleCell.svelte` / `TrackRow.svelte` / `TrackMeta.svelte` /
`TrackIndexCell.svelte` que sí se usan correctamente en
`PlaylistTrackTable.svelte`/`library/+page.svelte`. Evaluar si esas tres filas de Inicio
se pueden expresar con los componentes de `TrackTable` (aunque sea sin la tabla
completa, solo reusando `TrackTitleCell` para title+cover+artist) para no mantener 4
implementaciones de "fila de canción" en paralelo.
### 4.2 `MediaCard`/`PlaylistCard`/`AlbumCard` vs. tarjetas hechas a mano
`src/routes/+page.svelte` (sección "Tus playlists") y `src/routes/(app)/explore/+page.svelte`
tienen su propia tarjeta de álbum/playlist en vez de usar `MediaCard`/`PlaylistCard`/
`AlbumCard` (`lib/components/ui/`). Antes de tocar el estilo de esas tarjetas a mano,
comprobar si pueden sustituirse directamente por el componente compartido — sale gratis
el arreglo de tipografía/color si el componente compartido ya está migrado a tokens.
### 4.3 Menú desplegable "de cristal" duplicado
`TopBar.svelte` y `MobileHeader.svelte` duplican: estado `menuOpen`, el
`onDocumentClick` para cerrar al hacer click fuera, y el marcado del panel
(`animate-pop ... backdrop-blur-[22px]` + `background-color:var(--mf-panel-bg);
background-image:var(--mf-modal-glow)`). `TrackContextMenu.svelte` tiene un menú
flotante distinto (`bg-elevated` + `border-line`, sin el efecto cristal, y con el bug de
tamaño de fuente de §2.1). Candidatos:
- Extraer un primitivo `Dropdown.svelte` o `FloatingMenu.svelte` que resuelva
posicionamiento + cierre al clicar fuera + Escape, y que ambos menús de cuenta lo usen.
- Definir la receta "panel de cristal" (el `style="background-color:var(--mf-panel-bg);
background-image:var(--mf-modal-glow)"` + `backdrop-blur-[22px]`) en una utilidad o
clase reusable en vez de repetir el `style=` inline en cada sitio, y aplicarla también
a `TrackContextMenu` para que los tres menús floating de la app se vean iguales.
### 4.4 Candidato a token nuevo: "botón claro" (`#c6c4cb`/`#d6d5da`/`#d8d7dc`)
Aparece en al menos: el antiguo botón "Reanudar" de Inicio (ya migrado a
`Button variant="accent"` con `class="!bg-white ..."`, ver commit reciente) y
`TransportControls.svelte` (botón de play principal del reproductor, sigue con hex
propio). Si el diseño quiere mantener un botón "casi blanco" como acento fuerte en
ciertos sitios (distinto del verde de acento normal), vale la pena darle un token
propio (p. ej. `--mf-cta-strong-bg`/`--mf-cta-strong-fg`) en vez de repetir el hex cada
vez, así el modo claro también puede darle un tratamiento coherente en vez de heredar el
mismo blanco fijo en ambos temas (que en modo claro quedaría invisible sobre el fondo).
---
## 5. Código muerto — puntos de partida
Ya se han borrado en esta rama `MobileNav.svelte` y `MixBentoTile.svelte` por no
usarse. Grep rápido (basado en "ningún otro `.svelte`/`.ts` bajo `src/` menciona el
nombre del componente fuera de su propio archivo") encontró estos candidatos
adicionales — **verificar a mano antes de borrar** (el grep no será 100% preciso: no
detecta uso vía imports dinámicos, alias, o si el componente se monta desde fuera de
`src/`):
- `src/lib/components/ui/CoverBadge.svelte`
- `src/lib/components/ui/PlayPauseOverlay.svelte`
Método para repetir esta búsqueda tras cada ronda de refactor (los nombres de
componente que queden sin importadores son limpieza segura):
```bash
for f in $(find src/lib/components -name "*.svelte"); do
name=$(basename "$f" .svelte)
count=$(grep -rl "$name" --include="*.svelte" --include="*.ts" src | grep -v "/$name.svelte$" | wc -l)
[ "$count" -eq 0 ] && echo "$f"
done
```
Además de componentes huérfanos, revisar por archivo (esto no se ha auditado
sistemáticamente esta sesión, son solo los tipos de código muerto más comunes en este
código base):
- Imports de iconos (`@lucide/svelte/icons/*`) que dejaron de usarse tras quitar un
icono de un botón (pasó varias veces esta sesión con `Play`/`Pause`/`NowPlaying`;
`eslint` los marca como `no-unused-vars` — correr `npx eslint <archivo>` tras cada
cambio de marcado, no solo `svelte-check`, porque `svelte-check` no siempre lo pilla).
- Props de componentes que ya no lee nadie (ej. si se elimina el único sitio que pasaba
`description` a `PlaylistCard`, y el componente ya no la usa, quitar el prop además
del sitio que la usaba).
- Clases Tailwind puestas "por si acaso" que no tienen efecto porque una clase
posterior en el cascade las anula (ver §2.2) — no es código muerto en el sentido
clásico, pero es ruido que confunde a quien lea el archivo después.
---
## 6. Cómo verificar cada cambio
Del `CLAUDE.md` del proyecto (`web-player/CLAUDE.md`), aplica igual aquí:
```sh
npm run check # svelte-check — debe salir 0 errores
npm run lint # prettier --check . && eslint . — debe salir limpio
```
Y para lo visual, que `svelte-check`/`eslint` no cubren:
1. Levantar `musify-web` (y `musify-api` si la página necesita datos reales) con el
Browser pane / `.claude/launch.json`.
2. Abrir la página tocada y la página de Inicio **una al lado de otra** (o alternando)
para comparar tono de gris, tamaño de fuente y separaciones — no solo "que no se vea
roto", sino "que se vea igual que Inicio para el mismo tipo de elemento".
3. Si la página tiene estado activo/actual (ej. una fila de canción reproduciéndose),
probarlo — varios de los bugs de esta sesión (§2.2) solo eran visibles con el
`isCurrent`/hover activo, no en el estado por defecto.
4. Alternar el toggle de tema (menú de cuenta → "Modo blanco"/"Modo oscuro") en la
página tocada para confirmar que el texto sigue siendo legible en ambos modos.
---
## 7. Orden de trabajo sugerido
1. Terminar §2 (los 3 bugs conocidos que quedan sueltos: `TrackContextMenu.svelte`
tamaño de fuente, hover verde en `explore/+page.svelte`).
2. Migrar a tokens los componentes de chrome global (§3.3, punto 1) — máximo impacto
visual con menor superficie de código.
3. Extraer el "panel de cristal" y/o `Dropdown.svelte` (§4.3) **antes** de tocar
`TrackContextMenu` a mano, para no maquetarlo dos veces.
4. Migrar el resto de páginas por la prioridad de §3.3.
5. Revisar componentización (§4.1, §4.2) — mejor después de que los componentes base
(`TrackTitleCell`, `MediaCard`, etc.) ya estén en tokens, así "adoptarlos" en Inicio
no introduce una regresión visual.
6. Pasada final de código muerto (§5) con el listado de componentes/props ya
consolidado tras los pasos anteriores (habrá más candidatos a huérfanos según se
vayan unificando duplicados).
7. Migrar Inicio a tokens en último lugar (§3.3, punto 5), verificando que los valores
resultantes son pixel-iguales a los que tiene hoy (es la referencia, no debe cambiar
de aspecto, solo de cómo está escrito el código).
+7 -1
View File
@@ -36,6 +36,12 @@ export default ts.config(
} }
}, },
{ {
ignores: ['build/', '.svelte-kit/', 'static/', 'src/lib/api/schema.d.ts'] ignores: [
'build/',
'.svelte-kit/',
'static/',
'src/lib/api/schema.d.ts',
'design_handoff_musify_player/'
]
} }
); );
+20
View File
@@ -10,6 +10,8 @@
"dependencies": { "dependencies": {
"@fontsource-variable/bricolage-grotesque": "^5.3.0", "@fontsource-variable/bricolage-grotesque": "^5.3.0",
"@fontsource-variable/manrope": "^5.3.0", "@fontsource-variable/manrope": "^5.3.0",
"@fontsource-variable/plus-jakarta-sans": "^5.3.0",
"@fontsource-variable/sora": "^5.3.0",
"@lucide/svelte": "^1.23.0", "@lucide/svelte": "^1.23.0",
"@sveltejs/adapter-node": "^5.5.7", "@sveltejs/adapter-node": "^5.5.7",
"jose": "^6.2.3", "jose": "^6.2.3",
@@ -193,6 +195,24 @@
"url": "https://github.com/sponsors/ayuhito" "url": "https://github.com/sponsors/ayuhito"
} }
}, },
"node_modules/@fontsource-variable/plus-jakarta-sans": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource-variable/plus-jakarta-sans/-/plus-jakarta-sans-5.3.0.tgz",
"integrity": "sha512-/l/4r0yyWK9JzAlmA0LiYgGgmJe/Gswt4jTJEzr5QhJfwMbvJZDmYWyW0M4X7yCK69BajMVlV/Lx8g7WDc1+sw==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource-variable/sora": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource-variable/sora/-/sora-5.3.0.tgz",
"integrity": "sha512-h8kHta4Z8SkfUGeI82TUy1n3rbrKpBRGwuk4t4qCwto5oEHxlbOFD2yGP+Df5fbmTXwKGn+o667vYp0E4K8Whw==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@humanfs/core": { "node_modules/@humanfs/core": {
"version": "0.19.2", "version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
+2
View File
@@ -37,6 +37,8 @@
"dependencies": { "dependencies": {
"@fontsource-variable/bricolage-grotesque": "^5.3.0", "@fontsource-variable/bricolage-grotesque": "^5.3.0",
"@fontsource-variable/manrope": "^5.3.0", "@fontsource-variable/manrope": "^5.3.0",
"@fontsource-variable/plus-jakarta-sans": "^5.3.0",
"@fontsource-variable/sora": "^5.3.0",
"@lucide/svelte": "^1.23.0", "@lucide/svelte": "^1.23.0",
"@sveltejs/adapter-node": "^5.5.7", "@sveltejs/adapter-node": "^5.5.7",
"jose": "^6.2.3", "jose": "^6.2.3",
+367 -8
View File
@@ -194,6 +194,40 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/likes": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get The Current User'S Liked Tracks. */
get: operations["GetLikedTracks"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/likes/toggle": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Like Or Unlike A Track, By Id Or By External Source, Provisioning It In The Background If Needed. */
post: operations["ToggleTrackLike"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/mixes": { "/mixes": {
parameters: { parameters: {
query?: never; query?: never;
@@ -556,6 +590,75 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/users/{id}/listening-stats": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get A User'S Listening Stats For The Current Week And Streak. */
get: operations["GetListeningStats"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/users/{id}/profile": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get A User'S Public Profile, Including Follower Counts. */
get: operations["GetUserProfile"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/users/{id}/is-following": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Check Whether The Current User Follows Another User. */
get: operations["IsFollowingUser"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/users/{id}/follow": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Follow A User. */
post: operations["FollowUser"];
/** Unfollow A User. */
delete: operations["UnfollowUser"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
} }
export type webhooks = Record<string, never>; export type webhooks = Record<string, never>;
export interface components { export interface components {
@@ -576,8 +679,7 @@ export interface components {
description: null | string; description: null | string;
/** Format: int32 */ /** Format: int32 */
releaseYear: null | number | string; releaseYear: null | number | string;
/** Format: int64 */ ownerUserId: string;
ownerUserId: number | string;
/** Format: int32 */ /** Format: int32 */
trackCount: number | string; trackCount: number | string;
smallImageKeyName: null | string; smallImageKeyName: null | string;
@@ -600,8 +702,7 @@ export interface components {
description: null | string; description: null | string;
/** Format: int32 */ /** Format: int32 */
releaseYear: null | number | string; releaseYear: null | number | string;
/** Format: int64 */ ownerUserId: string;
ownerUserId: number | string;
/** Format: int32 */ /** Format: int32 */
trackCount: number | string; trackCount: number | string;
smallImageKeyName: null | string; smallImageKeyName: null | string;
@@ -658,6 +759,7 @@ export interface components {
description: null | string; description: null | string;
/** Format: uuid */ /** Format: uuid */
pictureIntentId: null | string; pictureIntentId: null | string;
visibility?: components["schemas"]["PlaylistVisibility"];
}; };
CreateTrackRequest: { CreateTrackRequest: {
title: string; title: string;
@@ -684,6 +786,14 @@ export interface components {
[key: string]: string[]; [key: string]: string[];
}; };
}; };
ListeningStatsResponse: {
/** Format: int32 */
tracksThisWeek: number | string;
/** Format: int32 */
secondsThisWeek: number | string;
/** Format: int32 */
streakDays: number | string;
};
MixApplicationResponse: { MixApplicationResponse: {
/** Format: uuid */ /** Format: uuid */
id: string; id: string;
@@ -772,6 +882,7 @@ export interface components {
smallImageKeyName: null | string; smallImageKeyName: null | string;
mediumImageKeyName: null | string; mediumImageKeyName: null | string;
largeImageKeyName: null | string; largeImageKeyName: null | string;
visibility: components["schemas"]["PlaylistVisibility"];
/** Format: date-time */ /** Format: date-time */
createdAt: string; createdAt: string;
/** Format: date-time */ /** Format: date-time */
@@ -790,6 +901,8 @@ export interface components {
uploadUrl: string; uploadUrl: string;
}; };
/** @enum {unknown} */ /** @enum {unknown} */
PlaylistVisibility: "Private" | "Public";
/** @enum {unknown} */
ProcessingStatus: "Pending" | "Processing" | "Completed" | "Failed"; ProcessingStatus: "Pending" | "Processing" | "Completed" | "Failed";
RequestAlbumPictureUploadRequest: { RequestAlbumPictureUploadRequest: {
fileType: string; fileType: string;
@@ -813,6 +926,12 @@ export interface components {
/** Format: int64 */ /** Format: int64 */
expectedAudioSizeBytes?: null | number | string; expectedAudioSizeBytes?: null | number | string;
}; };
ToggleLikeRequest: {
/** Format: uuid */
trackId: null | string;
source: null | components["schemas"]["TrackSource"];
externalId: null | string;
};
TrackApplicationResponse: components["schemas"]["TrackApplicationResponseLocalTrackApplicationResponse"] | components["schemas"]["TrackApplicationResponseExternalTrackApplicationResponse"]; TrackApplicationResponse: components["schemas"]["TrackApplicationResponseLocalTrackApplicationResponse"] | components["schemas"]["TrackApplicationResponseExternalTrackApplicationResponse"];
TrackApplicationResponseExternalTrackApplicationResponse: { TrackApplicationResponseExternalTrackApplicationResponse: {
/** @enum {string} */ /** @enum {string} */
@@ -835,8 +954,7 @@ export interface components {
TrackApplicationResponseLocalTrackApplicationResponse: { TrackApplicationResponseLocalTrackApplicationResponse: {
/** @enum {string} */ /** @enum {string} */
source?: "Local"; source?: "Local";
/** Format: int64 */ ownerUserId: string;
ownerUserId: number | string;
/** Format: uuid */ /** Format: uuid */
id: string; id: string;
title: string; title: string;
@@ -910,10 +1028,10 @@ export interface components {
newDescription: null | string; newDescription: null | string;
/** Format: uuid */ /** Format: uuid */
newPictureIntentId: null | string; newPictureIntentId: null | string;
newVisibility?: null | components["schemas"]["PlaylistVisibility"];
}; };
UserApplicationResponse: { UserApplicationResponse: {
/** Format: int64 */ id: string;
id: number | string;
name: string; name: string;
firstName: null | string; firstName: null | string;
secondName: null | string; secondName: null | string;
@@ -923,6 +1041,19 @@ export interface components {
/** Format: date-time */ /** Format: date-time */
updatedAt: string; updatedAt: string;
}; };
UserProfileResponse: {
id: string;
name: string;
firstName: null | string;
secondName: null | string;
profilePictureUrl: null | string;
/** Format: date-time */
createdAt: string;
/** Format: int32 */
followersCount: number | string;
/** Format: int32 */
followingCount: number | string;
};
YouTubeAlbumDetail: { YouTubeAlbumDetail: {
album: components["schemas"]["YouTubeAlbumResult"]; album: components["schemas"]["YouTubeAlbumResult"];
description: string; description: string;
@@ -1465,6 +1596,83 @@ export interface operations {
}; };
}; };
}; };
GetLikedTracks: {
parameters: {
query?: {
pageNumber?: number | string;
pageSize?: number | string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PaginatedResponseOfTrackApplicationResponse"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
ToggleTrackLike: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ToggleLikeRequest"];
};
};
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": boolean;
};
};
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["HttpValidationProblemDetails"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
GetMixes: { GetMixes: {
parameters: { parameters: {
query?: never; query?: never;
@@ -1633,6 +1841,7 @@ export interface operations {
name?: string; name?: string;
pageNumber?: number | string; pageNumber?: number | string;
pageSize?: number | string; pageSize?: number | string;
onlyPublic?: boolean;
}; };
header?: never; header?: never;
path: { path: {
@@ -2395,4 +2604,154 @@ export interface operations {
}; };
}; };
}; };
GetListeningStats: {
parameters: {
query?: never;
header?: never;
path: {
id: number | string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ListeningStatsResponse"];
};
};
};
};
GetUserProfile: {
parameters: {
query?: never;
header?: never;
path: {
id: number | string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["UserProfileResponse"];
};
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
IsFollowingUser: {
parameters: {
query?: never;
header?: never;
path: {
id: number | string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": boolean;
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
FollowUser: {
parameters: {
query?: never;
header?: never;
path: {
id: number | string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description No Content */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["HttpValidationProblemDetails"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
UnfollowUser: {
parameters: {
query?: never;
header?: never;
path: {
id: number | string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description No Content */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
} }
@@ -28,6 +28,7 @@
import ListPlus from '@lucide/svelte/icons/list-plus'; import ListPlus from '@lucide/svelte/icons/list-plus';
import ListEnd from '@lucide/svelte/icons/list-end'; import ListEnd from '@lucide/svelte/icons/list-end';
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte'; import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
import GlassMenu from '$lib/components/ui/GlassMenu.svelte';
interface Props { interface Props {
menu: AlbumMenuState; menu: AlbumMenuState;
@@ -55,32 +56,29 @@
onClose(); onClose();
}} }}
></div> ></div>
<div <GlassMenu class="fixed z-40 w-60" style="left:{menu.x}px; top:{menu.y}px;">
class="fixed z-40 w-60 rounded-panel border border-line bg-elevated p-1.5 shadow-menu"
style="left:{menu.x}px; top:{menu.y}px;"
>
<button <button
type="button" type="button"
onclick={onPlayNext} onclick={onPlayNext}
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-sm text-fg-2 transition hover:bg-hover" class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-[13.5px] font-medium text-fg-2 transition hover:bg-hover"
> >
<ListPlus class="h-5 w-5 shrink-0" strokeWidth={2} /> <ListPlus class="h-[18px] w-[18px] shrink-0" strokeWidth={2} />
<span class="text-base">Reproducir a continuación</span> <span>Reproducir a continuación</span>
</button> </button>
<button <button
type="button" type="button"
onclick={onAddToQueue} onclick={onAddToQueue}
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-sm text-fg-2 transition hover:bg-hover" class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-[13.5px] font-medium text-fg-2 transition hover:bg-hover"
> >
<ListEnd class="h-5 w-5 shrink-0" strokeWidth={2} /> <ListEnd class="h-[18px] w-[18px] shrink-0" strokeWidth={2} />
<span class="text-base">Añadir a la cola</span> <span>Añadir a la cola</span>
</button> </button>
{#if playlists.length > 0} {#if playlists.length > 0}
<div class="group/addmenu relative"> <div class="group/addmenu relative">
<div <div
class="flex items-center gap-3 rounded-control px-2 py-2.5 text-sm text-fg-2 transition group-hover/addmenu:bg-hover" class="flex items-center gap-3 rounded-control px-2 py-2.5 text-[13.5px] font-medium text-fg-2 transition group-hover/addmenu:bg-hover"
> >
<span class="flex-1 cursor-default text-base">Añadir álbum a una playlist</span> <span class="flex-1 cursor-default">Añadir álbum a una playlist</span>
<ChevronRight class="h-4 w-4 shrink-0 text-fg-3" /> <ChevronRight class="h-4 w-4 shrink-0 text-fg-3" />
</div> </div>
<div <div
@@ -108,7 +106,7 @@
<input type="hidden" name="albumId" value={menu.albumId} /> <input type="hidden" name="albumId" value={menu.albumId} />
<button <button
type="submit" type="submit"
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-base text-fg transition hover:bg-hover" class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-[13.5px] font-medium text-fg transition hover:bg-hover"
> >
<PlaylistArt <PlaylistArt
playlistId={playlist.id} playlistId={playlist.id}
@@ -127,4 +125,4 @@
{:else} {:else}
<p class="px-2 py-2.5 text-sm text-fg-3">No tienes playlists todavía.</p> <p class="px-2 py-2.5 text-sm text-fg-3">No tienes playlists todavía.</p>
{/if} {/if}
</div> </GlassMenu>
@@ -4,13 +4,21 @@
import Folder from '@lucide/svelte/icons/folder'; import Folder from '@lucide/svelte/icons/folder';
import LogOut from '@lucide/svelte/icons/log-out'; import LogOut from '@lucide/svelte/icons/log-out';
import LogIn from '@lucide/svelte/icons/log-in'; import LogIn from '@lucide/svelte/icons/log-in';
import User from '@lucide/svelte/icons/user';
import Settings from '@lucide/svelte/icons/settings';
import Sun from '@lucide/svelte/icons/sun';
import Moon from '@lucide/svelte/icons/moon';
import MenuItem from './ui/MenuItem.svelte'; import MenuItem from './ui/MenuItem.svelte';
import GlassMenu from './ui/GlassMenu.svelte';
import SearchBox from './SearchBox.svelte';
import { themeMode } from '$lib/theme/mode.svelte';
interface Props { interface Props {
user: SessionUser | null; user: SessionUser | null;
accountUrl?: string | null;
} }
let { user }: Props = $props(); let { user, accountUrl }: Props = $props();
let menuOpen = $state(false); let menuOpen = $state(false);
let menuRef: HTMLDivElement | undefined = $state(); let menuRef: HTMLDivElement | undefined = $state();
@@ -21,9 +29,18 @@
</script> </script>
<header <header
class="sticky top-0 z-40 flex h-14 items-center justify-between border-b border-line bg-bg/85 page-x backdrop-blur-md md:hidden" class="sticky top-0 z-40 flex flex-col gap-2.5 border-b border-hairline bg-bg/85 page-x pt-3 pb-3 backdrop-blur-md sm:hidden"
> >
<a href="/" class="font-display text-lg font-semibold tracking-tight text-fg">Musify</a> <div class="flex h-9 items-center justify-between">
<a
href="/"
class="flex items-center gap-2 font-display text-[17.5px] font-semibold tracking-[-0.02em] text-fg"
>
<span
class="h-5.5 w-5.5 rounded-[7px] bg-[image:var(--mf-logo-grad)] shadow-[var(--mf-logo-glow)]"
></span>
Musify
</a>
<div class="relative" bind:this={menuRef}> <div class="relative" bind:this={menuRef}>
{#if user} {#if user}
@@ -37,16 +54,22 @@
{#if user.picture} {#if user.picture}
<img src={user.picture} alt="" class="h-9 w-9 rounded-full object-cover" /> <img src={user.picture} alt="" class="h-9 w-9 rounded-full object-cover" />
{:else} {:else}
<span class="grid h-9 w-9 place-items-center rounded-full bg-surface-2 text-sm uppercase"> <span
class="grid h-9 w-9 place-items-center rounded-full bg-surface-2 text-sm uppercase"
>
{user.name.charAt(0)} {user.name.charAt(0)}
</span> </span>
{/if} {/if}
</button> </button>
{#if menuOpen} {#if menuOpen}
<div <GlassMenu class="absolute top-full right-0 mt-2 w-56 overflow-hidden">
class="animate-pop absolute top-full right-0 mt-2 w-56 overflow-hidden rounded-panel border border-line bg-elevated p-1.5 shadow-menu" <MenuItem
> icon={User}
label="Ver perfil"
href="/u/{user.sub}"
onclick={() => (menuOpen = false)}
/>
<MenuItem <MenuItem
icon={Folder} icon={Folder}
label="Canciones subidas" label="Canciones subidas"
@@ -59,11 +82,24 @@
href="/upload" href="/upload"
onclick={() => (menuOpen = false)} onclick={() => (menuOpen = false)}
/> />
<div class="-mx-1.5 my-1.5 border-t border-line"></div> {#if accountUrl}
<MenuItem
icon={Settings}
label="Ajustes"
href={accountUrl}
target="_blank"
onclick={() => (menuOpen = false)}
/>
{/if}
<MenuItem
icon={themeMode.current === 'dark' ? Sun : Moon}
label={themeMode.current === 'dark' ? 'Modo blanco' : 'Modo oscuro'}
onclick={() => themeMode.toggle()}
/>
<form method="POST" action="/logout" data-sveltekit-reload> <form method="POST" action="/logout" data-sveltekit-reload>
<MenuItem icon={LogOut} label="Salir" type="submit" /> <MenuItem icon={LogOut} label="Cerrar sesión" type="submit" />
</form> </form>
</div> </GlassMenu>
{/if} {/if}
{:else} {:else}
<a <a
@@ -76,6 +112,9 @@
</a> </a>
{/if} {/if}
</div> </div>
</div>
<SearchBox class="w-full" />
</header> </header>
<svelte:window onclick={onDocumentClick} /> <svelte:window onclick={onDocumentClick} />
@@ -1,53 +0,0 @@
<script lang="ts">
import { page } from '$app/state';
import { isSectionActive } from '$lib/navigation.svelte';
import type { SessionUser } from '$lib/types';
import Home from '@lucide/svelte/icons/house';
import Search from '@lucide/svelte/icons/search';
import Music from '@lucide/svelte/icons/music';
import Folder from '@lucide/svelte/icons/folder';
interface Props {
user: SessionUser | null;
}
let { user }: Props = $props();
const links = $derived([
{ href: '/', label: 'Inicio', icon: Home },
{ href: '/explore', label: 'Buscar', icon: Search },
...(user
? [
{ href: '/playlists', label: 'Listas', icon: Music },
{ href: '/library', label: 'Subidas', icon: Folder }
]
: [])
]);
function isActive(href: string) {
return isSectionActive(href, page.url.pathname, page.data.section);
}
</script>
<nav
class="fixed inset-x-0 bottom-0 z-50 border-t border-line bg-bg/95 backdrop-blur-md md:hidden"
style="padding-bottom:var(--mf-safe-b)"
>
<ul class="grid h-[var(--mf-nav-h)]" style="grid-template-columns:repeat({links.length}, 1fr)">
{#each links as link (link.href)}
{@const active = isActive(link.href)}
<li>
<a
href={link.href}
aria-current={active ? 'page' : undefined}
class="flex h-full flex-col items-center justify-center gap-1 transition-colors {active
? 'text-fg'
: 'text-fg-3'}"
>
<link.icon class="h-[22px] w-[22px] {active ? 'text-accent-soft' : ''}" />
<span class="text-[11px] leading-none font-medium">{link.label}</span>
</a>
</li>
{/each}
</ul>
</nav>
@@ -0,0 +1,86 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { SEARCH_DEBOUNCE_MS, SEARCH_MIN_LENGTH } from '$lib/config';
import Search from '@lucide/svelte/icons/search';
import X from '@lucide/svelte/icons/x';
interface Props {
class?: string;
placeholder?: string;
}
let { class: klass = '', placeholder = 'Canciones, artistas, álbumes o playlists' }: Props =
$props();
let query = $state('');
let searchInput: HTMLInputElement | undefined = $state();
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
function navigateSearch() {
const term = query.trim();
goto(term ? `/explore?q=${encodeURIComponent(term)}` : '/explore', {
replaceState: page.url.pathname === '/explore',
keepFocus: true,
noScroll: true
});
}
function onInput() {
clearTimeout(searchTimeout);
const term = query.trim();
const onExplore = page.url.pathname === '/explore';
if (term.length > 0 && term.length < SEARCH_MIN_LENGTH) return;
if (term.length === 0 && !onExplore) return;
searchTimeout = setTimeout(navigateSearch, SEARCH_DEBOUNCE_MS);
}
function clearQuery() {
clearTimeout(searchTimeout);
query = '';
if (page.url.pathname === '/explore') navigateSearch();
searchInput?.focus();
}
function onSubmit(event: SubmitEvent) {
event.preventDefault();
clearTimeout(searchTimeout);
navigateSearch();
}
function onKeydown(event: KeyboardEvent) {
if (event.key !== 'Enter') return;
event.preventDefault();
clearTimeout(searchTimeout);
navigateSearch();
}
</script>
<div class="relative min-w-0 {klass}">
<form onsubmit={onSubmit}>
<label
class="flex h-9.5 w-full items-center gap-2.5 rounded-[11px] bg-white/[3.5%] px-3.5 transition focus-within:bg-white/[6%]"
>
<Search class="h-[15px] w-[15px] shrink-0 text-fg-3" strokeWidth={1.8} />
<input
type="text"
bind:this={searchInput}
bind:value={query}
oninput={onInput}
onkeydown={onKeydown}
{placeholder}
class="w-full min-w-0 bg-transparent text-[13px] text-fg placeholder:text-muted focus:outline-none"
/>
{#if query}
<button
type="button"
onclick={clearQuery}
aria-label="Borrar búsqueda"
class="grid h-5 w-5 shrink-0 place-items-center rounded-full text-muted transition hover:text-fg"
>
<X class="h-3.5 w-3.5" />
</button>
{/if}
</label>
</form>
</div>
+91 -79
View File
@@ -1,117 +1,129 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
import { isSectionActive } from '$lib/navigation.svelte'; import { isSectionActive } from '$lib/navigation.svelte';
import { liked } from '$lib/player/liked.svelte';
import { createPlaylistModal } from '$lib/playlists.svelte';
import type { SessionUser } from '$lib/types'; import type { SessionUser } from '$lib/types';
import PlaylistArt from './ui/PlaylistArt.svelte';
import Home from '@lucide/svelte/icons/house'; import Home from '@lucide/svelte/icons/house';
import Folder from '@lucide/svelte/icons/folder'; 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 Disc from '@lucide/svelte/icons/disc-2';
import Music from '@lucide/svelte/icons/music'; import Folder from '@lucide/svelte/icons/folder';
import Upload from '@lucide/svelte/icons/upload'; import Upload from '@lucide/svelte/icons/upload';
import Plus from '@lucide/svelte/icons/plus';
interface SidebarPlaylist {
id: string;
name: string;
coverTrackIds: (string | number)[];
updatedAt: string;
}
interface Props { interface Props {
user: SessionUser | null; user: SessionUser | null;
playlists: SidebarPlaylist[];
} }
let { user }: Props = $props(); let { user, playlists }: Props = $props();
let navBox: HTMLElement | undefined = $state(); const navLinks = $derived(
let pillTop = $state(0);
let pillHeight = $state(0);
let pillVisible = $state(false);
let animate = $state(false);
const mainLinks = $derived([
{ href: '/', label: 'Inicio', icon: Home },
...(user ? [{ href: '/playlists', label: 'Listas', icon: Music }] : [])
]);
const secondaryLinks = $derived(
user user
? [ ? [
{ href: '/', label: 'Inicio', icon: Home },
{ href: '/explore', label: 'Descubrir', icon: Compass },
{ href: '/playlists', label: 'Playlists', icon: ListMusic },
{ href: '/liked', label: 'Me gusta', icon: Heart, count: liked.count },
{ href: '/albums', label: 'Mis álbumes', icon: Disc }, { href: '/albums', label: 'Mis álbumes', icon: Disc },
{ href: '/library', label: 'Canciones subidas', icon: Folder }, { href: '/library', label: 'Canciones subidas', icon: Folder },
{ href: '/upload', label: 'Subir música', icon: Upload } { href: '/upload', label: 'Subir música', icon: Upload }
] ]
: [] : [{ href: '/explore', label: 'Descubrir', icon: Compass }]
); );
function isActive(href: string) { function isActive(href: string) {
return isSectionActive(href, page.url.pathname, page.data.section); return isSectionActive(href, page.url.pathname, page.data.section);
} }
$effect(() => {
void page.url.pathname;
const box = navBox;
if (!box) return;
const active = box.querySelector<HTMLElement>('[data-active="true"]');
if (!active) {
pillVisible = false;
return;
}
const boxRect = box.getBoundingClientRect();
const rect = active.getBoundingClientRect();
pillTop = rect.top - boxRect.top;
pillHeight = rect.height;
pillVisible = true;
if (!animate) requestAnimationFrame(() => (animate = true));
});
</script> </script>
{#snippet navLink(link: { href: string; label: string; icon: typeof Home })} <aside
class="hidden shrink-0 flex-col border-r border-hairline bg-[image:var(--mf-sidebar-bg)] pt-5.5 pr-3 pl-5 transition-[background] duration-[600ms] ease-out lg:flex"
style="width:var(--mf-sidebar-w)"
>
<a href="/" class="flex items-center gap-2.5 px-1.5 pb-6.5">
<span class="font-display text-[17.5px] font-semibold tracking-[-0.02em] text-fg">Musify</span>
</a>
<nav class="flex flex-col gap-0.5">
{#each navLinks as link (link.href)}
{@const active = isActive(link.href)} {@const active = isActive(link.href)}
<a <a
href={link.href} href={link.href}
data-active={active} aria-current={active ? 'page' : undefined}
class="relative flex items-center gap-3.5 rounded-control px-4 py-2.5 text-sm transition-colors duration-100 {active class="flex items-center gap-3 rounded-[10px] px-3 py-2.25 text-[13.5px] font-medium transition-colors duration-150 {active
? 'text-fg' ? 'bg-surface-hover text-fg'
: 'text-fg-2 hover:bg-hover hover:text-fg'}" : 'text-fg-2 hover:bg-hover hover:text-fg'}"
> >
<link.icon class="h-5 w-5 shrink-0 {active ? 'text-accent-soft' : ''}" /> <link.icon class="h-[17px] w-[17px] shrink-0" strokeWidth={1.6} />
{link.label} <span class="flex-1 truncate">{link.label}</span>
{#if link.count}
<span class="text-[11px] text-muted tabular-nums">{link.count}</span>
{/if}
</a> </a>
{/snippet}
{#snippet groupLabel(label: string)}
<p class="mb-4 px-4 text-sm font-semibold tracking-[0.12em] text-fg-2 uppercase">
{label}
</p>
{/snippet}
<aside
class="sticky top-0 hidden h-screen shrink-0 flex-col border-r border-line bg-bg md:flex"
style="width:var(--mf-sidebar-w)"
>
<div
class="relative flex flex-1 flex-col gap-6 overflow-y-auto px-4.5 py-6 md:pt-8"
bind:this={navBox}
>
<div
class="pointer-events-none absolute inset-x-4.5 z-0 overflow-hidden rounded-control border border-line bg-surface {animate
? 'transition-[top,height] duration-200 ease-in-out'
: ''}"
style="top:{pillTop}px; height:{pillHeight}px; opacity:{pillVisible ? 1 : 0}"
>
<span class="absolute inset-y-0 left-0 w-0.75 rounded-r-full bg-accent-soft"></span>
</div>
<div>
{@render groupLabel('Navegación')}
<nav class="relative z-10 flex flex-col gap-2">
{#each mainLinks as link (link.href)}
{@render navLink(link)}
{/each} {/each}
</nav> </nav>
</div>
{#if secondaryLinks.length > 0} {#if user}
<div> <div class="mx-3 mt-6 mb-3.5 h-px bg-white/5"></div>
{@render groupLabel('Tu música')} <div class="flex items-center justify-between px-3 pb-1.5">
<nav class="relative z-10 flex flex-col gap-1"> <span class="text-[10.5px] font-semibold tracking-[0.13em] text-muted uppercase"
{#each secondaryLinks as link (link.href)} >Tus playlists</span
{@render navLink(link)} >
{/each} {#if playlists.length > 0}
</nav> <button
</div> type="button"
onclick={() => createPlaylistModal.show()}
aria-label="Crear playlist"
class="text-base text-muted transition-colors hover:text-fg"
>
+
</button>
{/if} {/if}
</div> </div>
<div class="flex flex-1 flex-col gap-px overflow-y-auto pb-4.5">
{#each playlists as playlist (playlist.id)}
{@const active = page.url.pathname === `/playlists/${playlist.id}`}
<a
href="/playlists/{playlist.id}"
class="flex items-center gap-2.75 rounded-[10px] px-3 py-1.75 transition-colors duration-150 {active
? 'bg-surface-hover'
: 'hover:bg-hover'}"
>
<PlaylistArt
playlistId={playlist.id}
trackIds={playlist.coverTrackIds}
version={playlist.updatedAt}
size="small"
class="h-8.5 w-8.5 shrink-0 overflow-hidden rounded-lg opacity-90"
/>
<div class="min-w-0 flex-1">
<div class="truncate text-[13.5px] font-medium {active ? 'text-fg' : 'text-fg-2'}">
{playlist.name}
</div>
</div>
</a>
{:else}
<button
type="button"
onclick={() => createPlaylistModal.show()}
class="flex items-center gap-2 rounded-[10px] px-3 py-2 text-[12px] text-muted transition-colors hover:bg-hover hover:text-fg-2"
>
<Plus class="h-3.5 w-3.5" strokeWidth={1.8} />
Crear tu primera lista
</button>
{/each}
</div>
{/if}
</aside> </aside>
@@ -0,0 +1,50 @@
<script lang="ts">
import { page } from '$app/state';
import { isSectionActive } from '$lib/navigation.svelte';
import type { SessionUser } from '$lib/types';
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';
interface Props {
user: SessionUser | null;
class?: string;
}
let { user, class: klass = '' }: Props = $props();
const links = $derived(
user
? [
{ href: '/', label: 'Inicio', icon: Home },
{ href: '/explore', label: 'Descubrir', icon: Compass },
{ href: '/playlists', label: 'Playlists', icon: ListMusic },
{ href: '/liked', label: 'Me gusta', icon: Heart }
]
: [{ href: '/explore', label: 'Descubrir', icon: Compass }]
);
function isActive(href: string) {
return isSectionActive(href, page.url.pathname, page.data.section);
}
</script>
<nav
class="pointer-events-auto flex items-center justify-around rounded-[18px] border border-hairline bg-[image:var(--mf-bar-bg)] p-1.75 backdrop-blur-[22px] transition-[background] duration-[600ms] lg:hidden {klass}"
style="padding-bottom:calc(0.4375rem + var(--mf-safe-b))"
>
{#each links as link (link.href)}
{@const active = isActive(link.href)}
<a
href={link.href}
aria-current={active ? 'page' : undefined}
class="flex flex-col items-center gap-1.25 rounded-xl px-2.5 py-1.5 {active
? 'text-fg'
: 'text-fg-2'}"
>
<link.icon class="h-[17px] w-[17px]" strokeWidth={1.6} />
<span class="text-[10px] font-medium">{link.label}</span>
</a>
{/each}
</nav>
+55 -171
View File
@@ -1,220 +1,104 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import type { SessionUser } from '$lib/types';
import type { SessionUser, YouTubeSong } from '$lib/types'; import ChevronLeft from '@lucide/svelte/icons/chevron-left';
import { player } from '$lib/player/player.svelte'; import ChevronRight from '@lucide/svelte/icons/chevron-right';
import {
queueItemForTarget,
targetArtist,
targetCoverSrc,
targetExplicit,
targetId,
targetTitle,
type TrackTarget
} from '$lib/tracks';
import { SEARCH_DEBOUNCE_MS, SEARCH_MIN_LENGTH } from '$lib/config';
import Search from '@lucide/svelte/icons/search';
import X from '@lucide/svelte/icons/x';
import LogOut from '@lucide/svelte/icons/log-out'; import LogOut from '@lucide/svelte/icons/log-out';
import User from '@lucide/svelte/icons/user';
import Settings from '@lucide/svelte/icons/settings';
import Sun from '@lucide/svelte/icons/sun';
import Moon from '@lucide/svelte/icons/moon';
import MenuItem from './ui/MenuItem.svelte'; import MenuItem from './ui/MenuItem.svelte';
import Cover from './ui/Cover.svelte'; import GlassMenu from './ui/GlassMenu.svelte';
import ExplicitBadge from './ui/ExplicitBadge.svelte'; import SearchBox from './SearchBox.svelte';
import { themeMode } from '$lib/theme/mode.svelte';
interface Props { interface Props {
user: SessionUser | null; user: SessionUser | null;
accountUrl?: string | null;
} }
const QUICK_RESULTS_LIMIT = 5; let { user, accountUrl }: Props = $props();
interface QuickSearchTrack {
id: string;
title: string;
artist?: string | null;
isExplicit?: boolean;
}
interface QuickSearchResponse {
items: { track?: QuickSearchTrack | null; youTubeSong?: YouTubeSong | null }[];
}
let { user }: Props = $props();
let query = $state('');
let results = $state<TrackTarget[]>([]);
let resultsOpen = $state(false);
let searching = $state(false);
let menuOpen = $state(false); let menuOpen = $state(false);
let menuRef: HTMLDivElement | undefined = $state(); let menuRef: HTMLDivElement | undefined = $state();
let searchRef: HTMLDivElement | undefined = $state();
let searchInput: HTMLInputElement | undefined = $state();
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
let searchToken = 0;
function clearQuery() {
query = '';
results = [];
resultsOpen = false;
searchInput?.focus();
}
function onInput() {
clearTimeout(searchTimeout);
const term = query.trim();
if (term.length < SEARCH_MIN_LENGTH) {
results = [];
resultsOpen = false;
return;
}
resultsOpen = true;
searchTimeout = setTimeout(() => runQuickSearch(term), SEARCH_DEBOUNCE_MS);
}
async function runQuickSearch(term: string) {
const token = ++searchToken;
searching = true;
try {
const params = new URLSearchParams({ name: term, pageSize: String(QUICK_RESULTS_LIMIT) });
const res = await fetch(`/api/tracks?${params}`);
if (!res.ok) throw new Error(String(res.status));
const data = (await res.json()) as QuickSearchResponse;
if (token !== searchToken) return;
results = data.items
.flatMap((item): TrackTarget[] => {
if (item.track) return [{ kind: 'local', track: item.track }];
if (item.youTubeSong) return [{ kind: 'youtube', song: item.youTubeSong }];
return [];
})
.slice(0, QUICK_RESULTS_LIMIT);
} catch {
if (token === searchToken) results = [];
} finally {
if (token === searchToken) searching = false;
}
}
function playResult(index: number) {
player.playOrToggle(results.map(queueItemForTarget), index);
resultsOpen = false;
}
function onSubmit(event: SubmitEvent) {
event.preventDefault();
clearTimeout(searchTimeout);
resultsOpen = false;
const q = query.trim();
goto(q ? `/explore?q=${encodeURIComponent(q)}` : '/explore');
}
function onDocumentClick(event: MouseEvent) { function onDocumentClick(event: MouseEvent) {
if (menuRef && !menuRef.contains(event.target as Node)) menuOpen = false; if (menuRef && !menuRef.contains(event.target as Node)) menuOpen = false;
if (searchRef && !searchRef.contains(event.target as Node)) resultsOpen = false;
} }
</script> </script>
<header <header class="hidden items-center gap-3.5 px-6.5 pt-4 pb-3 sm:flex">
class="sticky top-0 z-30 hidden grid-cols-[1fr_minmax(0,3fr)_1fr] items-center gap-4 border-b border-line bg-bg/70 px-8 py-4 backdrop-blur-lg md:grid" <div class="flex shrink-0 gap-1.5">
>
<div></div>
<div class="relative mx-auto w-full max-w-2xl" bind:this={searchRef}>
<form onsubmit={onSubmit}>
<label
class="flex w-full items-center gap-2.5 rounded-full border border-line bg-surface px-4 py-2.5 transition focus-within:border-accent-soft/60"
>
<Search class="h-4 w-4 shrink-0 text-fg-2" />
<input
type="text"
bind:this={searchInput}
bind:value={query}
oninput={onInput}
onfocus={() => (resultsOpen = results.length > 0)}
placeholder="Buscar"
class="w-full bg-transparent text-sm text-fg placeholder:text-fg-2 focus:outline-none"
/>
{#if query}
<button <button
type="button" type="button"
onclick={clearQuery} onclick={() => history.back()}
aria-label="Borrar búsqueda" aria-label="Atrás"
class="grid h-5 w-5 shrink-0 place-items-center rounded-full text-fg-2 transition hover:text-fg" class="grid h-7.5 w-7.5 place-items-center rounded-full bg-white/[3.5%] text-fg-2 transition-colors hover:bg-white/7 hover:text-fg"
> >
<X class="h-3.5 w-3.5" /> <ChevronLeft class="h-[15px] w-[15px]" strokeWidth={1.9} />
</button> </button>
{/if} <span
</label> aria-hidden="true"
</form> class="grid h-7.5 w-7.5 place-items-center rounded-full bg-white/[3.5%] text-muted"
{#if resultsOpen && (results.length > 0 || searching)}
<div
class="animate-pop absolute top-full right-0 left-0 z-20 mt-2 overflow-hidden rounded-panel border border-line bg-elevated p-1.5 shadow-menu"
> >
{#if searching && results.length === 0} <ChevronRight class="h-[15px] w-[15px]" strokeWidth={1.9} />
<p class="px-3 py-2.5 text-sm text-fg-2">Buscando…</p> </span>
{:else}
{#each results as target, i (targetId(target))}
<button
type="button"
onclick={() => playResult(i)}
class="group/result flex w-full min-w-0 items-center gap-3 rounded-control p-2 text-left transition hover:bg-hover"
>
<Cover
trackId={targetId(target)}
src={targetCoverSrc(target)}
size="small"
alt={targetTitle(target)}
class="h-9 w-9 shrink-0 rounded-control shadow-art"
/>
<div class="min-w-0 flex-1">
<div class="flex min-w-0 items-center gap-1.5">
{#if targetExplicit(target)}
<ExplicitBadge />
{/if}
<span class="truncate text-sm text-fg">{targetTitle(target)}</span>
</div>
{#if targetArtist(target)}
<div class="truncate text-xs text-fg-2">{targetArtist(target)}</div>
{/if}
</div>
</button>
{/each}
{/if}
</div>
{/if}
</div> </div>
<div class="flex items-center justify-end gap-6"> <SearchBox class="max-w-[480px] flex-1" />
<div class="flex-1"></div>
<div class="relative shrink-0" bind:this={menuRef}>
{#if user} {#if user}
<div class="relative" bind:this={menuRef}>
<button <button
type="button" type="button"
onclick={() => (menuOpen = !menuOpen)} onclick={() => (menuOpen = !menuOpen)}
aria-label="Tu cuenta" aria-label="Tu cuenta"
class="block shrink-0 rounded-full transition active:scale-95" class="block h-8.25 w-8.25 shrink-0 overflow-hidden rounded-full border border-white/10 transition active:scale-95"
> >
{#if user.picture} {#if user.picture}
<img src={user.picture} alt="" class="h-9 w-9 rounded-full object-cover" /> <img src={user.picture} alt="" class="h-full w-full object-cover opacity-90" />
{:else} {:else}
<span <span
class="grid h-9 w-9 place-items-center rounded-full bg-surface-2 text-sm font-semibold text-fg uppercase" class="grid h-full w-full place-items-center bg-surface-2 text-xs font-semibold text-fg uppercase"
> >
{user.name.charAt(0)} {user.name.charAt(0)}
</span> </span>
{/if} {/if}
</button> </button>
{#if menuOpen} {#if menuOpen}
<div <GlassMenu class="absolute top-full right-0 z-20 mt-2 w-52 overflow-hidden">
class="animate-pop absolute top-full right-0 z-20 mt-2 w-40 overflow-hidden rounded-panel border border-line bg-elevated p-1.5 shadow-menu" <MenuItem
> icon={User}
label="Ver perfil"
href="/u/{user.sub}"
onclick={() => (menuOpen = false)}
/>
{#if accountUrl}
<MenuItem
icon={Settings}
label="Ajustes"
href={accountUrl}
target="_blank"
onclick={() => (menuOpen = false)}
/>
{/if}
<MenuItem
icon={themeMode.current === 'dark' ? Sun : Moon}
label={themeMode.current === 'dark' ? 'Modo blanco' : 'Modo oscuro'}
onclick={() => themeMode.toggle()}
/>
<form method="POST" action="/logout" data-sveltekit-reload> <form method="POST" action="/logout" data-sveltekit-reload>
<MenuItem icon={LogOut} label="Salir" type="submit" /> <MenuItem icon={LogOut} label="Cerrar sesión" type="submit" />
</form> </form>
</div> </GlassMenu>
{/if} {/if}
</div>
{:else} {:else}
<a <a
href="/auth/login" href="/auth/login"
data-sveltekit-reload data-sveltekit-reload
class="shrink-0 rounded-full bg-accent-soft px-4 py-2 text-sm font-semibold text-on-accent transition hover:brightness-110" class="shrink-0 rounded-full bg-accent-soft px-4 py-2 text-sm font-semibold text-ink transition hover:brightness-110"
> >
Iniciar sesión Iniciar sesión
</a> </a>
@@ -22,6 +22,7 @@
import ListPlus from '@lucide/svelte/icons/list-plus'; import ListPlus from '@lucide/svelte/icons/list-plus';
import ChevronRight from '@lucide/svelte/icons/chevron-right'; import ChevronRight from '@lucide/svelte/icons/chevron-right';
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte'; import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
import GlassMenu from '$lib/components/ui/GlassMenu.svelte';
interface Props { interface Props {
menu: ContextMenuState; menu: ContextMenuState;
@@ -48,24 +49,21 @@
onClose(); onClose();
}} }}
></div> ></div>
<div <GlassMenu class="fixed z-40 w-60" style="left:{menu.x}px; top:{menu.y}px;">
class="fixed z-40 w-60 rounded-panel border border-line bg-elevated p-1.5 shadow-menu"
style="left:{menu.x}px; top:{menu.y}px;"
>
<button <button
type="button" type="button"
onclick={onAddToQueue} onclick={onAddToQueue}
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-sm text-fg-2 transition hover:bg-hover" class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-[13.5px] font-medium text-fg-2 transition hover:bg-hover"
> >
<ListPlus class="h-5 w-5 shrink-0" strokeWidth={2} /> <ListPlus class="h-[18px] w-[18px] shrink-0" strokeWidth={2} />
<span class="text-base">Reproducir a continuación</span> <span>Reproducir a continuación</span>
</button> </button>
{#if playlists.length > 0} {#if playlists.length > 0}
<div class="group/addmenu relative"> <div class="group/addmenu relative">
<div <div
class="flex items-center gap-3 rounded-control px-2 py-2.5 text-sm text-fg-2 transition group-hover/addmenu:bg-hover" class="flex items-center gap-3 rounded-control px-2 py-2.5 text-[13.5px] font-medium text-fg-2 transition group-hover/addmenu:bg-hover"
> >
<span class="flex-1 cursor-default text-base">Añadir a una playlist</span> <span class="flex-1 cursor-default">Añadir a una playlist</span>
<ChevronRight class="h-4 w-4 shrink-0 text-fg-3" /> <ChevronRight class="h-4 w-4 shrink-0 text-fg-3" />
</div> </div>
<div <div
@@ -99,7 +97,7 @@
{/if} {/if}
<button <button
type="submit" type="submit"
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-base text-fg transition hover:bg-hover" class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-[13.5px] font-medium text-fg transition hover:bg-hover"
> >
<PlaylistArt <PlaylistArt
playlistId={playlist.id} playlistId={playlist.id}
@@ -116,4 +114,4 @@
</div> </div>
</div> </div>
{/if} {/if}
</div> </GlassMenu>
@@ -2,44 +2,27 @@
import TrackInfo from './TrackInfo.svelte'; import TrackInfo from './TrackInfo.svelte';
import TransportControls from './TransportControls.svelte'; import TransportControls from './TransportControls.svelte';
import SeekBar from './SeekBar.svelte'; import SeekBar from './SeekBar.svelte';
import VolumeControl from './VolumeControl.svelte'; import PlayerExtras from './PlayerExtras.svelte';
import Alert from '$lib/components/ui/Alert.svelte';
import { player } from '$lib/player/player.svelte';
</script> </script>
{#if player.error}
<div <div
class="fixed inset-x-0 z-40 page-x py-2 md:left-[var(--mf-sidebar-w)] md:z-50" class="animate-enter pointer-events-auto flex items-center gap-4.5 rounded-[18px] border border-hairline bg-[image:var(--mf-bar-bg)] px-4 py-2.75 shadow-player backdrop-blur-[22px] transition-[background] duration-[600ms] sm:hidden"
style="bottom:calc(var(--mf-nav-h) + var(--mf-safe-b) + var(--mf-player-h))"
> >
<Alert tone="danger">{player.error}</Alert>
</div>
{/if}
<div
class="animate-enter fixed right-0 left-0 z-40 h-[var(--mf-player-h)] border-t border-line bg-bg md:left-[var(--mf-sidebar-w)] md:z-50"
style="bottom:calc(var(--mf-nav-h) + var(--mf-safe-b))"
>
<div class="absolute inset-x-0 top-0 md:hidden">
<SeekBar compact />
</div>
<div class="flex h-full items-center gap-3 page-x md:hidden">
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<TrackInfo /> <TrackInfo compact />
</div> </div>
<TransportControls compact /> <TransportControls compact />
</div> </div>
<div <div
class="hidden h-full items-center gap-5 px-8 md:grid" class="animate-enter pointer-events-auto hidden items-center gap-5 rounded-[18px] border border-hairline bg-[image:var(--mf-bar-bg)] px-4 py-2.75 shadow-player backdrop-blur-[22px] transition-[background] duration-[600ms] sm:flex"
style="grid-template-columns:minmax(200px,1fr) minmax(0,3fr) minmax(200px,1fr)"
> >
<div class="w-64 min-w-0 shrink-0">
<TrackInfo /> <TrackInfo />
<div class="flex flex-col items-center gap-2"> </div>
<div class="flex min-w-0 flex-1 flex-col items-center gap-2">
<TransportControls /> <TransportControls />
<SeekBar /> <SeekBar />
</div> </div>
<VolumeControl /> <PlayerExtras />
</div>
</div> </div>
@@ -0,0 +1,29 @@
<script lang="ts">
import PlayerBar from './PlayerBar.svelte';
import TabsBar from '../TabsBar.svelte';
import Alert from '$lib/components/ui/Alert.svelte';
import { player } from '$lib/player/player.svelte';
import type { SessionUser } from '$lib/types';
interface Props {
user: SessionUser | null;
}
let { user }: Props = $props();
const hasTrack = $derived(player.currentId !== null);
</script>
<div
class="pointer-events-none fixed inset-x-0 bottom-0 z-40 flex flex-col gap-2.5 px-4 pb-4 lg:left-[var(--mf-sidebar-w)]"
>
{#if player.error}
<div class="pointer-events-auto">
<Alert tone="danger">{player.error}</Alert>
</div>
{/if}
{#if hasTrack}
<PlayerBar />
{/if}
<TabsBar {user} />
</div>
@@ -0,0 +1,22 @@
<script lang="ts">
import VolumeControl from './VolumeControl.svelte';
import { queuePanel } from '$lib/player/queuePanel.svelte';
import AlignJustify from '@lucide/svelte/icons/align-justify';
import ListMusic from '@lucide/svelte/icons/list-music';
</script>
<div class="hidden w-64 shrink-0 items-center justify-end gap-3.5 sm:flex">
<span aria-hidden="true" class="text-fg-3">
<AlignJustify class="h-[15px] w-[15px]" strokeWidth={1.8} />
</span>
<VolumeControl />
<button
type="button"
onclick={() => queuePanel.toggle()}
aria-pressed={queuePanel.open}
aria-label="Cola de reproducción"
class="transition-colors hover:text-fg {queuePanel.open ? 'text-fg' : 'text-fg-2'}"
>
<ListMusic class="h-4 w-4" strokeWidth={1.8} />
</button>
</div>
@@ -0,0 +1,117 @@
<script lang="ts">
import { player } from '$lib/player/player.svelte';
import { queuePanel } from '$lib/player/queuePanel.svelte';
import Cover from '$lib/components/ui/Cover.svelte';
import ArtistLink from '$lib/components/ui/ArtistLink.svelte';
import { fmtTime } from '$lib/format';
import X from '@lucide/svelte/icons/x';
import { fly } from 'svelte/transition';
import { cubicOut } from 'svelte/easing';
const upcoming = $derived.by(() => {
const idx = player.tracks.findIndex((t) => t.id === player.currentId);
return idx === -1 ? player.tracks : player.tracks.slice(idx + 1);
});
</script>
{#if queuePanel.open}
<aside
transition:fly={{ x: 24, duration: 220, easing: cubicOut }}
class="hidden shrink-0 flex-col border-l border-hairline bg-[image:var(--mf-panel-bg)] px-4 pt-5 transition-[background] duration-[600ms] 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">
<h2 class="font-display text-[14.5px] font-semibold tracking-[-0.02em] text-fg">En cola</h2>
<button
type="button"
onclick={() => queuePanel.close()}
aria-label="Cerrar cola"
class="text-base text-fg-3 transition-colors hover:text-fg"
>
<X class="h-4 w-4" />
</button>
</div>
{#if player.current.id}
<p class="mb-2.75 text-[10.5px] font-semibold tracking-[0.12em] text-muted uppercase">
Reproduciendo
</p>
<div class="mb-6 flex items-center gap-3 rounded-xl bg-accent-tint p-2.5">
<Cover
trackId={player.current.id}
src={player.current.coverUrl}
size="small"
alt={player.current.title}
class="h-10 w-10 shrink-0 rounded-[9px]"
/>
<div class="min-w-0">
<div class="truncate text-[12.5px] font-semibold text-accent-soft">
{player.current.title}
</div>
<ArtistLink
name={player.current.artist || '—'}
ownerUserId={player.current.ownerUserId}
class="mt-0.75 text-[11px] text-fg-3"
/>
</div>
</div>
{/if}
<div class="mb-2.75 flex items-center justify-between gap-3">
<span
class="min-w-0 flex-1 truncate text-[10.5px] font-semibold tracking-[0.12em] text-muted uppercase"
>
A continuación
</span>
{#if upcoming.length > 0}
<button
type="button"
onclick={() => player.clearUpcoming()}
class="shrink-0 text-[11px] text-muted transition-colors hover:text-fg"
>
Vaciar
</button>
{/if}
</div>
<div class="flex flex-1 flex-col gap-px overflow-y-auto">
{#each upcoming as track, i (track.id)}
{@const index = player.tracks.length - upcoming.length + i}
<div
role="button"
tabindex="0"
onclick={() => player.playQueueIndex(index)}
onkeydown={(e) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
player.playQueueIndex(index);
}}
class="flex items-center gap-3 rounded-[11px] p-2.25 text-left hover:bg-hover"
>
<Cover
trackId={track.id}
src={track.coverUrl}
size="small"
alt={track.title}
class="h-9 w-9 shrink-0 rounded-lg opacity-90"
/>
<div class="min-w-0 flex-1">
<div class="truncate text-[12.5px] font-medium text-fg">{track.title}</div>
<ArtistLink
name={track.artist || '—'}
ownerUserId={track.ownerUserId}
class="mt-0.5 text-[11px] text-fg-3"
/>
</div>
{#if track.duration}
<span class="shrink-0 text-[11px] text-muted tabular-nums"
>{fmtTime(track.duration)}</span
>
{/if}
</div>
{:else}
<p class="px-2 py-6 text-center text-xs text-fg-3">No hay más canciones en la cola.</p>
{/each}
</div>
</aside>
{/if}
@@ -27,26 +27,21 @@
></div> ></div>
</button> </button>
{:else} {:else}
<div class="flex w-full max-w-172 items-center gap-3"> <div class="flex w-full max-w-135 items-center gap-2.75">
<span class="text-right text-xs text-muted tabular-nums"> <span class="min-w-8 text-right text-[10.5px] text-fg-3 tabular-nums">
{fmtTime(player.progress)} {fmtTime(player.progress)}
</span> </span>
<button <button
type="button" type="button"
onclick={seek} onclick={seek}
aria-label="Barra de progreso" aria-label="Barra de progreso"
class="group relative h-1.5 flex-1 cursor-pointer rounded-full bg-track" class="group relative h-3.5 flex-1 cursor-pointer"
> >
<div <div class="h-[3.5px] w-full overflow-hidden rounded-full bg-track">
class="absolute inset-y-0 left-0 rounded-full bg-accent/60 group-hover:brightness-110" <div class="h-full rounded-full bg-accent" style="width:{player.progressPercent}%"></div>
style="width:{player.progressPercent}%" </div>
></div>
<span
class="pointer-events-none absolute top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-on-art opacity-0 shadow-[0_1px_4px_rgba(0,0,0,0.5)] transition-opacity duration-75 group-hover:opacity-100"
style="left:{player.progressPercent}%"
></span>
</button> </button>
<span class="text-xs text-muted tabular-nums"> <span class="min-w-8 text-[10.5px] text-fg-3 tabular-nums">
{fmtTime(player.current.duration)} {fmtTime(player.current.duration)}
</span> </span>
</div> </div>
@@ -1,30 +1,71 @@
<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 Cover from '$lib/components/ui/Cover.svelte'; import Cover from '$lib/components/ui/Cover.svelte';
import ExplicitBadge from '$lib/components/ui/ExplicitBadge.svelte'; import ExplicitBadge from '$lib/components/ui/ExplicitBadge.svelte';
import ArtistLink from '$lib/components/ui/ArtistLink.svelte';
import Heart from '@lucide/svelte/icons/heart';
interface Props {
compact?: boolean;
}
let { compact = false }: Props = $props();
const isLiked = $derived(player.current.id !== '' && liked.isLiked(player.current.id));
function toggleLike() {
if (!player.current.id) return;
liked.toggle({
id: player.current.id,
title: player.current.title,
artist: player.current.artist,
coverUrl: player.current.coverUrl,
source: player.current.source,
explicit: player.current.explicit,
ownerUserId: player.current.ownerUserId
});
}
</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.25">
{#if player.current.id} {#if player.current.id}
<Cover <Cover
trackId={player.current.id} trackId={player.current.id}
src={player.current.coverUrl} src={player.current.coverUrl}
size="small" size="small"
alt={player.current.title} alt={player.current.title}
class="h-11 w-11 shrink-0 rounded-lg shadow-[0_6px_16px_-6px_rgba(0,0,0,0.6)] md:h-13 md:w-13" class="h-11 w-11 shrink-0 rounded-[11px] shadow-[0_8px_20px_rgba(0,0,0,0.5)] md:h-12 md:w-12"
/> />
{:else} {:else}
<div <div
class="h-11 w-11 shrink-0 rounded-lg bg-surface shadow-[0_6px_16px_-6px_rgba(0,0,0,0.6)] md:h-13 md:w-13" class="h-11 w-11 shrink-0 rounded-[11px] bg-surface shadow-[0_8px_20px_rgba(0,0,0,0.5)] md:h-12 md:w-12"
></div> ></div>
{/if} {/if}
<div class="min-w-0"> <div class="min-w-0 flex-1">
<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 player.current.explicit}
<ExplicitBadge /> <ExplicitBadge />
{/if} {/if}
<span class="truncate text-fg">{player.current.title}</span> <span class="truncate text-[13px] font-semibold tracking-[-0.01em] text-fg"
>{player.current.title}</span
>
</div> </div>
<div class="truncate text-sm text-muted">{player.current.artist || '—'}</div> <ArtistLink
name={player.current.artist || '—'}
ownerUserId={player.current.ownerUserId}
class="mt-0.5 text-[11.5px] text-fg-3"
/>
</div> </div>
{#if !compact}
<button
type="button"
onclick={toggleLike}
aria-label={isLiked ? 'Quitar de Me gusta' : 'Añadir a Me gusta'}
aria-pressed={isLiked}
class="shrink-0 transition hover:opacity-80 {isLiked ? 'text-accent' : 'text-fg-3'}"
>
<Heart class="h-4 w-4" fill={isLiked ? 'currentColor' : 'none'} strokeWidth={1.7} />
</button>
{/if}
</div> </div>
@@ -1,5 +1,7 @@
<script lang="ts"> <script lang="ts">
import { player } from '$lib/player/player.svelte'; import { player } from '$lib/player/player.svelte';
import Shuffle from '@lucide/svelte/icons/shuffle';
import Repeat from '@lucide/svelte/icons/repeat';
interface Props { interface Props {
compact?: boolean; compact?: boolean;
@@ -10,11 +12,23 @@
const sideClass = $derived( const sideClass = $derived(
compact compact
? 'grid h-10 w-10 place-items-center text-fg opacity-70 transition active:scale-90' ? 'grid h-10 w-10 place-items-center text-fg opacity-70 transition active:scale-90'
: 'flex text-fg opacity-70 transition hover:opacity-100 active:scale-90' : 'flex text-fg-2 transition-colors hover:text-fg active:scale-90'
); );
</script> </script>
<div class="flex shrink-0 items-center {compact ? 'gap-0.5' : 'gap-5'}"> <div class="flex shrink-0 items-center {compact ? 'gap-0.5' : 'gap-3.5'}">
{#if !compact}
<button
type="button"
onclick={() => player.toggleShuffle()}
aria-pressed={player.shuffle}
aria-label="Aleatorio"
class="transition-colors hover:text-fg {player.shuffle ? 'text-accent' : 'text-fg-3'}"
>
<Shuffle class="h-[15px] w-[15px]" strokeWidth={1.8} />
</button>
{/if}
<button type="button" onclick={() => player.previous()} aria-label="Anterior" class={sideClass}> <button type="button" onclick={() => player.previous()} aria-label="Anterior" class={sideClass}>
<svg width="16" height="14" viewBox="0 0 16 14" fill="currentColor"> <svg width="16" height="14" viewBox="0 0 16 14" fill="currentColor">
<rect x="0" y="0" width="2.5" height="14" rx="1" /> <rect x="0" y="0" width="2.5" height="14" rx="1" />
@@ -26,16 +40,16 @@
type="button" type="button"
onclick={() => player.toggle()} onclick={() => player.toggle()}
aria-label={player.playing ? 'Pausar' : 'Reproducir'} aria-label={player.playing ? 'Pausar' : 'Reproducir'}
class="grid h-[36px] w-[36px] shrink-0 place-items-center rounded-full bg-fg text-bg shadow-lg transition duration-100 active:scale-95" class="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-cta-strong text-ink transition duration-150 hover:brightness-95"
> >
{#if player.playing} {#if player.playing}
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor"> <svg width="15" height="15" viewBox="0 0 15 15" fill="currentColor">
<rect x="1.5" y="0" width="3.5" height="12" rx="1" /> <rect x="3" y="1" width="3" height="13" rx="1" />
<rect x="7" y="0" width="3.5" height="12" rx="1" /> <rect x="9" y="1" width="3" height="13" rx="1" />
</svg> </svg>
{:else} {:else}
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor"> <svg width="15" height="15" viewBox="0 0 15 15" fill="currentColor">
<path d="M2.5 0 12 6l-9.5 6z" /> <path d="M3 1.5v12l10-6z" />
</svg> </svg>
{/if} {/if}
</button> </button>
@@ -46,4 +60,16 @@
<rect x="13.5" y="0" width="2.5" height="14" rx="1" /> <rect x="13.5" y="0" width="2.5" height="14" rx="1" />
</svg> </svg>
</button> </button>
{#if !compact}
<button
type="button"
onclick={() => player.toggleRepeat()}
aria-pressed={player.repeat}
aria-label="Repetir"
class="transition-colors hover:text-fg {player.repeat ? 'text-accent' : 'text-fg-3'}"
>
<Repeat class="h-[15px] w-[15px]" strokeWidth={1.8} />
</button>
{/if}
</div> </div>
@@ -1,14 +1,15 @@
<script lang="ts"> <script lang="ts">
import { player } from '$lib/player/player.svelte'; import { player } from '$lib/player/player.svelte';
import Volume2 from '@lucide/svelte/icons/volume-2';
function handleVolumeChange(event: Event) { function handleVolumeChange(event: Event) {
const target = event.currentTarget as HTMLInputElement; const target = event.currentTarget as HTMLInputElement;
const newVolume = Number(target.value); player.setVolume(Number(target.value));
player.setVolume(newVolume);
} }
</script> </script>
<div class="flex items-center justify-end gap-3.5"> <div class="flex items-center gap-2.25">
<Volume2 class="h-[15px] w-[15px] shrink-0 text-fg-3" strokeWidth={1.8} />
<input <input
type="range" type="range"
min="0" min="0"
@@ -17,7 +18,7 @@
oninput={handleVolumeChange} oninput={handleVolumeChange}
aria-label="Volumen" aria-label="Volumen"
class="mf-volume" class="mf-volume"
style="background:linear-gradient(to right, var(--color-accent-soft) {player.volume}%, var(--color-track) {player.volume}%)" style="background:linear-gradient(to right, var(--mf-text-2) {player.volume}%, var(--mf-track) {player.volume}%)"
/> />
</div> </div>
@@ -25,8 +26,8 @@
.mf-volume { .mf-volume {
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; appearance: none;
width: 80px; width: 84px;
height: 4px; height: 3.5px;
border-radius: 100px; border-radius: 100px;
outline: none; outline: none;
cursor: pointer; cursor: pointer;
@@ -34,17 +35,17 @@
.mf-volume::-webkit-slider-thumb { .mf-volume::-webkit-slider-thumb {
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; appearance: none;
width: 12px; width: 11px;
height: 12px; height: 11px;
border-radius: 50%; border-radius: 50%;
background: var(--color-fg); background: var(--mf-text);
cursor: pointer; cursor: pointer;
} }
.mf-volume::-moz-range-thumb { .mf-volume::-moz-range-thumb {
width: 12px; width: 11px;
height: 12px; height: 11px;
border-radius: 50%; border-radius: 50%;
background: var(--color-fg); background: var(--mf-text);
border: none; border: none;
cursor: pointer; cursor: pointer;
} }
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import Disc3 from '@lucide/svelte/icons/disc-3';
import ImageDropzone from './ImageDropzone.svelte'; import ImageDropzone from './ImageDropzone.svelte';
import Button from './Button.svelte'; import Button from './Button.svelte';
import Field from './Field.svelte'; import Field from './Field.svelte';
@@ -59,11 +60,18 @@
if (result.type === 'success') onSuccess?.(); if (result.type === 'success') onSuccess?.();
}; };
}} }}
class="mt-4 w-full max-w-2xl space-y-4" class="mt-5 w-full max-w-2xl space-y-5"
> >
<div class="flex flex-col items-stretch gap-5 sm:flex-row sm:gap-8"> <div class="flex flex-col items-stretch gap-5 sm:flex-row sm:gap-8">
<Field label="Portada" optional={!requireCover}> <Field>
<ImageDropzone name="cover" fallbackUrl={coverFallbackUrl} required={requireCover} /> <ImageDropzone
name="cover"
fallbackUrl={coverFallbackUrl}
required={requireCover}
icon={Disc3}
gradient
class="h-36 w-36 rounded-[16px]"
/>
</Field> </Field>
<div class="flex flex-1 flex-col gap-4"> <div class="flex flex-1 flex-col gap-4">
@@ -78,7 +86,7 @@
/> />
</Field> </Field>
<Field label="Año de publicación" for="album-year" optional> <Field label="Año de publicación" for="album-year">
<Input <Input
id="album-year" id="album-year"
name="releaseYear" name="releaseYear"
@@ -92,7 +100,7 @@
</div> </div>
</div> </div>
<Field label="Descripción" for="album-description" optional> <Field label="Descripción" for="album-description">
<Textarea <Textarea
id="album-description" id="album-description"
name="description" name="description"
@@ -11,10 +11,14 @@
const tones = { const tones = {
neutral: 'border-line bg-surface text-fg-2', neutral: 'border-line bg-surface text-fg-2',
danger: 'border-danger/40 bg-danger/10 text-danger-fg' danger: 'border-danger/20 bg-danger/10 text-danger-fg'
}; };
</script> </script>
<p class="rounded-control border px-4 py-3 text-base {tones[tone]} {klass}"> <p
class="rounded-control border px-4 py-3 text-sm shadow-menu backdrop-blur-md {tones[
tone
]} {klass}"
>
{@render children()} {@render children()}
</p> </p>
@@ -1,10 +1,11 @@
<script lang="ts"> <script lang="ts">
interface Props { interface Props {
name: string; name: string;
imageUrl?: string | null;
size?: number; size?: number;
} }
let { name, size = 108 }: Props = $props(); let { name, imageUrl, size = 108 }: Props = $props();
const initials = $derived( const initials = $derived(
name name
@@ -21,11 +22,15 @@
class="relative grid shrink-0 place-items-center overflow-hidden rounded-full bg-surface shadow-art ring-1 ring-line transition duration-300 ease-out ring-inset group-hover/artist:shadow-art-lg" class="relative grid shrink-0 place-items-center overflow-hidden rounded-full bg-surface shadow-art ring-1 ring-line transition duration-300 ease-out ring-inset group-hover/artist:shadow-art-lg"
style="width:{size}px;height:{size}px" style="width:{size}px;height:{size}px"
> >
{#if imageUrl}
<img src={imageUrl} alt="" class="h-full w-full object-cover" />
{:else}
<div <div
class="pointer-events-none absolute inset-0" class="pointer-events-none absolute inset-0"
style="background:repeating-linear-gradient(135deg, rgba(255,255,255,0.12) 0 2px, transparent 2px 10px)" style="background:repeating-linear-gradient(135deg, rgba(255,255,255,0.12) 0 2px, transparent 2px 10px)"
></div> ></div>
<span class="relative text-lg font-semibold text-white/90">{initials}</span> <span class="relative text-lg font-semibold text-white/90">{initials}</span>
{/if}
</div> </div>
<div class="w-full truncate text-sm text-fg">{name}</div> <div class="w-full truncate text-sm text-fg">{name}</div>
</div> </div>
@@ -0,0 +1,23 @@
<script lang="ts">
interface Props {
name?: string | null;
ownerUserId?: string | number | null;
class?: string;
}
let { name, ownerUserId, class: klass = '' }: Props = $props();
</script>
{#if name}
{#if ownerUserId}
<a
href="/u/{ownerUserId}"
onclick={(e) => e.stopPropagation()}
class="inline-block max-w-full truncate align-top hover:text-fg-2 hover:underline {klass}"
>
{name}
</a>
{:else}
<span class="block truncate {klass}">{name}</span>
{/if}
{/if}
+10 -8
View File
@@ -3,7 +3,7 @@
import LoaderCircle from '@lucide/svelte/icons/loader-circle'; import LoaderCircle from '@lucide/svelte/icons/loader-circle';
interface Props { interface Props {
variant?: 'primary' | 'secondary' | 'subtle' | 'danger'; variant?: 'primary' | 'secondary' | 'accent' | 'danger';
size?: 'sm' | 'md' | 'lg'; size?: 'sm' | 'md' | 'lg';
type?: 'button' | 'submit'; type?: 'button' | 'submit';
href?: string; href?: string;
@@ -29,20 +29,22 @@
}: Props = $props(); }: Props = $props();
const sizes = { const sizes = {
sm: 'px-4 py-2 text-base', sm: 'px-4 py-2.5 text-[12.5px]',
md: 'px-5 py-2.5 text-base', md: 'px-5 py-3 text-[13px]',
lg: 'px-8 py-3.5 text-base' lg: 'px-6 py-3.5 text-sm'
}; };
const subtleButton = 'bg-white/[6%] text-fg-2 hover:bg-white/[9%] hover:text-fg';
const variants = { const variants = {
primary: 'bg-accent-soft text-on-accent active:scale-[0.97]', primary: subtleButton,
secondary: 'bg-bg text-fg ring-1 ring-line hover:bg-surface', secondary: subtleButton,
subtle: 'bg-white/10 text-fg hover:bg-accent/18', accent: 'bg-accent-btn text-accent-soft hover:bg-accent-btn-hover',
danger: 'bg-danger text-on-accent hover:brightness-110' danger: 'bg-danger text-on-accent hover:brightness-110'
}; };
const classes = $derived( const classes = $derived(
`inline-flex items-center justify-center gap-2 rounded-control font-medium 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 rounded-xl font-semibold 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}`
); );
</script> </script>
@@ -12,7 +12,7 @@
let { checked = $bindable(false), name, class: klass = '', children }: Props = $props(); let { checked = $bindable(false), name, class: klass = '', children }: Props = $props();
</script> </script>
<label class="flex items-center gap-3 text-base text-fg-2 {klass}"> <label class="flex items-center gap-3 text-sm text-fg-2 {klass}">
<span class="relative inline-grid h-5 w-5 shrink-0 place-items-center"> <span class="relative inline-grid h-5 w-5 shrink-0 place-items-center">
<input <input
type="checkbox" type="checkbox"
@@ -21,7 +21,7 @@
class="peer h-5 w-5 cursor-pointer appearance-none rounded 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 h-5 w-5 cursor-pointer appearance-none rounded 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 h-3.5 w-3.5 text-on-accent opacity-0 transition peer-checked:opacity-100" class="pointer-events-none absolute h-3.5 w-3.5 text-ink opacity-0 transition peer-checked:opacity-100"
strokeWidth={3} strokeWidth={3}
/> />
</span> </span>
@@ -1,14 +0,0 @@
<script lang="ts">
interface Props {
label: string;
class?: string;
}
let { label, class: klass = '' }: Props = $props();
</script>
<span
class="pointer-events-none absolute top-2 left-2 rounded-full bg-black/45 px-2 py-0.5 text-[10px] font-semibold tracking-[0.08em] text-white uppercase backdrop-blur-sm {klass}"
>
{label}
</span>
@@ -13,24 +13,19 @@
let { icon: Icon, title, description, class: klass = '', actions }: Props = $props(); let { icon: Icon, title, description, class: klass = '', actions }: Props = $props();
</script> </script>
<div <div class="flex flex-col items-center rounded-panel px-6 py-16 text-center {klass}">
class="flex flex-col items-center rounded-panel border border-dashed border-line px-6 py-16 text-center {klass}"
>
{#if Icon} {#if Icon}
<span <Icon class="h-9 w-9 text-fg-2" strokeWidth={1.3} />
class="grid h-16 w-16 place-items-center rounded-full text-on-accent"
style="background:radial-gradient(circle at 30% 30%, color-mix(in oklch, var(--color-accent), white 15%), var(--color-accent))"
>
<Icon class="h-7 w-7" />
</span>
{/if} {/if}
{#if title} {#if title}
<h3 class="mt-6 font-display text-2xl font-medium tracking-tight text-fg">{title}</h3> <h3 class="mt-6 font-display text-xl font-semibold tracking-[-0.025em] text-fg">
{title}
</h3>
{/if} {/if}
{#if description} {#if description}
<p class="mt-2 max-w-sm text-fg-2">{description}</p> <p class="mt-2.5 max-w-sm text-[13px] leading-[1.65] text-fg-2">{description}</p>
{/if} {/if}
{#if actions} {#if actions}
<div class="mt-6 flex items-center gap-3">{@render actions()}</div> <div class="mt-6 flex flex-wrap items-center justify-center gap-2.5">{@render actions()}</div>
{/if} {/if}
</div> </div>
@@ -0,0 +1,23 @@
<script lang="ts">
interface Props {
size?: number;
barWidth?: number;
paused?: boolean;
class?: string;
}
let { size = 13, barWidth = 2, paused = false, class: klass = '' }: Props = $props();
const delays = [0, 0.3, 0.6];
</script>
<div class="flex items-end justify-center gap-[2px] {klass}" style="height:{size}px">
{#each delays as delay (delay)}
<span
class="rounded-full bg-accent"
style="width:{barWidth}px; height:100%; animation:equalize .9s ease-in-out {delay}s infinite; animation-play-state:{paused
? 'paused'
: 'running'}"
></span>
{/each}
</div>
@@ -2,25 +2,25 @@
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
interface Props { interface Props {
label: string; label?: string;
for?: string; for?: string;
optional?: boolean;
class?: string; class?: string;
hint?: Snippet; hint?: Snippet;
children: Snippet; children: Snippet;
} }
let { label, for: forId, optional = false, class: klass = '', hint, children }: Props = $props(); let { label, for: forId, class: klass = '', hint, children }: Props = $props();
</script> </script>
<div class="flex min-w-0 flex-col {klass}"> <div class="flex min-w-0 flex-col {klass}">
<label for={forId} class="mb-2 block text-base font-medium text-fg-2"> {#if label}
<label for={forId} class="mb-2 block text-sm font-medium text-fg-2">
{label} {label}
{#if optional}<span class="text-muted">(Opcional)</span>{/if}
</label> </label>
{/if}
{@render children()} {@render children()}
{#if hint} {#if hint}
<div class="mt-2 flex items-center justify-between gap-3 text-sm text-muted"> <div class="mt-2 flex items-center justify-between gap-3 text-xs text-muted">
{@render hint()} {@render hint()}
</div> </div>
{/if} {/if}
@@ -0,0 +1,15 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
class?: string;
style?: string;
children: Snippet;
}
let { class: klass = '', style = '', children }: Props = $props();
</script>
<div class="glass-panel animate-pop rounded-panel p-1.5 shadow-menu {klass}" {style}>
{@render children()}
</div>
@@ -1,10 +1,13 @@
<script lang="ts"> <script lang="ts">
import ImageIcon from '@lucide/svelte/icons/image'; import ImageIcon from '@lucide/svelte/icons/image';
import type { LucideIcon } from '@lucide/svelte';
interface Props { interface Props {
name: string; name: string;
fallbackUrl?: string; fallbackUrl?: string;
required?: boolean; required?: boolean;
icon?: LucideIcon;
gradient?: boolean;
class?: string; class?: string;
} }
@@ -12,13 +15,16 @@
name, name,
fallbackUrl, fallbackUrl,
required = false, required = false,
class: icon: Icon = ImageIcon,
klass = 'h-40 w-40 sm:h-52 sm:w-52 rounded-control border border-line bg-surface hover:border-accent/50' gradient = false,
class: klass = 'h-40 w-40 sm:h-52 sm:w-52 rounded-control'
}: Props = $props(); }: Props = $props();
let preview = $state(''); let preview = $state('');
let fallbackFailed = $state(false); let fallbackFailed = $state(false);
const hasImage = $derived(preview !== '' || (!!fallbackUrl && !fallbackFailed));
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) preview = URL.createObjectURL(file); if (file) preview = URL.createObjectURL(file);
@@ -26,7 +32,10 @@
</script> </script>
<label <label
class="relative grid cursor-pointer place-items-center overflow-hidden transition-colors {klass}" class="relative grid cursor-pointer place-items-center overflow-hidden transition-colors {gradient
? ''
: 'border border-line bg-surface hover:border-accent/50'} {klass}"
style={gradient && !hasImage ? 'background:var(--mf-cover-grad)' : undefined}
> >
<input <input
type="file" type="file"
@@ -48,6 +57,6 @@
class="h-full w-full object-cover" class="h-full w-full object-cover"
/> />
{:else} {:else}
<ImageIcon class="h-12 w-12 text-muted" /> <Icon class="h-9 w-9 {gradient ? 'text-white/70' : 'text-muted'}" strokeWidth={1.3} />
{/if} {/if}
</label> </label>

Some files were not shown because too many files have changed in this diff Show More