Design Prot Merge #1

Merged
NotS1mp merged 217 commits from design-prot into master 2026-09-21 15:48:16 +00:00
51 changed files with 2508 additions and 389 deletions
Showing only changes of commit 3f5e01f910 - Show all commits
@@ -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(
@@ -106,9 +100,9 @@ namespace Musify.Application.PlayLists
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);
} }
@@ -78,9 +78,9 @@ namespace Musify.Application.PlayLists
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);
@@ -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);
+38 -42
View File
@@ -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,
+1 -3
View File
@@ -44,9 +44,7 @@
"MediumPictureHeight": 256, "MediumPictureHeight": 256,
"LargePictureWidth": 512, "LargePictureWidth": 512,
"LargePictureHeight": 512 "LargePictureHeight": 512
}, }
"SeedPresetPictures": true,
"OverwritePresetPictures": false
}, },
"Track": { "Track": {
"Routes": { "Routes": {
-1
View File
@@ -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",
}); });
}); });
@@ -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);
@@ -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);
}
}
}
+913
View File
@@ -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 |
+3 -3
View File
@@ -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>
+1 -1
View File
@@ -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 />
+5 -1
View File
@@ -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()
}; };
} }
+22 -22
View File
@@ -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%
); );
} }
+4 -4
View File
@@ -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>
+4 -10
View File
@@ -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>
+1 -1
View File
@@ -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"
+15 -9
View File
@@ -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}