Update packages
This commit is contained in:
@@ -12,10 +12,6 @@ namespace Musify.Application.Configuration
|
|||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
public PlayListPicturesSizes PicturesSizes { get; set; } = new PlayListPicturesSizes();
|
public PlayListPicturesSizes PicturesSizes { get; set; } = new PlayListPicturesSizes();
|
||||||
|
|
||||||
public bool SeedPresetPictures { get; set; } = true;
|
|
||||||
|
|
||||||
public bool OverwritePresetPictures { get; set; } = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public class PlayListRoutes
|
public class PlayListRoutes
|
||||||
@@ -38,18 +34,6 @@ namespace Musify.Application.Configuration
|
|||||||
[Required]
|
[Required]
|
||||||
public string LargePicturesFolder { get; set; } = "LargePictures";
|
public string LargePicturesFolder { get; set; } = "LargePictures";
|
||||||
|
|
||||||
[Required]
|
|
||||||
public string PresetOriginalPicture { get; set; } = "PresetOriginalPicture.webp";
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public string PresetSmallPicture { get; set; } = "PresetSmallPicture.webp";
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public string PresetMediumPicture { get; set; } = "PresetMediumPicture.webp";
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public string PresetLargePicture { get; set; } = "PresetLargePicture.webp";
|
|
||||||
|
|
||||||
public string SmallPicturesPath => StorageKey.Combine(ParentFolders, SmallPicturesFolder);
|
public string SmallPicturesPath => StorageKey.Combine(ParentFolders, SmallPicturesFolder);
|
||||||
|
|
||||||
public string MediumPicturesPath => StorageKey.Combine(ParentFolders, MediumPicturesFolder);
|
public string MediumPicturesPath => StorageKey.Combine(ParentFolders, MediumPicturesFolder);
|
||||||
@@ -58,12 +42,6 @@ namespace Musify.Application.Configuration
|
|||||||
|
|
||||||
public string OriginalPicturesPath => StorageKey.Combine(ParentFolders, OriginalPicturesFolder);
|
public string OriginalPicturesPath => StorageKey.Combine(ParentFolders, OriginalPicturesFolder);
|
||||||
|
|
||||||
public string PresetSmallPicturePath => StorageKey.Combine(ParentFolders, SmallPicturesFolder, PresetSmallPicture);
|
|
||||||
|
|
||||||
public string PresetMediumPicturePath => StorageKey.Combine(ParentFolders, MediumPicturesFolder, PresetMediumPicture);
|
|
||||||
|
|
||||||
public string PresetLargePicturePath => StorageKey.Combine(ParentFolders, LargePicturesFolder, PresetLargePicture);
|
|
||||||
|
|
||||||
public string BuildOriginalPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, OriginalPicturesFolder, pictureName);
|
public string BuildOriginalPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, OriginalPicturesFolder, pictureName);
|
||||||
|
|
||||||
public string BuildOriginalPicturePath(long userId, string pictureName) => StorageKey.Combine(UploadsFolder, userId.ToString(), ParentFolders, OriginalPicturesFolder, pictureName);
|
public string BuildOriginalPicturePath(long userId, string pictureName) => StorageKey.Combine(UploadsFolder, userId.ToString(), ParentFolders, OriginalPicturesFolder, pictureName);
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ namespace Musify.Application.PlayLists
|
|||||||
UploadIntentConfiguration uploadIntentConfiguration)
|
UploadIntentConfiguration uploadIntentConfiguration)
|
||||||
: ICommandHandler<CreatePlayListCommand, ErrorOr<PlayListApplicationResponse>>
|
: ICommandHandler<CreatePlayListCommand, ErrorOr<PlayListApplicationResponse>>
|
||||||
{
|
{
|
||||||
private sealed record ResolvedPlayListPictures(PlayListPictures Pictures, UploadIntent? Intent);
|
private sealed record ResolvedPlayListPictures(PlayListPictures? Pictures, UploadIntent? Intent);
|
||||||
|
|
||||||
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(CreatePlayListCommand request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(CreatePlayListCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -63,7 +63,7 @@ namespace Musify.Application.PlayLists
|
|||||||
|
|
||||||
if (resolvedPictures.Intent != null)
|
if (resolvedPictures.Intent != null)
|
||||||
{
|
{
|
||||||
var finalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, resolvedPictures.Pictures.OriginalName);
|
var finalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, resolvedPictures.Pictures!.OriginalName);
|
||||||
var publishResult = await PublishCreatePlayListEventAsync(playList.Id, resolvedPictures.Intent, finalPictureKey, cancellationToken);
|
var publishResult = await PublishCreatePlayListEventAsync(playList.Id, resolvedPictures.Intent, finalPictureKey, cancellationToken);
|
||||||
if (publishResult.IsError)
|
if (publishResult.IsError)
|
||||||
return publishResult.Errors;
|
return publishResult.Errors;
|
||||||
@@ -88,13 +88,7 @@ namespace Musify.Application.PlayLists
|
|||||||
{
|
{
|
||||||
if (!request.PictureIntentId.HasValue)
|
if (!request.PictureIntentId.HasValue)
|
||||||
{
|
{
|
||||||
return new ResolvedPlayListPictures(new PlayListPictures
|
return new ResolvedPlayListPictures(null, null);
|
||||||
{
|
|
||||||
OriginalName = playListConfiguration.Routes.PresetOriginalPicture,
|
|
||||||
SmallName = playListConfiguration.Routes.PresetSmallPicture,
|
|
||||||
MediumName = playListConfiguration.Routes.PresetMediumPicture,
|
|
||||||
LargeName = playListConfiguration.Routes.PresetLargePicture
|
|
||||||
}, null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var validation = await uploadIntentValidator.ValidateAndLoadAsync(
|
var validation = await uploadIntentValidator.ValidateAndLoadAsync(
|
||||||
@@ -103,12 +97,12 @@ namespace Musify.Application.PlayLists
|
|||||||
if (validation.IsError)
|
if (validation.IsError)
|
||||||
return validation.Errors;
|
return validation.Errors;
|
||||||
|
|
||||||
return new ResolvedPlayListPictures(new PlayListPictures
|
return new ResolvedPlayListPictures(new PlayListPictures
|
||||||
{
|
{
|
||||||
OriginalName = validation.Value.ObjectName,
|
OriginalName = validation.Value.ObjectName,
|
||||||
SmallName = playListConfiguration.Routes.PresetSmallPicture,
|
SmallName = validation.Value.ObjectName,
|
||||||
MediumName = playListConfiguration.Routes.PresetMediumPicture,
|
MediumName = validation.Value.ObjectName,
|
||||||
LargeName = playListConfiguration.Routes.PresetLargePicture
|
LargeName = validation.Value.ObjectName
|
||||||
}, validation.Value);
|
}, validation.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -75,12 +75,12 @@ namespace Musify.Application.PlayLists
|
|||||||
return validation.Errors;
|
return validation.Errors;
|
||||||
|
|
||||||
pictureIntent = validation.Value;
|
pictureIntent = validation.Value;
|
||||||
playListEntity.Pictures = new PlayListPictures
|
playListEntity.Pictures = new PlayListPictures
|
||||||
{
|
{
|
||||||
OriginalName = pictureIntent.ObjectName,
|
OriginalName = pictureIntent.ObjectName,
|
||||||
SmallName = playListConfiguration.Routes.PresetSmallPicture,
|
SmallName = pictureIntent.ObjectName,
|
||||||
MediumName = playListConfiguration.Routes.PresetMediumPicture,
|
MediumName = pictureIntent.ObjectName,
|
||||||
LargeName = playListConfiguration.Routes.PresetLargePicture
|
LargeName = pictureIntent.ObjectName
|
||||||
};
|
};
|
||||||
finalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName);
|
finalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using ErrorOr;
|
||||||
|
using Mediator;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Tracks.Responses;
|
||||||
|
|
||||||
|
namespace Musify.Application.Tracks
|
||||||
|
{
|
||||||
|
public record GetLastTrackListenedByUserIdQuery(long UserId) : IQuery<ErrorOr<TrackApplicationResponse>>;
|
||||||
|
|
||||||
|
public class GetLastTrackListenedByUserIdQueryHandler(IDatabase database)
|
||||||
|
: IQueryHandler<GetLastTrackListenedByUserIdQuery, ErrorOr<TrackApplicationResponse>>
|
||||||
|
{
|
||||||
|
public async ValueTask<ErrorOr<TrackApplicationResponse>> Handle(GetLastTrackListenedByUserIdQuery query, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var track = await database.ListeningHistories
|
||||||
|
.Include(t => t.Track.Owner)
|
||||||
|
.Include(t => t.Track.Tags)
|
||||||
|
.Where(t => t.UserId == query.UserId)
|
||||||
|
.OrderByDescending(t => t.ListenedAt)
|
||||||
|
.Select(t => TrackApplicationResponse.FromEntity(t.Track, t.Track.ListeningHistories.Count))
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
return track == null
|
||||||
|
? Error.NotFound("Track.NotFound", "No track found for the given user ID.")
|
||||||
|
: track;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,7 +33,7 @@ namespace Musify.Application.Tracks
|
|||||||
var pagedEntities = await tracksQuery
|
var pagedEntities = await tracksQuery
|
||||||
.OrderByDescending(t => t.Track.CreatedAt)
|
.OrderByDescending(t => t.Track.CreatedAt)
|
||||||
.ThenBy(t => t.Id)
|
.ThenBy(t => t.Id)
|
||||||
.Select(t => new { t.Track, ListensCount = t.Track.ListeningHistories.Count })
|
.Select(t => new { t.Track, ListensCount = t.Track.ListeningHistories.Count } )
|
||||||
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
||||||
|
|
||||||
var items = pagedEntities
|
var items = pagedEntities
|
||||||
|
|||||||
@@ -27,8 +27,7 @@ namespace Musify.Domain.Entities
|
|||||||
[MaxLength(256)]
|
[MaxLength(256)]
|
||||||
public string? Description { get; set; }
|
public string? Description { get; set; }
|
||||||
|
|
||||||
[Required]
|
public PlayListPictures? Pictures { get; set; }
|
||||||
public required PlayListPictures Pictures { get; set; }
|
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active;
|
public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active;
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 68 KiB |
@@ -44,8 +44,4 @@
|
|||||||
<Folder Include="Persistence\Migrations\" />
|
<Folder Include="Persistence\Migrations\" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<EmbeddedResource Include="Assets\PlayListDefaultCover.png" LogicalName="Musify.Infrastructure.Assets.PlayListDefaultCover.png" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -67,10 +67,10 @@ namespace Musify.Infrastructure.Persistence
|
|||||||
modelBuilder.Entity<PlayList>()
|
modelBuilder.Entity<PlayList>()
|
||||||
.OwnsOne(playList => playList.Pictures, pictures =>
|
.OwnsOne(playList => playList.Pictures, pictures =>
|
||||||
{
|
{
|
||||||
pictures.Property(p => p.OriginalName).HasColumnName("OriginalPictureName").HasMaxLength(64);
|
pictures.Property(p => p.OriginalName).HasColumnName("OriginalPictureName").HasMaxLength(64).IsRequired(false);
|
||||||
pictures.Property(p => p.SmallName).HasColumnName("SmallPictureName").HasMaxLength(64);
|
pictures.Property(p => p.SmallName).HasColumnName("SmallPictureName").HasMaxLength(64).IsRequired(false);
|
||||||
pictures.Property(p => p.MediumName).HasColumnName("MediumPictureName").HasMaxLength(64);
|
pictures.Property(p => p.MediumName).HasColumnName("MediumPictureName").HasMaxLength(64).IsRequired(false);
|
||||||
pictures.Property(p => p.LargeName).HasColumnName("LargePictureName").HasMaxLength(64);
|
pictures.Property(p => p.LargeName).HasColumnName("LargePictureName").HasMaxLength(64).IsRequired(false);
|
||||||
});
|
});
|
||||||
modelBuilder.Entity<PlayList>().Navigation(playList => playList.Pictures).IsRequired(false);
|
modelBuilder.Entity<PlayList>().Navigation(playList => playList.Pictures).IsRequired(false);
|
||||||
|
|
||||||
|
|||||||
+1164
File diff suppressed because it is too large
Load Diff
+106
@@ -0,0 +1,106 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Musify.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class MakePlayListPictureColumnsOptional : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "SmallPictureName",
|
||||||
|
table: "PlayLists",
|
||||||
|
type: "character varying(64)",
|
||||||
|
maxLength: 64,
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "character varying(64)",
|
||||||
|
oldMaxLength: 64,
|
||||||
|
oldNullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "OriginalPictureName",
|
||||||
|
table: "PlayLists",
|
||||||
|
type: "character varying(64)",
|
||||||
|
maxLength: 64,
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "character varying(64)",
|
||||||
|
oldMaxLength: 64,
|
||||||
|
oldNullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "MediumPictureName",
|
||||||
|
table: "PlayLists",
|
||||||
|
type: "character varying(64)",
|
||||||
|
maxLength: 64,
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "character varying(64)",
|
||||||
|
oldMaxLength: 64,
|
||||||
|
oldNullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "LargePictureName",
|
||||||
|
table: "PlayLists",
|
||||||
|
type: "character varying(64)",
|
||||||
|
maxLength: 64,
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "character varying(64)",
|
||||||
|
oldMaxLength: 64,
|
||||||
|
oldNullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "SmallPictureName",
|
||||||
|
table: "PlayLists",
|
||||||
|
type: "character varying(64)",
|
||||||
|
maxLength: 64,
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "character varying(64)",
|
||||||
|
oldMaxLength: 64,
|
||||||
|
oldNullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "OriginalPictureName",
|
||||||
|
table: "PlayLists",
|
||||||
|
type: "character varying(64)",
|
||||||
|
maxLength: 64,
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "character varying(64)",
|
||||||
|
oldMaxLength: 64,
|
||||||
|
oldNullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "MediumPictureName",
|
||||||
|
table: "PlayLists",
|
||||||
|
type: "character varying(64)",
|
||||||
|
maxLength: 64,
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "character varying(64)",
|
||||||
|
oldMaxLength: 64,
|
||||||
|
oldNullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "LargePictureName",
|
||||||
|
table: "PlayLists",
|
||||||
|
type: "character varying(64)",
|
||||||
|
maxLength: 64,
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "character varying(64)",
|
||||||
|
oldMaxLength: 64,
|
||||||
|
oldNullable: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -896,25 +896,21 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
|||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
b1.Property<string>("LargeName")
|
b1.Property<string>("LargeName")
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(64)
|
.HasMaxLength(64)
|
||||||
.HasColumnType("character varying(64)")
|
.HasColumnType("character varying(64)")
|
||||||
.HasColumnName("LargePictureName");
|
.HasColumnName("LargePictureName");
|
||||||
|
|
||||||
b1.Property<string>("MediumName")
|
b1.Property<string>("MediumName")
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(64)
|
.HasMaxLength(64)
|
||||||
.HasColumnType("character varying(64)")
|
.HasColumnType("character varying(64)")
|
||||||
.HasColumnName("MediumPictureName");
|
.HasColumnName("MediumPictureName");
|
||||||
|
|
||||||
b1.Property<string>("OriginalName")
|
b1.Property<string>("OriginalName")
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(64)
|
.HasMaxLength(64)
|
||||||
.HasColumnType("character varying(64)")
|
.HasColumnType("character varying(64)")
|
||||||
.HasColumnName("OriginalPictureName");
|
.HasColumnName("OriginalPictureName");
|
||||||
|
|
||||||
b1.Property<string>("SmallName")
|
b1.Property<string>("SmallName")
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(64)
|
.HasMaxLength(64)
|
||||||
.HasColumnType("character varying(64)")
|
.HasColumnType("character varying(64)")
|
||||||
.HasColumnName("SmallPictureName");
|
.HasColumnName("SmallPictureName");
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
using System.Reflection;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
using Microsoft.Extensions.Hosting;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Musify.Application.Configuration;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
|
|
||||||
namespace Musify.Infrastructure.Services
|
|
||||||
{
|
|
||||||
public class PlayListPresetSeeder(IServiceScopeFactory scopeFactory, ILogger<PlayListPresetSeeder> logger)
|
|
||||||
: BackgroundService
|
|
||||||
{
|
|
||||||
private const string SourceResourceName = "Musify.Infrastructure.Assets.PlayListDefaultCover.png";
|
|
||||||
private const string WebpContentType = "image/webp";
|
|
||||||
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
|
||||||
{
|
|
||||||
using var scope = scopeFactory.CreateScope();
|
|
||||||
var services = scope.ServiceProvider;
|
|
||||||
|
|
||||||
var playListConfiguration = services.GetRequiredService<PlayListConfiguration>();
|
|
||||||
if (!playListConfiguration.SeedPresetPictures)
|
|
||||||
return;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await SeedAsync(services, playListConfiguration, stoppingToken);
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
logger.LogError(exception, "Failed to seed playlist preset pictures");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task SeedAsync(IServiceProvider services, PlayListConfiguration configuration, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var storage = services.GetRequiredService<IStorageService>();
|
|
||||||
var pictures = services.GetRequiredService<IPictureService>();
|
|
||||||
var bucket = services.GetRequiredService<ApplicationStorageConfiguration>().Bucket;
|
|
||||||
|
|
||||||
var source = LoadSourceBytes();
|
|
||||||
|
|
||||||
var routes = configuration.Routes;
|
|
||||||
var sizes = configuration.PicturesSizes;
|
|
||||||
|
|
||||||
var targets = new (string Key, int Width, int Height)[]
|
|
||||||
{
|
|
||||||
(routes.BuildOriginalPicturePath(routes.PresetOriginalPicture), sizes.LargePictureWidth, sizes.LargePictureHeight),
|
|
||||||
(routes.PresetSmallPicturePath, sizes.SmallPictureWidth, sizes.SmallPictureHeight),
|
|
||||||
(routes.PresetMediumPicturePath, sizes.MediumPictureWidth, sizes.MediumPictureHeight),
|
|
||||||
(routes.PresetLargePicturePath, sizes.LargePictureWidth, sizes.LargePictureHeight),
|
|
||||||
};
|
|
||||||
|
|
||||||
foreach (var target in targets)
|
|
||||||
{
|
|
||||||
if (!configuration.OverwritePresetPictures)
|
|
||||||
{
|
|
||||||
var existing = await storage.HeadObjectAsync(bucket, target.Key, cancellationToken);
|
|
||||||
if (existing is not null)
|
|
||||||
{
|
|
||||||
logger.LogDebug("Playlist preset picture already present at {Bucket}/{Key}", bucket, target.Key);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
using var sourceStream = new MemoryStream(source, writable: false);
|
|
||||||
await using var resized = await pictures.ResizePictureAsWebpAsync(sourceStream, target.Width, target.Height, cancellationToken);
|
|
||||||
await storage.UploadFileAsync(resized, WebpContentType, bucket, target.Key, cancellationToken);
|
|
||||||
|
|
||||||
logger.LogInformation("Seeded playlist preset picture {Bucket}/{Key} ({Width}x{Height})", bucket, target.Key, target.Width, target.Height);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static byte[] LoadSourceBytes()
|
|
||||||
{
|
|
||||||
var assembly = Assembly.GetExecutingAssembly();
|
|
||||||
using var stream = assembly.GetManifestResourceStream(SourceResourceName)
|
|
||||||
?? throw new InvalidOperationException($"Embedded resource '{SourceResourceName}' was not found.");
|
|
||||||
|
|
||||||
using var memory = new MemoryStream();
|
|
||||||
stream.CopyTo(memory);
|
|
||||||
return memory.ToArray();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -46,12 +46,6 @@ namespace Musify.Infrastructure.Services
|
|||||||
return serviceDescriptors;
|
return serviceDescriptors;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static IServiceCollection AddPlayListPresetSeeder(this IServiceCollection serviceDescriptors, IConfiguration configuration)
|
|
||||||
{
|
|
||||||
serviceDescriptors.AddHostedService<PlayListPresetSeeder>();
|
|
||||||
return serviceDescriptors;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static IServiceCollection AddUploadIntentConfiguration(this IServiceCollection serviceDescriptors, IConfiguration configuration)
|
public static IServiceCollection AddUploadIntentConfiguration(this IServiceCollection serviceDescriptors, IConfiguration configuration)
|
||||||
{
|
{
|
||||||
serviceDescriptors.AddValidatedOptions<UploadIntentConfiguration>(configuration, UploadIntentConfiguration.SectionName);
|
serviceDescriptors.AddValidatedOptions<UploadIntentConfiguration>(configuration, UploadIntentConfiguration.SectionName);
|
||||||
|
|||||||
@@ -1,68 +1,64 @@
|
|||||||
<Project>
|
<Project>
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageVersion Include="AWSSDK.S3" Version="4.0.23.4" />
|
<PackageVersion Include="AWSSDK.S3" Version="4.0.103.3" />
|
||||||
<PackageVersion Include="DispatchR.Mediator" Version="2.2.0" />
|
<PackageVersion Include="DispatchR.Mediator" Version="2.2.0" />
|
||||||
<PackageVersion Include="ErrorOr" Version="2.1.1" />
|
<PackageVersion Include="ErrorOr" Version="2.1.1" />
|
||||||
<PackageVersion Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
|
<PackageVersion Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
|
||||||
<PackageVersion Include="Hangfire.NetCore" Version="1.8.24" />
|
<PackageVersion Include="Hangfire.NetCore" Version="1.8.25" />
|
||||||
<PackageVersion Include="Hangfire.PostgreSql" Version="1.21.1" />
|
<PackageVersion Include="Hangfire.PostgreSql" Version="1.21.1" />
|
||||||
<PackageVersion Include="MassTransit" Version="8.5.9" />
|
<PackageVersion Include="MassTransit" Version="8.5.9" />
|
||||||
<PackageVersion Include="MassTransit.EntityFrameworkCore" Version="8.5.9" />
|
<PackageVersion Include="MassTransit.EntityFrameworkCore" Version="8.5.9" />
|
||||||
<PackageVersion Include="MassTransit.RabbitMQ" Version="8.5.9" />
|
<PackageVersion Include="MassTransit.RabbitMQ" Version="8.5.9" />
|
||||||
<PackageVersion Include="Mediator.Abstractions" Version="3.0.2" />
|
<PackageVersion Include="Mediator.Abstractions" Version="3.0.2" />
|
||||||
<PackageVersion Include="Mediator.SourceGenerator" Version="3.0.2" />
|
<PackageVersion Include="Mediator.SourceGenerator" Version="3.0.2" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.8" />
|
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.12" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.8" />
|
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.12" />
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.12" />
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.12" />
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.8" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.12" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.8" />
|
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.12" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.8" />
|
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.12" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.8" />
|
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.12" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Configuration.FileExtensions" Version="10.0.8" />
|
<PackageVersion Include="Microsoft.Extensions.Configuration.FileExtensions" Version="10.0.12" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="10.0.8" />
|
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="10.0.12" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.8" />
|
<PackageVersion Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.12" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.8" />
|
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.12" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.8" />
|
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.12" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Options.DataAnnotations" Version="10.0.8" />
|
<PackageVersion Include="Microsoft.Extensions.Options.DataAnnotations" Version="10.0.12" />
|
||||||
<PackageVersion Include="Microsoft.IdentityModel.JsonWebTokens" Version="8.14.0" />
|
<PackageVersion Include="Microsoft.IdentityModel.JsonWebTokens" Version="8.23.0" />
|
||||||
<PackageVersion Include="Microsoft.OpenApi" Version="2.9.0" />
|
<PackageVersion Include="Microsoft.OpenApi" Version="3.10.2" />
|
||||||
<PackageVersion Include="MimeMapping" Version="4.0.0" />
|
<PackageVersion Include="MimeMapping" Version="4.0.0" />
|
||||||
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
|
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
|
||||||
<PackageVersion Include="OpenTelemetry" Version="1.15.3" />
|
<PackageVersion Include="OpenTelemetry" Version="1.19.0" />
|
||||||
<PackageVersion Include="OpenTelemetry.Api" Version="1.15.3" />
|
<PackageVersion Include="OpenTelemetry.Api" Version="1.19.0" />
|
||||||
<PackageVersion Include="OpenTelemetry.Api.ProviderBuilderExtensions" Version="1.15.3" />
|
<PackageVersion Include="OpenTelemetry.Api.ProviderBuilderExtensions" Version="1.19.0" />
|
||||||
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.3" />
|
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.19.0" />
|
||||||
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.15.3" />
|
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.19.0" />
|
||||||
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.2" />
|
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.19.0" />
|
||||||
<PackageVersion Include="OpenTelemetry.Instrumentation.EntityFrameworkCore" Version="1.15.1-beta.1" />
|
<PackageVersion Include="OpenTelemetry.Instrumentation.EntityFrameworkCore" Version="1.15.1-beta.1" />
|
||||||
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.15.1" />
|
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.19.0" />
|
||||||
<PackageVersion Include="OpenTelemetry.Instrumentation.Process" Version="1.15.1-beta.1" />
|
<PackageVersion Include="OpenTelemetry.Instrumentation.Process" Version="1.15.1-beta.1" />
|
||||||
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.1" />
|
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.19.0" />
|
||||||
<PackageVersion Include="Scalar.AspNetCore" Version="2.14.14" />
|
<PackageVersion Include="Scalar.AspNetCore" Version="2.17.6" />
|
||||||
<PackageVersion Include="SixLabors.ImageSharp" Version="3.1.12" />
|
<PackageVersion Include="SixLabors.ImageSharp" Version="4.1.2" />
|
||||||
<PackageVersion Include="X.PagedList" Version="10.5.9" />
|
<PackageVersion Include="X.PagedList" Version="10.6.0" />
|
||||||
<PackageVersion Include="X.PagedList.EF" Version="10.5.9" />
|
<PackageVersion Include="X.PagedList.EF" Version="10.6.0" />
|
||||||
<PackageVersion Include="Yarp.ReverseProxy" Version="2.3.0" />
|
<PackageVersion Include="Yarp.ReverseProxy" Version="2.3.0" />
|
||||||
|
|
||||||
<!-- Test-only -->
|
<!-- Test-only -->
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.8" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.12" />
|
||||||
<!-- Pins the native SQLite bundle EFCore.Sqlite pulls in transitively: 2.1.11 has
|
<!-- Pins the native SQLite bundle EFCore.Sqlite pulls in transitively: 2.1.11 has
|
||||||
a known high-severity advisory (GHSA-2m69-gcr7-jv3q), fixed in 2.1.13. -->
|
a known high-severity advisory (GHSA-2m69-gcr7-jv3q), fixed in 2.1.13. -->
|
||||||
<PackageVersion Include="SQLitePCLRaw.bundle_e_sqlite3" Version="2.1.13" />
|
<PackageVersion Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.5" />
|
||||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
|
||||||
<PackageVersion Include="NSubstitute" Version="5.3.0" />
|
<PackageVersion Include="NSubstitute" Version="6.2.0" />
|
||||||
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
|
<PackageVersion Include="coverlet.collector" Version="10.0.1" />
|
||||||
<PackageVersion Include="xunit.v3" Version="4.0.0" />
|
<PackageVersion Include="xunit.v3" Version="4.0.1" />
|
||||||
<PackageVersion Include="xunit.runner.visualstudio" Version="4.0.0" />
|
<PackageVersion Include="xunit.runner.visualstudio" Version="4.0.0" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.8" />
|
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.12" />
|
||||||
<PackageVersion Include="Testcontainers" Version="4.15.0" />
|
<PackageVersion Include="Testcontainers" Version="4.15.0" />
|
||||||
<PackageVersion Include="Testcontainers.PostgreSql" Version="4.15.0" />
|
<PackageVersion Include="Testcontainers.PostgreSql" Version="4.15.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
</Project>
|
|
||||||
@@ -40,6 +40,12 @@ public static class UserEndpoints
|
|||||||
.WithSummary("Get A User'S Listening Stats For The Current Week And Streak.")
|
.WithSummary("Get A User'S Listening Stats For The Current Week And Streak.")
|
||||||
.Produces<ListeningStatsResponse>();
|
.Produces<ListeningStatsResponse>();
|
||||||
|
|
||||||
|
group.MapGet("/{id}/last-listened-track", GetLastTrackListenedByUserId)
|
||||||
|
.WithName("GetLastTrackListenedByUserId")
|
||||||
|
.WithSummary("Get The Last Track A User Listened To.")
|
||||||
|
.Produces<TrackApplicationResponse>()
|
||||||
|
.Produces(StatusCodes.Status404NotFound);
|
||||||
|
|
||||||
group.MapGet("/{id}/profile", GetUserProfile)
|
group.MapGet("/{id}/profile", GetUserProfile)
|
||||||
.WithName("GetUserProfile")
|
.WithName("GetUserProfile")
|
||||||
.WithSummary("Get A User'S Public Profile, Including Follower Counts.")
|
.WithSummary("Get A User'S Public Profile, Including Follower Counts.")
|
||||||
@@ -116,6 +122,15 @@ public static class UserEndpoints
|
|||||||
return await mediator.Send(new GetListeningStatsQuery(id), cancellationToken);
|
return await mediator.Send(new GetListeningStatsQuery(id), cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> GetLastTrackListenedByUserId(
|
||||||
|
IMediator mediator,
|
||||||
|
long id,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await mediator.Send(new GetLastTrackListenedByUserIdQuery(id), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<IResult> GetUserProfile(
|
private static async Task<IResult> GetUserProfile(
|
||||||
IMediator mediator,
|
IMediator mediator,
|
||||||
long id,
|
long id,
|
||||||
|
|||||||
@@ -44,9 +44,7 @@
|
|||||||
"MediumPictureHeight": 256,
|
"MediumPictureHeight": 256,
|
||||||
"LargePictureWidth": 512,
|
"LargePictureWidth": 512,
|
||||||
"LargePictureHeight": 512
|
"LargePictureHeight": 512
|
||||||
},
|
}
|
||||||
"SeedPresetPictures": true,
|
|
||||||
"OverwritePresetPictures": false
|
|
||||||
},
|
},
|
||||||
"Track": {
|
"Track": {
|
||||||
"Routes": {
|
"Routes": {
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ services.AddStorageService(configuration);
|
|||||||
services.AddAudioTranscoder(configuration);
|
services.AddAudioTranscoder(configuration);
|
||||||
services.AddHttpClient();
|
services.AddHttpClient();
|
||||||
services.AddPictureService(configuration);
|
services.AddPictureService(configuration);
|
||||||
services.AddPlayListPresetSeeder(configuration);
|
|
||||||
services.AddDatabase(configuration);
|
services.AddDatabase(configuration);
|
||||||
services.AddMassTransitConsumers(configuration);
|
services.AddMassTransitConsumers(configuration);
|
||||||
services.AddUploadIntentJobs(configuration);
|
services.AddUploadIntentJobs(configuration);
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Musify.Api.DataTransferObjects.Users;
|
using Musify.Api.DataTransferObjects.Users;
|
||||||
using Musify.Api.Tests.TestSupport;
|
using Musify.Api.Tests.TestSupport;
|
||||||
|
using Musify.Application.Tracks.Responses;
|
||||||
using Musify.Application.Users.Responses;
|
using Musify.Application.Users.Responses;
|
||||||
|
using Musify.Domain.Entities;
|
||||||
|
using Musify.Infrastructure.Persistence;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Api.Tests.Endpoints
|
namespace Musify.Api.Tests.Endpoints
|
||||||
@@ -10,6 +17,33 @@ namespace Musify.Api.Tests.Endpoints
|
|||||||
[Collection(ApiCollection.Name)]
|
[Collection(ApiCollection.Name)]
|
||||||
public sealed class UserEndpointsTests(ApiTestFixture fixture)
|
public sealed class UserEndpointsTests(ApiTestFixture fixture)
|
||||||
{
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||||
|
{
|
||||||
|
Converters = { new JsonStringEnumConverter() }
|
||||||
|
};
|
||||||
|
|
||||||
|
private async Task SeedListenAsync(long userId, string trackTitle, DateTime listenedAt)
|
||||||
|
{
|
||||||
|
using var scope = fixture.Services.CreateScope();
|
||||||
|
var database = scope.ServiceProvider.GetRequiredService<Database>();
|
||||||
|
var owner = await database.Users.SingleAsync(u => u.Id == userId);
|
||||||
|
var track = new Track
|
||||||
|
{
|
||||||
|
Title = trackTitle,
|
||||||
|
NormalizedTitle = trackTitle.ToUpperInvariant(),
|
||||||
|
OwnerUserId = owner.Id,
|
||||||
|
Owner = owner
|
||||||
|
};
|
||||||
|
database.Tracks.Add(track);
|
||||||
|
database.ListeningHistories.Add(new ListeningHistory
|
||||||
|
{
|
||||||
|
UserId = userId,
|
||||||
|
TrackId = track.Id,
|
||||||
|
ListenedAt = listenedAt
|
||||||
|
});
|
||||||
|
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task PostUsers_NewUser_ReturnsCreatedWithLocationHeader()
|
public async Task PostUsers_NewUser_ReturnsCreatedWithLocationHeader()
|
||||||
{
|
{
|
||||||
@@ -70,5 +104,33 @@ namespace Musify.Api.Tests.Endpoints
|
|||||||
|
|
||||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetLastTrackListenedByUserId_HasHistory_ReturnsMostRecentTrack()
|
||||||
|
{
|
||||||
|
var client = fixture.CreateAnonymousClient();
|
||||||
|
var userId = Random.Shared.NextInt64(1, long.MaxValue);
|
||||||
|
await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, $"user-{userId}", null, null), TestContext.Current.CancellationToken);
|
||||||
|
await SeedListenAsync(userId, "Older listen", DateTime.UtcNow.AddMinutes(-10));
|
||||||
|
await SeedListenAsync(userId, "Newer listen", DateTime.UtcNow);
|
||||||
|
|
||||||
|
var response = await client.GetAsync($"/users/{userId}/last-listened-track", TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
var body = await response.Content.ReadFromJsonAsync<TrackApplicationResponse>(JsonOptions, TestContext.Current.CancellationToken);
|
||||||
|
Assert.Equal("Newer listen", body?.Title);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetLastTrackListenedByUserId_NoHistory_ReturnsNotFound()
|
||||||
|
{
|
||||||
|
var client = fixture.CreateAnonymousClient();
|
||||||
|
var userId = Random.Shared.NextInt64(1, long.MaxValue);
|
||||||
|
await client.PostAsJsonAsync("/users", new CreateUserRequest(userId, $"user-{userId}", null, null), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
var response = await client.GetAsync($"/users/{userId}/last-listened-track", TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,7 +78,6 @@ namespace Musify.Api.Tests.TestSupport
|
|||||||
["InfrastructureStorage:UseHttp"] = "true",
|
["InfrastructureStorage:UseHttp"] = "true",
|
||||||
["ApplicationStorage:Bucket"] = "musify-api-test",
|
["ApplicationStorage:Bucket"] = "musify-api-test",
|
||||||
["StreamGateway:PublicBaseUrl"] = "http://stream.musify.test",
|
["StreamGateway:PublicBaseUrl"] = "http://stream.musify.test",
|
||||||
["PlayList:SeedPresetPictures"] = "false",
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -23,7 +23,7 @@ namespace Musify.Application.Tests.PlayLists
|
|||||||
TestConfigurations.UploadIntent());
|
TestConfigurations.UploadIntent());
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Handle_NoPictureIntent_CreatesPlayListWithPresetPictures()
|
public async Task Handle_NoPictureIntent_CreatesPlayListWithoutPictures()
|
||||||
{
|
{
|
||||||
var user = TestEntities.User();
|
var user = TestEntities.User();
|
||||||
await SeedAsync(user);
|
await SeedAsync(user);
|
||||||
@@ -34,7 +34,7 @@ namespace Musify.Application.Tests.PlayLists
|
|||||||
|
|
||||||
Assert.False(result.IsError);
|
Assert.False(result.IsError);
|
||||||
Assert.Equal("My Playlist", result.Value.Name);
|
Assert.Equal("My Playlist", result.Value.Name);
|
||||||
Assert.Equal("PresetSmallPicture.webp", result.Value.SmallImageKeyName);
|
Assert.Null(result.Value.SmallImageKeyName);
|
||||||
await eventBus.DidNotReceive().PublishAsync(Arg.Any<Musify.Application.Events.CreatePlayListResourcesEvent>(), Arg.Any<CancellationToken>());
|
await eventBus.DidNotReceive().PublishAsync(Arg.Any<Musify.Application.Events.CreatePlayListResourcesEvent>(), Arg.Any<CancellationToken>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,5 +34,18 @@ namespace Musify.Application.Tests.PlayLists
|
|||||||
|
|
||||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_PlayListWithoutPictures_ReturnsNotFound()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User();
|
||||||
|
var playList = TestEntities.PlayList(owner.Id);
|
||||||
|
playList.Pictures = null;
|
||||||
|
await SeedAsync(owner, playList);
|
||||||
|
|
||||||
|
var result = await CreateHandler().Handle(new GetPlayListCoverQuery(playList.Id, "small"), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,8 +45,14 @@ namespace Musify.Application.Tests.TestSupport
|
|||||||
.HasForeignKey(track => track.OwnerUserId)
|
.HasForeignKey(track => track.OwnerUserId)
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
modelBuilder.Entity<PlayList>().OwnsOne(playList => playList.Pictures);
|
modelBuilder.Entity<PlayList>().OwnsOne(playList => playList.Pictures, pictures =>
|
||||||
modelBuilder.Entity<PlayList>().Navigation(playList => playList.Pictures).IsRequired();
|
{
|
||||||
|
pictures.Property(p => p.OriginalName).IsRequired(false);
|
||||||
|
pictures.Property(p => p.SmallName).IsRequired(false);
|
||||||
|
pictures.Property(p => p.MediumName).IsRequired(false);
|
||||||
|
pictures.Property(p => p.LargeName).IsRequired(false);
|
||||||
|
});
|
||||||
|
modelBuilder.Entity<PlayList>().Navigation(playList => playList.Pictures).IsRequired(false);
|
||||||
|
|
||||||
modelBuilder.Entity<Album>().OwnsOne(album => album.Pictures);
|
modelBuilder.Entity<Album>().OwnsOne(album => album.Pictures);
|
||||||
modelBuilder.Entity<Album>().Navigation(album => album.Pictures).IsRequired(false);
|
modelBuilder.Entity<Album>().Navigation(album => album.Pictures).IsRequired(false);
|
||||||
|
|||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
using ErrorOr;
|
||||||
|
using Musify.Application.Tests.TestSupport;
|
||||||
|
using Musify.Application.Tracks;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Musify.Application.Tests.Tracks
|
||||||
|
{
|
||||||
|
public sealed class GetLastTrackListenedByUserIdQueryHandlerTests : HandlerTestBase
|
||||||
|
{
|
||||||
|
private GetLastTrackListenedByUserIdQueryHandler CreateHandler() => new(Database);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_MultipleListens_ReturnsMostRecentlyListenedTrack()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User();
|
||||||
|
var older = TestEntities.Track(owner, "Older listen");
|
||||||
|
var newer = TestEntities.Track(owner, "Newer listen");
|
||||||
|
await SeedAsync(
|
||||||
|
owner, older, newer,
|
||||||
|
TestEntities.ListeningHistory(owner.Id, older.Id, DateTime.UtcNow.AddMinutes(-10)),
|
||||||
|
TestEntities.ListeningHistory(owner.Id, newer.Id, DateTime.UtcNow));
|
||||||
|
|
||||||
|
var result = await CreateHandler().Handle(new GetLastTrackListenedByUserIdQuery(owner.Id), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.False(result.IsError);
|
||||||
|
Assert.Equal("Newer listen", result.Value.Title);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_NoHistory_ReturnsNotFound()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User();
|
||||||
|
await SeedAsync(owner);
|
||||||
|
|
||||||
|
var result = await CreateHandler().Handle(new GetLastTrackListenedByUserIdQuery(owner.Id), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
|
||||||
using Musify.Application.Configuration;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Infrastructure.Configuration;
|
|
||||||
using Musify.Infrastructure.Services;
|
|
||||||
using Musify.Infrastructure.Tests.TestSupport;
|
|
||||||
using Xunit;
|
|
||||||
|
|
||||||
namespace Musify.Infrastructure.Tests.Services
|
|
||||||
{
|
|
||||||
[Collection(InfrastructureCollection.Name)]
|
|
||||||
public sealed class PlayListPresetSeederTests(InfrastructureTestFixture fixture)
|
|
||||||
{
|
|
||||||
private static PlayListConfiguration NewConfiguration(bool overwritePresetPictures) => new()
|
|
||||||
{
|
|
||||||
Routes = new PlayListRoutes { ParentFolders = $"PlayLists-{Guid.NewGuid():N}" },
|
|
||||||
SeedPresetPictures = true,
|
|
||||||
OverwritePresetPictures = overwritePresetPictures
|
|
||||||
};
|
|
||||||
|
|
||||||
private (PlayListPresetSeeder Seeder, IStorageService Storage) Build(PlayListConfiguration configuration)
|
|
||||||
{
|
|
||||||
var services = new ServiceCollection();
|
|
||||||
services.AddSingleton(configuration);
|
|
||||||
services.AddSingleton(new ApplicationStorageConfiguration { Bucket = InfrastructureTestFixture.S3Bucket });
|
|
||||||
services.AddSingleton<IStorageService>(_ => new StorageService(
|
|
||||||
fixture.CreateDatabase(),
|
|
||||||
fixture.CreateS3Client(),
|
|
||||||
NullLogger<StorageService>.Instance,
|
|
||||||
new InfrastructureStorageConfiguration
|
|
||||||
{
|
|
||||||
Address = fixture.S3ServiceUrl,
|
|
||||||
AccessKey = InfrastructureTestFixture.S3AccessKey,
|
|
||||||
SecretAccessKey = InfrastructureTestFixture.S3SecretKey,
|
|
||||||
ForcePathStyle = true,
|
|
||||||
UseHttp = true,
|
|
||||||
}));
|
|
||||||
services.AddSingleton<IPictureService>(new PictureService(NullLogger<PictureService>.Instance));
|
|
||||||
|
|
||||||
var provider = services.BuildServiceProvider();
|
|
||||||
var seeder = new PlayListPresetSeeder(provider.GetRequiredService<IServiceScopeFactory>(), NullLogger<PlayListPresetSeeder>.Instance);
|
|
||||||
return (seeder, provider.GetRequiredService<IStorageService>());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task RunToCompletionAsync(PlayListPresetSeeder seeder)
|
|
||||||
{
|
|
||||||
await seeder.StartAsync(TestContext.Current.CancellationToken);
|
|
||||||
await (seeder.ExecuteTask ?? Task.CompletedTask);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task ExecuteAsync_SeedsAllFourPresetPictures()
|
|
||||||
{
|
|
||||||
var configuration = NewConfiguration(overwritePresetPictures: false);
|
|
||||||
var (seeder, storage) = Build(configuration);
|
|
||||||
|
|
||||||
await RunToCompletionAsync(seeder);
|
|
||||||
|
|
||||||
var routes = configuration.Routes;
|
|
||||||
foreach (var key in new[]
|
|
||||||
{
|
|
||||||
routes.BuildOriginalPicturePath(routes.PresetOriginalPicture),
|
|
||||||
routes.PresetSmallPicturePath,
|
|
||||||
routes.PresetMediumPicturePath,
|
|
||||||
routes.PresetLargePicturePath
|
|
||||||
})
|
|
||||||
{
|
|
||||||
var metadata = await storage.HeadObjectAsync(InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken);
|
|
||||||
Assert.True(metadata is not null, $"expected {key} to have been seeded");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task ExecuteAsync_RunTwiceWithoutOverwrite_StaysIdempotent()
|
|
||||||
{
|
|
||||||
var configuration = NewConfiguration(overwritePresetPictures: false);
|
|
||||||
var (firstSeeder, storage) = Build(configuration);
|
|
||||||
await RunToCompletionAsync(firstSeeder);
|
|
||||||
|
|
||||||
var (secondSeeder, _) = Build(configuration);
|
|
||||||
await RunToCompletionAsync(secondSeeder);
|
|
||||||
|
|
||||||
var metadata = await storage.HeadObjectAsync(
|
|
||||||
InfrastructureTestFixture.S3Bucket, configuration.Routes.PresetSmallPicturePath, TestContext.Current.CancellationToken);
|
|
||||||
Assert.NotNull(metadata);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task ExecuteAsync_SeedPresetPicturesDisabled_UploadsNothing()
|
|
||||||
{
|
|
||||||
var configuration = NewConfiguration(overwritePresetPictures: false);
|
|
||||||
configuration.SeedPresetPictures = false;
|
|
||||||
var (seeder, storage) = Build(configuration);
|
|
||||||
|
|
||||||
await RunToCompletionAsync(seeder);
|
|
||||||
|
|
||||||
var metadata = await storage.HeadObjectAsync(
|
|
||||||
InfrastructureTestFixture.S3Bucket, configuration.Routes.PresetSmallPicturePath, TestContext.Current.CancellationToken);
|
|
||||||
Assert.Null(metadata);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,913 @@
|
|||||||
|
# Plan — Refactor y unificación del frontend (web-player)
|
||||||
|
|
||||||
|
> Plan de trabajo para agentes/IA con contexto limitado (~128k). Sustituye a
|
||||||
|
> `design-system-refactor-plan.md` (cuyo contenido ya está mayormente aplicado).
|
||||||
|
> Basado en una lectura completa de **todos** los `.svelte`, `.css` y `.ts` de cliente
|
||||||
|
> de `web-player/src` (2026-09-18, rama `design-prot`).
|
||||||
|
|
||||||
|
## Cómo usar este documento
|
||||||
|
|
||||||
|
1. **Lee siempre la sección A (Contexto común)** — es corta y todas las partes la asumen.
|
||||||
|
2. Elige **una parte** (P0…P14). Cada parte indica: objetivo, prerrequisitos, archivos
|
||||||
|
a leer, tareas, API exacta de lo que se crea, tabla de mapeo y criterios de cierre.
|
||||||
|
3. Una parte = un commit/PR. No mezcles partes.
|
||||||
|
4. Si una parte necesita algo de otra (p. ej. un token o un helper) y **no existe
|
||||||
|
todavía**, créalo tú siguiendo la especificación de la sección B — están descritos con
|
||||||
|
ruta y API exactas para que cualquier parte pueda crearlos de forma idéntica.
|
||||||
|
5. Al terminar una parte, marca su casilla en la sección D (Registro).
|
||||||
|
|
||||||
|
Orden recomendado (las flechas son dependencias **blandas**, ver punto 4):
|
||||||
|
|
||||||
|
```
|
||||||
|
P0 → P1 → P2
|
||||||
|
↘ P3 (Artwork/Avatar) ─┐
|
||||||
|
↘ P4 (Botones) ───────┤
|
||||||
|
↘ P5 (Helpers TS) ─────┼→ P6 (Menús) → P7 (Filas) → P8 (Tablas) → P9 (Tarjetas)
|
||||||
|
└→ P10 (Cabeceras) → P11 (Formularios) → P12 (Chrome) → P13 (Páginas) → P14 (Barrido final)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A. Contexto común (leer siempre)
|
||||||
|
|
||||||
|
### A.1 Stack y reglas del proyecto (de `web-player/CLAUDE.md`)
|
||||||
|
|
||||||
|
- SvelteKit 2 + **Svelte 5 runes** (`$props()` con `interface Props`, `$state`,
|
||||||
|
`$derived`, `$effect`; nada de `export let` ni `$:`; `{#each}` siempre con clave).
|
||||||
|
- **Tailwind 4**: no hay `tailwind.config.js`; la config vive en `src/routes/layout.css`
|
||||||
|
(`@theme`, `@utility`). Tokens de color crudos en `src/lib/theme/theme.css` (`--mf-*`),
|
||||||
|
tokens dinámicos por hue en `src/lib/theme/tokens.ts` (`buildThemeTokens`).
|
||||||
|
- Sin comentarios en código salvo imprescindibles.
|
||||||
|
- Cierre de cada parte: `npm run check` y `npm run lint` **limpios** (desde `web-player/`).
|
||||||
|
- Iconos: `@lucide/svelte/icons/<nombre>`.
|
||||||
|
|
||||||
|
### A.2 Gotchas conocidos (no reintroducirlos)
|
||||||
|
|
||||||
|
1. **Tokens animados por hue** (`--mf-bg`, `--mf-elevated`, `--mf-panel-bg`, `--mf-bar-bg`,
|
||||||
|
`--mf-hairline`, `--mf-hero-bg`, `--mf-modal-glow`, `--mf-spotlight-bg`,
|
||||||
|
`--mf-logo-grad`, `--mf-cover-grad`, `--mf-accent*`, `--mf-ambient`) se aplican como
|
||||||
|
**estilo inline** en `<html>` desde `+layout.svelte`. Un override en
|
||||||
|
`:root[data-theme='light']` **no gana**: la variante clara va en `buildThemeTokens()`.
|
||||||
|
2. **Conflictos de clases Tailwind**: nunca `class="bg-a {cond ? 'bg-b' : ''}"`. Siempre
|
||||||
|
mutuamente excluyente: `class={cond ? 'bg-b' : 'bg-a'}`. Overrides de la clase base de
|
||||||
|
un componente compartido → prop/variante, no `!important`.
|
||||||
|
3. Texto sin clase de tamaño hereda 16px del navegador. Todo texto visible debe tener una
|
||||||
|
receta tipográfica (ver B.1.6).
|
||||||
|
4. `--mf-ink` es "texto sobre CTA" y se invierte en claro; `--mf-tile-shade`,
|
||||||
|
`--mf-cover-grad`, `--mf-liked-grad` son "arte" (no invertir).
|
||||||
|
5. Referencia visual: **Inicio** (`src/routes/+page.svelte`). Un cambio de refactor no
|
||||||
|
debe cambiar el aspecto salvo donde la parte lo diga explícitamente (columna "Δ").
|
||||||
|
|
||||||
|
### A.3 Verificación de cada parte
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd web-player
|
||||||
|
npm run check
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
Visual (Browser pane, `.claude/launch.json` → `musify-web`; login de dev en la memoria del
|
||||||
|
proyecto): abre cada página tocada **y** Inicio; prueba estado activo (una canción
|
||||||
|
sonando), hover, móvil (375px) y modo claro (menú de cuenta → "Modo blanco").
|
||||||
|
|
||||||
|
### A.4 Línea base medida (para saber si avanzas)
|
||||||
|
|
||||||
|
| Métrica | Hoy |
|
||||||
|
| ------------------------------------------------- | --------------------------------------- |
|
||||||
|
| Componentes `.svelte` (lib + rutas `components/`) | 61 |
|
||||||
|
| Líneas `.svelte`/`.css`/`.ts` cliente | ~8.700 |
|
||||||
|
| Clases arbitrarias `x-[…]` | 44 |
|
||||||
|
| Espaciados fraccionarios (`h-3.75`, `gap-2.75`…) | ~190 apariciones, 60 valores distintos |
|
||||||
|
| Radios distintos usados | 13 (6 tokens + 7 de Tailwind por defecto) |
|
||||||
|
| `strokeWidth` distintos | 13 valores |
|
||||||
|
| Atributos `style="…"` | 24 |
|
||||||
|
| Pluralizaciones a mano `=== 1 ?` | 15 |
|
||||||
|
| Errores de `eslint` | 2 (`liked/+page.svelte`) |
|
||||||
|
|
||||||
|
Objetivo global: ~61 → ~45 componentes, −1.500/−2.000 líneas, 0 arbitrarios fuera de una
|
||||||
|
lista blanca, 1 escala por dimensión.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## B. Especificaciones compartidas (fuente única)
|
||||||
|
|
||||||
|
Cualquier parte que necesite algo de aquí y no exista, lo crea **exactamente así**.
|
||||||
|
|
||||||
|
### B.1 Tokens (en `src/routes/layout.css`, dentro de `@theme`)
|
||||||
|
|
||||||
|
Tailwind 4 genera utilidades a partir de estos namespaces: `--radius-*` → `rounded-*`,
|
||||||
|
`--spacing-*` → `size-* w-* h-* p-* m-* gap-*`, `--container-*` → `max-w-*`,
|
||||||
|
`--tracking-*` → `tracking-*`, `--leading-*` → `leading-*`.
|
||||||
|
|
||||||
|
#### B.1.1 Radios (`--radius-*`)
|
||||||
|
|
||||||
|
| Token | Valor | Para qué |
|
||||||
|
| ----------------- | ---------- | ----------------------------------------- |
|
||||||
|
| `rounded-tag` | `0.25rem` | badges, celdas mini (ExplicitBadge, índice) |
|
||||||
|
| `rounded-thumb` | `0.5rem` | carátulas ≤ 36px, logo |
|
||||||
|
| `rounded-control` | `0.625rem` | inputs, filas, items de menú, botones |
|
||||||
|
| `rounded-art` | `0.8125rem`| carátulas 42–88px, tarjetas de grid |
|
||||||
|
| `rounded-art-lg` | `1.125rem` | carátulas ≥ 136px, dropzones |
|
||||||
|
| `rounded-panel` | `1.125rem` | contenedores, menús, modales, tiles |
|
||||||
|
| `rounded-panel-lg`| `1.375rem` | hero, spotlight, "mejor resultado" |
|
||||||
|
| `rounded-full` | (Tailwind) | pastillas, avatares, botones redondos |
|
||||||
|
|
||||||
|
Eliminar `--radius-art-round` (0 usos). Mapeo de lo existente:
|
||||||
|
|
||||||
|
| Hoy | px | Usos | Pasa a | Δ |
|
||||||
|
| ---------------------------- | -- | ---- | ----------------- | ---- |
|
||||||
|
| `rounded-sm`, `rounded` | 4 | 2 | `rounded-tag` | 0 |
|
||||||
|
| `rounded-md` | 6 | 1 | `rounded-thumb` | +2px |
|
||||||
|
| `rounded-lg` | 8 | 4 | `rounded-thumb` | 0 |
|
||||||
|
| `rounded-xl` | 12 | 6 | `rounded-control` | −2px |
|
||||||
|
| `rounded-2xl` | 16 | 8 | `rounded-art-lg` (arte) / `rounded-panel` (contenedor) | +2px |
|
||||||
|
| `rounded-3xl` | 24 | 1 | `rounded-panel-lg`| −2px |
|
||||||
|
|
||||||
|
#### B.1.2 Tamaños de carátula/arte (`--spacing-cover-*`)
|
||||||
|
|
||||||
|
| Token | Valor | Usos actuales que absorbe (Δ) |
|
||||||
|
| ---------------- | ------------------------------ | --------------------------------------------------------------- |
|
||||||
|
| `cover-xs` | `2.25rem` (36) | thumbs de menús `h-9`; sidebar `h-8.5` (+2) |
|
||||||
|
| `cover-sm` | `2.625rem` (42) | filas de tabla `h-10.5`; cola y "Populares" `h-10` (+2); player móvil `h-11` (−2) |
|
||||||
|
| `cover-md` | `3rem` (48) | player escritorio `md:h-12` |
|
||||||
|
| `cover-lg` | `3.5rem` (56) | "Continuar" `h-14`; filas de búsqueda `h-13` (+4); tarjeta playlist de Inicio `h-15.5` (−6) |
|
||||||
|
| `cover-xl` | `5.5rem` (88) | "Mejor resultado" `h-22` |
|
||||||
|
| `cover-2xl` | `8.5rem` (136) | spotlight de Inicio `h-34` |
|
||||||
|
| `cover-hero` | `9rem` (144) | cabeceras de colección, dropzones, avatar de perfil |
|
||||||
|
| `cover-hero-sm` | `10.25rem` (164) | cabeceras de colección a partir de `sm:` |
|
||||||
|
|
||||||
|
Uso: `size-cover-sm` (Tailwind 4 `size-*` = `width`+`height`). **Nunca** `h-X w-X` en
|
||||||
|
pareja: siempre `size-*`.
|
||||||
|
|
||||||
|
#### B.1.3 Iconos (`--spacing-icon-*`) y trazo
|
||||||
|
|
||||||
|
| Token | Valor | Absorbe |
|
||||||
|
| ---------- | ---------- | ---------------------------------------------- |
|
||||||
|
| `icon-xs` | `0.875rem` | `h-3.5` (14) |
|
||||||
|
| `icon-sm` | `1rem` | `h-3.75` (15), `h-4` (16), `h-4.25` (17) |
|
||||||
|
| `icon-md` | `1.125rem` | `h-4.5` (18) |
|
||||||
|
| `icon-lg` | `1.25rem` | `h-5` (20), `h-6` (24, login móvil) |
|
||||||
|
| `icon-xl` | `2.25rem` | `h-9`/`h-10` (estados vacíos, dropzone, Me gusta) |
|
||||||
|
|
||||||
|
Trazo: en `layout.css`, fuera de `@theme`:
|
||||||
|
|
||||||
|
```css
|
||||||
|
@layer base {
|
||||||
|
svg.lucide {
|
||||||
|
stroke-width: 1.8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@utility stroke-thin { stroke-width: 1.3; }
|
||||||
|
@utility stroke-regular { stroke-width: 1.8; }
|
||||||
|
@utility stroke-bold { stroke-width: 2.5; }
|
||||||
|
```
|
||||||
|
|
||||||
|
La propiedad CSS `stroke-width` gana al atributo de presentación que pone Lucide, así que
|
||||||
|
el prop `strokeWidth` deja de tener efecto: **se elimina de todos los usos**. Mapeo:
|
||||||
|
`1–1.4` → `stroke-thin`, `1.5–2` → (nada, default), `2.5–3` → `stroke-bold`,
|
||||||
|
`strokeWidth={0}` + `fill` → clase `stroke-0`. Verifica en el navegador (DevTools →
|
||||||
|
Computed `stroke-width`) que el default aplica antes de migrar en masa.
|
||||||
|
|
||||||
|
#### B.1.4 Anchos (`--container-*`) y layout
|
||||||
|
|
||||||
|
| Token | Valor | Absorbe |
|
||||||
|
| --------------------- | -------- | --------------------------------------------------------- |
|
||||||
|
| `max-w-prose-sm` | `26rem` | `max-w-105` (error), `max-w-108` (hero p), `max-w-110`, `max-w-115` (sin resultados) |
|
||||||
|
| `max-w-hero` | `34rem` | `max-w-135` (hero, seekbar) |
|
||||||
|
| `max-w-search` | `30rem` | `max-w-120` (buscador TopBar) |
|
||||||
|
| `max-w-cta` | `20rem` | `max-w-80` (botones login) |
|
||||||
|
|
||||||
|
Ya existen y se mantienen en `theme.css`: `--mf-sidebar-w`, `--mf-queue-w`, `--mf-nav-h`,
|
||||||
|
`--mf-player-h`, `--mf-safe-b`. Añadir:
|
||||||
|
|
||||||
|
```css
|
||||||
|
--mf-player-side-w: 16rem; /* w-64 de PlayerBar/PlayerExtras */
|
||||||
|
--mf-menu-w: 15rem; /* w-60 de menús contextuales (= MENU_WIDTH 240) */
|
||||||
|
--mf-submenu-w: 16rem; /* w-64 (= SUBMENU_WIDTH 256) */
|
||||||
|
--mf-range-h: 3.5px; /* barra de progreso y volumen */
|
||||||
|
--mf-range-thumb: 11px;
|
||||||
|
--mf-volume-w: 84px;
|
||||||
|
```
|
||||||
|
|
||||||
|
Las constantes TS `MENU_WIDTH`/`SUBMENU_WIDTH` (duplicadas hoy en `TrackContextMenu` y
|
||||||
|
`AlbumContextMenu`) pasan a `src/lib/config.ts` con un comentario-no: el nombre basta.
|
||||||
|
|
||||||
|
#### B.1.5 Capas (`z-index`)
|
||||||
|
|
||||||
|
En `theme.css` `:root`: `--z-raised: 10; --z-sticky: 20; --z-backdrop: 30; --z-menu: 40;
|
||||||
|
--z-submenu: 50; --z-modal: 60; --z-grain: 80;`. Uso: `z-(--z-menu)`. Mapeo 1:1 con los
|
||||||
|
valores actuales (`z-10`,`z-20`,`z-30`,`z-40`,`z-50`,`z-[60]`, `body::after` 80). Nota:
|
||||||
|
`MobileHeader` y `PlayerDock` usan `z-40` pero son "sticky", no menú → `--z-sticky`
|
||||||
|
(verificar que el menú de cuenta sigue encima del header: menú 40 > sticky 20).
|
||||||
|
|
||||||
|
#### B.1.6 Tipografía (recetas `@utility` en `layout.css`)
|
||||||
|
|
||||||
|
Escala tipográfica = la de Tailwind (`text-xs`…`text-4xl`), **sin tamaños arbitrarios**.
|
||||||
|
Añadir en `@theme`: `--tracking-display: -0.02em; --tracking-eyebrow: 0.1em;
|
||||||
|
--leading-body: 1.65;`. Recetas (el color **no** va en la receta, se añade aparte):
|
||||||
|
|
||||||
|
```css
|
||||||
|
@utility text-display-1 { @apply font-display text-3xl font-medium tracking-tight sm:text-4xl; }
|
||||||
|
@utility text-display-2 { @apply font-display text-2xl font-medium tracking-tight; }
|
||||||
|
@utility text-display-3 { @apply font-display text-xl font-medium tracking-display; }
|
||||||
|
@utility text-display-4 { @apply font-display text-lg font-medium tracking-tight; }
|
||||||
|
@utility text-eyebrow { @apply text-xs font-medium tracking-widest uppercase; }
|
||||||
|
@utility text-body { @apply text-sm leading-body; }
|
||||||
|
@utility text-count { @apply text-xs tabular-nums; }
|
||||||
|
```
|
||||||
|
|
||||||
|
> Si `@apply` con variante `sm:` dentro de `@utility` no compila, usa
|
||||||
|
> `@media (width >= 40rem) { font-size: var(--text-4xl); line-height: var(--text-4xl--line-height); }`
|
||||||
|
> como hace `page-x`.
|
||||||
|
|
||||||
|
Mapeo de lo existente:
|
||||||
|
|
||||||
|
| Patrón hoy | Receta |
|
||||||
|
| -------------------------------------------------------------------------------------------- | ------------------------------ |
|
||||||
|
| `font-display text-3xl … sm:text-4xl` (PageHeader, CollectionHeader, hero Inicio, h1 búsqueda `text-2xl sm:text-3xl font-semibold tracking-[-0.03em]`) | `text-display-1` (Δ búsqueda: +1 paso) |
|
||||||
|
| `font-display text-2xl` (stats Inicio, título spotlight, "¡Subida!") | `text-display-2` |
|
||||||
|
| `font-display text-xl … tracking-[-0.02em]` (Modal, EmptyState, +error, "Sin resultados", diálogos de borrado) | `text-display-3` (todos `font-medium`) |
|
||||||
|
| `font-display text-lg` (SectionHeading, "Mejor resultado", Queue "En cola" `text-base`) | `text-display-4` |
|
||||||
|
| `text-xs font-medium tracking-widest uppercase` (+ variantes `tracking-[0.14em]`, `[0.16em]`, `tracking-wider`, `text-sm font-semibold`) | `text-eyebrow` |
|
||||||
|
| `text-sm leading-[1.65] text-fg-2` | `text-body text-fg-2` |
|
||||||
|
| `text-xs text-muted tabular-nums` | `text-count text-muted` |
|
||||||
|
| `tracking-[-0.01em]` (MixTile) | quitar |
|
||||||
|
|
||||||
|
#### B.1.7 Recetas de superficie y movimiento (`@utility` en `layout.css`)
|
||||||
|
|
||||||
|
```css
|
||||||
|
@utility theme-transition { transition: background 0.5s ease-out; } /* 8× transition-[background] duration-500 */
|
||||||
|
@utility glass-bar { /* PlayerBar ×2, TabsBar */
|
||||||
|
background-image: var(--mf-bar-bg);
|
||||||
|
border-top: 1px solid var(--mf-hairline);
|
||||||
|
backdrop-filter: blur(24px);
|
||||||
|
transition: background 0.5s ease-out;
|
||||||
|
}
|
||||||
|
@utility grid-cards { /* MediaGrid por defecto: 150/165 */
|
||||||
|
display: grid;
|
||||||
|
gap: --spacing(3);
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(9.375rem, 1fr));
|
||||||
|
@media (width >= 40rem) {
|
||||||
|
gap: --spacing(4);
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(10.3125rem, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@utility grid-cards-lg { /* /playlists y /u/[id]: 180/210 */
|
||||||
|
display: grid;
|
||||||
|
gap: --spacing(3);
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(11.25rem, 1fr));
|
||||||
|
@media (width >= 40rem) {
|
||||||
|
gap: --spacing(4);
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(13.125rem, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@utility grid-tiles { /* mixes de Inicio: 164 */
|
||||||
|
display: grid;
|
||||||
|
gap: --spacing(5);
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(10.25rem, 1fr));
|
||||||
|
}
|
||||||
|
@utility grid-wide { /* playlists de Inicio (258) y géneros (268) → 16.5rem, Δ ±6px */
|
||||||
|
display: grid;
|
||||||
|
gap: --spacing(3.5);
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(16.5rem, 1fr));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`.glass-panel` ya existe; `Modal` debe usarla en lugar de su `style=` inline duplicado.
|
||||||
|
|
||||||
|
Stagger (sustituye los 5 `style="animation-delay:…"` con 40 **o** 45ms):
|
||||||
|
|
||||||
|
```css
|
||||||
|
.animate-enter { animation-delay: calc(min(var(--i, 0), 10) * 45ms); }
|
||||||
|
```
|
||||||
|
|
||||||
|
Uso: `style="--i:{i}"`. Único `style=` permitido junto con los de B.4.
|
||||||
|
|
||||||
|
#### B.1.8 Colores: limpieza
|
||||||
|
|
||||||
|
- Borrar utilidades sin uso: `--color-accent-dim`, `--color-accent-glow`,
|
||||||
|
`--color-accent-hair`, `--color-accent-line`, `--color-accent-muted` (0 usos como
|
||||||
|
clase; `--mf-accent-dim` sí se usa en `::selection`, se mantiene la variable `--mf-`).
|
||||||
|
- `--color-on-accent` es alias de `--mf-on-art` (igual que `--color-on-art`) → borrar y
|
||||||
|
usar `on-art`. `--color-hover` = `--mf-surface-2` (igual que `surface-2`) → mantener
|
||||||
|
**solo** `hover` como semántico de hover y dejar `surface-2` para fondos en reposo.
|
||||||
|
- `--mf-sidebar-bg` no lo usa ningún componente (Sidebar usa `bg-bg`) → borrar de
|
||||||
|
`theme.css` y de `buildThemeTokens`.
|
||||||
|
- Botón: `--mf-btn-bg` = `--mf-surface-2`; `--mf-btn-secondary-bg` solo difiere en modo
|
||||||
|
claro. Exponer en `@theme` `--color-btn`, `--color-btn-hover`, `--color-btn-glass`,
|
||||||
|
`--color-btn-glass-hover` y eliminar los `bg-[var(--mf-btn-…)]` de `Button.svelte`.
|
||||||
|
- Scrollbar: `rgba(255,255,255,.18/.32)` fijo en `layout.css` (roto en claro) → tokens
|
||||||
|
`--mf-scrollbar`, `--mf-scrollbar-hover` con variante en `:root[data-theme='light']`.
|
||||||
|
- `MixTile`: `to-black/42`, `bg-ink/76`, `border-on-art/12` → `to-scrim/40`, receta de
|
||||||
|
`PlayButton` (B.3).
|
||||||
|
|
||||||
|
### B.2 Helpers TypeScript (fuente única)
|
||||||
|
|
||||||
|
| Archivo (nuevo) | API | Sustituye |
|
||||||
|
| ----------------------------------- | ------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------- |
|
||||||
|
| `src/lib/actions/pressable.ts` | `pressable(node, fn)` → añade `role="button"`, `tabindex=0`, click + Enter/Espacio | 7 copias de `onkeydown` Enter/Space (Inicio ×5, Queue, TrackTitleCell) |
|
||||||
|
| `src/lib/actions/clickOutside.ts` | `clickOutside(node, fn)` | `onDocumentClick` de `AccountMenu` |
|
||||||
|
| `src/lib/toggle.svelte.ts` | `createToggle(initial=false)` → `{ get open, show(), close(), toggle() }` | `playlists.svelte.ts` y `player/queuePanel.svelte.ts` (idénticos) |
|
||||||
|
| `src/lib/format.ts` (ampliar) | `plural(n, one, many)` → `"1 canción"`; `fmtCount(n,'canción','canciones')`; **minúscula siempre** | 15 pluralizaciones a mano, con mayúsculas inconsistentes ("Canción"/"canción") |
|
||||||
|
| `src/lib/navigation.svelte.ts` (ampliar) | `searchHref(q)`; `appNavLinks(user, counts?)` → `{href,label,icon,count?,primary:boolean}[]` | `buildHref` en TopBar y explore; listas de links de Sidebar y TabsBar |
|
||||||
|
| `src/lib/player/player.svelte.ts` (ampliar) | `isQueueCurrent(items)`, `playAllOrToggle(items)`, `playShuffled(items)`; renombrar `addToQueue` → `playNextItem` | `playAll`/`isCurrentQueue` en álbum, playlist, liked, mixes, spotlight de Inicio (5 copias) |
|
||||||
|
| `src/lib/player/player.svelte.ts` | `toQueueItem(track)` único (normaliza `duration`/`listensCount` a `number`) | mapeos a mano en `recentlyPlayed.ts` (×2), `liked.svelte.ts` (×2), `liked/+page`, `tracks.ts#targetForQueueItem` y los `Number(…)` dispersos |
|
||||||
|
| `src/lib/menus.svelte.ts` | `createTrackMenu()` → `{ state, open(e, track), close(), playNext() }` | estado + handler de menú contextual en Inicio, Explorar, Mezcla |
|
||||||
|
|
||||||
|
### B.3 Componentes nuevos (API)
|
||||||
|
|
||||||
|
| Componente (`$lib/components/ui/`) | Props | Sustituye / absorbe |
|
||||||
|
| ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ------------------- |
|
||||||
|
| `Artwork.svelte` | `src?`, `trackIds?: (string\|number)[]`, `size: 'xs'\|'sm'\|'md'\|'lg'\|'xl'\|'2xl'\|'hero'`, `shape?: 'auto'\|'round'`, `imageSize?: 'small'\|'medium'\|'large'` (auto por `size`), `fallback?: 'music'\|'gradient'\|'none'`, `class?`, `children?` (overlay) | `Cover`, `PlaylistArt`, `MixArt`; radio automático por tamaño (xs→thumb, sm–xl→art/control, 2xl/hero→art-lg). Prioridad: `src` → mosaico si ≥4 `trackIds` → primera carátula → fallback. Un único `onerror`. |
|
||||||
|
| `Avatar.svelte` | `name`, `src?`, `size: 'sm'\|'md'\|'lg'\|'hero'`, `class?` | iniciales/imagen en TopBar, MobileHeader, Explorar (×2), `ArtistAvatar` |
|
||||||
|
| `ListRow.svelte` | `href?` \| `onclick?`, `oncontextmenu?`, `active?`, `size?: 'sm'\|'md'\|'lg'`, `title`, `subtitle?`, `subtitleHref?`, `explicit?`, `meta?`, `leading?: Snippet`, `art: Snippet<[string]>` \| artwork props, `trailing?: Snippet` | `SearchResultRow` + 7 filas a mano (ver P7) |
|
||||||
|
| `MediaIdentity.svelte` | `title`, `subtitle?`, `subtitleHref?`, `explicit?`, `active?`, `art?: Snippet` \| `artwork` props, `titleClass?` | `TrackTitleCell` (renombrado + generalizado) y el bloque de `player/TrackInfo` |
|
||||||
|
| `TrackList.svelte` | `tracks: TrackLike[]`, `columns?: ('album'\|'added'\|'uploaded'\|'plays')[]`, `index?`, `onPlay(i)`, `rowAction?: {action, icon, label}` \| `rowActionSnippet?`, `oncontextmenu?(e, track)` | `TrackTable`+`TrackRow`+`TrackIndexCell`+`TrackMeta` y las 6 tablas (P8) |
|
||||||
|
| `ContextMenu.svelte` | `x`, `y`, `openLeft`, `onClose`, `items: {icon,label,onclick}[]`, `playlistAction?: {action: string, fields: Record<string,string>, label}`, `playlists` | `TrackContextMenu` + `AlbumContextMenu` (90% idénticos) |
|
||||||
|
| `PlayButton.svelte` | `playing?`, `size: 'sm'\|'md'\|'lg'`, `variant: 'strong'\|'glass'`, `label`, `onclick?`, `as?: 'button'\|'span'` | play de TransportControls, "Mejor resultado", overlay de MixTile |
|
||||||
|
| `ConfirmDialog.svelte` | `open`, `onClose`, `title`, `description`, `confirmLabel`, `action` (form POST) | 2 modales de borrado con estilos distintos |
|
||||||
|
| `Slider.svelte` | `value`, `max`, `label`, `oninput`, `variant: 'seek'\|'volume'` | `SeekBar` (click-only) + `VolumeControl` (`<style>` propio) |
|
||||||
|
| `Logo.svelte` | `size: 'sm'\|'lg'` | logo en Sidebar (sin marca), MobileHeader, login |
|
||||||
|
| `Chip.svelte` | `selected?`, `count?`, `href?`\|`onclick?` | chips de filtro y de géneros sugeridos en Explorar |
|
||||||
|
|
||||||
|
### B.4 Lista blanca de `style=` y arbitrarios (tras el refactor)
|
||||||
|
|
||||||
|
Permitidos: `style="--i:{i}"` (stagger), gradientes de tile por hue en Explorar
|
||||||
|
(`--tile-hue`), anchos de progreso (`width:{pct}%`), posición de menú (`left/top`).
|
||||||
|
Arbitrarios permitidos: `text-[clamp(…)]` de login, `grid-cols-[auto_1fr]`,
|
||||||
|
`max-h-[85dvh]`. Todo lo demás, a token.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## C. Partes
|
||||||
|
|
||||||
|
### P0 — Línea base y bugs sueltos (independiente, ~30 min)
|
||||||
|
|
||||||
|
**Objetivo:** dejar `lint` en verde y corregir bugs detectados que no requieren refactor.
|
||||||
|
|
||||||
|
Tareas:
|
||||||
|
1. `src/routes/(app)/liked/+page.svelte`: quitar imports `Play` y `Pause` (errores eslint).
|
||||||
|
2. `src/lib/components/ui/MediaCard.svelte`: el título no tiene tamaño (hereda 16px) →
|
||||||
|
`text-sm` (igual que `MixTile`).
|
||||||
|
3. `src/routes/+page.svelte`, filas del spotlight: `hover:bg-hover … {cond ? 'bg-accent-tint' : ''}`
|
||||||
|
→ excluyente (`{cond ? 'bg-accent-tint' : 'hover:bg-hover'}`), igual que las otras filas.
|
||||||
|
4. `src/routes/(app)/albums/[id]/+page.svelte` modal de borrado: `text-base text-fg-3` →
|
||||||
|
`text-sm leading-[1.65] text-fg-2` y título igual al de playlists (se unificará en P10).
|
||||||
|
5. `Sidebar.svelte` (botón `+`) y `Queue.svelte` (cerrar): quitar `text-base` inútil.
|
||||||
|
6. Borrar `src/lib/components/ui/Badge.svelte` (0 importadores).
|
||||||
|
7. `Button.svelte`: `primary` y `secondary` son la **misma** clase. No cambiar aspecto;
|
||||||
|
solo dejar `const variants = { primary: subtle, secondary: subtle, … }` con un único
|
||||||
|
`subtle` y anotar en D que P4 decide si `primary` debe diferenciarse (**decisión de
|
||||||
|
producto, preguntar al usuario**).
|
||||||
|
|
||||||
|
Cierre: `npm run lint` 0 errores.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### P1 — Tokens base (prerrequisito de P3–P14)
|
||||||
|
|
||||||
|
**Objetivo:** crear todo B.1 de forma **aditiva** (sin migrar componentes todavía, salvo
|
||||||
|
lo indicado). Tras esta parte, nada cambia visualmente.
|
||||||
|
|
||||||
|
Leer: `src/routes/layout.css`, `src/lib/theme/theme.css`, `src/lib/theme/tokens.ts`.
|
||||||
|
|
||||||
|
Tareas:
|
||||||
|
1. Añadir en `@theme`: radios B.1.1 (y borrar `--radius-art-round`), `--spacing-cover-*`
|
||||||
|
B.1.2, `--spacing-icon-*` B.1.3, `--container-*` B.1.4, `--tracking-*`/`--leading-*`
|
||||||
|
B.1.6, `--color-btn*` B.1.8.
|
||||||
|
2. Añadir en `theme.css` `:root`: variables de layout B.1.4 y z-index B.1.5.
|
||||||
|
3. Añadir `@utility` de B.1.3 (stroke), B.1.6 (tipografía), B.1.7 (superficies, grids);
|
||||||
|
stagger en `.animate-enter`.
|
||||||
|
4. Scrollbar a tokens con variante clara (B.1.8).
|
||||||
|
5. Borrar utilidades de color sin uso y `--mf-sidebar-bg` (B.1.8). Antes de borrar cada
|
||||||
|
una: `grep -rn "<nombre>" src` = 0.
|
||||||
|
6. Crear una página de prueba temporal **no commiteada** o usar DevTools para comprobar
|
||||||
|
que `size-cover-sm`, `rounded-thumb`, `max-w-hero`, `text-display-1`, `z-(--z-menu)`
|
||||||
|
y `stroke-thin` generan CSS (Tailwind 4 solo genera lo que ve usado: prueba con una
|
||||||
|
clase en un componente y revierte).
|
||||||
|
|
||||||
|
Cierre: check+lint; captura de Inicio antes/después idéntica.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### P2 — Tema: una sola fuente de verdad + sin parpadeo en modo claro
|
||||||
|
|
||||||
|
**Objetivo:** eliminar la duplicación `theme.css` ↔ `tokens.ts` y el flash oscuro al
|
||||||
|
cargar con modo claro guardado.
|
||||||
|
|
||||||
|
Hallazgos:
|
||||||
|
- Los valores por defecto de los ~15 tokens dinámicos están escritos **dos veces**: en
|
||||||
|
`theme.css` (`:root`, hue 145) y en `buildThemeTokens()`. El bloque claro de
|
||||||
|
`theme.css` repite `--mf-bg`, `--mf-elevated`, `--mf-panel-bg`, `--mf-bar-bg`,
|
||||||
|
`--mf-hairline`, `--mf-hero-bg`, `--mf-spotlight-bg`, `--mf-modal-glow`, que luego
|
||||||
|
`tokens.ts` pisa inline.
|
||||||
|
- `mode.svelte.ts` aplica `data-theme` solo tras hidratar → flash oscuro.
|
||||||
|
|
||||||
|
Tareas:
|
||||||
|
1. `src/app.html`: script inline en `<head>` que lea `localStorage['musify.theme']` y
|
||||||
|
ponga `document.documentElement.dataset.theme` antes del primer paint (try/catch).
|
||||||
|
2. `+layout.svelte`: en `<svelte:head>`, emitir
|
||||||
|
`<style>:root{…buildThemeTokens(ACCENT_HUE,'dark')}:root[data-theme=light]{…buildThemeTokens(ACCENT_HUE,'light')}</style>`
|
||||||
|
(serializado con un helper `tokensToCss(tokens)` en `tokens.ts`).
|
||||||
|
3. Borrar de `theme.css` todos los tokens que genera `buildThemeTokens` (dark y light).
|
||||||
|
`theme.css` queda solo con neutrales fijos, superficies, texto, layout y z-index.
|
||||||
|
4. `+layout.svelte`: los dos `div` de fondo con `style=` → `@utility app-backdrop` /
|
||||||
|
`app-vignette` en `layout.css`. `main` con `padding-bottom: calc(…)` inline → clase
|
||||||
|
con `--mf-dock-h` que se activa con `data-has-track` en el propio `main`.
|
||||||
|
|
||||||
|
Cierre: con modo claro guardado, recarga dura sin flash; cambio de pista sigue animando
|
||||||
|
el hue; check+lint.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### P3 — `Artwork` y `Avatar` (arte unificado)
|
||||||
|
|
||||||
|
**Objetivo:** un único componente de carátula y uno de avatar.
|
||||||
|
|
||||||
|
Hallazgos:
|
||||||
|
- `Cover`, `PlaylistArt` y `MixArt` implementan la misma cascada (imagen → mosaico 2×2 →
|
||||||
|
primera carátula → placeholder). `MixArt` ≡ `PlaylistArt` con
|
||||||
|
`trackIds = items.map(i => i.trackId)`.
|
||||||
|
- `AlbumCard` pasa el **id del álbum** como `trackId` a `Cover` solo para poder usar `src`
|
||||||
|
(hack).
|
||||||
|
- Avatar "imagen o inicial" reimplementado en `TopBar`, `MobileHeader`, Explorar (mejor
|
||||||
|
resultado + fila de usuario) y `ArtistAvatar` (con rayado).
|
||||||
|
- Cada uso decide su propio radio (`rounded-lg`, `rounded-xl`, `rounded-2xl`,
|
||||||
|
`rounded-control`, `rounded-art-lg`…) y su tamaño (`h-8.5`, `h-9`, `h-10`, `h-10.5`,
|
||||||
|
`h-11 md:h-12`, `h-13`, `h-14`, `h-15.5`, `h-22`, `h-34`, `h-36 sm:h-41`).
|
||||||
|
|
||||||
|
Tareas:
|
||||||
|
1. Crear `Artwork.svelte` y `Avatar.svelte` según B.3 usando tokens B.1.1/B.1.2.
|
||||||
|
`size` decide dimensión **y** radio; `class` solo para layout (`shrink-0`, márgenes).
|
||||||
|
2. Migrar **todos** los usos (buscar `Cover`, `PlaylistArt`, `MixArt`, `ArtistAvatar`,
|
||||||
|
`user.picture`, `profilePictureUrl`) aplicando la tabla B.1.2.
|
||||||
|
3. `ArtistAvatar` → en `u/[id]/+page.svelte` usar `<Avatar size="hero">` (el nombre ya lo
|
||||||
|
muestra la cabecera; el label bajo el avatar era redundante — **Δ visible**, confirmar).
|
||||||
|
4. Borrar `Cover.svelte`, `PlaylistArt.svelte`, `MixArt.svelte`, `ArtistAvatar.svelte`.
|
||||||
|
|
||||||
|
Cierre: `grep -rn "Cover\b\|PlaylistArt\|MixArt\|ArtistAvatar" src` solo en `Artwork`;
|
||||||
|
visual en Inicio, Explorar (búsqueda), Sidebar, Cola, player, cabeceras.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### P4 — Botones: `Button`, `IconButton`, `PlayButton`
|
||||||
|
|
||||||
|
**Objetivo:** que no quede ningún `<button>` estilado a mano.
|
||||||
|
|
||||||
|
Hallazgos (botones hechos a mano): TopBar atrás/adelante (círculos `h-7.5`), limpiar
|
||||||
|
búsqueda, login (`bg-accent-soft rounded-full`), MobileHeader login, Sidebar `+` ×2, Cola
|
||||||
|
cerrar/"Vaciar", PlayerExtras cola, TransportControls (shuffle, prev, play, next, repeat),
|
||||||
|
TrackInfo corazón, "Mejor resultado" (círculo `h-11.5`), MixTile overlay (`h-9.5`),
|
||||||
|
`BackLink`. Overrides con `!`: Inicio `!bg-cta-strong !text-ink hover:!brightness-95` y
|
||||||
|
`!font-normal`.
|
||||||
|
|
||||||
|
Tareas:
|
||||||
|
1. `Button.svelte`: variantes `subtle` (hoy primary/secondary), `glass` (hoy
|
||||||
|
`secondaryOnGlow`), `accent`, `strong` (cta-strong/ink — sustituye los `!`), `danger`.
|
||||||
|
Mantener alias `primary`/`secondary` solo si P0 no obtuvo decisión. Radio →
|
||||||
|
`rounded-control`. Quitar necesidad de `!font-normal` (peso normal en `glass`, o prop
|
||||||
|
`weight`).
|
||||||
|
2. `IconButton.svelte`: añadir `shape: 'square'|'round'`, `size: 'xs'|'sm'|'md'|'lg'`,
|
||||||
|
`tone: 'muted'|'subtle'|'plain'`, `pressed?: boolean` (→ `aria-pressed` + `text-accent`),
|
||||||
|
`href?`, `surface?: boolean` (fondo `surface-2`). Iconos con `size-icon-*`.
|
||||||
|
3. Crear `PlayButton.svelte` (B.3); iconos play/pausa SVG de `TransportControls` pasan a
|
||||||
|
él (un único sitio para esos `<svg>`).
|
||||||
|
4. Migrar todos los botones listados. `TopBar` "adelante" es un `<span>` decorativo
|
||||||
|
deshabilitado y `PlayerExtras` tiene un icono `AlignJustify` decorativo sin función:
|
||||||
|
**preguntar** si se eliminan (código muerto visual) o se mantienen por fidelidad al
|
||||||
|
prototipo.
|
||||||
|
|
||||||
|
Cierre: `grep -rn "<button" src | grep -v "ui/\(Button\|IconButton\|PlayButton\|Chip\|ListRow\)"`
|
||||||
|
→ solo casos justificados (dropzones, selector segmentado).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### P5 — Helpers TypeScript
|
||||||
|
|
||||||
|
**Objetivo:** crear B.2 y migrar sus usos (lógica, no estilos).
|
||||||
|
|
||||||
|
Tareas:
|
||||||
|
1. Crear `actions/pressable.ts`, `actions/clickOutside.ts`, `toggle.svelte.ts`.
|
||||||
|
`createPlaylistModal` y `queuePanel` → `createToggle()`; borrar los dos archivos viejos
|
||||||
|
o reducirlos a `export const queuePanel = createToggle()`.
|
||||||
|
2. `format.ts`: `plural()`; migrar las 15 pluralizaciones (`grep -rn "=== 1 ?" src`).
|
||||||
|
Unificar a minúscula ("3 canciones") — **Δ visible** en Playlists/Inicio/liked.
|
||||||
|
3. `player.svelte.ts`: `toQueueItem`, `isQueueCurrent`, `playAllOrToggle`,
|
||||||
|
`playShuffled`; renombrar `addToQueue` → `playNextItem` (el nombre actual confunde:
|
||||||
|
inserta a continuación, no al final). Migrar álbum, playlist, liked, mixes, Inicio.
|
||||||
|
4. `recentlyPlayed.ts` y `liked.svelte.ts`: usar `toQueueItem`.
|
||||||
|
5. `tracks.ts`: el envoltorio `TrackTarget = { track }` + 8 accesores (`targetTitle`,
|
||||||
|
`targetArtist`, …) no aporta nada. Sustituir por el `ApiTrackLike` directo y borrar
|
||||||
|
accesores; dejar solo `isCurrent(track)` si hace falta. (Toca Inicio, Explorar,
|
||||||
|
Mezcla, menús: hacerlo en esta parte para no dejar mezcla de estilos.)
|
||||||
|
6. `navigation.svelte.ts`: `searchHref`, `appNavLinks`.
|
||||||
|
7. `menus.svelte.ts`: `createTrackMenu()`; migrar Inicio, Explorar, Mezcla.
|
||||||
|
|
||||||
|
Cierre: check+lint; reproducir desde álbum, playlist, liked, mezcla, spotlight; menú
|
||||||
|
contextual "Reproducir a continuación" sigue funcionando.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### P6 — Menús: `ContextMenu` + `AccountMenu` + `GlassMenu`
|
||||||
|
|
||||||
|
**Objetivo:** un solo menú contextual.
|
||||||
|
|
||||||
|
Hallazgos: `TrackContextMenu` (109 líneas) y `AlbumContextMenu` (119) son copias:
|
||||||
|
misma función de posicionamiento con las mismas constantes, mismo backdrop, mismo Escape,
|
||||||
|
mismo submenú "Añadir a playlist" con `<form use:enhance>`; solo cambian los items y los
|
||||||
|
campos ocultos. Sus items reimplementan a mano las clases de `MenuItem` (con
|
||||||
|
`strokeWidth={2}` en vez de `1.8`). El item de playlist reimplementa `ListRow`.
|
||||||
|
|
||||||
|
Tareas:
|
||||||
|
1. Crear `ContextMenu.svelte` (B.3) usando `GlassMenu` + `MenuItem` + `Artwork size="xs"`
|
||||||
|
y anchos `--mf-menu-w`/`--mf-submenu-w`; `contextMenuPosition(event)` exportada del
|
||||||
|
módulo usando `MENU_WIDTH`/`SUBMENU_WIDTH` de `config.ts`.
|
||||||
|
2. Sustituir usos en Inicio, Explorar (pista y álbum), Mezcla. Borrar los dos menús viejos.
|
||||||
|
3. `AccountMenu`: usar `clickOutside`; eliminar el `div.relative` duplicado que
|
||||||
|
`TopBar`/`MobileHeader` ponen alrededor; `panelClass` → prop `width: 'sm'|'md'`.
|
||||||
|
4. Añadir Escape a `AccountMenu` (hoy no cierra con Escape — mejora a11y).
|
||||||
|
|
||||||
|
Cierre: menú de pista y de álbum en Explorar, submenú abre a izquierda cerca del borde,
|
||||||
|
móvil (submenú debajo), Escape cierra.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### P7 — Filas: `ListRow` + `MediaIdentity` + `EqBars`
|
||||||
|
|
||||||
|
**Objetivo:** una sola fila compacta para todas las listas que no son tabla.
|
||||||
|
|
||||||
|
Filas reimplementadas hoy (todas: arte + título + subtítulo + meta + estado activo):
|
||||||
|
|
||||||
|
| Dónde | Tamaño |
|
||||||
|
| --------------------------------------- | -------- |
|
||||||
|
| `SearchResultRow` (Explorar ×4 grupos) | lg |
|
||||||
|
| Inicio "Continuar escuchando" (tarjeta) | lg, card |
|
||||||
|
| Inicio "Populares" (índice/EQ + meta) | sm |
|
||||||
|
| Inicio spotlight (índice, sin arte) | sm |
|
||||||
|
| Inicio "Tus playlists" (tarjeta) | lg, card |
|
||||||
|
| `Queue` "Reproduciendo" + "A continuación" | sm |
|
||||||
|
| `Sidebar` playlists | xs |
|
||||||
|
| items de playlist de `ContextMenu` | xs |
|
||||||
|
|
||||||
|
Tareas:
|
||||||
|
1. Renombrar/generalizar `TrackTitleCell` → `MediaIdentity` (B.3). Usar en
|
||||||
|
`player/TrackInfo` (sustituye su bloque cover+título+artista).
|
||||||
|
2. Crear `ListRow` (B.3) sobre `MediaIdentity` + `pressable`; variante `card` = padding
|
||||||
|
`p-2.25 pr-3.5 rounded-art` (Continuar/Tus playlists).
|
||||||
|
3. `NowPlaying` ≡ `EqBars` con overlay: añadir a `EqBars` prop `overlay` (fondo
|
||||||
|
`scrim/45`, color `on-art`) y borrar `NowPlaying`.
|
||||||
|
4. Migrar todas las filas de la tabla. Borrar `SearchResultRow`.
|
||||||
|
|
||||||
|
Cierre: Inicio, Explorar, Cola, Sidebar sin cambios visibles salvo Δ de B.1.2; teclado
|
||||||
|
(Tab + Enter) reproduce en todas las filas.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### P8 — Tablas de pistas: `TrackList`
|
||||||
|
|
||||||
|
**Objetivo:** tablas declarativas; borrar 3 celdas y 3 componentes de ruta.
|
||||||
|
|
||||||
|
Hallazgos:
|
||||||
|
- 6 tablas (`library`, `liked`, `mixes/[id]`, `AlbumTrackTable`, `PlaylistTrackTable`,
|
||||||
|
`LibraryPicker`) repiten: `{@const active}`, `TrackIndexCell`, `TrackTitleCell`, N
|
||||||
|
`TrackMeta`, y un `<form method=POST use:enhance><input hidden trackId><IconButton revealOnHover>`.
|
||||||
|
- Anchos de columna pasados desde cada página e **inconsistentes**: "Escuchas" `106px` en
|
||||||
|
unas y `100px` en otras.
|
||||||
|
- La columna **"Álbum" siempre pinta "—"** (library, liked, playlist): placeholder sin
|
||||||
|
datos. **Decisión**: eliminarla (recomendado) o dejarla en el preset.
|
||||||
|
|
||||||
|
Tareas:
|
||||||
|
1. Crear `TrackList.svelte` (B.3) con presets de columna en el propio módulo:
|
||||||
|
`{ album: {label:'Álbum', width:'76px'}, added: {'Añadida','124px'}, uploaded: {'Subida','124px'}, plays: {'Escuchas','104px'} }`
|
||||||
|
y columnas fijas (`index 32px/28px`, título `minmax(180px,1fr)/minmax(120px,1fr)`,
|
||||||
|
duración `104px/52px`, acción `36px/32px`) como constantes del módulo.
|
||||||
|
2. `rowAction={{ action: '?/removeTrack', icon: X, label: 'Quitar de la playlist' }}`
|
||||||
|
genera el form+IconButton; para liked (`onclick`) aceptar `rowAction.onclick`.
|
||||||
|
3. Migrar las 6 tablas; inlinear `AlbumTrackTable`, `PlaylistTrackTable`, `LibraryPicker`
|
||||||
|
en sus páginas y borrarlos. Borrar `TrackRow`, `TrackIndexCell`, `TrackMeta`,
|
||||||
|
`TrackTable`.
|
||||||
|
4. `LibraryPicker` usa overlay `NowPlaying` en vez de índice: `TrackList` con
|
||||||
|
`index={false}` muestra EQ sobre el arte (vía `Artwork` children).
|
||||||
|
|
||||||
|
Cierre: las 6 páginas; hover revela acción; activo muestra EQ; móvil oculta metas.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### P9 — Tarjetas y grids
|
||||||
|
|
||||||
|
**Objetivo:** una tarjeta, cero wrappers.
|
||||||
|
|
||||||
|
Hallazgos: `AlbumCard` y `PlaylistCard` solo construyen el subtítulo y eligen el arte;
|
||||||
|
`MixTile` es una `MediaCard` con overlay de play y tipografía distinta; `MediaGrid` es un
|
||||||
|
`<div>` con variables inline; Inicio (mixes 164px, playlists 258px) y Explorar (géneros
|
||||||
|
268px) escriben su propio `grid-cols-[repeat(auto-fill,minmax(…))]`.
|
||||||
|
|
||||||
|
Tareas:
|
||||||
|
1. `MediaCard`: props `href`, `title`, `subtitle?`, `onPlay?` (overlay `PlayButton
|
||||||
|
variant="glass"`), `art` snippet **o** props de `Artwork`, `oncontextmenu?`, `index`.
|
||||||
|
Tipografía fija (`text-sm text-fg` / `text-xs text-fg-3`).
|
||||||
|
2. Helpers en `format.ts`: `albumMeta(album)`, `playlistMeta(playlist)`.
|
||||||
|
3. Sustituir `AlbumCard`, `PlaylistCard`, `MixTile` por `MediaCard`; `MediaGrid` por
|
||||||
|
`grid-cards`/`grid-cards-lg`/`grid-tiles`/`grid-wide` (B.1.7). Borrar los 4 archivos.
|
||||||
|
4. Tile de género de Explorar: `style` con `--tile-hue` y el gradiente en un `@utility
|
||||||
|
genre-tile` que lea `var(--tile-hue)`.
|
||||||
|
|
||||||
|
Cierre: /albums, /playlists, /u/[id], Inicio (mixes), Explorar (géneros).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### P10 — Cabeceras, diálogos y tipografía
|
||||||
|
|
||||||
|
**Objetivo:** 1 cabecera de página, 1 de sección, 1 diálogo de confirmación.
|
||||||
|
|
||||||
|
Hallazgos:
|
||||||
|
- `PageHeader` ≡ `CollectionHeader` sin portada/eyebrow. La cabecera de búsqueda de
|
||||||
|
Explorar reimplementa ambos (eyebrow `text-sm font-semibold tracking-[0.16em]`).
|
||||||
|
- `AlbumHeader`/`PlaylistHeader` (rutas) solo montan el meta y los botones.
|
||||||
|
- Cada página decide el margen tras la cabecera (`mt-7 sm:mt-8`, `mt-6 sm:mt-8`, `mt-9`,
|
||||||
|
`mt-10 sm:mt-12`…).
|
||||||
|
- 2 diálogos de borrado con tipografías distintas.
|
||||||
|
- `EmptyState` duplicado literalmente en Inicio ("Todavía no has escuchado nada" ×2).
|
||||||
|
|
||||||
|
Tareas:
|
||||||
|
1. Fusionar en `PageHeader`: `title`, `eyebrow?`, `description?`, `meta?`, `cover?`,
|
||||||
|
`actions?`, `align?`. **La cabecera fija su propio margen inferior** (`mb-7 sm:mb-8`);
|
||||||
|
quitar los `mt-*` de lo que venga después. Borrar `CollectionHeader`.
|
||||||
|
2. Inlinear `AlbumHeader`/`PlaylistHeader` en sus páginas (con `plural()` y el `BackLink`)
|
||||||
|
y borrarlos. Iconos de botones: unificar (álbum tiene iconos, playlist no) →
|
||||||
|
**decisión**: con iconos en ambos (recomendado) o sin ellos.
|
||||||
|
3. `SectionHeading`: prop `count?` que pinte la línea + "N resultados" (hoy repetido 4× en
|
||||||
|
Explorar) y `href?`/`linkLabel?` para "Ver todas".
|
||||||
|
4. `ConfirmDialog` (B.3); migrar álbum y playlist. `Modal` usa `.glass-panel` y
|
||||||
|
`z-(--z-modal)`; su eyebrow y título con recetas.
|
||||||
|
5. Aplicar recetas B.1.6 en: `EmptyState`, `+error.svelte`, "Sin resultados", hero de
|
||||||
|
Inicio, stats, `Queue`, `Sidebar` ("Tus playlists"), `PlaylistForm` (visibilidad).
|
||||||
|
6. Inicio: estado vacío repetido → snippet local.
|
||||||
|
|
||||||
|
Cierre: todas las páginas con cabecera; diálogos de borrado idénticos.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### P11 — Formularios
|
||||||
|
|
||||||
|
**Objetivo:** un único esqueleto de formulario con portada.
|
||||||
|
|
||||||
|
Hallazgos:
|
||||||
|
- `AlbumForm` y `PlaylistForm` comparten: `use:enhance` con `submitting`,
|
||||||
|
`ImageDropzone gradient class="h-36 w-36 rounded-2xl"`, `Field`/`Input`/`Textarea`,
|
||||||
|
`Alert`, pie Cancelar/Enviar.
|
||||||
|
- `upload/+page.svelte` reimplementa el selector de portada (label + input + preview +
|
||||||
|
gradiente) que ya hace `ImageDropzone`.
|
||||||
|
- `Input` y `Textarea` repiten la misma cadena de clases.
|
||||||
|
- Toggle Privada/Pública (`w-[calc(50%-4px)]`) y chips de Explorar: selectores de una
|
||||||
|
opción con estilos propios.
|
||||||
|
|
||||||
|
Tareas:
|
||||||
|
1. `CoverForm.svelte` (esqueleto): props `action`, `coverFallbackUrl?`, `coverIcon`,
|
||||||
|
`requireCover?`, `formMessage?`, `submitLabel`, `submittingLabel`, `canSubmit`,
|
||||||
|
`helperText?`, `onCancel`, `onSuccess?`, snippet `fields`. `AlbumForm` y
|
||||||
|
`PlaylistForm` quedan como ~30 líneas de campos cada uno (o se inlinean en sus
|
||||||
|
páginas si solo se usan en un sitio: `AlbumForm` se usa en 2, `PlaylistForm` en 2 →
|
||||||
|
mantener).
|
||||||
|
2. `ImageDropzone`: `size="hero"` usando `size-cover-hero` + `rounded-art-lg`; upload la
|
||||||
|
usa (Δ: icono de 64px → `icon-xl`).
|
||||||
|
3. `@utility field-control` en `layout.css` con la cadena común de `Input`/`Textarea`.
|
||||||
|
4. `SegmentedControl.svelte` (`options`, `value` bindable) para Privada/Pública; `Chip`
|
||||||
|
(B.3) para Explorar.
|
||||||
|
|
||||||
|
Cierre: crear/editar álbum y playlist, subir canción (drag&drop incluido).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### P12 — Chrome de la app (layout, navegación, reproductor)
|
||||||
|
|
||||||
|
**Objetivo:** una sola definición de navegación y un reproductor más pequeño.
|
||||||
|
|
||||||
|
Hallazgos:
|
||||||
|
- `Sidebar` y `TabsBar` definen cada uno su lista de links + `isActive`.
|
||||||
|
- `MobileHeader` compensa los links que faltan en `TabsBar` con items extra en el menú.
|
||||||
|
- Logo: gradiente + texto en MobileHeader y login; solo texto en Sidebar.
|
||||||
|
- `PlayerBar` repite dos veces la cadena de "barra de cristal" (igual que `TabsBar`).
|
||||||
|
- `PlayerExtras` (22 líneas) solo compone volumen + botón de cola.
|
||||||
|
- `SeekBar` es un `<button>` de click (sin teclado ni arrastre) con `h-[3.5px]`;
|
||||||
|
`VolumeControl` es un `<input range>` con `<style>` propio (`84px`, `11px`, `3.5px`).
|
||||||
|
- `TrackInfo` duplica el bloque identidad de pista (→ `MediaIdentity`, P7).
|
||||||
|
|
||||||
|
Tareas:
|
||||||
|
1. `appNavLinks()` (B.2) en Sidebar y TabsBar (`primary` = los 4 de TabsBar).
|
||||||
|
2. `Logo.svelte`; usar en Sidebar, MobileHeader y login (Δ: Sidebar gana el cuadrado
|
||||||
|
de gradiente — **confirmar**).
|
||||||
|
3. `glass-bar` en PlayerBar ×2 y TabsBar; `theme-transition` en Sidebar, Queue, etc.
|
||||||
|
4. Fusionar `PlayerExtras` dentro de `PlayerBar`; anchos laterales `--mf-player-side-w`.
|
||||||
|
5. `Slider.svelte` (B.3) con `<input type="range">` estilado vía tokens
|
||||||
|
`--mf-range-h/--mf-range-thumb/--mf-volume-w`; sustituye `SeekBar` y `VolumeControl`
|
||||||
|
(mejora: arrastre y teclado en la barra de progreso). Borrar ambos.
|
||||||
|
6. `TopBar`: usar `searchHref`, `IconButton`, `Avatar`; `max-w-search`.
|
||||||
|
7. z-index de header/dock/menús con B.1.5.
|
||||||
|
|
||||||
|
Cierre: navegación desktop/tablet/móvil, cola abre/cierra, seek por click, arrastre y
|
||||||
|
flechas, volumen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### P13 — Páginas grandes: Explorar (602 líneas) e Inicio (466)
|
||||||
|
|
||||||
|
**Objetivo:** que las páginas sean composición, no lógica ni marcado repetido. Hacer
|
||||||
|
**después** de P7–P10 (usa `ListRow`, `SectionHeading count`, `PageHeader`).
|
||||||
|
|
||||||
|
Explorar:
|
||||||
|
1. `GENRE_INFO` + `genreTiles` → `src/lib/genres.ts`.
|
||||||
|
2. Lógica de búsqueda (`topResult`, `searchCounts`, `searchChips`, `capped`,
|
||||||
|
`showGroup`, `playlistMatches`) → `src/lib/search.ts` (funciones puras).
|
||||||
|
3. Los 4 bloques de grupo (Canciones/Álbumes/Usuarios/Playlists) → un `{#each groups}`
|
||||||
|
con `SectionHeading count` + `ListRow`.
|
||||||
|
4. "Mejor resultado": un solo marcado con `Artwork`/`Avatar` según tipo + `PlayButton
|
||||||
|
as="span"`; eliminar el snippet duplicado button/a usando `ListRow`-like o
|
||||||
|
`<svelte:element this={href ? 'a' : 'button'}>`.
|
||||||
|
5. Paginación `loadMore` → reutilizable si otra página la necesita; si no, dejar.
|
||||||
|
|
||||||
|
Inicio:
|
||||||
|
1. Usar `Page` en vez de `page-x pt-3 sm:pt-4 …` a mano; `gap-11.5` → token de sección
|
||||||
|
(`--spacing-section: 2.875rem`, `gap-section`).
|
||||||
|
2. Stats ×3 idénticos → `{#each stats}`.
|
||||||
|
3. Hero/spotlight: `style=` de fondo → `@utility hero-surface` / `spotlight-surface`.
|
||||||
|
4. Filas → `ListRow` (si P7 no lo hizo ya).
|
||||||
|
|
||||||
|
Cierre: Explorar sin query, con query, filtros, sin resultados, scroll infinito; Inicio
|
||||||
|
completo con y sin datos.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### P14 — Barrido final y guardarraíles
|
||||||
|
|
||||||
|
**Objetivo:** que no vuelva a aparecer lo que se ha limpiado.
|
||||||
|
|
||||||
|
Tareas:
|
||||||
|
1. Script `web-player/scripts/check-tokens.mjs` + `npm run lint:tokens` (añadir a
|
||||||
|
`lint`). Falla si encuentra en `src/**/*.svelte`:
|
||||||
|
- `-\[` arbitrario fuera de la lista blanca B.4;
|
||||||
|
- `rounded-(sm|md|lg|xl|2xl|3xl)\b` o `rounded\b` a secas;
|
||||||
|
- `\b[hw]-\d` con su pareja en el mismo elemento (debe ser `size-*`);
|
||||||
|
- espaciado fraccionario `-\d+\.\d+` en `h-`/`w-`/`size-` (tamaños → tokens);
|
||||||
|
- `#[0-9a-f]{3,8}` en clases, `rgba(`, `strokeWidth=`, `text-base` sin receta;
|
||||||
|
- `style="` que no sea de la lista blanca.
|
||||||
|
2. Espaciados fraccionarios restantes (`gap-2.75`, `py-1.75`, `mt-3.25`, `p-2.25`…):
|
||||||
|
redondear al paso de 0.5 más cercano **o** a la escala entera cuando el Δ ≤ 1px.
|
||||||
|
Tabla guía: `.25/.75` → `.5` más cercano; `mt-0.75`→`mt-1`; `gap-1.75`→`gap-2`;
|
||||||
|
`p-4.25`→`p-4`; `mb-4.5` (SectionHeading) se mantiene (Δ 2px visible en todas las
|
||||||
|
secciones).
|
||||||
|
3. Detector de huérfanos (repetir y borrar lo que salga):
|
||||||
|
```bash
|
||||||
|
for f in $(find src/lib/components -name "*.svelte"); do n=$(basename "$f" .svelte); c=$(grep -rlw "$n" --include=*.svelte --include=*.ts src | grep -v "/$n.svelte$" | wc -l); [ "$c" -eq 0 ] && echo "$f"; done
|
||||||
|
```
|
||||||
|
4. Actualizar `web-player/CLAUDE.md` → sección "Estilos" con: B.4, "usa `size-cover-*`,
|
||||||
|
`size-icon-*`, recetas `text-display-*`/`text-eyebrow`/`text-body`/`text-count`,
|
||||||
|
radios semánticos; componentes: `Artwork`, `Avatar`, `ListRow`, `TrackList`,
|
||||||
|
`MediaCard`, `PageHeader`, `ContextMenu`…". Borrar `docs/design-system-refactor-plan.md`.
|
||||||
|
5. Recalcular la tabla A.4 y anotarla en D.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## D. Registro
|
||||||
|
|
||||||
|
| Parte | Estado | Commit | Notas / decisiones tomadas |
|
||||||
|
| ----- | ------ | ------ | -------------------------- |
|
||||||
|
| P0 | ⬜ | | |
|
||||||
|
| P1 | ⬜ | | |
|
||||||
|
| P2 | ⬜ | | |
|
||||||
|
| P3 | ⬜ | | |
|
||||||
|
| P4 | ⬜ | | |
|
||||||
|
| P5 | ⬜ | | |
|
||||||
|
| P6 | ⬜ | | |
|
||||||
|
| P7 | ⬜ | | |
|
||||||
|
| P8 | ⬜ | | |
|
||||||
|
| P9 | ⬜ | | |
|
||||||
|
| P10 | ⬜ | | |
|
||||||
|
| P11 | ⬜ | | |
|
||||||
|
| P12 | ⬜ | | |
|
||||||
|
| P13 | ⬜ | | |
|
||||||
|
| P14 | ⬜ | | |
|
||||||
|
|
||||||
|
Decisiones de producto pendientes (preguntar al usuario antes de la parte indicada):
|
||||||
|
- P0/P4: ¿`Button` primary debe verse distinto de secondary?
|
||||||
|
- P3: ¿quitar el nombre bajo el avatar de perfil?
|
||||||
|
- P4: ¿eliminar el botón "adelante" decorativo y el icono `AlignJustify` del player?
|
||||||
|
- P5: pluralizaciones en minúscula en toda la app.
|
||||||
|
- P8: ¿eliminar la columna "Álbum" que siempre muestra "—"?
|
||||||
|
- P10: ¿iconos en los botones de cabecera de álbum **y** playlist?
|
||||||
|
- P12: ¿logo con cuadrado de gradiente también en la Sidebar?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## E. Inventario completo (veredicto por archivo)
|
||||||
|
|
||||||
|
Leyenda: **=** se mantiene · **~** se modifica · **→X** se fusiona en X · **✕** se borra.
|
||||||
|
|
||||||
|
### `src/lib/components/ui/`
|
||||||
|
|
||||||
|
| Archivo | Líneas | Veredicto | Parte | Motivo |
|
||||||
|
| -------------------- | ------ | --------- | ----- | ------ |
|
||||||
|
| AccountMenu | 73 | ~ | P6 | `clickOutside`, Escape, ancho por prop |
|
||||||
|
| AlbumCard | 62 | ✕ →MediaCard | P9 | wrapper de meta + hack `trackId=albumId` |
|
||||||
|
| AlbumForm | 123 | ~ (CoverForm)| P11 | esqueleto compartido |
|
||||||
|
| Alert | 20 | = | | |
|
||||||
|
| ArtistAvatar | 36 | ✕ →Avatar | P3 | |
|
||||||
|
| BackLink | 33 | ~ | P4 | `IconButton`/tokens; quitar `hover:text-accent-soft` duplicado |
|
||||||
|
| Badge | 23 | ✕ | P0 | 0 usos |
|
||||||
|
| Button | 66 | ~ | P0/P4 | variantes duplicadas, overrides `!` |
|
||||||
|
| Checkbox | 29 | ~ | P14 | `size-*`, tokens |
|
||||||
|
| CollectionHeader | 45 | ✕ →PageHeader| P10 | |
|
||||||
|
| Cover | 47 | ✕ →Artwork | P3 | |
|
||||||
|
| EmptyState | 31 | ~ | P10 | recetas |
|
||||||
|
| EqBars | 23 | ~ | P7 | absorbe `NowPlaying` |
|
||||||
|
| ExplicitBadge | 13 | ~ | P1/P14 | `rounded-tag`, `size-4` |
|
||||||
|
| Field | 27 | = | | |
|
||||||
|
| GlassMenu | 15 | = | | base de menús |
|
||||||
|
| IconButton | 39 | ~ | P4 | shape/size/tone/pressed/href |
|
||||||
|
| ImageDropzone | 62 | ~ | P11 | `size="hero"`; usado por upload |
|
||||||
|
| InfiniteScroll | 39 | = | | |
|
||||||
|
| Input | 59 | ~ | P11 | `field-control` |
|
||||||
|
| MediaCard | 32 | ~ | P0/P9 | bug 16px; absorbe MixTile |
|
||||||
|
| MediaGrid | 27 | ✕ →`grid-*` | P9 | |
|
||||||
|
| MenuItem | 48 | ~ | P6 | iconos `size-icon-md` |
|
||||||
|
| MixArt | 25 | ✕ →Artwork | P3 | |
|
||||||
|
| MixTile | 51 | ✕ →MediaCard | P9 | |
|
||||||
|
| Modal | 106 | ~ | P10 | `.glass-panel`, z-token, recetas |
|
||||||
|
| NowPlaying | 20 | ✕ →EqBars | P7 | |
|
||||||
|
| Page | 14 | = | | Inicio debe usarlo (P13) |
|
||||||
|
| PageHeader | 26 | ~ | P10 | absorbe CollectionHeader |
|
||||||
|
| PlaylistArt | 61 | ✕ →Artwork | P3 | |
|
||||||
|
| PlaylistCard | 23 | ✕ →MediaCard | P9 | |
|
||||||
|
| PlaylistForm | 161 | ~ (CoverForm)| P11 | + SegmentedControl |
|
||||||
|
| SearchResultRow | 78 | ✕ →ListRow | P7 | |
|
||||||
|
| SectionHeading | 32 | ~ | P10 | `count`, "ver todas" |
|
||||||
|
| Surface | 17 | = | | (solo upload; aceptable) |
|
||||||
|
| Textarea | 28 | ~ | P11 | `field-control` |
|
||||||
|
| TrackIndexCell | 27 | ✕ →TrackList | P8 | |
|
||||||
|
| TrackMeta | 14 | ✕ →TrackList | P8 | |
|
||||||
|
| TrackRow | 27 | ✕ →TrackList | P8 | |
|
||||||
|
| TrackTable | 79 | ✕ →TrackList | P8 | |
|
||||||
|
| TrackTitleCell | 77 | → MediaIdentity | P7 | renombrar + generalizar |
|
||||||
|
| **Nuevos** | | Artwork, Avatar, ListRow, MediaIdentity, TrackList, ContextMenu, PlayButton, ConfirmDialog, Slider, Logo, Chip, SegmentedControl, CoverForm | | |
|
||||||
|
|
||||||
|
### `src/lib/components/` y `player/`
|
||||||
|
|
||||||
|
| Archivo | Líneas | Veredicto | Parte | Motivo |
|
||||||
|
| ------------------- | ------ | --------- | ----- | ------ |
|
||||||
|
| AlbumContextMenu | 119 | ✕ →ContextMenu | P6 | copia de TrackContextMenu |
|
||||||
|
| TrackContextMenu | 109 | ✕ →ContextMenu | P6 | |
|
||||||
|
| MobileHeader | 70 | ~ | P6/P12 | Avatar, Logo, sin wrapper duplicado |
|
||||||
|
| Sidebar | 125 | ~ | P7/P12 | `appNavLinks`, `ListRow` |
|
||||||
|
| TabsBar | 50 | ~ | P12 | `appNavLinks`, `glass-bar` |
|
||||||
|
| TopBar | 140 | ~ | P4/P12 | IconButton, Avatar, `searchHref` |
|
||||||
|
| player/PlayerBar | 28 | ~ | P12 | `glass-bar`, absorbe PlayerExtras |
|
||||||
|
| player/PlayerDock | 29 | ~ | P12 | z-token |
|
||||||
|
| player/PlayerExtras | 22 | ✕ →PlayerBar | P12 | |
|
||||||
|
| player/Queue | 111 | ~ | P7 | `ListRow`, recetas, `pressable` |
|
||||||
|
| player/SeekBar | 48 | ✕ →Slider | P12 | |
|
||||||
|
| player/TrackInfo | 65 | ~ | P7 | `MediaIdentity`, IconButton pressed |
|
||||||
|
| player/TransportControls | 75 | ~ | P4 | `PlayButton`, `IconButton pressed` |
|
||||||
|
| player/VolumeControl| 52 | ✕ →Slider | P12 | |
|
||||||
|
|
||||||
|
### Rutas
|
||||||
|
|
||||||
|
| Archivo | Líneas | Veredicto | Parte | Motivo |
|
||||||
|
| ------- | ------ | --------- | ----- | ------ |
|
||||||
|
| `+layout.svelte` | 142 | ~ | P2 | tokens SSR, fondos a utilidades, padding del dock |
|
||||||
|
| `+page.svelte` (Inicio) | 466 | ~ | P0/P5/P7/P13 | 4 filas a mano, stats ×3, vacío ×2, `style=` |
|
||||||
|
| `+error.svelte` | 16 | ~ | P10 | recetas, `max-w-prose-sm` |
|
||||||
|
| `login/+page.svelte` | 50 | ~ | P12 | `Logo`, `max-w-cta` |
|
||||||
|
| `(app)/explore/+page.svelte` | 602 | ~ | P13 | dividir datos/lógica; 4 grupos → each |
|
||||||
|
| `(app)/upload/+page.svelte` | 288 | ~ | P11 | `ImageDropzone`, recetas |
|
||||||
|
| `(app)/library/+page.svelte` | 108 | ~ | P8 | `TrackList` |
|
||||||
|
| `(app)/liked/+page.svelte` | 123 | ~ | P0/P5/P8/P10 | imports muertos, `playAllOrToggle`, `TrackList` |
|
||||||
|
| `(app)/mixes/[id]/+page.svelte` | 134 | ~ | P5/P8/P10 | menú, `TrackList`, `PageHeader` |
|
||||||
|
| `(app)/albums/+page.svelte` | 74 | ~ | P9 | `MediaCard` + `grid-cards` |
|
||||||
|
| `(app)/albums/[id]/+page.svelte` | 91 | ~ | P8/P10 | inline header/tablas, `ConfirmDialog` |
|
||||||
|
| `albums/[id]/components/AlbumHeader` | 82 | ✕ (inline) | P10 | |
|
||||||
|
| `albums/[id]/components/AlbumTrackTable` | 64 | ✕ (inline) | P8 | |
|
||||||
|
| `albums/[id]/components/LibraryPicker` | 65 | ✕ (inline) | P8 | |
|
||||||
|
| `(app)/playlists/+page.svelte` | 62 | ~ | P9 | |
|
||||||
|
| `(app)/playlists/[id]/+page.svelte` | 95 | ~ | P8/P10 | |
|
||||||
|
| `playlists/[id]/components/PlaylistHeader` | 62 | ✕ (inline) | P10 | |
|
||||||
|
| `playlists/[id]/components/PlaylistTrackTable` | 71 | ✕ (inline) | P8 | |
|
||||||
|
| `(app)/u/[id]/+page.svelte` | 97 | ~ | P3/P9/P10 | Avatar, grid |
|
||||||
|
| `routes/layout.css` | 238 | ~ | P1 | todos los tokens y recetas |
|
||||||
|
|
||||||
|
### `src/lib/` (cliente)
|
||||||
|
|
||||||
|
| Archivo | Veredicto | Parte | Motivo |
|
||||||
|
| ------- | --------- | ----- | ------ |
|
||||||
|
| `theme/theme.css` | ~ | P1/P2 | quitar duplicados de `tokens.ts`, añadir layout/z |
|
||||||
|
| `theme/tokens.ts` | ~ | P2 | `tokensToCss`, sin `--mf-sidebar-bg` |
|
||||||
|
| `theme/palette.ts`, `theme/color.ts`, `theme/mode.svelte.ts` | = | | (`mode` pierde el flash con P2) |
|
||||||
|
| `tracks.ts` | ~ | P5 | quitar envoltorio `TrackTarget` + 8 accesores |
|
||||||
|
| `recentlyPlayed.ts` | ~ | P5 | `toQueueItem` |
|
||||||
|
| `player/player.svelte.ts` | ~ | P5 | helpers de cola, `toQueueItem`, renombrar `addToQueue` |
|
||||||
|
| `player/liked.svelte.ts` | ~ | P5 | `toQueueItem` |
|
||||||
|
| `player/queuePanel.svelte.ts`, `playlists.svelte.ts` | → `toggle.svelte.ts` | P5 | idénticos |
|
||||||
|
| `format.ts` | ~ | P5/P9 | `plural`, `albumMeta`, `playlistMeta` |
|
||||||
|
| `navigation.svelte.ts` | ~ | P5 | `searchHref`, `appNavLinks` |
|
||||||
|
| `config.ts` | ~ | P6 | `MENU_WIDTH`, `SUBMENU_WIDTH` |
|
||||||
|
| `mixes.ts`, `albums.ts`, `collections.ts`, `types.ts` | = | | |
|
||||||
|
| `lib/server/**`, `+page.server.ts`, `routes/api/**`, `auth/**`, `hooks.server.ts` | fuera de alcance | | no contienen UI ni estilos |
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<aside
|
<aside
|
||||||
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"
|
class="hidden shrink-0 flex-col border-r border-hairline bg-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="px-3 pb-3 font-display text-lg font-semibold tracking-tight text-fg">Musify</a>
|
<a href="/" class="px-3 pb-3 font-display text-lg font-semibold tracking-tight text-fg">Musify</a>
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
href={link.href}
|
href={link.href}
|
||||||
aria-current={active ? 'page' : undefined}
|
aria-current={active ? 'page' : undefined}
|
||||||
class="flex items-center gap-3 rounded-control px-3 py-2 text-sm 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-2 text-fg'
|
||||||
: 'text-fg-2 hover:bg-hover hover:text-fg'}"
|
: 'text-fg-2 hover:bg-hover hover:text-fg'}"
|
||||||
>
|
>
|
||||||
<link.icon class="h-4.5 w-4.5 shrink-0" strokeWidth={1.5} />
|
<link.icon class="h-4.5 w-4.5 shrink-0" strokeWidth={1.5} />
|
||||||
@@ -105,7 +105,7 @@
|
|||||||
class="h-8.5 w-8.5 shrink-0 overflow-hidden rounded-lg opacity-90"
|
class="h-8.5 w-8.5 shrink-0 overflow-hidden rounded-lg opacity-90"
|
||||||
/>
|
/>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="truncate text-sm font-medium {active ? 'text-fg' : 'text-fg-2'}">
|
<div class="truncate text-sm {active ? 'text-fg' : 'text-fg-2'}">
|
||||||
{playlist.name}
|
{playlist.name}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<nav
|
<nav
|
||||||
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}"
|
class="pointer-events-auto flex items-center justify-around border-t border-hairline bg-[image:var(--mf-bar-bg)] px-4 py-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)}
|
||||||
|
|||||||
@@ -25,7 +25,5 @@
|
|||||||
{#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>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
{#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-500 sm:flex"
|
class="hidden shrink-0 flex-col border-l border-hairline bg-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">
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if player.current.id}
|
{#if player.current.id}
|
||||||
<p class="mb-2.75 text-xs font-medium tracking-widest text-muted uppercase">Reproduciendo</p>
|
<p class="mb-2.75 text-xs font-medium tracking-widest text-fg-3 uppercase">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}
|
||||||
@@ -56,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-medium tracking-widest text-muted uppercase"
|
class="min-w-0 flex-1 truncate text-xs font-medium tracking-widest text-fg-3 uppercase"
|
||||||
>
|
>
|
||||||
A continuación
|
A continuación
|
||||||
</span>
|
</span>
|
||||||
@@ -64,7 +64,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => player.clearUpcoming()}
|
onclick={() => player.clearUpcoming()}
|
||||||
class="shrink-0 text-xs text-muted transition-colors hover:text-fg"
|
class="shrink-0 text-xs text-fg-3 transition-colors hover:text-fg"
|
||||||
>
|
>
|
||||||
Vaciar
|
Vaciar
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -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-0.875 w-full overflow-hidden rounded-full bg-track">
|
<div class="h-[3.5px] 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>
|
||||||
|
|||||||
@@ -21,7 +21,8 @@
|
|||||||
title: player.current.title,
|
title: player.current.title,
|
||||||
artist: player.current.artist,
|
artist: player.current.artist,
|
||||||
explicit: player.current.explicit,
|
explicit: player.current.explicit,
|
||||||
ownerUserId: player.current.ownerUserId
|
ownerUserId: player.current.ownerUserId,
|
||||||
|
duration: player.current.duration
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -42,9 +43,7 @@
|
|||||||
{#if player.current.explicit}
|
{#if player.current.explicit}
|
||||||
<ExplicitBadge />
|
<ExplicitBadge />
|
||||||
{/if}
|
{/if}
|
||||||
<span class="truncate text-sm font-semibold tracking-[-0.01em] text-fg"
|
<span class="truncate text-sm text-fg">{player.current.title}</span>
|
||||||
>{player.current.title}</span
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
<ArtistLink
|
<ArtistLink
|
||||||
name={player.current.artist || '—'}
|
name={player.current.artist || '—'}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
style="width:{size}px;height:{size}px"
|
style="width:{size}px;height:{size}px"
|
||||||
>
|
>
|
||||||
{#if imageUrl}
|
{#if imageUrl}
|
||||||
<img src={imageUrl} alt="" class="h-full w-full object-cover" />
|
<img src={imageUrl} alt="" class="absolute inset-0 h-full w-full object-cover" />
|
||||||
{:else}
|
{:else}
|
||||||
<div
|
<div
|
||||||
class="pointer-events-none absolute inset-0"
|
class="pointer-events-none absolute inset-0"
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<a
|
<a
|
||||||
href="/u/{ownerUserId}"
|
href="/u/{ownerUserId}"
|
||||||
onclick={(e) => e.stopPropagation()}
|
onclick={(e) => e.stopPropagation()}
|
||||||
class="inline-block max-w-full truncate align-top hover:text-fg-2 hover:underline {klass}"
|
class="block max-w-full truncate hover:text-fg-2 hover:underline {klass}"
|
||||||
>
|
>
|
||||||
{name}
|
{name}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
|
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
variant?: 'primary' | 'secondary' | 'accent' | 'danger';
|
variant?: 'primary' | 'secondary' | 'secondaryOnGlow' | 'accent' | 'danger';
|
||||||
size?: 'sm' | 'md' | 'lg';
|
size?: 'sm' | 'md' | 'lg';
|
||||||
type?: 'button' | 'submit';
|
type?: 'button' | 'submit';
|
||||||
href?: string;
|
href?: string;
|
||||||
@@ -34,11 +34,15 @@
|
|||||||
lg: 'px-6 py-3.5 text-sm'
|
lg: 'px-6 py-3.5 text-sm'
|
||||||
};
|
};
|
||||||
|
|
||||||
const subtleButton = 'bg-surface-2 text-fg-2 hover:bg-surface-hover hover:text-fg';
|
const subtleButton =
|
||||||
|
'bg-[var(--mf-btn-bg)] text-fg-2 hover:bg-[var(--mf-btn-bg-hover)] hover:text-fg';
|
||||||
|
const subtleButtonOnGlow =
|
||||||
|
'bg-[var(--mf-btn-secondary-bg)] text-fg-2 hover:bg-[var(--mf-btn-secondary-bg-hover)] hover:text-fg';
|
||||||
|
|
||||||
const variants = {
|
const variants = {
|
||||||
primary: subtleButton,
|
primary: subtleButton,
|
||||||
secondary: subtleButton,
|
secondary: subtleButton,
|
||||||
|
secondaryOnGlow: subtleButtonOnGlow,
|
||||||
accent: 'bg-accent-btn text-accent-soft hover:bg-accent-btn-hover',
|
accent: 'bg-accent-btn text-accent-soft hover:bg-accent-btn-hover',
|
||||||
danger: 'bg-danger text-on-accent hover:brightness-110'
|
danger: 'bg-danger text-on-accent hover:brightness-110'
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,11 +21,11 @@
|
|||||||
>
|
>
|
||||||
{@render cover()}
|
{@render cover()}
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<p class="text-xs font-semibold tracking-[0.16em] text-fg-2 uppercase">{eyebrow}</p>
|
<p class="text-xs font-medium tracking-widest text-fg-2 uppercase">{eyebrow}</p>
|
||||||
<h1
|
<h1
|
||||||
class="{align === 'end'
|
class="{align === 'end'
|
||||||
? 'mt-1.5'
|
? 'mt-1.5'
|
||||||
: 'mt-3'} font-display text-3xl font-semibold tracking-[-0.035em] break-words text-fg sm:text-4xl"
|
: 'mt-3'} font-display text-3xl font-medium tracking-tight wrap-break-word text-fg sm:text-4xl"
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
</h1>
|
</h1>
|
||||||
|
|||||||
@@ -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 border border-hairline p-5 backdrop-blur-xl transition-[background] duration-500 outline-none sm:p-6 {panelClass}"
|
class="animate-pop max-h-[85dvh] w-full overflow-y-auto {maxWidth} rounded-panel 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,11 +87,11 @@
|
|||||||
<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-widest text-muted uppercase">
|
<p class="mb-2.5 text-xs font-medium tracking-widest text-muted uppercase">
|
||||||
{eyebrow}
|
{eyebrow}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
<h2 class="font-display text-xl font-semibold tracking-[-0.02em] text-fg">
|
<h2 class="font-display text-xl font-medium tracking-[-0.02em] text-fg">
|
||||||
{title}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between {klass}">
|
<div class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between {klass}">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<h1 class="font-display text-3xl font-semibold tracking-[-0.03em] text-fg sm:text-4xl">
|
<h1 class="font-display text-3xl font-medium tracking-tight text-fg sm:text-4xl">
|
||||||
{title}
|
{title}
|
||||||
</h1>
|
</h1>
|
||||||
{#if subtitle}
|
{#if subtitle}
|
||||||
|
|||||||
@@ -51,5 +51,11 @@
|
|||||||
{:else if single !== null}
|
{:else if single !== null}
|
||||||
<Cover trackId={single} size="medium" class={klass} />
|
<Cover trackId={single} size="medium" class={klass} />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="bg-surface {klass}"></div>
|
<div class="flex items-center justify-center bg-surface {klass}">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" class="h-2/5 w-2/5 text-fg-3">
|
||||||
|
<path
|
||||||
|
d="M19.952 1.651a.75.75 0 0 1 .298.599V16.303a3 3 0 0 1-2.176 2.884l-1.32.377a2.553 2.553 0 1 1-1.401-4.909l2.311-.66a1.5 1.5 0 0 0 1.088-1.442V6.994l-9 2.572v9.737a3 3 0 0 1-2.176 2.884l-1.32.377a2.553 2.553 0 1 1-1.402-4.909l2.312-.66a1.5 1.5 0 0 0 1.088-1.442V5.25a.75.75 0 0 1 .544-.721l10.5-3a.75.75 0 0 1 .754.122Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -74,7 +74,7 @@
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<div class="flex flex-1 flex-col gap-4">
|
<div class="flex flex-1 flex-col gap-2">
|
||||||
<Field label="Nombre" for="playlist-name">
|
<Field label="Nombre" for="playlist-name">
|
||||||
{#snippet hint()}
|
{#snippet hint()}
|
||||||
<span class="ml-auto tabular-nums">{name.length}/{NAME_MAX_LENGTH}</span>
|
<span class="ml-auto tabular-nums">{name.length}/{NAME_MAX_LENGTH}</span>
|
||||||
@@ -121,7 +121,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => (visibility = 'private')}
|
onclick={() => (visibility = 'private')}
|
||||||
class="relative z-10 w-20 rounded-full py-1.5 text-center text-xs font-semibold transition-colors {visibility ===
|
class="relative z-10 w-20 rounded-full py-1.5 text-center text-xs font-medium transition-colors {visibility ===
|
||||||
'private'
|
'private'
|
||||||
? 'text-ink'
|
? 'text-ink'
|
||||||
: 'text-muted hover:text-fg-2'}"
|
: 'text-muted hover:text-fg-2'}"
|
||||||
@@ -131,7 +131,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => (visibility = 'public')}
|
onclick={() => (visibility = 'public')}
|
||||||
class="relative z-10 w-20 rounded-full py-1.5 text-center text-xs font-semibold transition-colors {visibility ===
|
class="relative z-10 w-20 rounded-full py-1.5 text-center text-xs font-medium transition-colors {visibility ===
|
||||||
'public'
|
'public'
|
||||||
? 'text-ink'
|
? 'text-ink'
|
||||||
: 'text-muted hover:text-fg-2'}"
|
: 'text-muted hover:text-fg-2'}"
|
||||||
|
|||||||
@@ -62,7 +62,7 @@
|
|||||||
>
|
>
|
||||||
{@render overlay?.()}
|
{@render overlay?.()}
|
||||||
</Cover>
|
</Cover>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0">
|
||||||
<div class="flex min-w-0 items-center gap-2">
|
<div class="flex min-w-0 items-center gap-2">
|
||||||
{#if explicit}
|
{#if explicit}
|
||||||
<ExplicitBadge />
|
<ExplicitBadge />
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export interface LikedTrack {
|
|||||||
explicit?: boolean;
|
explicit?: boolean;
|
||||||
ownerUserId?: string | number | null;
|
ownerUserId?: string | number | null;
|
||||||
listensCount?: number | string;
|
listensCount?: number | string;
|
||||||
|
duration?: number | string;
|
||||||
likedAt: number;
|
likedAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ type LikeToggleInput = {
|
|||||||
artist?: string;
|
artist?: string;
|
||||||
explicit?: boolean;
|
explicit?: boolean;
|
||||||
ownerUserId?: string | number | null;
|
ownerUserId?: string | number | null;
|
||||||
|
duration?: number | string;
|
||||||
};
|
};
|
||||||
|
|
||||||
class LikedStore {
|
class LikedStore {
|
||||||
@@ -25,7 +27,7 @@ class LikedStore {
|
|||||||
count = $derived(Object.keys(this.entries).length);
|
count = $derived(Object.keys(this.entries).length);
|
||||||
list = $derived(Object.values(this.entries).sort((a, b) => b.likedAt - a.likedAt));
|
list = $derived(Object.values(this.entries).sort((a, b) => b.likedAt - a.likedAt));
|
||||||
|
|
||||||
hydrate(tracks: (ApiTrackLike & { createdAt: string })[]) {
|
hydrate(tracks: (ApiTrackLike & { createdAt: string; duration: number | string })[]) {
|
||||||
if (this.hydrated) return;
|
if (this.hydrated) return;
|
||||||
this.hydrated = true;
|
this.hydrated = true;
|
||||||
|
|
||||||
@@ -39,6 +41,7 @@ class LikedStore {
|
|||||||
explicit: item.explicit,
|
explicit: item.explicit,
|
||||||
ownerUserId: item.ownerUserId,
|
ownerUserId: item.ownerUserId,
|
||||||
listensCount: item.listensCount,
|
listensCount: item.listensCount,
|
||||||
|
duration: tracks[i].duration,
|
||||||
likedAt: new Date(tracks[i].createdAt).getTime()
|
likedAt: new Date(tracks[i].createdAt).getTime()
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -62,6 +65,7 @@ class LikedStore {
|
|||||||
artist: track.artist,
|
artist: track.artist,
|
||||||
explicit: track.explicit,
|
explicit: track.explicit,
|
||||||
ownerUserId: track.ownerUserId,
|
ownerUserId: track.ownerUserId,
|
||||||
|
duration: track.duration,
|
||||||
likedAt: Date.now()
|
likedAt: Date.now()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,9 +36,9 @@
|
|||||||
--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-l: 0.34;
|
--mf-tile-l: 0.34;
|
||||||
--mf-tile-c: 0.13;
|
--mf-tile-c: 0.1;
|
||||||
--mf-tile-shade-l: 0.14;
|
--mf-tile-shade-l: 0.14;
|
||||||
--mf-tile-shade-c: 0.05;
|
--mf-tile-shade-c: 0.035;
|
||||||
|
|
||||||
--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);
|
||||||
@@ -60,6 +60,10 @@
|
|||||||
--mf-surface: rgba(255, 255, 255, 0.022);
|
--mf-surface: rgba(255, 255, 255, 0.022);
|
||||||
--mf-surface-2: rgba(255, 255, 255, 0.045);
|
--mf-surface-2: rgba(255, 255, 255, 0.045);
|
||||||
--mf-surface-hover: rgba(255, 255, 255, 0.06);
|
--mf-surface-hover: rgba(255, 255, 255, 0.06);
|
||||||
|
--mf-btn-bg: var(--mf-surface-2);
|
||||||
|
--mf-btn-bg-hover: var(--mf-surface-hover);
|
||||||
|
--mf-btn-secondary-bg: rgba(255, 255, 255, 0.045);
|
||||||
|
--mf-btn-secondary-bg-hover: rgba(255, 255, 255, 0.07);
|
||||||
|
|
||||||
--mf-border: rgba(255, 255, 255, 0.04);
|
--mf-border: rgba(255, 255, 255, 0.04);
|
||||||
--mf-border-strong: rgba(255, 255, 255, 0.09);
|
--mf-border-strong: rgba(255, 255, 255, 0.09);
|
||||||
@@ -106,7 +110,7 @@
|
|||||||
:root[data-theme='light'] {
|
:root[data-theme='light'] {
|
||||||
--mf-bg: oklch(0.97 0.006 90);
|
--mf-bg: oklch(0.97 0.006 90);
|
||||||
--mf-elevated: oklch(0.99 0.003 90);
|
--mf-elevated: oklch(0.99 0.003 90);
|
||||||
--mf-sidebar-bg: linear-gradient(180deg, oklch(0.985 0.004 90), oklch(0.965 0.006 90));
|
--mf-sidebar-bg: oklch(0.97 0.006 90); /*linear-gradient(180deg, oklch(0.985 0.004 90), oklch(0.965 0.006 90));*/
|
||||||
--mf-panel-bg: oklch(0.97 0.006 90 / 0.7);
|
--mf-panel-bg: oklch(0.97 0.006 90 / 0.7);
|
||||||
--mf-bar-bg: oklch(0.97 0.006 90 / 0.9);
|
--mf-bar-bg: oklch(0.97 0.006 90 / 0.9);
|
||||||
--mf-hairline: rgba(0, 0, 0, 0.08);
|
--mf-hairline: rgba(0, 0, 0, 0.08);
|
||||||
@@ -118,7 +122,11 @@
|
|||||||
|
|
||||||
--mf-surface: rgba(0, 0, 0, 0.035);
|
--mf-surface: rgba(0, 0, 0, 0.035);
|
||||||
--mf-surface-2: rgba(0, 0, 0, 0.06);
|
--mf-surface-2: rgba(0, 0, 0, 0.06);
|
||||||
--mf-surface-hover: rgba(0, 0, 0, 0.08);
|
--mf-surface-hover: #e8eceb;
|
||||||
|
--mf-btn-bg: var(--mf-surface-2);
|
||||||
|
--mf-btn-bg-hover: var(--mf-surface-hover);
|
||||||
|
--mf-btn-secondary-bg: rgba(0, 0, 0, 0.09);
|
||||||
|
--mf-btn-secondary-bg-hover: rgba(0, 0, 0, 0.14);
|
||||||
|
|
||||||
--mf-border: rgba(0, 0, 0, 0.08);
|
--mf-border: rgba(0, 0, 0, 0.08);
|
||||||
--mf-border-strong: rgba(0, 0, 0, 0.14);
|
--mf-border-strong: rgba(0, 0, 0, 0.14);
|
||||||
@@ -129,31 +137,23 @@
|
|||||||
--mf-cta-strong-bg: #17171a;
|
--mf-cta-strong-bg: #17171a;
|
||||||
|
|
||||||
--mf-tile-l: 0.58;
|
--mf-tile-l: 0.58;
|
||||||
--mf-tile-c: 0.15;
|
--mf-tile-c: 0.085;
|
||||||
--mf-tile-shade-l: 0.42;
|
--mf-tile-shade-l: 0.42;
|
||||||
--mf-tile-shade-c: 0.12;
|
--mf-tile-shade-c: 0.05;
|
||||||
--mf-liked-grad: linear-gradient(150deg, oklch(0.62 0.12 330), oklch(0.46 0.08 10));
|
--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-ambient: none;
|
||||||
--mf-hero-bg: linear-gradient(
|
--mf-hero-bg: linear-gradient(oklch(0.789 0.0926 145 / 0.16), oklch(0.789 0.0926 145 / 0.16));
|
||||||
105deg,
|
|
||||||
oklch(0.7531 0.1172 145 / 0.34) 0%,
|
|
||||||
oklch(0.8303 0.0641 145 / 0.11) 37%,
|
|
||||||
oklch(0.88 0.03 145 / 0.03) 65%,
|
|
||||||
rgba(255, 255, 255, 0) 92%
|
|
||||||
);
|
|
||||||
--mf-spotlight-bg: linear-gradient(
|
--mf-spotlight-bg: linear-gradient(
|
||||||
100deg,
|
oklch(0.789 0.0926 145 / 0.16),
|
||||||
oklch(0.7641 0.1097 145 / 0.28) 0%,
|
oklch(0.789 0.0926 145 / 0.16)
|
||||||
oklch(0.8414 0.0566 145 / 0.1) 40%,
|
|
||||||
oklch(0.88 0.03 145 / 0.03) 69%,
|
|
||||||
rgba(255, 255, 255, 0) 96%
|
|
||||||
);
|
);
|
||||||
--mf-modal-glow: linear-gradient(
|
--mf-modal-glow: linear-gradient(
|
||||||
135deg,
|
135deg,
|
||||||
oklch(0.7531 0.1172 145 / 0.34) 0%,
|
oklch(0.7531 0.1172 145 / 0.34) 0%,
|
||||||
oklch(0.8303 0.0641 145 / 0.11) 28%,
|
oklch(0.789 0.0926 145 / 0.2) 30%,
|
||||||
oklch(0.88 0.03 145 / 0.03) 50%,
|
oklch(0.8303 0.0641 145 / 0.1) 55%,
|
||||||
rgba(255, 255, 255, 0) 70%
|
oklch(0.8717 0.0357 145 / 0.04) 80%,
|
||||||
|
oklch(0.88 0.03 145 / 0.02) 100%
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,16 +74,16 @@ 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(46% 34% at 78% 0%, ${tcLight(0.2, 40, h)}, transparent 56%)`
|
? 'none'
|
||||||
: `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(${tcLight(0.16, 55, h)}, ${tcLight(0.16, 55, h)})`
|
||||||
: `linear-gradient(105deg, ${tc(0.34, 42, h)} 0%, ${tc(0.11, 70, h)} 37%, ${tc(0.03, 88, h)} 65%, rgba(6,6,9,0) 92%)`,
|
: `linear-gradient(105deg, ${tc(0.34, 42, h)} 0%, ${tc(0.11, 70, h)} 37%, ${tc(0.03, 88, h)} 65%, rgba(6,6,9,0) 92%)`,
|
||||||
'--mf-modal-glow': light
|
'--mf-modal-glow': light
|
||||||
? `linear-gradient(135deg, ${tcLight(0.34, 42, h)} 0%, ${tcLight(0.11, 70, h)} 28%, ${tcLight(0.03, 88, h)} 50%, rgba(255,255,255,0) 70%)`
|
? `linear-gradient(135deg, ${tcLight(0.34, 42, h)} 0%, ${tcLight(0.2, 55, h)} 30%, ${tcLight(0.1, 70, h)} 55%, ${tcLight(0.04, 85, h)} 80%, ${tcLight(0.02, 88, h)} 100%)`
|
||||||
: `linear-gradient(135deg, ${tc(0.34, 42, h)} 0%, ${tc(0.11, 70, h)} 28%, ${tc(0.03, 88, h)} 50%, rgba(6,6,9,0) 70%)`,
|
: `linear-gradient(135deg, ${tc(0.34, 42, h)} 0%, ${tc(0.11, 70, h)} 28%, ${tc(0.03, 88, h)} 50%, rgba(6,6,9,0) 70%)`,
|
||||||
'--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(${tcLight(0.16, 55, h)}, ${tcLight(0.16, 55, h)})`
|
||||||
: `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': light
|
'--mf-logo-grad': light
|
||||||
? `linear-gradient(140deg, oklch(0.68 0.16 ${H}), oklch(0.5 0.14 ${H2}) 92%)`
|
? `linear-gradient(140deg, oklch(0.68 0.16 ${H}), oklch(0.5 0.14 ${H2}) 92%)`
|
||||||
|
|||||||
@@ -412,11 +412,11 @@
|
|||||||
{#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 flex h-28 flex-col gap-1 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-110"
|
||||||
style="animation-delay:{Math.min(i, 10) *
|
style="animation-delay:{Math.min(i, 10) *
|
||||||
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}))"
|
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-medium tracking-tight text-on-art">
|
<div class="text-on-art">
|
||||||
{genre.label}
|
{genre.label}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-on-art-2">{genre.tagline}</div>
|
<div class="text-xs text-on-art-2">{genre.tagline}</div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
import X from '@lucide/svelte/icons/x';
|
import X from '@lucide/svelte/icons/x';
|
||||||
import { player, type QueueItem } from '$lib/player/player.svelte';
|
import { player, type QueueItem } from '$lib/player/player.svelte';
|
||||||
import { liked } from '$lib/player/liked.svelte';
|
import { liked } from '$lib/player/liked.svelte';
|
||||||
import { fmtDate } from '$lib/format';
|
import { fmtDate, fmtTime } from '$lib/format';
|
||||||
import Page from '$lib/components/ui/Page.svelte';
|
import Page from '$lib/components/ui/Page.svelte';
|
||||||
import CollectionHeader from '$lib/components/ui/CollectionHeader.svelte';
|
import CollectionHeader from '$lib/components/ui/CollectionHeader.svelte';
|
||||||
import Button from '$lib/components/ui/Button.svelte';
|
import Button from '$lib/components/ui/Button.svelte';
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
<CollectionHeader
|
<CollectionHeader
|
||||||
eyebrow="Colección"
|
eyebrow="Colección"
|
||||||
title="Me gusta"
|
title="Me gusta"
|
||||||
meta="{tracks.length} {tracks.length === 1 ? 'canción' : 'canciones'}"
|
meta="{tracks.length} {tracks.length === 1 ? 'Canción' : 'Canciones'}"
|
||||||
align="center"
|
align="center"
|
||||||
>
|
>
|
||||||
{#snippet cover()}
|
{#snippet cover()}
|
||||||
@@ -60,13 +60,7 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet actions()}
|
{#snippet actions()}
|
||||||
<Button size="sm" onclick={playAll} disabled={tracks.length === 0}>
|
<Button size="sm" onclick={playAll} disabled={tracks.length === 0}>
|
||||||
{#if isCurrentQueue && player.playing}
|
{isCurrentQueue && player.playing ? 'Pausar' : 'Reproducir'}
|
||||||
<Pause class="h-4 w-4" strokeWidth={1.5} />
|
|
||||||
Pausar
|
|
||||||
{:else}
|
|
||||||
<Play class="h-4 w-4" strokeWidth={1.5} />
|
|
||||||
Reproducir
|
|
||||||
{/if}
|
|
||||||
</Button>
|
</Button>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</CollectionHeader>
|
</CollectionHeader>
|
||||||
@@ -103,7 +97,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 class="hidden sm:block">{Number(track.listensCount ?? 0)}</TrackMeta>
|
||||||
<TrackMeta>—</TrackMeta>
|
<TrackMeta>{fmtTime(Number(track.duration))}</TrackMeta>
|
||||||
<IconButton
|
<IconButton
|
||||||
label="Quitar de Me gusta"
|
label="Quitar de Me gusta"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
>
|
>
|
||||||
{#snippet actions()}
|
{#snippet actions()}
|
||||||
{#if items.length > 0}
|
{#if items.length > 0}
|
||||||
<Button onclick={() => createPlaylistModal.show()}>Crear lista</Button>
|
<Button onclick={() => createPlaylistModal.show()}>Crear nueva lista</Button>
|
||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
@@ -55,7 +55,7 @@
|
|||||||
description="Crea la primera y añádele canciones de tu biblioteca."
|
description="Crea la primera y añádele canciones de tu biblioteca."
|
||||||
>
|
>
|
||||||
{#snippet actions()}
|
{#snippet actions()}
|
||||||
<Button onclick={() => createPlaylistModal.show()}>Crear lista</Button>
|
<Button onclick={() => createPlaylistModal.show()}>Crear nueva lista</Button>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</EmptyState>
|
</EmptyState>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import Button from '$lib/components/ui/Button.svelte';
|
import Button from '$lib/components/ui/Button.svelte';
|
||||||
import ArtistAvatar from '$lib/components/ui/ArtistAvatar.svelte';
|
import ArtistAvatar from '$lib/components/ui/ArtistAvatar.svelte';
|
||||||
import CollectionHeader from '$lib/components/ui/CollectionHeader.svelte';
|
import CollectionHeader from '$lib/components/ui/CollectionHeader.svelte';
|
||||||
|
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
|
|
||||||
@@ -69,9 +70,7 @@
|
|||||||
</CollectionHeader>
|
</CollectionHeader>
|
||||||
|
|
||||||
<div class="mt-9">
|
<div class="mt-9">
|
||||||
<h2 class="font-display text-lg font-semibold tracking-[-0.02em] text-fg">
|
<SectionHeading title="Playlists públicas" subtitle="Creadas y compartidas por este usuario" />
|
||||||
Playlists públicas
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{#if playlists.length > 0}
|
{#if playlists.length > 0}
|
||||||
<MediaGrid min="210px" minMobile="180px" class="mt-6">
|
<MediaGrid min="210px" minMobile="180px" class="mt-6">
|
||||||
|
|||||||
@@ -112,7 +112,7 @@
|
|||||||
<Surface padding="sm" class={stepClass(stepStates[i])}>
|
<Surface padding="sm" class={stepClass(stepStates[i])}>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<span
|
<span
|
||||||
class="grid h-7 w-7 shrink-0 place-items-center rounded-full text-xs font-bold {stepStates[
|
class="grid h-7 w-7 shrink-0 place-items-center rounded-full text-sm {stepStates[
|
||||||
i
|
i
|
||||||
] === 'idle'
|
] === 'idle'
|
||||||
? 'bg-surface-2 text-fg-2'
|
? 'bg-surface-2 text-fg-2'
|
||||||
@@ -228,7 +228,7 @@
|
|||||||
{#if coverPreview !== ''}
|
{#if coverPreview !== ''}
|
||||||
<img src={coverPreview} alt="Portada" class="h-full w-full object-cover" />
|
<img src={coverPreview} alt="Portada" class="h-full w-full object-cover" />
|
||||||
{:else}
|
{:else}
|
||||||
<ImageIcon class="h-11 w-11 text-on-art/70" strokeWidth={1.3} />
|
<ImageIcon class="h-16 w-16 text-on-art/60" strokeWidth={1} />
|
||||||
{/if}
|
{/if}
|
||||||
</label>
|
</label>
|
||||||
</Field>
|
</Field>
|
||||||
|
|||||||
@@ -126,7 +126,7 @@
|
|||||||
<Modal
|
<Modal
|
||||||
open={createPlaylistModal.open}
|
open={createPlaylistModal.open}
|
||||||
onClose={() => createPlaylistModal.close()}
|
onClose={() => createPlaylistModal.close()}
|
||||||
title="Crear playlist"
|
title="Crear nueva playlist"
|
||||||
>
|
>
|
||||||
<PlaylistForm
|
<PlaylistForm
|
||||||
action="/playlists?/create"
|
action="/playlists?/create"
|
||||||
|
|||||||
@@ -73,7 +73,7 @@
|
|||||||
spotlightTrackCount === 0
|
spotlightTrackCount === 0
|
||||||
? spotlightVisibilityLabel
|
? spotlightVisibilityLabel
|
||||||
: [
|
: [
|
||||||
`${spotlightTrackCount} ${spotlightTrackCount === 1 ? 'canción' : 'canciones'}`,
|
`${spotlightTrackCount} ${spotlightTrackCount === 1 ? 'Canción' : 'Canciones'}`,
|
||||||
fmtDurationLong(spotlightDurationSeconds),
|
fmtDurationLong(spotlightDurationSeconds),
|
||||||
spotlightVisibilityLabel
|
spotlightVisibilityLabel
|
||||||
].join(' · ')
|
].join(' · ')
|
||||||
@@ -159,7 +159,9 @@
|
|||||||
{spotlight.name}
|
{spotlight.name}
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
<Button href="/explore" variant="secondary" class="!font-normal">Explorar música</Button>
|
<Button href="/explore" variant="secondaryOnGlow" class="!font-normal"
|
||||||
|
>Explorar música</Button
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -244,7 +246,7 @@
|
|||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
class="flex w-fit items-center gap-6 self-start"
|
class="flex w-fit items-center gap-5 self-start sm:gap-6"
|
||||||
onclick={() => goto(`/playlists/${spotlight.id}`)}
|
onclick={() => goto(`/playlists/${spotlight.id}`)}
|
||||||
onkeydown={(e) => onRowKeydown(e, () => goto(`/playlists/${spotlight.id}`))}
|
onkeydown={(e) => onRowKeydown(e, () => goto(`/playlists/${spotlight.id}`))}
|
||||||
>
|
>
|
||||||
@@ -255,7 +257,7 @@
|
|||||||
size="large"
|
size="large"
|
||||||
class="h-34 w-34 shrink-0 rounded-2xl"
|
class="h-34 w-34 shrink-0 rounded-2xl"
|
||||||
/>
|
/>
|
||||||
<div class="flex min-w-0 flex-col gap-4">
|
<div class="flex min-w-0 flex-col gap-3">
|
||||||
<p class="text-xs font-medium tracking-widest text-fg-2 uppercase">
|
<p class="text-xs font-medium tracking-widest text-fg-2 uppercase">
|
||||||
Playlist destacada
|
Playlist destacada
|
||||||
</p>
|
</p>
|
||||||
@@ -270,7 +272,7 @@
|
|||||||
</p>
|
</p>
|
||||||
<div class="mt-5 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" onclick={shuffleSpotlight}>Aleatorio</Button>
|
<Button variant="secondaryOnGlow" onclick={shuffleSpotlight}>Aleatorio</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-0.5 p-5.5 sm:p-6">
|
<div class="flex flex-col gap-0.5 p-5.5 sm:p-6">
|
||||||
@@ -284,13 +286,15 @@
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
playSpotlightTrack(i);
|
playSpotlightTrack(i);
|
||||||
}}
|
}}
|
||||||
class="flex items-center gap-3 rounded-control p-2 text-left transition-colors hover:bg-hover {isTargetCurrent(
|
class="flex items-center gap-4 rounded-control p-2 text-left transition-colors hover:bg-hover {isTargetCurrent(
|
||||||
target
|
target
|
||||||
)
|
)
|
||||||
? 'bg-accent-tint'
|
? 'bg-accent-tint'
|
||||||
: ''}"
|
: ''}"
|
||||||
>
|
>
|
||||||
<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 {isTargetCurrent(target)
|
class="truncate text-sm {isTargetCurrent(target)
|
||||||
@@ -305,7 +309,9 @@
|
|||||||
class="mt-0.5 text-xs text-fg-3"
|
class="mt-0.5 text-xs text-fg-3"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span class="shrink-0 text-xs text-muted tabular-nums">{fmtTime(seconds)}</span>
|
<span class="shrink-0 text-xs text-muted tabular-nums"
|
||||||
|
>{fmtTime(seconds)}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="p-2 text-sm text-fg-3">Esta playlist todavía no tiene canciones.</p>
|
<p class="p-2 text-sm text-fg-3">Esta playlist todavía no tiene canciones.</p>
|
||||||
@@ -421,7 +427,7 @@
|
|||||||
class="h-15.5 w-15.5 shrink-0 rounded-control"
|
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-medium tracking-tight text-fg">
|
<div class="truncate text-sm tracking-tight text-fg">
|
||||||
{playlist.name}
|
{playlist.name}
|
||||||
</div>
|
</div>
|
||||||
{#if playlist.description}
|
{#if playlist.description}
|
||||||
|
|||||||
Reference in New Issue
Block a user