Author SHA1 Message Date
Admin 35d6314ce8 Point the IDE dev settings at the shared Infrastructure stack
CI/CD / BackEnd (push) Successful in 1m18s
CI/CD / WebPlayer (push) Successful in 41s
CI/CD / Deploy (push) Skipped
appsettings.Development.json still targeted Musify's old own Postgres
(port 59000) with the superuser, the default RabbitMQ vhost as guest and
the admin S3 keys. They now use Infrastructure's dev ports and Musify's
own dev credentials (role, vhost and S3 identity), the same defaults as
deploy/.env.example.

The API's non-secret Zitadel settings (issuer, metadata, endpoints) move
there too, so user-secrets only need the per-machine client id.
2026-09-24 17:11:50 +02:00
Admin f99f74f4a1 Use Musify's own credentials on the shared services
Infrastructure now gives each project its own Postgres role, RabbitMQ
user and vhost, S3 identity limited to its bucket and a Zitadel service
account that owns only its project, instead of handing out the admin
accounts.

- Postgres, RabbitMQ and S3 settings default to Musify's own (musify).
- MassTransit connects to the `musify` vhost (RABBITMQ_VHOST, taken from
  the address path); RABBITMQ_DEFAULT_USER/PASS become RABBITMQ_USER and
  RABBITMQ_PASSWORD.
- zitadel-init mounts ZITADEL_PAT_DIR (Infrastructure's
  zitadel/.output/musify) and uses that PAT. It no longer creates the
  Zitadel project, which that account can't do: Infrastructure does.
2026-09-24 17:05:23 +02:00
Admin f2765e2e7b Call the API by container name from the web player
In prod every project shares infra-net, where the `api` service name
can resolve to another project's container as well.
2026-09-24 15:06:25 +02:00
Admin 7b8f3f1222 Use a reverse-domain redirect scheme for the native app
- Android/iOS now return to cat.ikerdemo.musify:/callback instead of
  musify://callback: a private-use scheme based on a domain we own
  (RFC 8252 section 7.1), so another app is unlikely to claim it.
- The Musify Native app no longer needs devMode: loopback and custom
  scheme redirects are both valid for a native app.
- zitadel-init also updates an existing app's config, so the change
  reaches instances created before it (the client id is kept).
- iOS takes the scheme from everything before the first ':', since the
  new URI has a single slash.
2026-09-24 15:06:25 +02:00
Admin c33b0e8bfd Stop disabling Zitadel Login V2 from zitadel-init
The shared Infrastructure stack now runs the Login V2 container, and
turning the feature off here would switch every project on the shared
instance back to the old login.
2026-09-24 15:06:25 +02:00
NotS1mp 87c3f97529 Merge pull request 'Web player general changes' (#22) from web-player-general-changes into master
CI/CD / BackEnd (push) Successful in 1m17s
CI/CD / WebPlayer (push) Successful in 40s
CI/CD / Deploy (push) Successful in 2m4s
Reviewed-on: #22
2026-09-23 22:37:30 +00:00
Admin 302dd1e0f8 Merge master into web-player-general-changes
CI/CD / BackEnd (push) Canceled after 0s
CI/CD / WebPlayer (push) Canceled after 0s
CI/CD / BackEnd (pull_request) Successful in 1m23s
CI/CD / WebPlayer (pull_request) Successful in 40s
CI/CD / Deploy (pull_request) Skipped
CI/CD / Deploy (push) Canceled after 0s
2026-09-24 00:32:03 +02:00
NotS1mp 421fc929e8 Merge pull request 'Dynamic genres' (#20) from dynamic-genres into master
CI/CD / BackEnd (push) Successful in 1m23s
CI/CD / WebPlayer (push) Successful in 39s
CI/CD / Deploy (push) Successful in 1m38s
Reviewed-on: #20
2026-09-23 22:24:22 +00:00
Admin 430c711ff2 Refactor CI/CD workflow to improve database migration process and key generation
CI/CD / BackEnd (push) Successful in 1m18s
CI/CD / WebPlayer (push) Successful in 40s
CI/CD / Deploy (push) Skipped
CI/CD / BackEnd (pull_request) Successful in 1m27s
CI/CD / WebPlayer (pull_request) Successful in 39s
CI/CD / Deploy (pull_request) Skipped
2026-09-23 23:59:45 +02:00
Admin cfdc61a4b8 Scroll content inside the shell so the player shares the ambient backdrop
CI/CD / BackEnd (push) Successful in 1m17s
CI/CD / WebPlayer (push) Successful in 40s
CI/CD / Deploy (push) Skipped
The player dock used to sit fixed over the scrolling page, so it needed an
opaque background. The shell is now a fixed-height frame where only the
content column scrolls, letting the dock stay transparent like the sidebar
and queue. Scroll reset/restore on navigation moves to restoreScroll(), and
the stable scrollbar gutter moves from <html> to the content scroller.
2026-09-23 23:52:27 +02:00
Admin 0f28e19c1d Remove follow button from user rows
CI/CD / BackEnd (push) Successful in 1m24s
CI/CD / WebPlayer (push) Successful in 40s
CI/CD / Deploy (push) Skipped
2026-09-23 23:20:27 +02:00
Admin 7316e355ee Add a second greeting variant per time slot 2026-09-23 23:20:27 +02:00
Admin b39e55fd87 Restore master hero and spotlight gradients and sway them in place 2026-09-23 23:15:15 +02:00
Admin cb046bba9e Let users browse followers and following lists
Following lists are public; followers are visible to the user and to mutual followers.
Adds GET /users/{id}/followers and /following, exposes isFollowing and canViewFollowers
on the profile, and returns isFollowedByViewer from user search so the web player no
longer calls /is-following per user.
2026-09-23 22:55:14 +02:00
Admin b9a40186b1 Lighten active surface gradients in light mode
CI/CD / BackEnd (push) Successful in 1m15s
CI/CD / WebPlayer (push) Successful in 40s
CI/CD / Deploy (push) Skipped
2026-09-23 22:44:42 +02:00
Admin fd0a39b879 IsExplicit 2026-09-23 22:42:28 +02:00
Admin cc7afe4326 Add explicit flag to tracks and mark it on upload 2026-09-23 22:41:57 +02:00
Admin 72cbc18a48 Add follow button next to each user in search results
Extract FollowButton and shared follow/unfollow actions, reused by the user profile page.
2026-09-23 22:34:56 +02:00
Admin 84408b842b Semibold 2026-09-23 22:25:04 +02:00
Admin 339fcb56ad Semibold 2026-09-23 22:24:57 +02:00
Admin 74cf534be5 Animate queue drag and drop reordering 2026-09-23 22:18:21 +02:00
Admin 89820c1377 Fix gradient background syntax in Artwork component 2026-09-23 22:10:46 +02:00
Admin 2fe26b15dd Allow reordering upcoming tracks in the queue by drag and drop 2026-09-23 22:10:21 +02:00
Admin 2cb560fda4 Keep light mode gradients visible while drifting 2026-09-23 22:07:50 +02:00
Admin d8530b9209 Animate menu close with shared pop transition 2026-09-23 22:01:51 +02:00
Admin 5049b0ba91 Update global icon stroke width to 1.5 in layout.css 2026-09-23 21:59:24 +02:00
Admin 1b98a75c0e Added shadow 2026-09-23 21:59:13 +02:00
Admin cbbde95773 Set global icon stroke width and remove per-icon strokeWidth props 2026-09-23 21:55:00 +02:00
Admin 26bb8a50a3 Solid player bar, gradient sliders and smooth progress 2026-09-23 21:48:39 +02:00
Admin bf19bb31f9 Make accent hue crossfade between songs last 2 to 4 seconds 2026-09-23 21:40:22 +02:00
Admin 55cc803708 Drift accent gradients on buttons, hero, spotlight, active rows and modals 2026-09-23 21:37:41 +02:00
Admin 805e521b1d Animate ambient background, add mute toggle and smooth queue panel 2026-09-23 21:36:01 +02:00
Admin f8d431a0d7 Implement volume control with mute functionality; update PlayerState to manage muted state 2026-09-23 21:28:17 +02:00
Admin 7b72db8a24 Add ambient drift animation to app backdrop; adjust inset and handle reduced motion 2026-09-23 21:28:07 +02:00
86 changed files with 2739 additions and 409 deletions
+14 -2
View File
@@ -86,11 +86,23 @@ jobs:
git fetch origin master git fetch origin master
git reset --hard origin/master git reset --hard origin/master
- name: Migrate database, generate keys - name: Generate stream-ticket keys
run: | run: |
set -euo pipefail set -euo pipefail
cd "$DEPLOY_PATH" cd "$DEPLOY_PATH"
$COMPOSE up -d wait-for-postgres keys-init migrate $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 # Both jobs verify themselves through the public auth./s3. URLs, served
# by the shared edge nginx (Infrastructure's edge/), and are idempotent. # by the shared edge nginx (Infrastructure's edge/), and are idempotent.
@@ -18,7 +18,8 @@ namespace Musify.Application.Tracks
string Title, string Title,
Guid PictureIntentId, Guid PictureIntentId,
Guid AudioIntentId, Guid AudioIntentId,
IReadOnlyCollection<Genre> Tags) IReadOnlyCollection<Genre> Tags,
bool IsExplicit = false)
: ICommand<ErrorOr<TrackApplicationResponse>>; : ICommand<ErrorOr<TrackApplicationResponse>>;
public class CreateTrackCommandHandler( public class CreateTrackCommandHandler(
@@ -83,6 +84,7 @@ namespace Musify.Application.Tracks
NormalizedTitle = request.Title.ToUpperInvariant(), NormalizedTitle = request.Title.ToUpperInvariant(),
OwnerUserId = user.Id, OwnerUserId = user.Id,
Owner = user, Owner = user,
IsExplicit = request.IsExplicit,
Pictures = new TrackPictures Pictures = new TrackPictures
{ {
OriginalName = pictureIntent.ObjectName, OriginalName = pictureIntent.ObjectName,
@@ -15,7 +15,8 @@ namespace Musify.Application.Tracks.Responses
DateTime CreatedAt, DateTime CreatedAt,
DateTime UpdatedAt, DateTime UpdatedAt,
[property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId, [property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId,
IReadOnlyCollection<Genre> Tags) IReadOnlyCollection<Genre> Tags,
bool IsExplicit)
{ {
public static TrackApplicationResponse FromEntity(Track track, int listensCount) => public static TrackApplicationResponse FromEntity(Track track, int listensCount) =>
new( new(
@@ -28,6 +29,7 @@ namespace Musify.Application.Tracks.Responses
track.CreatedAt, track.CreatedAt,
track.UpdatedAt, track.UpdatedAt,
track.OwnerUserId, 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 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( public record UserProfileResponse(
[property: JsonConverter(typeof(LongAsStringConverter))] long Id, [property: JsonConverter(typeof(LongAsStringConverter))] long Id,
@@ -17,7 +17,9 @@ namespace Musify.Application.Users
string? ProfilePictureUrl, string? ProfilePictureUrl,
DateTime CreatedAt, DateTime CreatedAt,
int FollowersCount, int FollowersCount,
int FollowingCount); int FollowingCount,
bool IsFollowing,
bool CanViewFollowers);
public class GetUserProfileQueryHandler(IDatabase database) public class GetUserProfileQueryHandler(IDatabase database)
: IQueryHandler<GetUserProfileQuery, ErrorOr<UserProfileResponse>> : IQueryHandler<GetUserProfileQuery, ErrorOr<UserProfileResponse>>
@@ -49,6 +51,14 @@ namespace Musify.Application.Users
.AsNoTracking() .AsNoTracking()
.CountAsync(f => f.FollowerId == request.UserId, cancellationToken); .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( return new UserProfileResponse(
user.Id, user.Id,
user.Name, user.Name,
@@ -57,7 +67,9 @@ namespace Musify.Application.Users
user.ProfilePictureUrl, user.ProfilePictureUrl,
user.CreatedAt, user.CreatedAt,
followersCount, followersCount,
followingCount); followingCount,
isFollowing,
canViewFollowers);
} }
} }
} }
@@ -8,13 +8,13 @@ using Musify.Application.Contracts;
namespace Musify.Application.Users namespace Musify.Application.Users
{ {
public record GetUsersQuery(int PageNumber, int PageSize, string? UsernameSearch) public record GetUsersQuery(int PageNumber, int PageSize, string? UsernameSearch, long? ViewerId = null)
: IQuery<ErrorOr<PaginatedResponse<UserApplicationResponse>>>; : IQuery<ErrorOr<PaginatedResponse<UserSummaryResponse>>>;
public class GetUsersQueryHandler(IDatabase database) 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 var usersQuery = database.Users
.AsNoTracking() .AsNoTracking()
@@ -27,11 +27,19 @@ namespace Musify.Application.Users
} }
var totalCount = await usersQuery.CountAsync(cancellationToken); var totalCount = await usersQuery.CountAsync(cancellationToken);
var viewerId = request.ViewerId;
var pagedUsers = await usersQuery 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); .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] [Required]
public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active; public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active;
[Required]
public bool IsExplicit { get; set; }
[Required] [Required]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
@@ -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") b.Property<double>("DurationSeconds")
.HasColumnType("double precision"); .HasColumnType("double precision");
b.Property<bool>("IsExplicit")
.HasColumnType("boolean");
b.Property<int>("LifeCycleStatus") b.Property<int>("LifeCycleStatus")
.HasColumnType("integer"); .HasColumnType("integer");
@@ -6,4 +6,5 @@ public record CreateTrackRequest(
string Title, string Title,
Guid PictureIntentId, Guid PictureIntentId,
Guid AudioIntentId, Guid AudioIntentId,
IReadOnlyCollection<Genre> Tags); IReadOnlyCollection<Genre> Tags,
bool IsExplicit = false);
@@ -185,7 +185,7 @@ public static class TrackEndpoints
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var result = await mediator.Send( var result = await mediator.Send(
new CreateTrackCommand(currentUser.RequiredId, request.Title, request.PictureIntentId, request.AudioIntentId, request.Tags), new CreateTrackCommand(currentUser.RequiredId, request.Title, request.PictureIntentId, request.AudioIntentId, request.Tags, request.IsExplicit),
cancellationToken); cancellationToken);
return result.ToCreatedResult(track => $"/tracks/{track.Id}"); return result.ToCreatedResult(track => $"/tracks/{track.Id}");
@@ -21,7 +21,7 @@ public static class UserEndpoints
group.MapGet("/", GetUsers) group.MapGet("/", GetUsers)
.WithName("GetUsers") .WithName("GetUsers")
.WithSummary("Get Paginated Users.") .WithSummary("Get Paginated Users.")
.Produces<PaginatedResponse<UserApplicationResponse>>(); .Produces<PaginatedResponse<UserSummaryResponse>>();
group.MapGet("/{id}", GetUserById) group.MapGet("/{id}", GetUserById)
.WithName("GetUserById") .WithName("GetUserById")
@@ -52,6 +52,19 @@ public static class UserEndpoints
.Produces<UserProfileResponse>() .Produces<UserProfileResponse>()
.Produces(StatusCodes.Status404NotFound); .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) group.MapGet("/{id}/is-following", IsFollowing)
.WithName("IsFollowingUser") .WithName("IsFollowingUser")
.WithSummary("Check Whether The Current User Follows Another User.") .WithSummary("Check Whether The Current User Follows Another User.")
@@ -88,12 +101,13 @@ public static class UserEndpoints
private static async Task<IResult> GetUsers( private static async Task<IResult> GetUsers(
IMediator mediator, IMediator mediator,
CurrentUser currentUser,
CancellationToken cancellationToken, CancellationToken cancellationToken,
string? usernameSearch, string? usernameSearch,
int pageNumber = 1, int pageNumber = 1,
int pageSize = 10) 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(); return result.ToHttpResult();
} }
@@ -133,10 +147,35 @@ public static class UserEndpoints
private static async Task<IResult> GetUserProfile( private static async Task<IResult> GetUserProfile(
IMediator mediator, IMediator mediator,
CurrentUser currentUser,
long id, long id,
CancellationToken cancellationToken) 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(); return result.ToHttpResult();
} }
@@ -6,21 +6,26 @@
} }
}, },
"Authentication": { "Authentication": {
"MetadataAddress": "http://host.docker.internal:8080/.well-known/openid-configuration",
"IssuerAddress": "http://host.docker.internal:8080",
"AuthorizationEndpoint": "http://host.docker.internal:8080/oauth/v2/authorize?prompt=login",
"TokenEndpoint": "http://host.docker.internal:8080/oauth/v2/token",
"ScalarRedirectUri": "http://localhost:5111/scalar/",
"Scopes": [ "openid", "profile", "email" ], "Scopes": [ "openid", "profile", "email" ],
"RequireHttpsMetadata": false "RequireHttpsMetadata": false
}, },
"Database": { "Database": {
"ConnectionString": "Host=localhost;Port=59000;Database=musify_db;Username=postgres;Password=postgres" "ConnectionString": "Host=localhost;Port=5432;Database=musify_db;Username=musify;Password=musify"
}, },
"MassTransit": { "MassTransit": {
"Address": "rabbitmq://localhost:5672", "Address": "rabbitmq://localhost:5672/musify",
"Username": "guest", "Username": "musify",
"Password": "guest" "Password": "musify"
}, },
"InfrastructureStorage": { "InfrastructureStorage": {
"Address": "http://localhost:8333", "Address": "http://localhost:8333",
"AccessKey": "admin_access_key", "AccessKey": "musify_access_key",
"SecretAccessKey": "admin_secret_key", "SecretAccessKey": "musify_secret_key",
"ForcePathStyle": true, "ForcePathStyle": true,
"UseHttp": true "UseHttp": true
}, },
@@ -6,20 +6,20 @@
} }
}, },
"Database": { "Database": {
"ConnectionString": "Host=localhost;Port=59000;Database=musify_db;Username=postgres;Password=postgres" "ConnectionString": "Host=localhost;Port=5432;Database=musify_db;Username=musify;Password=musify"
}, },
"MassTransit": { "MassTransit": {
"Address": "rabbitmq://localhost:5672", "Address": "rabbitmq://localhost:5672/musify",
"Username": "guest", "Username": "musify",
"Password": "guest" "Password": "musify"
}, },
"OpenTelemetry": { "OpenTelemetry": {
"OtlpEndpoint": "http://localhost:4317" "OtlpEndpoint": "http://localhost:4317"
}, },
"InfrastructureStorage": { "InfrastructureStorage": {
"Address": "http://localhost:8333", "Address": "http://localhost:8333",
"AccessKey": "admin_access_key", "AccessKey": "musify_access_key",
"SecretAccessKey": "admin_secret_key", "SecretAccessKey": "musify_secret_key",
"ForcePathStyle": true, "ForcePathStyle": true,
"UseHttp": true "UseHttp": true
}, },
@@ -17,6 +17,17 @@ namespace Musify.Application.Tests.Shared
Assert.Equal("The Owner", response.Artist); Assert.Equal("The Owner", response.Artist);
Assert.Equal(owner.Id, response.OwnerUserId); Assert.Equal(owner.Id, response.OwnerUserId);
Assert.Equal(3, response.ListensCount); 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, TrackPictures? pictures = null,
TrackAudio? audio = null, TrackAudio? audio = null,
LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active, LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active,
IEnumerable<Genre>? tags = null) IEnumerable<Genre>? tags = null,
bool isExplicit = false)
{ {
var track = new Track var track = new Track
{ {
@@ -64,6 +65,7 @@ namespace Musify.Application.Tests.TestSupport
NormalizedTitle = title.ToUpperInvariant(), NormalizedTitle = title.ToUpperInvariant(),
DurationSeconds = durationSeconds, DurationSeconds = durationSeconds,
LifeCycleStatus = lifeCycleStatus, LifeCycleStatus = lifeCycleStatus,
IsExplicit = isExplicit,
OwnerUserId = owner.Id, OwnerUserId = owner.Id,
Owner = owner, Owner = owner,
Pictures = pictures ?? ProcessedPictures(), Pictures = pictures ?? ProcessedPictures(),
@@ -58,6 +58,26 @@ namespace Musify.Application.Tests.Tracks
Assert.Equal(ProcessingStatus.Pending, stored.Audio.TranscodeStatus); 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] [Fact]
public async Task Handle_NoTags_ReturnsValidationError() public async Task Handle_NoTags_ReturnsValidationError()
{ {
@@ -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); 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] [Fact]
public async Task Handle_UserMissing_ReturnsNotFound() public async Task Handle_UserMissing_ReturnsNotFound()
{ {
@@ -1,5 +1,6 @@
using Musify.Application.Tests.TestSupport; using Musify.Application.Tests.TestSupport;
using Musify.Application.Users; using Musify.Application.Users;
using Musify.Domain.Entities;
using Xunit; using Xunit;
namespace Musify.Application.Tests.Users namespace Musify.Application.Tests.Users
@@ -30,5 +31,18 @@ namespace Musify.Application.Tests.Users
var user = Assert.Single(result.Value.Items); var user = Assert.Single(result.Value.Items);
Assert.Equal(1, user.Id); 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));
}
} }
} }
+21 -15
View File
@@ -5,8 +5,10 @@
# #
# Requires ../../../Infrastructure running first (Postgres, Zitadel, # Requires ../../../Infrastructure running first (Postgres, Zitadel,
# SeaweedFS, RabbitMQ, Jaeger). See ../../../Infrastructure/README.md. # SeaweedFS, RabbitMQ, Jaeger). See ../../../Infrastructure/README.md.
# Everything marked "must match Infrastructure's .env" below has to be the # Musify has its own credentials on every shared service (role, vhost, S3
# same value as the one in that file. # identity, Zitadel service account), provisioned by Infrastructure. Every
# value marked "= Infrastructure's MUSIFY_*" below has to be the same as that
# setting in Infrastructure's .env.
DOTNET_ENVIRONMENT=Development DOTNET_ENVIRONMENT=Development
@@ -21,17 +23,18 @@ PUBLIC_STREAM_URL=http://localhost:8081
PUBLIC_AUTH_URL=http://host.docker.internal:8080 PUBLIC_AUTH_URL=http://host.docker.internal:8080
# --- PostgreSQL (Infrastructure's shared instance, Musify's own database) --- # --- PostgreSQL (Infrastructure's shared instance, Musify's own database) ---
# POSTGRES_USER / POSTGRES_PASSWORD must match Infrastructure's .env. # Musify's own role, owner of musify_db only.
POSTGRES_HOST=host.docker.internal POSTGRES_HOST=host.docker.internal
POSTGRES_USER=postgres POSTGRES_USER=musify
POSTGRES_PASSWORD=postgres # = Infrastructure's MUSIFY_DB_PASSWORD
POSTGRES_PASSWORD=musify
POSTGRES_DB=musify_db POSTGRES_DB=musify_db
# --- Zitadel (OIDC, shared instance) ----------------------------------------- # --- Zitadel (OIDC, shared instance) -----------------------------------------
# Path to the admin service-account PAT Infrastructure's Zitadel writes on # Folder with Musify's own Zitadel PAT (a service account that owns the
# init. Adjust if your Infrastructure checkout lives somewhere else relative # Musify project only), written by Infrastructure's `provision`. Adjust if
# to this one. # your Infrastructure checkout lives somewhere else relative to this one.
ZITADEL_ADMIN_PAT_DIR=../../../Infrastructure/zitadel/.output ZITADEL_PAT_DIR=../../../Infrastructure/zitadel/.output/musify
# Allows the OIDC apps this project creates to use plain-http redirect URIs # Allows the OIDC apps this project creates to use plain-http redirect URIs
# (dev only). # (dev only).
ZITADEL_DEV_MODE=true ZITADEL_DEV_MODE=true
@@ -43,16 +46,19 @@ WEB_CLIENT_ID=
WEB_CLIENT_SECRET= WEB_CLIENT_SECRET=
# --- RabbitMQ (shared instance, MassTransit) -------------------------------- # --- RabbitMQ (shared instance, MassTransit) --------------------------------
# Must match Infrastructure's .env. # Musify's own user, only on its own vhost.
RABBITMQ_HOST=host.docker.internal RABBITMQ_HOST=host.docker.internal
RABBITMQ_DEFAULT_USER=guest RABBITMQ_VHOST=musify
RABBITMQ_DEFAULT_PASS=guest RABBITMQ_USER=musify
# = Infrastructure's MUSIFY_RABBITMQ_PASSWORD
RABBITMQ_PASSWORD=musify
# --- SeaweedFS (S3, shared instance, Musify's own bucket) -------------------- # --- SeaweedFS (S3, shared instance, Musify's own bucket) --------------------
# S3_ACCESS_KEY / S3_SECRET_KEY must match Infrastructure's .env. # Musify's own identity, limited to S3_BUCKET.
SEAWEEDFS_HOST=host.docker.internal SEAWEEDFS_HOST=host.docker.internal
S3_ACCESS_KEY=admin_access_key # = Infrastructure's MUSIFY_S3_ACCESS_KEY / MUSIFY_S3_SECRET_KEY
S3_SECRET_KEY=admin_secret_key S3_ACCESS_KEY=musify_access_key
S3_SECRET_KEY=musify_secret_key
S3_BUCKET=webapi-storage S3_BUCKET=webapi-storage
S3_PUBLIC_URL=http://host.docker.internal:8333 S3_PUBLIC_URL=http://host.docker.internal:8333
S3_INTERNAL_URL=http://host.docker.internal:8333 S3_INTERNAL_URL=http://host.docker.internal:8333
+10 -5
View File
@@ -28,14 +28,16 @@ PUBLIC_AUTH_URL=https://auth.example.com
S3_PUBLIC_URL=https://s3.example.com S3_PUBLIC_URL=https://s3.example.com
# --- PostgreSQL (Infrastructure's shared instance, Musify's own database) --- # --- PostgreSQL (Infrastructure's shared instance, Musify's own database) ---
# POSTGRES_USER / POSTGRES_PASSWORD must match Infrastructure's .env.prod. # Musify's own role, owner of musify_db only.
POSTGRES_HOST=postgres POSTGRES_HOST=postgres
POSTGRES_USER=postgres POSTGRES_USER=musify
# = Infrastructure's MUSIFY_DB_PASSWORD
POSTGRES_PASSWORD=CHANGE_ME POSTGRES_PASSWORD=CHANGE_ME
POSTGRES_DB=musify_db POSTGRES_DB=musify_db
# --- Zitadel (OIDC, shared instance) ----------------------------------------- # --- Zitadel (OIDC, shared instance) -----------------------------------------
ZITADEL_ADMIN_PAT_DIR=../../../Infrastructure/zitadel/.output # Folder with Musify's own Zitadel PAT, written by Infrastructure's `provision`.
ZITADEL_PAT_DIR=../../../Infrastructure/zitadel/.output/musify
ZITADEL_DEV_MODE=false ZITADEL_DEV_MODE=false
# OIDC clients: the zitadel-init one-shot service (see compose.yml) fills # OIDC clients: the zitadel-init one-shot service (see compose.yml) fills
@@ -46,11 +48,14 @@ WEB_CLIENT_SECRET=
# --- RabbitMQ (shared instance, MassTransit) -------------------------------- # --- RabbitMQ (shared instance, MassTransit) --------------------------------
RABBITMQ_HOST=rabbitmq RABBITMQ_HOST=rabbitmq
RABBITMQ_DEFAULT_USER=musify RABBITMQ_VHOST=musify
RABBITMQ_DEFAULT_PASS=CHANGE_ME RABBITMQ_USER=musify
# = Infrastructure's MUSIFY_RABBITMQ_PASSWORD
RABBITMQ_PASSWORD=CHANGE_ME
# --- SeaweedFS (S3, shared instance, Musify's own bucket) -------------------- # --- SeaweedFS (S3, shared instance, Musify's own bucket) --------------------
SEAWEEDFS_HOST=seaweedfs SEAWEEDFS_HOST=seaweedfs
# = Infrastructure's MUSIFY_S3_ACCESS_KEY / MUSIFY_S3_SECRET_KEY
S3_ACCESS_KEY=CHANGE_ME S3_ACCESS_KEY=CHANGE_ME
S3_SECRET_KEY=CHANGE_ME S3_SECRET_KEY=CHANGE_ME
S3_BUCKET=webapi-storage S3_BUCKET=webapi-storage
+11 -7
View File
@@ -68,13 +68,17 @@ dotnet run --project backend/Hosts/Musify.Worker # background
npm --prefix web-player run dev # :5173 npm --prefix web-player run dev # :5173
``` ```
When running the apps from the IDE, `Musify.Api` reads `Authentication:*` When running the apps from the IDE, neither the .NET hosts nor `web-player`
from user-secrets and `web-player` reads its own `web-player/.env`, neither look at `deploy/.env`. The hosts' `appsettings.Development.json` already
looks at `deploy/.env`. Copy the four values `zitadel-init` wrote point at Infrastructure's dev stack with Musify's own dev credentials (the
(`AUTH_CLIENT_ID`, `WEB_CLIENT_ID`, `WEB_CLIENT_SECRET`, `NATIVE_CLIENT_ID`) defaults of its `MUSIFY_*` settings) and at the shared Zitadel. Only the
into `dotnet user-secrets set Authentication:ClientId <value> --project OIDC client ids are per-machine: copy the values `zitadel-init` wrote once,
backend/Hosts/Musify.Api` and into `web-player/.env` once. They stay valid `AUTH_CLIENT_ID` into both `Authentication:ClientId` and
until Infrastructure's Zitadel volume is wiped. `Authentication:AudienceAddress` (`dotnet user-secrets set <key> <value>
--project backend/Hosts/Musify.Api`), `WEB_CLIENT_ID` and
`WEB_CLIENT_SECRET` into `web-player/.env`, and `NATIVE_CLIENT_ID` into
`mobile/gradle.properties`. They stay valid until Infrastructure's Zitadel
volume is wiped.
### Ports ### Ports
+42 -33
View File
@@ -26,9 +26,10 @@
# already do. # already do.
x-backend-env: &backend-env x-backend-env: &backend-env
Database__ConnectionString: "Host=${POSTGRES_HOST};Port=5432;Database=${POSTGRES_DB};Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD}" Database__ConnectionString: "Host=${POSTGRES_HOST};Port=5432;Database=${POSTGRES_DB};Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD}"
MassTransit__Address: "rabbitmq://${RABBITMQ_HOST}:5672" # Musify's own vhost and user on the shared broker (the path is the vhost).
MassTransit__Username: ${RABBITMQ_DEFAULT_USER} MassTransit__Address: "rabbitmq://${RABBITMQ_HOST}:5672/${RABBITMQ_VHOST}"
MassTransit__Password: ${RABBITMQ_DEFAULT_PASS} MassTransit__Username: ${RABBITMQ_USER}
MassTransit__Password: ${RABBITMQ_PASSWORD}
# The public S3 URL, not the internal one: the same address is used to sign # The public S3 URL, not the internal one: the same address is used to sign
# the presigned upload/download URLs the browser has to be able to reach. # the presigned upload/download URLs the browser has to be able to reach.
InfrastructureStorage__Address: ${S3_PUBLIC_URL} InfrastructureStorage__Address: ${S3_PUBLIC_URL}
@@ -135,7 +136,7 @@ services:
PUBLIC_WEB_URL: ${PUBLIC_WEB_URL} PUBLIC_WEB_URL: ${PUBLIC_WEB_URL}
ZITADEL_DEV_MODE: ${ZITADEL_DEV_MODE:-false} ZITADEL_DEV_MODE: ${ZITADEL_DEV_MODE:-false}
volumes: volumes:
- ${ZITADEL_ADMIN_PAT_DIR}:/output:ro - ${ZITADEL_PAT_DIR}:/output:ro
entrypoint: ["/bin/sh", "-c"] entrypoint: ["/bin/sh", "-c"]
command: command:
- | - |
@@ -163,13 +164,15 @@ services:
fi fi
} }
echo "==> waiting for the Zitadel service-account PAT" # Musify's own service account, owner of the Musify project only
# (created by Infrastructure's `provision`, see its README).
echo "==> waiting for Musify's Zitadel PAT"
deadline=$$(( $$(date +%s) + 180 )) deadline=$$(( $$(date +%s) + 180 ))
while [ ! -f /output/admin-sa.pat ]; do while [ ! -s /output/zitadel.pat ]; do
[ "$$(date +%s)" -lt "$$deadline" ] || { echo "PAT not found under /output. Is Infrastructure's Zitadel running? Check 'docker logs infra-zitadel'." >&2; exit 1; } [ "$$(date +%s)" -lt "$$deadline" ] || { echo "no zitadel.pat in ZITADEL_PAT_DIR. Is Infrastructure up, and does ZITADEL_PAT_DIR point at its zitadel/.output/musify? Check 'docker logs infra-provision'." >&2; exit 1; }
sleep 3 sleep 3
done done
PAT=$$(tr -d '\r\n' </output/admin-sa.pat) PAT=$$(tr -d '\r\n' </output/zitadel.pat)
MGMT="$$PUBLIC_AUTH_URL/management/v1" MGMT="$$PUBLIC_AUTH_URL/management/v1"
echo "==> waiting for Zitadel to answer at $$PUBLIC_AUTH_URL" echo "==> waiting for Zitadel to answer at $$PUBLIC_AUTH_URL"
@@ -188,21 +191,13 @@ services:
} }
name_query() { printf '{"queries":[{"nameQuery":{"name":"%s","method":"TEXT_QUERY_METHOD_EQUALS"}}]}' "$$1"; } name_query() { printf '{"queries":[{"nameQuery":{"name":"%s","method":"TEXT_QUERY_METHOD_EQUALS"}}]}' "$$1"; }
# `latest` images require the standalone Login UI v2 container, which
# this stack does not run; opting out keeps the built-in /ui/login.
curl -fsS -X PUT "$$PUBLIC_AUTH_URL/v2/features/instance" \
-H "Authorization: Bearer $$PAT" -H "Content-Type: application/json" \
-d '{"loginV2":{"required":false}}' >/dev/null 2>&1 || echo "warning: could not disable Login UI v2"
echo "==> project" echo "==> project"
PROJECT_NAME=Musify PROJECT_NAME=Musify
# Created by Infrastructure's `provision`: this account can't create
# projects, only manage this one.
PROJECT_ID=$$(api_call POST /projects/_search "$$(name_query "$$PROJECT_NAME")" | jq -r --arg n "$$PROJECT_NAME" '.result // [] | map(select(.name==$$n)) | first | .id // empty') PROJECT_ID=$$(api_call POST /projects/_search "$$(name_query "$$PROJECT_NAME")" | jq -r --arg n "$$PROJECT_NAME" '.result // [] | map(select(.name==$$n)) | first | .id // empty')
if [ -z "$$PROJECT_ID" ]; then [ -n "$$PROJECT_ID" ] || { echo "Zitadel project $$PROJECT_NAME not found. Is musify in Infrastructure's x-projects?" >&2; exit 1; }
PROJECT_ID=$$(api_call POST /projects "$$(printf '{"name":"%s"}' "$$PROJECT_NAME")" | jq -r '.id') echo " found (id $$PROJECT_ID)"
echo " created (id $$PROJECT_ID)"
else
echo " reused (id $$PROJECT_ID)"
fi
find_app() { api_call POST "/projects/$$PROJECT_ID/apps/_search" "$$(name_query "$$1")" | jq -r --arg n "$$1" '.result // [] | map(select(.name==$$n)) | first | .id // empty'; } find_app() { api_call POST "/projects/$$PROJECT_ID/apps/_search" "$$(name_query "$$1")" | jq -r --arg n "$$1" '.result // [] | map(select(.name==$$n)) | first | .id // empty'; }
@@ -265,27 +260,38 @@ services:
fi fi
echo "==> native app (KMP client: desktop loopback + mobile custom scheme)" echo "==> native app (KMP client: desktop loopback + mobile custom scheme)"
# Loopback (desktop) and a reverse-domain custom scheme (Android/iOS,
# RFC 8252 §7.1) are both valid for a NATIVE app without devMode, so
# dev and prod share the same config. Unlike the other apps, an
# existing one is also updated, so a change here reaches instances
# created before it.
NATIVE_APP_NAME="Musify Native" NATIVE_APP_NAME="Musify Native"
NATIVE_APP_ID=$$(find_app "$$NATIVE_APP_NAME") NATIVE_CONFIG='{
if [ -n "$$NATIVE_APP_ID" ]; then "redirectUris": ["http://127.0.0.1:17453/callback", "cat.ikerdemo.musify:/callback"],
NATIVE_CLIENT_ID=$$(api_call GET "/projects/$$PROJECT_ID/apps/$$NATIVE_APP_ID" | jq -r '.app.oidcConfig.clientId') "postLogoutRedirectUris": ["http://127.0.0.1:17453/logout", "cat.ikerdemo.musify:/logout"],
echo " reused (clientId $$NATIVE_CLIENT_ID)"
else
NATIVE_CLIENT_ID=$$(api_call POST "/projects/$$PROJECT_ID/apps/oidc" '{
"name": "Musify Native",
"redirectUris": ["http://127.0.0.1:17453/callback", "musify://callback"],
"postLogoutRedirectUris": ["http://127.0.0.1:17453/logout", "musify://logout"],
"responseTypes": ["OIDC_RESPONSE_TYPE_CODE"], "responseTypes": ["OIDC_RESPONSE_TYPE_CODE"],
"grantTypes": ["OIDC_GRANT_TYPE_AUTHORIZATION_CODE", "OIDC_GRANT_TYPE_REFRESH_TOKEN"], "grantTypes": ["OIDC_GRANT_TYPE_AUTHORIZATION_CODE", "OIDC_GRANT_TYPE_REFRESH_TOKEN"],
"appType": "OIDC_APP_TYPE_NATIVE", "appType": "OIDC_APP_TYPE_NATIVE",
"authMethodType": "OIDC_AUTH_METHOD_TYPE_NONE", "authMethodType": "OIDC_AUTH_METHOD_TYPE_NONE",
"version": "OIDC_VERSION_1_0", "devMode": false,
"devMode": true,
"accessTokenType": "OIDC_TOKEN_TYPE_JWT", "accessTokenType": "OIDC_TOKEN_TYPE_JWT",
"accessTokenRoleAssertion": true, "accessTokenRoleAssertion": true,
"idTokenRoleAssertion": true, "idTokenRoleAssertion": true,
"idTokenUserinfoAssertion": true "idTokenUserinfoAssertion": true
}' | jq -r '.clientId') }'
NATIVE_APP_ID=$$(find_app "$$NATIVE_APP_NAME")
if [ -n "$$NATIVE_APP_ID" ]; then
NATIVE_CLIENT_ID=$$(api_call GET "/projects/$$PROJECT_ID/apps/$$NATIVE_APP_ID" | jq -r '.app.oidcConfig.clientId')
# Zitadel answers 400 "No changes" when the config already matches.
RESULT=$$(curl -sS -X PUT "$$MGMT/projects/$$PROJECT_ID/apps/$$NATIVE_APP_ID/oidc_config" \
-H "Authorization: Bearer $$PAT" -H "Content-Type: application/json" -d "$$NATIVE_CONFIG")
case "$$RESULT" in
*'"details"'*'"changeDate"'*) echo " reused (clientId $$NATIVE_CLIENT_ID, config updated)" ;;
*COMMAND-1m88i*) echo " reused (clientId $$NATIVE_CLIENT_ID)" ;;
*) echo "could not update the native app config: $$RESULT" >&2; exit 1 ;;
esac
else
NATIVE_CLIENT_ID=$$(api_call POST "/projects/$$PROJECT_ID/apps/oidc" "$$(echo "$$NATIVE_CONFIG" | jq --arg name "$$NATIVE_APP_NAME" '. + {name: $$name, version: "OIDC_VERSION_1_0"}')" | jq -r '.clientId')
echo " created (clientId $$NATIVE_CLIENT_ID)" echo " created (clientId $$NATIVE_CLIENT_ID)"
fi fi
@@ -393,7 +399,10 @@ services:
AUTH_POST_LOGOUT_URI: "${PUBLIC_WEB_URL}/" AUTH_POST_LOGOUT_URI: "${PUBLIC_WEB_URL}/"
AUTH_ALLOW_INSECURE_HTTP: ${AUTH_ALLOW_INSECURE_HTTP} AUTH_ALLOW_INSECURE_HTTP: ${AUTH_ALLOW_INSECURE_HTTP}
SESSION_SECRET: ${SESSION_SECRET} SESSION_SECRET: ${SESSION_SECRET}
API_BASE_URL: http://api:5111 # The container name, not the `api` service name: in prod every project
# shares infra-net, where a generic name can resolve to another
# project's container.
API_BASE_URL: http://musify-api:5111
# Presigned PUTs are signed for the public S3 host; the container sends # Presigned PUTs are signed for the public S3 host; the container sends
# them straight to SeaweedFS instead of going back out through the edge. # them straight to SeaweedFS instead of going back out through the edge.
S3_UPLOAD_ENDPOINT: ${S3_INTERNAL_URL} S3_UPLOAD_ENDPOINT: ${S3_INTERNAL_URL}
+3 -3
View File
@@ -54,7 +54,7 @@ al patrón que ya usa Scalar para probar la API — ver `deploy/compose.yml` (`z
Redirect URIs registrados: Redirect URIs registrados:
- Desktop: `http://127.0.0.1:17453/callback` (RFC 8252, loopback con puerto fijo). - Desktop: `http://127.0.0.1:17453/callback` (RFC 8252, loopback con puerto fijo).
- Android / iOS: `musify://callback` (custom scheme). - Android / iOS: `cat.ikerdemo.musify:/callback` (custom scheme con dominio invertido, RFC 8252 §7.1).
`mobile/gradle.properties` controla a qué backend apunta Android/Desktop: `mobile/gradle.properties` controla a qué backend apunta Android/Desktop:
@@ -125,13 +125,13 @@ Kotlin/Native (`iosMain/`) sigue las APIs estándar de interop (`AVFoundation`,
1. Genera un proyecto Xcode con el asistente "Kotlin Multiplatform" de Android Studio, o crea uno vacío 1. Genera un proyecto Xcode con el asistente "Kotlin Multiplatform" de Android Studio, o crea uno vacío
e importa `composeApp` como framework (`./gradlew :composeApp:embedAndSignAppleFrameworkForXcode`). e importa `composeApp` como framework (`./gradlew :composeApp:embedAndSignAppleFrameworkForXcode`).
2. Registra el scheme `musify` en `Info.plist`: 2. Registra el scheme `cat.ikerdemo.musify` en `Info.plist`:
```xml ```xml
<key>CFBundleURLTypes</key> <key>CFBundleURLTypes</key>
<array> <array>
<dict> <dict>
<key>CFBundleURLSchemes</key> <key>CFBundleURLSchemes</key>
<array><string>musify</string></array> <array><string>cat.ikerdemo.musify</string></array>
</dict> </dict>
</array> </array>
``` ```
@@ -18,7 +18,9 @@
</intent-filter> </intent-filter>
</activity> </activity>
<!-- Catches the `musify://callback` redirect Zitadel sends after login/register. <!-- Catches the `cat.ikerdemo.musify:/callback` redirect Zitadel sends after
login/register. The URI has no host, so the filter matches on the scheme
alone.
See BrowserAuthLauncher.android.kt and AppConfig.nativeRedirectUri. --> See BrowserAuthLauncher.android.kt and AppConfig.nativeRedirectUri. -->
<activity <activity
android:name=".auth.AuthCallbackActivity" android:name=".auth.AuthCallbackActivity"
@@ -29,7 +31,7 @@
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="musify" android:host="callback" /> <data android:scheme="cat.ikerdemo.musify" />
</intent-filter> </intent-filter>
</activity> </activity>
</application> </application>
@@ -9,5 +9,5 @@ actual object AppConfig {
// Matches the AuthCallbackActivity intent-filter in AndroidManifest.xml and // Matches the AuthCallbackActivity intent-filter in AndroidManifest.xml and
// the redirect URI registered for the "Musify Native" app in Zitadel. // the redirect URI registered for the "Musify Native" app in Zitadel.
actual val nativeRedirectUri: String = "musify://callback" actual val nativeRedirectUri: String = "cat.ikerdemo.musify:/callback"
} }
@@ -5,7 +5,7 @@ import android.content.Intent
import android.os.Bundle import android.os.Bundle
/** /**
* Declared in AndroidManifest.xml with an intent-filter for `musify://callback`. * Declared in AndroidManifest.xml with an intent-filter for `cat.ikerdemo.musify:/callback`.
* Chrome Custom Tabs redirects here once Zitadel finishes the login/register * Chrome Custom Tabs redirects here once Zitadel finishes the login/register
* flow; this activity just forwards the URL to [AndroidAuthBridge] and closes. * flow; this activity just forwards the URL to [AndroidAuthBridge] and closes.
*/ */
@@ -9,7 +9,8 @@ package com.musify.app
* `nativeRedirectUri` is the OAuth redirect registered for the "Musify * `nativeRedirectUri` is the OAuth redirect registered for the "Musify
* Native" Zitadel app (see deploy/compose.yml, zitadel-init): a fixed * Native" Zitadel app (see deploy/compose.yml, zitadel-init): a fixed
* loopback port on Desktop (RFC 8252 native-app loopback flow), and the * loopback port on Desktop (RFC 8252 native-app loopback flow), and the
* `musify://callback` custom scheme on Android/iOS. * `cat.ikerdemo.musify:/callback` reverse-domain custom scheme on Android/iOS
* (RFC 8252 §7.1: based on a domain we own, so no other app plausibly claims it).
*/ */
expect object AppConfig { expect object AppConfig {
val apiBaseUrl: String val apiBaseUrl: String
@@ -12,7 +12,7 @@ package com.musify.app.auth
* - Desktop: a one-shot loopback HTTP server on the fixed port registered in * - Desktop: a one-shot loopback HTTP server on the fixed port registered in
* Zitadel (RFC 8252 §7.3), opened via the OS default browser. * Zitadel (RFC 8252 §7.3), opened via the OS default browser.
* - Android: Chrome Custom Tabs; the redirect is caught by * - Android: Chrome Custom Tabs; the redirect is caught by
* [AuthCallbackActivity] via the `musify://callback` intent filter. * [AuthCallbackActivity] via the `cat.ikerdemo.musify:/callback` intent filter.
* - iOS: `ASWebAuthenticationSession`, which owns the whole round trip. * - iOS: `ASWebAuthenticationSession`, which owns the whole round trip.
*/ */
expect class BrowserAuthLauncher() { expect class BrowserAuthLauncher() {
@@ -12,5 +12,5 @@ actual object AppConfig {
// Matches CFBundleURLTypes in Info.plist and the redirect URI registered // Matches CFBundleURLTypes in Info.plist and the redirect URI registered
// for the "Musify Native" app in Zitadel. // for the "Musify Native" app in Zitadel.
actual val nativeRedirectUri: String = "musify://callback" actual val nativeRedirectUri: String = "cat.ikerdemo.musify:/callback"
} }
@@ -23,14 +23,15 @@ private class PresentationContextProvider : NSObject(), ASWebAuthenticationPrese
/** /**
* `ASWebAuthenticationSession` owns the whole round trip: it opens a system * `ASWebAuthenticationSession` owns the whole round trip: it opens a system
* browser sheet at [authorizeUrl] (Zitadel's hosted login, with its * browser sheet at [authorizeUrl] (Zitadel's hosted login, with its
* "Regístrate" link) and intercepts the `musify://` redirect itself, no app * "Regístrate" link) and intercepts the `cat.ikerdemo.musify:` redirect itself, no app
* delegate / `onOpenURL` wiring required. * delegate / `onOpenURL` wiring required.
*/ */
@OptIn(ExperimentalForeignApi::class) @OptIn(ExperimentalForeignApi::class)
actual class BrowserAuthLauncher actual constructor() { actual class BrowserAuthLauncher actual constructor() {
actual suspend fun launch(authorizeUrl: String, redirectUri: String): String = actual suspend fun launch(authorizeUrl: String, redirectUri: String): String =
suspendCancellableCoroutine { continuation -> suspendCancellableCoroutine { continuation ->
val scheme = redirectUri.substringBefore("://") // `scheme:/path` (RFC 8252 private-use scheme): everything before the first ':'.
val scheme = redirectUri.substringBefore(":")
val contextProvider = PresentationContextProvider() val contextProvider = PresentationContextProvider()
val session = ASWebAuthenticationSession( val session = ASWebAuthenticationSession(
+8 -4
View File
@@ -42,7 +42,7 @@ npm run lint:tokens # scripts/check-tokens.mjs, the design system guardrail
- `media/` for music domain display components (Artwork, TrackList, MediaCard, PlayButton, and so on). - `media/` for music domain display components (Artwork, TrackList, MediaCard, PlayButton, and so on).
- `forms/` for composite entity forms (AlbumForm, PlaylistForm, CoverForm, ImageDropzone). - `forms/` for composite entity forms (AlbumForm, PlaylistForm, CoverForm, ImageDropzone).
- `layout/` for page level scaffolding (Page, PageHeader, SectionHeading). This is different from `components/layout/` above, which is the global app chrome. - `layout/` for page level scaffolding (Page, PageHeader, SectionHeading). This is different from `components/layout/` above, which is the global app chrome.
- 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]/`. - 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`).
## Styles ## Styles
@@ -55,15 +55,19 @@ The design system lives in `src/routes/layout.css` (`@theme` tokens plus `@utili
- **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. - **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. - **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`. - 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`.
- Known and accepted gap (documented in `scripts/check-tokens.mjs`): icon `strokeWidth={n}` isn't unified yet. There are 13 different values spread across almost every component, and migrating them to `stroke-thin/regular/bold` recipes safely would require visually confirming that `lucide-svelte` actually respects `stroke-width` through CSS, so that was left out of the last pass. - **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/+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/navigation.svelte.ts`; don't use `window.scrollTo`/`scrollY`.
## Motion ## 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 and the player dock padding. 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. 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 `+layout.svelte` (600ms), 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`. The only exceptions are looping indicators (equalizer bars, pulse, spin, sheen) and the accent hue crossfade in `+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 ## Auth
+1 -1
View File
@@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html lang="en" class="scrollbar-stable"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
+132 -1
View File
@@ -658,6 +658,40 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/users/{id}/followers": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get The Users Following A User. Visible To The User And To Mutual Followers. */
get: operations["GetUserFollowers"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/users/{id}/following": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get The Users A User Follows. */
get: operations["GetUserFollowing"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/users/{id}/is-following": { "/users/{id}/is-following": {
parameters: { parameters: {
query?: never; query?: never;
@@ -773,6 +807,8 @@ export interface components {
/** Format: uuid */ /** Format: uuid */
audioIntentId: string; audioIntentId: string;
tags: components["schemas"]["Genre"][]; tags: components["schemas"]["Genre"][];
/** @default false */
isExplicit: boolean;
}; };
CreateUserRequest: { CreateUserRequest: {
/** Format: int64 */ /** Format: int64 */
@@ -865,6 +901,19 @@ export interface components {
hasNextPage: boolean; hasNextPage: boolean;
hasPreviousPage: boolean; hasPreviousPage: boolean;
}; };
PaginatedResponseOfUserSummaryResponse: {
items: components["schemas"]["UserSummaryResponse"][];
/** Format: int32 */
pageNumber: number | string;
/** Format: int32 */
pageSize: number | string;
/** Format: int32 */
pageCount: number | string;
/** Format: int32 */
totalItemCount: number | string;
hasNextPage: boolean;
hasPreviousPage: boolean;
};
PaginatedResponseOfUserApplicationResponse: { PaginatedResponseOfUserApplicationResponse: {
items: components["schemas"]["UserApplicationResponse"][]; items: components["schemas"]["UserApplicationResponse"][];
/** Format: int32 */ /** Format: int32 */
@@ -959,6 +1008,7 @@ export interface components {
updatedAt: string; updatedAt: string;
ownerUserId: string; ownerUserId: string;
tags: components["schemas"]["Genre"][]; tags: components["schemas"]["Genre"][];
isExplicit: boolean;
}; };
TrackSearchItemResponse: { TrackSearchItemResponse: {
track: components["schemas"]["TrackApplicationResponse"]; track: components["schemas"]["TrackApplicationResponse"];
@@ -1039,6 +1089,16 @@ export interface components {
followersCount: number | string; followersCount: number | string;
/** Format: int32 */ /** Format: int32 */
followingCount: number | string; followingCount: number | string;
isFollowing: boolean;
canViewFollowers: boolean;
};
UserSummaryResponse: {
id: string;
name: string;
firstName: null | string;
secondName: null | string;
profilePictureUrl: null | string;
isFollowedByViewer: boolean;
}; };
}; };
responses: never; responses: never;
@@ -2428,7 +2488,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["PaginatedResponseOfUserApplicationResponse"]; "application/json": components["schemas"]["PaginatedResponseOfUserSummaryResponse"];
}; };
}; };
}; };
@@ -2611,6 +2671,77 @@ export interface operations {
}; };
}; };
}; };
GetUserFollowers: {
parameters: {
query?: {
pageNumber?: number | string;
pageSize?: number | string;
};
header?: never;
path: {
id: number | string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PaginatedResponseOfUserSummaryResponse"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
GetUserFollowing: {
parameters: {
query?: {
pageNumber?: number | string;
pageSize?: number | string;
};
header?: never;
path: {
id: number | string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PaginatedResponseOfUserSummaryResponse"];
};
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
IsFollowingUser: { IsFollowingUser: {
parameters: { parameters: {
query?: never; query?: never;
@@ -44,7 +44,7 @@
</AccountMenu> </AccountMenu>
{:else} {:else}
<IconButton href="/auth/login" reload label="Iniciar sesión" shape="round" size="md"> <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> </IconButton>
{/if} {/if}
</header> </header>
@@ -31,7 +31,7 @@
</script> </script>
<aside <aside
class="hidden shrink-0 flex-col border-r border-hairline p-5 lg:flex" class="hidden min-h-0 shrink-0 flex-col border-r border-hairline p-5 lg:flex"
style="width:var(--mf-sidebar-w)" style="width:var(--mf-sidebar-w)"
> >
<a href="/" class="mb-4"> <a href="/" class="mb-4">
@@ -48,7 +48,7 @@
? 'bg-surface-2 text-fg' ? 'bg-surface-2 text-fg'
: 'text-fg-2 hover:bg-hover hover:text-fg'}" : 'text-fg-2 hover:bg-hover hover:text-fg'}"
> >
<link.icon class="size-icon-md shrink-0" strokeWidth={1.5} /> <link.icon class="size-icon-md shrink-0" />
<span class="flex-1 truncate">{link.label}</span> <span class="flex-1 truncate">{link.label}</span>
{#if link.count} {#if link.count}
<span class="text-xs text-fg-2 tabular-nums">{link.count}</span> <span class="text-xs text-fg-2 tabular-nums">{link.count}</span>
@@ -68,12 +68,12 @@
size="xs" size="xs"
onclick={() => createPlaylistModal.show()} onclick={() => createPlaylistModal.show()}
> >
<Plus class="size-icon-sm text-fg-2" strokeWidth={1.25} /> <Plus class="size-icon-sm text-fg-2" />
</IconButton> </IconButton>
{/if} {/if}
</div> </div>
<div class="flex flex-1 flex-col gap-px overflow-y-auto pb-icon-md"> <div class="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto pb-icon-md">
{#each playlists as playlist (playlist.id)} {#each playlists as playlist (playlist.id)}
{@const active = page.url.pathname === `/playlists/${playlist.id}`} {@const active = page.url.pathname === `/playlists/${playlist.id}`}
<ListRow <ListRow
@@ -93,7 +93,7 @@
onclick={() => createPlaylistModal.show()} onclick={() => createPlaylistModal.show()}
class="flex items-center gap-2 rounded-control px-3 py-2 text-xs text-fg-2 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 hover:text-fg-2"
> >
<Plus class="size-3.5" strokeWidth={1.8} /> <Plus class="size-3.5" />
Crear tu primera lista Crear tu primera lista
</button> </button>
{/each} {/each}
@@ -18,7 +18,7 @@
</script> </script>
<nav <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))" style="padding-bottom:calc(0.4375rem + var(--mf-safe-b))"
> >
{#each links as link (link.href)} {#each links as link (link.href)}
@@ -30,7 +30,7 @@
? 'text-fg' ? 'text-fg'
: 'text-fg-2'}" : 'text-fg-2'}"
> >
<link.icon class="size-4" strokeWidth={1.6} /> <link.icon class="size-4" />
<span class="text-xs font-medium">{link.label}</span> <span class="text-xs font-medium">{link.label}</span>
</a> </a>
{/each} {/each}
@@ -62,14 +62,14 @@
size="xs" size="xs"
onclick={() => history.back()} onclick={() => history.back()}
> >
<ChevronLeft class="size-icon-sm" strokeWidth={1.9} /> <ChevronLeft class="size-icon-sm" />
</IconButton> </IconButton>
<form onsubmit={onSubmit} class="max-w-search min-w-0 flex-1"> <form onsubmit={onSubmit} class="max-w-search min-w-0 flex-1">
<label <label
class="flex h-9.5 w-full items-center gap-2.5 rounded-control bg-surface-2 px-3.5 transition focus-within:bg-surface-hover" class="flex h-9.5 w-full items-center gap-2.5 rounded-control bg-surface-2 px-3.5 transition focus-within:bg-surface-hover"
> >
<Search class="size-4 shrink-0 text-fg-2" strokeWidth={1.8} /> <Search class="size-4 shrink-0 text-fg-2" />
<input <input
type="text" type="text"
bind:this={searchInput} bind:this={searchInput}
@@ -6,7 +6,9 @@
import { player } from '$lib/player/player.svelte'; import { player } from '$lib/player/player.svelte';
import { queuePanel } from '$lib/player/queuePanel.svelte'; import { queuePanel } from '$lib/player/queuePanel.svelte';
import { fmtTime } from '$lib/utils/format'; import { fmtTime } from '$lib/utils/format';
import Volume1 from '@lucide/svelte/icons/volume-1';
import Volume2 from '@lucide/svelte/icons/volume-2'; import Volume2 from '@lucide/svelte/icons/volume-2';
import VolumeX from '@lucide/svelte/icons/volume-x';
import ListMusic from '@lucide/svelte/icons/list-music'; import ListMusic from '@lucide/svelte/icons/list-music';
function seek(value: number) { function seek(value: number) {
@@ -15,7 +17,7 @@
</script> </script>
<div <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-icon-md px-4 py-3 glass-bar sm:hidden"
> >
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<TrackInfo compact /> <TrackInfo compact />
@@ -24,7 +26,7 @@
</div> </div>
<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"> <div class="w-(--mf-player-side-w) min-w-0 shrink-0">
<TrackInfo /> <TrackInfo />
@@ -48,9 +50,22 @@
</div> </div>
</div> </div>
<div class="hidden w-(--mf-player-side-w) shrink-0 items-center justify-end gap-3.5 sm:flex"> <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-2" strokeWidth={1.8} /> <IconButton
label={player.muted ? 'Activar sonido' : 'Silenciar'}
tone="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 <Slider
value={player.volume} value={player.muted ? 0 : player.volume}
max={100} max={100}
label="Volumen" label="Volumen"
oninput={(value) => player.setVolume(value)} oninput={(value) => player.setVolume(value)}
@@ -63,7 +78,7 @@
pressed={queuePanel.open} pressed={queuePanel.open}
onclick={() => queuePanel.toggle()} onclick={() => queuePanel.toggle()}
> >
<ListMusic class="size-icon-sm" strokeWidth={1.8} /> <ListMusic class="size-icon-sm" />
</IconButton> </IconButton>
</div> </div>
</div> </div>
@@ -13,9 +13,7 @@
const hasTrack = $derived(player.currentId !== null); const hasTrack = $derived(player.currentId !== null);
</script> </script>
<div <div class="flex shrink-0 flex-col">
class="pointer-events-none fixed inset-x-0 bottom-0 z-(--z-sticky) flex flex-col lg:left-(--mf-sidebar-w)"
>
{#if hasTrack} {#if hasTrack}
<PlayerBar /> <PlayerBar />
{/if} {/if}
@@ -5,21 +5,46 @@
import ListRow from '$lib/components/ui/media/ListRow.svelte'; import ListRow from '$lib/components/ui/media/ListRow.svelte';
import IconButton from '$lib/components/ui/primitives/IconButton.svelte'; import IconButton from '$lib/components/ui/primitives/IconButton.svelte';
import { fmtTime } from '$lib/utils/format'; import { fmtTime } from '$lib/utils/format';
import X from '@lucide/svelte/icons/x'; import { flip } from 'svelte/animate';
import { fly } from 'svelte/transition';
import { expoOut } from 'svelte/easing'; import { expoOut } from 'svelte/easing';
import X from '@lucide/svelte/icons/x';
const upcoming = $derived.by(() => { const upcoming = $derived.by(() => {
const idx = player.tracks.findIndex((t) => t.id === player.currentId); const idx = player.tracks.findIndex((t) => t.id === player.currentId);
return idx === -1 ? player.tracks : player.tracks.slice(idx + 1); 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> </script>
{#if queuePanel.open} <div
class="queue-shell max-sm:hidden"
data-open={queuePanel.open ? '' : undefined}
inert={!queuePanel.open}
>
<aside <aside
transition:fly={{ x: 24, duration: 200, easing: expoOut }} class="flex min-h-0 shrink-0 flex-col border-l border-hairline px-4 pt-5 pb-5"
class="hidden shrink-0 flex-col border-l border-hairline px-4 pt-5 sm:flex" style="width:var(--mf-queue-w)"
style="width:var(--mf-queue-w); padding-bottom:calc(var(--mf-player-h) + 2rem)"
> >
<div class="mb-5 flex items-center justify-between"> <div class="mb-5 flex items-center justify-between">
<h2 class="font-display text-base font-medium tracking-tight text-fg">En cola</h2> <h2 class="font-display text-base font-medium tracking-tight text-fg">En cola</h2>
@@ -30,12 +55,13 @@
{#if player.current.id} {#if player.current.id}
<p class="mb-3 text-eyebrow text-fg-2">Reproduciendo</p> <p class="mb-3 text-eyebrow text-fg-2">Reproduciendo</p>
<div class="mb-6 rounded-control surface-active p-2.5"> <div class="mb-6 surface-active rounded-control p-2.5">
<MediaIdentity <MediaIdentity
trackId={player.current.id} trackId={player.current.id}
title={player.current.title} title={player.current.title}
artist={player.current.artist || '—'} artist={player.current.artist || '—'}
ownerUserId={player.current.ownerUserId} ownerUserId={player.current.ownerUserId}
explicit={player.current.explicit}
active active
/> />
</div> </div>
@@ -54,27 +80,66 @@
{/if} {/if}
</div> </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)} {#each upcoming as track, i (track.id)}
{@const index = player.tracks.length - upcoming.length + i} {@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 <ListRow
onclick={() => player.playQueueIndex(index)} onclick={() => player.playQueueIndex(index)}
size="sm" size="sm"
title={track.title} title={track.title}
subtitle={track.artist || '—'} subtitle={track.artist || '—'}
subtitleHref={track.ownerUserId} subtitleHref={track.ownerUserId}
explicit={track.explicit}
trackId={track.id} trackId={track.id}
class="p-2" class="p-2"
> >
{#snippet trailing()} {#snippet trailing()}
{#if track.duration} {#if track.duration}
<span class="shrink-0 text-xs text-fg-2 tabular-nums">{fmtTime(track.duration)}</span> <span class="shrink-0 text-xs text-fg-2 tabular-nums"
>{fmtTime(track.duration)}</span
>
{/if} {/if}
{/snippet} {/snippet}
</ListRow> </ListRow>
</div>
{:else} {:else}
<p class="px-2 py-6 text-center text-xs text-fg-2">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} {/each}
</div> </div>
</aside> </aside>
{/if} </div>
@@ -39,7 +39,7 @@
{:else} {:else}
<div class="size-cover-sm shrink-0 rounded-art bg-surface md:size-cover-md"></div> <div class="size-cover-sm shrink-0 rounded-art bg-surface md:size-cover-md"></div>
{/if} {/if}
<div class="min-w-0 flex-1"> <div class="min-w-0">
<div class="flex min-w-0 items-center gap-1.5"> <div class="flex min-w-0 items-center gap-1.5">
{#if player.current.explicit} {#if player.current.explicit}
<ExplicitBadge /> <ExplicitBadge />
@@ -56,11 +56,11 @@
<IconButton <IconButton
label={isLiked ? 'Quitar de Me gusta' : 'Añadir a Me gusta'} label={isLiked ? 'Quitar de Me gusta' : 'Añadir a Me gusta'}
tone="plain" tone="plain"
size="xs" size="md"
pressed={isLiked} pressed={isLiked}
onclick={toggleLike} onclick={toggleLike}
> >
<Heart class="size-icon-md" fill={isLiked ? 'currentColor' : 'none'} strokeWidth={1.7} /> <Heart class="size-icon-md" fill={isLiked ? 'currentColor' : 'none'} />
</IconButton> </IconButton>
{/if} {/if}
</div> </div>
@@ -26,7 +26,7 @@
aria-label="Aleatorio" aria-label="Aleatorio"
class="transition-colors hover:text-fg {player.shuffle ? 'text-accent' : 'text-fg-2'}" class="transition-colors hover:text-fg {player.shuffle ? 'text-accent' : 'text-fg-2'}"
> >
<Shuffle class="size-4" strokeWidth={1.8} /> <Shuffle class="size-4" />
</button> </button>
{/if} {/if}
@@ -58,7 +58,7 @@
aria-label="Repetir" aria-label="Repetir"
class="transition-colors hover:text-fg {player.repeat ? 'text-accent' : 'text-fg-2'}" class="transition-colors hover:text-fg {player.repeat ? 'text-accent' : 'text-fg-2'}"
> >
<Repeat class="size-4" strokeWidth={1.8} /> <Repeat class="size-4" />
</button> </button>
{/if} {/if}
</div> </div>
@@ -68,6 +68,6 @@
class="h-full w-full object-cover" class="h-full w-full object-cover"
/> />
{:else} {:else}
<Icon class="size-icon-xl {gradient ? 'text-on-art/70' : 'text-fg-2'}" strokeWidth={1.3} /> <Icon class="size-icon-xl {gradient ? 'text-on-art/70' : 'text-fg-2'}" />
{/if} {/if}
</label> </label>
@@ -5,7 +5,7 @@
title: string; title: string;
eyebrow?: string; eyebrow?: string;
description?: string; description?: string;
meta?: string; meta?: string | Snippet;
cover?: Snippet; cover?: Snippet;
actions?: Snippet; actions?: Snippet;
align?: 'center' | 'end'; align?: 'center' | 'end';
@@ -45,8 +45,10 @@
{#if description} {#if description}
<p class="mt-3 max-w-2xl text-body text-fg-2">{description}</p> <p class="mt-3 max-w-2xl text-body text-fg-2">{description}</p>
{/if} {/if}
{#if meta} {#if typeof meta === 'string'}
<p class="mt-3 text-sm text-fg-2">{meta}</p> <p class="mt-3 text-sm text-fg-2">{meta}</p>
{:else if meta}
<p class="mt-3 text-sm text-fg-2">{@render meta()}</p>
{/if} {/if}
</div> </div>
{#if actions && !cover} {#if actions && !cover}
@@ -125,7 +125,7 @@
</svg> </svg>
</div> </div>
{:else if fallback === 'gradient'} {:else if fallback === 'gradient'}
<div class="h-full w-full bg-[image:var(--mf-cover-grad)]"></div> <div class="h-full w-full bg-(image:--mf-cover-grad)"></div>
{:else} {:else}
<div class="h-full w-full bg-surface"></div> <div class="h-full w-full bg-surface"></div>
{/if} {/if}
@@ -7,7 +7,7 @@
</script> </script>
<span <span
class="grid size-4 shrink-0 place-items-center rounded-tag bg-fg-2 text-xs leading-4 font-bold text-bg {klass}" class="grid size-4 shrink-0 place-items-center rounded-tag border border-accent/30 bg-accent-tint pt-px text-xs leading-none font-normal text-accent-soft {klass}"
title="Contenido explícito" title="Contenido explícito"
aria-label="Contenido explícito">E</span aria-label="Contenido explícito">E</span
> >
@@ -0,0 +1,44 @@
<script lang="ts">
import { untrack } from 'svelte';
import { enhance } from '$app/forms';
import Button from '../primitives/Button.svelte';
interface Props {
following: boolean;
userId?: string;
invalidateAll?: boolean;
class?: string;
}
let { following: initial, userId, invalidateAll = false, class: klass = '' }: Props = $props();
let following = $state(untrack(() => initial));
let submitting = $state(false);
</script>
<form
method="POST"
action={following ? '?/unfollow' : '?/follow'}
class={klass}
use:enhance={() => {
submitting = true;
return async ({ result, update }) => {
if (result.type === 'success' && typeof result.data?.following === 'boolean')
following = result.data.following;
await update({ reset: false, invalidateAll });
submitting = false;
};
}}
>
{#if userId}
<input type="hidden" name="userId" value={userId} />
{/if}
<Button
type="submit"
size="sm"
variant={following ? 'secondary' : 'primary'}
disabled={submitting}
>
{following ? 'Dejar de seguir' : 'Seguir'}
</Button>
</form>
@@ -95,7 +95,7 @@
style="--mf-track-cols:{gridColumns}; --mf-track-cols-mobile:{gridColumnsMobile}" style="--mf-track-cols:{gridColumns}; --mf-track-cols-mobile:{gridColumnsMobile}"
> >
{#if index} {#if index}
<Hash size={16} strokeWidth={1.5} /> <Hash size={16} />
{/if} {/if}
<span>Título</span> <span>Título</span>
{#each columns as column (column)} {#each columns as column (column)}
@@ -0,0 +1,20 @@
<script lang="ts">
import ListRow from './ListRow.svelte';
import Avatar from './Avatar.svelte';
interface Props {
user: {
id: string;
name: string;
profilePictureUrl: string | null;
};
}
let { user }: Props = $props();
</script>
<ListRow title={user.name} href="/user/{user.id}" size="lg" class="min-w-0 flex-1">
{#snippet art()}
<Avatar name={user.name} src={user.profilePictureUrl} size="md" />
{/snippet}
</ListRow>
@@ -24,7 +24,7 @@
> >
{#if tone === 'danger'} {#if tone === 'danger'}
<span class="mb-4 grid size-10 place-items-center rounded-full bg-danger/12 text-danger-fg"> <span class="mb-4 grid size-10 place-items-center rounded-full bg-danger/12 text-danger-fg">
<CircleAlert class="size-icon-md" strokeWidth={2} /> <CircleAlert class="size-icon-md" />
</span> </span>
{/if} {/if}
<h2 class="text-display-3 text-fg">{title}</h2> <h2 class="text-display-3 text-fg">{title}</h2>
@@ -1,6 +1,8 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import { pop } from '$lib/utils/transitions';
interface Props { interface Props {
class?: string; class?: string;
style?: string; style?: string;
@@ -12,9 +14,8 @@
</script> </script>
<div <div
class="glass-panel animate-pop rounded-panel p-1.5 {border transition:pop|global
? 'border border-hairline' class="glass-panel rounded-panel p-1.5 {border ? 'border border-hairline' : ''} {klass}"
: ''} {klass}"
{style} {style}
> >
{@render children()} {@render children()}
@@ -3,8 +3,7 @@
import X from '@lucide/svelte/icons/x'; import X from '@lucide/svelte/icons/x';
import IconButton from '../primitives/IconButton.svelte'; import IconButton from '../primitives/IconButton.svelte';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import { expoOut } from 'svelte/easing'; import { pop } from '$lib/utils/transitions';
import type { TransitionConfig } from 'svelte/transition';
interface Props { interface Props {
open: boolean; open: boolean;
@@ -48,15 +47,6 @@
); );
} }
function pop(node: Element, { duration = 200 }: { duration?: number } = {}): TransitionConfig {
return {
duration,
easing: expoOut,
css: (t) =>
`opacity: ${t}; transform: scale(${0.98 + 0.02 * t}) translateY(${0.25 * (1 - t)}rem);`
};
}
function onWindowKeydown(event: KeyboardEvent) { function onWindowKeydown(event: KeyboardEvent) {
if (!open) return; if (!open) return;
if (event.key === 'Escape') { if (event.key === 'Escape') {
@@ -90,7 +80,7 @@
<div <div
bind:this={panel} bind:this={panel}
transition:pop={{ duration: 200 }} transition:pop={{ duration: 200 }}
class="glass-panel max-h-[85dvh] w-full overflow-y-auto {maxWidth} rounded-panel p-5 outline-none sm:p-6 {panelClass}" class="glass-panel max-h-[85dvh] w-full glow-drift overflow-y-auto {maxWidth} rounded-panel p-5 outline-none sm:p-6 {panelClass}"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label={title} aria-label={title}
@@ -22,7 +22,6 @@
/> />
<Check <Check
class="pointer-events-none absolute size-3.5 text-ink opacity-0 transition peer-checked:opacity-100" class="pointer-events-none absolute size-3.5 text-ink opacity-0 transition peer-checked:opacity-100"
strokeWidth={3}
/> />
</span> </span>
<span class="cursor-pointer">{@render children()}</span> <span class="cursor-pointer">{@render children()}</span>
@@ -15,7 +15,7 @@
<div class="flex flex-col items-center rounded-panel px-6 py-16 text-center {klass}"> <div class="flex flex-col items-center rounded-panel px-6 py-16 text-center {klass}">
{#if Icon} {#if Icon}
<Icon class="size-icon-xl text-fg-2" strokeWidth={1.25} /> <Icon class="size-icon-xl text-fg-2" />
{/if} {/if}
{#if title} {#if title}
<h3 class="mt-6 text-display-3 text-fg"> <h3 class="mt-6 text-display-3 text-fg">
@@ -24,7 +24,7 @@
oninput={handleInput} oninput={handleInput}
aria-label={label} aria-label={label}
class="mf-slider mf-slider-{variant}" class="mf-slider mf-slider-{variant}"
style="background:linear-gradient(to right, var(--mf-text-2) {percent}%, var(--mf-track) {percent}%)" style="--p:{percent}%"
/> />
<style> <style>
@@ -35,6 +35,17 @@
border-radius: 100px; border-radius: 100px;
outline: none; outline: none;
cursor: pointer; cursor: pointer;
background-image:
linear-gradient(
to right,
transparent var(--p),
color-mix(in srgb, var(--mf-text) 12%, var(--mf-bg)) var(--p)
),
var(--mf-slider-fill);
background-size:
100% 100%,
300% 100%;
animation: gradient-drift 10s linear infinite alternate;
} }
.mf-slider-seek { .mf-slider-seek {
width: 100%; width: 100%;
+1
View File
@@ -17,5 +17,6 @@ export const HOME_POPULAR_MAX = 8;
export const PLAYLIST_PICKER_PAGE_SIZE = 50; export const PLAYLIST_PICKER_PAGE_SIZE = 50;
export const LIKED_TRACKS_PAGE_SIZE = 500; export const LIKED_TRACKS_PAGE_SIZE = 500;
export const EXPLORE_USERS_LIMIT = 12; export const EXPLORE_USERS_LIMIT = 12;
export const FOLLOW_LIST_PAGE_SIZE = 30;
export const MENU_WIDTH = 240; export const MENU_WIDTH = 240;
export const SUBMENU_WIDTH = 256; export const SUBMENU_WIDTH = 256;
+36 -2
View File
@@ -120,6 +120,7 @@ class PlayerState {
playing = $state(false); playing = $state(false);
progress = $state(0); progress = $state(0);
volume = $state(browser ? Number(localStorage.getItem('player.volume') ?? 100) : 100); volume = $state(browser ? Number(localStorage.getItem('player.volume') ?? 100) : 100);
muted = $state(browser ? localStorage.getItem('player.muted') === 'true' : false);
loading = $state(false); loading = $state(false);
recentlyPlayed = $state<PlayerTrack[]>([]); recentlyPlayed = $state<PlayerTrack[]>([]);
playlists = $state<Playlist[]>([]); playlists = $state<Playlist[]>([]);
@@ -150,6 +151,7 @@ class PlayerState {
const audio = new Audio(); const audio = new Audio();
audio.preload = 'auto'; audio.preload = 'auto';
audio.volume = this.volume / 100; audio.volume = this.volume / 100;
audio.muted = this.muted;
audio.addEventListener('durationchange', () => this.#syncDuration(audio.duration)); audio.addEventListener('durationchange', () => this.#syncDuration(audio.duration));
audio.addEventListener('loadedmetadata', () => this.#syncDuration(audio.duration)); audio.addEventListener('loadedmetadata', () => this.#syncDuration(audio.duration));
audio.addEventListener('timeupdate', () => { audio.addEventListener('timeupdate', () => {
@@ -223,10 +225,19 @@ class PlayerState {
}); });
} }
#tickProgress = () => { #clockTime = 0;
#clockStamp = 0;
#tickProgress = (now: number) => {
const audio = this.#audio; const audio = this.#audio;
if (!audio) return; if (!audio) return;
this.progress = audio.currentTime; if (audio.currentTime !== this.#clockTime) {
this.#clockTime = audio.currentTime;
this.#clockStamp = now;
}
const elapsed = Math.min(0.25, (now - this.#clockStamp) / 1000);
const smooth = this.#clockTime + (audio.paused ? 0 : elapsed * audio.playbackRate);
this.progress = Math.min(smooth, Number.isFinite(audio.duration) ? audio.duration : smooth);
this.#rafId = requestAnimationFrame(this.#tickProgress); this.#rafId = requestAnimationFrame(this.#tickProgress);
}; };
@@ -434,6 +445,18 @@ class PlayerState {
else audio.pause(); else audio.pause();
} }
toggleMute() {
this.#applyMuted(!this.muted);
if (!this.muted && this.volume === 0) this.setVolume(50);
}
#applyMuted(value: boolean) {
this.muted = value;
const audio = this.#audioEl();
if (audio) audio.muted = value;
if (browser) localStorage.setItem('player.muted', String(value));
}
next() { next() {
if (this.tracks.length === 0) return; if (this.tracks.length === 0) return;
const idx = this.#index(); const idx = this.#index();
@@ -466,6 +489,16 @@ class PlayerState {
this.#loadCurrent(); this.#loadCurrent();
} }
moveQueueItem(from: number, insertAt: number) {
if (from < 0 || from >= this.tracks.length) return;
const target = insertAt > from ? insertAt - 1 : insertAt;
if (target === from) return;
const tracks = [...this.tracks];
const [item] = tracks.splice(from, 1);
tracks.splice(target, 0, item);
this.tracks = tracks;
}
clearUpcoming() { clearUpcoming() {
this.tracks = this.currentId === null ? [] : [this.current]; this.tracks = this.currentId === null ? [] : [this.current];
} }
@@ -492,6 +525,7 @@ class PlayerState {
setVolume(value: number) { setVolume(value: number) {
this.volume = Math.min(100, Math.max(0, Math.round(value))); this.volume = Math.min(100, Math.max(0, Math.round(value)));
if (this.muted && this.volume > 0) this.#applyMuted(false);
const audio = this.#audioEl(); const audio = this.#audioEl();
if (audio) { if (audio) {
audio.volume = this.volume / 100; audio.volume = this.volume / 100;
@@ -0,0 +1,28 @@
import { fail, type RequestEvent } from '@sveltejs/kit';
import { createApiClient, requireAccessTokenAction } from '$lib/server/api';
async function setFollowing({ request, params, locals, fetch }: RequestEvent, follow: boolean) {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const form = await request.formData();
const id = String(form.get('userId') ?? '') || String(params.id ?? '');
if (!id) return fail(400, { message: 'Falta el usuario.' });
const api = createApiClient({ fetch, accessToken });
const options = { params: { path: { id } } };
const { error } = follow
? await api.POST('/users/{id}/follow', options)
: await api.DELETE('/users/{id}/follow', options);
if (error) {
return fail(502, {
message: follow ? 'No se pudo seguir al usuario.' : 'No se pudo dejar de seguir al usuario.'
});
}
return { following: follow, userId: id };
}
export const followUserAction = (event: RequestEvent) => setFollowing(event, true);
export const unfollowUserAction = (event: RequestEvent) => setFollowing(event, false);
+17
View File
@@ -0,0 +1,17 @@
import type { createApiClient } from '$lib/server/api';
import type { FollowList } from '$lib/types';
type ApiClient = ReturnType<typeof createApiClient>;
export function fetchFollowList(
api: ApiClient,
list: FollowList,
id: string,
pageNumber: number,
pageSize: number
) {
const options = { params: { path: { id }, query: { pageNumber, pageSize } } };
return list === 'followers'
? api.GET('/users/{id}/followers', options)
: api.GET('/users/{id}/following', options);
}
+54 -50
View File
@@ -17,69 +17,72 @@ const BIRTHDAY_MESSAGE = 'Hoy toca celebrar. Tú eliges la música.';
const HOUR_SLOTS = [6, 9, 12, 15, 18, 21, 24]; const HOUR_SLOTS = [6, 9, 12, 15, 18, 21, 24];
const WEEKLY_SCHEDULE: string[][] = [ const WEEKLY_SCHEDULE: [string, string][][] = [
[
['Domingo de madrugada.', 'Madrugada de domingo. Todo va más lento.'],
['Buenos días. Tómate el domingo con calma.', 'Buenos días. Hoy nadie tiene prisa.'],
['Domingo. Empieza despacio.', 'Un domingo así pide algo tranquilo.'],
['Una mañana tranquila merece buena música.', 'Domingo al mediodía. Pon algo que abrace.'],
[ [
'Domingo de madrugada.',
'Buenos días. Tómate el domingo con calma.',
'Domingo. Empieza despacio.',
'Una mañana tranquila merece buena música.',
'Domingo por la tarde. Hora de bajar el ritmo.', 'Domingo por la tarde. Hora de bajar el ritmo.',
'La semana empieza a despedirse.', 'La tarde del domingo se disfruta con calma.'
'Una última canción antes de volver a empezar.' ],
['La semana empieza a despedirse.', 'El domingo se va apagando. Deja que suene.'],
['Una última canción antes de volver a empezar.', 'Cierra el domingo con algo que te guste.']
], ],
[ [
'La semana acaba de empezar.', ['La semana acaba de empezar.', 'Ya es lunes, aunque aún sea de noche.'],
'Buenos días. Empezamos otra semana.', ['Buenos días. Empezamos otra semana.', 'Lunes por la mañana. Arranca con buen pie.'],
'Lunes. Dale una banda sonora al día.', ['Lunes. Dale una banda sonora al día.', 'El lunes se lleva mejor con música.'],
'Una pausa musical para este lunes.', ['Una pausa musical para este lunes.', 'Mediodía de lunes. Te has ganado un respiro.'],
'El lunes todavía no ha terminado.', ['El lunes todavía no ha terminado.', 'Media tarde de lunes. Aguanta un poco más.'],
'El lunes ya empieza a quedar atrás.', ['El lunes ya empieza a quedar atrás.', 'Casi lo has conseguido. Un lunes menos.'],
'Sobrevive al lunes con una última canción.' ['Sobrevive al lunes con una última canción.', 'Lunes superado. Ahora, lo que tú quieras.']
], ],
[ [
'La noche sigue sonando.', ['La noche sigue sonando.', 'Martes de madrugada. La música no duerme.'],
'Buenos días. Martes en marcha.', ['Buenos días. Martes en marcha.', 'Martes. Ya estás más despierto que ayer.'],
'La semana ya está cogiendo ritmo.', ['La semana ya está cogiendo ritmo.', 'El martes ya tiene su propio compás.'],
'Martes al mediodía. ¿Qué escuchamos?', ['Martes al mediodía. ¿Qué escuchamos?', 'A mitad del martes. Una canción de por medio.'],
'Una tarde cualquiera necesita música.', ['Una tarde cualquiera necesita música.', 'Tarde de martes. Ponle algo con ritmo.'],
'El día empieza a bajar el ritmo.', ['El día empieza a bajar el ritmo.', 'Cae la tarde. Es buen momento para poner algo.'],
'Un martes más llega a su fin.' ['Un martes más llega a su fin.', 'El martes se acaba. Ciérralo con música.']
], ],
[ [
'Mitad de semana, mitad de noche.', ['Mitad de semana, mitad de noche.', 'Miércoles de madrugada. Justo en el centro.'],
'Buenos días. Ya es miércoles.', ['Buenos días. Ya es miércoles.', 'Miércoles. La semana ya está de tu lado.'],
'Ya estamos en el ecuador de la semana.', ['Ya estamos en el ecuador de la semana.', 'Ecuador de la semana. Lo peor ya pasó.'],
'Miércoles al mediodía. Un poco de música.', ['Miércoles al mediodía. Un poco de música.', 'Un descanso en pleno miércoles.'],
'La semana ya va cuesta abajo.', ['La semana ya va cuesta abajo.', 'Miércoles por la tarde. Todo empieza a rodar.'],
'Unas canciones para terminar el miércoles.', ['Unas canciones para terminar el miércoles.', 'El miércoles se despide. Ponle banda sonora.'],
'Mitad de semana superada.' ['Mitad de semana superada.', 'Ya solo queda bajar la cuesta.']
], ],
[ [
'Una noche más antes del fin de semana.', ['Una noche más antes del fin de semana.', 'Jueves de madrugada. Queda menos.'],
'Buenos días. Ya casi estamos.', ['Buenos días. Ya casi estamos.', 'Jueves. Hoy se huele el viernes.'],
'Jueves. El fin de semana se acerca.', ['Jueves. El fin de semana se acerca.', 'Un jueves con buena música se pasa volando.'],
'Una pausa musical antes de seguir.', ['Una pausa musical antes de seguir.', 'Mediodía de jueves. Toca recargar pilas.'],
'Queda poco para desconectar.', ['Queda poco para desconectar.', 'Tarde de jueves. El fin de semana asoma.'],
'Ya se empieza a notar el viernes.', ['Ya se empieza a notar el viernes.', 'El jueves ya está de salida.'],
'Una noche más y llega el fin de semana.' ['Una noche más y llega el fin de semana.', 'Última noche de la semana laboral. Que suene.']
], ],
[ [
'La semana ha terminado. La noche continúa.', ['La semana ha terminado. La noche continúa.', 'Viernes de madrugada. Aún queda noche.'],
'Buenos días. Es viernes.', ['Buenos días. Es viernes.', 'Viernes. Hoy ya se nota el ambiente.'],
'Viernes. Hoy suena diferente.', ['Viernes. Hoy suena diferente.', 'Es viernes. Sube el volumen.'],
'Ya huele a fin de semana.', ['Ya huele a fin de semana.', 'Mediodía de viernes. Casi libre.'],
'Últimas horas de la semana.', ['Últimas horas de la semana.', 'Tarde de viernes. La semana ya es historia.'],
'Se acabó la semana. Dale al play.', ['Se acabó la semana. Dale al play.', 'Empieza el fin de semana. Que empiece con música.'],
'Viernes por la noche. Tú eliges la banda sonora.' ['Viernes por la noche. Tú eliges la banda sonora.', 'Noche de viernes. Que no falte música.']
], ],
[ [
'La noche es joven. Que siga sonando.', ['La noche es joven. Que siga sonando.', 'Sábado de madrugada. La fiesta no acaba.'],
'Buenos días. Hoy no hay prisa.', ['Buenos días. Hoy no hay prisa.', 'Sábado por la mañana. Sin alarma y con música.'],
'Sábado. Empieza el día a tu ritmo.', ['Sábado. Empieza el día a tu ritmo.', 'Hoy manda el sábado. Tú pones el ritmo.'],
'Mediodía de sábado. ¿Qué ponemos?', ['Mediodía de sábado. ¿Qué ponemos?', 'Sábado al mediodía. Hora de elegir banda sonora.'],
'Una tarde de sábado pide música.', ['Una tarde de sábado pide música.', 'Tarde de sábado. Sin planes también se disfruta.'],
'El sábado todavía tiene mucho por delante.', ['El sábado todavía tiene mucho por delante.', 'Cae el sábado y aún queda mucho por sonar.'],
'Esta noche puede sonar cualquier cosa.' ['Esta noche puede sonar cualquier cosa.', 'Noche de sábado. Todo vale, elige tú.']
] ]
]; ];
@@ -99,5 +102,6 @@ export function pickGreeting(date: Date, birthday?: Birthday | null): string {
const hour = date.getHours(); const hour = date.getHours();
const slot = HOUR_SLOTS.findIndex((until) => hour < until); const slot = HOUR_SLOTS.findIndex((until) => hour < until);
return WEEKLY_SCHEDULE[date.getDay()][slot === -1 ? 0 : slot]; const variant = Math.floor(date.getTime() / 86_400_000) % 2;
return WEEKLY_SCHEDULE[date.getDay()][slot === -1 ? 0 : slot][variant];
} }
+21 -1
View File
@@ -1,4 +1,4 @@
import { afterNavigate } from '$app/navigation'; import { afterNavigate, beforeNavigate } from '$app/navigation';
import type { LucideIcon } from '@lucide/svelte'; import type { LucideIcon } from '@lucide/svelte';
import Home from '@lucide/svelte/icons/house'; import Home from '@lucide/svelte/icons/house';
import Compass from '@lucide/svelte/icons/compass'; import Compass from '@lucide/svelte/icons/compass';
@@ -55,6 +55,26 @@ export function trackNavigation() {
}); });
} }
const scrollPositions = new Map<number, number>();
export function restoreScroll(getScroller: () => HTMLElement | undefined) {
let current: number | null = null;
beforeNavigate(() => {
const scroller = getScroller();
if (current !== null && scroller) scrollPositions.set(current, scroller.scrollTop);
});
afterNavigate((nav) => {
current = historyIndex();
const scroller = getScroller();
if (!scroller || nav.type === 'enter' || nav.to?.url.hash) return;
const saved =
nav.type === 'popstate' && current !== null ? scrollPositions.get(current) : undefined;
scroller.scrollTop = saved ?? 0;
});
}
export function previousPage(): PageRef | null { export function previousPage(): PageRef | null {
return visited.get(index - 1) ?? null; return visited.get(index - 1) ?? null;
} }
+11 -9
View File
@@ -22,10 +22,10 @@ export function buildThemeTokens(hue: number, mode: 'dark' | 'light' = 'dark'):
const g = (l1: number, c1: number, a1: number, l2: number, c2: number, a2: number) => const g = (l1: number, c1: number, a1: number, l2: number, c2: number, a2: number) =>
`linear-gradient(90deg, oklch(${l1} ${c1} ${H} / ${a1}), oklch(${l2} ${c2} ${H} / ${a2}))`; `linear-gradient(90deg, oklch(${l1} ${c1} ${H} / ${a1}), oklch(${l2} ${c2} ${H} / ${a2}))`;
const gActive = light const gActive = light
? g(0.82, 0.05, 0.55, 0.87, 0.035, 0.36) ? g(0.86, 0.04, 0.45, 0.9, 0.03, 0.28)
: g(0.46, 0.06, 0.34, 0.4, 0.045, 0.22); : g(0.46, 0.06, 0.34, 0.4, 0.045, 0.22);
const gHover = light const gHover = light
? g(0.86, 0.035, 0.38, 0.9, 0.025, 0.24) ? g(0.9, 0.03, 0.34, 0.93, 0.02, 0.2)
: g(0.42, 0.045, 0.26, 0.38, 0.035, 0.17); : g(0.42, 0.045, 0.26, 0.38, 0.035, 0.17);
const glow = (l: number, c: number, a: number) => `oklch(${l} ${c} ${H} / ${a})`; const glow = (l: number, c: number, a: number) => `oklch(${l} ${c} ${H} / ${a})`;
// Fixed danger hue so error dialogs read red whatever the theme hue is. // Fixed danger hue so error dialogs read red whatever the theme hue is.
@@ -45,24 +45,26 @@ export function buildThemeTokens(hue: number, mode: 'dark' | 'light' = 'dark'):
'--mf-bg': light ? `oklch(0.975 0.012 ${H})` : `oklch(0.068 0.008 ${H})`, '--mf-bg': light ? `oklch(0.975 0.012 ${H})` : `oklch(0.068 0.008 ${H})`,
'--mf-elevated': light ? `oklch(0.99 0.008 ${H})` : `oklch(0.12 0.012 ${H})`, '--mf-elevated': light ? `oklch(0.99 0.008 ${H})` : `oklch(0.12 0.012 ${H})`,
'--mf-panel-bg': light ? 'rgba(255, 255, 255, 0.28)' : 'rgba(0, 0, 0, 0.16)', '--mf-panel-bg': light ? 'rgba(255, 255, 255, 0.28)' : 'rgba(0, 0, 0, 0.16)',
'--mf-bar-bg': light ? `oklch(0.99 0.01 ${H} / 0.86)` : `oklch(0.105 0.012 ${H} / 0.9)`,
'--mf-hairline': light ? `oklch(0.3 0.02 ${H} / 0.06)` : `oklch(0.62 0.02 ${H} / 0.05)`, '--mf-hairline': light ? `oklch(0.3 0.02 ${H} / 0.06)` : `oklch(0.62 0.02 ${H} / 0.05)`,
'--mf-ambient': light '--mf-ambient': light
? `radial-gradient(80% 70% at 12% 0%, ${glow(0.9, 0.05, 0.55)}, transparent 75%), radial-gradient(70% 60% at 88% 8%, ${glow(0.92, 0.035, 0.4)}, transparent 75%), radial-gradient(90% 70% at 50% 110%, ${glow(0.93, 0.03, 0.35)}, transparent 80%), linear-gradient(180deg, ${glow(0.96, 0.02, 0.4)}, transparent 60%)` ? `radial-gradient(80% 70% at 12% 0%, ${glow(0.9, 0.05, 0.55)}, transparent 75%), radial-gradient(70% 60% at 88% 8%, ${glow(0.92, 0.035, 0.4)}, transparent 75%), radial-gradient(90% 70% at 50% 110%, ${glow(0.93, 0.03, 0.35)}, transparent 80%), linear-gradient(180deg, ${glow(0.96, 0.02, 0.4)}, transparent 60%)`
: `radial-gradient(75% 65% at 12% 0%, ${glow(0.3, 0.045, 0.5)}, transparent 75%), radial-gradient(65% 55% at 85% 5%, ${glow(0.24, 0.035, 0.35)}, transparent 75%), radial-gradient(90% 70% at 50% 105%, ${glow(0.18, 0.025, 0.3)}, transparent 80%), linear-gradient(180deg, ${glow(0.14, 0.02, 0.35)}, transparent 55%)`, : `radial-gradient(75% 65% at 12% 0%, ${glow(0.3, 0.045, 0.5)}, transparent 75%), radial-gradient(65% 55% at 85% 5%, ${glow(0.24, 0.035, 0.35)}, transparent 75%), radial-gradient(90% 70% at 50% 105%, ${glow(0.18, 0.025, 0.3)}, transparent 80%), linear-gradient(180deg, ${glow(0.14, 0.02, 0.35)}, transparent 55%)`,
'--mf-slider-fill': light
? `linear-gradient(90deg, oklch(0.5 0.1 ${H}), oklch(0.68 0.11 ${r(h + 18, 1)}), oklch(0.5 0.1 ${H}))`
: `linear-gradient(90deg, oklch(0.66 0.11 ${H}), oklch(0.84 0.09 ${r(h + 18, 1)}), oklch(0.66 0.11 ${H}))`,
'--mf-hero-tint': light ? `oklch(0.8 0.05 ${H})` : `oklch(0.3 0.04 ${H})`, '--mf-hero-tint': light ? `oklch(0.8 0.05 ${H})` : `oklch(0.3 0.04 ${H})`,
'--mf-hero-bg': light '--mf-hero-c1': light ? glow(0.9, 0.04, 0.6) : glow(0.24, 0.04, 0.55),
? `linear-gradient(120deg, ${glow(0.9, 0.04, 0.6)}, ${glow(0.96, 0.02, 0.4)} 60%)` '--mf-hero-c2': light ? glow(0.96, 0.02, 0.4) : glow(0.14, 0.02, 0.35),
: `linear-gradient(120deg, ${glow(0.24, 0.04, 0.55)}, ${glow(0.14, 0.02, 0.35)} 60%, ${glow(0.12, 0.015, 0.25)})`, '--mf-hero-c3': light ? glow(0.96, 0.02, 0.4) : glow(0.12, 0.015, 0.25),
'--mf-modal-glow': light '--mf-modal-glow': light
? `linear-gradient(135deg, ${glow(0.88, 0.05, 0.5)} 0%, ${glow(0.93, 0.03, 0.2)} 40%, transparent 75%)` ? `linear-gradient(135deg, ${glow(0.88, 0.05, 0.5)} 0%, ${glow(0.93, 0.03, 0.2)} 40%, transparent 75%)`
: `linear-gradient(135deg, ${glow(0.3, 0.045, 0.4)} 0%, ${glow(0.2, 0.03, 0.15)} 35%, transparent 70%)`, : `linear-gradient(135deg, ${glow(0.3, 0.045, 0.4)} 0%, ${glow(0.2, 0.03, 0.15)} 35%, transparent 70%)`,
'--mf-modal-glow-danger': light '--mf-modal-glow-danger': light
? `linear-gradient(135deg, ${dangerGlow(0.86, 0.07, 0.55)} 0%, ${dangerGlow(0.92, 0.04, 0.22)} 40%, transparent 75%)` ? `linear-gradient(135deg, ${dangerGlow(0.86, 0.07, 0.55)} 0%, ${dangerGlow(0.92, 0.04, 0.22)} 40%, transparent 75%)`
: `linear-gradient(135deg, ${dangerGlow(0.34, 0.1, 0.45)} 0%, ${dangerGlow(0.22, 0.06, 0.18)} 35%, transparent 70%)`, : `linear-gradient(135deg, ${dangerGlow(0.34, 0.1, 0.45)} 0%, ${dangerGlow(0.22, 0.06, 0.18)} 35%, transparent 70%)`,
'--mf-spotlight-bg': light '--mf-spotlight-c1': light ? glow(0.9, 0.04, 0.55) : glow(0.22, 0.04, 0.5),
? `linear-gradient(120deg, ${glow(0.9, 0.04, 0.55)}, ${glow(0.96, 0.02, 0.35)} 55%)` '--mf-spotlight-c2': light ? glow(0.96, 0.02, 0.35) : glow(0.13, 0.02, 0.3),
: `linear-gradient(120deg, ${glow(0.22, 0.04, 0.5)}, ${glow(0.13, 0.02, 0.3)} 55%, ${glow(0.11, 0.015, 0.2)})`, '--mf-spotlight-c3': light ? glow(0.96, 0.02, 0.35) : glow(0.11, 0.015, 0.2),
'--mf-cover-grad': light '--mf-cover-grad': light
? `linear-gradient(150deg, oklch(0.84 0.03 ${H}), oklch(0.74 0.02 ${H}))` ? `linear-gradient(150deg, oklch(0.84 0.03 ${H}), oklch(0.74 0.02 ${H}))`
: `linear-gradient(150deg, oklch(0.34 0.03 ${H}), oklch(0.18 0.015 ${H}))` : `linear-gradient(150deg, oklch(0.34 0.03 ${H}), oklch(0.18 0.015 ${H}))`
+2
View File
@@ -4,3 +4,5 @@ export interface SessionUser {
email: string; email: string;
picture: string; picture: string;
} }
export type FollowList = 'followers' | 'following';
+14
View File
@@ -0,0 +1,14 @@
import { expoOut } from 'svelte/easing';
import type { TransitionConfig } from 'svelte/transition';
export function pop(
node: Element,
{ duration = 200 }: { duration?: number } = {}
): TransitionConfig {
return {
duration,
easing: expoOut,
css: (t) =>
`opacity: ${t}; transform: scale(${0.98 + 0.02 * t}) translateY(${0.25 * (1 - t)}rem);`
};
}
+5
View File
@@ -0,0 +1,5 @@
import type { ParamMatcher } from '@sveltejs/kit';
import type { FollowList } from '$lib/types';
export const match = ((param: string): param is FollowList =>
param === 'followers' || param === 'following') satisfies ParamMatcher;
@@ -27,7 +27,7 @@
{#snippet actions()} {#snippet actions()}
{#if items.length > 0} {#if items.length > 0}
<Button onclick={() => (creating = true)}> <Button onclick={() => (creating = true)}>
<Plus class="size-4" strokeWidth={2.5} /> <Plus class="size-4" />
Nuevo álbum Nuevo álbum
</Button> </Button>
{/if} {/if}
@@ -1,6 +1,7 @@
import type { PageServerLoad, Actions } from './$types'; import type { PageServerLoad, Actions } from './$types';
import { createApiClient, unwrapOrError } from '$lib/server/api'; import { createApiClient, unwrapOrError } from '$lib/server/api';
import { addAlbumToPlaylistAction, addTrackAction } from '$lib/server/playlistActions'; import { addAlbumToPlaylistAction, addTrackAction } from '$lib/server/playlistActions';
import { followUserAction, unfollowUserAction } from '$lib/server/followActions';
import { import {
EXPLORE_ALBUMS_PAGE_SIZE, EXPLORE_ALBUMS_PAGE_SIZE,
EXPLORE_PAGE_SIZE as PAGE_SIZE, EXPLORE_PAGE_SIZE as PAGE_SIZE,
@@ -64,11 +65,14 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
tracks, tracks,
albums: (albumsRes?.data?.items ?? []).map((item) => item.album), albums: (albumsRes?.data?.items ?? []).map((item) => item.album),
users: usersRes?.data?.items ?? [], users: usersRes?.data?.items ?? [],
viewerId: user?.sub ?? null,
playlists: playlistsRes?.data?.items ?? [] playlists: playlistsRes?.data?.items ?? []
}; };
}; };
export const actions: Actions = { export const actions: Actions = {
addTrack: addTrackAction, addTrack: addTrackAction,
addAlbumToPlaylist: addAlbumToPlaylistAction addAlbumToPlaylist: addAlbumToPlaylistAction,
follow: followUserAction,
unfollow: unfollowUserAction
}; };
@@ -10,6 +10,7 @@
import InfiniteScroll from '$lib/components/ui/primitives/InfiniteScroll.svelte'; import InfiniteScroll from '$lib/components/ui/primitives/InfiniteScroll.svelte';
import Artwork from '$lib/components/ui/media/Artwork.svelte'; import Artwork from '$lib/components/ui/media/Artwork.svelte';
import Avatar from '$lib/components/ui/media/Avatar.svelte'; import Avatar from '$lib/components/ui/media/Avatar.svelte';
import UserRow from '$lib/components/ui/media/UserRow.svelte';
import PlayButton from '$lib/components/ui/media/PlayButton.svelte'; import PlayButton from '$lib/components/ui/media/PlayButton.svelte';
import EqBars from '$lib/components/ui/media/EqBars.svelte'; import EqBars from '$lib/components/ui/media/EqBars.svelte';
import ListRow from '$lib/components/ui/media/ListRow.svelte'; import ListRow from '$lib/components/ui/media/ListRow.svelte';
@@ -348,6 +349,7 @@
title={track.title} title={track.title}
subtitle={track.artist} subtitle={track.artist}
subtitleHref={track.ownerUserId} subtitleHref={track.ownerUserId}
explicit={track.isExplicit}
active={player.current.id === track.id} active={player.current.id === track.id}
size="lg" size="lg"
trackId={track.id} trackId={track.id}
@@ -414,11 +416,7 @@
<ul class="flex flex-col gap-1"> <ul class="flex flex-col gap-1">
{#each capped(sfilter, users) as u (u.id)} {#each capped(sfilter, users) as u (u.id)}
<li> <li>
<ListRow title={u.name} href="/user/{u.id}" size="lg"> <UserRow user={u} />
{#snippet art()}
<Avatar name={u.name} src={u.profilePictureUrl} size="md" />
{/snippet}
</ListRow>
</li> </li>
{/each} {/each}
</ul> </ul>
@@ -39,7 +39,7 @@
<div <div
class="grid size-cover-hero shrink-0 place-items-center rounded-art-lg bg-[image:var(--mf-liked-grad)] sm:size-cover-hero-sm" class="grid size-cover-hero shrink-0 place-items-center rounded-art-lg bg-[image:var(--mf-liked-grad)] sm:size-cover-hero-sm"
> >
<Heart class="size-icon-xl text-on-art/90" fill="currentColor" strokeWidth={0} /> <Heart class="size-icon-xl text-on-art/90" fill="currentColor" />
</div> </div>
{/snippet} {/snippet}
{#snippet actions()} {#snippet actions()}
@@ -31,6 +31,7 @@ export const actions: Actions = {
const audio = form.get('audio'); const audio = form.get('audio');
const cover = form.get('cover'); const cover = form.get('cover');
const rawTags = form.getAll('tags').map(String); const rawTags = form.getAll('tags').map(String);
const isExplicit = form.get('isExplicit') === 'on';
if (title === '' || title.length > MAX_TITLE) { if (title === '' || title.length > MAX_TITLE) {
return fail(400, { message: 'El título es obligatorio (máx. 100 caracteres).' }); return fail(400, { message: 'El título es obligatorio (máx. 100 caracteres).' });
@@ -99,7 +100,8 @@ export const actions: Actions = {
title, title,
pictureIntentId: urls.pictureIntentId, pictureIntentId: urls.pictureIntentId,
audioIntentId: urls.audioIntentId, audioIntentId: urls.audioIntentId,
tags tags,
isExplicit
} }
}); });
+29 -10
View File
@@ -13,6 +13,7 @@
import Input from '$lib/components/ui/primitives/Input.svelte'; import Input from '$lib/components/ui/primitives/Input.svelte';
import Checkbox from '$lib/components/ui/primitives/Checkbox.svelte'; import Checkbox from '$lib/components/ui/primitives/Checkbox.svelte';
import Button from '$lib/components/ui/primitives/Button.svelte'; import Button from '$lib/components/ui/primitives/Button.svelte';
import ExplicitBadge from '$lib/components/ui/media/ExplicitBadge.svelte';
import Chip from '$lib/components/ui/primitives/Chip.svelte'; import Chip from '$lib/components/ui/primitives/Chip.svelte';
import { genreOptions } from '$lib/data/genres'; import { genreOptions } from '$lib/data/genres';
@@ -31,6 +32,7 @@
let dragging = $state(false); let dragging = $state(false);
let status = $state<Status>('idle'); let status = $state<Status>('idle');
let acceptedTerms = $state(false); let acceptedTerms = $state(false);
let isExplicit = $state(false);
let errorMsg = $state(''); let errorMsg = $state('');
let errorDetail = $state(''); let errorDetail = $state('');
let tags = $state<string[]>([]); let tags = $state<string[]>([]);
@@ -108,6 +110,7 @@
coverResetToken += 1; coverResetToken += 1;
status = 'idle'; status = 'idle';
acceptedTerms = false; acceptedTerms = false;
isExplicit = false;
errorMsg = ''; errorMsg = '';
errorDetail = ''; errorDetail = '';
tags = []; tags = [];
@@ -137,7 +140,7 @@
: 'bg-accent-btn text-accent-soft'}" : 'bg-accent-btn text-accent-soft'}"
> >
{#if stepStates[i] === 'done'} {#if stepStates[i] === 'done'}
<Check class="size-3.5" strokeWidth={2.5} /> <Check class="size-3.5" />
{:else} {:else}
{i + 1} {i + 1}
{/if} {/if}
@@ -153,7 +156,7 @@
{#if status === 'done'} {#if status === 'done'}
<Surface padding="lg" class="mt-10 bg-accent-tint text-center"> <Surface padding="lg" class="mt-10 bg-accent-tint text-center">
<span class="mx-auto grid size-14 place-items-center rounded-full bg-accent-soft text-ink"> <span class="mx-auto grid size-14 place-items-center rounded-full bg-accent-soft text-ink">
<Check class="size-7" strokeWidth={2.5} /> <Check class="size-7" />
</span> </span>
<h2 class="mt-6 font-display text-2xl font-semibold tracking-display text-fg">¡Subida!</h2> <h2 class="mt-6 font-display text-2xl font-semibold tracking-display text-fg">¡Subida!</h2>
<p class="mt-3 text-sm text-fg-2"> <p class="mt-3 text-sm text-fg-2">
@@ -214,9 +217,9 @@
aria-label="Seleccionar archivo de audio" aria-label="Seleccionar archivo de audio"
/> />
{#if audioName === ''} {#if audioName === ''}
<Headphones class="size-9 text-fg-2 sm:size-10" strokeWidth={1.4} /> <Headphones class="size-9 text-fg-2 sm:size-10" />
{:else} {:else}
<Music class="size-9 text-accent-soft sm:size-10" strokeWidth={1.4} /> <Music class="size-9 text-accent-soft sm:size-10" />
{/if} {/if}
{#if audioName === ''} {#if audioName === ''}
<p class="mt-4 text-sm font-medium text-fg"> <p class="mt-4 text-sm font-medium text-fg">
@@ -231,19 +234,23 @@
{/if} {/if}
</div> </div>
<div class="grid items-start gap-6 sm:grid-cols-[auto_1fr] sm:gap-8"> <Surface padding="md">
<Field label="Portada"> <div class="grid items-start gap-6 md:grid-cols-[auto_1fr] md:gap-10">
<Field label="Portada" class="items-center md:items-start">
{#key coverResetToken} {#key coverResetToken}
<ImageDropzone <ImageDropzone
name="cover" name="cover"
icon={ImageIcon} icon={ImageIcon}
gradient gradient
size="hero"
onselect={() => (hasCover = true)} onselect={() => (hasCover = true)}
/> />
{/key} {/key}
{#snippet hint()}
<p>{hasCover ? 'Portada lista' : 'Haz clic para elegir'}</p>
{/snippet}
</Field> </Field>
<div class="flex min-w-0 flex-col gap-7">
<Field label="Título" for="title"> <Field label="Título" for="title">
<Input <Input
id="title" id="title"
@@ -257,10 +264,9 @@
<span class="shrink-0 tabular-nums">{title.length}/{MAX_TITLE}</span> <span class="shrink-0 tabular-nums">{title.length}/{MAX_TITLE}</span>
{/snippet} {/snippet}
</Field> </Field>
</div>
<Field label="Géneros"> <Field label="Géneros">
<div class="flex flex-wrap gap-2"> <div class="mb-2 flex flex-wrap gap-2">
{#each options as option (option.genre)} {#each options as option (option.genre)}
<Chip <Chip
selected={tags.includes(option.genre)} selected={tags.includes(option.genre)}
@@ -275,10 +281,23 @@
<input type="hidden" name="tags" value={tag} /> <input type="hidden" name="tags" value={tag} />
{/each} {/each}
{#snippet hint()} {#snippet hint()}
<p>Elige al menos uno. Algunos géneros no se pueden combinar, como Metal con Ambient.</p> <p>
Elige al menos uno. Algunos géneros no se pueden combinar, como Metal con Ambient.
</p>
<span class="shrink-0 tabular-nums">{tags.length} elegidos</span>
{/snippet} {/snippet}
</Field> </Field>
<Checkbox bind:checked={isExplicit} name="isExplicit">
<span class="inline-flex items-center gap-2">
Contenido explícito
<ExplicitBadge />
</span>
</Checkbox>
</div>
</div>
</Surface>
<Checkbox bind:checked={acceptedTerms}> <Checkbox bind:checked={acceptedTerms}>
Acepto los Acepto los
<a <a
@@ -1,10 +1,6 @@
import type { PageServerLoad, Actions } from './$types'; import type { PageServerLoad, Actions } from './$types';
import { import { createApiClient, optionalUser, unwrapOrError } from '$lib/server/api';
createApiClient, import { followUserAction, unfollowUserAction } from '$lib/server/followActions';
optionalUser,
requireAccessTokenAction,
unwrapOrError
} from '$lib/server/api';
const PROFILE_PLAYLISTS_PAGE_SIZE = 50; const PROFILE_PLAYLISTS_PAGE_SIZE = 50;
@@ -43,33 +39,10 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch, parent
const isOwnProfile = viewer !== null && viewer.sub === params.id; const isOwnProfile = viewer !== null && viewer.sub === params.id;
const isFollowing = return { profile, playlists, isOwnProfile, isAnonymous: viewer === null };
viewer && !isOwnProfile
? await api
.GET('/users/{id}/is-following', { params: { path: { id: params.id } } })
.then((res) => res.data ?? false)
.catch(() => false)
: false;
return { profile, playlists, isOwnProfile, isFollowing, isAnonymous: viewer === null };
}; };
export const actions: Actions = { export const actions: Actions = {
follow: async ({ params, locals, fetch }) => { follow: followUserAction,
const accessToken = requireAccessTokenAction(locals); unfollow: unfollowUserAction
if (typeof accessToken !== 'string') return accessToken;
const api = createApiClient({ fetch, accessToken });
await api.POST('/users/{id}/follow', { params: { path: { id: params.id } } });
return { following: true };
},
unfollow: async ({ params, locals, fetch }) => {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const api = createApiClient({ fetch, accessToken });
await api.DELETE('/users/{id}/follow', { params: { path: { id: params.id } } });
return { following: false };
}
}; };
@@ -1,29 +1,19 @@
<script lang="ts"> <script lang="ts">
import { untrack } from 'svelte';
import { enhance } from '$app/forms';
import ListMusic from '@lucide/svelte/icons/list-music'; import ListMusic from '@lucide/svelte/icons/list-music';
import Page from '$lib/components/ui/layout/Page.svelte'; import Page from '$lib/components/ui/layout/Page.svelte';
import MediaCard from '$lib/components/ui/media/MediaCard.svelte'; import MediaCard from '$lib/components/ui/media/MediaCard.svelte';
import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte'; import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte';
import Button from '$lib/components/ui/primitives/Button.svelte'; import FollowButton from '$lib/components/ui/media/FollowButton.svelte';
import Avatar from '$lib/components/ui/media/Avatar.svelte'; import Avatar from '$lib/components/ui/media/Avatar.svelte';
import PageHeader from '$lib/components/ui/layout/PageHeader.svelte'; import PageHeader from '$lib/components/ui/layout/PageHeader.svelte';
import SectionHeading from '$lib/components/ui/layout/SectionHeading.svelte'; import SectionHeading from '$lib/components/ui/layout/SectionHeading.svelte';
import { plural, playlistMeta } from '$lib/utils/format'; import { plural, playlistMeta } from '$lib/utils/format';
let { data, form } = $props(); let { data } = $props();
const profile = $derived(data.profile); const profile = $derived(data.profile);
const playlists = $derived(data.playlists); const playlists = $derived(data.playlists);
const canFollow = $derived(!data.isOwnProfile && !data.isAnonymous); const canFollow = $derived(!data.isOwnProfile && !data.isAnonymous);
let following = $state(untrack(() => data.isFollowing));
let submitting = $state(false);
$effect(() => {
if (form && 'following' in form && typeof form.following === 'boolean')
following = form.following;
});
</script> </script>
<svelte:head> <svelte:head>
@@ -32,37 +22,27 @@
</svelte:head> </svelte:head>
{#snippet followAction()} {#snippet followAction()}
<form <FollowButton following={profile.isFollowing} invalidateAll />
method="POST" {/snippet}
action={following ? '?/unfollow' : '?/follow'}
use:enhance={() => { {#snippet followStats()}
submitting = true; {@const followers = plural(Number(profile.followersCount), 'seguidor', 'seguidores')}
return async ({ update }) => { {#if data.isOwnProfile || profile.canViewFollowers}
await update(); <a href="/user/{profile.id}/followers" class="transition-colors hover:text-fg">{followers}</a>
submitting = false; {:else}
}; <span>{followers}</span>
}} {/if}
> ·
<Button <a href="/user/{profile.id}/following" class="transition-colors hover:text-fg">
type="submit" {profile.followingCount} siguiendo
size="sm" </a>
variant={following ? 'secondary' : 'primary'}
disabled={submitting}
>
{following ? 'Dejar de seguir' : 'Seguir'}
</Button>
</form>
{/snippet} {/snippet}
<Page> <Page>
<PageHeader <PageHeader
eyebrow="Perfil" eyebrow="Perfil"
title={profile.name} title={profile.name}
meta="{plural( meta={followStats}
Number(profile.followersCount),
'seguidor',
'seguidores'
)} · {profile.followingCount} siguiendo"
align="center" align="center"
actions={canFollow ? followAction : undefined} actions={canFollow ? followAction : undefined}
> >
@@ -0,0 +1,32 @@
import type { PageServerLoad, Actions } from './$types';
import { createApiClient, optionalUser, unwrapOrError } from '$lib/server/api';
import { fetchFollowList } from '$lib/server/follows';
import { followUserAction, unfollowUserAction } from '$lib/server/followActions';
import { FOLLOW_LIST_PAGE_SIZE } from '$lib/config';
export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => {
const { allowAnonymousListening } = await parent();
const viewer = optionalUser(locals, url, allowAnonymousListening);
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
const [profileRes, listRes] = await Promise.all([
api.GET('/users/{id}/profile', { params: { path: { id: params.id } } }),
fetchFollowList(api, params.list, params.id, 1, FOLLOW_LIST_PAGE_SIZE)
]);
const profile = unwrapOrError(profileRes, 'Usuario no encontrado.', 404);
const forbidden = listRes.response?.status === 403;
return {
list: params.list,
profile,
users: forbidden ? null : unwrapOrError(listRes, 'No se pudo cargar la lista.'),
forbidden,
viewerId: viewer?.sub ?? null
};
};
export const actions: Actions = {
follow: followUserAction,
unfollow: unfollowUserAction
};
@@ -0,0 +1,93 @@
<script lang="ts">
import Users from '@lucide/svelte/icons/users';
import Lock from '@lucide/svelte/icons/lock';
import Page from '$lib/components/ui/layout/Page.svelte';
import PageHeader from '$lib/components/ui/layout/PageHeader.svelte';
import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte';
import InfiniteScroll from '$lib/components/ui/primitives/InfiniteScroll.svelte';
import Alert from '$lib/components/ui/primitives/Alert.svelte';
import UserRow from '$lib/components/ui/media/UserRow.svelte';
import { appendUnique } from '$lib/data/collections';
import { plural } from '$lib/utils/format';
let { data, form } = $props();
type ListUser = NonNullable<typeof data.users>['items'][number];
let items = $state<ListUser[]>([]);
let page = $state(1);
let hasMore = $state(false);
let loadingMore = $state(false);
$effect(() => {
items = data.users?.items ?? [];
page = Number(data.users?.pageNumber ?? 1);
hasMore = data.users?.hasNextPage ?? false;
});
const isFollowers = $derived(data.list === 'followers');
const title = $derived(isFollowers ? 'Seguidores' : 'Siguiendo');
const total = $derived(Number(data.users?.totalItemCount ?? 0));
async function loadMore() {
if (loadingMore) return;
loadingMore = true;
try {
const res = await fetch(`/api/users/${data.profile.id}/${data.list}?pageNumber=${page + 1}`);
if (!res.ok) throw new Error(String(res.status));
const next = (await res.json()) as NonNullable<typeof data.users>;
items = appendUnique(items, next.items, (u) => u.id);
page = Number(next.pageNumber);
hasMore = next.hasNextPage;
} catch {
hasMore = false;
} finally {
loadingMore = false;
}
}
</script>
<svelte:head>
<title>{title} · {data.profile.name}</title>
</svelte:head>
<Page>
<PageHeader
eyebrow={data.profile.name}
{title}
meta={data.forbidden
? undefined
: isFollowers
? plural(total, 'seguidor', 'seguidores')
: plural(total, 'usuario', 'usuarios')}
/>
{#if form?.message}
<Alert class="mb-4">{form.message}</Alert>
{/if}
{#if data.forbidden}
<EmptyState
icon={Lock}
title="Seguidores privados"
description="Solo puedes ver los seguidores de {data.profile.name} si os seguís mutuamente."
/>
{:else if items.length === 0}
<EmptyState
icon={Users}
title={isFollowers ? 'Sin seguidores' : 'No sigue a nadie'}
description={isFollowers
? `Todavía nadie sigue a ${data.profile.name}.`
: `${data.profile.name} todavía no sigue a nadie.`}
/>
{:else}
<ul class="flex flex-col gap-1">
{#each items as user (user.id)}
<li>
<UserRow {user} />
</li>
{/each}
</ul>
<InfiniteScroll onLoadMore={loadMore} {hasMore} loading={loadingMore} />
{/if}
</Page>
+10 -6
View File
@@ -19,12 +19,15 @@
import { ACCENT_HUE } from '$lib/theme/color'; import { ACCENT_HUE } from '$lib/theme/color';
import { buildThemeTokens, applyThemeTokens, tokensToCss } from '$lib/theme/tokens'; import { buildThemeTokens, applyThemeTokens, tokensToCss } from '$lib/theme/tokens';
import { themeMode } from '$lib/theme/mode.svelte'; import { themeMode } from '$lib/theme/mode.svelte';
import { trackNavigation } from '$lib/state/navigation.svelte'; import { trackNavigation, restoreScroll } from '$lib/state/navigation.svelte';
import { page } from '$app/state'; import { page } from '$app/state';
let { children, data } = $props(); let { children, data } = $props();
let scroller = $state<HTMLElement>();
trackNavigation(); trackNavigation();
restoreScroll(() => scroller);
$effect(() => { $effect(() => {
if (data.likedTracks?.length) liked.hydrate(data.likedTracks); if (data.likedTracks?.length) liked.hydrate(data.likedTracks);
@@ -35,7 +38,6 @@
}); });
const isAuthPage = $derived(page.url.pathname === '/auth'); const isAuthPage = $derived(page.url.pathname === '/auth');
const hasTrack = $derived(player.currentId !== null);
const initialThemeCss = `:root{${tokensToCss(buildThemeTokens(ACCENT_HUE, 'dark'))}}:root[data-theme='light']{${tokensToCss(buildThemeTokens(ACCENT_HUE, 'light'))}}`; const initialThemeCss = `:root{${tokensToCss(buildThemeTokens(ACCENT_HUE, 'dark'))}}:root[data-theme='light']{${tokensToCss(buildThemeTokens(ACCENT_HUE, 'light'))}}`;
@@ -74,7 +76,7 @@
if (dh > 180) dh -= 360; if (dh > 180) dh -= 360;
else if (dh < -180) dh += 360; else if (dh < -180) dh += 360;
const start = performance.now(); const start = performance.now();
const duration = 600; const duration = 2000 + Math.random() * 2000;
const tick = (now: number) => { const tick = (now: number) => {
const t = Math.min(1, (now - start) / duration); const t = Math.min(1, (now - start) / duration);
const eased = 1 - Math.pow(1 - t, 3); const eased = 1 - Math.pow(1 - t, 3);
@@ -101,17 +103,18 @@
{@render children()} {@render children()}
</div> </div>
{:else} {:else}
<div class="relative flex min-h-screen text-fg antialiased"> <div class="relative flex h-dvh overflow-hidden text-fg antialiased">
<div class="app-backdrop"></div> <div class="app-backdrop"></div>
<div class="app-vignette"></div> <div class="app-vignette"></div>
<Sidebar user={data.user} playlists={data.sidebarPlaylists ?? []} /> <Sidebar user={data.user} playlists={data.sidebarPlaylists ?? []} />
<div class="flex min-w-0 flex-1">
<div class="flex min-w-0 flex-1 flex-col"> <div class="flex min-w-0 flex-1 flex-col">
<div class="flex min-h-0 flex-1">
<div bind:this={scroller} class="flex min-w-0 flex-1 app-scroll flex-col">
<MobileHeader user={data.user} accountUrl={data.accountUrl} /> <MobileHeader user={data.user} accountUrl={data.accountUrl} />
<TopBar user={data.user} accountUrl={data.accountUrl} /> <TopBar user={data.user} accountUrl={data.accountUrl} />
<main class="flex-1 app-main" data-has-track={hasTrack ? '' : undefined}> <main class="flex-1">
{#key page.url.pathname} {#key page.url.pathname}
<div class="animate-fade">{@render children()}</div> <div class="animate-fade">{@render children()}</div>
{/key} {/key}
@@ -122,6 +125,7 @@
</div> </div>
<PlayerDock user={data.user} /> <PlayerDock user={data.user} />
</div>
<Modal <Modal
open={createPlaylistModal.open} open={createPlaylistModal.open}
+5 -3
View File
@@ -134,11 +134,11 @@
</svelte:head> </svelte:head>
<Page> <Page>
<div class="relative overflow-hidden rounded-panel-lg hero-surface px-6 py-8 sm:px-9 sm:py-8.5"> <div class="relative hero-surface overflow-hidden rounded-panel-lg px-6 py-8 sm:px-9 sm:py-8.5">
<div <div
class="relative flex flex-col items-start gap-7 lg:flex-row lg:flex-wrap lg:items-end lg:justify-between" class="relative flex flex-col items-start gap-7 lg:flex-row lg:flex-wrap lg:items-end lg:justify-between"
> >
<div class="animate-enter max-w-hero min-w-65"> <div class="animate-enter max-w-hero min-w-65 dark:shadow-accent">
<h1 <h1
class="font-display text-3xl leading-tight font-medium tracking-tight text-pretty text-fg sm:text-4xl" class="font-display text-3xl leading-tight font-medium tracking-tight text-pretty text-fg sm:text-4xl"
> >
@@ -195,6 +195,7 @@
title={item.title} title={item.title}
subtitle={item.artist} subtitle={item.artist}
subtitleHref={item.ownerUserId} subtitleHref={item.ownerUserId}
explicit={item.explicit}
trackId={item.id} trackId={item.id}
class="animate-enter" class="animate-enter"
style="--i:{i}" style="--i:{i}"
@@ -209,7 +210,7 @@
{#if spotlight} {#if spotlight}
<section> <section>
<div <div
class="grid grid-cols-1 overflow-hidden rounded-panel-lg spotlight-surface lg:grid-cols-2" class="grid spotlight-surface grid-cols-1 overflow-hidden rounded-panel-lg lg:grid-cols-2"
> >
<div class="flex flex-col justify-center p-8 sm:p-9"> <div class="flex flex-col justify-center p-8 sm:p-9">
<div <div
@@ -252,6 +253,7 @@
title={track.title} title={track.title}
subtitle={track.artist} subtitle={track.artist}
subtitleHref={track.ownerUserId} subtitleHref={track.ownerUserId}
explicit={track.isExplicit}
> >
{#snippet leading()} {#snippet leading()}
<span class="w-5 shrink-0 text-center text-xs text-fg-2 tabular-nums" <span class="w-5 shrink-0 text-center text-xs text-fg-2 tabular-nums"
@@ -0,0 +1,19 @@
import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit';
import { createApiClient, unwrapOrError } from '$lib/server/api';
import { fetchFollowList } from '$lib/server/follows';
import { FOLLOW_LIST_PAGE_SIZE } from '$lib/config';
export const GET: RequestHandler = async ({ params, url, locals, fetch }) => {
const pageNumber = Math.max(1, Number(url.searchParams.get('pageNumber')) || 1);
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
const result = await fetchFollowList(
api,
params.list,
params.id,
pageNumber,
FOLLOW_LIST_PAGE_SIZE
);
return json(unwrapOrError(result, 'No se pudo cargar la lista.', result.response?.status));
};
+113 -16
View File
@@ -98,10 +98,6 @@
} }
} }
@utility scrollbar-stable {
scrollbar-gutter: stable;
}
@utility text-display-1 { @utility text-display-1 {
@apply font-display text-3xl font-medium tracking-tight sm:text-4xl; @apply font-display text-3xl font-medium tracking-tight sm:text-4xl;
} }
@@ -131,9 +127,7 @@
transition-duration: var(--default-transition-duration); transition-duration: var(--default-transition-duration);
} }
@utility glass-bar { @utility glass-bar {
background-color: var(--mf-bar-bg);
border-top: 1px solid var(--mf-hairline); border-top: 1px solid var(--mf-hairline);
backdrop-filter: blur(24px);
} }
@utility grid-cards { @utility grid-cards {
display: grid; display: grid;
@@ -169,10 +163,16 @@
@utility app-backdrop { @utility app-backdrop {
pointer-events: none; pointer-events: none;
position: fixed; position: fixed;
inset: 0; inset: -10%;
z-index: -10; z-index: -10;
background-color: var(--mf-bg); background-color: var(--mf-bg);
background-image: var(--mf-ambient); background-image: var(--mf-ambient);
animation: ambient-drift 48s ease-in-out infinite alternate;
will-change: transform;
@media (prefers-reduced-motion: reduce) {
animation: none;
}
} }
@utility app-vignette { @utility app-vignette {
pointer-events: none; pointer-events: none;
@@ -185,15 +185,47 @@
transparent 62% transparent 62%
); );
} }
@utility app-main { @utility queue-shell {
--mf-dock-h: 0px; display: flex;
padding-bottom: calc(var(--mf-nav-h) + var(--mf-safe-b) + var(--mf-dock-h)); justify-content: flex-end;
transition: padding var(--mf-motion-slow) var(--mf-ease); flex-shrink: 0;
overflow: hidden;
width: 0;
visibility: hidden;
transition:
width var(--mf-motion-slow) ease-in-out,
visibility var(--mf-motion-slow);
&[data-has-track] { &[data-open] {
--mf-dock-h: var(--mf-player-h); width: var(--mf-queue-w);
visibility: visible;
} }
} }
@utility queue-row {
position: relative;
transition:
margin var(--mf-motion-base) var(--mf-ease),
opacity var(--mf-motion-base) var(--mf-ease),
scale var(--mf-motion-base) var(--mf-ease);
&[data-dragging] {
opacity: 0.4;
scale: 0.97;
}
&[data-drop='before'] {
margin-top: --spacing(14);
}
&[data-drop='after'] {
margin-bottom: --spacing(14);
}
}
@utility app-scroll {
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
scrollbar-width: thin;
scrollbar-color: var(--mf-scrollbar) transparent;
}
@utility genre-tile { @utility genre-tile {
background: linear-gradient( background: linear-gradient(
140deg, 140deg,
@@ -203,16 +235,32 @@
} }
@utility surface-active { @utility surface-active {
background-image: var(--mf-g-active); background-image: var(--mf-g-active);
background-size: 125% 100%;
animation: gradient-drift 14s ease-in-out infinite alternate;
} }
@utility surface-active-hover { @utility surface-active-hover {
background-image: var(--mf-g-hover); background-image: var(--mf-g-hover);
background-size: 125% 100%;
animation: gradient-drift 14s ease-in-out infinite alternate;
} }
@utility hero-surface { @utility hero-surface {
background-image: var(--mf-hero-bg); background-image: linear-gradient(
var(--gd-angle),
var(--mf-hero-c1),
var(--mf-hero-c2) calc(60% + var(--gd-shift)),
var(--mf-hero-c3)
);
animation: gradient-sway 18s ease-in-out infinite alternate;
} }
@utility spotlight-surface { @utility spotlight-surface {
animation: gradient-sway 18s ease-in-out infinite alternate;
background-image: background-image:
var(--mf-spotlight-bg), linear-gradient(
var(--gd-angle),
var(--mf-spotlight-c1),
var(--mf-spotlight-c2) calc(55% + var(--gd-shift)),
var(--mf-spotlight-c3)
),
linear-gradient( linear-gradient(
to right, to right,
transparent 50%, transparent 50%,
@@ -220,13 +268,17 @@
transparent 92.5% transparent 92.5%
); );
} }
@utility glow-drift {
background-size: 125% 100%;
animation: gradient-drift 18s ease-in-out infinite alternate;
}
@utility field-control { @utility field-control {
@apply w-full rounded-control bg-surface py-3 text-sm text-fg transition placeholder:text-fg-2 focus:bg-surface-2 focus:outline-none; @apply w-full rounded-control bg-surface py-3 text-sm text-fg transition placeholder:text-fg-2 focus:bg-surface-2 focus:outline-none;
} }
@layer base { @layer base {
svg.lucide { svg.lucide {
stroke-width: 1.8; stroke-width: 1.5;
} }
} }
@@ -307,6 +359,50 @@ button:disabled {
} }
} }
@keyframes ambient-drift {
0% {
transform: translate3d(-3%, -2%, 0) scale(1);
}
50% {
transform: translate3d(2%, 3%, 0) scale(1.06);
}
100% {
transform: translate3d(3%, -1%, 0) scale(1.02);
}
}
@property --gd-angle {
syntax: '<angle>';
inherits: false;
initial-value: 120deg;
}
@property --gd-shift {
syntax: '<percentage>';
inherits: false;
initial-value: 0%;
}
@keyframes gradient-sway {
from {
--gd-angle: 110deg;
--gd-shift: -8%;
}
to {
--gd-angle: 132deg;
--gd-shift: 12%;
}
}
@keyframes gradient-drift {
from {
background-position: 0% 50%;
}
to {
background-position: 100% 50%;
}
}
@keyframes sheen { @keyframes sheen {
from { from {
background-position: 200% 0; background-position: 200% 0;
@@ -346,6 +442,7 @@ button:disabled {
*::before, *::before,
*::after { *::after {
animation-duration: 0.001ms !important; animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
animation-delay: 0ms !important; animation-delay: 0ms !important;
transition-duration: 0.001ms !important; transition-duration: 0.001ms !important;
scroll-behavior: auto !important; scroll-behavior: auto !important;