4 Commits
Author SHA1 Message Date
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
29 changed files with 858 additions and 129 deletions
@@ -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);
}
@@ -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();
} }
@@ -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));
}
} }
} }
+1 -1
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
+118 -3
View File
@@ -607,6 +607,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;
@@ -807,8 +841,8 @@ export interface components {
hasNextPage: boolean; hasNextPage: boolean;
hasPreviousPage: boolean; hasPreviousPage: boolean;
}; };
PaginatedResponseOfUserApplicationResponse: { PaginatedResponseOfUserSummaryResponse: {
items: components["schemas"]["UserApplicationResponse"][]; items: components["schemas"]["UserSummaryResponse"][];
/** Format: int32 */ /** Format: int32 */
pageNumber: number | string; pageNumber: number | string;
/** Format: int32 */ /** Format: int32 */
@@ -976,6 +1010,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;
@@ -2277,7 +2321,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["PaginatedResponseOfUserApplicationResponse"]; "application/json": components["schemas"]["PaginatedResponseOfUserSummaryResponse"];
}; };
}; };
}; };
@@ -2460,6 +2504,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;
@@ -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}
@@ -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>
+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;
+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];
} }
+6 -6
View File
@@ -53,18 +53,18 @@ export function buildThemeTokens(hue: number, mode: 'dark' | 'light' = 'dark'):
? `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.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}))`, : `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.93, 0.03, 0.52)} 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.93, 0.03, 0.47)} 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';
+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;
@@ -46,31 +46,12 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
const tracks = unwrapOrError(tracksRes, 'No se pudieron cargar las canciones.'); const tracks = unwrapOrError(tracksRes, 'No se pudieron cargar las canciones.');
const userItems = usersRes?.data?.items ?? [];
const followedIds = user
? await Promise.all(
userItems
.filter((u) => u.id !== user.sub)
.map((u) =>
api
.GET('/users/{id}/is-following', { params: { path: { id: u.id } } })
.then((res) => (res.data ? u.id : null))
.catch(() => null)
)
)
: [];
const followed = new Set(followedIds);
return { return {
query, query,
tracks, tracks,
albums: (albumsRes?.data?.items ?? []).map((item) => item.album), albums: (albumsRes?.data?.items ?? []).map((item) => item.album),
users: userItems.map((u) => ({ users: usersRes?.data?.items ?? [],
...u, viewerId: user?.sub ?? null,
isSelf: user?.sub === u.id,
isFollowing: followed.has(u.id)
})),
canFollow: !!user,
playlists: playlistsRes?.data?.items ?? [] playlists: playlistsRes?.data?.items ?? []
}; };
}; };
@@ -10,7 +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 FollowButton from '$lib/components/ui/media/FollowButton.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';
@@ -402,15 +402,8 @@
<SectionHeading title="Usuarios" count={counts.Usuarios} /> <SectionHeading title="Usuarios" count={counts.Usuarios} />
<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 class="flex items-center gap-3"> <li>
<ListRow title={u.name} href="/user/{u.id}" size="lg" class="min-w-0 flex-1"> <UserRow user={u} />
{#snippet art()}
<Avatar name={u.name} src={u.profilePictureUrl} size="md" />
{/snippet}
</ListRow>
{#if data.canFollow && !u.isSelf}
<FollowButton following={u.isFollowing} userId={u.id} class="shrink-0" />
{/if}
</li> </li>
{/each} {/each}
</ul> </ul>
@@ -39,15 +39,7 @@ 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 = {
@@ -22,18 +22,27 @@
</svelte:head> </svelte:head>
{#snippet followAction()} {#snippet followAction()}
<FollowButton following={data.isFollowing} invalidateAll /> <FollowButton following={profile.isFollowing} invalidateAll />
{/snippet}
{#snippet followStats()}
{@const followers = plural(Number(profile.followersCount), 'seguidor', 'seguidores')}
{#if data.isOwnProfile || profile.canViewFollowers}
<a href="/user/{profile.id}/followers" class="transition-colors hover:text-fg">{followers}</a>
{:else}
<span>{followers}</span>
{/if}
·
<a href="/user/{profile.id}/following" class="transition-colors hover:text-fg">
{profile.followingCount} siguiendo
</a>
{/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>
@@ -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));
};
+37 -8
View File
@@ -251,17 +251,23 @@
animation: gradient-drift 14s ease-in-out infinite alternate; 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(
background-size: 125% 100%; var(--gd-angle),
animation: gradient-drift 18s ease-in-out infinite alternate; 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 {
background-size: animation: gradient-sway 18s ease-in-out infinite alternate;
125% 100%,
100% 100%;
animation: gradient-drift 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%,
@@ -372,6 +378,29 @@ button:disabled {
} }
} }
@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 { @keyframes gradient-drift {
from { from {
background-position: 0% 50%; background-position: 0% 50%;