Compare commits
122
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e85af2b3b | ||
|
|
2d23eb87c6 | ||
|
|
e5ce63a192 | ||
|
|
b5c9658d65 | ||
|
|
ba0a2373b5 | ||
|
|
b0163f6a4d | ||
|
|
0a192645d6 | ||
|
|
351b333529 | ||
|
|
155f0e2b6a | ||
|
|
a54d1ef077 | ||
|
|
2361b88e0a | ||
|
|
e7c2fd5571 | ||
|
|
3126cb6dd6 | ||
|
|
03afcc8a90 | ||
|
|
f210fa4702 | ||
|
|
4b708aa987 | ||
|
|
bd07b1134e | ||
|
|
c041daf649 | ||
|
|
975fa7e4fd | ||
|
|
1a2ae37f49 | ||
|
|
1cad36b929 | ||
|
|
8cb9386447 | ||
|
|
5cda7cb4c1 | ||
|
|
e89463fb83 | ||
|
|
b011ccdd5b | ||
|
|
1df7f6ccf6 | ||
|
|
2915737f03 | ||
|
|
5b2a0e4d46 | ||
|
|
37fc229bf6 | ||
|
|
bc43b4b6eb | ||
|
|
dda8c30d45 | ||
|
|
906f38140a | ||
|
|
3e546d4a1e | ||
|
|
b2f450dcf7 | ||
|
|
87c3f97529 | ||
|
|
302dd1e0f8 | ||
|
|
421fc929e8 | ||
|
|
c4ab5dc388 | ||
|
|
920dcc6779 | ||
|
|
430c711ff2 | ||
|
|
cfdc61a4b8 | ||
|
|
0f28e19c1d | ||
|
|
7316e355ee | ||
|
|
b39e55fd87 | ||
|
|
cb046bba9e | ||
|
|
b9a40186b1 | ||
|
|
fd0a39b879 | ||
|
|
cc7afe4326 | ||
|
|
72cbc18a48 | ||
|
|
84408b842b | ||
|
|
339fcb56ad | ||
|
|
74cf534be5 | ||
|
|
89820c1377 | ||
|
|
2fe26b15dd | ||
|
|
2cb560fda4 | ||
|
|
d8530b9209 | ||
|
|
5049b0ba91 | ||
|
|
1b98a75c0e | ||
|
|
cbbde95773 | ||
|
|
26bb8a50a3 | ||
|
|
bf19bb31f9 | ||
|
|
55cc803708 | ||
|
|
805e521b1d | ||
|
|
f8d431a0d7 | ||
|
|
7b72db8a24 | ||
|
|
b61ff1ebec | ||
|
|
8870c65c6f | ||
|
|
5df5e635f3 | ||
|
|
d5e3cadc43 | ||
|
|
505ae05c53 | ||
|
|
a7929c0ac9 | ||
|
|
4d39008eab | ||
|
|
5efd59826b | ||
|
|
8970f29410 | ||
|
|
523318fc06 | ||
|
|
ba531b4f61 | ||
|
|
3d51f08e0b | ||
|
|
2660c124d5 | ||
|
|
d7199b4d1d | ||
|
|
c60704080b | ||
|
|
1e3e46cfdc | ||
|
|
93d4220f3a | ||
|
|
8a748f2b3a | ||
|
|
2138a4a890 | ||
|
|
94a1981a4c | ||
|
|
2272a401bb | ||
|
|
3ab43a2ef6 | ||
|
|
79698f2265 | ||
|
|
94f80c03c5 | ||
|
|
d98d01ab20 | ||
|
|
6be5c43e6d | ||
|
|
8deb762a52 | ||
|
|
fe21c19810 | ||
|
|
6e86c3d2c5 | ||
|
|
aca4b73fac | ||
|
|
078f7ac6f6 | ||
|
|
b168df6ca6 | ||
|
|
8d178884d4 | ||
|
|
cd2131ef6f | ||
|
|
d6bff15f11 | ||
|
|
b82fca0bc2 | ||
|
|
4fbd4fa81d | ||
|
|
68b5709d88 | ||
|
|
434b2c1305 | ||
|
|
9064ef687c | ||
|
|
c93f3d352c | ||
|
|
a9bfcbbe2f | ||
|
|
717e939038 | ||
|
|
dd1cb8a5d1 | ||
|
|
7c9399a342 | ||
|
|
a3ec9d19d3 | ||
|
|
5f2b5bd5a2 | ||
|
|
3bb4e66ad0 | ||
|
|
fbb102ffbd | ||
|
|
76f7535ffa | ||
|
|
7fcf0645da | ||
|
|
50618865e7 | ||
|
|
fe2f6a785a | ||
|
|
53fc24d4bc | ||
|
|
ff5fa501af | ||
|
|
6e9883018a | ||
|
|
a985ff4fb3 |
@@ -0,0 +1,127 @@
|
||||
name: CI/CD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['**']
|
||||
pull_request:
|
||||
branches: ['**']
|
||||
|
||||
concurrency:
|
||||
group: production-deploy
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
backend-tests:
|
||||
name: BackEnd
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
defaults:
|
||||
run:
|
||||
working-directory: backend
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore Musify.slnx
|
||||
|
||||
- name: Build
|
||||
run: dotnet build Musify.slnx --no-restore -c Release
|
||||
|
||||
- name: Test
|
||||
run: dotnet test Musify.slnx --no-build -c Release
|
||||
|
||||
web-player-checks:
|
||||
name: WebPlayer
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
defaults:
|
||||
run:
|
||||
working-directory: web-player
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: web-player/package-lock.json
|
||||
|
||||
- run: npm ci
|
||||
|
||||
- name: Type-check
|
||||
run: npm run check
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
# Runs on the production host itself, through the existing self-hosted
|
||||
# Gitea Actions runner (act_runner), which also carries a "production"
|
||||
# label registered in native (non-docker) mode so its steps see the host's
|
||||
# real Docker daemon directly (Docker socket + a bind-mounted deploy
|
||||
# directory — see deploy/README.md for how that runner is set up).
|
||||
#
|
||||
# DEPLOY_PATH is the persistent clone already on that host (the one whose
|
||||
# deploy/.env.prod is filled in and matches SharedServices/.env.prod) —
|
||||
# deploy reuses it instead of a fresh checkout, since .env.prod is
|
||||
# untracked and only lives there.
|
||||
deploy:
|
||||
name: Deploy
|
||||
needs: [backend-tests, web-player-checks]
|
||||
if: github.ref == 'refs/heads/master' && github.event_name == 'push'
|
||||
runs-on: [production]
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
DEPLOY_PATH: /home/ubuntu/deploys/musify
|
||||
COMPOSE: docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compose.prod.yml
|
||||
steps:
|
||||
- name: Pull latest master
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config --global --add safe.directory "$DEPLOY_PATH"
|
||||
cd "$DEPLOY_PATH"
|
||||
git fetch origin master
|
||||
git reset --hard origin/master
|
||||
|
||||
- name: Generate stream-ticket keys
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd "$DEPLOY_PATH"
|
||||
$COMPOSE run --rm --no-deps keys-init
|
||||
|
||||
# The migrations are baked into the migrator image, so it is rebuilt on
|
||||
# every deploy (plain `up` would reuse the stale image and re-apply the
|
||||
# old migrations). `run --rm` starts wait-for-postgres, blocks until the
|
||||
# migration finishes and propagates its exit code, so a failed migration
|
||||
# aborts the deploy before the new API is started against an old schema.
|
||||
- name: Migrate database
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd "$DEPLOY_PATH"
|
||||
$COMPOSE build migrate
|
||||
$COMPOSE run --rm migrate
|
||||
|
||||
# Both jobs verify themselves through the public auth./s3. URLs, served
|
||||
# by the shared edge nginx (Infrastructure's edge/), and are idempotent.
|
||||
- name: Create OIDC app and S3 bucket
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd "$DEPLOY_PATH"
|
||||
$COMPOSE up -d seaweedfs-init zitadel-init
|
||||
for job in musify-seaweedfs-init musify-zitadel-init; do
|
||||
code=$(docker wait "$job")
|
||||
if [ "$code" != "0" ]; then
|
||||
docker logs "$job"
|
||||
echo "$job exited with $code"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Build and start apps
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd "$DEPLOY_PATH"
|
||||
$COMPOSE --profile apps up -d --build api worker gateway web-player
|
||||
+3
-1
@@ -7,6 +7,9 @@ obj/
|
||||
*.userosscache
|
||||
.vs/
|
||||
artifacts/
|
||||
TestResults/
|
||||
*.trx
|
||||
*.coverage
|
||||
|
||||
# Rider / VS
|
||||
.idea/
|
||||
@@ -25,5 +28,4 @@ Thumbs.db
|
||||
# Deploy secrets and runtime state
|
||||
deploy/keys/
|
||||
deploy/secrets/
|
||||
deploy/nginx/certs/
|
||||
deploy/zitadel/.output/
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using Mediator;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.Genres
|
||||
{
|
||||
public record GetAvailableGenresQuery : IQuery<IReadOnlyList<AvailableGenreResponse>>;
|
||||
|
||||
public record AvailableGenreResponse(Genre Genre, IReadOnlyList<Genre> IncompatibleWith);
|
||||
|
||||
public class GetAvailableGenresQueryHandler
|
||||
: IQueryHandler<GetAvailableGenresQuery, IReadOnlyList<AvailableGenreResponse>>
|
||||
{
|
||||
public ValueTask<IReadOnlyList<AvailableGenreResponse>> Handle(GetAvailableGenresQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var all = Enum.GetValues<Genre>();
|
||||
|
||||
IReadOnlyList<AvailableGenreResponse> result = all
|
||||
.Select(genre => new AvailableGenreResponse(
|
||||
genre,
|
||||
all.Where(other => !GenreCompatibility.AreCompatible(genre, other)).ToList()))
|
||||
.ToList();
|
||||
|
||||
return ValueTask.FromResult(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using Mediator;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.Genres
|
||||
{
|
||||
public record GetGenresQuery : IQuery<IReadOnlyList<GenreResponse>>;
|
||||
|
||||
public record GenreResponse(Genre Genre, int TrackCount);
|
||||
|
||||
public class GetGenresQueryHandler(IDatabase database)
|
||||
: IQueryHandler<GetGenresQuery, IReadOnlyList<GenreResponse>>
|
||||
{
|
||||
public async ValueTask<IReadOnlyList<GenreResponse>> Handle(GetGenresQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var rows = await database.TrackTags
|
||||
.AsNoTracking()
|
||||
.Where(tag => database.UserHasTracks.Any(userTrack => userTrack.TrackId == tag.TrackId))
|
||||
.GroupBy(tag => tag.Tag)
|
||||
.Select(group => new { Genre = group.Key, TrackCount = group.Select(tag => tag.TrackId).Distinct().Count() })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return rows
|
||||
.OrderByDescending(row => row.TrackCount)
|
||||
.ThenBy(row => row.Genre)
|
||||
.Select(row => new GenreResponse(row.Genre, row.TrackCount))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using ErrorOr;
|
||||
using Mediator;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.PlayLists.Responses;
|
||||
using Musify.Domain.Entities;
|
||||
|
||||
namespace Musify.Application.PlayLists
|
||||
{
|
||||
public record AddAlbumToPlayListCommand(long UserId, Guid PlayListId, Guid AlbumId)
|
||||
: ICommand<ErrorOr<AddAlbumToPlayListResponse>>;
|
||||
|
||||
public class AddAlbumToPlayListCommandHandler(
|
||||
IDatabase database,
|
||||
ILogger<AddAlbumToPlayListCommandHandler> logger)
|
||||
: ICommandHandler<AddAlbumToPlayListCommand, ErrorOr<AddAlbumToPlayListResponse>>
|
||||
{
|
||||
public async ValueTask<ErrorOr<AddAlbumToPlayListResponse>> Handle(AddAlbumToPlayListCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var playList = await database.PlayLists
|
||||
.SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken);
|
||||
if (playList == null)
|
||||
{
|
||||
logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId);
|
||||
return Error.NotFound();
|
||||
}
|
||||
|
||||
if (playList.UserId != request.UserId)
|
||||
{
|
||||
logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId);
|
||||
return Error.Unauthorized();
|
||||
}
|
||||
|
||||
var albumExists = await database.Albums
|
||||
.AsNoTracking()
|
||||
.AnyAsync(album => album.Id == request.AlbumId, cancellationToken);
|
||||
if (!albumExists)
|
||||
{
|
||||
logger.LogInformation("Album {AlbumId} not found", request.AlbumId);
|
||||
return Error.NotFound(description: $"Album {request.AlbumId} not found");
|
||||
}
|
||||
|
||||
var albumTrackIds = await database.AlbumHasTracks
|
||||
.AsNoTracking()
|
||||
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId)
|
||||
.OrderBy(albumTrack => albumTrack.TrackNumber)
|
||||
.Select(albumTrack => albumTrack.TrackId)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var playListTracksQuery = database.PlayListHasTracks
|
||||
.Where(plt => plt.PlayListId == request.PlayListId);
|
||||
|
||||
var existingTrackIds = (await playListTracksQuery
|
||||
.Select(plt => plt.TrackId)
|
||||
.ToListAsync(cancellationToken)).ToHashSet();
|
||||
|
||||
var newTrackIds = albumTrackIds
|
||||
.Where(trackId => !existingTrackIds.Contains(trackId))
|
||||
.ToList();
|
||||
|
||||
if (newTrackIds.Count == 0)
|
||||
return new AddAlbumToPlayListResponse(0);
|
||||
|
||||
var nextPosition = existingTrackIds.Count > 0
|
||||
? await playListTracksQuery.MaxAsync(plt => plt.Position, cancellationToken) + 1
|
||||
: 0;
|
||||
|
||||
await database.PlayListHasTracks.AddRangeAsync(
|
||||
newTrackIds.Select((trackId, index) => new PlayListHasTrack
|
||||
{
|
||||
PlayListId = request.PlayListId,
|
||||
TrackId = trackId,
|
||||
Position = nextPosition + index
|
||||
}),
|
||||
cancellationToken);
|
||||
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
|
||||
logger.LogInformation("Added {Count} tracks from album {AlbumId} to playlist {PlayListId}",
|
||||
newTrackIds.Count, request.AlbumId, request.PlayListId);
|
||||
|
||||
return new AddAlbumToPlayListResponse(newTrackIds.Count);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,8 @@ namespace Musify.Application.PlayLists
|
||||
.Select(p => new
|
||||
{
|
||||
PlayList = p,
|
||||
TrackCount = p.PlayListTracks.Count,
|
||||
DurationSeconds = p.PlayListTracks.Sum(plt => (double?)plt.Track.DurationSeconds) ?? 0,
|
||||
CoverTrackIds = p.PlayListTracks
|
||||
.OrderBy(plt => plt.Position)
|
||||
.Take(PlayListApplicationResponse.CoverTrackCount)
|
||||
@@ -36,7 +38,7 @@ namespace Musify.Application.PlayLists
|
||||
&& entry.PlayList.UserId != request.RequestingUserId)
|
||||
return Error.NotFound();
|
||||
|
||||
return PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds);
|
||||
return PlayListApplicationResponse.FromEntity(entry.PlayList, entry.TrackCount, entry.DurationSeconds, entry.CoverTrackIds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ namespace Musify.Application.PlayLists
|
||||
.Select(p => new
|
||||
{
|
||||
PlayList = p,
|
||||
TrackCount = p.PlayListTracks.Count,
|
||||
DurationSeconds = p.PlayListTracks.Sum(plt => (double?)plt.Track.DurationSeconds) ?? 0,
|
||||
CoverTrackIds = p.PlayListTracks
|
||||
.OrderBy(plt => plt.Position)
|
||||
.Take(PlayListApplicationResponse.CoverTrackCount)
|
||||
@@ -34,7 +36,7 @@ namespace Musify.Application.PlayLists
|
||||
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
||||
|
||||
var pagedPlayLists = new StaticPagedList<PlayListApplicationResponse>(
|
||||
pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds)),
|
||||
pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.TrackCount, entry.DurationSeconds, entry.CoverTrackIds)),
|
||||
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
|
||||
|
||||
return PaginatedResponse<PlayListApplicationResponse>.FromPagedList(pagedPlayLists);
|
||||
|
||||
@@ -46,6 +46,8 @@ namespace Musify.Application.PlayLists
|
||||
.Select(p => new
|
||||
{
|
||||
PlayList = p,
|
||||
TrackCount = p.PlayListTracks.Count,
|
||||
DurationSeconds = p.PlayListTracks.Sum(plt => (double?)plt.Track.DurationSeconds) ?? 0,
|
||||
CoverTrackIds = p.PlayListTracks
|
||||
.OrderBy(plt => plt.Position)
|
||||
.Take(PlayListApplicationResponse.CoverTrackCount)
|
||||
@@ -55,7 +57,7 @@ namespace Musify.Application.PlayLists
|
||||
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
||||
|
||||
var pagedPlayLists = new StaticPagedList<PlayListApplicationResponse>(
|
||||
pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds)),
|
||||
pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.TrackCount, entry.DurationSeconds, entry.CoverTrackIds)),
|
||||
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
|
||||
|
||||
return PaginatedResponse<PlayListApplicationResponse>.FromPagedList(pagedPlayLists);
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
namespace Musify.Application.PlayLists.Responses
|
||||
{
|
||||
public record AddAlbumToPlayListResponse(int AddedCount);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using Musify.Application.Serialization;
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
@@ -10,7 +12,10 @@ namespace Musify.Application.PlayLists.Responses
|
||||
string? SmallImageKeyName,
|
||||
string? MediumImageKeyName,
|
||||
string? LargeImageKeyName,
|
||||
[property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId,
|
||||
PlaylistVisibility Visibility,
|
||||
int TrackCount,
|
||||
double DurationSeconds,
|
||||
DateTime CreatedAt,
|
||||
DateTime UpdatedAt,
|
||||
IReadOnlyList<Guid> CoverTrackIds)
|
||||
@@ -19,6 +24,8 @@ namespace Musify.Application.PlayLists.Responses
|
||||
|
||||
public static PlayListApplicationResponse FromEntity(
|
||||
PlayList playList,
|
||||
int trackCount = 0,
|
||||
double durationSeconds = 0,
|
||||
IReadOnlyList<Guid>? coverTrackIds = null)
|
||||
{
|
||||
return new PlayListApplicationResponse(
|
||||
@@ -28,7 +35,10 @@ namespace Musify.Application.PlayLists.Responses
|
||||
playList.Pictures?.SmallName,
|
||||
playList.Pictures?.MediumName,
|
||||
playList.Pictures?.LargeName,
|
||||
playList.UserId,
|
||||
playList.Visibility,
|
||||
trackCount,
|
||||
durationSeconds,
|
||||
playList.CreatedAt,
|
||||
playList.UpdatedAt,
|
||||
coverTrackIds ?? []);
|
||||
|
||||
@@ -112,7 +112,22 @@ namespace Musify.Application.PlayLists
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
logger.LogInformation("Updated playlist {PlayListId}", playListEntity.Id);
|
||||
return PlayListApplicationResponse.FromEntity(playListEntity, coverTrackIds);
|
||||
var totals = await database.PlayListHasTracks
|
||||
.AsNoTracking()
|
||||
.Where(plt => plt.PlayListId == playListEntity.Id)
|
||||
.GroupBy(plt => plt.PlayListId)
|
||||
.Select(group => new
|
||||
{
|
||||
TrackCount = group.Count(),
|
||||
DurationSeconds = group.Sum(plt => plt.Track.DurationSeconds)
|
||||
})
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
|
||||
return PlayListApplicationResponse.FromEntity(
|
||||
playListEntity,
|
||||
totals?.TrackCount ?? 0,
|
||||
totals?.DurationSeconds ?? 0,
|
||||
coverTrackIds);
|
||||
}
|
||||
|
||||
private async Task<ErrorOr<Success>> PublishUpdatePlayListPictureSourceEventAsync(
|
||||
|
||||
@@ -18,7 +18,8 @@ namespace Musify.Application.Tracks
|
||||
string Title,
|
||||
Guid PictureIntentId,
|
||||
Guid AudioIntentId,
|
||||
IReadOnlyCollection<Genre> Tags)
|
||||
IReadOnlyCollection<Genre> Tags,
|
||||
bool IsExplicit = false)
|
||||
: ICommand<ErrorOr<TrackApplicationResponse>>;
|
||||
|
||||
public class CreateTrackCommandHandler(
|
||||
@@ -83,6 +84,7 @@ namespace Musify.Application.Tracks
|
||||
NormalizedTitle = request.Title.ToUpperInvariant(),
|
||||
OwnerUserId = user.Id,
|
||||
Owner = user,
|
||||
IsExplicit = request.IsExplicit,
|
||||
Pictures = new TrackPictures
|
||||
{
|
||||
OriginalName = pictureIntent.ObjectName,
|
||||
|
||||
@@ -5,10 +5,11 @@ using Musify.Application.Tracks.Responses;
|
||||
using X.PagedList;
|
||||
using X.PagedList.EF;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.Tracks
|
||||
{
|
||||
public record GetTracksQuery(string? Name, int PageNumber, int PageSize)
|
||||
public record GetTracksQuery(string? Name, int PageNumber, int PageSize, Genre? Genre = null)
|
||||
: IQuery<ErrorOr<TracksSearchResponse>>;
|
||||
|
||||
public class GetTracksQueryHandler(IDatabase database)
|
||||
@@ -28,6 +29,9 @@ namespace Musify.Application.Tracks
|
||||
tracksQuery = tracksQuery.Where(t => t.Track.NormalizedTitle.Contains(normalizedName));
|
||||
}
|
||||
|
||||
if (request.Genre is { } genre)
|
||||
tracksQuery = tracksQuery.Where(t => t.Track.Tags.Any(tag => tag.Tag == genre));
|
||||
|
||||
var totalCount = await tracksQuery.CountAsync(cancellationToken);
|
||||
|
||||
var pagedEntities = await tracksQuery
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace Musify.Application.Tracks
|
||||
{
|
||||
public static class ListeningRules
|
||||
{
|
||||
public const double CountedThresholdSeconds = 30;
|
||||
public const double CountedThresholdFraction = 0.5;
|
||||
public const double DurationToleranceSeconds = 2;
|
||||
public const double ClockToleranceSeconds = 5;
|
||||
public const int UncountedRetentionDays = 8;
|
||||
|
||||
public static double CountedThreshold(double durationSeconds) =>
|
||||
Math.Min(CountedThresholdSeconds, durationSeconds * CountedThresholdFraction);
|
||||
}
|
||||
}
|
||||
@@ -11,18 +11,6 @@ namespace Musify.Application.Tracks
|
||||
double PlayedSeconds)
|
||||
: ICommand<ErrorOr<Success>>;
|
||||
|
||||
public static class ListeningRules
|
||||
{
|
||||
public const double CountedThresholdSeconds = 30;
|
||||
public const double CountedThresholdFraction = 0.5;
|
||||
public const double DurationToleranceSeconds = 2;
|
||||
public const double ClockToleranceSeconds = 5;
|
||||
public const int UncountedRetentionDays = 8;
|
||||
|
||||
public static double CountedThreshold(double durationSeconds) =>
|
||||
Math.Min(CountedThresholdSeconds, durationSeconds * CountedThresholdFraction);
|
||||
}
|
||||
|
||||
public class RecordListeningProgressCommandHandler(IDatabase database)
|
||||
: ICommandHandler<RecordListeningProgressCommand, ErrorOr<Success>>
|
||||
{
|
||||
|
||||
@@ -15,7 +15,8 @@ namespace Musify.Application.Tracks.Responses
|
||||
DateTime CreatedAt,
|
||||
DateTime UpdatedAt,
|
||||
[property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId,
|
||||
IReadOnlyCollection<Genre> Tags)
|
||||
IReadOnlyCollection<Genre> Tags,
|
||||
bool IsExplicit)
|
||||
{
|
||||
public static TrackApplicationResponse FromEntity(Track track, int listensCount) =>
|
||||
new(
|
||||
@@ -28,6 +29,7 @@ namespace Musify.Application.Tracks.Responses
|
||||
track.CreatedAt,
|
||||
track.UpdatedAt,
|
||||
track.OwnerUserId,
|
||||
track.Tags.Select(tag => tag.Tag).ToList());
|
||||
track.Tags.Select(tag => tag.Tag).ToList(),
|
||||
track.IsExplicit);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Application.Contracts;
|
||||
|
||||
namespace Musify.Application.Users
|
||||
{
|
||||
public static class FollowVisibility
|
||||
{
|
||||
public static async Task<bool> CanViewFollowersAsync(
|
||||
IDatabase database,
|
||||
long? viewerId,
|
||||
long userId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (viewerId is not { } viewer)
|
||||
return false;
|
||||
|
||||
if (viewer == userId)
|
||||
return true;
|
||||
|
||||
var mutualRows = await database.UserFollows
|
||||
.AsNoTracking()
|
||||
.CountAsync(f =>
|
||||
(f.FollowerId == viewer && f.FollowedId == userId) ||
|
||||
(f.FollowerId == userId && f.FollowedId == viewer),
|
||||
cancellationToken);
|
||||
|
||||
return mutualRows == 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using ErrorOr;
|
||||
using Mediator;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Shared;
|
||||
using Musify.Application.Users.Responses;
|
||||
using X.PagedList.EF;
|
||||
|
||||
namespace Musify.Application.Users
|
||||
{
|
||||
public record GetUserFollowersQuery(long? ViewerId, long UserId, int PageNumber, int PageSize)
|
||||
: IQuery<ErrorOr<PaginatedResponse<UserSummaryResponse>>>;
|
||||
|
||||
public class GetUserFollowersQueryHandler(IDatabase database)
|
||||
: IQueryHandler<GetUserFollowersQuery, ErrorOr<PaginatedResponse<UserSummaryResponse>>>
|
||||
{
|
||||
public async ValueTask<ErrorOr<PaginatedResponse<UserSummaryResponse>>> Handle(GetUserFollowersQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!await database.Users.AnyAsync(u => u.Id == request.UserId, cancellationToken))
|
||||
return Error.NotFound();
|
||||
|
||||
if (!await FollowVisibility.CanViewFollowersAsync(database, request.ViewerId, request.UserId, cancellationToken))
|
||||
return Error.Forbidden();
|
||||
|
||||
var followsQuery = database.UserFollows
|
||||
.AsNoTracking()
|
||||
.Where(f => f.FollowedId == request.UserId);
|
||||
|
||||
var totalCount = await followsQuery.CountAsync(cancellationToken);
|
||||
var viewerId = request.ViewerId;
|
||||
|
||||
var page = await followsQuery
|
||||
.OrderByDescending(f => f.CreatedAt)
|
||||
.Select(f => new UserSummaryResponse(
|
||||
f.Follower.Id,
|
||||
f.Follower.Name,
|
||||
f.Follower.FirstName,
|
||||
f.Follower.SecondName,
|
||||
f.Follower.ProfilePictureUrl,
|
||||
viewerId != null && database.UserFollows.Any(v => v.FollowerId == viewerId && v.FollowedId == f.FollowerId)))
|
||||
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
||||
|
||||
return PaginatedResponse<UserSummaryResponse>.FromPagedList(page);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using ErrorOr;
|
||||
using Mediator;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Shared;
|
||||
using Musify.Application.Users.Responses;
|
||||
using X.PagedList.EF;
|
||||
|
||||
namespace Musify.Application.Users
|
||||
{
|
||||
public record GetUserFollowingQuery(long? ViewerId, long UserId, int PageNumber, int PageSize)
|
||||
: IQuery<ErrorOr<PaginatedResponse<UserSummaryResponse>>>;
|
||||
|
||||
public class GetUserFollowingQueryHandler(IDatabase database)
|
||||
: IQueryHandler<GetUserFollowingQuery, ErrorOr<PaginatedResponse<UserSummaryResponse>>>
|
||||
{
|
||||
public async ValueTask<ErrorOr<PaginatedResponse<UserSummaryResponse>>> Handle(GetUserFollowingQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!await database.Users.AnyAsync(u => u.Id == request.UserId, cancellationToken))
|
||||
return Error.NotFound();
|
||||
|
||||
var followsQuery = database.UserFollows
|
||||
.AsNoTracking()
|
||||
.Where(f => f.FollowerId == request.UserId);
|
||||
|
||||
var totalCount = await followsQuery.CountAsync(cancellationToken);
|
||||
var viewerId = request.ViewerId;
|
||||
|
||||
var page = await followsQuery
|
||||
.OrderByDescending(f => f.CreatedAt)
|
||||
.Select(f => new UserSummaryResponse(
|
||||
f.Followed.Id,
|
||||
f.Followed.Name,
|
||||
f.Followed.FirstName,
|
||||
f.Followed.SecondName,
|
||||
f.Followed.ProfilePictureUrl,
|
||||
viewerId != null && database.UserFollows.Any(v => v.FollowerId == viewerId && v.FollowedId == f.FollowedId)))
|
||||
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
||||
|
||||
return PaginatedResponse<UserSummaryResponse>.FromPagedList(page);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ using Musify.Application.Serialization;
|
||||
|
||||
namespace Musify.Application.Users
|
||||
{
|
||||
public record GetUserProfileQuery(long UserId) : IQuery<ErrorOr<UserProfileResponse>>;
|
||||
public record GetUserProfileQuery(long UserId, long? ViewerId = null) : IQuery<ErrorOr<UserProfileResponse>>;
|
||||
|
||||
public record UserProfileResponse(
|
||||
[property: JsonConverter(typeof(LongAsStringConverter))] long Id,
|
||||
@@ -17,7 +17,9 @@ namespace Musify.Application.Users
|
||||
string? ProfilePictureUrl,
|
||||
DateTime CreatedAt,
|
||||
int FollowersCount,
|
||||
int FollowingCount);
|
||||
int FollowingCount,
|
||||
bool IsFollowing,
|
||||
bool CanViewFollowers);
|
||||
|
||||
public class GetUserProfileQueryHandler(IDatabase database)
|
||||
: IQueryHandler<GetUserProfileQuery, ErrorOr<UserProfileResponse>>
|
||||
@@ -49,6 +51,14 @@ namespace Musify.Application.Users
|
||||
.AsNoTracking()
|
||||
.CountAsync(f => f.FollowerId == request.UserId, cancellationToken);
|
||||
|
||||
var isFollowing = request.ViewerId is { } viewerId && viewerId != request.UserId &&
|
||||
await database.UserFollows
|
||||
.AsNoTracking()
|
||||
.AnyAsync(f => f.FollowerId == viewerId && f.FollowedId == request.UserId, cancellationToken);
|
||||
|
||||
var canViewFollowers = await FollowVisibility.CanViewFollowersAsync(
|
||||
database, request.ViewerId, request.UserId, cancellationToken);
|
||||
|
||||
return new UserProfileResponse(
|
||||
user.Id,
|
||||
user.Name,
|
||||
@@ -57,7 +67,9 @@ namespace Musify.Application.Users
|
||||
user.ProfilePictureUrl,
|
||||
user.CreatedAt,
|
||||
followersCount,
|
||||
followingCount);
|
||||
followingCount,
|
||||
isFollowing,
|
||||
canViewFollowers);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,13 +8,13 @@ using Musify.Application.Contracts;
|
||||
|
||||
namespace Musify.Application.Users
|
||||
{
|
||||
public record GetUsersQuery(int PageNumber, int PageSize, string? UsernameSearch)
|
||||
: IQuery<ErrorOr<PaginatedResponse<UserApplicationResponse>>>;
|
||||
public record GetUsersQuery(int PageNumber, int PageSize, string? UsernameSearch, long? ViewerId = null)
|
||||
: IQuery<ErrorOr<PaginatedResponse<UserSummaryResponse>>>;
|
||||
|
||||
public class GetUsersQueryHandler(IDatabase database)
|
||||
: IQueryHandler<GetUsersQuery, ErrorOr<PaginatedResponse<UserApplicationResponse>>>
|
||||
: IQueryHandler<GetUsersQuery, ErrorOr<PaginatedResponse<UserSummaryResponse>>>
|
||||
{
|
||||
public async ValueTask<ErrorOr<PaginatedResponse<UserApplicationResponse>>> Handle(GetUsersQuery request, CancellationToken cancellationToken)
|
||||
public async ValueTask<ErrorOr<PaginatedResponse<UserSummaryResponse>>> Handle(GetUsersQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var usersQuery = database.Users
|
||||
.AsNoTracking()
|
||||
@@ -27,11 +27,19 @@ namespace Musify.Application.Users
|
||||
}
|
||||
|
||||
var totalCount = await usersQuery.CountAsync(cancellationToken);
|
||||
var viewerId = request.ViewerId;
|
||||
var pagedUsers = await usersQuery
|
||||
.Select(u => UserApplicationResponse.FromEntity(u))
|
||||
.OrderBy(u => u.Name)
|
||||
.Select(u => new UserSummaryResponse(
|
||||
u.Id,
|
||||
u.Name,
|
||||
u.FirstName,
|
||||
u.SecondName,
|
||||
u.ProfilePictureUrl,
|
||||
viewerId != null && database.UserFollows.Any(f => f.FollowerId == viewerId && f.FollowedId == u.Id)))
|
||||
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
||||
|
||||
return PaginatedResponse<UserApplicationResponse>.FromPagedList(pagedUsers);
|
||||
return PaginatedResponse<UserSummaryResponse>.FromPagedList(pagedUsers);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using Musify.Application.Serialization;
|
||||
|
||||
namespace Musify.Application.Users.Responses
|
||||
{
|
||||
public record UserSummaryResponse(
|
||||
[property: JsonConverter(typeof(LongAsStringConverter))] long Id,
|
||||
string Name,
|
||||
string? FirstName,
|
||||
string? SecondName,
|
||||
string? ProfilePictureUrl,
|
||||
bool IsFollowedByViewer);
|
||||
}
|
||||
@@ -31,6 +31,9 @@ namespace Musify.Domain.Entities
|
||||
[Required]
|
||||
public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active;
|
||||
|
||||
[Required]
|
||||
public bool IsExplicit { get; set; }
|
||||
|
||||
[Required]
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ namespace Musify.Infrastructure.Persistence
|
||||
}
|
||||
else if (entry.State == EntityState.Modified)
|
||||
{
|
||||
entry.Entity.UpdatedAt = now;
|
||||
entry.Property(nameof(IAuditable.UpdatedAt)).CurrentValue = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,9 +13,13 @@ namespace Musify.Infrastructure.Persistence
|
||||
public class Database(DatabaseConfiguration configuration)
|
||||
: DbContext, AppIDatabase
|
||||
{
|
||||
private static readonly AuditableEntityInterceptor AuditInterceptor = new();
|
||||
|
||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||
{
|
||||
optionsBuilder.UseNpgsql(configuration.ConnectionString, o => o.MapEnum<Genre>("genre"));
|
||||
optionsBuilder
|
||||
.UseNpgsql(configuration.ConnectionString, o => o.MapEnum<Genre>("genre"))
|
||||
.AddInterceptors(AuditInterceptor);
|
||||
}
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
|
||||
@@ -18,10 +18,7 @@ namespace Musify.Infrastructure.Persistence
|
||||
serviceDescriptors.AddSingleton(serviceProvider =>
|
||||
serviceProvider.GetRequiredService<IOptions<DatabaseConfiguration>>().Value);
|
||||
|
||||
serviceDescriptors.AddSingleton<AuditableEntityInterceptor>();
|
||||
|
||||
serviceDescriptors.AddDbContext<Database>((serviceProvider, options) =>
|
||||
options.AddInterceptors(serviceProvider.GetRequiredService<AuditableEntityInterceptor>()));
|
||||
serviceDescriptors.AddDbContext<Database>();
|
||||
|
||||
serviceDescriptors.AddScoped<IDatabase>(serviceProvider => serviceProvider.GetRequiredService<Database>());
|
||||
|
||||
|
||||
+1167
File diff suppressed because it is too large
Load Diff
+29
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Musify.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddTrackIsExplicit : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsExplicit",
|
||||
table: "Tracks",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsExplicit",
|
||||
table: "Tracks");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -436,6 +436,9 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
||||
b.Property<double>("DurationSeconds")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<bool>("IsExplicit")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<int>("LifeCycleStatus")
|
||||
.HasColumnType("integer");
|
||||
|
||||
|
||||
@@ -6,4 +6,5 @@ public record CreateTrackRequest(
|
||||
string Title,
|
||||
Guid PictureIntentId,
|
||||
Guid AudioIntentId,
|
||||
IReadOnlyCollection<Genre> Tags);
|
||||
IReadOnlyCollection<Genre> Tags,
|
||||
bool IsExplicit = false);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
using Mediator;
|
||||
using Musify.Application.Genres;
|
||||
|
||||
namespace Musify.Api.Endpoints;
|
||||
|
||||
public static class GenreEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapGenreEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/genres")
|
||||
.WithTags("Genres");
|
||||
|
||||
group.MapGet("/", GetGenres)
|
||||
.WithName("GetGenres")
|
||||
.WithSummary("Get The Genres That Have At Least One Track, Most Populated First. Works Anonymously.")
|
||||
.Produces<IReadOnlyList<GenreResponse>>();
|
||||
|
||||
group.MapGet("/available", GetAvailableGenres)
|
||||
.WithName("GetAvailableGenres")
|
||||
.WithSummary("Get Every Genre A Track Can Be Tagged With, Including The Genres It Cannot Be Combined With. Works Anonymously.")
|
||||
.Produces<IReadOnlyList<AvailableGenreResponse>>();
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<AvailableGenreResponse>> GetAvailableGenres(
|
||||
IMediator mediator,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return await mediator.Send(new GetAvailableGenresQuery(), cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<GenreResponse>> GetGenres(
|
||||
IMediator mediator,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return await mediator.Send(new GetGenresQuery(), cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -94,6 +94,14 @@ public static class PlayListEndpoints
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.Produces(StatusCodes.Status409Conflict);
|
||||
|
||||
group.MapPost("/{playlistId}/albums/{albumId}", AddAlbumToPlayList)
|
||||
.WithName("AddAlbumToPlayList")
|
||||
.WithSummary("Add All The Tracks Of An Album To One Of Your PlayLists, Skipping Those Already In It.")
|
||||
.RequireAuthorization()
|
||||
.Produces<AddAlbumToPlayListResponse>()
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapDelete("/{playlistId}/tracks/{trackId}", RemoveTrackFromPlayList)
|
||||
.WithName("RemoveTrackFromPlayList")
|
||||
.WithSummary("Remove A Track From A PlayList.")
|
||||
@@ -234,6 +242,20 @@ public static class PlayListEndpoints
|
||||
return trackResult.ToCreatedResult(track => $"/tracks/{track.Id}");
|
||||
}
|
||||
|
||||
private static async Task<IResult> AddAlbumToPlayList(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
Guid playlistId,
|
||||
Guid albumId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(
|
||||
new AddAlbumToPlayListCommand(currentUser.RequiredId, playlistId, albumId),
|
||||
cancellationToken);
|
||||
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> RemoveTrackFromPlayList(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Mediator;
|
||||
using Musify.Domain.ValueObjects;
|
||||
using Musify.Application.Tracks;
|
||||
using Musify.Application.Tracks.Responses;
|
||||
using Musify.Application.Shared;
|
||||
@@ -19,8 +20,9 @@ public static class TrackEndpoints
|
||||
|
||||
group.MapGet("/", GetTracks)
|
||||
.WithName("GetTracks")
|
||||
.WithSummary("Get Paginated Tracks, Optionally Filtered By Name.")
|
||||
.Produces<TracksSearchResponse>();
|
||||
.WithSummary("Get Paginated Tracks, Optionally Filtered By Name And Genre.")
|
||||
.Produces<TracksSearchResponse>()
|
||||
.ProducesValidationProblem();
|
||||
|
||||
group.MapGet("/{id}", GetTrackById)
|
||||
.WithName("GetTrackById")
|
||||
@@ -92,10 +94,25 @@ public static class TrackEndpoints
|
||||
IMediator mediator,
|
||||
CancellationToken cancellationToken,
|
||||
string? name,
|
||||
string? genre,
|
||||
int pageNumber = 1,
|
||||
int pageSize = 10)
|
||||
{
|
||||
var result = await mediator.Send(new GetTracksQuery(name, pageNumber, pageSize), cancellationToken);
|
||||
Genre? parsedGenre = null;
|
||||
if (!string.IsNullOrWhiteSpace(genre))
|
||||
{
|
||||
if (!Enum.TryParse<Genre>(genre, ignoreCase: true, out var value) || !Enum.IsDefined(value))
|
||||
{
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
[nameof(genre)] = [$"'{genre}' is not a valid genre."]
|
||||
});
|
||||
}
|
||||
|
||||
parsedGenre = value;
|
||||
}
|
||||
|
||||
var result = await mediator.Send(new GetTracksQuery(name, pageNumber, pageSize, parsedGenre), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
@@ -168,7 +185,7 @@ public static class TrackEndpoints
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(
|
||||
new CreateTrackCommand(currentUser.RequiredId, request.Title, request.PictureIntentId, request.AudioIntentId, request.Tags),
|
||||
new CreateTrackCommand(currentUser.RequiredId, request.Title, request.PictureIntentId, request.AudioIntentId, request.Tags, request.IsExplicit),
|
||||
cancellationToken);
|
||||
|
||||
return result.ToCreatedResult(track => $"/tracks/{track.Id}");
|
||||
|
||||
@@ -21,7 +21,7 @@ public static class UserEndpoints
|
||||
group.MapGet("/", GetUsers)
|
||||
.WithName("GetUsers")
|
||||
.WithSummary("Get Paginated Users.")
|
||||
.Produces<PaginatedResponse<UserApplicationResponse>>();
|
||||
.Produces<PaginatedResponse<UserSummaryResponse>>();
|
||||
|
||||
group.MapGet("/{id}", GetUserById)
|
||||
.WithName("GetUserById")
|
||||
@@ -52,6 +52,19 @@ public static class UserEndpoints
|
||||
.Produces<UserProfileResponse>()
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapGet("/{id}/followers", GetFollowers)
|
||||
.WithName("GetUserFollowers")
|
||||
.WithSummary("Get The Users Following A User. Visible To The User And To Mutual Followers.")
|
||||
.Produces<PaginatedResponse<UserSummaryResponse>>()
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapGet("/{id}/following", GetFollowing)
|
||||
.WithName("GetUserFollowing")
|
||||
.WithSummary("Get The Users A User Follows.")
|
||||
.Produces<PaginatedResponse<UserSummaryResponse>>()
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapGet("/{id}/is-following", IsFollowing)
|
||||
.WithName("IsFollowingUser")
|
||||
.WithSummary("Check Whether The Current User Follows Another User.")
|
||||
@@ -88,12 +101,13 @@ public static class UserEndpoints
|
||||
|
||||
private static async Task<IResult> GetUsers(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
CancellationToken cancellationToken,
|
||||
string? usernameSearch,
|
||||
int pageNumber = 1,
|
||||
int pageSize = 10)
|
||||
{
|
||||
var result = await mediator.Send(new GetUsersQuery(pageNumber, pageSize, usernameSearch), cancellationToken);
|
||||
var result = await mediator.Send(new GetUsersQuery(pageNumber, pageSize, usernameSearch, currentUser.Id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
@@ -133,10 +147,35 @@ public static class UserEndpoints
|
||||
|
||||
private static async Task<IResult> GetUserProfile(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
long id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(new GetUserProfileQuery(id), cancellationToken);
|
||||
var result = await mediator.Send(new GetUserProfileQuery(id, currentUser.Id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetFollowers(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
long id,
|
||||
CancellationToken cancellationToken,
|
||||
int pageNumber = 1,
|
||||
int pageSize = 20)
|
||||
{
|
||||
var result = await mediator.Send(new GetUserFollowersQuery(currentUser.Id, id, pageNumber, pageSize), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetFollowing(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
long id,
|
||||
CancellationToken cancellationToken,
|
||||
int pageNumber = 1,
|
||||
int pageSize = 20)
|
||||
{
|
||||
var result = await mediator.Send(new GetUserFollowingQuery(currentUser.Id, id, pageNumber, pageSize), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ app.UseAuthorization();
|
||||
app
|
||||
.MapAlbumEndpoints()
|
||||
.MapConfigEndpoints()
|
||||
.MapGenreEndpoints()
|
||||
.MapLikeEndpoints()
|
||||
.MapMixEndpoints()
|
||||
.MapPlayListEndpoints()
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
@WebApi_HostAddress = http://localhost:5111
|
||||
|
||||
GET {{WebApi_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
@@ -12,10 +12,6 @@ RUN dotnet restore Hosts/Musify.Api/Api.csproj
|
||||
|
||||
COPY . .
|
||||
RUN dotnet publish Hosts/Musify.Api/Api.csproj -c Release -o /app --no-restore
|
||||
# Config files are AppSettings*.json (PascalCase); on case-sensitive Linux the host
|
||||
# looks for appsettings*.json. Add lowercase copies so they get loaded.
|
||||
RUN cp /app/AppSettings.json /app/appsettings.json \
|
||||
&& cp /app/AppSettings.Development.json /app/appsettings.Development.json
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
|
||||
WORKDIR /app
|
||||
|
||||
@@ -11,9 +11,6 @@ RUN dotnet restore Hosts/Musify.Worker/Worker.csproj
|
||||
|
||||
COPY . .
|
||||
RUN dotnet publish Hosts/Musify.Worker/Worker.csproj -c Release -o /app --no-restore
|
||||
# AppSettings*.json (PascalCase) -> lowercase copies for case-sensitive Linux.
|
||||
RUN cp /app/AppSettings.json /app/appsettings.json \
|
||||
&& cp /app/AppSettings.Development.json /app/appsettings.Development.json
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
|
||||
WORKDIR /app
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Musify.Api.Tests.TestSupport;
|
||||
using Musify.Application.Genres;
|
||||
using Musify.Application.Tracks.Responses;
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
using Musify.Infrastructure.Persistence;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Api.Tests.Endpoints
|
||||
{
|
||||
[Collection(ApiCollection.Name)]
|
||||
public sealed class GenreEndpointsTests(ApiTestFixture fixture)
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
Converters = { new JsonStringEnumConverter() }
|
||||
};
|
||||
|
||||
private async Task<string> SeedTrackAsync(Genre genre)
|
||||
{
|
||||
using var scope = fixture.Services.CreateScope();
|
||||
var database = scope.ServiceProvider.GetRequiredService<Database>();
|
||||
var owner = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = "genre-owner", NormalizedName = "GENRE-OWNER" };
|
||||
var title = $"Genre track {Guid.NewGuid():N}";
|
||||
var track = new Track
|
||||
{
|
||||
Title = title,
|
||||
NormalizedTitle = title.ToUpperInvariant(),
|
||||
OwnerUserId = owner.Id,
|
||||
Owner = owner,
|
||||
Tags = [new TrackTag { TrackId = Guid.Empty, Tag = genre }]
|
||||
};
|
||||
foreach (var tag in track.Tags)
|
||||
tag.TrackId = track.Id;
|
||||
database.Users.Add(owner);
|
||||
database.Tracks.Add(track);
|
||||
database.UserHasTracks.Add(new UserHasTrack { UserId = owner.Id, TrackId = track.Id });
|
||||
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
return title;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetGenres_Anonymous_ListsGenresInUse()
|
||||
{
|
||||
await SeedTrackAsync(Genre.Funk);
|
||||
|
||||
var response = await fixture.CreateAnonymousClient().GetAsync("/genres", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<List<GenreResponse>>(JsonOptions, TestContext.Current.CancellationToken);
|
||||
Assert.Contains(body!, g => g.Genre == Genre.Funk && g.TrackCount >= 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAvailableGenres_Anonymous_ListsEveryGenreWithIncompatibilities()
|
||||
{
|
||||
var response = await fixture.CreateAnonymousClient().GetAsync("/genres/available", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<List<AvailableGenreResponse>>(JsonOptions, TestContext.Current.CancellationToken);
|
||||
Assert.Equal(Enum.GetValues<Genre>().Length, body!.Count);
|
||||
Assert.Contains(Genre.Lofi, body.Single(g => g.Genre == Genre.Metal).IncompatibleWith);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Nope")]
|
||||
[InlineData("999")]
|
||||
public async Task GetTracks_UnknownGenre_ReturnsBadRequest(string genre)
|
||||
{
|
||||
var response = await fixture.CreateAnonymousClient().GetAsync($"/tracks?genre={genre}", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTracks_GenreQuery_IsCaseInsensitive()
|
||||
{
|
||||
var title = await SeedTrackAsync(Genre.Blues);
|
||||
|
||||
var response = await fixture.CreateAnonymousClient().GetAsync("/tracks?genre=blues&pageSize=100", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<TracksSearchResponse>(JsonOptions, TestContext.Current.CancellationToken);
|
||||
Assert.Contains(body!.Items, item => item.Track.Title == title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTracks_GenreQuery_FiltersByTag()
|
||||
{
|
||||
var title = await SeedTrackAsync(Genre.Ambient);
|
||||
await SeedTrackAsync(Genre.Techno);
|
||||
|
||||
var response = await fixture.CreateAnonymousClient().GetAsync("/tracks?genre=Ambient&pageSize=100", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<TracksSearchResponse>(JsonOptions, TestContext.Current.CancellationToken);
|
||||
Assert.Contains(body!.Items, item => item.Track.Title == title);
|
||||
Assert.All(body.Items, item => Assert.Contains(Genre.Ambient, item.Track.Tags));
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
@@ -57,5 +57,25 @@ namespace Musify.Application.Tests.Albums
|
||||
Assert.False(result.IsError);
|
||||
Assert.Equal(2, result.Value.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_UncountedListens_AreIgnored()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var countedAlbum = TestEntities.Album(owner.Id, "Counted album");
|
||||
var skippedAlbum = TestEntities.Album(owner.Id, "Skipped album");
|
||||
var countedTrack = TestEntities.Track(owner, "In counted");
|
||||
var skippedTrack = TestEntities.Track(owner, "In skipped");
|
||||
await SeedAsync(
|
||||
owner, countedAlbum, skippedAlbum, countedTrack, skippedTrack,
|
||||
new AlbumHasTrack { AlbumId = countedAlbum.Id, TrackId = countedTrack.Id, TrackNumber = 1 },
|
||||
new AlbumHasTrack { AlbumId = skippedAlbum.Id, TrackId = skippedTrack.Id, TrackNumber = 1 },
|
||||
TestEntities.ListeningHistory(owner.Id, countedTrack.Id, DateTime.UtcNow.AddDays(-1), playedSeconds: 120),
|
||||
TestEntities.ListeningHistory(owner.Id, skippedTrack.Id, DateTime.UtcNow, playedSeconds: 1, isCounted: false));
|
||||
|
||||
var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 10), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(["Counted album"], result.Value.Select(album => album.Title));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
using Musify.Application.Genres;
|
||||
using Musify.Domain.ValueObjects;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.Genres
|
||||
{
|
||||
public sealed class GetAvailableGenresQueryHandlerTests
|
||||
{
|
||||
private static async Task<IReadOnlyList<AvailableGenreResponse>> RunAsync() =>
|
||||
await new GetAvailableGenresQueryHandler().Handle(new GetAvailableGenresQuery(), TestContext.Current.CancellationToken);
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ReturnsEveryGenre()
|
||||
{
|
||||
var result = await RunAsync();
|
||||
|
||||
Assert.Equal(Enum.GetValues<Genre>(), result.Select(r => r.Genre));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ListsIncompatibleGenresSymmetrically()
|
||||
{
|
||||
var result = await RunAsync();
|
||||
|
||||
var metal = result.Single(r => r.Genre == Genre.Metal);
|
||||
var lofi = result.Single(r => r.Genre == Genre.Lofi);
|
||||
Assert.Contains(Genre.Lofi, metal.IncompatibleWith);
|
||||
Assert.Contains(Genre.Metal, lofi.IncompatibleWith);
|
||||
Assert.Empty(result.Single(r => r.Genre == Genre.Pop).IncompatibleWith);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using Musify.Application.Genres;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.Genres
|
||||
{
|
||||
public sealed class GetGenresQueryHandlerTests : HandlerTestBase
|
||||
{
|
||||
private GetGenresQueryHandler CreateHandler() => new(Database);
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NoTracks_ReturnsEmpty()
|
||||
{
|
||||
var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ReturnsOnlyGenresInUse_MostPopulatedFirst()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var rockA = TestEntities.Track(owner, "Rock A", tags: [Genre.Rock]);
|
||||
var rockB = TestEntities.Track(owner, "Rock B", tags: [Genre.Rock, Genre.Indie]);
|
||||
var jazz = TestEntities.Track(owner, "Jazz", tags: [Genre.Jazz]);
|
||||
await SeedAsync(
|
||||
owner, rockA, rockB, jazz,
|
||||
new UserHasTrack { UserId = owner.Id, TrackId = rockA.Id },
|
||||
new UserHasTrack { UserId = owner.Id, TrackId = rockB.Id },
|
||||
new UserHasTrack { UserId = owner.Id, TrackId = jazz.Id });
|
||||
|
||||
var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(
|
||||
[(Genre.Rock, 2), (Genre.Jazz, 1), (Genre.Indie, 1)],
|
||||
result.Select(r => (r.Genre, r.TrackCount)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_TrackInSeveralLibraries_IsCountedOnce()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var other = TestEntities.User(id: 2, name: "other-user");
|
||||
var track = TestEntities.Track(owner, tags: [Genre.Pop]);
|
||||
await SeedAsync(
|
||||
owner, other, track,
|
||||
new UserHasTrack { UserId = owner.Id, TrackId = track.Id },
|
||||
new UserHasTrack { UserId = other.Id, TrackId = track.Id });
|
||||
|
||||
var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(1, Assert.Single(result).TrackCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_TrackNotInAnyLibrary_IsIgnored()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var track = TestEntities.Track(owner, tags: [Genre.Metal]);
|
||||
await SeedAsync(owner, track);
|
||||
|
||||
var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
@@ -72,5 +72,21 @@ namespace Musify.Application.Tests.Mixes
|
||||
.ToListAsync(TestContext.Current.CancellationToken);
|
||||
Assert.All(discoveryItems, item => Assert.Equal(unheardTrack.Id, item.TrackId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_TrackWithOnlyUncountedListens_IsTreatedAsUnheard()
|
||||
{
|
||||
var user = TestEntities.User();
|
||||
var skipped = TestEntities.Track(user, "Only skipped");
|
||||
await SeedAsync(
|
||||
user, skipped,
|
||||
new UserHasTrack { UserId = user.Id, TrackId = skipped.Id },
|
||||
TestEntities.ListeningHistory(user.Id, skipped.Id, playedSeconds: 3, isCounted: false));
|
||||
|
||||
await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken);
|
||||
|
||||
var mixes = await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken);
|
||||
Assert.Contains(mixes, mix => mix.Title == "Descubrimiento");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
using ErrorOr;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Application.PlayLists;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Musify.Domain.Entities;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.PlayLists
|
||||
{
|
||||
public sealed class AddAlbumToPlayListCommandHandlerTests : HandlerTestBase
|
||||
{
|
||||
private AddAlbumToPlayListCommandHandler CreateHandler() => new(Database, NoOpLogger<AddAlbumToPlayListCommandHandler>());
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_AddsAlbumTracksInAlbumOrder()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var playList = TestEntities.PlayList(owner.Id);
|
||||
var album = TestEntities.Album(owner.Id);
|
||||
var first = TestEntities.Track(owner, "First");
|
||||
var second = TestEntities.Track(owner, "Second");
|
||||
await SeedAsync(
|
||||
owner, playList, album, first, second,
|
||||
new AlbumHasTrack { AlbumId = album.Id, TrackId = second.Id, TrackNumber = 2 },
|
||||
new AlbumHasTrack { AlbumId = album.Id, TrackId = first.Id, TrackNumber = 1 });
|
||||
|
||||
var result = await CreateHandler().Handle(new AddAlbumToPlayListCommand(owner.Id, playList.Id, album.Id), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Equal(2, result.Value.AddedCount);
|
||||
var links = await Database.PlayListHasTracks.OrderBy(link => link.Position).ToListAsync(TestContext.Current.CancellationToken);
|
||||
Assert.Equal([first.Id, second.Id], links.Select(link => link.TrackId));
|
||||
Assert.Equal([0, 1], links.Select(link => link.Position));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_SkipsTracksAlreadyInPlayList_AndAppendsAfterThem()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var playList = TestEntities.PlayList(owner.Id);
|
||||
var album = TestEntities.Album(owner.Id);
|
||||
var existing = TestEntities.Track(owner, "Existing");
|
||||
var fresh = TestEntities.Track(owner, "Fresh");
|
||||
await SeedAsync(
|
||||
owner, playList, album, existing, fresh,
|
||||
new AlbumHasTrack { AlbumId = album.Id, TrackId = existing.Id, TrackNumber = 1 },
|
||||
new AlbumHasTrack { AlbumId = album.Id, TrackId = fresh.Id, TrackNumber = 2 },
|
||||
new PlayListHasTrack { PlayListId = playList.Id, TrackId = existing.Id, Position = 4 });
|
||||
|
||||
var result = await CreateHandler().Handle(new AddAlbumToPlayListCommand(owner.Id, playList.Id, album.Id), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(1, result.Value.AddedCount);
|
||||
var link = await Database.PlayListHasTracks.SingleAsync(l => l.TrackId == fresh.Id, TestContext.Current.CancellationToken);
|
||||
Assert.Equal(5, link.Position);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_PlayListMissing_ReturnsNotFound()
|
||||
{
|
||||
var result = await CreateHandler().Handle(new AddAlbumToPlayListCommand(1, Guid.NewGuid(), Guid.NewGuid()), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NotOwner_ReturnsUnauthorized()
|
||||
{
|
||||
var owner = TestEntities.User(1, "owner");
|
||||
var stranger = TestEntities.User(2, "stranger");
|
||||
var playList = TestEntities.PlayList(owner.Id);
|
||||
var album = TestEntities.Album(owner.Id);
|
||||
await SeedAsync(owner, stranger, playList, album);
|
||||
|
||||
var result = await CreateHandler().Handle(new AddAlbumToPlayListCommand(stranger.Id, playList.Id, album.Id), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_AlbumMissing_ReturnsNotFound()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var playList = TestEntities.PlayList(owner.Id);
|
||||
await SeedAsync(owner, playList);
|
||||
|
||||
var result = await CreateHandler().Handle(new AddAlbumToPlayListCommand(owner.Id, playList.Id, Guid.NewGuid()), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,17 @@ namespace Musify.Application.Tests.Shared
|
||||
Assert.Equal("The Owner", response.Artist);
|
||||
Assert.Equal(owner.Id, response.OwnerUserId);
|
||||
Assert.Equal(3, response.ListensCount);
|
||||
Assert.False(response.IsExplicit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromEntity_CopiesExplicitFlag()
|
||||
{
|
||||
var track = TestEntities.Track(TestEntities.User(), isExplicit: true);
|
||||
|
||||
var response = TrackApplicationResponse.FromEntity(track, listensCount: 0);
|
||||
|
||||
Assert.True(response.IsExplicit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,8 @@ namespace Musify.Application.Tests.TestSupport
|
||||
TrackPictures? pictures = null,
|
||||
TrackAudio? audio = null,
|
||||
LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active,
|
||||
IEnumerable<Genre>? tags = null)
|
||||
IEnumerable<Genre>? tags = null,
|
||||
bool isExplicit = false)
|
||||
{
|
||||
var track = new Track
|
||||
{
|
||||
@@ -64,6 +65,7 @@ namespace Musify.Application.Tests.TestSupport
|
||||
NormalizedTitle = title.ToUpperInvariant(),
|
||||
DurationSeconds = durationSeconds,
|
||||
LifeCycleStatus = lifeCycleStatus,
|
||||
IsExplicit = isExplicit,
|
||||
OwnerUserId = owner.Id,
|
||||
Owner = owner,
|
||||
Pictures = pictures ?? ProcessedPictures(),
|
||||
|
||||
@@ -58,6 +58,26 @@ namespace Musify.Application.Tests.Tracks
|
||||
Assert.Equal(ProcessingStatus.Pending, stored.Audio.TranscodeStatus);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task Handle_StoresExplicitFlag(bool isExplicit)
|
||||
{
|
||||
var user = TestEntities.User();
|
||||
var (picture, audio) = SeedIntents(user.Id);
|
||||
await SeedAsync(user, picture, audio);
|
||||
|
||||
var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, [Genre.Pop], isExplicit);
|
||||
|
||||
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Equal(isExplicit, result.Value.IsExplicit);
|
||||
var stored = await Database.Tracks.FindAsync([result.Value.Id], TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(stored);
|
||||
Assert.Equal(isExplicit, stored.IsExplicit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NoTags_ReturnsValidationError()
|
||||
{
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Musify.Application.Tracks;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.Tracks
|
||||
{
|
||||
public sealed class DeleteStaleUncountedListensCommandHandlerTests : HandlerTestBase
|
||||
{
|
||||
private DeleteStaleUncountedListensCommandHandler CreateHandler() =>
|
||||
new(Database, NoOpLogger<DeleteStaleUncountedListensCommandHandler>());
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_DeletesOnlyOldUncountedListens()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var track = TestEntities.Track(owner);
|
||||
var oldUncounted = TestEntities.ListeningHistory(owner.Id, track.Id, DateTime.UtcNow.AddDays(-9), playedSeconds: 2, isCounted: false);
|
||||
var oldCounted = TestEntities.ListeningHistory(owner.Id, track.Id, DateTime.UtcNow.AddDays(-9), playedSeconds: 100);
|
||||
var recentUncounted = TestEntities.ListeningHistory(owner.Id, track.Id, DateTime.UtcNow.AddDays(-2), playedSeconds: 2, isCounted: false);
|
||||
await SeedAsync(owner, track, oldUncounted, oldCounted, recentUncounted);
|
||||
|
||||
var deleted = await CreateHandler().Handle(new DeleteStaleUncountedListensCommand(), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(1, deleted);
|
||||
var remaining = await Database.ListeningHistories.Select(l => l.Id).ToListAsync(TestContext.Current.CancellationToken);
|
||||
Assert.DoesNotContain(oldUncounted.Id, remaining);
|
||||
Assert.Contains(oldCounted.Id, remaining);
|
||||
Assert.Contains(recentUncounted.Id, remaining);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,5 +35,21 @@ namespace Musify.Application.Tests.Tracks
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_UncountedListens_AreIgnored()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var counted = TestEntities.Track(owner, "Counted");
|
||||
var skipped = TestEntities.Track(owner, "Skipped");
|
||||
await SeedAsync(
|
||||
owner, counted, skipped,
|
||||
TestEntities.ListeningHistory(owner.Id, counted.Id, DateTime.UtcNow.AddMinutes(-5), playedSeconds: 90),
|
||||
TestEntities.ListeningHistory(owner.Id, skipped.Id, DateTime.UtcNow, playedSeconds: 2, isCounted: false));
|
||||
|
||||
var result = await CreateHandler().Handle(new GetListeningHistoryQuery(owner.Id), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(["Counted"], result.Select(track => track.Title));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,5 +33,21 @@ namespace Musify.Application.Tests.Tracks
|
||||
|
||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ListensCount_OnlyCountsCountedListens()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var track = TestEntities.Track(owner);
|
||||
await SeedAsync(
|
||||
owner, track,
|
||||
TestEntities.ListeningHistory(owner.Id, track.Id, playedSeconds: 100),
|
||||
TestEntities.ListeningHistory(owner.Id, track.Id, playedSeconds: 100),
|
||||
TestEntities.ListeningHistory(owner.Id, track.Id, playedSeconds: 1, isCounted: false));
|
||||
|
||||
var result = await CreateHandler().Handle(new GetTrackByIdQuery(track.Id), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(2, result.Value.ListensCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Musify.Application.Tracks;
|
||||
using Musify.Domain.ValueObjects;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.Tracks
|
||||
@@ -43,5 +44,23 @@ namespace Musify.Application.Tests.Tracks
|
||||
var item = Assert.Single(result.Value.Items);
|
||||
Assert.Equal("Bohemian Rhapsody", item.Track.Title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_GenreFilter_ReturnsOnlyTracksWithThatTag()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var rock = TestEntities.Track(owner, "Loud", tags: [Genre.Rock, Genre.Indie]);
|
||||
var jazz = TestEntities.Track(owner, "Smooth", tags: [Genre.Jazz]);
|
||||
await SeedAsync(
|
||||
owner, rock, jazz,
|
||||
new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = rock.Id },
|
||||
new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = jazz.Id });
|
||||
|
||||
var result = await CreateHandler().Handle(new GetTracksQuery(null, 1, 10, Genre.Indie), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
var item = Assert.Single(result.Value.Items);
|
||||
Assert.Equal("Loud", item.Track.Title);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Application.Albums;
|
||||
using Musify.Application.Mixes;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Musify.Application.Tracks;
|
||||
using Musify.Domain.Entities;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.Tracks
|
||||
{
|
||||
public sealed class UncountedListensTests : HandlerTestBase
|
||||
{
|
||||
[Fact]
|
||||
public async Task ListeningHistory_IgnoresUncountedListens()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var counted = TestEntities.Track(owner, "Counted");
|
||||
var skipped = TestEntities.Track(owner, "Skipped");
|
||||
await SeedAsync(
|
||||
owner, counted, skipped,
|
||||
TestEntities.ListeningHistory(owner.Id, counted.Id, DateTime.UtcNow.AddMinutes(-5), playedSeconds: 90),
|
||||
TestEntities.ListeningHistory(owner.Id, skipped.Id, DateTime.UtcNow, playedSeconds: 2, isCounted: false));
|
||||
|
||||
var result = await new GetListeningHistoryQueryHandler(Database)
|
||||
.Handle(new GetListeningHistoryQuery(owner.Id), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(["Counted"], result.Select(track => track.Title));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RecentlyListenedAlbums_IgnoresUncountedListens()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var countedAlbum = TestEntities.Album(owner.Id, "Counted album");
|
||||
var skippedAlbum = TestEntities.Album(owner.Id, "Skipped album");
|
||||
var countedTrack = TestEntities.Track(owner, "In counted");
|
||||
var skippedTrack = TestEntities.Track(owner, "In skipped");
|
||||
await SeedAsync(
|
||||
owner, countedAlbum, skippedAlbum, countedTrack, skippedTrack,
|
||||
new AlbumHasTrack { AlbumId = countedAlbum.Id, TrackId = countedTrack.Id, TrackNumber = 1 },
|
||||
new AlbumHasTrack { AlbumId = skippedAlbum.Id, TrackId = skippedTrack.Id, TrackNumber = 1 },
|
||||
TestEntities.ListeningHistory(owner.Id, countedTrack.Id, DateTime.UtcNow.AddDays(-1), playedSeconds: 120),
|
||||
TestEntities.ListeningHistory(owner.Id, skippedTrack.Id, DateTime.UtcNow, playedSeconds: 1, isCounted: false));
|
||||
|
||||
var result = await new GetRecentlyListenedAlbumsQueryHandler(Database)
|
||||
.Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 10), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(["Counted album"], result.Value.Select(album => album.Title));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Mixes_TreatTracksWithOnlyUncountedListensAsUnheard()
|
||||
{
|
||||
var user = TestEntities.User();
|
||||
var skipped = TestEntities.Track(user, "Only skipped");
|
||||
await SeedAsync(
|
||||
user, skipped,
|
||||
new UserHasTrack { UserId = user.Id, TrackId = skipped.Id },
|
||||
TestEntities.ListeningHistory(user.Id, skipped.Id, playedSeconds: 3, isCounted: false));
|
||||
|
||||
var handler = new GenerateMixesForUserCommandHandler(
|
||||
Database, TestConfigurations.Mix(), NoOpLogger<GenerateMixesForUserCommandHandler>());
|
||||
await handler.Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken);
|
||||
|
||||
var mixes = await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken);
|
||||
Assert.Contains(mixes, mix => mix.Title == "Descubrimiento");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TrackListensCount_OnlyCountsCountedListens()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var track = TestEntities.Track(owner);
|
||||
await SeedAsync(
|
||||
owner, track,
|
||||
TestEntities.ListeningHistory(owner.Id, track.Id, playedSeconds: 100),
|
||||
TestEntities.ListeningHistory(owner.Id, track.Id, playedSeconds: 100),
|
||||
TestEntities.ListeningHistory(owner.Id, track.Id, playedSeconds: 1, isCounted: false));
|
||||
|
||||
var result = await new GetTrackByIdQueryHandler(Database)
|
||||
.Handle(new GetTrackByIdQuery(track.Id), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(2, result.Value.ListensCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cleanup_DeletesOnlyOldUncountedListens()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var track = TestEntities.Track(owner);
|
||||
var oldUncounted = TestEntities.ListeningHistory(owner.Id, track.Id, DateTime.UtcNow.AddDays(-9), playedSeconds: 2, isCounted: false);
|
||||
var oldCounted = TestEntities.ListeningHistory(owner.Id, track.Id, DateTime.UtcNow.AddDays(-9), playedSeconds: 100);
|
||||
var recentUncounted = TestEntities.ListeningHistory(owner.Id, track.Id, DateTime.UtcNow.AddDays(-2), playedSeconds: 2, isCounted: false);
|
||||
await SeedAsync(owner, track, oldUncounted, oldCounted, recentUncounted);
|
||||
|
||||
var deleted = await new DeleteStaleUncountedListensCommandHandler(Database, NoOpLogger<DeleteStaleUncountedListensCommandHandler>())
|
||||
.Handle(new DeleteStaleUncountedListensCommand(), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(1, deleted);
|
||||
var remaining = await Database.ListeningHistories.Select(l => l.Id).ToListAsync(TestContext.Current.CancellationToken);
|
||||
Assert.DoesNotContain(oldUncounted.Id, remaining);
|
||||
Assert.Contains(oldCounted.Id, remaining);
|
||||
Assert.Contains(recentUncounted.Id, remaining);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using ErrorOr;
|
||||
using Musify.Application.Shared;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Musify.Application.Users;
|
||||
using Musify.Application.Users.Responses;
|
||||
using Musify.Domain.Entities;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.Users
|
||||
{
|
||||
public sealed class GetUserFollowersQueryHandlerTests : HandlerTestBase
|
||||
{
|
||||
private GetUserFollowersQueryHandler CreateHandler() => new(Database);
|
||||
|
||||
private async Task<ErrorOr<PaginatedResponse<UserSummaryResponse>>> Send(long? viewerId, long userId) =>
|
||||
await CreateHandler().Handle(new GetUserFollowersQuery(viewerId, userId, 1, 20), TestContext.Current.CancellationToken);
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_OwnProfile_ReturnsFollowers()
|
||||
{
|
||||
var user = TestEntities.User(1, "user");
|
||||
var follower = TestEntities.User(2, "follower");
|
||||
await SeedAsync(user, follower, new UserFollow { FollowerId = follower.Id, FollowedId = user.Id });
|
||||
|
||||
var result = await Send(user.Id, user.Id);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
var item = Assert.Single(result.Value.Items);
|
||||
Assert.Equal(follower.Id, item.Id);
|
||||
Assert.False(item.IsFollowedByViewer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_MutualFollow_ReturnsFollowersWithViewerFollowState()
|
||||
{
|
||||
var viewer = TestEntities.User(1, "viewer");
|
||||
var user = TestEntities.User(2, "user");
|
||||
var other = TestEntities.User(3, "other");
|
||||
await SeedAsync(
|
||||
viewer, user, other,
|
||||
new UserFollow { FollowerId = viewer.Id, FollowedId = user.Id },
|
||||
new UserFollow { FollowerId = user.Id, FollowedId = viewer.Id },
|
||||
new UserFollow { FollowerId = other.Id, FollowedId = user.Id },
|
||||
new UserFollow { FollowerId = viewer.Id, FollowedId = other.Id });
|
||||
|
||||
var result = await Send(viewer.Id, user.Id);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Equal(2, result.Value.TotalItemCount);
|
||||
Assert.All(result.Value.Items, item => Assert.Equal(item.Id == other.Id, item.IsFollowedByViewer));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ViewerFollowsButNotFollowedBack_ReturnsForbidden()
|
||||
{
|
||||
var viewer = TestEntities.User(1, "viewer");
|
||||
var user = TestEntities.User(2, "user");
|
||||
await SeedAsync(viewer, user, new UserFollow { FollowerId = viewer.Id, FollowedId = user.Id });
|
||||
|
||||
var result = await Send(viewer.Id, user.Id);
|
||||
|
||||
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_FollowedByUserButNotFollowingBack_ReturnsForbidden()
|
||||
{
|
||||
var viewer = TestEntities.User(1, "viewer");
|
||||
var user = TestEntities.User(2, "user");
|
||||
await SeedAsync(viewer, user, new UserFollow { FollowerId = user.Id, FollowedId = viewer.Id });
|
||||
|
||||
var result = await Send(viewer.Id, user.Id);
|
||||
|
||||
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_Anonymous_ReturnsForbidden()
|
||||
{
|
||||
await SeedAsync(TestEntities.User(1, "user"));
|
||||
|
||||
var result = await Send(null, 1);
|
||||
|
||||
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_UserMissing_ReturnsNotFound()
|
||||
{
|
||||
var result = await Send(1, 404);
|
||||
|
||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using ErrorOr;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Musify.Application.Users;
|
||||
using Musify.Domain.Entities;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.Users
|
||||
{
|
||||
public sealed class GetUserFollowingQueryHandlerTests : HandlerTestBase
|
||||
{
|
||||
private GetUserFollowingQueryHandler CreateHandler() => new(Database);
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_Anonymous_ReturnsFollowedUsers()
|
||||
{
|
||||
var user = TestEntities.User(1, "user");
|
||||
var followedA = TestEntities.User(2, "followedA");
|
||||
var followedB = TestEntities.User(3, "followedB");
|
||||
await SeedAsync(
|
||||
user, followedA, followedB,
|
||||
new UserFollow { FollowerId = user.Id, FollowedId = followedA.Id },
|
||||
new UserFollow { FollowerId = user.Id, FollowedId = followedB.Id });
|
||||
|
||||
var result = await CreateHandler().Handle(new GetUserFollowingQuery(null, user.Id, 1, 20), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Equal(2, result.Value.TotalItemCount);
|
||||
Assert.All(result.Value.Items, item => Assert.False(item.IsFollowedByViewer));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_Viewer_MarksUsersTheViewerFollows()
|
||||
{
|
||||
var viewer = TestEntities.User(1, "viewer");
|
||||
var user = TestEntities.User(2, "user");
|
||||
var followedA = TestEntities.User(3, "followedA");
|
||||
var followedB = TestEntities.User(4, "followedB");
|
||||
await SeedAsync(
|
||||
viewer, user, followedA, followedB,
|
||||
new UserFollow { FollowerId = user.Id, FollowedId = followedA.Id },
|
||||
new UserFollow { FollowerId = user.Id, FollowedId = followedB.Id },
|
||||
new UserFollow { FollowerId = viewer.Id, FollowedId = followedA.Id });
|
||||
|
||||
var result = await CreateHandler().Handle(new GetUserFollowingQuery(viewer.Id, user.Id, 1, 20), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.All(result.Value.Items, item => Assert.Equal(item.Id == followedA.Id, item.IsFollowedByViewer));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_Pagination_ReturnsRequestedPage()
|
||||
{
|
||||
await SeedAsync(
|
||||
TestEntities.User(1, "user"), TestEntities.User(2, "a"), TestEntities.User(3, "b"), TestEntities.User(4, "c"),
|
||||
new UserFollow { FollowerId = 1, FollowedId = 2 },
|
||||
new UserFollow { FollowerId = 1, FollowedId = 3 },
|
||||
new UserFollow { FollowerId = 1, FollowedId = 4 });
|
||||
|
||||
var result = await CreateHandler().Handle(new GetUserFollowingQuery(null, 1, 2, 2), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Single(result.Value.Items);
|
||||
Assert.Equal(3, result.Value.TotalItemCount);
|
||||
Assert.False(result.Value.HasNextPage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_UserMissing_ReturnsNotFound()
|
||||
{
|
||||
var result = await CreateHandler().Handle(new GetUserFollowingQuery(null, 404, 1, 20), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,46 @@ namespace Musify.Application.Tests.Users
|
||||
Assert.Equal(1, result.Value.FollowingCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_MutualFollow_ViewerCanSeeFollowersAndIsFollowing()
|
||||
{
|
||||
var viewer = TestEntities.User(1, "viewer");
|
||||
var user = TestEntities.User(2, "user");
|
||||
await SeedAsync(
|
||||
viewer, user,
|
||||
new UserFollow { FollowerId = viewer.Id, FollowedId = user.Id },
|
||||
new UserFollow { FollowerId = user.Id, FollowedId = viewer.Id });
|
||||
|
||||
var result = await CreateHandler().Handle(new GetUserProfileQuery(user.Id, viewer.Id), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(result.Value.IsFollowing);
|
||||
Assert.True(result.Value.CanViewFollowers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_OneWayFollow_ViewerCannotSeeFollowers()
|
||||
{
|
||||
var viewer = TestEntities.User(1, "viewer");
|
||||
var user = TestEntities.User(2, "user");
|
||||
await SeedAsync(viewer, user, new UserFollow { FollowerId = viewer.Id, FollowedId = user.Id });
|
||||
|
||||
var result = await CreateHandler().Handle(new GetUserProfileQuery(user.Id, viewer.Id), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(result.Value.IsFollowing);
|
||||
Assert.False(result.Value.CanViewFollowers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_OwnProfile_CanSeeFollowersButIsNotFollowing()
|
||||
{
|
||||
await SeedAsync(TestEntities.User(1, "user"));
|
||||
|
||||
var result = await CreateHandler().Handle(new GetUserProfileQuery(1, 1), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.Value.IsFollowing);
|
||||
Assert.True(result.Value.CanViewFollowers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_UserMissing_ReturnsNotFound()
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Musify.Application.Users;
|
||||
using Musify.Domain.Entities;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.Users
|
||||
@@ -30,5 +31,18 @@ namespace Musify.Application.Tests.Users
|
||||
var user = Assert.Single(result.Value.Items);
|
||||
Assert.Equal(1, user.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_Viewer_MarksFollowedUsers()
|
||||
{
|
||||
await SeedAsync(
|
||||
TestEntities.User(1, "viewer"), TestEntities.User(2, "alice"), TestEntities.User(3, "bob"),
|
||||
new UserFollow { FollowerId = 1, FollowedId = 2 });
|
||||
|
||||
var result = await CreateHandler().Handle(new GetUsersQuery(PageNumber: 1, PageSize: 10, UsernameSearch: null, ViewerId: 1), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.All(result.Value.Items, item => Assert.Equal(item.Id == 2, item.IsFollowedByViewer));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
@@ -1,3 +1,4 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Infrastructure.Tests.TestSupport;
|
||||
using Xunit;
|
||||
@@ -43,6 +44,11 @@ namespace Musify.Infrastructure.Tests.Persistence
|
||||
Assert.Equal(originalCreatedAt, user.CreatedAt);
|
||||
Assert.True(user.UpdatedAt > originalCreatedAt,
|
||||
$"original-created-at={originalCreatedAt:O}, final-updated-at={user.UpdatedAt:O}");
|
||||
|
||||
await using var reader = fixture.CreateDatabase();
|
||||
var stored = await reader.Users.SingleAsync(u => u.Id == user.Id, TestContext.Current.CancellationToken);
|
||||
Assert.True(stored.UpdatedAt > stored.CreatedAt,
|
||||
$"stored-created-at={stored.CreatedAt:O}, stored-updated-at={stored.UpdatedAt:O}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-11
@@ -1,11 +1,14 @@
|
||||
# Production configuration for deploy/compose.yml + compose.prod.yml.
|
||||
# Copy to deploy/.env.prod, then:
|
||||
#
|
||||
# 1. replace every `example.com` with your domain,
|
||||
# 1. replace every `*.example.com` with your actual domains (each service
|
||||
# below has its own, independent domain — they don't need to share a
|
||||
# root, e.g. for DNS providers that don't allow multi-level
|
||||
# subdomains),
|
||||
# 2. replace every CHANGE_ME with a strong random value
|
||||
# (`openssl rand -hex 24`),
|
||||
# 3. point these DNS records at the server, all proxied/TLS-terminated the
|
||||
# same way: example.com, www, api, auth, stream, s3.
|
||||
# 3. point each domain below at the server and add its vhost to the shared
|
||||
# edge (Infrastructure's edge/conf.d).
|
||||
#
|
||||
# Requires ../../../Infrastructure running first, on the same Docker host,
|
||||
# with its own `infra-net` network already created. See
|
||||
@@ -14,12 +17,13 @@
|
||||
|
||||
DOTNET_ENVIRONMENT=Production
|
||||
|
||||
# --- Domain ------------------------------------------------------------------
|
||||
# ROOT_DOMAIN drives the nginx vhosts; the URLs below must agree with it.
|
||||
ROOT_DOMAIN=example.com
|
||||
PUBLIC_WEB_URL=https://example.com
|
||||
PUBLIC_API_URL=https://api.example.com
|
||||
PUBLIC_STREAM_URL=https://stream.example.com
|
||||
# --- Domains -------------------------------------------------------------
|
||||
# Public URLs. They must match the vhosts of the shared edge nginx
|
||||
# (Infrastructure's edge/conf.d), which is what serves them; the domains are
|
||||
# independent, they don't need to share a root.
|
||||
PUBLIC_WEB_URL=https://musify.example.com
|
||||
PUBLIC_API_URL=https://musifyapi.example.com
|
||||
PUBLIC_STREAM_URL=https://musifystream.example.com
|
||||
PUBLIC_AUTH_URL=https://auth.example.com
|
||||
S3_PUBLIC_URL=https://s3.example.com
|
||||
|
||||
@@ -63,5 +67,3 @@ AUTH_ALLOW_INSECURE_HTTP=false
|
||||
|
||||
# --- Misc --------------------------------------------------------------------
|
||||
AUTH_REQUIRE_HTTPS=true
|
||||
# Largest request body nginx accepts (audio uploads go through the web player).
|
||||
MAX_UPLOAD_SIZE=1024m
|
||||
|
||||
+53
-29
@@ -13,7 +13,7 @@ wherever you actually cloned it.
|
||||
|---|---|
|
||||
| `compose.yml` | Musify's own services, including the one-shot bootstrap jobs. No published ports, no domains, no environment-specific values. |
|
||||
| `compose.dev.yml` | Development overlay: publishes Musify's ports to the host. |
|
||||
| `compose.prod.yml` | Production overlay: adds the nginx edge and joins Infrastructure's `infra-net` network. |
|
||||
| `compose.prod.yml` | Production overlay: joins Infrastructure's `infra-net` network. |
|
||||
| `.env` / `.env.prod` | The values compose interpolates. Not versioned; templates in `.env.example` / `.env.prod.example`. |
|
||||
|
||||
The four applications (`api`, `worker`, `gateway`, `web-player`) sit behind
|
||||
@@ -113,26 +113,26 @@ SeaweedFS is done from the `Infrastructure` repo instead.
|
||||
|
||||
## Production
|
||||
|
||||
On a server with a domain:
|
||||
Musify doesn't run its own nginx: the shared edge in Infrastructure
|
||||
(`edge/`) owns host ports 80/443, terminates TLS for every project's domains
|
||||
and proxies to Musify's containers over `infra-net`. On a server with
|
||||
domains:
|
||||
|
||||
1. Bring up the `Infrastructure` repo first, with its own `.env.prod` filled
|
||||
in. It creates the `infra-net` network this stack joins.
|
||||
2. From this repository's root:
|
||||
2. Bring up its `edge/` (see Infrastructure's README) and add Musify's
|
||||
vhosts and certificate there (`edge/conf.d/musify.conf`). The five
|
||||
public hostnames (web, api, stream, auth, s3) are independent: they
|
||||
don't need to share a root domain.
|
||||
3. From this repository's root:
|
||||
|
||||
```sh
|
||||
cp deploy/.env.prod.example deploy/.env.prod
|
||||
```
|
||||
|
||||
Fill it in:
|
||||
|
||||
1. Replace every `example.com` with your domain and every `CHANGE_ME` with a
|
||||
random value (`openssl rand -hex 24`). `chmod 600 deploy/.env.prod`.
|
||||
2. Drop **`deploy/nginx/certs/origin.pem` and `origin.key`**, the origin
|
||||
certificate nginx serves. The stack assumes Cloudflare in *Full (strict)*
|
||||
mode in front of it, so a Cloudflare Origin Certificate is enough; any
|
||||
certificate valid for the five hostnames works.
|
||||
3. Point `example.com`, `www`, `api`, `auth`, `stream` and `s3` at the server,
|
||||
proxied and TLS-terminated the same way.
|
||||
Replace every `*.example.com` with your domains (they must match the
|
||||
edge's vhosts) and every `CHANGE_ME` with a random value
|
||||
(`openssl rand -hex 24`). `chmod 600 deploy/.env.prod`.
|
||||
|
||||
Then:
|
||||
|
||||
@@ -141,14 +141,45 @@ docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compo
|
||||
docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compose.prod.yml --profile apps up -d --build
|
||||
```
|
||||
|
||||
The first command starts Musify's bootstrap jobs and the nginx edge; the
|
||||
second builds and starts the applications, already wired to the OIDC client
|
||||
ids the first one produced. Both are idempotent, so running them again
|
||||
deploys a new version (`... --profile apps up -d --build` alone is enough for
|
||||
an update once the stack already exists).
|
||||
The first command starts Musify's bootstrap jobs (`zitadel-init` and
|
||||
`seaweedfs-init` reach Zitadel/SeaweedFS through the public `auth.`/`s3.`
|
||||
URLs, so the edge has to be serving them by then); the second builds and
|
||||
starts the applications, already wired to the OIDC client ids the first one
|
||||
produced. Both are idempotent, so running them again deploys a new version
|
||||
(`... --profile apps up -d --build` alone is enough for an update once the
|
||||
stack already exists).
|
||||
|
||||
Only nginx publishes ports (80/443). Every other Musify service, and all of
|
||||
Infrastructure's, is reachable only on the internal `infra-net` network.
|
||||
Nothing in this stack publishes a port; everything is reachable only on the
|
||||
internal `infra-net` network, through the edge.
|
||||
|
||||
### Continuous deployment
|
||||
|
||||
`.gitea/workflows/ci-cd.yml` runs the backend and web-player checks on every
|
||||
push/PR, and on a successful push to `master` deploys automatically. The
|
||||
deploy job runs on `gitea-runner`, the self-hosted Gitea Actions runner on
|
||||
the production host (`~/gitea/docker-compose.yml`), configured by
|
||||
`~/gitea/runner-data/config.yaml` (`CONFIG_FILE`): job containers are pinned
|
||||
to the `gitea_gitea` network so `actions/checkout` can resolve `gitea`,
|
||||
`ubuntu-latest` is `node:22` (JS actions need Node inside the job
|
||||
container), and a `production:host` label runs steps natively.
|
||||
|
||||
Native mode means the label's steps execute inside the runner container
|
||||
itself rather than a fresh one. That container has `/var/run/docker.sock`
|
||||
mounted, so `docker`/`docker compose` there still control the host's real
|
||||
Docker daemon (sibling containers, "Docker outside of Docker"). Its compose
|
||||
service also bind-mounts `/home/ubuntu/deploys` into itself at the *same*
|
||||
path, which matters because compose's own relative bind mounts (`./keys`)
|
||||
are resolved by the host daemon against that literal path; mounting it
|
||||
anywhere else would silently bind the wrong (empty) directory.
|
||||
|
||||
`DEPLOY_PATH` (hardcoded in the workflow as `/home/ubuntu/deploys/musify`)
|
||||
is a persistent clone on that host, reused instead of a fresh checkout each
|
||||
run since `deploy/.env.prod` is untracked and only lives there.
|
||||
`/home/ubuntu/deploys/SharedServices` is the sibling clone of the
|
||||
`Infrastructure` repo (shared services + `edge/`), already running; its
|
||||
`.env.prod` shares the Postgres/RabbitMQ/S3 credentials with Musify's own
|
||||
`deploy/.env.prod` by design (see that repo's README). The edge is deployed
|
||||
from that repo, not from this pipeline.
|
||||
|
||||
---
|
||||
|
||||
@@ -159,13 +190,9 @@ deploy/
|
||||
├─ compose.yml # Musify's own services: wait-for-postgres, keys-init,
|
||||
│ # migrate, seaweedfs-init, zitadel-init, api/worker/gateway/web-player
|
||||
├─ compose.dev.yml # dev overlay: host ports
|
||||
├─ compose.prod.yml # prod overlay: nginx edge, joins infra-net
|
||||
├─ compose.prod.yml # prod overlay: joins infra-net
|
||||
├─ .env.example / .env.prod.example
|
||||
├─ keys/ # RS256 stream-ticket key pair (generated)
|
||||
└─ nginx/
|
||||
├─ templates/default.conf.template # vhosts, rendered with the domain by envsubst
|
||||
├─ snippets/proxy.conf # proxy headers shared by every vhost
|
||||
└─ certs/ # origin.pem + origin.key (not versioned)
|
||||
└─ keys/ # RS256 stream-ticket key pair (generated)
|
||||
```
|
||||
|
||||
The application images are built from `backend/Musify.*.Dockerfile` (build
|
||||
@@ -180,9 +207,6 @@ and `web-player/Dockerfile`.
|
||||
the comments in `.env.example`).
|
||||
- **Zitadel** runs `start-from-init`, which is idempotent: the same command
|
||||
works on an empty volume and on an existing one.
|
||||
- **`AppSettings*.json` are PascalCase** in `Musify.Api` and `Musify.Worker`;
|
||||
on case-sensitive Linux the host looks for `appsettings*.json`, so their
|
||||
Dockerfiles create lowercase copies at publish time.
|
||||
- **Re-running `zitadel-init`, `seaweedfs-init` or `migrate`** is safe: they
|
||||
reuse the existing project/apps/bucket by name, and `migrate` is a normal
|
||||
`dotnet ef database update`, a no-op once the schema is current.
|
||||
|
||||
+5
-34
@@ -1,11 +1,10 @@
|
||||
name: musify-prod
|
||||
|
||||
# Production overlay: adds the nginx edge and joins Infrastructure's shared
|
||||
# `infra-net` network so this project's containers can reach Postgres,
|
||||
# Zitadel, SeaweedFS, RabbitMQ and Jaeger by container name. None of them
|
||||
# run as part of this compose project anymore (see
|
||||
# ../../../Infrastructure/README.md, which must already be up). Nothing but
|
||||
# nginx publishes a port; every other service is reachable only on infra-net.
|
||||
# Production overlay: joins Infrastructure's shared `infra-net` network so
|
||||
# this project's containers can reach Postgres, Zitadel, SeaweedFS, RabbitMQ
|
||||
# and Jaeger by container name, and so the shared edge nginx (Infrastructure's
|
||||
# `edge/`, which owns host ports 80/443 and terminates TLS for every domain)
|
||||
# can reach api, gateway and web-player. Nothing here publishes a port.
|
||||
#
|
||||
# docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compose.prod.yml up -d
|
||||
# docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compose.prod.yml --profile apps up -d --build
|
||||
@@ -20,31 +19,3 @@ services:
|
||||
zitadel-init:
|
||||
volumes:
|
||||
- ./.env.prod:/workspace/.env
|
||||
|
||||
# Every vhost proxies through `set $upstream ...; proxy_pass $upstream;`
|
||||
# with the embedded Docker DNS resolver (see nginx/templates), so nginx
|
||||
# does not need api/gateway/web-player to exist at its own startup, only
|
||||
# at request time. That is what lets it start in the FIRST command
|
||||
# (profile-gated services aren't part of that invocation yet) and still be
|
||||
# reachable at the auth domain for zitadel-init to provision through.
|
||||
nginx:
|
||||
image: nginx:1.27-alpine
|
||||
container_name: musify-nginx
|
||||
environment:
|
||||
# The entrypoint renders /etc/nginx/templates/*.template with envsubst.
|
||||
# The filter keeps it from eating nginx's own $host / $remote_addr.
|
||||
MUSIFY_ROOT_DOMAIN: ${ROOT_DOMAIN}
|
||||
MUSIFY_MAX_UPLOAD_SIZE: ${MAX_UPLOAD_SIZE:-1024m}
|
||||
NGINX_ENVSUBST_FILTER: ^MUSIFY_
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./nginx/templates:/etc/nginx/templates:ro
|
||||
- ./nginx/snippets:/etc/nginx/snippets:ro
|
||||
- ./nginx/certs:/etc/nginx/certs:ro
|
||||
- nginx_cache:/var/cache/nginx
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
nginx_cache:
|
||||
|
||||
+5
-1
@@ -19,7 +19,7 @@
|
||||
# file, so the apps start with real values. See deploy/README.md.
|
||||
|
||||
# Backend settings that are identical for the API and the Worker. ASP.NET Core
|
||||
# maps `__` to `:`, so these override the matching AppSettings.json sections.
|
||||
# maps `__` to `:`, so these override the matching appsettings.json sections.
|
||||
# POSTGRES_HOST / RABBITMQ_HOST / JAEGER_HOST differ between environments
|
||||
# (host.docker.internal in dev, the container name over infra-net in prod,
|
||||
# see .env.example / .env.prod.example), same as PUBLIC_AUTH_URL/S3_PUBLIC_URL
|
||||
@@ -381,6 +381,10 @@ services:
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 3000
|
||||
# adapter-node rejects request bodies over 512K by default; audio
|
||||
# uploads are posted to /upload. Keep in line with the edge's
|
||||
# client_max_body_size.
|
||||
BODY_SIZE_LIMIT: 1G
|
||||
ORIGIN: ${PUBLIC_WEB_URL}
|
||||
ZITADEL_ISSUER: ${PUBLIC_AUTH_URL}
|
||||
ZITADEL_CLIENT_ID: ${WEB_CLIENT_ID}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
# Shared proxy settings for every Musify vhost.
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
@@ -1,119 +0,0 @@
|
||||
# Musify edge. The nginx entrypoint renders this with envsubst into
|
||||
# /etc/nginx/conf.d/default.conf (replacing the image's stock file) using the
|
||||
# MUSIFY_* variables set in compose.prod.yml.
|
||||
#
|
||||
# Cloudflare (Full strict) terminates the public edge; this box only needs to
|
||||
# serve the Cloudflare Origin Certificate in nginx/certs/. Each vhost proxies to
|
||||
# a compose service by name through the embedded Docker DNS resolver, so nginx
|
||||
# re-resolves it instead of caching the IP of a container that may be recreated.
|
||||
|
||||
resolver 127.0.0.11 valid=10s;
|
||||
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name ${MUSIFY_ROOT_DOMAIN} www.${MUSIFY_ROOT_DOMAIN}
|
||||
api.${MUSIFY_ROOT_DOMAIN} auth.${MUSIFY_ROOT_DOMAIN}
|
||||
stream.${MUSIFY_ROOT_DOMAIN} s3.${MUSIFY_ROOT_DOMAIN};
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
# Web player (SvelteKit, adapter-node). Uploads are posted to its own form
|
||||
# actions, so it needs the large body size too.
|
||||
server {
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
server_name ${MUSIFY_ROOT_DOMAIN};
|
||||
client_max_body_size ${MUSIFY_MAX_UPLOAD_SIZE};
|
||||
|
||||
ssl_certificate /etc/nginx/certs/origin.pem;
|
||||
ssl_certificate_key /etc/nginx/certs/origin.key;
|
||||
|
||||
location / {
|
||||
set $upstream http://web-player:3000;
|
||||
proxy_pass $upstream;
|
||||
include /etc/nginx/snippets/proxy.conf;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
server_name www.${MUSIFY_ROOT_DOMAIN};
|
||||
|
||||
ssl_certificate /etc/nginx/certs/origin.pem;
|
||||
ssl_certificate_key /etc/nginx/certs/origin.key;
|
||||
|
||||
return 301 https://${MUSIFY_ROOT_DOMAIN}$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
server_name api.${MUSIFY_ROOT_DOMAIN};
|
||||
|
||||
ssl_certificate /etc/nginx/certs/origin.pem;
|
||||
ssl_certificate_key /etc/nginx/certs/origin.key;
|
||||
|
||||
location / {
|
||||
set $upstream http://api:5111;
|
||||
proxy_pass $upstream;
|
||||
include /etc/nginx/snippets/proxy.conf;
|
||||
}
|
||||
}
|
||||
|
||||
# Audio bytes. Buffering off keeps range requests (seeking) responsive.
|
||||
server {
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
server_name stream.${MUSIFY_ROOT_DOMAIN};
|
||||
|
||||
ssl_certificate /etc/nginx/certs/origin.pem;
|
||||
ssl_certificate_key /etc/nginx/certs/origin.key;
|
||||
|
||||
location / {
|
||||
set $upstream http://gateway:8081;
|
||||
proxy_pass $upstream;
|
||||
include /etc/nginx/snippets/proxy.conf;
|
||||
proxy_buffering off;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
server_name auth.${MUSIFY_ROOT_DOMAIN};
|
||||
|
||||
ssl_certificate /etc/nginx/certs/origin.pem;
|
||||
ssl_certificate_key /etc/nginx/certs/origin.key;
|
||||
|
||||
location / {
|
||||
set $upstream http://zitadel:8080;
|
||||
proxy_pass $upstream;
|
||||
include /etc/nginx/snippets/proxy.conf;
|
||||
grpc_set_header Host $host;
|
||||
}
|
||||
}
|
||||
|
||||
# Presigned S3 PUT/GET straight from the browser.
|
||||
server {
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
server_name s3.${MUSIFY_ROOT_DOMAIN};
|
||||
client_max_body_size ${MUSIFY_MAX_UPLOAD_SIZE};
|
||||
|
||||
ssl_certificate /etc/nginx/certs/origin.pem;
|
||||
ssl_certificate_key /etc/nginx/certs/origin.key;
|
||||
|
||||
location / {
|
||||
set $upstream http://seaweedfs:8333;
|
||||
proxy_pass $upstream;
|
||||
include /etc/nginx/snippets/proxy.conf;
|
||||
proxy_request_buffering off;
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -134,10 +134,10 @@ omits the `<Error>` list when there are no errors, unlike AWS S3), and
|
||||
(load → modify → save) because it ran before EF had detected the pending
|
||||
changes.
|
||||
|
||||
## Config (AppSettings)
|
||||
## Config (appsettings)
|
||||
|
||||
Convention: `AppSettings.json` is the template, with empty or neutral
|
||||
values; `AppSettings.Development.json` has the real dev values; in Docker
|
||||
Convention: `appsettings.json` is the template, with empty or neutral
|
||||
values; `appsettings.Development.json` has the real dev values; in Docker
|
||||
everything is overridden with environment variables (`Section__Key`) from
|
||||
`deploy/compose.yml`. To run `Musify.Api` from the IDE, the `Authentication`
|
||||
values go into user-secrets (not the repo). Copy them from `deploy/.env`
|
||||
|
||||
@@ -36,7 +36,7 @@ Grouped by area:
|
||||
|
||||
## What the Worker needs configured
|
||||
|
||||
The Worker registers: storage (S3), transcoder (ffmpeg), pictures, the DB, the MassTransit consumers, and the upload-intent jobs. Its `AppSettings` needs the right sections: `Bucket` (= `webapi-storage`, same as the API), `PlayList`, `Track`, `MassTransit`, `AudioTranscoder`, `UploadIntent`, `Workers` (temp directory), `InfrastructureStorage`, `Database`.
|
||||
The Worker registers: storage (S3), transcoder (ffmpeg), pictures, the DB, the MassTransit consumers, and the upload-intent jobs. Its `appsettings` needs the right sections: `Bucket` (= `webapi-storage`, same as the API), `PlayList`, `Track`, `MassTransit`, `AudioTranscoder`, `UploadIntent`, `Workers` (temp directory), `InfrastructureStorage`, `Database`.
|
||||
|
||||
## Background jobs
|
||||
|
||||
|
||||
@@ -11,8 +11,9 @@ ZITADEL_CLIENT_SECRET=your-web-app-client-secret
|
||||
AUTH_REDIRECT_URI=http://localhost:5173/auth/callback
|
||||
AUTH_POST_LOGOUT_URI=http://localhost:5173/
|
||||
|
||||
# Secret used to derive the session cookie encryption key (any long random string)
|
||||
SESSION_SECRET=change-me-to-a-long-random-string
|
||||
# Secret used to derive the session cookie encryption key. Required, at least
|
||||
# 32 characters (e.g. `openssl rand -base64 48`).
|
||||
SESSION_SECRET=change-me-to-a-long-random-string-of-32-chars-or-more
|
||||
|
||||
# Backend API base URL
|
||||
API_BASE_URL=http://localhost:5111
|
||||
|
||||
@@ -9,3 +9,5 @@ package
|
||||
package-lock.json
|
||||
src/lib/api/schema.d.ts
|
||||
static
|
||||
.agents
|
||||
skills-lock.json
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"endOfLine": "auto",
|
||||
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
|
||||
"tailwindStylesheet": "./src/routes/layout.css",
|
||||
"overrides": [
|
||||
|
||||
+86
-34
@@ -1,61 +1,113 @@
|
||||
# Musify — web-player
|
||||
# Musify: web-player
|
||||
|
||||
Web pública de Musify (carpeta `web-player/`). Para diseño y recetas de UI usa el skill **`musify-web`**.
|
||||
Musify's public web app (`web-player/` folder). Design and UI recipes live in this file and in `src/routes/layout.css`.
|
||||
|
||||
## Stack
|
||||
|
||||
SvelteKit 2 (`adapter-node`) · Svelte 5 **runes** · Tailwind 4 · TypeScript strict · auth OIDC Zitadel (`openid-client` + `jose`).
|
||||
SvelteKit 2 (`adapter-node`), Svelte 5 **runes**, Tailwind 4, TypeScript strict, Zitadel OIDC auth (`openid-client` + `jose`).
|
||||
|
||||
No hay `svelte.config.js` (config inline en `vite.config.ts`) ni `tailwind.config.js` (config en `src/routes/layout.css`).
|
||||
There's no `svelte.config.js` (config is inline in `vite.config.ts`) and no `tailwind.config.js` (config lives in `src/routes/layout.css`).
|
||||
|
||||
## Comandos
|
||||
## Commands
|
||||
|
||||
```sh
|
||||
npm run dev # servidor de desarrollo (puerto 5173)
|
||||
npm run dev # dev server (port 5173)
|
||||
npm run build
|
||||
npm run check # svelte-check — debe salir 0 errores antes de cerrar un cambio
|
||||
npm run check # svelte-check, must exit 0 errors before closing a change
|
||||
npm run format # prettier --write .
|
||||
npm run lint # prettier --check . && eslint . && npm run lint:tokens
|
||||
npm run lint:tokens # scripts/check-tokens.mjs — guardrail del sistema de diseño (ver abajo)
|
||||
npm run test # vitest run (tests live next to the file they test)
|
||||
npm run lint # prettier --check . && eslint . && npm run lint:tokens && npm run test
|
||||
npm run lint:tokens # scripts/check-tokens.mjs, the design system guardrail (see below)
|
||||
```
|
||||
|
||||
## Reglas
|
||||
## Rules
|
||||
|
||||
- **Sin comentarios** en el código salvo que sean imprescindibles.
|
||||
- `npm run lint` y `npm run check` deben salir limpios antes de cerrar un cambio.
|
||||
- **Svelte 5 runes siempre:** `$props()` con `interface Props`, `$state`, `$derived`, `$effect`. Nada de `export let` ni `$:`. `{#each}` siempre con clave.
|
||||
- **Server-only bajo `$lib/server/`**; nunca importado desde cliente.
|
||||
- Datos SSR vía `load` en `+page.server.ts`/`+layout.server.ts` → prop `data`.
|
||||
- Enlaces a endpoints `+server.ts` (login/logout) llevan `data-sveltekit-reload`.
|
||||
- Reutiliza los tokens/recetas del sistema de diseño; extrae a `$lib/components/ui/` lo que se repita.
|
||||
- **No comments** in code unless essential.
|
||||
- `npm run lint` and `npm run check` must exit clean before closing a change.
|
||||
- **Imports:** always `$lib/...`, never `../` (ESLint enforces it). Non-null assertions are forbidden too.
|
||||
- **Always Svelte 5 runes:** `$props()` with `interface Props`, `$state`, `$derived`, `$effect`. No `export let` or `$:`. `{#each}` always keyed.
|
||||
- **Server only code goes under `$lib/server/`**, and it's never imported from client code.
|
||||
- SSR data comes through `load` in `+page.server.ts`/`+layout.server.ts` and lands in the `data` prop.
|
||||
- Links to `+server.ts` endpoints (`/auth/login`, `/auth/logout`) carry `data-sveltekit-reload`.
|
||||
- Reuse design system tokens and recipes before writing new CSS. If something repeats, extract it to `$lib/components/ui/`.
|
||||
|
||||
## Estilos
|
||||
## Folder structure
|
||||
|
||||
El sistema de diseño vive en `src/routes/layout.css` (tokens `@theme` + recetas `@utility`) y `src/lib/theme/` (`theme.css` para neutros estáticos, `tokens.ts` para los tokens dinámicos por hue de usuario). `scripts/check-tokens.mjs` (`npm run lint:tokens`, parte de `lint`) falla si `src/**/*.svelte` reintroduce lo que el refactor de `docs/frontend-refactor-plan.md` (P0–P14) eliminó — léelo antes de añadir un valor arbitrario o un radio/tamaño "de fábrica".
|
||||
- `src/lib/types.ts` holds the domain types (`Track`, `Album`, `Playlist`, `Mix`, `ListeningStats`, `Paged<T>`). Pages and the player only ever see these, never raw API DTOs.
|
||||
- `src/lib/player/` is the player: `player.svelte.ts` (state and orchestration), `queue.ts` (pure queue operations), `stream.ts`, `mediaSession.ts`, `progressClock.ts`, `listenTracker.ts`, `recentlyPlayed.ts` (merges `player.recentlyPlayed` with the server history), and `actions.ts` (`playAllOrToggle`, `playShuffled`, `isQueueCurrent`).
|
||||
- `src/lib/state/` holds reactive rune state: `panels.svelte.ts` (queue panel, create playlist modal), `history.svelte.ts` (previous page for `BackLink`), `menu.svelte.ts` (`createMenu<T>()`), `pagedList.svelte.ts` (`createPagedList`), `dialog.svelte.ts`, `liked.svelte.ts` (the user's liked tracks), `toggle.svelte.ts` (`createToggle`), and `scroll.ts` (`restoreScroll`). Nothing in `state/` imports from `components/`.
|
||||
- `src/lib/theme/` holds the theme: `accent.svelte.ts` (accent hue per track and the hue fade), `mode.svelte.ts`, `palette.ts`, `tokens.ts`, and `color.ts` (`ACCENT_HUE`). The player never imports from `theme/`: `(app)/+layout.svelte` follows `player.currentId` and calls `accent.follow`.
|
||||
- `src/lib/data/` holds client side domain helpers: albums, genres, search. It never imports from `player/`.
|
||||
- `src/lib/utils/` holds generic utilities: `format.ts`, `collections.ts`, `hrefs.ts` (search, genre and cover URLs), `menuPosition.ts`, `storage.ts`, `transitions.ts`.
|
||||
- `src/lib/actions/` holds Svelte actions: `clickOutside`, `onEscape`, `pressable`.
|
||||
- `src/lib/validation.ts` holds the form limits (`LIMITS`) shared by client forms and server parsers.
|
||||
- `src/lib/server/` is server only code (see below).
|
||||
- `src/lib/components/shell/` holds the app shell: Sidebar, TopBar, TabsBar, MobileHeader, AccountMenu, and `navLinks.ts`.
|
||||
- `src/lib/components/player/` holds the player UI: PlayerBar, PlayerDock, Queue, TrackInfo, TransportControls.
|
||||
- `src/lib/components/music/` holds the components that connect the UI to app state (the player, album data): TrackList, PlayAllButton, TrackContextMenu, AlbumContextMenu.
|
||||
- `src/lib/components/ui/` holds the design system, which is presentation only. ESLint forbids importing `$lib/player/*`, `$lib/data/*`, `$lib/server/*`, `components/music`, `components/shell` and `components/player` from it; it can use `$lib/state/*`, `$lib/utils/*`, `$lib/actions/*`, `$lib/theme/*` and `$lib/types`. It is split into five categories:
|
||||
- `primitives/` for generic controls with no music domain knowledge (Button, Input, Chip, Slider, and so on).
|
||||
- `overlay/` for anything that floats above the content (Modal, ContextMenu, GlassMenu, ConfirmDialog, and so on).
|
||||
- `media/` for stateless music display components that receive props (Artwork, MediaCard, ListRow, PlayButton, and so on).
|
||||
- `forms/` for composite entity forms (AlbumForm, PlaylistForm, CoverForm, ImageDropzone).
|
||||
- `layout/` for page level scaffolding (Page, PageHeader, SectionHeading).
|
||||
- The root `+layout.svelte` only holds the globals (fonts, theme, `DialogHost`). Everything the user sees after signing in lives under `src/routes/(app)/`, including Home (`(app)/+page.svelte`); `(app)/+layout.server.ts` guards the group and loads the shell data, and `(app)/+error.svelte` renders inside the shell. Unknown URLs hit the catch-all `(app)/[...rest]/` (it throws a 404) so they also get the shell (unknown `/api/...` URLs hit `api/[...rest]/`, a JSON 404); the root `+error.svelte` only covers errors outside the group or in the group layout load. Auth routes live under `src/routes/auth/`: `+page.svelte` is the login screen, and `login/`, `logout/`, `callback/` are the OIDC endpoints. The public user profile route is `src/routes/(app)/user/[id]/`, and its followers/following lists share one route, `user/[id]/[list=followList]/` (matcher in `src/params/followList.ts`).
|
||||
|
||||
- **Radios:** siempre `rounded-tag/thumb/control/art/art-lg/panel/panel-lg` (o `rounded-full` para píldoras/círculos). Nunca `rounded-sm/md/lg/xl/2xl/3xl` ni `rounded` a secas.
|
||||
- **Tamaños cuadrados:** `size-*` en vez de `h-N w-N` repetido (icon: `size-icon-xs/sm/md/lg/xl`; portadas: `size-cover-xs…hero`).
|
||||
- **Tipografía:** recetas `text-display-1/2/3/4`, `text-eyebrow`, `text-body`, `text-count`; tracking con `tracking-display`/`tracking-eyebrow`, no `tracking-[…]` suelto.
|
||||
- **Espaciado:** escala de Tailwind en pasos de `.5` (evita cuartos como `.25`/`.75`; solo aceptables si el token semántico lo pide, p. ej. `mb-4.5` en `SectionHeading`).
|
||||
- **Colores:** siempre `--mf-*`/`text-*`/`bg-*`/`border-*` de `theme.css`/`tokens.ts`. Nunca hex ni `rgba()` literales en un `.svelte` (esos literales solo existen como fuente de verdad dentro de `theme.css`/`tokens.ts`).
|
||||
- **`style="…"` y arbitrarios `-[…]`:** solo la lista blanca de B.4 en `docs/frontend-refactor-plan.md` (stagger `--i`, hue de tile, posición de menú, anchos/alturas dinámicos vía `var(--mf-*)`, `text-[clamp(…)]`, `grid-cols-[auto_1fr]`, `max-h-[85dvh]`, paddings en `vh` para centrado vertical). Todo lo demás, a un token.
|
||||
- Componentes base a reutilizar antes de crear uno nuevo: `Artwork`, `Avatar`, `ListRow`, `MediaIdentity`, `TrackList`, `MediaCard`, `PageHeader`, `ContextMenu`, `ConfirmDialog`, `PlayButton`, `Slider`, `Logo`, `Chip`, `SegmentedControl`, `CoverForm`.
|
||||
- Gaps conocidos y aceptados (documentados en `scripts/check-tokens.mjs`): `strokeWidth={n}` en iconos no está unificado (13 valores distintos repartidos por casi todos los componentes; migrarlo a las recetas `stroke-thin/regular/bold` de forma segura requeriría poder verificar visualmente que `lucide-svelte` respeta `stroke-width` por CSS, así que se dejó fuera de esta pasada).
|
||||
## Naming conventions and glossary
|
||||
|
||||
- Variant maps are module level constants in upper case (`SIZE`, `VARIANT`).
|
||||
- Errors: destructure as `const { data, error } = ...` and name the caught value `err` in `catch (err)`.
|
||||
- Play handlers: `playFrom(index)` and `playAll()`. Form actions: `create`, `edit`, `delete`, `addTrack`, `removeTrack`.
|
||||
- UI copy: "Playlist" (never "Lista"), "Mezcla" (never "Mix"), "Modo claro". Total durations always go through `fmtDurationLong`.
|
||||
|
||||
## Styles
|
||||
|
||||
The design system lives in `src/routes/layout.css` (`@theme` tokens plus `@utility` recipes) and in `src/lib/theme/` (`theme.css` for the static neutrals, `tokens.ts` for the dynamic tokens that depend on the user's hue). `scripts/check-tokens.mjs` (run as `npm run lint:tokens`, part of `lint`) fails on a stock radius, an `h-N w-N` pair, fractional quarter spacing, a literal hex/`rgba()`, or an arbitrary `-[…]` value outside its whitelist, so check that script before adding one.
|
||||
|
||||
- **Radii:** always `rounded-tag/thumb/control/art/art-lg/panel/panel-lg` (or `rounded-full` for pills and circles). Never `rounded-sm/md/lg/xl/2xl/3xl` or a bare `rounded`.
|
||||
- **Square sizes:** use `size-*` instead of repeating `h-N w-N` (icons: `size-icon-xs/sm/md/lg/xl/2xl/3xl`; covers: `size-cover-xs` through `size-cover-hero`).
|
||||
- **Typography:** use the recipes `text-display-1/2/3/4`, `text-eyebrow`, `text-body`, with tracking through `tracking-display`/`tracking-eyebrow` rather than a loose `tracking-[…]`.
|
||||
- **Spacing:** stick to the Tailwind scale in `.5` steps and avoid quarters like `.25`/`.75`, unless the semantic token calls for it (for example `mb-4.5` in `SectionHeading`).
|
||||
- **Colors:** always use `--mf-*`/`text-*`/`bg-*`/`border-*` from `theme.css`/`tokens.ts`. Never a literal hex or `rgba()` inside a `.svelte` file; those literals only belong in `theme.css`/`tokens.ts` as the source of truth.
|
||||
- **Inline `style="…"` and arbitrary `-[…]` values:** only the ones whitelisted in `scripts/check-tokens.mjs` (the stagger variable `--i`, tile hue, menu position, progress width, `text-[clamp(…)]`, `leading-[…]` on login's display type, `grid-cols-[auto_1fr]`, `max-h-[85dvh]`, `my-`/`py-[Ndvh]` for vertical centering, `transition-[filter]` on genre tiles, and `bg-[image:var(--mf-*)]`). Everything else should become a token.
|
||||
- Reach for these base components before creating a new one: `Artwork`, `Avatar`, `ListRow`, `MediaIdentity`, `TrackList`, `MediaCard`, `PageHeader`, `ContextMenu`, `ConfirmDialog`, `PlayButton`, `Slider`, `Logo`, `Chip`, `SegmentedControl`, `CoverForm`.
|
||||
- **Icon stroke:** one global rule (`svg.lucide { stroke-width: 1.5 }` in `layout.css`) sets the stroke for every icon. Never pass `strokeWidth` to an icon, since any CSS rule overrides the SVG attribute and it would do nothing. For a different stroke on one icon, use a Tailwind `stroke-*` utility.
|
||||
|
||||
## App shell
|
||||
|
||||
The shell in `src/routes/(app)/+layout.svelte` is a fixed `h-dvh` frame: Sidebar | (content column over PlayerDock) | Queue. Only the content column (`app-scroll`) scrolls, so nothing ever passes under the dock and the chrome can stay transparent over `app-backdrop`. Because the window never scrolls, scroll reset/restore on navigation is handled by `restoreScroll()` in `$lib/state/scroll.ts`; don't use `window.scrollTo`/`scrollY`.
|
||||
|
||||
## Motion
|
||||
|
||||
Every animation follows one scale of three durations and one easing curve, all defined in `theme.css`. Fast (`--mf-motion-fast`, 150ms) is for hover, focus and press feedback: color, opacity and small scale changes. Plain `transition` or `transition-colors` already uses it because it is the Tailwind default, so never write `duration-150`. Base (`--mf-motion-base`, 200ms) is for things that appear on top of the page or move inside a control: modals, menus, the queue panel, `animate-pop`, `animate-fade` and the segmented control thumb. Slow (`--mf-motion-slow`, 300ms) is for content entering the page and larger movement: `animate-enter`, cover zoom on hover.
|
||||
|
||||
Entrances and movement always use `--mf-ease` (`ease-snappy` in Tailwind, `expoOut` in Svelte transitions), which feels immediate because most of the motion happens at the start. Color transitions keep the Tailwind default curve. Overlays must feel instant: the modal backdrop uses `transition:fade={{ duration: 200 }}` and the panel uses a local `pop` transition (opacity + scale + translateY, `expoOut`, 200ms) mirroring `animate-pop`'s keyframes — both directives so the modal fades and pops on close too, not just on open, and the whole thing settles in 200ms. Staggered lists use `--i` with 40ms steps capped at ten items.
|
||||
|
||||
The only exceptions are looping indicators (equalizer bars, pulse, spin, sheen) and the accent hue crossfade in the root `+layout.svelte` (a random 2 to 4 seconds per song change), which is ambient and meant to be slow. Do not add new durations or curves; if something needs a different feel, pick the closest step of the scale. `prefers-reduced-motion` disables everything globally in `layout.css`.
|
||||
|
||||
## Auth
|
||||
|
||||
`hooks.server.ts` valida la cookie de sesión cifrada (`mf_session`), refresca el `access_token` y puebla `locals.user` / `locals.accessToken`. Para proteger una ruta, comprueba `locals.user` en su `load` y redirige a `/login?returnTo=…`. Para el backend, envía `locals.accessToken` como Bearer hacia `API_BASE_URL`.
|
||||
`hooks.server.ts` validates the encrypted session cookie (`mf_session`), refreshes the `access_token`, and fills `locals.user` and `locals.accessToken`. To protect a route, check `locals.user` in its `load` and redirect to `/auth?returnTo=…`. When talking to the backend, send `locals.accessToken` as a Bearer token to `API_BASE_URL`.
|
||||
|
||||
Variables de entorno en `.env` (ver `.env.example`): `ZITADEL_*`, `AUTH_REDIRECT_URI`, `AUTH_POST_LOGOUT_URI`, `SESSION_SECRET`, `API_BASE_URL`.
|
||||
Environment variables live in `.env` (see `.env.example`): `ZITADEL_*`, `AUTH_REDIRECT_URI`, `AUTH_POST_LOGOUT_URI`, `SESSION_SECRET`, `API_BASE_URL`.
|
||||
|
||||
## Backend API
|
||||
|
||||
Cliente tipado con `openapi-fetch` sobre el OpenAPI de `Musify.Api`. En un `load`/action server:
|
||||
Typed client via `openapi-fetch` over the `Musify.Api` OpenAPI spec. Inside a server `load` or action use the client in `$lib/server/api.ts`:
|
||||
|
||||
```ts
|
||||
import { createApiClient } from '$lib/server/api';
|
||||
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
|
||||
const { data, error: err } = await api.GET('/tracks');
|
||||
const api = apiFor(event); // or apiFor({ fetch, locals }); adds the bearer token when there is a session
|
||||
const { data, error } = await api.GET('/tracks');
|
||||
```
|
||||
|
||||
Devuelve `{ data, error }` (estilo ErrorOr), no lanza. Los tipos viven en `src/lib/api/schema.d.ts` y se regeneran con `npm run gen:api` (requiere la API dev levantada en `API_BASE_URL`, que expone `/openapi/v1.json` solo en Development).
|
||||
It returns `{ data, error }` (ErrorOr style) and never throws except on network errors. The types live in `src/lib/api/schema.d.ts` and get regenerated with `npm run gen:api` (this needs the dev API running at `API_BASE_URL`, which only exposes `/openapi/v1.json` in Development).
|
||||
|
||||
Server helpers:
|
||||
|
||||
- `api.ts` holds the client and error handling: `apiFor`, `unwrapOrError`, `failOnError` (turns an API error into a `fail(...)`), `requireData` (also narrows the response data) and `apiErrorDetail`.
|
||||
- `guards.ts` holds `requireUser`, `optionalUser` and `loginRedirect`. Keep them in pages and actions even though the `(app)` layout also redirects: page loads run in parallel with the layout load, and form actions skip it.
|
||||
- `mappers.ts` (`toTrack`, `toAlbum`, `toPlaylist`, `toMix`, `toPage`, `toCount`, `toListeningStats`, ...) turn DTOs into the domain types. Every load and `/api` endpoint goes through them, so `Number()` conversions on DTOs never reach routes or the client.
|
||||
- `actions/` holds `authedAction(handler)` (returns 401 without a token, reads the form and hands `{ api, form, params, locals, url }` to the handler) plus the shared `follow` and `playlist` actions.
|
||||
- `forms/` holds `fields.ts` (`formString`, `formFile`) and the form parsers (`parseAlbumForm`, `parsePlaylistForm`, `parseTrackUploadForm`); they validate with `LIMITS` from `$lib/validation`. `uploadOptionalCover` handles the optional cover upload.
|
||||
- `genres.ts` caches the genre list; `playbackConfig.ts` caches the anonymous listening setting.
|
||||
- Page sizes come from `$lib/config`; do not hardcode them.
|
||||
|
||||
@@ -31,5 +31,4 @@ Built and run as a container by the production stack — see
|
||||
[../deploy/README.md](../deploy/README.md). There the same variables come from
|
||||
`deploy/.env.prod` through compose instead of from `.env`.
|
||||
|
||||
Conventions and the design system are documented in [CLAUDE.md](CLAUDE.md) and
|
||||
in the `musify-web` skill.
|
||||
Conventions and the design system are documented in [CLAUDE.md](CLAUDE.md).
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
# Plan de refactor tras la auditoría del frontend
|
||||
|
||||
> **Cerrado.** Todas las partes están hechas; el plan activo es `frontend-structure-plan.md`.
|
||||
|
||||
Plan de trabajo derivado de la auditoría del `web-player` (2026-09-24). Cada parte es un PR independiente, revisable por separado, que deja `npm run check` y `npm run lint` en verde. Antes de empezar una parte, mira el **Registro** del final.
|
||||
|
||||
Leyenda de tamaño: **S** (< 1 h) · **M** (medio día) · **L** (1 día o más). Las partes marcadas con 🔗 dependen de cambios en `Musify.Api`.
|
||||
|
||||
## Orden y dependencias
|
||||
|
||||
```
|
||||
P0 Red de seguridad
|
||||
└─ P1 Seguridad ─ P2 Reproductor ─ P3 Bugs de páginas ─ P4 Código muerto
|
||||
└─ P5 Modelo Track único
|
||||
├─ P6 Capa de servidor ─ P7 Formularios y validación
|
||||
│ └─ P8 Rendimiento 🔗
|
||||
└─ P9 Responsabilidades ─ P10 Componentes y páginas
|
||||
└─ P11 Consistencia ─ P12 Guardarraíles y docs
|
||||
```
|
||||
|
||||
P1 a P4 son arreglos acotados y se pueden mergear en cualquier orden. De P5 en adelante el orden importa: cada parte simplifica la siguiente.
|
||||
|
||||
---
|
||||
|
||||
## P0 · Red de seguridad (M)
|
||||
|
||||
Objetivo: tener tests de las funciones puras antes de tocar lógica.
|
||||
|
||||
- Añadir Vitest (`vitest`, script `npm run test`, incluido en `lint` o en CI).
|
||||
- Tests para lo que ya es puro: `fmtTime`, `fmtDurationLong`, `plural`, `albumMeta` (`utils/format.ts`), `findTopResult` (`data/search.ts`), `findConflict`, `genreHue` (`data/genres.ts`), `pickGreeting` (`server/greeting.ts`), `parseAlbumForm` (`server/albumForm.ts`), `appendUnique`, `shuffle`.
|
||||
- Extraer las operaciones de cola de `PlayerState` a funciones puras en `player/queue.ts` (`insertNext`, `append`, `move`, `nextIndex`, `previousIndex`) y testearlas. `PlayerState` pasa a llamarlas. Esto prepara P2.
|
||||
|
||||
**Cierre:** `npm run test` en verde y cubre las funciones anteriores.
|
||||
|
||||
---
|
||||
|
||||
## P1 · Seguridad (S)
|
||||
|
||||
1. **Redirección abierta con `returnTo`.**
|
||||
- Nueva función `safeReturnTo(value: string | null | undefined): string` en `$lib/server/auth.ts`: acepta solo rutas que empiecen por `/` y no por `//` ni `/\`; si no, devuelve `/`.
|
||||
- Aplicarla en `routes/auth/+page.server.ts` (ambos usos), en `routes/auth/login/+server.ts` (antes de guardar la cookie) y en `routes/auth/callback/+server.ts` (al leer la cookie).
|
||||
2. **`SESSION_SECRET` obligatorio.** En `$lib/server/config.ts`, `sessionSecret` pasa por `required()` y exige una longitud mínima de 32 caracteres. Actualizar el comentario de `.env.example`.
|
||||
3. **Validar las entradas de los endpoints `/api`.**
|
||||
- `api/likes/+server.ts`: no reenviar el body tal cual; leer `trackId`, comprobar que es un string no vacío y devolver 400 si no.
|
||||
- `api/listens/[id]/progress/+server.ts`: envolver `request.json()` para devolver 400 con JSON inválido en vez de 500.
|
||||
- `server/image.ts`: `encodeURIComponent(id)` al construir la URL de upstream.
|
||||
- `api/tracks/+server.ts`: acotar `pageSize` con un máximo (`MAX_PAGE_SIZE` en `config.ts`).
|
||||
|
||||
**Cierre:** `/auth?returnTo=https://example.com` acaba en `/`; arrancar sin `SESSION_SECRET` falla con un mensaje claro; tests de `safeReturnTo`.
|
||||
|
||||
---
|
||||
|
||||
## P2 · Reproductor y cola (M)
|
||||
|
||||
1. **Duplicados en la cola** (`player.svelte.ts` `playNext`/`appendToQueue`, `Queue.svelte`).
|
||||
- `playNext` mueve la pista si ya estaba en la cola, en vez de duplicarla.
|
||||
- `appendToQueue` omite las pistas que ya están pendientes.
|
||||
- Con esto las claves `(track.id)` vuelven a ser únicas y `#index()` es fiable.
|
||||
2. **Fin de cola.** Con repetir desactivado, al terminar la última pista el reproductor se detiene en vez de volver al principio. Si más adelante se quiere "repetir todo", `repeat` pasará a `'off' | 'all' | 'one'` (queda fuera de este plan).
|
||||
3. **La cola se abre sola** (`+layout.svelte:44`). Abrirla solo la primera vez que empieza a sonar algo en la sesión (`currentId` pasa de `null` a un valor) y no volver a abrirla si el usuario la cierra.
|
||||
4. **Rollback de "Me gusta"** (`liked.svelte.ts:69`). En caso de error, revertir solo la entrada de ese id, no sustituir el mapa entero.
|
||||
5. **localStorage seguro.**
|
||||
- Nuevo `$lib/utils/storage.ts` con `readStorage(key, fallback)` y `writeStorage(key, value)` envueltos en try/catch.
|
||||
- Volumen: validar y acotar a 0–100 (hoy puede quedar `NaN`).
|
||||
- Unificar claves: `musify.player.volume`, `musify.player.muted`, `musify.theme`.
|
||||
- Persistir el volumen siempre, no solo cuando existe el elemento `<audio>`.
|
||||
|
||||
**Cierre:** añadir la misma pista dos veces a la cola no rompe nada; la cola termina; tests de `player/queue.ts` actualizados; prueba manual en el Browser pane.
|
||||
|
||||
---
|
||||
|
||||
## P3 · Bugs de páginas y datos (M)
|
||||
|
||||
1. **Permisos en la playlist** (`playlists/[id]`).
|
||||
- El `load` devuelve `isOwner`, comparando el propietario de la playlist con `locals.user.sub`. Si la API no expone el propietario, añadirlo al DTO 🔗.
|
||||
- Mostrar Editar, Eliminar y "Quitar" solo al propietario. Añadir `BackLink` como en el álbum y la mezcla, y `section: '/playlists'` cuando sea propia.
|
||||
2. **Una sola fuente para las playlists del usuario.**
|
||||
- `+layout.server.ts` carga las playlists del usuario con `PLAYLIST_PICKER_PAGE_SIZE` y devuelve también `totalItemCount`.
|
||||
- La barra lateral muestra las 8 primeras y usa el total real en el contador.
|
||||
- Los menús contextuales (inicio, explorar, mezcla) usan esa lista desde `page.data` y así explorar y mezcla dejan de pedirla aparte.
|
||||
3. **Playlist destacada de inicio.**
|
||||
- Nuevo endpoint `api/playlists/[id]/tracks/+server.ts`, análogo al de álbumes. "Reproducir" y "Aleatorio" cargan la lista completa en ese momento.
|
||||
- La meta muestra el total real. La duración se quita hasta que la API la devuelva (P8).
|
||||
- La promesa se resuelve con `{#await}` en vez de con un `$effect` que copia a estado.
|
||||
4. **Borrar canción de la biblioteca con confirmación.** `ConfirmDialog` acepta `fields` (inputs ocultos) y `library` lo usa, igual que álbumes y playlists.
|
||||
5. **Explorar.**
|
||||
- Usar `plural()` para el número de canciones de un álbum.
|
||||
- Contadores de chips y "N coincidencias" a partir de `totalItemCount` de cada respuesta, no de lo cargado.
|
||||
6. **Enlace `/terms`** en la subida: quitar el enlace hasta que exista una página de términos (o crearla, si se decide).
|
||||
7. **`playbackConfig.ts`:** no guardar en caché cuando la respuesta trae `error`.
|
||||
8. **`ImageDropzone`:** liberar la URL anterior con `URL.revokeObjectURL` al cambiar de archivo y al desmontar.
|
||||
9. **Textos.**
|
||||
- "Populares esta semana" duplica "Continuar escuchando": quitar la sección hasta que haya un endpoint real de más escuchadas (o renombrarla a "Escuchado recientemente").
|
||||
- `+error.svelte`: quitar la promesa de modo sin conexión.
|
||||
- "Retomalo" → "Retómalo"; "Modo blanco" → "Modo claro".
|
||||
|
||||
**Cierre:** un no propietario no ve acciones de edición; la barra lateral cuenta bien; la destacada reproduce la lista completa.
|
||||
|
||||
---
|
||||
|
||||
## P4 · Código muerto (S)
|
||||
|
||||
Borrar sin sustituto:
|
||||
|
||||
- `player.svelte.ts`: `playPlaylist`, `playlistTracks`, `playlists`, interfaz `Playlist`, getter `isPlaying`, `playNextItem` (usar `playNext([item])`).
|
||||
- `theme/palette.ts`: `Accent.gradient` (y con ello el tipo `Accent`, que queda en `string`).
|
||||
- `IconButton`: el mapa `TEXT` y la prop `tone` pasan a `plain` o por defecto; `muted` y `subtle` son idénticos.
|
||||
- `Artwork`: prop `fallback` y sus ramas `gradient` y `none`.
|
||||
- `MediaIdentity.badge`, `FollowButton.userId`, `EqBars.size` y `EqBars.barWidth`.
|
||||
- Acciones `follow` y `unfollow` en `explore/+page.server.ts` y en `user/[id]/[list=followList]/+page.server.ts`, con el `form?.message` que solo servía para ellas.
|
||||
- Utilidades `media-grid` y `text-count` de `layout.css` (y su mención en `CLAUDE.md`).
|
||||
- `TrackList.svelte`: el `use:enhance` redundante (queda `use:enhance`) y los `stopPropagation` de la línea 141.
|
||||
- `+page.server.ts` (inicio): el `redirect` previo a `requireUser`. `+layout.server.ts`: la rama `accountUrl = null`.
|
||||
- Clases que no hacen nada: `hover:text-fg-2` sobre `text-fg-2` (SegmentedControl, Sidebar, ArtistLink) y el `hidden sm:flex` anidado de `PlayerBar`.
|
||||
- Separadores vacíos (`<span></span>`, `<div class="flex-1">`) en `CoverForm` y `TopBar`: sustituir por `ml-auto`.
|
||||
- `sessionCookieOptions` deja de exportarse.
|
||||
|
||||
**Cierre:** `grep` de cada símbolo sin resultados; la app se comporta igual.
|
||||
|
||||
---
|
||||
|
||||
## P5 · Modelo `Track` único (L)
|
||||
|
||||
Objetivo: una sola forma de pista, normalizada en la frontera del servidor. Es el cambio que más código quita.
|
||||
|
||||
1. **Tipos de dominio** en `$lib/types.ts`:
|
||||
```ts
|
||||
export interface Track {
|
||||
id: string;
|
||||
title: string;
|
||||
artist: string | null;
|
||||
ownerUserId: string | null;
|
||||
explicit: boolean;
|
||||
duration: number;
|
||||
listensCount: number;
|
||||
}
|
||||
export interface LikedTrack extends Track {
|
||||
likedAt: number;
|
||||
}
|
||||
```
|
||||
Más `Album` y `Playlist` normalizados (`trackCount: number`, `releaseYear: number | null`, ids como `string`).
|
||||
2. **Mappers de servidor** en `$lib/server/mappers.ts`: `toTrack`, `toAlbum`, `toPlaylist`, `toMixTrack` (sustituye a `mixItemTrack`). Hacen todas las conversiones `Number()` y `String()`. Todos los `load` y los endpoints `/api` que devuelven pistas los usan.
|
||||
3. **El reproductor usa `Track`.**
|
||||
- Se eliminan `PlayerTrack`, `QueueItem`, `ApiTrackLike`, `LikeToggleInput` y `TrackListTrack` (que pasa a ser `Track & { date?: string | number }`).
|
||||
- Se eliminan `toQueueItems`, `toQueueItem`, `trackFromQueueItem` y `toTrack` del cliente.
|
||||
- `current` pasa a ser `Track | null` en vez del centinela `EMPTY` con `id: ''`.
|
||||
4. **Páginas.** Quitar todos los `Number(...)`, los `?? undefined` y los mapeos intermedios (`listTracks`, `songRows`, `spotlightRows`, `popularTracks`…).
|
||||
|
||||
**Cierre:** `grep -rn "Number(" src/routes --include=*.svelte` vacío; `isExplicit` solo aparece en `mappers.ts`; tests de los mappers.
|
||||
|
||||
---
|
||||
|
||||
## P6 · Capa de servidor (M)
|
||||
|
||||
1. **Helpers en `$lib/server/api.ts`.**
|
||||
- `apiFor(event)` sustituye los 15 `createApiClient({ fetch, accessToken: locals.accessToken ?? undefined })`.
|
||||
- Exportar `type ApiClient` (hoy se redefine en `follows.ts`).
|
||||
- `loginRedirect(url)`, usado por `requireUser`, `optionalUser` y `+layout.server.ts`.
|
||||
2. **Envoltorio de acciones** `authedAction(handler)`: comprueba el token (401 si falta), crea `api`, lee `form` y pasa `{ api, form, params, locals }`. Sustituye el bloque `requireAccessTokenAction` + `typeof` + `formData` + `createApiClient` de las ~10 acciones.
|
||||
3. **Lectura de formularios:** `formString(form, key)` y `formFile(form, key)` (devuelve `null` si el archivo viene vacío). Sustituyen los 17 `String(form.get(...) ?? '')` y los `instanceof File && size > 0`.
|
||||
4. **Nombres.**
|
||||
- `unwrapOrFail` → `failOnError` (no desenvuelve nada).
|
||||
- Desestructurar siempre como `const { data, error } = ...` y usar `catch (err)`.
|
||||
- La acción `rename` de playlist pasa a `edit` y devuelve `{ edited: true }`, como la de álbum.
|
||||
5. **Sin aserciones no nulas:** quitar `urlsResult.data!` y `trackResult.data!` de `upload/+page.server.ts` usando el patrón de `failOnError` que estrecha el tipo.
|
||||
6. **`+layout.server.ts`:** una sola función para las tres cargas con fallback, sin `try/catch` duplicado (openapi-fetch solo lanza con error de red).
|
||||
7. **Tamaños de página:** todos los `pageSize` salen de `config.ts` (quitar `50`, `200`, `500` sueltos y el `PAGE_SIZE` local de `library`). Quitar el alias `EXPLORE_PAGE_SIZE as PAGE_SIZE`.
|
||||
|
||||
**Cierre:** ninguna acción repite la plantilla de autenticación; `grep "createApiClient(" src/routes` vacío.
|
||||
|
||||
---
|
||||
|
||||
## P7 · Formularios y validación compartida (M)
|
||||
|
||||
1. **Límites en un único sitio:** `$lib/validation.ts` (compartido entre cliente y servidor):
|
||||
```ts
|
||||
export const LIMITS = {
|
||||
playlistName: 100,
|
||||
playlistDescription: 300,
|
||||
albumTitle: 200,
|
||||
albumDescription: 256,
|
||||
trackTitle: 100
|
||||
} as const;
|
||||
```
|
||||
Antes de fijarlos, comprobar los validadores de `Musify.Api` y usar sus valores. `ALBUM_EARLIEST_YEAR` se mueve aquí.
|
||||
2. **Parsers de servidor** en `$lib/server/forms/`: `parseAlbumForm` (existente), `parsePlaylistForm` (nuevo, sustituye el código duplicado de `create` y `edit`), `parseTrackUploadForm` (sacado de `upload/+page.server.ts`). Todos validan con `LIMITS`.
|
||||
3. **Portadas:** `uploadOptionalCover(api, endpoint, file)` sustituye los 4 bloques `if (cover ...) uploadPresignedImage(...)`.
|
||||
4. **Formularios de cliente:** `AlbumForm`, `PlaylistForm` y la subida usan `LIMITS` en `maxlength` y en el contador. Añadir el contador que falta en `AlbumForm` para que los tres se comporten igual.
|
||||
5. `formatSize` de la subida pasa a `utils/format.ts`.
|
||||
|
||||
**Cierre:** cambiar un límite en `LIMITS` actualiza cliente y servidor; tests de los parsers.
|
||||
|
||||
---
|
||||
|
||||
## P8 · Rendimiento 🔗 (M, parte en backend)
|
||||
|
||||
1. **Backend:** añadir `trackCount` y `durationSeconds` a la respuesta de playlist.
|
||||
2. **Frontend:**
|
||||
- `playlists/+page.server.ts` deja de pedir hasta 500 pistas por playlist.
|
||||
- `user/[id]/+page.server.ts` deja de hacer una petición por playlist.
|
||||
- La destacada de inicio recupera la duración real.
|
||||
3. **Explorar:** cachear `/genres` en `$lib/server/genres.ts` (como `playbackConfig`, con TTL de unos minutos) y lanzar todas las peticiones en paralelo en vez de esperar primero a los géneros.
|
||||
4. **Añadir álbum a playlist** (`playlistActions.ts`): hoy hace un POST por pista en serie y se corta en 200. Pedir un endpoint de inserción múltiple 🔗; mientras tanto, paralelizar con un límite de concurrencia.
|
||||
|
||||
**Cierre:** la página de playlists hace una sola petición a la API; el perfil hace dos.
|
||||
|
||||
---
|
||||
|
||||
## P9 · Separación de responsabilidades en `lib/` (L)
|
||||
|
||||
1. **Dividir `player.svelte.ts`:**
|
||||
- `player/queue.ts`: operaciones de cola (de P0).
|
||||
- `player/stream.ts`: `resolveStream(id)` (fetch de `/stream`, ticket, `listenId`) y `readErrorMessage`.
|
||||
- `player/mediaSession.ts`: registro de acciones y sincronización de metadatos y estado.
|
||||
- `player/progressClock.ts`: el bucle `requestAnimationFrame` de progreso suave.
|
||||
- `player/actions.ts`: `playAllOrToggle`, `playShuffled`, `isQueueCurrent`.
|
||||
- `player/player.svelte.ts`: solo el estado y la orquestación.
|
||||
2. **Acento como número.**
|
||||
- Nuevo `theme/accent.svelte.ts`: caché, `extractAccent` y el tono actual como `number`.
|
||||
- Ahí mismo vive el fundido de tono que hoy está en `+layout.svelte` (`animateThemeHue()`).
|
||||
- Se elimina `parseHue` (ya no hay que leer el tono de una cadena `oklch(...)`).
|
||||
3. **Dividir `state/navigation.svelte.ts`:**
|
||||
- `state/history.svelte.ts`: `trackNavigation`, `previousPage`.
|
||||
- `state/scroll.ts`: `restoreScroll`.
|
||||
- `utils/hrefs.ts`: `searchHref`, `genreHref` y las URLs de portada (`trackCover(id, size)`, `playlistCover(playlist, size)` con el `v=updatedAt`, `albumCover(id, size)`), que hoy se escriben a mano en más de 15 sitios.
|
||||
- `components/layout/navLinks.ts`: `appNavLinks` e `isNavActive` (sustituye el `isActive` duplicado de Sidebar y TabsBar).
|
||||
4. **Menús.** `contextMenuPosition` y `MenuPosition` pasan a `utils/menuPosition.ts`, sin `preventDefault()` dentro (lo hace quien llama). `state/menus.svelte.ts` deja de importar de un componente.
|
||||
5. **Movimientos de archivos:**
|
||||
- `data/collections.ts` → `utils/collections.ts`.
|
||||
- `player/queuePanel.svelte.ts` y `state/playlists.svelte.ts` → `state/panels.svelte.ts` (`queuePanel`, `createPlaylistModal`).
|
||||
- `data/search.ts`: claves internas (`tracks`, `albums`, `playlists`, `users`) y etiquetas aparte en un mapa de textos.
|
||||
6. Tipar `+layout.svelte` con `interface Props`.
|
||||
|
||||
**Cierre:** `player.svelte.ts` por debajo de ~250 líneas; ningún archivo de `state/` importa de `components/`.
|
||||
|
||||
---
|
||||
|
||||
## P10 · Componentes extraídos y páginas más finas (L)
|
||||
|
||||
1. **Menú contextual.**
|
||||
- `state/menu.svelte.ts` con `createMenu<T>()` genérico: sustituye `createTrackMenu` y el `albumMenu` hecho a mano de explorar.
|
||||
- Componente `TrackContextMenu` que encapsula el bloque de 15 líneas repetido en inicio, explorar y mezcla. `AlbumContextMenu` para explorar.
|
||||
2. **`PlayAllButton {items}`** con el "Reproducir/Pausar" de álbum, playlist, mezcla, me gusta y la portada de inicio.
|
||||
3. **`createPagedList(fetcher)`** en `state/pagedList.svelte.ts` para la paginación infinita de explorar y de seguidores (estado, `appendUnique`, `hasMore` y un estado de error visible en vez de tragarse el fallo).
|
||||
4. **Primitivas.**
|
||||
- `Button`, `IconButton`, `Chip` y `MenuItem` usan `<svelte:element this={href ? 'a' : 'button'}>` en vez de duplicar las dos ramas.
|
||||
- Acción `use:onEscape` para `Modal`, `ContextMenu` y `AccountMenu`.
|
||||
- `AccountMenu` incluye su propio disparador con el `Avatar`; `TopBar` y `MobileHeader` dejan de duplicarlo.
|
||||
- `Artwork` y `Avatar` usan `{#key src}` en vez del `$effect` que reinicia `failed`.
|
||||
5. **Páginas.**
|
||||
- Quitar los alias `const x = $derived(data.x)` y las funciones de una línea (`playSpotlight`, `shuffleSpotlight`, `openContextMenu`, `stepClass`…).
|
||||
- Sustituir los `$effect` que copian `data` a estado local por `$derived` escribible cuando sea posible.
|
||||
- Navegación con enlaces reales: `ListRow href` en vez de `onclick={() => goto(...)}`, y la destacada de inicio como `<a>`.
|
||||
- `upload`: el indicador de pasos solo refleja estados reales (o se simplifica a "subiendo / listo").
|
||||
|
||||
**Cierre:** ninguna página repite el bloque del menú ni el botón de reproducir; las tarjetas se pueden abrir con clic central.
|
||||
|
||||
---
|
||||
|
||||
## P11 · Consistencia (M, mecánico)
|
||||
|
||||
1. **Imports:** siempre `$lib/...`, nunca `../`. Se hace cumplir en P12.
|
||||
2. **Convenciones de nombres** (se documentan en `CLAUDE.md`):
|
||||
- Mapas de variantes en constantes de módulo en mayúsculas (`SIZE`, `VARIANT`).
|
||||
- Errores: `error` al desestructurar y `err` en `catch`.
|
||||
- Manejadores de reproducción: `playFrom(index)`, `playAll()`.
|
||||
- Acciones: `create`, `edit`, `delete`, `addTrack`, `removeTrack`.
|
||||
3. **Glosario de interfaz:** "Playlist" (nunca "Lista"), "Mezcla" (nunca "Mix"), "Modo claro". Duraciones totales siempre con `fmtDurationLong`.
|
||||
4. **Tokens.**
|
||||
- Iconos siempre con `size-icon-*`; añadir `size-icon-2xs` si hace falta para los de 14 px.
|
||||
- Nada de `size={16}` en iconos Lucide.
|
||||
- No usar tokens de icono como espaciado (`pb-icon-md`, `gap-icon-md`).
|
||||
- Anchos siempre con clase (`w-(--mf-sidebar-w)`), no con `style`.
|
||||
- Nueva utilidad `focus-ring` usada por `Button`, `IconButton`, `Chip` y los enlaces interactivos.
|
||||
5. **Rutas:** mover `routes/+page.svelte` y `+page.server.ts` (inicio) dentro de `(app)`, para que el grupo tenga sentido: todo lo de la app dentro y `auth/` fuera.
|
||||
|
||||
**Cierre:** `npm run lint` en verde con las reglas nuevas de P12.
|
||||
|
||||
---
|
||||
|
||||
## P12 · Guardarraíles y documentación (S)
|
||||
|
||||
1. **ESLint:**
|
||||
- `no-restricted-imports` que prohíba `../*`.
|
||||
- `@typescript-eslint/no-non-null-assertion`.
|
||||
- `svelte/no-unused-props`.
|
||||
2. **`scripts/check-tokens.mjs`:** fallar con `size={N}` en iconos y con `size-N` sobre componentes Lucide.
|
||||
3. **Opcional:** `knip` para detectar exportaciones y archivos sin uso.
|
||||
4. **Documentación:**
|
||||
- `CLAUDE.md`: nueva estructura de carpetas, helpers de servidor (`apiFor`, `authedAction`, mappers), convenciones de nombres y glosario.
|
||||
- `docs/paginas.md`: está desactualizado (menciona el grupo `(marketing)` y la landing, que ya no existen).
|
||||
|
||||
**Cierre:** las reglas nuevas fallan si se reintroduce cualquiera de los patrones eliminados.
|
||||
|
||||
---
|
||||
|
||||
## Registro
|
||||
|
||||
| Parte | Estado | PR / notas |
|
||||
| --------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------- |
|
||||
| P0 Red de seguridad | ✅ | Vitest + 37 tests; `player/queue.ts` extraído. `lint` ahora ejecuta `test` |
|
||||
| P1 Seguridad | ✅ | Hecho en la rama `claude/frontend-audit-plan-4f9432` |
|
||||
| P2 Reproductor y cola | ✅ | Sin probar en navegador (API caída) |
|
||||
| P3 Bugs de páginas | ✅ | Backend: `ownerUserId` en playlist. Sin probar en navegador |
|
||||
| P4 Código muerto | ✅ | Se mantiene el redirect a `/explore` de inicio (cubre escucha anónima) |
|
||||
| P5 Modelo Track único | ✅ | `Paged<T>`, `Mix`, `PlaylistSummary` añadidos. Sin probar en navegador |
|
||||
| P6 Capa de servidor | ✅ | `failOnError` + `requireData`; `parseAlbumForm` usa `formString` |
|
||||
| P7 Formularios y validación | ✅ | Límites según las columnas de BD del backend (playlist 50/256, pista 200) |
|
||||
| P8 Rendimiento 🔗 | ✅ | Backend: `trackCount` y `durationSeconds` en playlist. Añadir álbum usa `POST /playlists/{id}/albums/{albumId}` (bulk) |
|
||||
| P9 Responsabilidades | ✅ | `player.svelte.ts` queda en ~320 líneas |
|
||||
| P10 Componentes y páginas | ✅ | Quedan algunos alias `$derived(data.x)`; `{#key}` sustituido por `failedSrc` |
|
||||
| P11 Consistencia | ✅ | |
|
||||
| P12 Guardarraíles y docs | ✅ | `knip` no añadido (opcional) |
|
||||
|
||||
Leyenda: ⬜ pendiente · 🟡 en curso · ✅ hecho.
|
||||
@@ -1,931 +0,0 @@
|
||||
# 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 | ✅ | (pendiente) | Button primary/secondary se mantienen iguales (decisión del usuario) |
|
||||
| P1 | ✅ | (pendiente) | Verificado con `build` (Tailwind compila `@apply sm:` dentro de `@utility`); sin QA visual autenticada (Docker no disponible en esta sesión) |
|
||||
| P2 | ✅ | (pendiente) | `<style>{expr}</style>` no interpola en Svelte (se trata como CSS del componente); solución: `{@html \`<style>${css}</style>\`}` |
|
||||
| P3 | ✅ | (pendiente) | Se quitó el nombre bajo el avatar de perfil (decisión tomada: redundante con el título de CollectionHeader). Se añadió `size="fill"` a Artwork (no está en la spec) para las tarjetas de grid fluidas (AlbumCard/PlaylistCard/MixTile), ya que P9 (MediaCard) aún no existe. SearchResultRow perdió su prop `kind`/radio-por-tipo (ahora el radio lo decide Artwork/Avatar). Sin QA visual autenticada (Docker no disponible) |
|
||||
| P4 | ✅ | (pendiente) | Decisión tomada sin bloquear: se elimina el botón "adelante" decorativo (TopBar) y el icono `AlignJustify` decorativo (PlayerExtras) por ser código muerto visual. `TransportControls` (shuffle/prev/next/repeat) se deja sin migrar a IconButton a propósito: son iconos sin chrome (sin fondo/hit-box), migrarlos habría añadido una píldora de hover no presente hoy — riesgo visual no verificable sin Docker |
|
||||
| P5 | 🟡 | (pendiente) | Hecho: pressable/clickOutside, toggle.svelte.ts, plural() (+minúsculas, Δ visible), helpers de player.svelte.ts (toQueueItem/isQueueCurrent/playAllOrToggle/playShuffled, addToQueue→playNextItem), navigation.svelte.ts (searchHref/appNavLinks — appNavLinks creado pero su migración a Sidebar/TabsBar se deja para P12 tal como indica esa parte), menus.svelte.ts (createTrackMenu, migrado en Inicio/Explorar/Mezcla). **Pendiente a propósito**: la tarea 5 (eliminar el envoltorio `TrackTarget` de tracks.ts y sus 8 accesores) se difiere — es el cambio de mayor riesgo del plan (toca tipos en Inicio/Explorar/Mezcla/menús de forma transversal) y no se pudo verificar visualmente sin Docker; abordarlo junto con P13 cuando se reescriban esas páginas |
|
||||
| P6 | ✅ | (pendiente) | z-index de AccountMenu unificado a z-(--z-menu) (antes z-20, ahora al mismo nivel que ContextMenu — Δ menor, mejora consistencia); sin QA visual autenticada (Docker no disponible) |
|
||||
| P7 | ✅ | (pendiente) | MediaIdentity queda "puro" (sin onClick) según spec; las 6 tablas de pista (P8) envuelven su propio `use:pressable` hasta que P8 las sustituya por TrackList. Deltas documentados: fila activa de Sidebar ahora usa bg-accent-tint/text-accent-soft (antes gris neutro, ahora consistente con el resto de filas activas); tarjeta "Tus playlists" de Inicio pierde la línea "por {tu nombre}" (redundante, siempre el propio usuario) y su radio pasa de rounded-panel a rounded-art (consistencia con la tarjeta "Continuar"); filas de Queue "A continuación" pierden el opacity-80 del arte (ListRow no expone esa personalización). Sin QA visual autenticada (Docker no disponible) |
|
||||
| P8 | ✅ | (pendiente) | Decisión tomada (recomendada por el propio plan, sin bloquear): se elimina la columna "Álbum" que siempre mostraba "—". TrackList unifica TrackTable+TrackRow+TrackIndexCell+TrackMeta; AlbumTrackTable/PlaylistTrackTable/LibraryPicker se inlinean en sus páginas y se borran. Sin QA visual autenticada (Docker no disponible) |
|
||||
| P9 | ✅ | (pendiente) | MediaCard gana hover-zoom del arte y scrim+PlayButton condicionados a `onPlay` (antes exclusivos de MixTile); ahora se aplicarían a cualquier card futura con onPlay, consistencia intencional. Sin QA visual autenticada (Docker no disponible) |
|
||||
| P10 | ✅ | (pendiente) | Decisión tomada (recomendada, sin bloquear): iconos en los botones de cabecera de playlist (antes solo álbum tenía). PlaylistHeader pierde su prop `id` sin usar tras inlinearlo. Explorar: la cabecera de "Resultados para…" ahora usa el `mb-7/8` de PageHeader en vez de un `mt-5` ajustado a mano antes de los chips de filtro — gap ligeramente mayor entre el resumen y los chips (Δ menor, documentado). Sin QA visual autenticada (Docker no disponible) |
|
||||
| P11 | ✅ | (pendiente) | CoverForm añade snippet `extra` (no está en la spec literal, solo `fields`) para el contenido a ancho completo bajo la fila portada+campos (descripción de álbum, selector de visibilidad de playlist) que no cabía en una única columna. Upload usa ImageDropzone con `onselect` (prop nueva) y se envuelve en `{#key}` para poder resetear tras publicar — Δ: el contenedor de portada pasa de 144/176px (móvil/desktop) a 144px fijo (size-cover-hero, sin variante desktop mayor), y el icono de posición vacía de 64px a 36px (icon-xl), tal como recomienda el plan. Sin QA visual autenticada (Docker no disponible) |
|
||||
| P12 | ✅ | (pendiente) | PlayerExtras se fusiona en PlayerBar (volumen/cola inline); Slider sustituye a SeekBar+VolumeControl (confirmado por grep que el modo `compact` de SeekBar era código muerto). Sidebar y TabsBar migran a `appNavLinks()` (P5). Decisión tomada (sin bloquear): Sidebar gana la marca-gradiente `Logo` que no tenía antes, para consistencia con MobileHeader/login. z-index de PlayerDock/MobileHeader corregido a `z-(--z-sticky)` (son barras fijas, no menús) y SegmentedControl a `z-(--z-raised)`, tal como indica B.1.5. `glass-bar`/`theme-transition` aplicados en PlayerBar (ambas variantes), TabsBar, Queue e Inicio (hero/spotlight), sustituyendo estilos duplicados. Sin QA visual autenticada (Docker no disponible); smoke test de `/login` sin errores de consola |
|
||||
| P13 | ✅ | (pendiente) | Se aborda junto con esta parte la tarea 5 diferida en P5: se elimina el envoltorio `TrackTarget` de `tracks.ts` y sus 8 accesores (`targetId`/`targetTitle`/`targetArtist`/`targetOwnerUserId`/`targetExplicit`/`targetListensCount`/`isTargetCurrent`/`queueItemForTarget`/`targetForQueueItem`); Explorar, Inicio, Mezcla, `menus.svelte.ts` y `mixes.ts` pasan a usar los objetos de pista (`ApiTrackLike`/`LocalTrack`) directamente, con `toQueueItem`/`trackFromQueueItem` (nuevo, en `player.svelte.ts`) como únicos conversores. TypeScript reveló que el campo `isExplicit` no existe en el schema real de `/tracks` — el badge "explicit" de Explorar ya era un no-op antes del refactor (siempre `undefined ?? false`); se retira el binding en vez de fingir soporte. Explorar: `GENRE_INFO`/`genreTiles` → `src/lib/genres.ts`; lógica de búsqueda (`showGroup`/`capped`/`matchPlaylists`/`searchCounts`/`searchChips`/`findTopResult`) → `src/lib/search.ts` (funciones puras, parametrizadas por `sfilter`/`query` en vez de cerrar sobre el estado del componente); "Mejor resultado" unifica el botón/enlace duplicado con `<svelte:element this={...}>`. Decisión tomada sin bloquear: **no** se colapsan los 4 bloques de grupo (Canciones/Álbumes/Usuarios/Playlists) en un único `{#each groups}` — cada grupo tiene snippets, contextmenu y props de `ListRow` genuinamente distintos (menú de pista vs. de álbum, overlay solo en canciones, trailing con formato distinto), y forzar una configuración genérica habría añadido más indirección de la que elimina. Inicio: usa `Page` (antes `page-x pt-3 sm:pt-4` a mano); stats ×3 → `{#each}`; fondos de hero/spotlight (`style=` con `var(--mf-hero-bg)`/`var(--mf-spotlight-bg)`) → utilities `hero-surface`/`spotlight-surface` (nuevas, en `layout.css`) y token `--spacing-section: 2.875rem` → `gap-section` sustituye `gap-11.5`. Sin QA visual autenticada (Docker no disponible); `/explore` sin sesión redirige a `/login` porque `+layout.server.ts` exige `allowAnonymousListening` desde el backend (inalcanzable sin Docker) — comportamiento preexistente, no una regresión; smoke test de `/login` sin errores de consola |
|
||||
| P14 | ✅ | (pendiente) | `scripts/check-tokens.mjs` (`npm run lint:tokens`, añadido a `lint`) escanea `src/**/*.svelte` y falla en: arbitrarios `-[…]` fuera de la lista blanca de B.4, `rounded-(sm/md/lg/xl/2xl/3xl)`/`rounded` a secas, pares `h-N w-N` (deberían ser `size-N`), espaciado fraccionario en cuartos (`.25`/`.75`), hex/`rgba()` literales. Se limpió el repo hasta dejarlo en 0 violaciones: ~40 pares `h-N w-N` → `size-N` (icons sobre todo, vía script puntual), ~26 espaciados en cuartos redondeados al entero más cercano (Δ≤1px, p. ej. `py-2.75`→`py-3`, `gap-1.75`→`gap-2`), 3 `rounded-sm/lg` + 1 `rounded` a secas → tokens (`rounded-tag`/`rounded-thumb`/`rounded-control`), 2 `tracking-[-0.02em]` → `tracking-display` (coincidían exactamente con el token ya existente y no se habían migrado), 1 `tracking-[0.14em]` en Explorar → receta `text-eyebrow`, `rounded-3xl` del hero de Inicio → `rounded-panel-lg` (consistente con la tarjeta de playlist destacada). Se retiraron las recetas `stroke-thin/regular/bold` (creadas en P1, 0 consumidores) — **decisión tomada, sin bloquear**: no se migra `strokeWidth={n}` (13 valores, en casi todos los componentes) a esas recetas por CSS porque no se puede verificar sin Docker que `lucide-svelte` respete `stroke-width` por clase en vez del prop, y una regresión ahí sería silenciosa en toda la app; el script documenta este gap explícitamente y no lo exige. Se abordó también junto con la tarea 5 diferida de P5 (ver P13). Detector de huérfanos: 0 componentes sin importadores (los 47 de `src/lib/components/` están todos en uso). `docs/design-system-refactor-plan.md` (plan anterior, superseded) borrado. `web-player/CLAUDE.md` gana sección "Estilos" con la lista blanca B.4, componentes base a reutilizar y el gap de `strokeWidth` documentado. Línea base recalculada (ver tabla debajo) |
|
||||
|
||||
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?
|
||||
|
||||
### D.1 Línea base recalculada (tras P14, cierre del refactor)
|
||||
|
||||
Recalculada con los mismos criterios que A.4 (grep sobre `src/`), en la misma sesión de
|
||||
cierre de P14.
|
||||
|
||||
| Métrica | A.4 (antes) | Tras P14 |
|
||||
| -------------------------------------------------- | ----------- | ------------------------------------------ |
|
||||
| Componentes `.svelte` en `src/lib/components/` | 61 | 47 |
|
||||
| Líneas `.svelte`/`.css`/`.ts` cliente | ~8.700 | ~8.310 (−~390; el objetivo de −1.500/−2.000 ya se había alcanzado en P3–P11, antes de que P13 añadiera `genres.ts`/`search.ts` como código nuevo bien separado) |
|
||||
| Clases arbitrarias `x-[…]` | 44 | 11 (las 11 restantes, en la lista blanca de B.4) |
|
||||
| Espaciados fraccionarios en cuartos (`.25`/`.75`) | ~190 apariciones | 0 (redondeados al entero más cercano, Δ≤1px) |
|
||||
| Radios distintos usados | 13 | 8 (7 tokens B.1.1 + `rounded-full`) |
|
||||
| `strokeWidth` distintos | 13 | 12 (fuera de alcance, ver Registro P14 y CLAUDE.md) |
|
||||
| Atributos `style="…"` | 24 | 16 (en 12 archivos; todos dinámicos por instancia — stagger, posición de menú, `var(--mf-*)`, animación — ninguno con color/medida literal) |
|
||||
| Pluralizaciones a mano `=== 1 ?` | 15 | 2 (1 es la propia `plural()` en `format.ts`; 1 es concordancia de participio en `library` que `plural()` no puede cubrir) |
|
||||
| Errores de `eslint` | 2 | 0 |
|
||||
| `npm run lint:tokens` (nuevo, P14) | — | 0 violaciones |
|
||||
|
||||
---
|
||||
|
||||
## 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 |
|
||||
@@ -0,0 +1,185 @@
|
||||
# Plan de estructura del frontend
|
||||
|
||||
Plan derivado de la revisión de estructura del `web-player` (2026-09-24), hecha después de cerrar `frontend-audit-plan.md` (P0–P12). Cada parte es un PR independiente que deja `npm run check` y `npm run lint` en verde y no cambia el comportamiento visible. Antes de empezar una parte, mira el **Registro** del final.
|
||||
|
||||
Leyenda de tamaño: **S** (< 1 h) · **M** (medio día) · **L** (1 día o más).
|
||||
|
||||
## Orden y dependencias
|
||||
|
||||
```
|
||||
E1 Layout del grupo (app)
|
||||
└─ E2 Carpeta shell
|
||||
└─ E3 Frontera de ui/ ─ E6 Explorar en componentes
|
||||
|
||||
E4 Ubicación de estados (independiente)
|
||||
E5 Capa de servidor (independiente)
|
||||
E7 Acento fuera del reproductor (después de E1)
|
||||
|
||||
E9 Tests (antes o junto con E1 y E5)
|
||||
|
||||
E8 Documentación (al final)
|
||||
```
|
||||
|
||||
E1 → E2 → E3 van en orden porque tocan los mismos imports. E4 y E5 se pueden hacer en cualquier momento. Conviene hacer E9 primero, o al menos sus puntos 1 y 2, para que E1 y E5 muevan el código de sesión con tests que detecten si algo se rompe.
|
||||
|
||||
---
|
||||
|
||||
## E1 · Layout del grupo `(app)` (M)
|
||||
|
||||
Problema: el grupo `(app)` no tiene `+layout`. El shell y las cargas de la app están en la raíz, que distingue la pantalla de login a mano (`isAuthPage` en `+layout.svelte`, `PUBLIC_PATHS` en `+layout.server.ts`). Por eso `/auth` también consulta `playbackConfig`, y `+error.svelte` se pinta fuera del shell (un 404 dentro de la app pierde la barra lateral y el reproductor).
|
||||
|
||||
1. **`routes/+layout.svelte`** se queda con lo global: fuentes, `theme.css`, `layout.css`, favicon, el `<style>` inicial del tema, `animateThemeHue` y `<DialogHost />`. Renderiza `children` sin envoltorio.
|
||||
2. **`routes/+layout.server.ts`** desaparece (o queda vacío). Si alguna ruta fuera de `(app)` necesita `user`, lo lee de `locals` en su propio `load`.
|
||||
3. **Nuevo `routes/(app)/+layout.server.ts`** con todo lo que hoy hay en la raíz: `getAllowAnonymousListening`, el guard (`loginRedirect` si no hay usuario ni escucha anónima), `accountUrl`, playlists del usuario, me gusta y última pista. Sin `PUBLIC_PATHS`.
|
||||
4. **Nuevo `routes/(app)/+layout.svelte`** con el shell: backdrop, `Sidebar`, `MobileHeader`, `TopBar`, `<main>` con el `{#key}`, `Queue`, `PlayerDock`, el modal de crear playlist, la hidratación de `liked` y `player`, la apertura automática de la cola y el atajo de la barra espaciadora. Desaparecen `isAuthPage` y la rama `!data.user && !data.allowAnonymousListening`, porque el guard ya ha redirigido.
|
||||
5. **Nuevo `routes/(app)/+error.svelte`** (el contenido del actual) para que los errores dentro de la app se vean con el shell. El `+error.svelte` de la raíz se queda como fallback mínimo para errores fuera del grupo.
|
||||
6. **No quitar** los `requireUser`/`optionalUser` de las páginas ni `authedAction`. Los `load` de página se ejecutan en paralelo con los del layout, y las acciones de formulario no ejecutan el `load` del layout, así que el guard del layout no basta.
|
||||
|
||||
**Cierre:** `grep -rn "isAuthPage\|PUBLIC_PATHS" src` vacío; `/auth` no llama a la API; un 404 en `/albums/xxx` se ve dentro del shell; prueba manual con sesión, sin sesión y con escucha anónima.
|
||||
|
||||
---
|
||||
|
||||
## E2 · Carpeta `shell` (S)
|
||||
|
||||
Problema: hay dos carpetas `layout` con significados distintos (`components/layout/` es el shell de la app y `components/ui/layout/` son piezas para montar páginas). `AccountMenu` está en `ui/overlay/`, pero solo lo usan `TopBar` y `MobileHeader`.
|
||||
|
||||
1. Renombrar `components/layout/` a `components/shell/` (`Sidebar`, `TopBar`, `TabsBar`, `MobileHeader`, `navLinks.ts`).
|
||||
2. Mover `components/ui/overlay/AccountMenu.svelte` a `components/shell/AccountMenu.svelte`.
|
||||
3. Actualizar imports en `(app)/+layout.svelte` y `components/player/PlayerDock.svelte`.
|
||||
|
||||
**Cierre:** `grep -rn "components/layout/\|overlay/AccountMenu" src` vacío.
|
||||
|
||||
---
|
||||
|
||||
## E3 · Frontera de `components/ui/` (M)
|
||||
|
||||
Problema: `ui/` se presenta como el design system, pero cuatro componentes dependen del estado global de la app. `TrackList` y `PlayAllButton` importan `player`. `TrackContextMenu` y `AlbumContextMenu` importan `player`, y `AlbumContextMenu` también importa `$lib/data/albums`. El resto de `ui/media/` son componentes de presentación sin estado (reciben props), y así deben quedarse.
|
||||
|
||||
1. **Nueva carpeta `components/music/`** para los componentes que conectan la interfaz con el estado de la app:
|
||||
- `ui/media/TrackList.svelte` → `music/TrackList.svelte`
|
||||
- `ui/media/PlayAllButton.svelte` → `music/PlayAllButton.svelte`
|
||||
- `ui/overlay/TrackContextMenu.svelte` → `music/TrackContextMenu.svelte`
|
||||
- `ui/overlay/AlbumContextMenu.svelte` → `music/AlbumContextMenu.svelte`
|
||||
2. **Regla de ESLint:** un bloque con `files: ['src/lib/components/ui/**']` y `no-restricted-imports` que prohíba `$lib/player/*`, `$lib/data/*`, `$lib/server/*`, `$lib/components/music/*`, `$lib/components/shell/*` y `$lib/components/player/*`. Hay que repetir en ese bloque el patrón `../*` del bloque general, porque la configuración de un override sustituye a la del bloque general, no se suma.
|
||||
3. Comprobar que la regla pasa sin excepciones. `ui/` puede seguir importando `$lib/state/*` (historial, diálogos), `$lib/utils/*`, `$lib/actions/*`, `$lib/theme/*` y `$lib/types`.
|
||||
|
||||
**Cierre:** `npm run lint` en verde; si un componente de `ui/` importa `player`, el lint falla.
|
||||
|
||||
---
|
||||
|
||||
## E4 · Ubicación de estados (S)
|
||||
|
||||
1. `player/liked.svelte.ts` → `state/liked.svelte.ts`. Guarda los me gusta del usuario, no el estado del reproductor, y lo usan `Sidebar`, `TrackInfo`, el layout y las páginas.
|
||||
2. `data/recentlyPlayed.ts` → `player/recentlyPlayed.ts`. Solo mezcla `player.recentlyPlayed` con el historial, y hoy es el único archivo de `data/` que depende del reproductor.
|
||||
|
||||
**Cierre:** `grep -rn "player/liked\|data/recentlyPlayed" src` vacío; `data/` no importa de `player/`.
|
||||
|
||||
---
|
||||
|
||||
## E5 · Capa de servidor (S–M)
|
||||
|
||||
Problema: `server/api.ts` mezcla el cliente de la API, los guards de sesión, la lectura de formularios y el envoltorio de acciones. Los parsers de `server/forms/` importan `api.ts` solo para usar `formString`/`formFile`.
|
||||
|
||||
1. **`server/api.ts`** se queda con el cliente y el manejo de errores: `createApiClient`, `ApiClient`, `apiFor`, `unwrapOrError`, `apiErrorDetail`, `failOnError`, `requireData`.
|
||||
2. **Nuevo `server/guards.ts`:** `loginRedirect`, `requireUser`, `optionalUser` (14 imports en `routes/` más `(app)/+layout.server.ts`).
|
||||
3. **Nuevo `server/forms/fields.ts`:** `formString`, `formFile`. Los parsers de `forms/` lo importan desde ahí.
|
||||
4. **Nueva carpeta `server/actions/`:**
|
||||
- `authedAction.ts` con `authedAction` y `ActionContext`.
|
||||
- `followActions.ts` → `actions/follow.ts`.
|
||||
- `playlistActions.ts` → `actions/playlist.ts`.
|
||||
|
||||
**Cierre:** `server/forms/` no importa `server/api`; `api.ts` no importa `redirect` ni `RequestEvent` salvo para `apiFor`.
|
||||
|
||||
---
|
||||
|
||||
## E6 · Explorar en componentes (M)
|
||||
|
||||
Problema: `(app)/explore/+page.svelte` (393 líneas) resuelve tres vistas en una: la rejilla de géneros, la vista de un género y los resultados de búsqueda (mejor resultado y cuatro grupos).
|
||||
|
||||
1. Sacar componentes junto a la página (SvelteKit ignora los archivos sin `+` dentro de `routes/`):
|
||||
- `explore/GenreGrid.svelte`: la rejilla de géneros y el estado vacío.
|
||||
- `explore/TopResult.svelte`: el snippet `topResultBody` y `playTopResult`.
|
||||
- `explore/SearchResults.svelte`: los cuatro grupos, `capped`, `showGroup` y el `InfiniteScroll` de pistas.
|
||||
2. `+page.svelte` queda como selector de vista: cabecera, chips, mensaje de "sin resultados" y los dos menús contextuales.
|
||||
3. El estado compartido (`createPagedList`, `trackMenu`, `albumMenu`) se crea en la página y se pasa por props; los componentes no leen `page.data`.
|
||||
|
||||
**Cierre:** `explore/+page.svelte` por debajo de ~150 líneas; ninguna vista nueva supera las ~150.
|
||||
|
||||
---
|
||||
|
||||
## E7 · Acento fuera del reproductor (S)
|
||||
|
||||
Problema: `player.svelte.ts` importa `theme/accent.svelte` y en `#announce` llama a `accent.follow(...)`, así que el estado del reproductor depende del tema visual.
|
||||
|
||||
1. Quitar `accent` y `trackCover` de `player.svelte.ts`; `#announce` solo actualiza `mediaSession`.
|
||||
2. En `(app)/+layout.svelte`, un `$effect` que siga a `player.currentId` y llame a `accent.follow(trackCover(id, 'small'), () => player.currentId === id)`.
|
||||
3. Comprobar que la hidratación de la última pista también actualiza el acento (el efecto depende de `currentId`, así que debería).
|
||||
|
||||
**Cierre:** `player/` no importa de `theme/`; el color cambia al cambiar de canción y al cargar la página con una última pista.
|
||||
|
||||
---
|
||||
|
||||
## E8 · Documentación y restos (S)
|
||||
|
||||
1. **`CLAUDE.md`:**
|
||||
- Estructura de carpetas con `components/shell/`, `components/music/`, la regla de frontera de `ui/`, `state/liked.svelte.ts`, `server/guards.ts`, `server/forms/fields.ts` y `server/actions/`.
|
||||
- Documentar lo que falta: `lib/actions/` (`clickOutside`, `onEscape`, `pressable`), `state/toggle.svelte.ts` y `theme/color.ts`.
|
||||
- Corregir la referencia a `$lib/state/navigation.svelte.ts` en "App shell" (ahora es `restoreScroll` en `$lib/state/scroll.ts`) y describir el shell en `(app)/+layout.svelte`.
|
||||
- La skill `musify-web` que cita la introducción no está en `web-player/.claude/skills`: añadirla o quitar la referencia.
|
||||
2. **Conversiones fuera de los mappers.** La regla de P5 dice que los `Number(...)` sobre DTOs solo se hacen en `server/mappers.ts`, pero quedan varios en `routes/`:
|
||||
- `(app)/+page.server.ts`: las estadísticas de escucha (nuevo `toListeningStats`, con test) y `totalItemCount` de las playlists.
|
||||
- `(app)/explore/+page.server.ts`: `albumsTotal` y `usersTotal`.
|
||||
- `(app)/user/[id]/+page.server.ts`: `followersCount`.
|
||||
- La carga de playlists del layout (que tras E1 está en `(app)/+layout.server.ts`): `totalItemCount`.
|
||||
|
||||
Los totales pueden salir de `toPage(...).totalItemCount` o de un helper `toCount` en los mappers.
|
||||
|
||||
3. **Planes:** marcar `frontend-audit-plan.md` como cerrado (o archivarlo) y dejar este como el plan activo.
|
||||
|
||||
**Cierre:** cada ruta citada en `CLAUDE.md` existe; `grep -rn "Number(" src/routes` solo devuelve lecturas de parámetros o formularios.
|
||||
|
||||
---
|
||||
|
||||
## E9 · Tests de los arreglos y del código con efectos (M)
|
||||
|
||||
Los tests se quedan junto al archivo que prueban (`x.ts` + `x.test.ts`); no se crea una carpeta `tests/`. Hoy cubren bien la lógica pura (formatos, mappers, parsers, cola, búsqueda), pero no el código con fetch, estado o tiempo, ni los arreglos de seguridad de P1 y P2, que pueden volver sin que nada falle.
|
||||
|
||||
1. **Sesión y guards.**
|
||||
- `server/auth.ts`: `encodeSession` → `decodeSession` devuelve la misma sesión; un token manipulado, cifrado con otra clave o vacío devuelve `null`. Para el secreto, usar `vi.mock('$env/dynamic/private')` con un `SESSION_SECRET` de prueba, no el `.env` local (en CI no existe).
|
||||
- Guards (`requireUser`, `optionalUser`, `loginRedirect`; en `server/guards.ts` tras E5): redirigen a `/auth?returnTo=…` con la ruta y la query codificadas, y dejan pasar cuando toca.
|
||||
- Tras E1, el `load` de `(app)/+layout.server.ts`: redirige sin sesión ni escucha anónima, devuelve datos vacíos en modo anónimo y no llama a la API sin usuario.
|
||||
2. **Endpoints `/api` (arreglos de P1).** Llamar al `RequestHandler` con un evento falso y un `fetch` simulado:
|
||||
- `api/likes`: 401 sin token, 400 con `trackId` ausente, vacío o no string, 502 si la API falla.
|
||||
- `api/listens/[id]/progress`: 400 con JSON inválido, con `playedSeconds` negativo, no finito o no numérico; 404 si la API devuelve 404.
|
||||
- `api/tracks`: `pageSize` se limita a `MAX_PAGE_SIZE` y `pageNumber` inválido pasa a 1.
|
||||
3. **Rollback de "Me gusta" (`liked.svelte.ts`, arreglo de P2).** Con `fetch` simulado que falla: se revierte solo esa pista y se conservan los cambios hechos mientras tanto en otras. También los casos de dar y quitar el me gusta, y que `hydrate` solo se aplica una vez.
|
||||
4. **`ListenTracker`.** Con `vi.useFakeTimers()` y `fetch` simulado:
|
||||
- Suma solo los avances de hasta 2,5 s; un salto mayor (seek) o hacia atrás no cuenta.
|
||||
- `interrupt()` hace que el siguiente `tick` no sume.
|
||||
- Envía a los 30 s y en `flush()`; no reenvía si no hay progreso nuevo.
|
||||
- Si el envío falla, el siguiente `flush()` vuelve a enviar el total.
|
||||
- `begin()` envía lo pendiente de la escucha anterior antes de empezar la nueva.
|
||||
5. **`createPagedList`.** Añade la página siguiente sin duplicados, actualiza `hasMore`, ignora un `loadMore` con otro en curso y, si falla, marca `error` y conserva los elementos. `reset` limpia el error.
|
||||
6. **`apiErrorDetail`.** Mensajes de `errors` (varios campos, arrays), después `detail`, después `title`, y `undefined` para valores que no son objeto o sin texto.
|
||||
|
||||
Fuera de alcance, a propósito: tests de componentes (sobre todo presentación, ya vigilada por `check-tokens`), utilidades triviales (`storage`, `menuPosition`, `navLinks`), `mediaSession`, `progressClock`, `palette` (necesita canvas) y `player.svelte.ts` entero (depende de `<audio>`; su lógica está en `queue.ts`, que ya tiene tests). E2E con Playwright queda para cuando CI pueda levantar el backend y Zitadel.
|
||||
|
||||
**Cierre:** cada punto anterior tiene su `*.test.ts` junto al módulo; `npm run test` en verde sin `.env` local; volver a introducir cualquiera de los bugs de P1/P2 cubiertos hace fallar un test.
|
||||
|
||||
---
|
||||
|
||||
## Registro
|
||||
|
||||
| Parte | Estado | PR / notas |
|
||||
| ------------------------------- | ------ | ---------------------------------------- |
|
||||
| E1 Layout del grupo (app) | ✅ | |
|
||||
| E2 Carpeta shell | ✅ | |
|
||||
| E3 Frontera de ui/ | ✅ | Regla de ESLint sobre `components/ui/**` |
|
||||
| E4 Ubicación de estados | ✅ | |
|
||||
| E5 Capa de servidor | ✅ | |
|
||||
| E6 Explorar en componentes | ✅ | `+page.svelte` queda en ~180 líneas |
|
||||
| E7 Acento fuera del reproductor | ✅ | |
|
||||
| E8 Documentación y restos | ✅ | |
|
||||
| E9 Tests | ✅ | 121 tests, sin `.env` local |
|
||||
|
||||
Leyenda: ⬜ pendiente · 🟡 en curso · ✅ hecho.
|
||||
+36
-145
@@ -1,159 +1,50 @@
|
||||
# Musify — Guía de páginas
|
||||
# Musify, page guide
|
||||
|
||||
Documento vivo de qué debe contener cada página del frontend web. Sirve como mapa
|
||||
para priorizar features. El sistema de diseño (tokens, recetas, componentes) vive en
|
||||
el skill `musify-web`; aquí describimos **contenido y estructura**, no estilos.
|
||||
Living document describing what each page of the web frontend contains. The design system (tokens, recipes, components) lives in the `musify-web` skill and in `CLAUDE.md`; here we describe **content and structure**, not styling.
|
||||
|
||||
Leyenda de estado: ✅ hecho · 🟡 parcial / maqueta · ⬜ pendiente.
|
||||
## Route structure
|
||||
|
||||
> **Nota (rediseño app):** se está portando el prototipo `Musify.dc.html` (app: sidebar +
|
||||
> vistas + player persistente con acento dinámico por pista). La landing de marketing se
|
||||
> eliminó. Las secciones de abajo describen el diseño anterior y quedan pendientes de
|
||||
> actualizar a las vistas de la app (Inicio, Buscar, Biblioteca, Subir, Importar, Descargas,
|
||||
> Playlists). El **player** ya está hecho por componentes en `$lib/components/player/` con el
|
||||
> tema en `$lib/theme/` (`theme.css` paleta + `color.ts` acento/gradiente).
|
||||
|
||||
## Inicio (app) `/` 🟡
|
||||
|
||||
- **Hero**: saludo + "Tu música. Sin límites." con color-wash del hue de la pista actual.
|
||||
- **Acceso rápido = escuchado recientemente** (`player.recentlyPlayed`, hoy en cliente).
|
||||
Estado vacío con icono + CTA «Explorar».
|
||||
- **Tus playlists**: tarjetas mosaico de las playlists (hoy datos de muestra). Estado vacío.
|
||||
|
||||
### Backend pendiente (ASP.NET) para Inicio
|
||||
|
||||
- **Escuchado recientemente**: registrar reproducciones por usuario (tabla de historial:
|
||||
`userId`, `trackId`, `playedAt`) y exponer `GET /me/recently-played?limit=` (o
|
||||
`/users/{id}/recently-played`) devolviendo pistas ordenadas por `playedAt` desc, sin
|
||||
duplicados. El front lo cargaría en un `load` y lo pasaría al store en vez de `recentlyPlayed`.
|
||||
- **Playlists por última escucha**: añadir `lastPlayedAt` a la playlist (hoy el schema solo
|
||||
tiene `createdAt`/`updatedAt`) y permitir ordenar `GET /playlists/users/{userId}` por ese
|
||||
campo (p. ej. `?sort=lastPlayed`), para mostrar «tus últimas escuchadas».
|
||||
- Las pistas necesitarán **artista** y un **color/hue** (o portada real de la que extraerlo)
|
||||
para el arte y el acento dinámico; hoy `TrackApplicationResponse` no trae artista ni color.
|
||||
|
||||
## Estructura de rutas
|
||||
|
||||
Rutas agrupadas para separar lo público de lo autenticado (los grupos no cambian la URL):
|
||||
Everything the user sees after signing in lives under the `(app)` group (the group doesn't change the URL). `auth/` and `api/` sit outside it.
|
||||
|
||||
```
|
||||
src/routes/
|
||||
(marketing)/ Público, sin sesión obligatoria
|
||||
+page.svelte / Landing ✅
|
||||
(app)/ Experiencia de la app
|
||||
explore/ /explore Explorar / buscar ✅
|
||||
upload/ /upload Subir música 🟡 maqueta
|
||||
library/ /library Tu biblioteca ⬜
|
||||
playlists/ /playlists Tus playlists ⬜
|
||||
[id]/ /playlists/:id Detalle playlist ⬜
|
||||
login logout auth/callback Auth (endpoints) ✅
|
||||
api/tracks/[id]/stream Proxy de stream ✅
|
||||
(app)/
|
||||
+page.svelte / Home
|
||||
explore/ /explore Search and genre discovery
|
||||
liked/ /liked Liked songs
|
||||
library/ /library Your uploaded songs
|
||||
upload/ /upload Upload a song
|
||||
albums/ /albums Your albums
|
||||
[id]/ /albums/:id Album detail
|
||||
playlists/ /playlists Your playlists
|
||||
[id]/ /playlists/:id Playlist detail
|
||||
mixes/[id]/ /mixes/:id Generated mix
|
||||
user/[id]/ /user/:id Public profile
|
||||
[list=followList]/ /user/:id/followers|following
|
||||
auth/ auth/login auth/logout auth/callback Login screen and OIDC endpoints
|
||||
api/ Same origin JSON endpoints used by the client
|
||||
```
|
||||
|
||||
Componentes globales (en todas las páginas): **Navbar**, **Footer** y **PlayerBar**
|
||||
(barra de reproducción fija, aparece al reproducir).
|
||||
Global chrome on every app page: sidebar (desktop) or tab bar (mobile), top bar with search and account menu, the persistent player dock, and the queue panel.
|
||||
|
||||
---
|
||||
## Pages
|
||||
|
||||
## 1. Landing `/` ✅ (marketing)
|
||||
- **Home `/`**: a greeting, weekly listening stats, "continue listening" (recently played merged with the in-session history), the featured playlist with its first tracks (play and shuffle load the full list on demand), the user's mixes and playlists. Anonymous visitors are redirected to `/explore`.
|
||||
- **Explore `/explore`**: genre tiles when there is no query, otherwise search results grouped by songs, albums, users and playlists with filter chips and counts taken from the API totals. Songs paginate with infinite scroll. Track and album context menus offer play next, add to queue and add to playlist.
|
||||
- **Liked `/liked`**: the songs the user liked, newest first, with unlike per row.
|
||||
- **Library `/library`**: the songs the user uploaded, with a confirmed delete.
|
||||
- **Upload `/upload`**: audio, cover, title, genres (incompatible genres are blocked) and explicit flag. The server reserves presigned URLs, uploads both files and creates the track; processing happens asynchronously in the backend.
|
||||
- **Albums `/albums`, `/albums/:id`**: list and create albums. The owner can edit, delete and add or remove tracks from their library.
|
||||
- **Playlists `/playlists`, `/playlists/:id`**: list and create playlists. The owner can edit, delete and remove tracks; everyone else gets a read only view.
|
||||
- **Mix `/mixes/:id`**: a generated mix with play, shuffle and a track menu.
|
||||
- **Profile `/user/:id`**: public playlists, follow button and links to the followers and following lists, which respect the profile's privacy.
|
||||
- **Auth `/auth`**: the login screen. Its buttons lead to `/auth/login`, which starts the Zitadel OIDC flow; `?mode=register` opens the sign up variant and `?returnTo=` (local paths only) sends the user back afterwards. `/auth/logout` ends the session and `/auth/callback` creates the encrypted `mf_session` cookie.
|
||||
|
||||
Página de entrada para no autenticados. Vender el producto y llevar a registro/explorar.
|
||||
## Data flow
|
||||
|
||||
- **Hero**: badge de estado (beta), título, subtítulo, CTAs primario (Empezar gratis →
|
||||
registro) y secundario (Explorar canciones).
|
||||
- **Features**: 3 cards (escuchar, playlists, subir).
|
||||
- **Pendiente sugerido**: sección de capturas/demo, prueba social, FAQ corta.
|
||||
Each page loads its data in `+page.server.ts` through the typed API client, and normalizes it with the mappers in `$lib/server/mappers.ts` so the client only ever sees the domain types in `$lib/types.ts`. Mutations are form actions built with `authedAction`. Client side pagination goes through the `/api/*` endpoints.
|
||||
|
||||
## 2. Login / Auth ✅ (endpoints)
|
||||
## Pending backend work
|
||||
|
||||
No es una página visual propia: `/login` inicia el flujo OIDC de Zitadel y redirige.
|
||||
`?mode=register` arranca en registro; `?returnTo=` vuelve a la ruta pedida tras entrar.
|
||||
`/logout` cierra sesión. `/auth/callback` intercambia el código y crea la cookie
|
||||
cifrada `mf_session`. La UI de credenciales la sirve Zitadel, no Musify.
|
||||
|
||||
## 3. Explorar / Buscar `/explore` ✅ (app)
|
||||
|
||||
Descubrimiento y búsqueda de canciones del catálogo.
|
||||
|
||||
- **Buscador**: input de texto (`?q=`) que filtra por nombre en el backend.
|
||||
- **Contador de resultados** y **grid de tracks** (2→4 columnas): portada, título, fecha,
|
||||
botón play/pausa en hover que encola y reproduce vía el player global.
|
||||
- **Paginación** anterior/siguiente (`?page=`, 24 por página).
|
||||
- **Estado vacío**: sin resultados / catálogo vacío.
|
||||
- **Pendiente sugerido**: filtros (más recientes, por usuario), skeletons de carga,
|
||||
portada real del track (hoy la API de listado no devuelve la key de imagen).
|
||||
|
||||
## 4. Subir música `/upload` 🟡 maqueta (app)
|
||||
|
||||
Alta de una canción nueva. Flujo real de backend (para cuando se cablee):
|
||||
|
||||
1. `POST /tracks/upload-urls` → URLs presignadas para portada y audio + `intentId`s.
|
||||
2. `PUT` de cada archivo a su URL presignada (subida directa al almacenamiento).
|
||||
3. `POST /tracks` con `title`, `pictureIntentId`, `audioIntentId`.
|
||||
4. El backend procesa el audio (transcodifica a DASH) de forma asíncrona.
|
||||
|
||||
Contenido de la página:
|
||||
|
||||
- **Zona de audio**: drag & drop o selector; muestra nombre y tamaño del archivo, validación
|
||||
de tipo (audio) y tamaño.
|
||||
- **Portada**: selector de imagen con vista previa cuadrada.
|
||||
- **Metadatos**: título (obligatorio). Espacio para futuros campos (descripción, etc.).
|
||||
- **Pipeline visible**: pasos Subir → Procesar → Publicar con su estado.
|
||||
- **Barra de progreso** de subida y estado de procesado.
|
||||
- **Estados**: vacío, archivo seleccionado, subiendo, procesando, publicado, error.
|
||||
- **Requiere sesión** (al cablear: redirigir a `/login?returnTo=/upload`).
|
||||
|
||||
## 5. Tu biblioteca `/library` ⬜ (app)
|
||||
|
||||
Panel personal del usuario autenticado: lo suyo en un sitio.
|
||||
|
||||
- **Tabs / secciones**: «Tus canciones» (`GET /tracks/users/{userId}`) y «Tus playlists»
|
||||
(`GET /playlists/users/{userId}`).
|
||||
- **Tus canciones**: lista con play, y acciones de gestión (borrar `DELETE /tracks/{id}`,
|
||||
añadir a playlist).
|
||||
- **Tus playlists**: grid de portadas + botón «Crear playlist».
|
||||
- **Estado vacío** con CTA a subir / crear.
|
||||
- **Requiere sesión**.
|
||||
|
||||
## 6. Playlists `/playlists` ⬜ (app)
|
||||
|
||||
Listado de las playlists del usuario.
|
||||
|
||||
- **Grid** de playlists (portada, nombre, nº de tracks) → enlaza al detalle.
|
||||
- **Crear playlist**: modal/página con nombre, descripción y portada
|
||||
(`POST /playlists`, portada vía `POST /playlists/upload-picture`).
|
||||
- **Estado vacío** con CTA.
|
||||
- **Requiere sesión**.
|
||||
|
||||
## 7. Detalle de playlist `/playlists/[id]` ⬜ (app)
|
||||
|
||||
- **Cabecera**: portada grande, nombre, descripción, nº de tracks, botón «Reproducir todo».
|
||||
- **Lista de tracks** (`GET /playlists/{id}/tracks`, paginada): índice, título, fecha,
|
||||
play, quitar de la playlist (`DELETE /playlists/{id}/tracks/{trackId}`).
|
||||
- **Acciones del dueño**: editar (`PUT`), borrar (`DELETE`) la playlist, reordenar (futuro).
|
||||
- **Añadir tracks**: desde explorar/biblioteca (`POST /playlists/{id}/tracks/{trackId}`).
|
||||
|
||||
## 8. Perfil de usuario `/users/[id]` ⬜ (futuro)
|
||||
|
||||
Vista pública de un usuario: avatar, nombre, sus canciones y playlists públicas
|
||||
(`GET /users/{id}`, `GET /tracks/users/{id}`, `GET /playlists/users/{id}`).
|
||||
|
||||
---
|
||||
|
||||
## Player global (PlayerBar) ✅
|
||||
|
||||
Barra fija inferior visible al reproducir. Info de la pista, transporte
|
||||
(anterior/play-pausa/siguiente), barra de progreso y control de volumen. Encola listas
|
||||
desde cualquier grid (`player.playQueue`). Reproduce DASH vía el proxy
|
||||
`/api/tracks/:id/stream`, que pide manifiesto + ticket firmado al backend.
|
||||
|
||||
## Backend disponible (resumen)
|
||||
|
||||
| Recurso | Endpoints |
|
||||
| --------- | ----------------------------------------------------------------------- |
|
||||
| Tracks | listar/buscar, detalle, por usuario, stream, crear, borrar, upload-urls |
|
||||
| Playlists | CRUD, tracks de la playlist, add/remove track, upload-picture |
|
||||
| Users | listar/buscar, detalle, crear |
|
||||
|
||||
Cliente tipado en `$lib/server/api.ts` (`openapi-fetch`, estilo ErrorOr). Consumir en
|
||||
`load`/actions server, nunca desde cliente. Tipos en `src/lib/api/schema.d.ts`.
|
||||
- Sorting playlists by last listen (`lastPlayedAt`) for a "recently played playlists" shelf.
|
||||
- An endpoint returning the most listened tracks, to bring back a "popular" section on Home.
|
||||
|
||||
@@ -19,6 +19,19 @@ export default ts.config(
|
||||
'error',
|
||||
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' }
|
||||
],
|
||||
'@typescript-eslint/no-non-null-assertion': 'error',
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
group: ['../*'],
|
||||
message: 'Use $lib/... instead of relative parent imports.'
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
'svelte/no-unused-props': 'error',
|
||||
'svelte/no-navigation-without-resolve': 'off',
|
||||
'svelte/prefer-svelte-reactivity': 'off'
|
||||
}
|
||||
@@ -36,12 +49,34 @@ export default ts.config(
|
||||
}
|
||||
},
|
||||
{
|
||||
ignores: [
|
||||
'build/',
|
||||
'.svelte-kit/',
|
||||
'static/',
|
||||
'src/lib/api/schema.d.ts',
|
||||
'design_handoff_musify_player/'
|
||||
files: ['src/lib/components/ui/**'],
|
||||
rules: {
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
group: ['../*'],
|
||||
message: 'Use $lib/... instead of relative parent imports.'
|
||||
},
|
||||
{
|
||||
group: [
|
||||
'$lib/player/*',
|
||||
'$lib/data/*',
|
||||
'$lib/server/*',
|
||||
'$lib/components/music/*',
|
||||
'$lib/components/shell/*',
|
||||
'$lib/components/player/*'
|
||||
],
|
||||
message:
|
||||
'components/ui is the presentation layer: it cannot depend on app state, data or other component layers.'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
ignores: ['build/', '.svelte-kit/', 'static/', 'src/lib/api/schema.d.ts']
|
||||
}
|
||||
);
|
||||
|
||||
Generated
+344
-35
@@ -36,15 +36,40 @@
|
||||
"tailwindcss": "^4.3.0",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.64.0",
|
||||
"vite": "^8.0.16"
|
||||
"vite": "^8.0.16",
|
||||
"vitest": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.11.3",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz",
|
||||
"integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.3",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.3",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
|
||||
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
|
||||
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz",
|
||||
"integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
@@ -309,9 +334,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
|
||||
"integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
@@ -334,21 +359,24 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
|
||||
"integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.4.tgz",
|
||||
"integrity": "sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@tybys/wasm-util": "^0.10.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=23.5.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emnapi/core": "^1.7.1",
|
||||
"@emnapi/runtime": "^1.7.1"
|
||||
"@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4",
|
||||
"@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
@@ -597,6 +625,16 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
|
||||
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz",
|
||||
@@ -1118,7 +1156,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.69.1.tgz",
|
||||
"integrity": "sha512-+nz8Fx/cElzb2ZPHC+6Ll3y3NEVIe4Na75PeplLlyTmd1dBXAjz2KR14y1ZgNjb2ThfAYzulu+PFy1UE3RCUzA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@sveltejs/acorn-typescript": "^1.0.9",
|
||||
@@ -1170,7 +1207,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.1.2.tgz",
|
||||
"integrity": "sha512-DrUBA2UXRfDmUX/ZTiEopd3X40yavsJF1FX2RygcuIScHL7o5YX1fMvoYnDhjeJQC4weCOklirpNWlcb2NiSeA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"deepmerge": "^4.3.1",
|
||||
"magic-string": "^0.30.21",
|
||||
@@ -1408,6 +1444,27 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
||||
"version": "1.11.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.2",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.2",
|
||||
"dev": true,
|
||||
@@ -1503,21 +1560,39 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
|
||||
"integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
|
||||
"version": "0.10.4",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz",
|
||||
"integrity": "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/deep-eql": "*",
|
||||
"assertion-error": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cookie": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
|
||||
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/deep-eql": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/esrecurse": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
|
||||
@@ -1544,7 +1619,6 @@
|
||||
"integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
@@ -1606,7 +1680,6 @@
|
||||
"integrity": "sha512-KA0OshtlcCCXmbfqyZkM5pV3/WNraJf7DkJRLpyrmwPtud57H5BDX7C3k0LPSPxpprfRL+cJDGabF10mvNCoCw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.64.0",
|
||||
"@typescript-eslint/types": "8.64.0",
|
||||
@@ -1714,7 +1787,6 @@
|
||||
"integrity": "sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
@@ -1793,12 +1865,69 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.1.tgz",
|
||||
"integrity": "sha512-6K1DoBNAPGvuOcSsGA4D6x+5zEEff/KmOOP3uetT2TrGpVfI+HRHRnJJfKi5ib/g1vx8IYHQD8s0pbJz8WQI7Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/trace-mapping": "0.3.31",
|
||||
"@vitest/spy": "5.0.1",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^1.2.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"msw": "^2.4.9",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"msw": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker/node_modules/estree-walker": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker/node_modules/magic-string": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz",
|
||||
"integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.1.tgz",
|
||||
"integrity": "sha512-rbto/mF/SGERxEgYOek7Xm6B9b+y+mVoo+f4b2LymYO8zM1b7uB5nHuhVMTP2hxdzgxvGiZYGxGIaMvL5y180Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.17.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
|
||||
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -1842,6 +1971,16 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/axobject-query": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||
@@ -1874,6 +2013,16 @@
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||
@@ -2014,6 +2163,13 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
|
||||
"integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/escape-string-regexp": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||
@@ -2033,7 +2189,6 @@
|
||||
"integrity": "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
@@ -2275,6 +2430,16 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/expect-type": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
|
||||
"integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
@@ -2959,9 +3124,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/obug": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz",
|
||||
"integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==",
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz",
|
||||
"integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/sxzz",
|
||||
"https://opencollective.com/debug"
|
||||
@@ -3082,9 +3247,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
|
||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
|
||||
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -3112,7 +3277,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.12",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -3246,7 +3410,6 @@
|
||||
"integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
@@ -3263,7 +3426,6 @@
|
||||
"integrity": "sha512-wXvbXMjSvb4C9ENWTHXyd+ihakKCsJ6rJhLP6/8HFNj4GkZr48jqL9PoKsl2sk7SyCZRTnJ7O2TTowUpOxP/KA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
@@ -3434,7 +3596,6 @@
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
|
||||
"integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.9"
|
||||
},
|
||||
@@ -3529,6 +3690,13 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/siginfo": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/sirv": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
|
||||
@@ -3552,6 +3720,20 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/stackback": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/std-env": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
|
||||
"integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/supports-preserve-symlinks-flag": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
|
||||
@@ -3569,7 +3751,6 @@
|
||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.4.tgz",
|
||||
"integrity": "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/remapping": "^2.3.4",
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||
@@ -3717,6 +3898,26 @@
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "6.1.4",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz",
|
||||
"integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyexec": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz",
|
||||
"integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
@@ -3781,7 +3982,6 @@
|
||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -3843,7 +4043,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz",
|
||||
"integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.4",
|
||||
@@ -3935,6 +4134,99 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.1.tgz",
|
||||
"integrity": "sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/mocker": "5.0.1",
|
||||
"chai": "^6.2.2",
|
||||
"es-module-lexer": "^2.3.2",
|
||||
"expect-type": "^1.4.0",
|
||||
"magic-string": "^1.2.3",
|
||||
"obug": "^2.1.4",
|
||||
"picomatch": "^4.0.7",
|
||||
"std-env": "^4.2.0",
|
||||
"tinybench": "6.1.4",
|
||||
"tinyexec": "1.3.0",
|
||||
"tinyglobby": "^0.2.17",
|
||||
"why-is-node-running": "^2.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"vitest": "vitest.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.12.0 || ^24.0.0 || >=26.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@edge-runtime/vm": "*",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "5.0.1",
|
||||
"@vitest/browser-preview": "5.0.1",
|
||||
"@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0",
|
||||
"@vitest/coverage-istanbul": "5.0.1",
|
||||
"@vitest/coverage-v8": "5.0.1",
|
||||
"@vitest/ui": "5.0.1",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*",
|
||||
"vite": "^6.4.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edge-runtime/vm": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentelemetry/api": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-playwright": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-preview": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-webdriverio": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/coverage-istanbul": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/coverage-v8": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/ui": {
|
||||
"optional": true
|
||||
},
|
||||
"happy-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"jsdom": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vitest/node_modules/magic-string": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz",
|
||||
"integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
@@ -3951,6 +4243,23 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/why-is-node-running": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"siginfo": "^2.0.0",
|
||||
"stackback": "0.0.2"
|
||||
},
|
||||
"bin": {
|
||||
"why-is-node-running": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/word-wrap": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||
|
||||
@@ -11,9 +11,11 @@
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"gen:api": "npx -y openapi-typescript@7 http://localhost:5111/openapi/v1.json -o src/lib/api/schema.d.ts",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"format": "prettier --write .",
|
||||
"lint:tokens": "node scripts/check-tokens.mjs",
|
||||
"lint": "prettier --check . && eslint . && npm run lint:tokens"
|
||||
"lint": "prettier --check . && eslint . && npm run lint:tokens && npm run test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
@@ -33,7 +35,8 @@
|
||||
"tailwindcss": "^4.3.0",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.64.0",
|
||||
"vite": "^8.0.16"
|
||||
"vite": "^8.0.16",
|
||||
"vitest": "^5.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/bricolage-grotesque": "^5.3.0",
|
||||
|
||||
@@ -24,12 +24,16 @@ const PAIRED_HW = /\b(?:([a-z0-9-]+:))?h-([\d.]+)\s+\1w-\2\b/g;
|
||||
const QUARTER_STEP_SPACING = /\b[a-z-]+-\d+\.(25|75)\b/g;
|
||||
const HEX_COLOR = /#[0-9a-fA-F]{3,8}\b/g;
|
||||
const RGBA_LITERAL = /\brgba?\(/g;
|
||||
const LUCIDE_IMPORT = /import (\w+) from '@lucide\/svelte\/icons\/[\w-]+'/g;
|
||||
const ICON_PIXEL_SIZE = /\bsize=\{\d+\}/;
|
||||
const ICON_RAW_SIZE = /\bclass="[^"]*\bsize-\d[\w.]*/;
|
||||
|
||||
let violations = [];
|
||||
|
||||
for (const file of files) {
|
||||
const content = readFileSync(file, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
const icons = [...content.matchAll(LUCIDE_IMPORT)].map((m) => m[1]);
|
||||
|
||||
lines.forEach((line, i) => {
|
||||
const lineNo = i + 1;
|
||||
@@ -40,6 +44,17 @@ for (const file of files) {
|
||||
}
|
||||
}
|
||||
|
||||
for (const icon of icons) {
|
||||
const tag = line.match(new RegExp(`<${icon}\\s[^>]*`));
|
||||
if (!tag) continue;
|
||||
if (ICON_PIXEL_SIZE.test(tag[0])) {
|
||||
violations.push(`${file}:${lineNo} — icono con size={N}: usa una clase size-icon-*`);
|
||||
}
|
||||
if (ICON_RAW_SIZE.test(tag[0])) {
|
||||
violations.push(`${file}:${lineNo} — icono con size-N: usa una clase size-icon-*`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const match of line.matchAll(RAW_ROUNDED)) {
|
||||
violations.push(`${file}:${lineNo} — radio de Tailwind sin tokenizar: \`${match[0]}\``);
|
||||
}
|
||||
@@ -71,7 +86,7 @@ for (const file of files) {
|
||||
if (violations.length > 0) {
|
||||
console.error(`\n${violations.length} violación(es) del sistema de diseño:\n`);
|
||||
for (const v of violations) console.error(` ${v}`);
|
||||
console.error('\nVer docs/frontend-refactor-plan.md, sección B.4.\n');
|
||||
console.error('\nVer la lista blanca (ARBITRARY_WHITELIST) en este script, o CLAUDE.md.\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="scrollbar-stable">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
import {
|
||||
clearSessionCookie,
|
||||
decodeSession,
|
||||
encodeSession,
|
||||
readSessionCookie,
|
||||
refreshSession,
|
||||
sessionCookieOptions,
|
||||
SESSION_COOKIE
|
||||
writeSessionCookie
|
||||
} from '$lib/server/auth';
|
||||
import { authConfig } from '$lib/server/config';
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
let session = await decodeSession(event.cookies.get(SESSION_COOKIE));
|
||||
let session = await decodeSession(readSessionCookie(event.cookies));
|
||||
|
||||
if (
|
||||
session &&
|
||||
@@ -17,13 +18,9 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
) {
|
||||
session = await refreshSession(session);
|
||||
if (session) {
|
||||
event.cookies.set(
|
||||
SESSION_COOKIE,
|
||||
await encodeSession(session),
|
||||
sessionCookieOptions(event.url)
|
||||
);
|
||||
writeSessionCookie(event.cookies, await encodeSession(session), event.url);
|
||||
} else {
|
||||
event.cookies.delete(SESSION_COOKIE, { path: '/' });
|
||||
clearSessionCookie(event.cookies);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Action } from 'svelte/action';
|
||||
|
||||
export const onEscape: Action<HTMLElement, (() => void) | undefined> = (_node, handler) => {
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') handler?.();
|
||||
}
|
||||
window.addEventListener('keydown', onKeydown);
|
||||
|
||||
return {
|
||||
update(next) {
|
||||
handler = next;
|
||||
},
|
||||
destroy() {
|
||||
window.removeEventListener('keydown', onKeydown);
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -1,8 +0,0 @@
|
||||
import { toQueueItems, type ApiTrackLike, type QueueItem } from '$lib/player/player.svelte';
|
||||
|
||||
export async function fetchAlbumQueueItems(albumId: string): Promise<QueueItem[]> {
|
||||
const res = await fetch(`/api/albums/${albumId}/tracks`);
|
||||
if (!res.ok) return [];
|
||||
const data = (await res.json()) as { items: ApiTrackLike[] };
|
||||
return toQueueItems(data.items ?? []);
|
||||
}
|
||||
Vendored
+624
-279
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
import ListEnd from '@lucide/svelte/icons/list-end';
|
||||
import ListPlus from '@lucide/svelte/icons/list-plus';
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import { fetchAlbumTracks } from '$lib/data/albums';
|
||||
import type { Menu } from '$lib/state/menu.svelte';
|
||||
import type { Playlist } from '$lib/types';
|
||||
import ContextMenu from '$lib/components/ui/overlay/ContextMenu.svelte';
|
||||
|
||||
interface Props {
|
||||
menu: Menu<string>;
|
||||
playlists: Playlist[];
|
||||
}
|
||||
|
||||
let { menu, playlists }: Props = $props();
|
||||
|
||||
async function playNext(albumId: string) {
|
||||
menu.close();
|
||||
player.playNext(await fetchAlbumTracks(albumId));
|
||||
}
|
||||
|
||||
async function addToQueue(albumId: string) {
|
||||
menu.close();
|
||||
player.appendToQueue(await fetchAlbumTracks(albumId));
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if menu.state}
|
||||
{@const { target: albumId, x, y, openLeft } = menu.state}
|
||||
<ContextMenu
|
||||
{x}
|
||||
{y}
|
||||
{openLeft}
|
||||
onClose={() => menu.close()}
|
||||
items={[
|
||||
{ icon: ListPlus, label: 'Reproducir a continuación', onclick: () => playNext(albumId) },
|
||||
{ icon: ListEnd, label: 'Añadir a la cola', onclick: () => addToQueue(albumId) }
|
||||
]}
|
||||
playlistAction={{
|
||||
action: '?/addAlbumToPlaylist',
|
||||
fields: { albumId },
|
||||
label: 'Añadir álbum a una playlist'
|
||||
}}
|
||||
{playlists}
|
||||
/>
|
||||
{/if}
|
||||
@@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import { isQueueCurrent, playAllOrToggle } from '$lib/player/actions';
|
||||
import type { Track } from '$lib/types';
|
||||
import Button from '$lib/components/ui/primitives/Button.svelte';
|
||||
|
||||
interface Props {
|
||||
items: Track[];
|
||||
load?: () => Promise<Track[]>;
|
||||
variant?: 'primary' | 'secondary' | 'accent';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
idleLabel?: string;
|
||||
activeLabel?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
items,
|
||||
load,
|
||||
variant = 'primary',
|
||||
size = 'sm',
|
||||
idleLabel = 'Reproducir',
|
||||
activeLabel = 'Pausar'
|
||||
}: Props = $props();
|
||||
|
||||
const active = $derived(isQueueCurrent(items) && player.playing);
|
||||
|
||||
async function play() {
|
||||
if (load && !isQueueCurrent(items)) playAllOrToggle(await load());
|
||||
else playAllOrToggle(items);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Button {variant} {size} onclick={play} disabled={items.length === 0 && !load}>
|
||||
{active ? activeLabel : idleLabel}
|
||||
</Button>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import ListPlus from '@lucide/svelte/icons/list-plus';
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import type { Menu } from '$lib/state/menu.svelte';
|
||||
import type { Playlist, Track } from '$lib/types';
|
||||
import ContextMenu from '$lib/components/ui/overlay/ContextMenu.svelte';
|
||||
|
||||
interface Props {
|
||||
menu: Menu<Track>;
|
||||
playlists: Playlist[];
|
||||
}
|
||||
|
||||
let { menu, playlists }: Props = $props();
|
||||
|
||||
function playNext(track: Track) {
|
||||
player.playNext([track]);
|
||||
menu.close();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if menu.state}
|
||||
{@const { target: track, x, y, openLeft } = menu.state}
|
||||
<ContextMenu
|
||||
{x}
|
||||
{y}
|
||||
{openLeft}
|
||||
onClose={() => menu.close()}
|
||||
items={[{ icon: ListPlus, label: 'Reproducir a continuación', onclick: () => playNext(track) }]}
|
||||
playlistAction={{
|
||||
action: '?/addTrack',
|
||||
fields: { trackId: track.id },
|
||||
label: 'Añadir a una playlist'
|
||||
}}
|
||||
{playlists}
|
||||
/>
|
||||
{/if}
|
||||
+22
-39
@@ -1,18 +1,9 @@
|
||||
<script lang="ts" module>
|
||||
import type { Track } from '$lib/types';
|
||||
|
||||
export type TrackListColumn = 'added' | 'uploaded' | 'plays';
|
||||
|
||||
export interface TrackListTrack {
|
||||
id: string | number;
|
||||
title: string;
|
||||
artist?: string | null;
|
||||
ownerUserId?: string | number | null;
|
||||
isExplicit?: boolean;
|
||||
explicit?: boolean;
|
||||
duration?: number | string;
|
||||
listensCount?: number | string;
|
||||
addedAt?: string | number;
|
||||
uploadedAt?: string | number;
|
||||
}
|
||||
export type TrackListTrack = Track & { date?: string | number };
|
||||
|
||||
const COLUMN_PRESET: Record<TrackListColumn, { label: string; width: string }> = {
|
||||
added: { label: 'Añadida', width: '124px' },
|
||||
@@ -32,11 +23,11 @@
|
||||
import Hash from '@lucide/svelte/icons/hash';
|
||||
import Play from '@lucide/svelte/icons/play';
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import { fmtTime, fmtDate } from '$lib/format';
|
||||
import { fmtTime, fmtDate } from '$lib/utils/format';
|
||||
import { pressable } from '$lib/actions/pressable';
|
||||
import MediaIdentity from './MediaIdentity.svelte';
|
||||
import EqBars from './EqBars.svelte';
|
||||
import IconButton from './IconButton.svelte';
|
||||
import MediaIdentity from '$lib/components/ui/media/MediaIdentity.svelte';
|
||||
import EqBars from '$lib/components/ui/media/EqBars.svelte';
|
||||
import IconButton from '$lib/components/ui/primitives/IconButton.svelte';
|
||||
|
||||
interface RowAction {
|
||||
action?: string;
|
||||
@@ -91,11 +82,11 @@
|
||||
|
||||
<div class="overflow-x-auto {klass}">
|
||||
<div
|
||||
class="grid track-grid items-center gap-3 border-b border-line pr-4 pb-3 pl-2 text-sm text-fg-3 sm:gap-8 sm:pr-6 sm:pl-3"
|
||||
class="grid track-grid items-center gap-3 border-b border-line pr-4 pb-3 pl-2 text-sm text-fg-2 sm:gap-8 sm:pr-6 sm:pl-3"
|
||||
style="--mf-track-cols:{gridColumns}; --mf-track-cols-mobile:{gridColumnsMobile}"
|
||||
>
|
||||
{#if index}
|
||||
<Hash size={16} strokeWidth={1.5} />
|
||||
<Hash class="size-icon-sm" />
|
||||
{/if}
|
||||
<span>Título</span>
|
||||
{#each columns as column (column)}
|
||||
@@ -106,13 +97,14 @@
|
||||
<span></span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="mt-1 flex flex-col gap-0.5">
|
||||
<div class="mt-1 flex flex-col gap-0.5" role="list">
|
||||
{#each tracks as track, i (track.id)}
|
||||
{@const active = player.current.id === track.id}
|
||||
{@const active = player.currentId === track.id}
|
||||
|
||||
<div
|
||||
role="listitem"
|
||||
class="group grid track-grid items-center gap-3 rounded-control py-3 pr-4 pl-2 transition-colors sm:gap-8 sm:pr-6 sm:pl-3 {active
|
||||
? 'bg-accent-tint'
|
||||
? 'surface-active'
|
||||
: 'hover:bg-hover'}"
|
||||
style="--mf-track-cols:{gridColumns}; --mf-track-cols-mobile:{gridColumnsMobile}"
|
||||
oncontextmenu={oncontextmenu ? (event) => oncontextmenu(event, track, i) : undefined}
|
||||
@@ -122,7 +114,7 @@
|
||||
type="button"
|
||||
onclick={() => onPlay(i)}
|
||||
aria-label={active && player.playing ? 'Pausar' : 'Reproducir'}
|
||||
class="relative grid size-8 place-items-center rounded-tag text-xs text-muted tabular-nums"
|
||||
class="relative grid size-8 place-items-center rounded-tag text-xs text-fg-2 tabular-nums"
|
||||
>
|
||||
{#if active}
|
||||
<EqBars paused={!player.playing} />
|
||||
@@ -137,7 +129,6 @@
|
||||
role="button"
|
||||
tabindex="0"
|
||||
use:pressable={() => onPlay(i)}
|
||||
onclick={(event) => event.stopPropagation()}
|
||||
class="flex min-w-0 flex-1 text-left"
|
||||
>
|
||||
<MediaIdentity
|
||||
@@ -145,7 +136,7 @@
|
||||
title={track.title}
|
||||
artist={track.artist}
|
||||
ownerUserId={track.ownerUserId}
|
||||
explicit={track.isExplicit ?? track.explicit ?? false}
|
||||
explicit={track.explicit}
|
||||
{active}
|
||||
>
|
||||
{#snippet overlay()}
|
||||
@@ -156,28 +147,20 @@
|
||||
</MediaIdentity>
|
||||
</div>
|
||||
{#each columns as column (column)}
|
||||
<span class="hidden truncate text-center text-xs text-muted tabular-nums sm:block">
|
||||
<span class="hidden truncate text-center text-xs text-fg-2 tabular-nums sm:block">
|
||||
{#if column === 'plays'}
|
||||
{Number(track.listensCount ?? 0)}
|
||||
{:else if column === 'added' && track.addedAt}
|
||||
{fmtDate(track.addedAt)}
|
||||
{:else if column === 'uploaded' && track.uploadedAt}
|
||||
{fmtDate(track.uploadedAt)}
|
||||
{track.listensCount}
|
||||
{:else if track.date}
|
||||
{fmtDate(track.date)}
|
||||
{/if}
|
||||
</span>
|
||||
{/each}
|
||||
<span class="truncate text-center text-xs text-muted tabular-nums">
|
||||
{fmtTime(Number(track.duration))}
|
||||
<span class="truncate text-center text-xs text-fg-2 tabular-nums">
|
||||
{fmtTime(track.duration)}
|
||||
</span>
|
||||
{#if rowAction}
|
||||
{#if rowAction.action}
|
||||
<form
|
||||
method="POST"
|
||||
action={rowAction.action}
|
||||
use:enhance={() =>
|
||||
async ({ update }) =>
|
||||
update()}
|
||||
>
|
||||
<form method="POST" action={rowAction.action} use:enhance>
|
||||
<input type="hidden" name="trackId" value={track.id} />
|
||||
<IconButton type="submit" label={rowAction.label} size="xs" revealOnHover>
|
||||
<rowAction.icon class="size-icon-sm" />
|
||||
@@ -1,21 +1,23 @@
|
||||
<script lang="ts">
|
||||
import TrackInfo from './TrackInfo.svelte';
|
||||
import TransportControls from './TransportControls.svelte';
|
||||
import Slider from '$lib/components/ui/Slider.svelte';
|
||||
import IconButton from '$lib/components/ui/IconButton.svelte';
|
||||
import Slider from '$lib/components/ui/primitives/Slider.svelte';
|
||||
import IconButton from '$lib/components/ui/primitives/IconButton.svelte';
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import { queuePanel } from '$lib/player/queuePanel.svelte';
|
||||
import { fmtTime } from '$lib/format';
|
||||
import { queuePanel } from '$lib/state/panels.svelte';
|
||||
import { fmtTime } from '$lib/utils/format';
|
||||
import Volume1 from '@lucide/svelte/icons/volume-1';
|
||||
import Volume2 from '@lucide/svelte/icons/volume-2';
|
||||
import VolumeX from '@lucide/svelte/icons/volume-x';
|
||||
import ListMusic from '@lucide/svelte/icons/list-music';
|
||||
|
||||
function seek(value: number) {
|
||||
if (player.current.duration > 0) player.seekFraction(value / player.current.duration);
|
||||
if (player.duration > 0) player.seekFraction(value / player.duration);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="animate-enter pointer-events-auto flex items-center gap-icon-md glass-bar px-4 py-3 sm:hidden"
|
||||
class="animate-enter pointer-events-auto flex items-center gap-4.5 px-4 py-3 glass-bar sm:hidden"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<TrackInfo compact />
|
||||
@@ -24,7 +26,7 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="animate-enter pointer-events-auto hidden items-center gap-5 glass-bar px-4 py-3 sm:flex"
|
||||
class="animate-enter pointer-events-auto hidden items-center gap-5 px-4 py-3 glass-bar sm:flex"
|
||||
>
|
||||
<div class="w-(--mf-player-side-w) min-w-0 shrink-0">
|
||||
<TrackInfo />
|
||||
@@ -32,25 +34,38 @@
|
||||
<div class="flex min-w-0 flex-1 flex-col items-center gap-2">
|
||||
<TransportControls />
|
||||
<div class="flex w-full max-w-hero items-center gap-3">
|
||||
<span class="min-w-8 text-right text-xs text-fg-3 tabular-nums">
|
||||
<span class="min-w-8 text-right text-xs text-fg-2 tabular-nums">
|
||||
{fmtTime(player.progress)}
|
||||
</span>
|
||||
<Slider
|
||||
value={player.progress}
|
||||
max={player.current.duration}
|
||||
max={player.duration}
|
||||
label="Barra de progreso"
|
||||
oninput={seek}
|
||||
variant="seek"
|
||||
/>
|
||||
<span class="min-w-8 text-xs text-fg-3 tabular-nums">
|
||||
{fmtTime(player.current.duration)}
|
||||
<span class="min-w-8 text-xs text-fg-2 tabular-nums">
|
||||
{fmtTime(player.duration)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hidden w-(--mf-player-side-w) shrink-0 items-center justify-end gap-3.5 sm:flex">
|
||||
<Volume2 class="size-icon-sm shrink-0 text-fg-3" strokeWidth={1.8} />
|
||||
<div class="flex w-(--mf-player-side-w) shrink-0 items-center justify-end gap-3.5">
|
||||
<IconButton
|
||||
label={player.muted ? 'Activar sonido' : 'Silenciar'}
|
||||
plain
|
||||
size="xs"
|
||||
onclick={() => player.toggleMute()}
|
||||
>
|
||||
{#if player.muted || player.volume === 0}
|
||||
<VolumeX class="size-icon-sm" />
|
||||
{:else if player.volume < 50}
|
||||
<Volume1 class="size-icon-sm" />
|
||||
{:else}
|
||||
<Volume2 class="size-icon-sm" />
|
||||
{/if}
|
||||
</IconButton>
|
||||
<Slider
|
||||
value={player.volume}
|
||||
value={player.muted ? 0 : player.volume}
|
||||
max={100}
|
||||
label="Volumen"
|
||||
oninput={(value) => player.setVolume(value)}
|
||||
@@ -58,12 +73,12 @@
|
||||
/>
|
||||
<IconButton
|
||||
label="Cola de reproducción"
|
||||
tone="plain"
|
||||
plain
|
||||
size="xs"
|
||||
pressed={queuePanel.open}
|
||||
onclick={() => queuePanel.toggle()}
|
||||
>
|
||||
<ListMusic class="size-icon-sm" strokeWidth={1.8} />
|
||||
<ListMusic class="size-icon-sm" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import PlayerBar from './PlayerBar.svelte';
|
||||
import TabsBar from '../TabsBar.svelte';
|
||||
import Alert from '$lib/components/ui/Alert.svelte';
|
||||
import TabsBar from '$lib/components/shell/TabsBar.svelte';
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import type { SessionUser } from '$lib/types';
|
||||
|
||||
@@ -14,14 +13,7 @@
|
||||
const hasTrack = $derived(player.currentId !== null);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="pointer-events-none fixed inset-x-0 bottom-0 z-(--z-sticky) flex flex-col lg:left-(--mf-sidebar-w)"
|
||||
>
|
||||
{#if player.error}
|
||||
<div class="pointer-events-auto px-4 pb-2.5">
|
||||
<Alert tone="danger">{player.error}</Alert>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex shrink-0 flex-col">
|
||||
{#if hasTrack}
|
||||
<PlayerBar />
|
||||
{/if}
|
||||
|
||||
@@ -1,81 +1,144 @@
|
||||
<script lang="ts">
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import { queuePanel } from '$lib/player/queuePanel.svelte';
|
||||
import MediaIdentity from '$lib/components/ui/MediaIdentity.svelte';
|
||||
import ListRow from '$lib/components/ui/ListRow.svelte';
|
||||
import IconButton from '$lib/components/ui/IconButton.svelte';
|
||||
import { fmtTime } from '$lib/format';
|
||||
import { queuePanel } from '$lib/state/panels.svelte';
|
||||
import MediaIdentity from '$lib/components/ui/media/MediaIdentity.svelte';
|
||||
import ListRow from '$lib/components/ui/media/ListRow.svelte';
|
||||
import IconButton from '$lib/components/ui/primitives/IconButton.svelte';
|
||||
import { fmtTime } from '$lib/utils/format';
|
||||
import { flip } from 'svelte/animate';
|
||||
import { expoOut } from 'svelte/easing';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import { fly } from 'svelte/transition';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
|
||||
const upcoming = $derived.by(() => {
|
||||
const idx = player.tracks.findIndex((t) => t.id === player.currentId);
|
||||
return idx === -1 ? player.tracks : player.tracks.slice(idx + 1);
|
||||
});
|
||||
|
||||
let dragFrom = $state<number | null>(null);
|
||||
let dropAt = $state<number | null>(null);
|
||||
|
||||
function onDragOver(event: DragEvent, index: number) {
|
||||
if (dragFrom === null) return;
|
||||
event.preventDefault();
|
||||
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
|
||||
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
dropAt = event.clientY < rect.top + rect.height / 2 ? index : index + 1;
|
||||
}
|
||||
|
||||
function onDrop(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
if (dragFrom !== null && dropAt !== null) player.moveQueueItem(dragFrom, dropAt);
|
||||
endDrag();
|
||||
}
|
||||
|
||||
function endDrag() {
|
||||
dragFrom = null;
|
||||
dropAt = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if queuePanel.open}
|
||||
<div
|
||||
class="queue-shell max-sm:hidden"
|
||||
data-open={queuePanel.open ? '' : undefined}
|
||||
inert={!queuePanel.open}
|
||||
>
|
||||
<aside
|
||||
transition:fly={{ x: 24, duration: 220, easing: cubicOut }}
|
||||
class="hidden shrink-0 flex-col border-l border-hairline bg-bg px-4 pt-5 theme-transition sm:flex"
|
||||
style="width:var(--mf-queue-w); padding-bottom:calc(var(--mf-player-h) + 2rem)"
|
||||
class="flex min-h-0 w-(--mf-queue-w) shrink-0 flex-col border-l border-hairline px-4 pt-5 pb-5"
|
||||
>
|
||||
<div class="mb-5 flex items-center justify-between">
|
||||
<h2 class="font-display text-base font-medium tracking-tight text-fg">En cola</h2>
|
||||
<IconButton label="Cerrar cola" tone="plain" size="xs" onclick={() => queuePanel.close()}>
|
||||
<IconButton label="Cerrar cola" plain size="xs" onclick={() => queuePanel.close()}>
|
||||
<X class="size-icon-md" />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
{#if player.current.id}
|
||||
<p class="mb-3 text-eyebrow text-fg-3">Reproduciendo</p>
|
||||
<div class="mb-6 rounded-control bg-accent-tint p-2.5">
|
||||
{#if player.current}
|
||||
<p class="mb-3 text-eyebrow text-fg-2">Reproduciendo</p>
|
||||
<div class="mb-6 surface-active rounded-control p-2.5">
|
||||
<MediaIdentity
|
||||
trackId={player.current.id}
|
||||
title={player.current.title}
|
||||
artist={player.current.artist || '—'}
|
||||
ownerUserId={player.current.ownerUserId}
|
||||
explicit={player.current.explicit}
|
||||
active
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mb-3 flex items-center justify-between gap-3">
|
||||
<span class="min-w-0 flex-1 truncate text-eyebrow text-fg-3"> A continuación </span>
|
||||
<span class="min-w-0 flex-1 truncate text-eyebrow text-fg-2"> A continuación </span>
|
||||
{#if upcoming.length > 0}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => player.clearUpcoming()}
|
||||
class="shrink-0 text-xs text-fg-3 transition-colors hover:text-fg"
|
||||
class="shrink-0 text-xs text-fg-2 transition-colors hover:text-fg"
|
||||
>
|
||||
Vaciar
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 flex-col gap-px overflow-y-auto">
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto"
|
||||
ondragover={(event) => dragFrom !== null && event.preventDefault()}
|
||||
ondrop={onDrop}
|
||||
>
|
||||
{#each upcoming as track, i (track.id)}
|
||||
{@const index = player.tracks.length - upcoming.length + i}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
draggable="true"
|
||||
ondragstart={(event) => {
|
||||
dragFrom = index;
|
||||
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
|
||||
}}
|
||||
ondragover={(event) => onDragOver(event, index)}
|
||||
ondrop={onDrop}
|
||||
ondragend={endDrag}
|
||||
class="queue-row"
|
||||
data-dragging={dragFrom === index ? '' : undefined}
|
||||
data-drop={dragFrom === null
|
||||
? undefined
|
||||
: dropAt === index
|
||||
? 'before'
|
||||
: dropAt === index + 1 && index === player.tracks.length - 1
|
||||
? 'after'
|
||||
: undefined}
|
||||
animate:flip={{ duration: 300, easing: expoOut }}
|
||||
>
|
||||
{#if dropAt === index && dragFrom !== null}
|
||||
<div
|
||||
class="pointer-events-none absolute inset-x-2 -top-7 h-0.5 rounded-full bg-accent"
|
||||
></div>
|
||||
{:else if dropAt === index + 1 && dropAt === player.tracks.length && dragFrom !== null}
|
||||
<div
|
||||
class="pointer-events-none absolute inset-x-2 -bottom-7 h-0.5 rounded-full bg-accent"
|
||||
></div>
|
||||
{/if}
|
||||
<ListRow
|
||||
onclick={() => player.playQueueIndex(index)}
|
||||
size="sm"
|
||||
title={track.title}
|
||||
subtitle={track.artist || '—'}
|
||||
subtitleHref={track.ownerUserId}
|
||||
explicit={track.explicit}
|
||||
trackId={track.id}
|
||||
class="p-2"
|
||||
>
|
||||
{#snippet trailing()}
|
||||
{#if track.duration}
|
||||
<span class="shrink-0 text-xs text-muted tabular-nums">{fmtTime(track.duration)}</span
|
||||
<span class="shrink-0 text-xs text-fg-2 tabular-nums"
|
||||
>{fmtTime(track.duration)}</span
|
||||
>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</ListRow>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="px-2 py-6 text-center text-xs text-fg-3">No hay más canciones en la cola.</p>
|
||||
<p class="px-2 py-6 text-center text-xs text-fg-2">No hay más canciones en la cola.</p>
|
||||
{/each}
|
||||
</div>
|
||||
</aside>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import { liked } from '$lib/player/liked.svelte';
|
||||
import Artwork from '$lib/components/ui/Artwork.svelte';
|
||||
import ExplicitBadge from '$lib/components/ui/ExplicitBadge.svelte';
|
||||
import ArtistLink from '$lib/components/ui/ArtistLink.svelte';
|
||||
import IconButton from '$lib/components/ui/IconButton.svelte';
|
||||
import { liked } from '$lib/state/liked.svelte';
|
||||
import Artwork from '$lib/components/ui/media/Artwork.svelte';
|
||||
import ExplicitBadge from '$lib/components/ui/media/ExplicitBadge.svelte';
|
||||
import ArtistLink from '$lib/components/ui/media/ArtistLink.svelte';
|
||||
import IconButton from '$lib/components/ui/primitives/IconButton.svelte';
|
||||
import Heart from '@lucide/svelte/icons/heart';
|
||||
|
||||
interface Props {
|
||||
@@ -13,54 +13,47 @@
|
||||
|
||||
let { compact = false }: Props = $props();
|
||||
|
||||
const isLiked = $derived(player.current.id !== '' && liked.isLiked(player.current.id));
|
||||
const current = $derived(player.current);
|
||||
const isLiked = $derived(current !== null && liked.isLiked(current.id));
|
||||
|
||||
function toggleLike() {
|
||||
if (!player.current.id) return;
|
||||
liked.toggle({
|
||||
id: player.current.id,
|
||||
title: player.current.title,
|
||||
artist: player.current.artist,
|
||||
explicit: player.current.explicit,
|
||||
ownerUserId: player.current.ownerUserId,
|
||||
duration: player.current.duration
|
||||
});
|
||||
if (current) liked.toggle(current);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex min-w-0 items-center gap-2.5 md:gap-3">
|
||||
{#if player.current.id}
|
||||
{#if current}
|
||||
<Artwork
|
||||
trackIds={[player.current.id]}
|
||||
trackIds={[current.id]}
|
||||
size="sm"
|
||||
alt={player.current.title}
|
||||
alt={current.title}
|
||||
class="shrink-0 md:size-cover-md"
|
||||
/>
|
||||
{:else}
|
||||
<div class="size-cover-sm shrink-0 rounded-art bg-surface md:size-cover-md"></div>
|
||||
{/if}
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="min-w-0">
|
||||
<div class="flex min-w-0 items-center gap-1.5">
|
||||
{#if player.current.explicit}
|
||||
{#if current?.explicit}
|
||||
<ExplicitBadge />
|
||||
{/if}
|
||||
<span class="truncate text-sm text-fg">{player.current.title}</span>
|
||||
<span class="truncate text-sm text-fg">{current?.title ?? ''}</span>
|
||||
</div>
|
||||
<ArtistLink
|
||||
name={player.current.artist || '—'}
|
||||
ownerUserId={player.current.ownerUserId}
|
||||
class="mt-0.5 text-xs text-fg-3"
|
||||
name={current?.artist || '—'}
|
||||
ownerUserId={current?.ownerUserId}
|
||||
class="mt-0.5 text-xs text-fg-2"
|
||||
/>
|
||||
</div>
|
||||
{#if !compact}
|
||||
<IconButton
|
||||
label={isLiked ? 'Quitar de Me gusta' : 'Añadir a Me gusta'}
|
||||
tone="plain"
|
||||
size="xs"
|
||||
plain
|
||||
size="md"
|
||||
pressed={isLiked}
|
||||
onclick={toggleLike}
|
||||
>
|
||||
<Heart class="size-icon-md" fill={isLiked ? 'currentColor' : 'none'} strokeWidth={1.7} />
|
||||
<Heart class="size-icon-md" fill={isLiked ? 'currentColor' : 'none'} />
|
||||
</IconButton>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import PlayButton from '$lib/components/ui/PlayButton.svelte';
|
||||
import PlayButton from '$lib/components/ui/media/PlayButton.svelte';
|
||||
import Shuffle from '@lucide/svelte/icons/shuffle';
|
||||
import Repeat from '@lucide/svelte/icons/repeat';
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
const sideClass = $derived(
|
||||
compact
|
||||
? 'grid size-10 place-items-center text-fg opacity-70 transition active:scale-90'
|
||||
: 'flex text-fg-2 transition-colors hover:text-fg active:scale-90'
|
||||
: 'flex text-fg-2 transition hover:text-fg active:scale-90'
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -24,9 +24,9 @@
|
||||
onclick={() => player.toggleShuffle()}
|
||||
aria-pressed={player.shuffle}
|
||||
aria-label="Aleatorio"
|
||||
class="transition-colors hover:text-fg {player.shuffle ? 'text-accent' : 'text-fg-3'}"
|
||||
class="transition-colors hover:text-fg {player.shuffle ? 'text-accent' : 'text-fg-2'}"
|
||||
>
|
||||
<Shuffle class="size-4" strokeWidth={1.8} />
|
||||
<Shuffle class="size-icon-sm" />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
@@ -56,9 +56,9 @@
|
||||
onclick={() => player.toggleRepeat()}
|
||||
aria-pressed={player.repeat}
|
||||
aria-label="Repetir"
|
||||
class="transition-colors hover:text-fg {player.repeat ? 'text-accent' : 'text-fg-3'}"
|
||||
class="transition-colors hover:text-fg {player.repeat ? 'text-accent' : 'text-fg-2'}"
|
||||
>
|
||||
<Repeat class="size-4" strokeWidth={1.8} />
|
||||
<Repeat class="size-icon-sm" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
+27
-16
@@ -6,20 +6,21 @@
|
||||
import Settings from '@lucide/svelte/icons/settings';
|
||||
import Sun from '@lucide/svelte/icons/sun';
|
||||
import Moon from '@lucide/svelte/icons/moon';
|
||||
import MenuItem from './MenuItem.svelte';
|
||||
import GlassMenu from './GlassMenu.svelte';
|
||||
import MenuItem from '$lib/components/ui/overlay/MenuItem.svelte';
|
||||
import GlassMenu from '$lib/components/ui/overlay/GlassMenu.svelte';
|
||||
import { themeMode } from '$lib/theme/mode.svelte';
|
||||
import { clickOutside } from '$lib/actions/clickOutside';
|
||||
import { onEscape } from '$lib/actions/onEscape';
|
||||
import Avatar from '$lib/components/ui/media/Avatar.svelte';
|
||||
|
||||
interface Props {
|
||||
user: SessionUser;
|
||||
accountUrl?: string | null;
|
||||
width?: 'sm' | 'md';
|
||||
extraItems?: Snippet<[{ close: () => void }]>;
|
||||
trigger: Snippet<[{ toggle: () => void; open: boolean }]>;
|
||||
}
|
||||
|
||||
let { user, accountUrl, width = 'sm', extraItems, trigger }: Props = $props();
|
||||
let { user, accountUrl, width = 'sm', extraItems }: Props = $props();
|
||||
|
||||
const WIDTH: Record<'sm' | 'md', string> = { sm: 'w-52', md: 'w-56' };
|
||||
|
||||
@@ -32,20 +33,30 @@
|
||||
function close() {
|
||||
open = false;
|
||||
}
|
||||
|
||||
function onWindowKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') close();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onWindowKeydown} />
|
||||
|
||||
<div class="relative" use:clickOutside={close}>
|
||||
{@render trigger({ toggle, open })}
|
||||
<div class="relative" use:clickOutside={close} use:onEscape={open ? close : undefined}>
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggle}
|
||||
aria-label="Tu cuenta"
|
||||
aria-expanded={open}
|
||||
class="block shrink-0 rounded-full transition active:scale-95"
|
||||
>
|
||||
<Avatar
|
||||
name={user.name}
|
||||
src={user.picture}
|
||||
size="sm"
|
||||
class="border border-line-strong opacity-90"
|
||||
/>
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<GlassMenu class="absolute top-full right-0 z-(--z-menu) mt-2 overflow-hidden {WIDTH[width]}">
|
||||
<MenuItem icon={User} label="Ver perfil" href="/u/{user.sub}" onclick={close} />
|
||||
<GlassMenu
|
||||
border={false}
|
||||
class="absolute top-full right-0 z-(--z-menu) mt-2 overflow-hidden {WIDTH[width]}"
|
||||
>
|
||||
<MenuItem icon={User} label="Ver perfil" href="/user/{user.sub}" onclick={close} />
|
||||
{@render extraItems?.({ close })}
|
||||
{#if accountUrl}
|
||||
<MenuItem
|
||||
@@ -58,10 +69,10 @@
|
||||
{/if}
|
||||
<MenuItem
|
||||
icon={themeMode.current === 'dark' ? Sun : Moon}
|
||||
label={themeMode.current === 'dark' ? 'Modo blanco' : 'Modo oscuro'}
|
||||
label={themeMode.current === 'dark' ? 'Modo claro' : 'Modo oscuro'}
|
||||
onclick={() => themeMode.toggle()}
|
||||
/>
|
||||
<form method="POST" action="/logout" data-sveltekit-reload>
|
||||
<form method="POST" action="/auth/logout" data-sveltekit-reload>
|
||||
<MenuItem icon={LogOut} label="Cerrar sesión" type="submit" />
|
||||
</form>
|
||||
</GlassMenu>
|
||||
+6
-18
@@ -3,11 +3,10 @@
|
||||
import Upload from '@lucide/svelte/icons/upload';
|
||||
import Folder from '@lucide/svelte/icons/folder';
|
||||
import LogIn from '@lucide/svelte/icons/log-in';
|
||||
import MenuItem from './ui/MenuItem.svelte';
|
||||
import AccountMenu from './ui/AccountMenu.svelte';
|
||||
import Avatar from './ui/Avatar.svelte';
|
||||
import IconButton from './ui/IconButton.svelte';
|
||||
import Logo from './ui/Logo.svelte';
|
||||
import MenuItem from '$lib/components/ui/overlay/MenuItem.svelte';
|
||||
import AccountMenu from '$lib/components/shell/AccountMenu.svelte';
|
||||
import IconButton from '$lib/components/ui/primitives/IconButton.svelte';
|
||||
import Logo from '$lib/components/ui/primitives/Logo.svelte';
|
||||
|
||||
interface Props {
|
||||
user: SessionUser | null;
|
||||
@@ -18,7 +17,7 @@
|
||||
</script>
|
||||
|
||||
<header
|
||||
class="sticky top-0 z-(--z-sticky) flex h-14 items-center justify-between border-b border-hairline bg-bg/85 page-x backdrop-blur-md sm:hidden"
|
||||
class="sticky top-0 z-(--z-sticky) flex h-14 items-center justify-between bg-transparent page-x backdrop-blur-md sm:hidden"
|
||||
>
|
||||
<a href="/">
|
||||
<Logo size="sm" />
|
||||
@@ -26,17 +25,6 @@
|
||||
|
||||
{#if user}
|
||||
<AccountMenu {user} {accountUrl} width="md">
|
||||
{#snippet trigger({ toggle, open })}
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggle}
|
||||
aria-label="Tu cuenta"
|
||||
aria-expanded={open}
|
||||
class="flex items-center rounded-full"
|
||||
>
|
||||
<Avatar name={user.name} src={user.picture} size="sm" />
|
||||
</button>
|
||||
{/snippet}
|
||||
{#snippet extraItems({ close })}
|
||||
<MenuItem icon={Folder} label="Canciones subidas" href="/library" onclick={close} />
|
||||
<MenuItem icon={Upload} label="Subir música" href="/upload" onclick={close} />
|
||||
@@ -44,7 +32,7 @@
|
||||
</AccountMenu>
|
||||
{:else}
|
||||
<IconButton href="/auth/login" reload label="Iniciar sesión" shape="round" size="md">
|
||||
<LogIn class="size-icon-lg" strokeWidth={2} />
|
||||
<LogIn class="size-icon-lg" />
|
||||
</IconButton>
|
||||
{/if}
|
||||
</header>
|
||||
+30
-31
@@ -1,57 +1,58 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { isSectionActive, appNavLinks } from '$lib/navigation.svelte';
|
||||
import { liked } from '$lib/player/liked.svelte';
|
||||
import { createPlaylistModal } from '$lib/playlists.svelte';
|
||||
import { appNavLinks, isNavActive } from './navLinks';
|
||||
import { playlistCover } from '$lib/utils/hrefs';
|
||||
import { liked } from '$lib/state/liked.svelte';
|
||||
import { createPlaylistModal } from '$lib/state/panels.svelte';
|
||||
import type { SessionUser } from '$lib/types';
|
||||
import ListRow from './ui/ListRow.svelte';
|
||||
import IconButton from './ui/IconButton.svelte';
|
||||
import Logo from './ui/Logo.svelte';
|
||||
import ListRow from '$lib/components/ui/media/ListRow.svelte';
|
||||
import IconButton from '$lib/components/ui/primitives/IconButton.svelte';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import Logo from '$lib/components/ui/primitives/Logo.svelte';
|
||||
|
||||
const SIDEBAR_PLAYLISTS_LIMIT = 8;
|
||||
|
||||
interface SidebarPlaylist {
|
||||
id: string;
|
||||
name: string;
|
||||
coverTrackIds: (string | number)[];
|
||||
coverTrackIds: string[];
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
user: SessionUser | null;
|
||||
playlists: SidebarPlaylist[];
|
||||
playlistCount: number;
|
||||
}
|
||||
|
||||
let { user, playlists }: Props = $props();
|
||||
let { user, playlists, playlistCount }: Props = $props();
|
||||
|
||||
const navLinks = $derived(appNavLinks(user, { playlists: playlists.length, liked: liked.count }));
|
||||
const visiblePlaylists = $derived(playlists.slice(0, SIDEBAR_PLAYLISTS_LIMIT));
|
||||
|
||||
function isActive(href: string) {
|
||||
return isSectionActive(href, page.url.pathname, page.data.section);
|
||||
}
|
||||
const navLinks = $derived(appNavLinks(user, { playlists: playlistCount, liked: liked.count }));
|
||||
</script>
|
||||
|
||||
<aside
|
||||
class="hidden shrink-0 flex-col border-r border-hairline bg-bg p-5 theme-transition lg:flex"
|
||||
style="width:var(--mf-sidebar-w)"
|
||||
class="hidden min-h-0 w-(--mf-sidebar-w) shrink-0 flex-col border-r border-hairline p-5 lg:flex"
|
||||
>
|
||||
<a href="/" class="px-3 pb-3">
|
||||
<a href="/" class="mb-4">
|
||||
<Logo size="sm" />
|
||||
</a>
|
||||
|
||||
<nav class="flex flex-col gap-1">
|
||||
{#each navLinks as link (link.href)}
|
||||
{@const active = isActive(link.href)}
|
||||
{@const active = isNavActive(link.href)}
|
||||
<a
|
||||
href={link.href}
|
||||
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 {active
|
||||
? 'bg-surface-2 text-fg'
|
||||
: 'text-fg-2 hover:bg-hover hover:text-fg'}"
|
||||
>
|
||||
<link.icon class="size-4.5 shrink-0" strokeWidth={1.5} />
|
||||
<link.icon class="size-icon-md shrink-0" />
|
||||
<span class="flex-1 truncate">{link.label}</span>
|
||||
{#if link.count}
|
||||
<span class="text-xs text-muted tabular-nums">{link.count}</span>
|
||||
<span class="text-xs text-fg-2 tabular-nums">{link.count}</span>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
@@ -60,30 +61,28 @@
|
||||
{#if user}
|
||||
<div class="mx-3 mt-6 mb-3.5 h-px bg-line"></div>
|
||||
<div class="flex items-center justify-between px-3 pb-1.5">
|
||||
<span class="text-eyebrow text-muted">Tus playlists</span>
|
||||
{#if playlists.length > 0}
|
||||
<span class="text-eyebrow text-fg-2">Tus playlists</span>
|
||||
{#if visiblePlaylists.length > 0}
|
||||
<IconButton
|
||||
label="Crear playlist"
|
||||
tone="plain"
|
||||
plain
|
||||
size="xs"
|
||||
onclick={() => createPlaylistModal.show()}
|
||||
>
|
||||
<Plus class="size-icon-sm" strokeWidth={1.25} />
|
||||
<Plus class="size-icon-sm text-fg-2" />
|
||||
</IconButton>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 flex-col gap-px overflow-y-auto pb-4.5">
|
||||
{#each playlists as playlist (playlist.id)}
|
||||
<div class="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto pb-4.5">
|
||||
{#each visiblePlaylists as playlist (playlist.id)}
|
||||
{@const active = page.url.pathname === `/playlists/${playlist.id}`}
|
||||
<ListRow
|
||||
href="/playlists/{playlist.id}"
|
||||
{active}
|
||||
size="xs"
|
||||
title={playlist.name}
|
||||
coverSrc="/api/playlists/{playlist.id}/cover?size=small&v={encodeURIComponent(
|
||||
playlist.updatedAt
|
||||
)}"
|
||||
coverSrc={playlistCover(playlist, 'small')}
|
||||
trackIds={playlist.coverTrackIds}
|
||||
class="px-3 py-2"
|
||||
/>
|
||||
@@ -91,10 +90,10 @@
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => createPlaylistModal.show()}
|
||||
class="flex items-center gap-2 rounded-control px-3 py-2 text-xs text-muted transition-colors hover:bg-hover hover:text-fg-2"
|
||||
class="flex items-center gap-2 rounded-control px-3 py-2 text-xs text-fg-2 transition-colors hover:bg-hover"
|
||||
>
|
||||
<Plus class="size-3.5" strokeWidth={1.8} />
|
||||
Crear tu primera lista
|
||||
<Plus class="size-icon-xs" />
|
||||
Crear tu primera playlist
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
+4
-9
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { isSectionActive, appNavLinks } from '$lib/navigation.svelte';
|
||||
import { appNavLinks, isNavActive } from './navLinks';
|
||||
import type { SessionUser } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
@@ -11,18 +10,14 @@
|
||||
let { user, class: klass = '' }: Props = $props();
|
||||
|
||||
const links = $derived(appNavLinks(user).filter((link) => link.primary));
|
||||
|
||||
function isActive(href: string) {
|
||||
return isSectionActive(href, page.url.pathname, page.data.section);
|
||||
}
|
||||
</script>
|
||||
|
||||
<nav
|
||||
class="pointer-events-auto flex items-center justify-around glass-bar px-4 py-2 lg:hidden {klass}"
|
||||
class="pointer-events-auto flex items-center justify-around px-4 py-2 glass-bar lg:hidden {klass}"
|
||||
style="padding-bottom:calc(0.4375rem + var(--mf-safe-b))"
|
||||
>
|
||||
{#each links as link (link.href)}
|
||||
{@const active = isActive(link.href)}
|
||||
{@const active = isNavActive(link.href)}
|
||||
<a
|
||||
href={link.href}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
@@ -30,7 +25,7 @@
|
||||
? 'text-fg'
|
||||
: 'text-fg-2'}"
|
||||
>
|
||||
<link.icon class="size-4" strokeWidth={1.6} />
|
||||
<link.icon class="size-icon-sm" />
|
||||
<span class="text-xs font-medium">{link.label}</span>
|
||||
</a>
|
||||
{/each}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user