Checkpoint web-player design-system refactor and backend track genre tagging
Advances the ongoing token/componentization cleanup (anchored player dock, border-based panels instead of floating shadows, corrected light-mode accent/gradient tokens) alongside in-progress backend support for track genre tags.
This commit is contained in:
@@ -29,6 +29,7 @@ namespace Musify.Application.Albums
|
|||||||
var tracksQuery = database.AlbumHasTracks
|
var tracksQuery = database.AlbumHasTracks
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Include(albumTrack => albumTrack.Track.Owner)
|
.Include(albumTrack => albumTrack.Track.Owner)
|
||||||
|
.Include(albumTrack => albumTrack.Track.Tags)
|
||||||
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId);
|
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId);
|
||||||
|
|
||||||
var totalCount = await tracksQuery.CountAsync(cancellationToken);
|
var totalCount = await tracksQuery.CountAsync(cancellationToken);
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ namespace Musify.Application.Contracts
|
|||||||
|
|
||||||
DbSet<TrackLike> TrackLikes { get; }
|
DbSet<TrackLike> TrackLikes { get; }
|
||||||
|
|
||||||
|
DbSet<TrackTag> TrackTags { get; }
|
||||||
|
|
||||||
DbSet<UserFollow> UserFollows { get; }
|
DbSet<UserFollow> UserFollows { get; }
|
||||||
|
|
||||||
Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken);
|
Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken);
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ namespace Musify.Application.Likes
|
|||||||
var likesQuery = database.TrackLikes
|
var likesQuery = database.TrackLikes
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Include(like => like.Track.Owner)
|
.Include(like => like.Track.Owner)
|
||||||
|
.Include(like => like.Track.Tags)
|
||||||
.Where(like => like.UserId == request.UserId);
|
.Where(like => like.UserId == request.UserId);
|
||||||
|
|
||||||
var totalCount = await likesQuery.CountAsync(cancellationToken);
|
var totalCount = await likesQuery.CountAsync(cancellationToken);
|
||||||
|
|||||||
@@ -21,14 +21,20 @@ namespace Musify.Application.Mixes
|
|||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Include(track => track.Owner)
|
.Include(track => track.Owner)
|
||||||
.Where(track => trackIds.Contains(track.Id))
|
.Where(track => trackIds.Contains(track.Id))
|
||||||
.ToDictionaryAsync(track => track.Id, cancellationToken);
|
.Select(track => new { Track = track, ListensCount = track.ListeningHistories.Count })
|
||||||
|
.ToDictionaryAsync(x => x.Track.Id, cancellationToken);
|
||||||
|
|
||||||
return orderedItems
|
return orderedItems
|
||||||
.Where(item => tracksById.ContainsKey(item.TrackId))
|
.Where(item => tracksById.ContainsKey(item.TrackId))
|
||||||
.Select(item =>
|
.Select(item =>
|
||||||
{
|
{
|
||||||
var track = tracksById[item.TrackId];
|
var entry = tracksById[item.TrackId];
|
||||||
return new MixItemApplicationResponse(item.TrackId, track.Title, track.Owner.Name, track.DurationSeconds);
|
return new MixItemApplicationResponse(
|
||||||
|
item.TrackId,
|
||||||
|
entry.Track.Title,
|
||||||
|
entry.Track.Owner.Name,
|
||||||
|
entry.Track.DurationSeconds,
|
||||||
|
entry.ListensCount);
|
||||||
})
|
})
|
||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,5 +4,6 @@ namespace Musify.Application.Mixes.Responses
|
|||||||
Guid TrackId,
|
Guid TrackId,
|
||||||
string Title,
|
string Title,
|
||||||
string? Artist,
|
string? Artist,
|
||||||
double DurationSeconds);
|
double DurationSeconds,
|
||||||
|
int ListensCount);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ namespace Musify.Application.PlayLists
|
|||||||
var tracksQuery = database.PlayListHasTracks
|
var tracksQuery = database.PlayListHasTracks
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Include(plt => plt.Track.Owner)
|
.Include(plt => plt.Track.Owner)
|
||||||
|
.Include(plt => plt.Track.Tags)
|
||||||
.Where(plt => plt.PlayListId == request.PlayListId);
|
.Where(plt => plt.PlayListId == request.PlayListId);
|
||||||
|
|
||||||
var totalCount = await tracksQuery.CountAsync(cancellationToken);
|
var totalCount = await tracksQuery.CountAsync(cancellationToken);
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ namespace Musify.Application.Tracks
|
|||||||
long UserId,
|
long UserId,
|
||||||
string Title,
|
string Title,
|
||||||
Guid PictureIntentId,
|
Guid PictureIntentId,
|
||||||
Guid AudioIntentId)
|
Guid AudioIntentId,
|
||||||
|
IReadOnlyCollection<Genre> Tags)
|
||||||
: ICommand<ErrorOr<TrackApplicationResponse>>;
|
: ICommand<ErrorOr<TrackApplicationResponse>>;
|
||||||
|
|
||||||
public class CreateTrackCommandHandler(
|
public class CreateTrackCommandHandler(
|
||||||
@@ -40,6 +41,23 @@ namespace Musify.Application.Tracks
|
|||||||
return Error.NotFound(description: $"User {request.UserId} not found");
|
return Error.NotFound(description: $"User {request.UserId} not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var distinctTags = request.Tags.Distinct().ToList();
|
||||||
|
if (distinctTags.Count == 0)
|
||||||
|
{
|
||||||
|
logger.LogWarning("Track {Title} was submitted without any tags", request.Title);
|
||||||
|
return Error.Validation(description: "At least one tag is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var tagConflicts = GenreCompatibility.FindConflicts(distinctTags);
|
||||||
|
if (tagConflicts.Count > 0)
|
||||||
|
{
|
||||||
|
var conflict = tagConflicts.First();
|
||||||
|
logger.LogWarning(
|
||||||
|
"Track {Title} was submitted with incompatible tags {First} and {Second}",
|
||||||
|
request.Title, conflict.First, conflict.Second);
|
||||||
|
return Error.Validation(description: $"Tags '{conflict.First}' and '{conflict.Second}' are not compatible.");
|
||||||
|
}
|
||||||
|
|
||||||
var pictureValidation = await uploadIntentValidator.ValidateAndLoadAsync(
|
var pictureValidation = await uploadIntentValidator.ValidateAndLoadAsync(
|
||||||
uploadIntentConfiguration,
|
uploadIntentConfiguration,
|
||||||
request.PictureIntentId, request.UserId, cancellationToken);
|
request.PictureIntentId, request.UserId, cancellationToken);
|
||||||
@@ -80,6 +98,10 @@ namespace Musify.Application.Tracks
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
trackEntity.Tags = distinctTags
|
||||||
|
.Select(tag => new TrackTag { TrackId = trackEntity.Id, Tag = tag })
|
||||||
|
.ToList();
|
||||||
|
|
||||||
await database.Tracks.AddAsync(trackEntity, cancellationToken);
|
await database.Tracks.AddAsync(trackEntity, cancellationToken);
|
||||||
|
|
||||||
await database.UserHasTracks.AddAsync(new UserHasTrack
|
await database.UserHasTracks.AddAsync(new UserHasTrack
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ namespace Musify.Application.Tracks
|
|||||||
|
|
||||||
var tracksById = await database.Tracks
|
var tracksById = await database.Tracks
|
||||||
.Include(t => t.Owner)
|
.Include(t => t.Owner)
|
||||||
|
.Include(t => t.Tags)
|
||||||
.Where(t => recentTrackIds.Contains(t.Id))
|
.Where(t => recentTrackIds.Contains(t.Id))
|
||||||
.Select(t => new { t.Id, Track = t, ListensCount = t.ListeningHistories.Count } )
|
.Select(t => new { t.Id, Track = t, ListensCount = t.ListeningHistories.Count } )
|
||||||
.ToDictionaryAsync(t => t.Id, cancellationToken);
|
.ToDictionaryAsync(t => t.Id, cancellationToken);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ namespace Musify.Application.Tracks
|
|||||||
{
|
{
|
||||||
var entity = await database.Tracks.AsNoTracking()
|
var entity = await database.Tracks.AsNoTracking()
|
||||||
.Include(t => t.Owner)
|
.Include(t => t.Owner)
|
||||||
|
.Include(t => t.Tags)
|
||||||
.Where(t => t.Id == request.TrackId)
|
.Where(t => t.Id == request.TrackId)
|
||||||
.Select(t => new { Track = t, ListensCount = t.ListeningHistories.Count })
|
.Select(t => new { Track = t, ListensCount = t.ListeningHistories.Count })
|
||||||
.SingleOrDefaultAsync(cancellationToken);
|
.SingleOrDefaultAsync(cancellationToken);
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ namespace Musify.Application.Tracks
|
|||||||
var tracksQuery = database.UserHasTracks
|
var tracksQuery = database.UserHasTracks
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Include(ut => ut.Track.Owner)
|
.Include(ut => ut.Track.Owner)
|
||||||
|
.Include(ut => ut.Track.Tags)
|
||||||
.AsQueryable();
|
.AsQueryable();
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(request.Name))
|
if (!string.IsNullOrEmpty(request.Name))
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ namespace Musify.Application.Tracks
|
|||||||
var tracksQuery = database.UserHasTracks
|
var tracksQuery = database.UserHasTracks
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Include(ut => ut.Track.Owner)
|
.Include(ut => ut.Track.Owner)
|
||||||
|
.Include(ut => ut.Track.Tags)
|
||||||
.Where(p => p.UserId == request.UserId);
|
.Where(p => p.UserId == request.UserId);
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(request.Name))
|
if (!string.IsNullOrEmpty(request.Name))
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ namespace Musify.Application.Tracks.Responses
|
|||||||
int ListensCount,
|
int ListensCount,
|
||||||
DateTime CreatedAt,
|
DateTime CreatedAt,
|
||||||
DateTime UpdatedAt,
|
DateTime UpdatedAt,
|
||||||
[property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId)
|
[property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId,
|
||||||
|
IReadOnlyCollection<Genre> Tags)
|
||||||
{
|
{
|
||||||
public static TrackApplicationResponse FromEntity(Track track, int listensCount) =>
|
public static TrackApplicationResponse FromEntity(Track track, int listensCount) =>
|
||||||
new(
|
new(
|
||||||
@@ -26,6 +27,7 @@ namespace Musify.Application.Tracks.Responses
|
|||||||
listensCount,
|
listensCount,
|
||||||
track.CreatedAt,
|
track.CreatedAt,
|
||||||
track.UpdatedAt,
|
track.UpdatedAt,
|
||||||
track.OwnerUserId);
|
track.OwnerUserId,
|
||||||
|
track.Tags.Select(tag => tag.Tag).ToList());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,5 +48,7 @@ namespace Musify.Domain.Entities
|
|||||||
public ICollection<AlbumHasTrack> AlbumTracks { get; set; } = new List<AlbumHasTrack>();
|
public ICollection<AlbumHasTrack> AlbumTracks { get; set; } = new List<AlbumHasTrack>();
|
||||||
|
|
||||||
public ICollection<ListeningHistory> ListeningHistories { get; set; } = new List<ListeningHistory>();
|
public ICollection<ListeningHistory> ListeningHistories { get; set; } = new List<ListeningHistory>();
|
||||||
|
|
||||||
|
public ICollection<TrackTag> Tags { get; set; } = new List<TrackTag>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Musify.Domain.Entities
|
||||||
|
{
|
||||||
|
#pragma warning disable CS8618
|
||||||
|
public class TrackTag
|
||||||
|
{
|
||||||
|
[Key]
|
||||||
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public required Guid TrackId { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public required Genre Tag { get; set; }
|
||||||
|
|
||||||
|
[ForeignKey(nameof(TrackId))]
|
||||||
|
public Track Track { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
namespace Musify.Domain.ValueObjects
|
||||||
|
{
|
||||||
|
public enum Genre
|
||||||
|
{
|
||||||
|
Pop = 0,
|
||||||
|
Rock = 1,
|
||||||
|
HipHop = 2,
|
||||||
|
RnB = 3,
|
||||||
|
Jazz = 4,
|
||||||
|
Blues = 5,
|
||||||
|
Classical = 6,
|
||||||
|
Electronic = 7,
|
||||||
|
House = 8,
|
||||||
|
Techno = 9,
|
||||||
|
Trance = 10,
|
||||||
|
Dubstep = 11,
|
||||||
|
DrumAndBass = 12,
|
||||||
|
Metal = 13,
|
||||||
|
Punk = 14,
|
||||||
|
Reggae = 15,
|
||||||
|
Reggaeton = 16,
|
||||||
|
Country = 17,
|
||||||
|
Folk = 18,
|
||||||
|
Indie = 19,
|
||||||
|
KPop = 20,
|
||||||
|
Latin = 21,
|
||||||
|
Soul = 22,
|
||||||
|
Funk = 23,
|
||||||
|
Ambient = 24,
|
||||||
|
Lofi = 25
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
namespace Musify.Domain.ValueObjects
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Encodes which genre pairs are mutually exclusive on a single track (e.g. a track cannot
|
||||||
|
/// be tagged as both Classical and Metal at the same time).
|
||||||
|
/// </summary>
|
||||||
|
public static class GenreCompatibility
|
||||||
|
{
|
||||||
|
private static readonly HashSet<(Genre, Genre)> IncompatiblePairs = BuildIncompatiblePairs();
|
||||||
|
|
||||||
|
private static HashSet<(Genre, Genre)> BuildIncompatiblePairs()
|
||||||
|
{
|
||||||
|
var pairs = new HashSet<(Genre, Genre)>();
|
||||||
|
|
||||||
|
void Add(Genre a, Genre b)
|
||||||
|
{
|
||||||
|
pairs.Add((a, b));
|
||||||
|
pairs.Add((b, a));
|
||||||
|
}
|
||||||
|
|
||||||
|
var intense = new[] { Genre.Metal, Genre.Punk, Genre.Dubstep, Genre.DrumAndBass };
|
||||||
|
var mellow = new[] { Genre.Classical, Genre.Ambient, Genre.Lofi };
|
||||||
|
|
||||||
|
foreach (var intenseGenre in intense)
|
||||||
|
foreach (var mellowGenre in mellow)
|
||||||
|
Add(intenseGenre, mellowGenre);
|
||||||
|
|
||||||
|
return pairs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool AreCompatible(Genre a, Genre b) =>
|
||||||
|
a == b || !IncompatiblePairs.Contains((a, b));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns every mutually exclusive genre pair found within the given set of tags.
|
||||||
|
/// </summary>
|
||||||
|
public static IReadOnlyCollection<(Genre First, Genre Second)> FindConflicts(IEnumerable<Genre> tags)
|
||||||
|
{
|
||||||
|
var distinctTags = tags.Distinct().ToList();
|
||||||
|
var conflicts = new List<(Genre, Genre)>();
|
||||||
|
|
||||||
|
for (var i = 0; i < distinctTags.Count; i++)
|
||||||
|
{
|
||||||
|
for (var j = i + 1; j < distinctTags.Count; j++)
|
||||||
|
{
|
||||||
|
if (!AreCompatible(distinctTags[i], distinctTags[j]))
|
||||||
|
conflicts.Add((distinctTags[i], distinctTags[j]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return conflicts;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ namespace Musify.Infrastructure.Persistence
|
|||||||
{
|
{
|
||||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||||
{
|
{
|
||||||
optionsBuilder.UseNpgsql(configuration.ConnectionString);
|
optionsBuilder.UseNpgsql(configuration.ConnectionString, o => o.MapEnum<Genre>("genre"));
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
@@ -128,6 +128,16 @@ namespace Musify.Infrastructure.Persistence
|
|||||||
.HasIndex(like => new { like.UserId, like.TrackId })
|
.HasIndex(like => new { like.UserId, like.TrackId })
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
|
modelBuilder.Entity<TrackTag>()
|
||||||
|
.HasOne(trackTag => trackTag.Track)
|
||||||
|
.WithMany(track => track.Tags)
|
||||||
|
.HasForeignKey(trackTag => trackTag.TrackId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
modelBuilder.Entity<TrackTag>()
|
||||||
|
.HasIndex(trackTag => new { trackTag.TrackId, trackTag.Tag })
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
modelBuilder.Entity<UserFollow>()
|
modelBuilder.Entity<UserFollow>()
|
||||||
.HasOne(follow => follow.Follower)
|
.HasOne(follow => follow.Follower)
|
||||||
.WithMany()
|
.WithMany()
|
||||||
@@ -181,6 +191,8 @@ namespace Musify.Infrastructure.Persistence
|
|||||||
|
|
||||||
public DbSet<TrackLike> TrackLikes => Set<TrackLike>();
|
public DbSet<TrackLike> TrackLikes => Set<TrackLike>();
|
||||||
|
|
||||||
|
public DbSet<TrackTag> TrackTags => Set<TrackTag>();
|
||||||
|
|
||||||
public DbSet<UserFollow> UserFollows => Set<UserFollow>();
|
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)
|
||||||
|
|||||||
Generated
+1168
File diff suppressed because it is too large
Load Diff
+54
@@ -0,0 +1,54 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Musify.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddTrackTags : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AlterDatabase()
|
||||||
|
.Annotation("Npgsql:Enum:genre", "ambient,blues,classical,country,drum_and_bass,dubstep,electronic,folk,funk,hip_hop,house,indie,jazz,k_pop,latin,lofi,metal,pop,punk,reggae,reggaeton,rn_b,rock,soul,techno,trance");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "TrackTags",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
TrackId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
Tag = table.Column<Genre>(type: "genre", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_TrackTags", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_TrackTags_Tracks_TrackId",
|
||||||
|
column: x => x.TrackId,
|
||||||
|
principalTable: "Tracks",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_TrackTags_TrackId_Tag",
|
||||||
|
table: "TrackTags",
|
||||||
|
columns: new[] { "TrackId", "Tag" },
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "TrackTags");
|
||||||
|
|
||||||
|
migrationBuilder.AlterDatabase()
|
||||||
|
.OldAnnotation("Npgsql:Enum:genre", "ambient,blues,classical,country,drum_and_bass,dubstep,electronic,folk,funk,hip_hop,house,indie,jazz,k_pop,latin,lofi,metal,pop,punk,reggae,reggaeton,rn_b,rock,soul,techno,trance");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ using System;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
using Musify.Infrastructure.Persistence;
|
using Musify.Infrastructure.Persistence;
|
||||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
|||||||
.HasAnnotation("ProductVersion", "10.0.8")
|
.HasAnnotation("ProductVersion", "10.0.8")
|
||||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "genre", new[] { "ambient", "blues", "classical", "country", "drum_and_bass", "dubstep", "electronic", "folk", "funk", "hip_hop", "house", "indie", "jazz", "k_pop", "latin", "lofi", "metal", "pop", "punk", "reggae", "reggaeton", "rn_b", "rock", "soul", "techno", "trance" });
|
||||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b =>
|
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b =>
|
||||||
@@ -476,6 +478,26 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
|||||||
b.ToTable("TrackLikes");
|
b.ToTable("TrackLikes");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Musify.Domain.Entities.TrackTag", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Genre>("Tag")
|
||||||
|
.HasColumnType("genre");
|
||||||
|
|
||||||
|
b.Property<Guid>("TrackId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TrackId", "Tag")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("TrackTags");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Musify.Domain.Entities.Upload", b =>
|
modelBuilder.Entity("Musify.Domain.Entities.Upload", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -1041,6 +1063,17 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
|||||||
b.Navigation("User");
|
b.Navigation("User");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Musify.Domain.Entities.TrackTag", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Musify.Domain.Entities.Track", "Track")
|
||||||
|
.WithMany("Tags")
|
||||||
|
.HasForeignKey("TrackId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Track");
|
||||||
|
});
|
||||||
|
|
||||||
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")
|
||||||
@@ -1113,6 +1146,8 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
|||||||
|
|
||||||
b.Navigation("PlayListTracks");
|
b.Navigation("PlayListTracks");
|
||||||
|
|
||||||
|
b.Navigation("Tags");
|
||||||
|
|
||||||
b.Navigation("UserTracks");
|
b.Navigation("UserTracks");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
namespace Musify.Api.DataTransferObjects.Tracks;
|
namespace Musify.Api.DataTransferObjects.Tracks;
|
||||||
|
|
||||||
public record CreateTrackRequest(
|
public record CreateTrackRequest(
|
||||||
string Title,
|
string Title,
|
||||||
Guid PictureIntentId,
|
Guid PictureIntentId,
|
||||||
Guid AudioIntentId);
|
Guid AudioIntentId,
|
||||||
|
IReadOnlyCollection<Genre> Tags);
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ public static class TrackEndpoints
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var result = await mediator.Send(
|
var result = await mediator.Send(
|
||||||
new CreateTrackCommand(currentUser.RequiredId, request.Title, request.PictureIntentId, request.AudioIntentId),
|
new CreateTrackCommand(currentUser.RequiredId, request.Title, request.PictureIntentId, request.AudioIntentId, request.Tags),
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
return result.ToCreatedResult(track => $"/tracks/{track.Id}");
|
return result.ToCreatedResult(track => $"/tracks/{track.Id}");
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using Musify.Api.DataTransferObjects.Tracks;
|
using Musify.Api.DataTransferObjects.Tracks;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
namespace Musify.Api.Validators.Tracks;
|
namespace Musify.Api.Validators.Tracks;
|
||||||
|
|
||||||
@@ -16,5 +17,18 @@ public sealed class CreateTrackRequestValidator : AbstractValidator<CreateTrackR
|
|||||||
|
|
||||||
RuleFor(x => x.AudioIntentId)
|
RuleFor(x => x.AudioIntentId)
|
||||||
.NotEmpty();
|
.NotEmpty();
|
||||||
|
|
||||||
|
RuleFor(x => x.Tags)
|
||||||
|
.NotEmpty()
|
||||||
|
.WithMessage("At least one tag is required.");
|
||||||
|
|
||||||
|
RuleFor(x => x.Tags)
|
||||||
|
.Must(tags => GenreCompatibility.FindConflicts(tags).Count == 0)
|
||||||
|
.When(x => x.Tags is { Count: > 0 })
|
||||||
|
.WithMessage(x =>
|
||||||
|
{
|
||||||
|
var conflict = GenreCompatibility.FindConflicts(x.Tags).First();
|
||||||
|
return $"Tags '{conflict.First}' and '{conflict.Second}' are not compatible.";
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,6 +95,16 @@ namespace Musify.Application.Tests.TestSupport
|
|||||||
.HasIndex(like => new { like.UserId, like.TrackId })
|
.HasIndex(like => new { like.UserId, like.TrackId })
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
|
modelBuilder.Entity<TrackTag>()
|
||||||
|
.HasOne(trackTag => trackTag.Track)
|
||||||
|
.WithMany(track => track.Tags)
|
||||||
|
.HasForeignKey(trackTag => trackTag.TrackId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
modelBuilder.Entity<TrackTag>()
|
||||||
|
.HasIndex(trackTag => new { trackTag.TrackId, trackTag.Tag })
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
modelBuilder.Entity<UserFollow>()
|
modelBuilder.Entity<UserFollow>()
|
||||||
.HasOne(follow => follow.Follower)
|
.HasOne(follow => follow.Follower)
|
||||||
.WithMany()
|
.WithMany()
|
||||||
@@ -138,6 +148,8 @@ namespace Musify.Application.Tests.TestSupport
|
|||||||
|
|
||||||
public DbSet<TrackLike> TrackLikes => Set<TrackLike>();
|
public DbSet<TrackLike> TrackLikes => Set<TrackLike>();
|
||||||
|
|
||||||
|
public DbSet<TrackTag> TrackTags => Set<TrackTag>();
|
||||||
|
|
||||||
public DbSet<UserFollow> UserFollows => Set<UserFollow>();
|
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)
|
||||||
|
|||||||
@@ -55,8 +55,10 @@ namespace Musify.Application.Tests.TestSupport
|
|||||||
double durationSeconds = 180,
|
double durationSeconds = 180,
|
||||||
TrackPictures? pictures = null,
|
TrackPictures? pictures = null,
|
||||||
TrackAudio? audio = null,
|
TrackAudio? audio = null,
|
||||||
LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active) =>
|
LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active,
|
||||||
new()
|
IEnumerable<Genre>? tags = null)
|
||||||
|
{
|
||||||
|
var track = new Track
|
||||||
{
|
{
|
||||||
Title = title,
|
Title = title,
|
||||||
NormalizedTitle = title.ToUpperInvariant(),
|
NormalizedTitle = title.ToUpperInvariant(),
|
||||||
@@ -68,6 +70,13 @@ namespace Musify.Application.Tests.TestSupport
|
|||||||
Audio = audio ?? ProcessedAudio()
|
Audio = audio ?? ProcessedAudio()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
track.Tags = (tags ?? [Genre.Pop])
|
||||||
|
.Select(tag => new TrackTag { TrackId = track.Id, Tag = tag })
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return track;
|
||||||
|
}
|
||||||
|
|
||||||
public static PlayListPictures PlayListPictures() => new()
|
public static PlayListPictures PlayListPictures() => new()
|
||||||
{
|
{
|
||||||
OriginalName = "original.webp",
|
OriginalName = "original.webp",
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ namespace Musify.Application.Tests.Tracks
|
|||||||
var (picture, audio) = SeedIntents(user.Id);
|
var (picture, audio) = SeedIntents(user.Id);
|
||||||
await SeedAsync(user, picture, audio);
|
await SeedAsync(user, picture, audio);
|
||||||
|
|
||||||
var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id);
|
var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, [Genre.Pop, Genre.Rock]);
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
@@ -50,6 +50,7 @@ namespace Musify.Application.Tests.Tracks
|
|||||||
Assert.Equal("My Song", result.Value.Title);
|
Assert.Equal("My Song", result.Value.Title);
|
||||||
var localResponse = Assert.IsType<TrackApplicationResponse>(result.Value);
|
var localResponse = Assert.IsType<TrackApplicationResponse>(result.Value);
|
||||||
Assert.Equal(user.Id, localResponse.OwnerUserId);
|
Assert.Equal(user.Id, localResponse.OwnerUserId);
|
||||||
|
Assert.Equal([Genre.Pop, Genre.Rock], localResponse.Tags);
|
||||||
await eventBus.Received(1).PublishAsync(Arg.Any<Musify.Application.Events.CreateTrackResourcesEvent>(), Arg.Any<CancellationToken>());
|
await eventBus.Received(1).PublishAsync(Arg.Any<Musify.Application.Events.CreateTrackResourcesEvent>(), Arg.Any<CancellationToken>());
|
||||||
|
|
||||||
var stored = await Database.Tracks.FindAsync([result.Value.Id], TestContext.Current.CancellationToken);
|
var stored = await Database.Tracks.FindAsync([result.Value.Id], TestContext.Current.CancellationToken);
|
||||||
@@ -57,10 +58,38 @@ namespace Musify.Application.Tests.Tracks
|
|||||||
Assert.Equal(ProcessingStatus.Pending, stored.Audio.TranscodeStatus);
|
Assert.Equal(ProcessingStatus.Pending, stored.Audio.TranscodeStatus);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_NoTags_ReturnsValidationError()
|
||||||
|
{
|
||||||
|
var user = TestEntities.User();
|
||||||
|
var (picture, audio) = SeedIntents(user.Id);
|
||||||
|
await SeedAsync(user, picture, audio);
|
||||||
|
|
||||||
|
var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, []);
|
||||||
|
|
||||||
|
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_IncompatibleTags_ReturnsValidationError()
|
||||||
|
{
|
||||||
|
var user = TestEntities.User();
|
||||||
|
var (picture, audio) = SeedIntents(user.Id);
|
||||||
|
await SeedAsync(user, picture, audio);
|
||||||
|
|
||||||
|
var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, [Genre.Classical, Genre.Metal]);
|
||||||
|
|
||||||
|
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Handle_UserMissing_ReturnsNotFound()
|
public async Task Handle_UserMissing_ReturnsNotFound()
|
||||||
{
|
{
|
||||||
var command = new CreateTrackCommand(404, "Orphan Song", Guid.NewGuid(), Guid.NewGuid());
|
var command = new CreateTrackCommand(404, "Orphan Song", Guid.NewGuid(), Guid.NewGuid(), [Genre.Pop]);
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
@@ -75,7 +104,7 @@ namespace Musify.Application.Tests.Tracks
|
|||||||
var (picture, audio) = SeedIntents(otherUser.Id);
|
var (picture, audio) = SeedIntents(otherUser.Id);
|
||||||
await SeedAsync(user, otherUser, picture, audio);
|
await SeedAsync(user, otherUser, picture, audio);
|
||||||
|
|
||||||
var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id);
|
var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, [Genre.Pop]);
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
@@ -91,7 +120,7 @@ namespace Musify.Application.Tests.Tracks
|
|||||||
var audio = TestEntities.UploadIntent(user.Id, UploadIntentPurpose.TrackAudio, objectName: "song.mp3", status: UploadIntentStatus.Consumed);
|
var audio = TestEntities.UploadIntent(user.Id, UploadIntentPurpose.TrackAudio, objectName: "song.mp3", status: UploadIntentStatus.Consumed);
|
||||||
await SeedAsync(user, picture, audio);
|
await SeedAsync(user, picture, audio);
|
||||||
|
|
||||||
var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id);
|
var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, [Genre.Pop]);
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Musify.Domain.Tests.ValueObjects
|
||||||
|
{
|
||||||
|
public sealed class GenreCompatibilityTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData(Genre.Classical, Genre.Metal)]
|
||||||
|
[InlineData(Genre.Classical, Genre.Punk)]
|
||||||
|
[InlineData(Genre.Ambient, Genre.Dubstep)]
|
||||||
|
[InlineData(Genre.Lofi, Genre.DrumAndBass)]
|
||||||
|
public void AreCompatible_IncompatiblePair_ReturnsFalse(Genre first, Genre second)
|
||||||
|
{
|
||||||
|
Assert.False(GenreCompatibility.AreCompatible(first, second));
|
||||||
|
Assert.False(GenreCompatibility.AreCompatible(second, first));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(Genre.Pop, Genre.Rock)]
|
||||||
|
[InlineData(Genre.HipHop, Genre.RnB)]
|
||||||
|
[InlineData(Genre.Classical, Genre.Jazz)]
|
||||||
|
public void AreCompatible_CompatiblePair_ReturnsTrue(Genre first, Genre second)
|
||||||
|
{
|
||||||
|
Assert.True(GenreCompatibility.AreCompatible(first, second));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AreCompatible_SameGenre_ReturnsTrue()
|
||||||
|
{
|
||||||
|
Assert.True(GenreCompatibility.AreCompatible(Genre.Pop, Genre.Pop));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FindConflicts_NoIncompatiblePairs_ReturnsEmpty()
|
||||||
|
{
|
||||||
|
var conflicts = GenreCompatibility.FindConflicts([Genre.Pop, Genre.Rock, Genre.Indie]);
|
||||||
|
|
||||||
|
Assert.Empty(conflicts);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FindConflicts_IncompatiblePairPresent_ReturnsIt()
|
||||||
|
{
|
||||||
|
var conflicts = GenreCompatibility.FindConflicts([Genre.Pop, Genre.Classical, Genre.Metal]);
|
||||||
|
|
||||||
|
var conflict = Assert.Single(conflicts);
|
||||||
|
Assert.Equal((Genre.Classical, Genre.Metal), conflict);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,31 @@ namespace Musify.Infrastructure.Tests.Persistence
|
|||||||
Assert.Equal(user.Id, stored.OwnerUserId);
|
Assert.Equal(user.Id, stored.OwnerUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SaveChangesAsync_TrackWithTags_RoundTripsThroughNativePostgresEnum()
|
||||||
|
{
|
||||||
|
await using var database = fixture.CreateDatabase();
|
||||||
|
|
||||||
|
var userName = "tags-roundtrip-user";
|
||||||
|
var user = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = userName, NormalizedName = userName.ToUpperInvariant() };
|
||||||
|
var track = BuildTrack(user, $"tags-{Guid.NewGuid():N}");
|
||||||
|
track.Tags =
|
||||||
|
[
|
||||||
|
new TrackTag { TrackId = track.Id, Tag = Genre.Pop },
|
||||||
|
new TrackTag { TrackId = track.Id, Tag = Genre.Rock }
|
||||||
|
];
|
||||||
|
|
||||||
|
await database.AddRangeAsync(user, track);
|
||||||
|
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
await using var reloaded = fixture.CreateDatabase();
|
||||||
|
var stored = await reloaded.Tracks.AsNoTracking()
|
||||||
|
.Include(t => t.Tags)
|
||||||
|
.SingleAsync(t => t.Id == track.Id, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal([Genre.Pop, Genre.Rock], stored.Tags.Select(t => t.Tag).OrderBy(t => t));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AlbumHasTracks_DeletingAlbum_CascadesToTheLinkRows()
|
public async Task AlbumHasTracks_DeletingAlbum_CascadesToTheLinkRows()
|
||||||
{
|
{
|
||||||
|
|||||||
Vendored
+2
@@ -743,6 +743,8 @@ export interface components {
|
|||||||
artist: null | string;
|
artist: null | string;
|
||||||
/** Format: double */
|
/** Format: double */
|
||||||
durationSeconds: number | string;
|
durationSeconds: number | string;
|
||||||
|
/** Format: int32 */
|
||||||
|
listensCount: number | string;
|
||||||
};
|
};
|
||||||
PaginatedResponseOfAlbumApplicationResponse: {
|
PaginatedResponseOfAlbumApplicationResponse: {
|
||||||
items: components["schemas"]["AlbumApplicationResponse"][];
|
items: components["schemas"]["AlbumApplicationResponse"][];
|
||||||
|
|||||||
@@ -55,7 +55,7 @@
|
|||||||
onclick={onPlayNext}
|
onclick={onPlayNext}
|
||||||
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-sm font-medium 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-sm font-medium text-fg-2 transition hover:bg-hover"
|
||||||
>
|
>
|
||||||
<ListPlus class="h-[18px] w-[18px] shrink-0" strokeWidth={2} />
|
<ListPlus class="h-4.5 w-4.5 shrink-0" strokeWidth={2} />
|
||||||
<span>Reproducir a continuación</span>
|
<span>Reproducir a continuación</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -63,7 +63,7 @@
|
|||||||
onclick={onAddToQueue}
|
onclick={onAddToQueue}
|
||||||
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-sm font-medium 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-sm font-medium text-fg-2 transition hover:bg-hover"
|
||||||
>
|
>
|
||||||
<ListEnd class="h-[18px] w-[18px] shrink-0" strokeWidth={2} />
|
<ListEnd class="h-4.5 w-4.5 shrink-0" strokeWidth={2} />
|
||||||
<span>Añadir a la cola</span>
|
<span>Añadir a la cola</span>
|
||||||
</button>
|
</button>
|
||||||
{#if playlists.length > 0}
|
{#if playlists.length > 0}
|
||||||
@@ -80,7 +80,7 @@
|
|||||||
: 'left-full pl-1.5'}"
|
: 'left-full pl-1.5'}"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="w-64 overflow-hidden rounded-panel border border-line bg-elevated p-2 shadow-menu max-sm:w-full"
|
class="w-64 overflow-hidden rounded-panel border border-line bg-elevated p-2 max-sm:w-full"
|
||||||
>
|
>
|
||||||
<div class="max-h-80 overflow-y-auto">
|
<div class="max-h-80 overflow-y-auto">
|
||||||
{#each playlists as playlist (playlist.id)}
|
{#each playlists as playlist (playlist.id)}
|
||||||
|
|||||||
@@ -21,9 +21,7 @@
|
|||||||
href="/"
|
href="/"
|
||||||
class="flex items-center gap-2 font-display text-lg font-semibold tracking-[-0.02em] text-fg"
|
class="flex items-center gap-2 font-display text-lg font-semibold tracking-[-0.02em] text-fg"
|
||||||
>
|
>
|
||||||
<span
|
<span class="h-5.5 w-5.5 rounded-lg bg-[image:var(--mf-logo-grad)]"></span>
|
||||||
class="h-5.5 w-5.5 rounded-[7px] bg-[image:var(--mf-logo-grad)] shadow-[var(--mf-logo-glow)]"
|
|
||||||
></span>
|
|
||||||
Musify
|
Musify
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
? [
|
? [
|
||||||
{ href: '/', label: 'Inicio', icon: Home },
|
{ href: '/', label: 'Inicio', icon: Home },
|
||||||
{ href: '/explore', label: 'Descubrir', icon: Compass },
|
{ href: '/explore', label: 'Descubrir', icon: Compass },
|
||||||
{ href: '/playlists', label: 'Playlists', icon: ListMusic },
|
{ href: '/playlists', label: 'Playlists', icon: ListMusic, count: playlists.length },
|
||||||
{ href: '/liked', label: 'Me gusta', icon: Heart, count: liked.count },
|
{ 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 },
|
||||||
@@ -48,24 +48,22 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<aside
|
<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"
|
class="hidden shrink-0 flex-col border-r border-hairline bg-[image:var(--mf-sidebar-bg)] p-5 transition-[background] duration-500 ease-out lg:flex"
|
||||||
style="width:var(--mf-sidebar-w)"
|
style="width:var(--mf-sidebar-w)"
|
||||||
>
|
>
|
||||||
<a href="/" class="flex items-center gap-2.5 px-1.5 pb-6.5">
|
<a href="/" class="px-3 pb-3 font-display text-lg font-semibold tracking-tight text-fg">Musify</a>
|
||||||
<span class="font-display text-lg font-semibold tracking-[-0.02em] text-fg">Musify</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<nav class="flex flex-col gap-0.5">
|
<nav class="flex flex-col gap-1">
|
||||||
{#each navLinks as link (link.href)}
|
{#each navLinks as link (link.href)}
|
||||||
{@const active = isActive(link.href)}
|
{@const active = isActive(link.href)}
|
||||||
<a
|
<a
|
||||||
href={link.href}
|
href={link.href}
|
||||||
aria-current={active ? 'page' : undefined}
|
aria-current={active ? 'page' : undefined}
|
||||||
class="flex items-center gap-3 rounded-[10px] px-3 py-2.25 text-sm font-medium transition-colors duration-150 {active
|
class="flex items-center gap-3 rounded-control px-3 py-2 text-sm transition-colors duration-150 {active
|
||||||
? 'bg-surface-hover 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-[17px] w-[17px] shrink-0" strokeWidth={1.6} />
|
<link.icon class="h-4.5 w-4.5 shrink-0" strokeWidth={1.5} />
|
||||||
<span class="flex-1 truncate">{link.label}</span>
|
<span class="flex-1 truncate">{link.label}</span>
|
||||||
{#if link.count}
|
{#if link.count}
|
||||||
<span class="text-xs text-muted tabular-nums">{link.count}</span>
|
<span class="text-xs text-muted tabular-nums">{link.count}</span>
|
||||||
@@ -77,8 +75,7 @@
|
|||||||
{#if user}
|
{#if user}
|
||||||
<div class="mx-3 mt-6 mb-3.5 h-px bg-line"></div>
|
<div class="mx-3 mt-6 mb-3.5 h-px bg-line"></div>
|
||||||
<div class="flex items-center justify-between px-3 pb-1.5">
|
<div class="flex items-center justify-between px-3 pb-1.5">
|
||||||
<span class="text-xs font-semibold tracking-[0.13em] text-muted uppercase">Tus playlists</span
|
<span class="text-xs font-medium tracking-widest text-muted uppercase">Tus playlists</span>
|
||||||
>
|
|
||||||
{#if playlists.length > 0}
|
{#if playlists.length > 0}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -86,7 +83,7 @@
|
|||||||
aria-label="Crear playlist"
|
aria-label="Crear playlist"
|
||||||
class="text-base text-muted transition-colors hover:text-fg"
|
class="text-base text-muted transition-colors hover:text-fg"
|
||||||
>
|
>
|
||||||
+
|
<Plus class="h-3.5 w-3.5" strokeWidth={1.25} />
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -96,7 +93,7 @@
|
|||||||
{@const active = page.url.pathname === `/playlists/${playlist.id}`}
|
{@const active = page.url.pathname === `/playlists/${playlist.id}`}
|
||||||
<a
|
<a
|
||||||
href="/playlists/{playlist.id}"
|
href="/playlists/{playlist.id}"
|
||||||
class="flex items-center gap-2.75 rounded-[10px] px-3 py-1.75 transition-colors duration-150 {active
|
class="flex items-center gap-2.75 rounded-control px-3 py-1.75 transition-colors duration-150 {active
|
||||||
? 'bg-surface-hover'
|
? 'bg-surface-hover'
|
||||||
: 'hover:bg-hover'}"
|
: 'hover:bg-hover'}"
|
||||||
>
|
>
|
||||||
@@ -117,7 +114,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => createPlaylistModal.show()}
|
onclick={() => createPlaylistModal.show()}
|
||||||
class="flex items-center gap-2 rounded-[10px] px-3 py-2 text-xs text-muted transition-colors hover:bg-hover hover:text-fg-2"
|
class="flex items-center gap-2 rounded-control px-3 py-2 text-xs text-muted transition-colors hover:bg-hover hover:text-fg-2"
|
||||||
>
|
>
|
||||||
<Plus class="h-3.5 w-3.5" strokeWidth={1.8} />
|
<Plus class="h-3.5 w-3.5" strokeWidth={1.8} />
|
||||||
Crear tu primera lista
|
Crear tu primera lista
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<nav
|
<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}"
|
class="pointer-events-auto flex items-center justify-around rounded-panel border border-hairline bg-[image:var(--mf-bar-bg)] p-1.75 backdrop-blur-xl transition-[background] duration-500 lg:hidden {klass}"
|
||||||
style="padding-bottom:calc(0.4375rem + var(--mf-safe-b))"
|
style="padding-bottom:calc(0.4375rem + var(--mf-safe-b))"
|
||||||
>
|
>
|
||||||
{#each links as link (link.href)}
|
{#each links as link (link.href)}
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
? 'text-fg'
|
? 'text-fg'
|
||||||
: 'text-fg-2'}"
|
: 'text-fg-2'}"
|
||||||
>
|
>
|
||||||
<link.icon class="h-[17px] w-[17px]" strokeWidth={1.6} />
|
<link.icon class="h-4.25 w-4.25" strokeWidth={1.6} />
|
||||||
<span class="text-xs font-medium">{link.label}</span>
|
<span class="text-xs font-medium">{link.label}</span>
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -62,21 +62,21 @@
|
|||||||
aria-label="Atrás"
|
aria-label="Atrás"
|
||||||
class="grid h-7.5 w-7.5 place-items-center rounded-full bg-surface-2 text-fg-2 transition-colors hover:bg-surface-hover hover:text-fg"
|
class="grid h-7.5 w-7.5 place-items-center rounded-full bg-surface-2 text-fg-2 transition-colors hover:bg-surface-hover hover:text-fg"
|
||||||
>
|
>
|
||||||
<ChevronLeft class="h-[15px] w-[15px]" strokeWidth={1.9} />
|
<ChevronLeft class="h-3.75 w-3.75" strokeWidth={1.9} />
|
||||||
</button>
|
</button>
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
class="grid h-7.5 w-7.5 place-items-center rounded-full bg-surface-2 text-muted"
|
class="grid h-7.5 w-7.5 place-items-center rounded-full bg-surface-2 text-muted"
|
||||||
>
|
>
|
||||||
<ChevronRight class="h-[15px] w-[15px]" strokeWidth={1.9} />
|
<ChevronRight class="h-3.75 w-3.75" strokeWidth={1.9} />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onsubmit={onSubmit} class="max-w-[480px] min-w-0 flex-1">
|
<form onsubmit={onSubmit} class="max-w-120 min-w-0 flex-1">
|
||||||
<label
|
<label
|
||||||
class="flex h-9.5 w-full items-center gap-2.5 rounded-[11px] bg-surface-2 px-3.5 transition focus-within:bg-surface-hover"
|
class="flex h-9.5 w-full items-center gap-2.5 rounded-control bg-surface-2 px-3.5 transition focus-within:bg-surface-hover"
|
||||||
>
|
>
|
||||||
<Search class="h-[15px] w-[15px] shrink-0 text-fg-3" strokeWidth={1.8} />
|
<Search class="h-3.75 w-3.75 shrink-0 text-fg-3" strokeWidth={1.8} />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
bind:this={searchInput}
|
bind:this={searchInput}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@
|
|||||||
onclick={onAddToQueue}
|
onclick={onAddToQueue}
|
||||||
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-sm font-medium 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-sm font-medium text-fg-2 transition hover:bg-hover"
|
||||||
>
|
>
|
||||||
<ListPlus class="h-[18px] w-[18px] shrink-0" strokeWidth={2} />
|
<ListPlus class="h-4.5 w-4.5 shrink-0" strokeWidth={2} />
|
||||||
<span>Reproducir a continuación</span>
|
<span>Reproducir a continuación</span>
|
||||||
</button>
|
</button>
|
||||||
{#if playlists.length > 0}
|
{#if playlists.length > 0}
|
||||||
@@ -72,7 +72,7 @@
|
|||||||
: 'left-full pl-1.5'}"
|
: 'left-full pl-1.5'}"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="w-64 overflow-hidden rounded-panel border border-line bg-elevated p-2 shadow-menu max-sm:w-full"
|
class="w-64 overflow-hidden rounded-panel border border-line bg-elevated p-2 max-sm:w-full"
|
||||||
>
|
>
|
||||||
<div class="max-h-80 overflow-y-auto">
|
<div class="max-h-80 overflow-y-auto">
|
||||||
{#each playlists as playlist (playlist.id)}
|
{#each playlists as playlist (playlist.id)}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
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"
|
class="animate-enter pointer-events-auto flex items-center gap-4.5 border-t border-hairline bg-[image:var(--mf-bar-bg)] px-4 py-2.75 backdrop-blur-xl transition-[background] duration-500 sm:hidden"
|
||||||
>
|
>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<TrackInfo compact />
|
<TrackInfo compact />
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
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"
|
class="animate-enter pointer-events-auto hidden items-center gap-5 border-t border-hairline bg-[image:var(--mf-bar-bg)] px-4 py-2.75 backdrop-blur-xl transition-[background] duration-500 sm:flex"
|
||||||
>
|
>
|
||||||
<div class="w-64 min-w-0 shrink-0">
|
<div class="w-64 min-w-0 shrink-0">
|
||||||
<TrackInfo />
|
<TrackInfo />
|
||||||
|
|||||||
@@ -15,15 +15,17 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<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)]"
|
class="pointer-events-none fixed inset-x-0 bottom-0 z-40 flex flex-col lg:left-[var(--mf-sidebar-w)]"
|
||||||
>
|
>
|
||||||
{#if player.error}
|
{#if player.error}
|
||||||
<div class="pointer-events-auto">
|
<div class="pointer-events-auto px-4 pb-2.5">
|
||||||
<Alert tone="danger">{player.error}</Alert>
|
<Alert tone="danger">{player.error}</Alert>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if hasTrack}
|
{#if hasTrack}
|
||||||
<PlayerBar />
|
<PlayerBar />
|
||||||
{/if}
|
{/if}
|
||||||
|
<div class="px-4 pt-2.5 pb-4 lg:hidden">
|
||||||
<TabsBar {user} />
|
<TabsBar {user} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
<div class="hidden w-64 shrink-0 items-center justify-end gap-3.5 sm:flex">
|
<div class="hidden w-64 shrink-0 items-center justify-end gap-3.5 sm:flex">
|
||||||
<span aria-hidden="true" class="text-fg-3">
|
<span aria-hidden="true" class="text-fg-3">
|
||||||
<AlignJustify class="h-[15px] w-[15px]" strokeWidth={1.8} />
|
<AlignJustify class="h-3.75 w-3.75" strokeWidth={1.8} />
|
||||||
</span>
|
</span>
|
||||||
<VolumeControl />
|
<VolumeControl />
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -17,11 +17,11 @@
|
|||||||
{#if queuePanel.open}
|
{#if queuePanel.open}
|
||||||
<aside
|
<aside
|
||||||
transition:fly={{ x: 24, duration: 220, easing: cubicOut }}
|
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"
|
class="hidden shrink-0 flex-col border-l border-hairline bg-[image:var(--mf-panel-bg)] px-4 pt-5 transition-[background] duration-500 sm:flex"
|
||||||
style="width:var(--mf-queue-w); padding-bottom:calc(var(--mf-player-h) + 2rem)"
|
style="width:var(--mf-queue-w); padding-bottom:calc(var(--mf-player-h) + 2rem)"
|
||||||
>
|
>
|
||||||
<div class="mb-5 flex items-center justify-between">
|
<div class="mb-5 flex items-center justify-between">
|
||||||
<h2 class="font-display text-base font-semibold tracking-[-0.02em] text-fg">En cola</h2>
|
<h2 class="font-display text-base font-medium tracking-tight text-fg">En cola</h2>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => queuePanel.close()}
|
onclick={() => queuePanel.close()}
|
||||||
@@ -33,24 +33,22 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if player.current.id}
|
{#if player.current.id}
|
||||||
<p class="mb-2.75 text-xs font-semibold tracking-[0.12em] text-muted uppercase">
|
<p class="mb-2.75 text-xs font-medium tracking-widest text-muted uppercase">Reproduciendo</p>
|
||||||
Reproduciendo
|
|
||||||
</p>
|
|
||||||
<div class="mb-6 flex items-center gap-3 rounded-xl bg-accent-tint p-2.5">
|
<div class="mb-6 flex items-center gap-3 rounded-xl bg-accent-tint p-2.5">
|
||||||
<Cover
|
<Cover
|
||||||
trackId={player.current.id}
|
trackId={player.current.id}
|
||||||
size="small"
|
size="small"
|
||||||
alt={player.current.title}
|
alt={player.current.title}
|
||||||
class="h-10 w-10 shrink-0 rounded-[9px]"
|
class="h-10 w-10 shrink-0 rounded-lg"
|
||||||
/>
|
/>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<div class="truncate text-sm font-semibold text-accent-soft">
|
<div class="truncate text-sm text-accent-soft">
|
||||||
{player.current.title}
|
{player.current.title}
|
||||||
</div>
|
</div>
|
||||||
<ArtistLink
|
<ArtistLink
|
||||||
name={player.current.artist || '—'}
|
name={player.current.artist || '—'}
|
||||||
ownerUserId={player.current.ownerUserId}
|
ownerUserId={player.current.ownerUserId}
|
||||||
class="mt-0.75 text-xs text-fg-3"
|
class="mt-0.5 text-xs text-fg-3"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -58,7 +56,7 @@
|
|||||||
|
|
||||||
<div class="mb-2.75 flex items-center justify-between gap-3">
|
<div class="mb-2.75 flex items-center justify-between gap-3">
|
||||||
<span
|
<span
|
||||||
class="min-w-0 flex-1 truncate text-xs font-semibold tracking-[0.12em] text-muted uppercase"
|
class="min-w-0 flex-1 truncate text-xs font-medium tracking-widest text-muted uppercase"
|
||||||
>
|
>
|
||||||
A continuación
|
A continuación
|
||||||
</span>
|
</span>
|
||||||
@@ -85,16 +83,16 @@
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
player.playQueueIndex(index);
|
player.playQueueIndex(index);
|
||||||
}}
|
}}
|
||||||
class="flex items-center gap-3 rounded-[11px] p-2.25 text-left hover:bg-hover"
|
class="flex items-center gap-3 rounded-control p-2.25 text-left hover:bg-hover"
|
||||||
>
|
>
|
||||||
<Cover
|
<Cover
|
||||||
trackId={track.id}
|
trackId={track.id}
|
||||||
size="small"
|
size="small"
|
||||||
alt={track.title}
|
alt={track.title}
|
||||||
class="h-9 w-9 shrink-0 rounded-lg opacity-90"
|
class="h-10 w-10 shrink-0 rounded-lg opacity-80"
|
||||||
/>
|
/>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="truncate text-sm font-medium text-fg">{track.title}</div>
|
<div class="truncate text-xs text-fg">{track.title}</div>
|
||||||
<ArtistLink
|
<ArtistLink
|
||||||
name={track.artist || '—'}
|
name={track.artist || '—'}
|
||||||
ownerUserId={track.ownerUserId}
|
ownerUserId={track.ownerUserId}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
aria-label="Barra de progreso"
|
aria-label="Barra de progreso"
|
||||||
class="group relative h-3.5 flex-1 cursor-pointer"
|
class="group relative h-3.5 flex-1 cursor-pointer"
|
||||||
>
|
>
|
||||||
<div class="h-[3.5px] w-full overflow-hidden rounded-full bg-track">
|
<div class="h-0.875 w-full overflow-hidden rounded-full bg-track">
|
||||||
<div class="h-full rounded-full bg-accent" style="width:{player.progressPercent}%"></div>
|
<div class="h-full rounded-full bg-accent" style="width:{player.progressPercent}%"></div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -32,10 +32,10 @@
|
|||||||
trackId={player.current.id}
|
trackId={player.current.id}
|
||||||
size="small"
|
size="small"
|
||||||
alt={player.current.title}
|
alt={player.current.title}
|
||||||
class="h-11 w-11 shrink-0 rounded-[11px] shadow-cover-xs md:h-12 md:w-12"
|
class="h-11 w-11 shrink-0 rounded-xl md:h-12 md:w-12"
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="h-11 w-11 shrink-0 rounded-[11px] bg-surface shadow-cover-xs md:h-12 md:w-12"></div>
|
<div class="h-11 w-11 shrink-0 rounded-xl bg-surface md:h-12 md:w-12"></div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="min-w-0 flex-1">
|
<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">
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
aria-label="Aleatorio"
|
aria-label="Aleatorio"
|
||||||
class="transition-colors hover:text-fg {player.shuffle ? 'text-accent' : 'text-fg-3'}"
|
class="transition-colors hover:text-fg {player.shuffle ? 'text-accent' : 'text-fg-3'}"
|
||||||
>
|
>
|
||||||
<Shuffle class="h-[15px] w-[15px]" strokeWidth={1.8} />
|
<Shuffle class="h-3.75 w-3.75" strokeWidth={1.8} />
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@
|
|||||||
aria-label="Repetir"
|
aria-label="Repetir"
|
||||||
class="transition-colors hover:text-fg {player.repeat ? 'text-accent' : 'text-fg-3'}"
|
class="transition-colors hover:text-fg {player.repeat ? 'text-accent' : 'text-fg-3'}"
|
||||||
>
|
>
|
||||||
<Repeat class="h-[15px] w-[15px]" strokeWidth={1.8} />
|
<Repeat class="h-3.75 w-3.75" strokeWidth={1.8} />
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex items-center gap-2.25">
|
<div class="flex items-center gap-2.25">
|
||||||
<Volume2 class="h-[15px] w-[15px] shrink-0 text-fg-3" strokeWidth={1.8} />
|
<Volume2 class="h-3.75 w-3.75 shrink-0 text-fg-3" strokeWidth={1.8} />
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min="0"
|
min="0"
|
||||||
|
|||||||
@@ -70,7 +70,7 @@
|
|||||||
required={requireCover}
|
required={requireCover}
|
||||||
icon={Disc3}
|
icon={Disc3}
|
||||||
gradient
|
gradient
|
||||||
class="h-36 w-36 rounded-[16px]"
|
class="h-36 w-36 rounded-2xl"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
|||||||
@@ -15,10 +15,6 @@
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<p
|
<p class="rounded-control border px-4 py-3 text-sm backdrop-blur-md {tones[tone]} {klass}">
|
||||||
class="rounded-control border px-4 py-3 text-sm shadow-menu backdrop-blur-md {tones[
|
|
||||||
tone
|
|
||||||
]} {klass}"
|
|
||||||
>
|
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
<div class="flex flex-col items-center gap-2.5 text-center" style="width:{size}px">
|
<div class="flex flex-col items-center gap-2.5 text-center" style="width:{size}px">
|
||||||
<div
|
<div
|
||||||
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 ring-1 ring-line transition duration-300 ease-out ring-inset"
|
||||||
style="width:{size}px;height:{size}px"
|
style="width:{size}px;height:{size}px"
|
||||||
>
|
>
|
||||||
{#if imageUrl}
|
{#if imageUrl}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const classes = $derived(
|
const classes = $derived(
|
||||||
`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}`
|
`inline-flex items-center justify-center gap-2 rounded-xl 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>
|
||||||
|
|
||||||
|
|||||||
@@ -15,15 +15,15 @@
|
|||||||
|
|
||||||
<div class="flex flex-col items-center rounded-panel px-6 py-16 text-center {klass}">
|
<div class="flex flex-col items-center rounded-panel px-6 py-16 text-center {klass}">
|
||||||
{#if Icon}
|
{#if Icon}
|
||||||
<Icon class="h-9 w-9 text-fg-2" strokeWidth={1.3} />
|
<Icon class="h-9 w-9 text-fg-2" strokeWidth={1.25} />
|
||||||
{/if}
|
{/if}
|
||||||
{#if title}
|
{#if title}
|
||||||
<h3 class="mt-6 font-display text-xl font-semibold tracking-[-0.025em] text-fg">
|
<h3 class="mt-6 font-display text-xl font-medium tracking-tight text-fg">
|
||||||
{title}
|
{title}
|
||||||
</h3>
|
</h3>
|
||||||
{/if}
|
{/if}
|
||||||
{#if description}
|
{#if description}
|
||||||
<p class="mt-2.5 max-w-sm text-sm leading-[1.65] text-fg-2">{description}</p>
|
<p class="mt-2.5 max-w-sm text-sm leading-relaxed text-fg-2">{description}</p>
|
||||||
{/if}
|
{/if}
|
||||||
{#if actions}
|
{#if actions}
|
||||||
<div class="mt-6 flex flex-wrap items-center justify-center gap-2.5">{@render actions()}</div>
|
<div class="mt-6 flex flex-wrap items-center justify-center gap-2.5">{@render actions()}</div>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
const delays = [0, 0.3, 0.6];
|
const delays = [0, 0.3, 0.6];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex items-end justify-center gap-[2px] {klass}" style="height:{size}px">
|
<div class="flex items-end justify-center gap-0.5 {klass}" style="height:{size}px">
|
||||||
{#each delays as delay (delay)}
|
{#each delays as delay (delay)}
|
||||||
<span
|
<span
|
||||||
class="rounded-full bg-accent"
|
class="rounded-full bg-accent"
|
||||||
|
|||||||
@@ -10,6 +10,6 @@
|
|||||||
let { class: klass = '', style = '', children }: Props = $props();
|
let { class: klass = '', style = '', children }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="glass-panel animate-pop rounded-panel p-1.5 shadow-menu {klass}" {style}>
|
<div class="glass-panel animate-pop rounded-panel border border-hairline p-1.5 {klass}" {style}>
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts" module>
|
<script lang="ts" module>
|
||||||
export const mediaCardArt =
|
export const mediaCardArt =
|
||||||
'aspect-square w-full rounded-art shadow-art transition duration-300 ease-out group-hover:shadow-art-lg';
|
'aspect-square w-full rounded-art transition duration-300 ease-out';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
{oncontextmenu}
|
{oncontextmenu}
|
||||||
>
|
>
|
||||||
{@render art(mediaCardArt)}
|
{@render art(mediaCardArt)}
|
||||||
<div class="mt-2.5 truncate text-sm font-medium text-fg">{title}</div>
|
<div class="mt-2.5 truncate text-fg">{title}</div>
|
||||||
{#if subtitle}
|
{#if subtitle}
|
||||||
<div class="mt-0.5 truncate text-xs text-fg-3">{subtitle}</div>
|
<div class="mt-0.5 truncate text-xs text-fg-3">{subtitle}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -37,12 +37,12 @@
|
|||||||
{onclick}
|
{onclick}
|
||||||
class={classes}
|
class={classes}
|
||||||
>
|
>
|
||||||
<Icon class="h-[18px] w-[18px] shrink-0" strokeWidth={1.8} />
|
<Icon class="h-4.5 w-4.5 shrink-0" strokeWidth={1.8} />
|
||||||
{label}
|
{label}
|
||||||
</a>
|
</a>
|
||||||
{:else}
|
{:else}
|
||||||
<button {type} {onclick} class={classes}>
|
<button {type} {onclick} class={classes}>
|
||||||
<Icon class="h-[18px] w-[18px] shrink-0" strokeWidth={1.8} />
|
<Icon class="h-4.5 w-4.5 shrink-0" strokeWidth={1.8} />
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
class="group/tile animate-enter block cursor-pointer {klass}"
|
class="group/tile animate-enter block cursor-pointer {klass}"
|
||||||
style="animation-delay:{Math.min(index, 10) * 45}ms"
|
style="animation-delay:{Math.min(index, 10) * 45}ms"
|
||||||
>
|
>
|
||||||
<div class="relative aspect-square overflow-hidden rounded-art shadow-art">
|
<div class="relative aspect-square overflow-hidden rounded-art">
|
||||||
<MixArt
|
<MixArt
|
||||||
items={mix.items}
|
items={mix.items}
|
||||||
class="h-full w-full scale-100 transition duration-300 group-hover/tile:scale-105"
|
class="h-full w-full scale-100 transition duration-300 group-hover/tile:scale-105"
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
onclick={play}
|
onclick={play}
|
||||||
aria-label="Reproducir {mix.title}"
|
aria-label="Reproducir {mix.title}"
|
||||||
class="absolute right-2.5 bottom-2.5 grid h-9.5 w-9.5 place-items-center rounded-full border border-on-art/12 bg-ink/76 text-fg opacity-0 backdrop-blur-[8px] transition-opacity duration-150 group-hover/tile:opacity-100"
|
class="absolute right-2.5 bottom-2.5 grid h-9.5 w-9.5 place-items-center rounded-full border border-on-art/12 bg-ink/76 text-fg opacity-0 backdrop-blur transition-opacity duration-150 group-hover/tile:opacity-100"
|
||||||
>
|
>
|
||||||
<Play class="h-3.5 w-3.5" fill="currentColor" />
|
<Play class="h-3.5 w-3.5" fill="currentColor" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -76,7 +76,7 @@
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
bind:this={panel}
|
bind:this={panel}
|
||||||
class="animate-pop max-h-[85dvh] w-full overflow-y-auto {maxWidth} rounded-panel p-5 shadow-2xl backdrop-blur-[22px] transition-[background] duration-[600ms] outline-none sm:p-6 {panelClass}"
|
class="animate-pop max-h-[85dvh] w-full overflow-y-auto {maxWidth} rounded-panel border border-hairline p-5 backdrop-blur-xl transition-[background] duration-500 outline-none sm:p-6 {panelClass}"
|
||||||
style="background-color:var(--mf-panel-bg); background-image:var(--mf-modal-glow)"
|
style="background-color:var(--mf-panel-bg); background-image:var(--mf-modal-glow)"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
@@ -87,7 +87,7 @@
|
|||||||
<div class="flex items-center justify-between gap-4">
|
<div class="flex items-center justify-between gap-4">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
{#if eyebrow}
|
{#if eyebrow}
|
||||||
<p class="mb-2.5 text-xs font-semibold tracking-[0.1em] text-muted uppercase">
|
<p class="mb-2.5 text-xs font-semibold tracking-widest text-muted uppercase">
|
||||||
{eyebrow}
|
{eyebrow}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -8,11 +8,11 @@
|
|||||||
const delays = [0, 0.3, 0.6];
|
const delays = [0, 0.3, 0.6];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="absolute inset-0 flex items-end justify-center gap-[3px] bg-scrim/45 pb-2.5">
|
<div class="absolute inset-0 flex items-end justify-center gap-0.75 bg-scrim/45 pb-2.5">
|
||||||
{#each delays as delay (delay)}
|
{#each delays as delay (delay)}
|
||||||
<span
|
<span
|
||||||
class="w-[3px] origin-bottom rounded-full bg-on-art"
|
class="h-3.5 w-0.75 origin-bottom rounded-full bg-on-art"
|
||||||
style="height:14px; animation:equalize .9s ease-in-out {delay}s infinite; animation-play-state:{paused
|
style="animation:equalize .9s ease-in-out {delay}s infinite; animation-play-state:{paused
|
||||||
? 'paused'
|
? 'paused'
|
||||||
: 'running'}"
|
: 'running'}"
|
||||||
></span>
|
></span>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
let { id, name, trackCount, trackIds, updatedAt, index = 0 }: Props = $props();
|
let { id, name, trackCount, trackIds, updatedAt, index = 0 }: Props = $props();
|
||||||
|
|
||||||
const subtitle = $derived(`${trackCount} ${trackCount === 1 ? 'canción' : 'canciones'}`);
|
const subtitle = $derived(`${trackCount} ${trackCount === 1 ? 'Canción' : 'Canciones'}`);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<MediaCard href="/playlists/{id}" title={name} {subtitle} {index}>
|
<MediaCard href="/playlists/{id}" title={name} {subtitle} {index}>
|
||||||
|
|||||||
@@ -70,7 +70,7 @@
|
|||||||
fallbackUrl={coverFallbackUrl}
|
fallbackUrl={coverFallbackUrl}
|
||||||
icon={Music}
|
icon={Music}
|
||||||
gradient
|
gradient
|
||||||
class="h-36 w-36 rounded-[16px]"
|
class="h-36 w-36 rounded-2xl"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
@@ -102,7 +102,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-between gap-4 rounded-[14px] bg-surface px-4 py-3.5">
|
<div class="flex items-center justify-between gap-4 rounded-art bg-surface px-4 py-3.5">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<div class="text-sm font-medium text-fg-2">Visibilidad</div>
|
<div class="text-sm font-medium text-fg-2">Visibilidad</div>
|
||||||
<div class="mt-0.5 text-xs text-muted">
|
<div class="mt-0.5 text-xs text-muted">
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
|
|
||||||
const RADIUS: Record<SearchResultKind, string> = {
|
const RADIUS: Record<SearchResultKind, string> = {
|
||||||
track: 'rounded-control',
|
track: 'rounded-control',
|
||||||
album: 'rounded-[6px]',
|
album: 'rounded-md',
|
||||||
playlist: 'rounded-[13px]',
|
playlist: 'rounded-art',
|
||||||
user: 'rounded-full'
|
user: 'rounded-full'
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -37,9 +37,7 @@
|
|||||||
art
|
art
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const artClass = $derived(
|
const artClass = $derived(`relative h-13 w-13 shrink-0 overflow-hidden ${RADIUS[kind]}`);
|
||||||
`relative h-13 w-13 shrink-0 overflow-hidden shadow-art ${RADIUS[kind]}`
|
|
||||||
);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet content()}
|
{#snippet content()}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet heading()}
|
{#snippet heading()}
|
||||||
<h2 class="font-display text-lg font-medium tracking-[-0.025em] text-fg">
|
<h2 class="font-display text-lg font-medium tracking-tight text-fg">
|
||||||
{title}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
{#if subtitle}
|
{#if subtitle}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { Hash } from '@lucide/svelte';
|
||||||
|
|
||||||
import { setContext } from 'svelte';
|
import { setContext } from 'svelte';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
@@ -31,7 +33,7 @@
|
|||||||
index ? '32px' : '',
|
index ? '32px' : '',
|
||||||
'minmax(180px,1fr)',
|
'minmax(180px,1fr)',
|
||||||
...meta.map((column) => column.width),
|
...meta.map((column) => column.width),
|
||||||
'64px',
|
'104px',
|
||||||
action ? '36px' : ''
|
action ? '36px' : ''
|
||||||
]
|
]
|
||||||
.filter((width) => width !== '')
|
.filter((width) => width !== '')
|
||||||
@@ -56,17 +58,17 @@
|
|||||||
|
|
||||||
<div class="overflow-x-auto {klass}">
|
<div class="overflow-x-auto {klass}">
|
||||||
<div
|
<div
|
||||||
class="grid track-grid items-center gap-3 border-b border-line pr-4 pb-3 pl-2 text-xs font-semibold tracking-[0.12em] text-muted uppercase sm:gap-8 sm:pr-6 sm:pl-3"
|
class="grid track-grid items-center gap-3 border-b border-line pr-4 pb-3 pl-2 text-sm text-fg-3 sm:gap-8 sm:pr-6 sm:pl-3"
|
||||||
style="--mf-track-cols:{columns}; --mf-track-cols-mobile:{columnsMobile}"
|
style="--mf-track-cols:{columns}; --mf-track-cols-mobile:{columnsMobile}"
|
||||||
>
|
>
|
||||||
{#if index}
|
{#if index}
|
||||||
<span class="text-center">#</span>
|
<Hash size={16} strokeWidth={1.5} />
|
||||||
{/if}
|
{/if}
|
||||||
<span>Título</span>
|
<span class="transition-colors">Título</span>
|
||||||
{#each meta as column (column.label)}
|
{#each meta as column (column.label)}
|
||||||
<span class="hidden text-center sm:block">{column.label}</span>
|
<span class="hidden text-center transition-colors sm:block">{column.label}</span>
|
||||||
{/each}
|
{/each}
|
||||||
<span class="hidden text-center sm:block">Duración</span>
|
<span class="hidden text-center transition-colors sm:block">Duración</span>
|
||||||
{#if action}
|
{#if action}
|
||||||
<span></span>
|
<span></span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
artist,
|
artist,
|
||||||
ownerUserId,
|
ownerUserId,
|
||||||
coverSrc,
|
coverSrc,
|
||||||
coverSize = 'h-[42px] w-[42px]',
|
coverSize = 'h-10.5 w-10.5',
|
||||||
explicit = false,
|
explicit = false,
|
||||||
active = false,
|
active = false,
|
||||||
titleClass = '',
|
titleClass = '',
|
||||||
@@ -39,12 +39,17 @@
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
onClick();
|
onClick();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onclick(event: MouseEvent) {
|
||||||
|
event.stopPropagation();
|
||||||
|
onClick();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
onclick={onClick}
|
{onclick}
|
||||||
onkeydown={onKeydown}
|
onkeydown={onKeydown}
|
||||||
class="flex min-w-0 items-center gap-3.5 text-left"
|
class="flex min-w-0 items-center gap-3.5 text-left"
|
||||||
>
|
>
|
||||||
@@ -62,10 +67,8 @@
|
|||||||
{#if explicit}
|
{#if explicit}
|
||||||
<ExplicitBadge />
|
<ExplicitBadge />
|
||||||
{/if}
|
{/if}
|
||||||
<span
|
<span class="min-w-0 truncate text-sm {active ? 'text-accent-soft' : 'text-fg'} {titleClass}"
|
||||||
class="min-w-0 truncate text-sm font-medium {active
|
>{title}</span
|
||||||
? 'text-accent-soft'
|
|
||||||
: 'text-fg'} {titleClass}">{title}</span
|
|
||||||
>
|
>
|
||||||
{@render badge?.()}
|
{@render badge?.()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export const EXPLORE_ALBUMS_LIMIT = 18;
|
|||||||
export const HOME_LATEST_PAGE_SIZE = 10;
|
export const HOME_LATEST_PAGE_SIZE = 10;
|
||||||
export const HOME_MIXES_LIMIT = 6;
|
export const HOME_MIXES_LIMIT = 6;
|
||||||
export const HOME_SHELF_LIMIT = 6;
|
export const HOME_SHELF_LIMIT = 6;
|
||||||
export const HOME_CONTINUE_LIMIT = 6;
|
export const HOME_CONTINUE_LIMIT = 10;
|
||||||
export const HOME_SPOTLIGHT_TRACKS_LIMIT = 5;
|
export const HOME_SPOTLIGHT_TRACKS_LIMIT = 5;
|
||||||
export const HOME_POPULAR_MAX = 8;
|
export const HOME_POPULAR_MAX = 8;
|
||||||
export const PLAYLIST_PICKER_PAGE_SIZE = 50;
|
export const PLAYLIST_PICKER_PAGE_SIZE = 50;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export function fmtDurationLong(seconds: number): string {
|
|||||||
const total = Math.max(0, Math.floor(seconds || 0));
|
const total = Math.max(0, Math.floor(seconds || 0));
|
||||||
const hours = Math.floor(total / 3600);
|
const hours = Math.floor(total / 3600);
|
||||||
const minutes = Math.floor((total % 3600) / 60);
|
const minutes = Math.floor((total % 3600) / 60);
|
||||||
return hours > 0 ? `${hours} h ${minutes} min` : `${minutes} min`;
|
return hours > 0 ? `${hours} H ${minutes} Min` : `${minutes} Min`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dateFormatter = new Intl.DateTimeFormat('es', { dateStyle: 'medium' });
|
const dateFormatter = new Intl.DateTimeFormat('es', { dateStyle: 'medium' });
|
||||||
@@ -17,3 +17,8 @@ const dateFormatter = new Intl.DateTimeFormat('es', { dateStyle: 'medium' });
|
|||||||
export function fmtDate(value: string | number | Date): string {
|
export function fmtDate(value: string | number | Date): string {
|
||||||
return dateFormatter.format(new Date(value));
|
return dateFormatter.format(new Date(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fmtPlays(count: number | string | undefined): string {
|
||||||
|
const value = Math.max(0, Math.floor(Number(count ?? 0)));
|
||||||
|
return `${value} ${value === 1 ? 'Reproducción' : 'Reproducciones'}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ export function mixItemTarget(item: MixItem): TrackTarget {
|
|||||||
track: {
|
track: {
|
||||||
id: item.trackId,
|
id: item.trackId,
|
||||||
title: item.title,
|
title: item.title,
|
||||||
artist: item.artist
|
artist: item.artist,
|
||||||
|
listensCount: item.listensCount
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export interface LikedTrack {
|
|||||||
artist?: string;
|
artist?: string;
|
||||||
explicit?: boolean;
|
explicit?: boolean;
|
||||||
ownerUserId?: string | number | null;
|
ownerUserId?: string | number | null;
|
||||||
|
listensCount?: number | string;
|
||||||
likedAt: number;
|
likedAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,6 +38,7 @@ class LikedStore {
|
|||||||
artist: item.artist,
|
artist: item.artist,
|
||||||
explicit: item.explicit,
|
explicit: item.explicit,
|
||||||
ownerUserId: item.ownerUserId,
|
ownerUserId: item.ownerUserId,
|
||||||
|
listensCount: item.listensCount,
|
||||||
likedAt: new Date(tracks[i].createdAt).getTime()
|
likedAt: new Date(tracks[i].createdAt).getTime()
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export interface PlayerTrack {
|
|||||||
duration: number;
|
duration: number;
|
||||||
explicit?: boolean;
|
explicit?: boolean;
|
||||||
ownerUserId?: string | number | null;
|
ownerUserId?: string | number | null;
|
||||||
|
listensCount?: number | string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Playlist {
|
export interface Playlist {
|
||||||
@@ -23,6 +24,7 @@ export interface QueueItem {
|
|||||||
artist?: string;
|
artist?: string;
|
||||||
explicit?: boolean;
|
explicit?: boolean;
|
||||||
ownerUserId?: string | number | null;
|
ownerUserId?: string | number | null;
|
||||||
|
listensCount?: number | string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiTrackLike {
|
export interface ApiTrackLike {
|
||||||
@@ -31,6 +33,7 @@ export interface ApiTrackLike {
|
|||||||
artist?: string | null;
|
artist?: string | null;
|
||||||
isExplicit?: boolean;
|
isExplicit?: boolean;
|
||||||
ownerUserId?: string | number | null;
|
ownerUserId?: string | number | null;
|
||||||
|
listensCount?: number | string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toQueueItems(tracks: ApiTrackLike[]): QueueItem[] {
|
export function toQueueItems(tracks: ApiTrackLike[]): QueueItem[] {
|
||||||
@@ -39,7 +42,8 @@ export function toQueueItems(tracks: ApiTrackLike[]): QueueItem[] {
|
|||||||
title: track.title,
|
title: track.title,
|
||||||
artist: track.artist ?? undefined,
|
artist: track.artist ?? undefined,
|
||||||
explicit: track.isExplicit,
|
explicit: track.isExplicit,
|
||||||
ownerUserId: track.ownerUserId
|
ownerUserId: track.ownerUserId,
|
||||||
|
listensCount: track.listensCount
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +72,8 @@ function toTrack(item: QueueItem): PlayerTrack {
|
|||||||
artist: item.artist ?? '',
|
artist: item.artist ?? '',
|
||||||
duration: 0,
|
duration: 0,
|
||||||
explicit: item.explicit,
|
explicit: item.explicit,
|
||||||
ownerUserId: item.ownerUserId
|
ownerUserId: item.ownerUserId,
|
||||||
|
listensCount: item.listensCount
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ export function mergeRecentlyPlayed(history: ApiTrackLike[], limit: number): Que
|
|||||||
title: t.title,
|
title: t.title,
|
||||||
artist: t.artist,
|
artist: t.artist,
|
||||||
explicit: t.explicit,
|
explicit: t.explicit,
|
||||||
ownerUserId: t.ownerUserId
|
ownerUserId: t.ownerUserId,
|
||||||
|
listensCount: t.listensCount
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,7 +31,8 @@ export function mergeRecentlyPlayed(history: ApiTrackLike[], limit: number): Que
|
|||||||
title: item.title,
|
title: item.title,
|
||||||
artist: item.artist,
|
artist: item.artist,
|
||||||
explicit: item.explicit,
|
explicit: item.explicit,
|
||||||
ownerUserId: item.ownerUserId
|
ownerUserId: item.ownerUserId,
|
||||||
|
listensCount: item.listensCount
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,10 +33,12 @@
|
|||||||
rgba(6, 6, 9, 0) 70%
|
rgba(6, 6, 9, 0) 70%
|
||||||
);
|
);
|
||||||
--mf-logo-grad: linear-gradient(140deg, oklch(0.78 0.15 145), oklch(0.62 0.13 185) 92%);
|
--mf-logo-grad: linear-gradient(140deg, oklch(0.78 0.15 145), oklch(0.62 0.13 185) 92%);
|
||||||
--mf-logo-glow: 0 0 20px oklch(0.74 0.145 145 / 0.3);
|
|
||||||
--mf-cover-grad: linear-gradient(150deg, oklch(0.5 0.13 145), oklch(0.16 0.035 145));
|
--mf-cover-grad: linear-gradient(150deg, oklch(0.5 0.13 145), oklch(0.16 0.035 145));
|
||||||
--mf-liked-grad: linear-gradient(150deg, oklch(0.4 0.09 330), oklch(0.18 0.04 10));
|
--mf-liked-grad: linear-gradient(150deg, oklch(0.4 0.09 330), oklch(0.18 0.04 10));
|
||||||
--mf-tile-shade: rgba(9, 9, 12, 0.95);
|
--mf-tile-l: 0.34;
|
||||||
|
--mf-tile-c: 0.13;
|
||||||
|
--mf-tile-shade-l: 0.14;
|
||||||
|
--mf-tile-shade-c: 0.05;
|
||||||
|
|
||||||
--mf-accent: oklch(0.74 0.145 145);
|
--mf-accent: oklch(0.74 0.145 145);
|
||||||
--mf-accent-title: oklch(0.87 0.07 145);
|
--mf-accent-title: oklch(0.87 0.07 145);
|
||||||
@@ -65,6 +67,7 @@
|
|||||||
|
|
||||||
--mf-scrim: #000000;
|
--mf-scrim: #000000;
|
||||||
--mf-on-art: #fefdfb;
|
--mf-on-art: #fefdfb;
|
||||||
|
--mf-on-art-2: #cac8c3;
|
||||||
--mf-ink: #0a0a0d;
|
--mf-ink: #0a0a0d;
|
||||||
--mf-cta-strong-bg: #ffffff;
|
--mf-cta-strong-bg: #ffffff;
|
||||||
|
|
||||||
@@ -125,28 +128,32 @@
|
|||||||
--mf-ink: #ffffff;
|
--mf-ink: #ffffff;
|
||||||
--mf-cta-strong-bg: #17171a;
|
--mf-cta-strong-bg: #17171a;
|
||||||
|
|
||||||
--mf-ambient:
|
--mf-tile-l: 0.58;
|
||||||
radial-gradient(52% 38% at 14% 0%, oklch(0.9 0.06 145 / 0.26), transparent 54%),
|
--mf-tile-c: 0.15;
|
||||||
radial-gradient(46% 34% at 78% 0%, oklch(0.9121 0.0497 145 / 0.2), transparent 56%);
|
--mf-tile-shade-l: 0.42;
|
||||||
|
--mf-tile-shade-c: 0.12;
|
||||||
|
--mf-liked-grad: linear-gradient(150deg, oklch(0.62 0.12 330), oklch(0.46 0.08 10));
|
||||||
|
|
||||||
|
--mf-ambient: radial-gradient(46% 34% at 78% 0%, oklch(0.7476 0.121 145 / 0.2), transparent 56%);
|
||||||
--mf-hero-bg: linear-gradient(
|
--mf-hero-bg: linear-gradient(
|
||||||
105deg,
|
105deg,
|
||||||
oklch(0.9145 0.0476 145 / 0.34) 0%,
|
oklch(0.7531 0.1172 145 / 0.34) 0%,
|
||||||
oklch(0.9483 0.0186 145 / 0.11) 37%,
|
oklch(0.8303 0.0641 145 / 0.11) 37%,
|
||||||
oklch(0.97 0 145 / 0.03) 65%,
|
oklch(0.88 0.03 145 / 0.03) 65%,
|
||||||
rgba(255, 255, 255, 0) 92%
|
rgba(255, 255, 255, 0) 92%
|
||||||
);
|
);
|
||||||
--mf-spotlight-bg: linear-gradient(
|
--mf-spotlight-bg: linear-gradient(
|
||||||
100deg,
|
100deg,
|
||||||
oklch(0.9193 0.0434 145 / 0.28) 0%,
|
oklch(0.7641 0.1097 145 / 0.28) 0%,
|
||||||
oklch(0.9531 0.0145 145 / 0.1) 40%,
|
oklch(0.8414 0.0566 145 / 0.1) 40%,
|
||||||
oklch(0.97 0 145 / 0.03) 69%,
|
oklch(0.88 0.03 145 / 0.03) 69%,
|
||||||
rgba(255, 255, 255, 0) 96%
|
rgba(255, 255, 255, 0) 96%
|
||||||
);
|
);
|
||||||
--mf-modal-glow: linear-gradient(
|
--mf-modal-glow: linear-gradient(
|
||||||
135deg,
|
135deg,
|
||||||
oklch(0.9145 0.0476 145 / 0.34) 0%,
|
oklch(0.7531 0.1172 145 / 0.34) 0%,
|
||||||
oklch(0.9483 0.0186 145 / 0.11) 28%,
|
oklch(0.8303 0.0641 145 / 0.11) 28%,
|
||||||
oklch(0.97 0 145 / 0.03) 50%,
|
oklch(0.88 0.03 145 / 0.03) 50%,
|
||||||
rgba(255, 255, 255, 0) 70%
|
rgba(255, 255, 255, 0) 70%
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,13 +13,14 @@ function tc(alpha: number, l: number, hue: number): string {
|
|||||||
return `oklch(${r(lightness)} ${r(chroma)} ${r(hue, 1)} / ${alpha})`;
|
return `oklch(${r(lightness)} ${r(chroma)} ${r(hue, 1)} / ${alpha})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Light-mode counterpart of tc(): fades toward the light canvas (~0.97
|
// Light-mode counterpart of tc(): fades toward a soft tinted glass (~0.88
|
||||||
// lightness, 0 chroma at l=88) instead of toward black, so the "glow"
|
// lightness) instead of toward black, so the "glow" gradients read as a
|
||||||
// gradients read as a soft tint instead of a dark smudge on a light bg.
|
// visible colored tint instead of a dark smudge — or, at the old lightness
|
||||||
|
// floor of 0.97, an almost invisible wash next to the near-white canvas.
|
||||||
function tcLight(alpha: number, l: number, hue: number): string {
|
function tcLight(alpha: number, l: number, hue: number): string {
|
||||||
const depth = (88 - l) / 58;
|
const depth = (88 - l) / 58;
|
||||||
const lightness = 0.97 - 0.07 * depth;
|
const lightness = 0.88 - 0.16 * depth;
|
||||||
const chroma = Math.max(0, 0.06 * depth);
|
const chroma = 0.03 + 0.11 * depth;
|
||||||
return `oklch(${r(lightness)} ${r(chroma)} ${r(hue, 1)} / ${alpha})`;
|
return `oklch(${r(lightness)} ${r(chroma)} ${r(hue, 1)} / ${alpha})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,15 +44,27 @@ export function buildThemeTokens(hue: number, mode: 'dark' | 'light' = 'dark'):
|
|||||||
const H2 = r(h2, 1);
|
const H2 = r(h2, 1);
|
||||||
const light = mode === 'light';
|
const light = mode === 'light';
|
||||||
|
|
||||||
|
// Dark mode wants a bright, light accent (pops on a near-black canvas).
|
||||||
|
// Light mode needs the opposite: the same hue pulled down to a dark,
|
||||||
|
// saturated shade so it still reads against a near-white canvas — using
|
||||||
|
// the dark-mode lightness here is what made accent text/icons/buttons
|
||||||
|
// nearly invisible in light mode.
|
||||||
|
const accentL = light ? 0.52 : L;
|
||||||
|
const accentC = light ? 0.16 : C;
|
||||||
|
const titleL = light ? 0.4 : 0.87;
|
||||||
|
const titleC = light ? 0.13 : 0.07;
|
||||||
|
const mutedL = light ? 0.48 : 0.79;
|
||||||
|
const mutedC = light ? 0.06 : 0.035;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'--mf-accent': `oklch(${r(L)} ${r(C)} ${H})`,
|
'--mf-accent': `oklch(${r(accentL)} ${r(accentC)} ${H})`,
|
||||||
'--mf-accent-title': `oklch(0.87 0.07 ${H})`,
|
'--mf-accent-title': `oklch(${r(titleL)} ${r(titleC)} ${H})`,
|
||||||
'--mf-accent-muted': `oklch(0.79 0.035 ${H})`,
|
'--mf-accent-muted': `oklch(${r(mutedL)} ${r(mutedC)} ${H})`,
|
||||||
'--mf-accent-soft-bg': `oklch(${r(L)} ${r(C)} ${H} / 0.08)`,
|
'--mf-accent-soft-bg': `oklch(${r(accentL)} ${r(accentC)} ${H} / 0.08)`,
|
||||||
'--mf-accent-line': `oklch(${r(L)} ${r(C)} ${H} / 0.2)`,
|
'--mf-accent-line': `oklch(${r(accentL)} ${r(accentC)} ${H} / 0.2)`,
|
||||||
'--mf-accent-hair': `oklch(${r(L)} ${r(C)} ${H} / 0.09)`,
|
'--mf-accent-hair': `oklch(${r(accentL)} ${r(accentC)} ${H} / 0.09)`,
|
||||||
'--mf-accent-btn-bg': `oklch(${r(L)} ${r(C)} ${H} / 0.15)`,
|
'--mf-accent-btn-bg': `oklch(${r(accentL)} ${r(accentC)} ${H} / 0.15)`,
|
||||||
'--mf-accent-btn-bg-hover': `oklch(${r(L)} ${r(C)} ${H} / 0.24)`,
|
'--mf-accent-btn-bg-hover': `oklch(${r(accentL)} ${r(accentC)} ${H} / 0.24)`,
|
||||||
'--mf-bg': light ? `oklch(0.97 0.006 ${H})` : `oklch(0.068 0.008 ${H})`,
|
'--mf-bg': light ? `oklch(0.97 0.006 ${H})` : `oklch(0.068 0.008 ${H})`,
|
||||||
'--mf-elevated': light ? `oklch(0.99 0.003 ${H})` : `oklch(0.11 0.012 ${H})`,
|
'--mf-elevated': light ? `oklch(0.99 0.003 ${H})` : `oklch(0.11 0.012 ${H})`,
|
||||||
'--mf-sidebar-bg': light
|
'--mf-sidebar-bg': light
|
||||||
@@ -61,7 +74,7 @@ export function buildThemeTokens(hue: number, mode: 'dark' | 'light' = 'dark'):
|
|||||||
'--mf-bar-bg': light ? `oklch(0.97 0.006 ${H} / 0.9)` : `oklch(0.105 0.012 ${H} / 0.9)`,
|
'--mf-bar-bg': light ? `oklch(0.97 0.006 ${H} / 0.9)` : `oklch(0.105 0.012 ${H} / 0.9)`,
|
||||||
'--mf-hairline': light ? `oklch(0.3 0.02 ${H} / 0.1)` : `oklch(0.62 0.03 ${H} / 0.08)`,
|
'--mf-hairline': light ? `oklch(0.3 0.02 ${H} / 0.1)` : `oklch(0.62 0.03 ${H} / 0.08)`,
|
||||||
'--mf-ambient': light
|
'--mf-ambient': light
|
||||||
? `radial-gradient(52% 38% at 14% 0%, ${tcLight(0.26, 30, h)}, transparent 54%), radial-gradient(46% 34% at 78% 0%, ${tcLight(0.2, 40, h)}, transparent 56%)`
|
? `radial-gradient(46% 34% at 78% 0%, ${tcLight(0.2, 40, h)}, transparent 56%)`
|
||||||
: `radial-gradient(52% 38% at 14% 0%, ${tc(0.26, 30, h)}, transparent 54%), radial-gradient(46% 34% at 78% 0%, ${tc(0.2, 40, h)}, transparent 56%)`,
|
: `radial-gradient(52% 38% at 14% 0%, ${tc(0.26, 30, h)}, transparent 54%), radial-gradient(46% 34% at 78% 0%, ${tc(0.2, 40, h)}, transparent 56%)`,
|
||||||
'--mf-hero-bg': light
|
'--mf-hero-bg': light
|
||||||
? `linear-gradient(105deg, ${tcLight(0.34, 42, h)} 0%, ${tcLight(0.11, 70, h)} 37%, ${tcLight(0.03, 88, h)} 65%, rgba(255,255,255,0) 92%)`
|
? `linear-gradient(105deg, ${tcLight(0.34, 42, h)} 0%, ${tcLight(0.11, 70, h)} 37%, ${tcLight(0.03, 88, h)} 65%, rgba(255,255,255,0) 92%)`
|
||||||
@@ -72,9 +85,12 @@ export function buildThemeTokens(hue: number, mode: 'dark' | 'light' = 'dark'):
|
|||||||
'--mf-spotlight-bg': light
|
'--mf-spotlight-bg': light
|
||||||
? `linear-gradient(100deg, ${tcLight(0.28, 46, h)} 0%, ${tcLight(0.1, 74, h)} 40%, ${tcLight(0.03, 88, h)} 69%, rgba(255,255,255,0) 96%)`
|
? `linear-gradient(100deg, ${tcLight(0.28, 46, h)} 0%, ${tcLight(0.1, 74, h)} 40%, ${tcLight(0.03, 88, h)} 69%, rgba(255,255,255,0) 96%)`
|
||||||
: `linear-gradient(100deg, ${tc(0.28, 46, h)} 0%, ${tc(0.1, 74, h)} 40%, ${tc(0.03, 88, h)} 69%, rgba(6,6,9,0) 96%)`,
|
: `linear-gradient(100deg, ${tc(0.28, 46, h)} 0%, ${tc(0.1, 74, h)} 40%, ${tc(0.03, 88, h)} 69%, rgba(6,6,9,0) 96%)`,
|
||||||
'--mf-logo-grad': `linear-gradient(140deg, oklch(0.78 0.15 ${H}), oklch(0.62 0.13 ${H2}) 92%)`,
|
'--mf-logo-grad': light
|
||||||
'--mf-logo-glow': `0 0 20px oklch(${r(L)} ${r(C)} ${H} / 0.3)`,
|
? `linear-gradient(140deg, oklch(0.68 0.16 ${H}), oklch(0.5 0.14 ${H2}) 92%)`
|
||||||
'--mf-cover-grad': `linear-gradient(150deg, oklch(0.5 0.13 ${H}), oklch(0.16 0.035 ${H}))`
|
: `linear-gradient(140deg, oklch(0.78 0.15 ${H}), oklch(0.62 0.13 ${H2}) 92%)`,
|
||||||
|
'--mf-cover-grad': light
|
||||||
|
? `linear-gradient(150deg, oklch(0.62 0.14 ${H}), oklch(0.44 0.08 ${H}))`
|
||||||
|
: `linear-gradient(150deg, oklch(0.5 0.13 ${H}), oklch(0.16 0.035 ${H}))`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
import {
|
import { player, toQueueItems, type ApiTrackLike, type QueueItem } from '$lib/player/player.svelte';
|
||||||
player,
|
|
||||||
toQueueItems,
|
|
||||||
type ApiTrackLike,
|
|
||||||
type QueueItem
|
|
||||||
} from '$lib/player/player.svelte';
|
|
||||||
|
|
||||||
export type TrackTarget = { track: ApiTrackLike };
|
export type TrackTarget = { track: ApiTrackLike };
|
||||||
|
|
||||||
@@ -27,6 +22,10 @@ export function targetExplicit(target: TrackTarget): boolean {
|
|||||||
return target.track.isExplicit ?? false;
|
return target.track.isExplicit ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function targetListensCount(target: TrackTarget): number | string | undefined {
|
||||||
|
return target.track.listensCount;
|
||||||
|
}
|
||||||
|
|
||||||
export function isTargetCurrent(target: TrackTarget): boolean {
|
export function isTargetCurrent(target: TrackTarget): boolean {
|
||||||
return player.current.id === target.track.id;
|
return player.current.id === target.track.id;
|
||||||
}
|
}
|
||||||
@@ -42,7 +41,8 @@ export function targetForQueueItem(item: QueueItem): TrackTarget {
|
|||||||
title: item.title,
|
title: item.title,
|
||||||
artist: item.artist,
|
artist: item.artist,
|
||||||
isExplicit: item.explicit,
|
isExplicit: item.explicit,
|
||||||
ownerUserId: item.ownerUserId
|
ownerUserId: item.ownerUserId,
|
||||||
|
listensCount: item.listensCount
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@
|
|||||||
{trackIds}
|
{trackIds}
|
||||||
{coverUrl}
|
{coverUrl}
|
||||||
size="large"
|
size="large"
|
||||||
class="h-36 w-36 shrink-0 rounded-[18px] shadow-cover-lg sm:h-41 sm:w-41"
|
class="h-36 w-36 shrink-0 rounded-art-lg sm:h-41 sm:w-41"
|
||||||
/>
|
/>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet actions()}
|
{#snippet actions()}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
import TrackIndexCell from '$lib/components/ui/TrackIndexCell.svelte';
|
import TrackIndexCell from '$lib/components/ui/TrackIndexCell.svelte';
|
||||||
import TrackTitleCell from '$lib/components/ui/TrackTitleCell.svelte';
|
import TrackTitleCell from '$lib/components/ui/TrackTitleCell.svelte';
|
||||||
|
|
||||||
type AlbumTrack = ApiTrackLike & { duration: number | string };
|
type AlbumTrack = ApiTrackLike & { duration: number | string; listensCount: number | string };
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
tracks: AlbumTrack[];
|
tracks: AlbumTrack[];
|
||||||
@@ -24,7 +24,12 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<TrackTable index action={isOwner} class="mt-6 sm:mt-8">
|
<TrackTable
|
||||||
|
index
|
||||||
|
action={isOwner}
|
||||||
|
meta={[{ label: 'Escuchas', width: '100px' }]}
|
||||||
|
class="mt-6 sm:mt-8"
|
||||||
|
>
|
||||||
{#each tracks as track, i (track.id)}
|
{#each tracks as track, i (track.id)}
|
||||||
{@const active = player.current.id === track.id}
|
{@const active = player.current.id === track.id}
|
||||||
<TrackRow {active}>
|
<TrackRow {active}>
|
||||||
@@ -38,6 +43,7 @@
|
|||||||
{active}
|
{active}
|
||||||
onClick={() => playFrom(i)}
|
onClick={() => playFrom(i)}
|
||||||
/>
|
/>
|
||||||
|
<TrackMeta class="hidden sm:block">{Number(track.listensCount)}</TrackMeta>
|
||||||
<TrackMeta>{fmtTime(Number(track.duration))}</TrackMeta>
|
<TrackMeta>{fmtTime(Number(track.duration))}</TrackMeta>
|
||||||
{#if isOwner}
|
{#if isOwner}
|
||||||
<form
|
<form
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
import TrackTitleCell from '$lib/components/ui/TrackTitleCell.svelte';
|
import TrackTitleCell from '$lib/components/ui/TrackTitleCell.svelte';
|
||||||
import NowPlaying from '$lib/components/ui/NowPlaying.svelte';
|
import NowPlaying from '$lib/components/ui/NowPlaying.svelte';
|
||||||
|
|
||||||
type LibraryTrack = ApiTrackLike & { duration: number | string };
|
type LibraryTrack = ApiTrackLike & { duration: number | string; listensCount: number | string };
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
library: LibraryTrack[];
|
library: LibraryTrack[];
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
|
|
||||||
<div class="mt-10 sm:mt-12">
|
<div class="mt-10 sm:mt-12">
|
||||||
<SectionHeading title="Tus canciones subidas" />
|
<SectionHeading title="Tus canciones subidas" />
|
||||||
<TrackTable action>
|
<TrackTable action meta={[{ label: 'Escuchas', width: '100px' }]}>
|
||||||
{#each library as track, i (track.id)}
|
{#each library as track, i (track.id)}
|
||||||
{@const active = player.current.id === track.id}
|
{@const active = player.current.id === track.id}
|
||||||
<TrackRow {active}>
|
<TrackRow {active}>
|
||||||
@@ -45,6 +45,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</TrackTitleCell>
|
</TrackTitleCell>
|
||||||
|
<TrackMeta class="hidden sm:block">{Number(track.listensCount)}</TrackMeta>
|
||||||
<TrackMeta>{fmtTime(Number(track.duration))}</TrackMeta>
|
<TrackMeta>{fmtTime(Number(track.duration))}</TrackMeta>
|
||||||
<form
|
<form
|
||||||
method="POST"
|
method="POST"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import Play from '@lucide/svelte/icons/play';
|
import Play from '@lucide/svelte/icons/play';
|
||||||
import { player } from '$lib/player/player.svelte';
|
import { player } from '$lib/player/player.svelte';
|
||||||
import { fetchAlbumQueueItems } from '$lib/albums';
|
import { fetchAlbumQueueItems } from '$lib/albums';
|
||||||
import { fmtTime } from '$lib/format';
|
import { fmtTime, fmtPlays } from '$lib/format';
|
||||||
import Page from '$lib/components/ui/Page.svelte';
|
import Page from '$lib/components/ui/Page.svelte';
|
||||||
import PageHeader from '$lib/components/ui/PageHeader.svelte';
|
import PageHeader from '$lib/components/ui/PageHeader.svelte';
|
||||||
import Alert from '$lib/components/ui/Alert.svelte';
|
import Alert from '$lib/components/ui/Alert.svelte';
|
||||||
@@ -30,6 +30,7 @@
|
|||||||
targetArtist,
|
targetArtist,
|
||||||
targetExplicit,
|
targetExplicit,
|
||||||
targetId,
|
targetId,
|
||||||
|
targetListensCount,
|
||||||
targetTitle,
|
targetTitle,
|
||||||
type TrackTarget
|
type TrackTarget
|
||||||
} from '$lib/tracks';
|
} from '$lib/tracks';
|
||||||
@@ -299,23 +300,23 @@
|
|||||||
trackId={targetId(topResult.target)}
|
trackId={targetId(topResult.target)}
|
||||||
size="large"
|
size="large"
|
||||||
alt={targetTitle(topResult.target)}
|
alt={targetTitle(topResult.target)}
|
||||||
class="h-22 w-22 shrink-0 rounded-2xl shadow-art"
|
class="h-22 w-22 shrink-0 rounded-2xl"
|
||||||
/>
|
/>
|
||||||
{:else if topResult.kind === 'album'}
|
{:else if topResult.kind === 'album'}
|
||||||
<PlaylistArt
|
<PlaylistArt
|
||||||
trackIds={topResult.album.coverTrackIds}
|
trackIds={topResult.album.coverTrackIds}
|
||||||
class="h-22 w-22 shrink-0 rounded-2xl shadow-art"
|
class="h-22 w-22 shrink-0 rounded-2xl"
|
||||||
/>
|
/>
|
||||||
{:else if topResult.kind === 'playlist'}
|
{:else if topResult.kind === 'playlist'}
|
||||||
<PlaylistArt
|
<PlaylistArt
|
||||||
playlistId={topResult.playlist.id}
|
playlistId={topResult.playlist.id}
|
||||||
trackIds={topResult.playlist.coverTrackIds}
|
trackIds={topResult.playlist.coverTrackIds}
|
||||||
version={topResult.playlist.updatedAt}
|
version={topResult.playlist.updatedAt}
|
||||||
class="h-22 w-22 shrink-0 rounded-2xl shadow-art"
|
class="h-22 w-22 shrink-0 rounded-2xl"
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<div
|
<div
|
||||||
class="grid h-22 w-22 shrink-0 place-items-center overflow-hidden rounded-full bg-surface-2 shadow-art"
|
class="grid h-22 w-22 shrink-0 place-items-center overflow-hidden rounded-full bg-surface-2"
|
||||||
>
|
>
|
||||||
{#if topResult.user.profilePictureUrl}
|
{#if topResult.user.profilePictureUrl}
|
||||||
<img
|
<img
|
||||||
@@ -385,7 +386,7 @@
|
|||||||
|
|
||||||
{#if nothingFound}
|
{#if nothingFound}
|
||||||
<div class="mx-auto max-w-115 py-[8vh] text-center">
|
<div class="mx-auto max-w-115 py-[8vh] text-center">
|
||||||
<h1 class="font-display text-xl font-semibold tracking-[-0.025em] text-fg">
|
<h1 class="font-display text-xl font-semibold tracking-tight text-fg">
|
||||||
Sin resultados para «{data.query}»
|
Sin resultados para «{data.query}»
|
||||||
</h1>
|
</h1>
|
||||||
<p class="mt-2.5 text-sm leading-[1.65] text-fg-2">
|
<p class="mt-2.5 text-sm leading-[1.65] text-fg-2">
|
||||||
@@ -411,14 +412,14 @@
|
|||||||
{#each genreTiles as genre, i (genre.query)}
|
{#each genreTiles as genre, i (genre.query)}
|
||||||
<a
|
<a
|
||||||
href={buildHref(genre.query)}
|
href={buildHref(genre.query)}
|
||||||
class="animate-enter group relative block h-28 overflow-hidden rounded-2xl p-4.25 transition-[filter] hover:brightness-125"
|
class="animate-enter group relative flex h-28 flex-col gap-1 overflow-hidden rounded-2xl p-4.25 transition-[filter] hover:brightness-125"
|
||||||
style="animation-delay:{Math.min(i, 10) *
|
style="animation-delay:{Math.min(i, 10) *
|
||||||
40}ms; background:linear-gradient(140deg, oklch(0.24 0.07 {genre.hue}), var(--mf-tile-shade))"
|
40}ms; background:linear-gradient(140deg, oklch(var(--mf-tile-l) var(--mf-tile-c) {genre.hue}), oklch(var(--mf-tile-shade-l) var(--mf-tile-shade-c) {genre.hue}))"
|
||||||
>
|
>
|
||||||
<div class="font-display text-base font-semibold tracking-[-0.02em] text-fg">
|
<div class="font-display text-base font-medium tracking-tight text-on-art">
|
||||||
{genre.label}
|
{genre.label}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1.5 text-xs text-fg-3">{genre.tagline}</div>
|
<div class="text-xs text-on-art-2">{genre.tagline}</div>
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -445,7 +446,7 @@
|
|||||||
kind="track"
|
kind="track"
|
||||||
title={targetTitle(item)}
|
title={targetTitle(item)}
|
||||||
subtitle={targetArtist(item)}
|
subtitle={targetArtist(item)}
|
||||||
meta={fmtTime(seconds)}
|
meta="{fmtTime(seconds)} · {fmtPlays(targetListensCount(item))}"
|
||||||
explicit={targetExplicit(item)}
|
explicit={targetExplicit(item)}
|
||||||
onclick={() => togglePlay(i)}
|
onclick={() => togglePlay(i)}
|
||||||
oncontextmenu={(e) => openContextMenu(e, item)}
|
oncontextmenu={(e) => openContextMenu(e, item)}
|
||||||
|
|||||||
@@ -51,9 +51,9 @@
|
|||||||
index
|
index
|
||||||
action
|
action
|
||||||
meta={[
|
meta={[
|
||||||
{ label: 'Álbum', width: '160px' },
|
{ label: 'Álbum', width: '76px' },
|
||||||
{ label: 'Subida', width: '100px' },
|
{ label: 'Subida', width: '124px' },
|
||||||
{ label: 'Escuchas', width: '100px' }
|
{ label: 'Escuchas', width: '106px' }
|
||||||
]}
|
]}
|
||||||
class="mt-7 sm:mt-8"
|
class="mt-7 sm:mt-8"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -53,7 +53,7 @@
|
|||||||
>
|
>
|
||||||
{#snippet cover()}
|
{#snippet cover()}
|
||||||
<div
|
<div
|
||||||
class="grid h-36 w-36 shrink-0 place-items-center rounded-[18px] bg-[image:var(--mf-liked-grad)] shadow-cover-lg sm:h-41 sm:w-41"
|
class="grid h-36 w-36 shrink-0 place-items-center rounded-art-lg bg-[image:var(--mf-liked-grad)] sm:h-41 sm:w-41"
|
||||||
>
|
>
|
||||||
<Heart class="h-10 w-10 text-on-art/90" fill="currentColor" strokeWidth={0} />
|
<Heart class="h-10 w-10 text-on-art/90" fill="currentColor" strokeWidth={0} />
|
||||||
</div>
|
</div>
|
||||||
@@ -76,8 +76,9 @@
|
|||||||
index
|
index
|
||||||
action
|
action
|
||||||
meta={[
|
meta={[
|
||||||
{ label: 'Álbum', width: '160px' },
|
{ label: 'Álbum', width: '76px' },
|
||||||
{ label: 'Añadida', width: '140px' }
|
{ label: 'Añadida', width: '124px' },
|
||||||
|
{ label: 'Escuchas', width: '106px' }
|
||||||
]}
|
]}
|
||||||
class="mt-6 sm:mt-8"
|
class="mt-6 sm:mt-8"
|
||||||
>
|
>
|
||||||
@@ -101,6 +102,7 @@
|
|||||||
/>
|
/>
|
||||||
<TrackMeta class="hidden sm:block">—</TrackMeta>
|
<TrackMeta class="hidden sm:block">—</TrackMeta>
|
||||||
<TrackMeta class="hidden sm:block">{fmtDate(track.likedAt)}</TrackMeta>
|
<TrackMeta class="hidden sm:block">{fmtDate(track.likedAt)}</TrackMeta>
|
||||||
|
<TrackMeta class="hidden sm:block">{Number(track.listensCount ?? 0)}</TrackMeta>
|
||||||
<TrackMeta>—</TrackMeta>
|
<TrackMeta>—</TrackMeta>
|
||||||
<IconButton
|
<IconButton
|
||||||
label="Quitar de Me gusta"
|
label="Quitar de Me gusta"
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
queueItemForTarget,
|
queueItemForTarget,
|
||||||
targetArtist,
|
targetArtist,
|
||||||
targetId,
|
targetId,
|
||||||
|
targetListensCount,
|
||||||
targetTitle
|
targetTitle
|
||||||
} from '$lib/tracks';
|
} from '$lib/tracks';
|
||||||
|
|
||||||
@@ -74,7 +75,7 @@
|
|||||||
meta="{items.length} {items.length === 1 ? 'canción' : 'canciones'} · {fmtTime(totalSeconds)}"
|
meta="{items.length} {items.length === 1 ? 'canción' : 'canciones'} · {fmtTime(totalSeconds)}"
|
||||||
>
|
>
|
||||||
{#snippet cover()}
|
{#snippet cover()}
|
||||||
<MixArt {items} class="h-36 w-36 shrink-0 rounded-[18px] shadow-cover-lg sm:h-41 sm:w-41" />
|
<MixArt {items} class="h-36 w-36 shrink-0 rounded-art-lg sm:h-41 sm:w-41" />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet actions()}
|
{#snippet actions()}
|
||||||
<Button size="sm" onclick={playAll} disabled={queue.length === 0}>
|
<Button size="sm" onclick={playAll} disabled={queue.length === 0}>
|
||||||
@@ -97,7 +98,7 @@
|
|||||||
<Alert tone="danger" class="mt-4">{form.message}</Alert>
|
<Alert tone="danger" class="mt-4">{form.message}</Alert>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<TrackTable index class="mt-6 sm:mt-8">
|
<TrackTable index meta={[{ label: 'Escuchas', width: '100px' }]} class="mt-6 sm:mt-8">
|
||||||
{#each items as item, i (mixItemKey(item))}
|
{#each items as item, i (mixItemKey(item))}
|
||||||
{@const active = isTargetCurrent(targets[i])}
|
{@const active = isTargetCurrent(targets[i])}
|
||||||
<TrackRow {active} oncontextmenu={(e) => (contextMenu = contextMenuStateFor(e, targets[i]))}>
|
<TrackRow {active} oncontextmenu={(e) => (contextMenu = contextMenuStateFor(e, targets[i]))}>
|
||||||
@@ -109,6 +110,7 @@
|
|||||||
{active}
|
{active}
|
||||||
onClick={() => playFrom(i)}
|
onClick={() => playFrom(i)}
|
||||||
/>
|
/>
|
||||||
|
<TrackMeta class="hidden sm:block">{Number(targetListensCount(targets[i]) ?? 0)}</TrackMeta>
|
||||||
<TrackMeta>{fmtTime(Number(item.durationSeconds))}</TrackMeta>
|
<TrackMeta>{fmtTime(Number(item.durationSeconds))}</TrackMeta>
|
||||||
</TrackRow>
|
</TrackRow>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
const items = $derived(data.playlists.items);
|
const items = $derived(data.playlists.items);
|
||||||
const totals = $derived(data.totals);
|
const totals = $derived(data.totals);
|
||||||
const summary = $derived(
|
const summary = $derived(
|
||||||
`${totals.playlistCount} ${totals.playlistCount === 1 ? 'playlist' : 'playlists'} · ${totals.trackCount} ${totals.trackCount === 1 ? 'canción' : 'canciones'} · ${fmtDurationLong(totals.durationSeconds)}`
|
`${totals.playlistCount} ${totals.playlistCount === 1 ? 'Playlist' : 'Playlists'} · ${totals.trackCount} ${totals.trackCount === 1 ? 'Canción' : 'Canciones'} · ${fmtDurationLong(totals.durationSeconds)}`
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const meta = $derived(
|
const meta = $derived(
|
||||||
`${trackIds.length} ${trackIds.length === 1 ? 'canción' : 'canciones'} · ${
|
`${trackIds.length} ${trackIds.length === 1 ? 'Canción' : 'Canciones'} · ${
|
||||||
visibility === 'Public' ? 'Pública' : 'Privada'
|
visibility === 'Public' ? 'Pública' : 'Privada'
|
||||||
}`
|
}`
|
||||||
);
|
);
|
||||||
@@ -49,16 +49,12 @@
|
|||||||
{trackIds}
|
{trackIds}
|
||||||
size="large"
|
size="large"
|
||||||
version={updatedAt}
|
version={updatedAt}
|
||||||
class="h-36 w-36 shrink-0 rounded-[18px] shadow-cover-lg sm:h-41 sm:w-41"
|
class="h-36 w-36 shrink-0 rounded-art-lg sm:h-41 sm:w-41"
|
||||||
/>
|
/>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet actions()}
|
{#snippet actions()}
|
||||||
<Button size="sm" onclick={onPlayAll} disabled={trackIds.length === 0}>
|
<Button size="sm" onclick={onPlayAll} disabled={trackIds.length === 0}>
|
||||||
{#if playing}
|
{playing ? 'Pausar' : 'Reproducir'}
|
||||||
Pausar
|
|
||||||
{:else}
|
|
||||||
Reproducir
|
|
||||||
{/if}
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="secondary" onclick={onEdit}>Editar</Button>
|
<Button size="sm" variant="secondary" onclick={onEdit}>Editar</Button>
|
||||||
<Button size="sm" variant="secondary" onclick={onDelete}>Eliminar</Button>
|
<Button size="sm" variant="secondary" onclick={onDelete}>Eliminar</Button>
|
||||||
|
|||||||
@@ -10,7 +10,11 @@
|
|||||||
import TrackIndexCell from '$lib/components/ui/TrackIndexCell.svelte';
|
import TrackIndexCell from '$lib/components/ui/TrackIndexCell.svelte';
|
||||||
import TrackTitleCell from '$lib/components/ui/TrackTitleCell.svelte';
|
import TrackTitleCell from '$lib/components/ui/TrackTitleCell.svelte';
|
||||||
|
|
||||||
type PlaylistTrack = ApiTrackLike & { createdAt: string; duration: number | string };
|
type PlaylistTrack = ApiTrackLike & {
|
||||||
|
createdAt: string;
|
||||||
|
duration: number | string;
|
||||||
|
listensCount: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
tracks: PlaylistTrack[];
|
tracks: PlaylistTrack[];
|
||||||
@@ -27,8 +31,9 @@
|
|||||||
index
|
index
|
||||||
action
|
action
|
||||||
meta={[
|
meta={[
|
||||||
{ label: 'Álbum', width: '160px' },
|
{ label: 'Álbum', width: '76px' },
|
||||||
{ label: 'Añadida', width: '140px' }
|
{ label: 'Añadida', width: '124px' },
|
||||||
|
{ label: 'Escuchas', width: '106px' }
|
||||||
]}
|
]}
|
||||||
class="mt-6 sm:mt-8"
|
class="mt-6 sm:mt-8"
|
||||||
>
|
>
|
||||||
@@ -47,6 +52,7 @@
|
|||||||
/>
|
/>
|
||||||
<TrackMeta class="hidden sm:block">—</TrackMeta>
|
<TrackMeta class="hidden sm:block">—</TrackMeta>
|
||||||
<TrackMeta class="hidden sm:block">{fmtDate(track.createdAt)}</TrackMeta>
|
<TrackMeta class="hidden sm:block">{fmtDate(track.createdAt)}</TrackMeta>
|
||||||
|
<TrackMeta class="hidden sm:block">{Number(track.listensCount)}</TrackMeta>
|
||||||
<TrackMeta>{fmtTime(Number(track.duration))}</TrackMeta>
|
<TrackMeta>{fmtTime(Number(track.duration))}</TrackMeta>
|
||||||
<form
|
<form
|
||||||
method="POST"
|
method="POST"
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
import History from '@lucide/svelte/icons/history';
|
import History from '@lucide/svelte/icons/history';
|
||||||
import Shuffle from '@lucide/svelte/icons/shuffle';
|
import Shuffle from '@lucide/svelte/icons/shuffle';
|
||||||
import ListMusic from '@lucide/svelte/icons/list-music';
|
import ListMusic from '@lucide/svelte/icons/list-music';
|
||||||
import { player } from '$lib/player/player.svelte';
|
import { player } from '$lib/player/player.svelte';
|
||||||
import { mergeRecentlyPlayed } from '$lib/recentlyPlayed';
|
import { mergeRecentlyPlayed } from '$lib/recentlyPlayed';
|
||||||
import { shuffle as shuffleList } from '$lib/collections';
|
import { shuffle as shuffleList } from '$lib/collections';
|
||||||
import { fmtTime, fmtDurationLong } from '$lib/format';
|
import { fmtTime, fmtDurationLong, fmtPlays } from '$lib/format';
|
||||||
import {
|
import {
|
||||||
isTargetCurrent,
|
isTargetCurrent,
|
||||||
queueItemForTarget,
|
queueItemForTarget,
|
||||||
@@ -13,6 +14,7 @@
|
|||||||
targetExplicit,
|
targetExplicit,
|
||||||
targetForQueueItem,
|
targetForQueueItem,
|
||||||
targetId,
|
targetId,
|
||||||
|
targetListensCount,
|
||||||
targetOwnerUserId,
|
targetOwnerUserId,
|
||||||
targetTitle,
|
targetTitle,
|
||||||
type TrackTarget
|
type TrackTarget
|
||||||
@@ -109,6 +111,12 @@
|
|||||||
player.playQueue(shuffleList(spotlightQueue), 0);
|
player.playQueue(shuffleList(spotlightQueue), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onRowKeydown(event: KeyboardEvent, action: () => void) {
|
||||||
|
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||||
|
event.preventDefault();
|
||||||
|
action();
|
||||||
|
}
|
||||||
|
|
||||||
function openContextMenu(event: MouseEvent, target: TrackTarget) {
|
function openContextMenu(event: MouseEvent, target: TrackTarget) {
|
||||||
contextMenu = contextMenuStateFor(event, target);
|
contextMenu = contextMenuStateFor(event, target);
|
||||||
}
|
}
|
||||||
@@ -126,14 +134,14 @@
|
|||||||
|
|
||||||
<section class="page-x pt-3 sm:pt-4">
|
<section class="page-x pt-3 sm:pt-4">
|
||||||
<div
|
<div
|
||||||
class="relative overflow-hidden rounded-[24px] bg-[image:var(--mf-hero-bg)] px-6 py-8 transition-[background] duration-[600ms] sm:px-9 sm:py-8.5"
|
class="relative overflow-hidden rounded-3xl bg-[image:var(--mf-hero-bg)] px-6 py-8 transition-[background] duration-500 sm:px-9 sm:py-8.5"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="relative flex flex-col items-start gap-7 lg:flex-row lg:flex-wrap lg:items-end lg:justify-between"
|
class="relative flex flex-col items-start gap-7 lg:flex-row lg:flex-wrap lg:items-end lg:justify-between"
|
||||||
>
|
>
|
||||||
<div class="animate-enter max-w-135 min-w-65">
|
<div class="animate-enter max-w-135 min-w-65">
|
||||||
<h1
|
<h1
|
||||||
class="font-display text-3xl leading-[1.1] font-semibold tracking-[-0.035em] text-pretty text-fg sm:text-4xl"
|
class="font-display text-3xl leading-tight font-medium tracking-tight text-pretty text-fg sm:text-4xl"
|
||||||
>
|
>
|
||||||
{data.greeting}
|
{data.greeting}
|
||||||
</h1>
|
</h1>
|
||||||
@@ -144,7 +152,7 @@
|
|||||||
{#if spotlight}
|
{#if spotlight}
|
||||||
<Button
|
<Button
|
||||||
variant="accent"
|
variant="accent"
|
||||||
class="!bg-cta-strong !font-bold !text-ink hover:!brightness-95"
|
class="!bg-cta-strong !text-ink hover:!brightness-95"
|
||||||
onclick={playSpotlight}
|
onclick={playSpotlight}
|
||||||
>
|
>
|
||||||
{heroPlaying ? 'Pausar' : 'Reanudar'}
|
{heroPlaying ? 'Pausar' : 'Reanudar'}
|
||||||
@@ -158,19 +166,19 @@
|
|||||||
{#await data.listeningStats then stats}
|
{#await data.listeningStats then stats}
|
||||||
<div class="flex gap-6.5">
|
<div class="flex gap-6.5">
|
||||||
<div>
|
<div>
|
||||||
<div class="font-display text-2xl font-semibold tracking-[-0.03em] text-fg">
|
<div class="font-display text-2xl font-medium tracking-tight text-fg">
|
||||||
{stats.tracksThisWeek}
|
{stats.tracksThisWeek}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1 text-xs text-fg-3">Canciones nuevas escuchadas esta semana</div>
|
<div class="mt-1 text-xs text-fg-3">Canciones nuevas escuchadas esta semana</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="font-display text-2xl font-semibold tracking-[-0.03em] text-fg">
|
<div class="font-display text-2xl font-medium tracking-tight text-fg">
|
||||||
{fmtDurationLong(Number(stats.secondsThisWeek))}
|
{fmtDurationLong(Number(stats.secondsThisWeek))}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1 text-xs text-fg-3">Tiempo de escucha esta semana</div>
|
<div class="mt-1 text-xs text-fg-3">Tiempo de escucha esta semana</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="font-display text-2xl font-semibold tracking-[-0.03em] text-fg">
|
<div class="font-display text-2xl font-medium tracking-tight text-fg">
|
||||||
{stats.streakDays}
|
{stats.streakDays}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1 text-xs text-fg-3">Días consecutivos escuchando</div>
|
<div class="mt-1 text-xs text-fg-3">Días consecutivos escuchando</div>
|
||||||
@@ -184,17 +192,20 @@
|
|||||||
<div class="flex flex-col gap-11.5 page-x pt-10 pb-8 sm:pb-10">
|
<div class="flex flex-col gap-11.5 page-x pt-10 pb-8 sm:pb-10">
|
||||||
<!-- CONTINUAR ESCUCHANDO -->
|
<!-- CONTINUAR ESCUCHANDO -->
|
||||||
<section>
|
<section>
|
||||||
<SectionHeading title="Continuar escuchando" />
|
<SectionHeading title="Continuar escuchando" subtitle="Retomalo donde lo dejaste" />
|
||||||
{#if continueItems.length > 0}
|
{#if continueItems.length > 0}
|
||||||
<div class="grid grid-cols-2 gap-2.5 sm:grid-cols-3 lg:grid-cols-5">
|
<div class="grid grid-cols-2 gap-2.5 sm:grid-cols-3 lg:grid-cols-5">
|
||||||
{#each continueItems as item, i (item.id)}
|
{#each continueItems as item, i (item.id)}
|
||||||
{@const isCurrent = player.current.id === item.id}
|
{@const isCurrent = player.current.id === item.id}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
||||||
<div
|
<div
|
||||||
class="animate-enter rounded-[13px] p-2.25 pr-3.5 transition-colors {isCurrent
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
class="animate-enter rounded-art p-2.25 pr-3.5 transition-colors {isCurrent
|
||||||
? 'bg-accent-tint'
|
? 'bg-accent-tint'
|
||||||
: 'hover:bg-hover'}"
|
: 'hover:bg-hover'}"
|
||||||
style="animation-delay:{Math.min(i, 10) * 40}ms"
|
style="animation-delay:{Math.min(i, 10) * 40}ms"
|
||||||
|
onclick={() => playContinue(i)}
|
||||||
|
onkeydown={(e) => onRowKeydown(e, () => playContinue(i))}
|
||||||
oncontextmenu={(e) => openContextMenu(e, targetForQueueItem(item))}
|
oncontextmenu={(e) => openContextMenu(e, targetForQueueItem(item))}
|
||||||
>
|
>
|
||||||
<TrackTitleCell
|
<TrackTitleCell
|
||||||
@@ -202,7 +213,7 @@
|
|||||||
title={item.title}
|
title={item.title}
|
||||||
artist={item.artist}
|
artist={item.artist}
|
||||||
ownerUserId={item.ownerUserId}
|
ownerUserId={item.ownerUserId}
|
||||||
coverSize="h-13 w-13"
|
coverSize="h-14 w-14"
|
||||||
active={isCurrent}
|
active={isCurrent}
|
||||||
onClick={() => playContinue(i)}
|
onClick={() => playContinue(i)}
|
||||||
/>
|
/>
|
||||||
@@ -226,43 +237,43 @@
|
|||||||
{#if spotlight}
|
{#if spotlight}
|
||||||
<section>
|
<section>
|
||||||
<div
|
<div
|
||||||
class="grid grid-cols-1 overflow-hidden rounded-panel-lg bg-[image:var(--mf-spotlight-bg)] transition-[background] duration-[600ms] lg:grid-cols-2"
|
class="grid grid-cols-1 overflow-hidden rounded-panel-lg transition-[background] duration-500 lg:grid-cols-2"
|
||||||
|
style="background-image:var(--mf-spotlight-bg), linear-gradient(to right, transparent 50%, color-mix(in oklch, var(--mf-scrim) 22%, transparent) 61%, transparent 92.5%)"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col justify-center p-8 sm:p-9">
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
class="flex w-fit items-center gap-6 self-start"
|
||||||
|
onclick={() => goto(`/playlists/${spotlight.id}`)}
|
||||||
|
onkeydown={(e) => onRowKeydown(e, () => goto(`/playlists/${spotlight.id}`))}
|
||||||
>
|
>
|
||||||
<div class="flex flex-col justify-center gap-5 p-8 sm:p-9">
|
|
||||||
<div class="flex items-center gap-5">
|
|
||||||
<PlaylistArt
|
<PlaylistArt
|
||||||
playlistId={spotlight.id}
|
playlistId={spotlight.id}
|
||||||
trackIds={spotlight.coverTrackIds}
|
trackIds={spotlight.coverTrackIds}
|
||||||
version={spotlight.updatedAt}
|
version={spotlight.updatedAt}
|
||||||
size="large"
|
size="large"
|
||||||
class="h-28 w-28 shrink-0 rounded-[16px] shadow-cover-md"
|
class="h-34 w-34 shrink-0 rounded-2xl"
|
||||||
/>
|
/>
|
||||||
<div class="min-w-0">
|
<div class="flex min-w-0 flex-col gap-4">
|
||||||
<p class="text-xs font-semibold tracking-[0.16em] text-fg-2 uppercase">
|
<p class="text-xs font-medium tracking-widest text-fg-2 uppercase">
|
||||||
Playlist destacada
|
Playlist destacada
|
||||||
</p>
|
</p>
|
||||||
<h3
|
<h3 class="truncate font-display text-2xl font-medium text-fg">
|
||||||
class="mt-2.5 truncate font-display text-2xl font-semibold tracking-[-0.03em] text-fg"
|
|
||||||
>
|
|
||||||
{spotlight.name}
|
{spotlight.name}
|
||||||
</h3>
|
</h3>
|
||||||
<p class="mt-2 text-sm text-fg-2">{spotlightMeta}</p>
|
<p class="text-sm text-fg-2">{spotlightMeta}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="max-w-110 text-sm leading-[1.7] text-fg-2">
|
<p class="mt-5 max-w-110 text-sm text-fg-2">
|
||||||
{spotlight.description || 'Tu colección, siempre a mano.'}
|
{spotlight.description || 'Tu colección, siempre a mano.'}
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-1 flex gap-2.5">
|
<div class="mt-5 flex gap-2.5">
|
||||||
<Button variant="accent" onclick={playSpotlight}>Reproducir</Button>
|
<Button variant="accent" onclick={playSpotlight}>Reproducir</Button>
|
||||||
<Button variant="secondary" class="!font-normal" onclick={shuffleSpotlight}>
|
<Button variant="secondary" onclick={shuffleSpotlight}>Aleatorio</Button>
|
||||||
Aleatorio
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div class="flex flex-col gap-0.5 p-5.5 sm:p-6">
|
||||||
class="flex flex-col gap-0.5 p-5.5 sm:p-6"
|
|
||||||
style="background-image:linear-gradient(to right, color-mix(in oklch, var(--mf-scrim) 26%, transparent), transparent)"
|
|
||||||
>
|
|
||||||
{#each spotlightRows as { target, seconds }, i (targetId(target))}
|
{#each spotlightRows as { target, seconds }, i (targetId(target))}
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
@@ -273,7 +284,7 @@
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
playSpotlightTrack(i);
|
playSpotlightTrack(i);
|
||||||
}}
|
}}
|
||||||
class="flex items-center gap-3 rounded-[10px] p-2 text-left transition-colors hover:bg-hover {isTargetCurrent(
|
class="flex items-center gap-3 rounded-control p-2 text-left transition-colors hover:bg-hover {isTargetCurrent(
|
||||||
target
|
target
|
||||||
)
|
)
|
||||||
? 'bg-accent-tint'
|
? 'bg-accent-tint'
|
||||||
@@ -282,7 +293,7 @@
|
|||||||
<span class="w-5 shrink-0 text-center text-xs text-muted tabular-nums">{i + 1}</span>
|
<span class="w-5 shrink-0 text-center text-xs text-muted tabular-nums">{i + 1}</span>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div
|
<div
|
||||||
class="truncate text-sm font-medium {isTargetCurrent(target)
|
class="truncate text-sm {isTargetCurrent(target)
|
||||||
? 'text-accent-soft'
|
? 'text-accent-soft'
|
||||||
: 'text-fg'}"
|
: 'text-fg'}"
|
||||||
>
|
>
|
||||||
@@ -329,11 +340,14 @@
|
|||||||
<div class="grid grid-cols-1 gap-x-8.5 gap-y-1.5 lg:grid-cols-2">
|
<div class="grid grid-cols-1 gap-x-8.5 gap-y-1.5 lg:grid-cols-2">
|
||||||
{#each popularTargets as target, i (targetId(target))}
|
{#each popularTargets as target, i (targetId(target))}
|
||||||
{@const isCurrent = isTargetCurrent(target)}
|
{@const isCurrent = isTargetCurrent(target)}
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
||||||
<div
|
<div
|
||||||
class="flex items-center gap-3.5 rounded-[11px] p-2 transition-colors {isCurrent
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
class="flex items-center gap-3.5 rounded-control p-2 transition-colors {isCurrent
|
||||||
? 'bg-accent-tint'
|
? 'bg-accent-tint'
|
||||||
: 'hover:bg-hover'}"
|
: 'hover:bg-hover'}"
|
||||||
|
onclick={() => playPopular(i)}
|
||||||
|
onkeydown={(e) => onRowKeydown(e, () => playPopular(i))}
|
||||||
oncontextmenu={(e) => openContextMenu(e, target)}
|
oncontextmenu={(e) => openContextMenu(e, target)}
|
||||||
>
|
>
|
||||||
<span class="flex h-3.5 w-4.5 shrink-0 items-end justify-center">
|
<span class="flex h-3.5 w-4.5 shrink-0 items-end justify-center">
|
||||||
@@ -349,12 +363,15 @@
|
|||||||
title={targetTitle(target)}
|
title={targetTitle(target)}
|
||||||
artist={targetArtist(target)}
|
artist={targetArtist(target)}
|
||||||
ownerUserId={targetOwnerUserId(target)}
|
ownerUserId={targetOwnerUserId(target)}
|
||||||
coverSize="h-9.5 w-9.5"
|
coverSize="h-10 w-10"
|
||||||
explicit={targetExplicit(target)}
|
explicit={targetExplicit(target)}
|
||||||
active={isCurrent}
|
active={isCurrent}
|
||||||
onClick={() => playPopular(i)}
|
onClick={() => playPopular(i)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<span class="hidden shrink-0 text-xs text-muted tabular-nums sm:block">
|
||||||
|
{fmtPlays(targetListensCount(target))}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -378,7 +395,7 @@
|
|||||||
{#if data.playlistsHasMore}
|
{#if data.playlistsHasMore}
|
||||||
<a
|
<a
|
||||||
href="/playlists"
|
href="/playlists"
|
||||||
class="shrink-0 text-xs font-semibold tracking-[0.07em] text-fg-3 uppercase transition-colors hover:text-fg"
|
class="shrink-0 text-xs font-medium tracking-wider text-fg-3 uppercase transition-colors hover:text-fg"
|
||||||
>
|
>
|
||||||
Ver todas
|
Ver todas
|
||||||
</a>
|
</a>
|
||||||
@@ -388,27 +405,35 @@
|
|||||||
{#if playlists.length > 0}
|
{#if playlists.length > 0}
|
||||||
<div class="grid grid-cols-[repeat(auto-fill,minmax(258px,1fr))] gap-3.5">
|
<div class="grid grid-cols-[repeat(auto-fill,minmax(258px,1fr))] gap-3.5">
|
||||||
{#each playlists as playlist, i (playlist.id)}
|
{#each playlists as playlist, i (playlist.id)}
|
||||||
<a
|
<div
|
||||||
href="/playlists/{playlist.id}"
|
role="button"
|
||||||
class="group/card animate-enter flex items-center gap-3.5 rounded-[15px] p-3 transition-colors hover:bg-hover"
|
tabindex="0"
|
||||||
|
class="group/card animate-enter flex items-center gap-3.5 rounded-2xl p-3 transition-colors hover:bg-hover"
|
||||||
style="animation-delay:{Math.min(i, 10) * 40}ms"
|
style="animation-delay:{Math.min(i, 10) * 40}ms"
|
||||||
|
onclick={() => goto(`/playlists/${playlist.id}`)}
|
||||||
|
onkeydown={(e) => onRowKeydown(e, () => goto(`/playlists/${playlist.id}`))}
|
||||||
>
|
>
|
||||||
<PlaylistArt
|
<PlaylistArt
|
||||||
playlistId={playlist.id}
|
playlistId={playlist.id}
|
||||||
trackIds={playlist.coverTrackIds}
|
trackIds={playlist.coverTrackIds}
|
||||||
version={playlist.updatedAt}
|
version={playlist.updatedAt}
|
||||||
size="medium"
|
size="medium"
|
||||||
class="h-15.5 w-15.5 shrink-0 rounded-[11px] shadow-cover-sm"
|
class="h-15.5 w-15.5 shrink-0 rounded-control"
|
||||||
/>
|
/>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="truncate text-sm font-semibold tracking-[-0.01em] text-fg">
|
<div class="truncate text-sm font-medium tracking-tight text-fg">
|
||||||
{playlist.name}
|
{playlist.name}
|
||||||
</div>
|
</div>
|
||||||
{#if playlist.description}
|
{#if playlist.description}
|
||||||
<div class="mt-1 truncate text-xs text-fg-3">{playlist.description}</div>
|
<div class="mt-1 truncate text-xs text-fg-3">{playlist.description}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
<ArtistLink
|
||||||
|
name={data.user?.name}
|
||||||
|
ownerUserId={data.user?.sub}
|
||||||
|
class="mt-1 text-xs text-fg-3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
--color-cta-strong: var(--mf-cta-strong-bg);
|
--color-cta-strong: var(--mf-cta-strong-bg);
|
||||||
--color-scrim: var(--mf-scrim);
|
--color-scrim: var(--mf-scrim);
|
||||||
--color-on-art: var(--mf-on-art);
|
--color-on-art: var(--mf-on-art);
|
||||||
|
--color-on-art-2: var(--mf-on-art-2);
|
||||||
--color-danger: oklch(63.7% 0.15 25.331);
|
--color-danger: oklch(63.7% 0.15 25.331);
|
||||||
--color-danger-fg: oklch(88.5% 0.062 18.334);
|
--color-danger-fg: oklch(88.5% 0.062 18.334);
|
||||||
|
|
||||||
@@ -42,17 +43,6 @@
|
|||||||
--radius-art: 0.8125rem;
|
--radius-art: 0.8125rem;
|
||||||
--radius-art-lg: 1.125rem;
|
--radius-art-lg: 1.125rem;
|
||||||
--radius-art-round: 1.75rem;
|
--radius-art-round: 1.75rem;
|
||||||
|
|
||||||
--shadow-art: 0 14px 32px rgba(0, 0, 0, 0.45);
|
|
||||||
--shadow-art-lg: 0 24px 50px -16px rgba(0, 0, 0, 0.75);
|
|
||||||
--shadow-menu: 0 12px 28px -10px rgba(0, 0, 0, 0.6);
|
|
||||||
--shadow-player: 0 20px 56px rgba(0, 0, 0, 0.6);
|
|
||||||
--shadow-glow: 0 0 0 1px var(--mf-border-strong), 0 20px 45px -20px var(--mf-accent-glow);
|
|
||||||
|
|
||||||
--shadow-cover-lg: 0 24px 56px rgba(0, 0, 0, 0.55);
|
|
||||||
--shadow-cover-md: 0 16px 40px rgba(0, 0, 0, 0.5);
|
|
||||||
--shadow-cover-sm: 0 10px 24px rgba(0, 0, 0, 0.42);
|
|
||||||
--shadow-cover-xs: 0 8px 20px rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@utility page-x {
|
@utility page-x {
|
||||||
|
|||||||
@@ -16,8 +16,7 @@
|
|||||||
|
|
||||||
<div class="relative flex w-full max-w-4xl flex-col items-center gap-12 text-center">
|
<div class="relative flex w-full max-w-4xl flex-col items-center gap-12 text-center">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<span class="h-8 w-8 rounded-xl bg-[image:var(--mf-logo-grad)] shadow-[var(--mf-logo-glow)]"
|
<span class="h-8 w-8 rounded-xl bg-[image:var(--mf-logo-grad)]"></span>
|
||||||
></span>
|
|
||||||
<span class="font-display text-[clamp(1.5rem,3vw,2.5rem)] font-semibold tracking-tight">
|
<span class="font-display text-[clamp(1.5rem,3vw,2.5rem)] font-semibold tracking-tight">
|
||||||
Musify
|
Musify
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user