Compare commits
4 commits
f8e6032449
...
b0a286dd9f
Author | SHA1 | Date | |
---|---|---|---|
b0a286dd9f | |||
2a0df335bc | |||
d9d48c3cbf | |||
8bd4449804 |
54 changed files with 1252 additions and 554 deletions
|
@ -2,14 +2,12 @@ using System.Net;
|
||||||
using System.Web;
|
using System.Web;
|
||||||
using Foxnouns.Backend.Database;
|
using Foxnouns.Backend.Database;
|
||||||
using Foxnouns.Backend.Database.Models;
|
using Foxnouns.Backend.Database.Models;
|
||||||
|
using Foxnouns.Backend.Dto;
|
||||||
using Foxnouns.Backend.Extensions;
|
using Foxnouns.Backend.Extensions;
|
||||||
using Foxnouns.Backend.Middleware;
|
using Foxnouns.Backend.Middleware;
|
||||||
using Foxnouns.Backend.Services;
|
using Foxnouns.Backend.Services;
|
||||||
using Foxnouns.Backend.Utils;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Newtonsoft.Json;
|
|
||||||
using NodaTime;
|
|
||||||
|
|
||||||
namespace Foxnouns.Backend.Controllers.Authentication;
|
namespace Foxnouns.Backend.Controllers.Authentication;
|
||||||
|
|
||||||
|
@ -47,28 +45,6 @@ public class AuthController(
|
||||||
return Ok(new UrlsResponse(config.EmailAuth.Enabled, discord, null, null));
|
return Ok(new UrlsResponse(config.EmailAuth.Enabled, discord, null, null));
|
||||||
}
|
}
|
||||||
|
|
||||||
private record UrlsResponse(bool EmailEnabled, string? Discord, string? Google, string? Tumblr);
|
|
||||||
|
|
||||||
public record AuthResponse(
|
|
||||||
UserRendererService.UserResponse User,
|
|
||||||
string Token,
|
|
||||||
Instant ExpiresAt
|
|
||||||
);
|
|
||||||
|
|
||||||
public record SingleUrlResponse(string Url);
|
|
||||||
|
|
||||||
public record AddOauthAccountResponse(
|
|
||||||
Snowflake Id,
|
|
||||||
[property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] AuthType Type,
|
|
||||||
string RemoteId,
|
|
||||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
|
||||||
string? RemoteUsername
|
|
||||||
);
|
|
||||||
|
|
||||||
public record OauthRegisterRequest(string Ticket, string Username);
|
|
||||||
|
|
||||||
public record CallbackRequest(string Code, string State);
|
|
||||||
|
|
||||||
[HttpPost("force-log-out")]
|
[HttpPost("force-log-out")]
|
||||||
[Authorize("identify")]
|
[Authorize("identify")]
|
||||||
public async Task<IActionResult> ForceLogoutAsync()
|
public async Task<IActionResult> ForceLogoutAsync()
|
||||||
|
@ -83,9 +59,7 @@ public class AuthController(
|
||||||
|
|
||||||
[HttpGet("methods/{id}")]
|
[HttpGet("methods/{id}")]
|
||||||
[Authorize("*")]
|
[Authorize("*")]
|
||||||
[ProducesResponseType<UserRendererService.AuthMethodResponse>(
|
[ProducesResponseType<AuthMethodResponse>(statusCode: StatusCodes.Status200OK)]
|
||||||
statusCode: StatusCodes.Status200OK
|
|
||||||
)]
|
|
||||||
public async Task<IActionResult> GetAuthMethodAsync(Snowflake id)
|
public async Task<IActionResult> GetAuthMethodAsync(Snowflake id)
|
||||||
{
|
{
|
||||||
AuthMethod? authMethod = await db
|
AuthMethod? authMethod = await db
|
||||||
|
@ -143,13 +117,3 @@ public class AuthController(
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public record CallbackResponse(
|
|
||||||
bool HasAccount,
|
|
||||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? Ticket,
|
|
||||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? RemoteUsername,
|
|
||||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
|
||||||
UserRendererService.UserResponse? User,
|
|
||||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? Token,
|
|
||||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Instant? ExpiresAt
|
|
||||||
);
|
|
||||||
|
|
|
@ -3,6 +3,7 @@ using System.Web;
|
||||||
using EntityFramework.Exceptions.Common;
|
using EntityFramework.Exceptions.Common;
|
||||||
using Foxnouns.Backend.Database;
|
using Foxnouns.Backend.Database;
|
||||||
using Foxnouns.Backend.Database.Models;
|
using Foxnouns.Backend.Database.Models;
|
||||||
|
using Foxnouns.Backend.Dto;
|
||||||
using Foxnouns.Backend.Extensions;
|
using Foxnouns.Backend.Extensions;
|
||||||
using Foxnouns.Backend.Middleware;
|
using Foxnouns.Backend.Middleware;
|
||||||
using Foxnouns.Backend.Services;
|
using Foxnouns.Backend.Services;
|
||||||
|
@ -29,7 +30,7 @@ public class DiscordAuthController(
|
||||||
|
|
||||||
[HttpPost("callback")]
|
[HttpPost("callback")]
|
||||||
[ProducesResponseType<CallbackResponse>(StatusCodes.Status200OK)]
|
[ProducesResponseType<CallbackResponse>(StatusCodes.Status200OK)]
|
||||||
public async Task<IActionResult> CallbackAsync([FromBody] AuthController.CallbackRequest req)
|
public async Task<IActionResult> CallbackAsync([FromBody] CallbackRequest req)
|
||||||
{
|
{
|
||||||
CheckRequirements();
|
CheckRequirements();
|
||||||
await keyCacheService.ValidateAuthStateAsync(req.State);
|
await keyCacheService.ValidateAuthStateAsync(req.State);
|
||||||
|
@ -58,10 +59,8 @@ public class DiscordAuthController(
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("register")]
|
[HttpPost("register")]
|
||||||
[ProducesResponseType<AuthController.AuthResponse>(StatusCodes.Status200OK)]
|
[ProducesResponseType<AuthResponse>(StatusCodes.Status200OK)]
|
||||||
public async Task<IActionResult> RegisterAsync(
|
public async Task<IActionResult> RegisterAsync([FromBody] OauthRegisterRequest req)
|
||||||
[FromBody] AuthController.OauthRegisterRequest req
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
RemoteAuthService.RemoteUser? remoteUser =
|
RemoteAuthService.RemoteUser? remoteUser =
|
||||||
await keyCacheService.GetKeyAsync<RemoteAuthService.RemoteUser>(
|
await keyCacheService.GetKeyAsync<RemoteAuthService.RemoteUser>(
|
||||||
|
@ -109,14 +108,12 @@ public class DiscordAuthController(
|
||||||
+ $"&prompt=none&state={state}"
|
+ $"&prompt=none&state={state}"
|
||||||
+ $"&redirect_uri={HttpUtility.UrlEncode($"{config.BaseUrl}/auth/callback/discord")}";
|
+ $"&redirect_uri={HttpUtility.UrlEncode($"{config.BaseUrl}/auth/callback/discord")}";
|
||||||
|
|
||||||
return Ok(new AuthController.SingleUrlResponse(url));
|
return Ok(new SingleUrlResponse(url));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("add-account/callback")]
|
[HttpPost("add-account/callback")]
|
||||||
[Authorize("*")]
|
[Authorize("*")]
|
||||||
public async Task<IActionResult> AddAccountCallbackAsync(
|
public async Task<IActionResult> AddAccountCallbackAsync([FromBody] CallbackRequest req)
|
||||||
[FromBody] AuthController.CallbackRequest req
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
CheckRequirements();
|
CheckRequirements();
|
||||||
|
|
||||||
|
@ -144,7 +141,7 @@ public class DiscordAuthController(
|
||||||
);
|
);
|
||||||
|
|
||||||
return Ok(
|
return Ok(
|
||||||
new AuthController.AddOauthAccountResponse(
|
new AddOauthAccountResponse(
|
||||||
authMethod.Id,
|
authMethod.Id,
|
||||||
AuthType.Discord,
|
AuthType.Discord,
|
||||||
authMethod.RemoteId,
|
authMethod.RemoteId,
|
||||||
|
|
|
@ -2,6 +2,7 @@ using System.Net;
|
||||||
using EntityFramework.Exceptions.Common;
|
using EntityFramework.Exceptions.Common;
|
||||||
using Foxnouns.Backend.Database;
|
using Foxnouns.Backend.Database;
|
||||||
using Foxnouns.Backend.Database.Models;
|
using Foxnouns.Backend.Database.Models;
|
||||||
|
using Foxnouns.Backend.Dto;
|
||||||
using Foxnouns.Backend.Extensions;
|
using Foxnouns.Backend.Extensions;
|
||||||
using Foxnouns.Backend.Middleware;
|
using Foxnouns.Backend.Middleware;
|
||||||
using Foxnouns.Backend.Services;
|
using Foxnouns.Backend.Services;
|
||||||
|
@ -30,7 +31,7 @@ public class EmailAuthController(
|
||||||
|
|
||||||
[HttpPost("register/init")]
|
[HttpPost("register/init")]
|
||||||
public async Task<IActionResult> RegisterInitAsync(
|
public async Task<IActionResult> RegisterInitAsync(
|
||||||
[FromBody] RegisterRequest req,
|
[FromBody] EmailRegisterRequest req,
|
||||||
CancellationToken ct = default
|
CancellationToken ct = default
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
|
@ -73,7 +74,7 @@ public class EmailAuthController(
|
||||||
|
|
||||||
[HttpPost("register")]
|
[HttpPost("register")]
|
||||||
public async Task<IActionResult> CompleteRegistrationAsync(
|
public async Task<IActionResult> CompleteRegistrationAsync(
|
||||||
[FromBody] CompleteRegistrationRequest req
|
[FromBody] EmailCompleteRegistrationRequest req
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
CheckRequirements();
|
CheckRequirements();
|
||||||
|
@ -102,7 +103,7 @@ public class EmailAuthController(
|
||||||
await keyCacheService.DeleteKeyAsync($"email:{req.Ticket}");
|
await keyCacheService.DeleteKeyAsync($"email:{req.Ticket}");
|
||||||
|
|
||||||
return Ok(
|
return Ok(
|
||||||
new AuthController.AuthResponse(
|
new AuthResponse(
|
||||||
await userRenderer.RenderUserAsync(user, user, renderMembers: false),
|
await userRenderer.RenderUserAsync(user, user, renderMembers: false),
|
||||||
tokenStr,
|
tokenStr,
|
||||||
token.ExpiresAt
|
token.ExpiresAt
|
||||||
|
@ -111,9 +112,9 @@ public class EmailAuthController(
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("login")]
|
[HttpPost("login")]
|
||||||
[ProducesResponseType<AuthController.AuthResponse>(StatusCodes.Status200OK)]
|
[ProducesResponseType<AuthResponse>(StatusCodes.Status200OK)]
|
||||||
public async Task<IActionResult> LoginAsync(
|
public async Task<IActionResult> LoginAsync(
|
||||||
[FromBody] LoginRequest req,
|
[FromBody] EmailLoginRequest req,
|
||||||
CancellationToken ct = default
|
CancellationToken ct = default
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
|
@ -141,7 +142,7 @@ public class EmailAuthController(
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
return Ok(
|
return Ok(
|
||||||
new AuthController.AuthResponse(
|
new AuthResponse(
|
||||||
await userRenderer.RenderUserAsync(user, user, renderMembers: false, ct: ct),
|
await userRenderer.RenderUserAsync(user, user, renderMembers: false, ct: ct),
|
||||||
tokenStr,
|
tokenStr,
|
||||||
token.ExpiresAt
|
token.ExpiresAt
|
||||||
|
@ -151,7 +152,7 @@ public class EmailAuthController(
|
||||||
|
|
||||||
[HttpPost("change-password")]
|
[HttpPost("change-password")]
|
||||||
[Authorize("*")]
|
[Authorize("*")]
|
||||||
public async Task<IActionResult> UpdatePasswordAsync([FromBody] ChangePasswordRequest req)
|
public async Task<IActionResult> UpdatePasswordAsync([FromBody] EmailChangePasswordRequest req)
|
||||||
{
|
{
|
||||||
if (!await authService.ValidatePasswordAsync(CurrentUser!, req.Current))
|
if (!await authService.ValidatePasswordAsync(CurrentUser!, req.Current))
|
||||||
throw new ApiError.Forbidden("Invalid password");
|
throw new ApiError.Forbidden("Invalid password");
|
||||||
|
@ -211,7 +212,7 @@ public class EmailAuthController(
|
||||||
|
|
||||||
[HttpPost("add-email/callback")]
|
[HttpPost("add-email/callback")]
|
||||||
[Authorize("*")]
|
[Authorize("*")]
|
||||||
public async Task<IActionResult> AddEmailCallbackAsync([FromBody] CallbackRequest req)
|
public async Task<IActionResult> AddEmailCallbackAsync([FromBody] EmailCallbackRequest req)
|
||||||
{
|
{
|
||||||
CheckRequirements();
|
CheckRequirements();
|
||||||
|
|
||||||
|
@ -233,7 +234,7 @@ public class EmailAuthController(
|
||||||
);
|
);
|
||||||
|
|
||||||
return Ok(
|
return Ok(
|
||||||
new AuthController.AddOauthAccountResponse(
|
new AddOauthAccountResponse(
|
||||||
authMethod.Id,
|
authMethod.Id,
|
||||||
AuthType.Email,
|
AuthType.Email,
|
||||||
authMethod.RemoteId,
|
authMethod.RemoteId,
|
||||||
|
@ -258,14 +259,4 @@ public class EmailAuthController(
|
||||||
if (!config.EmailAuth.Enabled)
|
if (!config.EmailAuth.Enabled)
|
||||||
throw new ApiError.BadRequest("Email authentication is not enabled on this instance.");
|
throw new ApiError.BadRequest("Email authentication is not enabled on this instance.");
|
||||||
}
|
}
|
||||||
|
|
||||||
public record LoginRequest(string Email, string Password);
|
|
||||||
|
|
||||||
public record RegisterRequest(string Email);
|
|
||||||
|
|
||||||
public record CompleteRegistrationRequest(string Ticket, string Username, string Password);
|
|
||||||
|
|
||||||
public record CallbackRequest(string State);
|
|
||||||
|
|
||||||
public record ChangePasswordRequest(string Current, string New);
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,7 @@ using System.Net;
|
||||||
using EntityFramework.Exceptions.Common;
|
using EntityFramework.Exceptions.Common;
|
||||||
using Foxnouns.Backend.Database;
|
using Foxnouns.Backend.Database;
|
||||||
using Foxnouns.Backend.Database.Models;
|
using Foxnouns.Backend.Database.Models;
|
||||||
|
using Foxnouns.Backend.Dto;
|
||||||
using Foxnouns.Backend.Middleware;
|
using Foxnouns.Backend.Middleware;
|
||||||
using Foxnouns.Backend.Services;
|
using Foxnouns.Backend.Services;
|
||||||
using Foxnouns.Backend.Services.Auth;
|
using Foxnouns.Backend.Services.Auth;
|
||||||
|
@ -25,7 +26,7 @@ public class FediverseAuthController(
|
||||||
private readonly ILogger _logger = logger.ForContext<FediverseAuthController>();
|
private readonly ILogger _logger = logger.ForContext<FediverseAuthController>();
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[ProducesResponseType<AuthController.SingleUrlResponse>(statusCode: StatusCodes.Status200OK)]
|
[ProducesResponseType<SingleUrlResponse>(statusCode: StatusCodes.Status200OK)]
|
||||||
public async Task<IActionResult> GetFediverseUrlAsync(
|
public async Task<IActionResult> GetFediverseUrlAsync(
|
||||||
[FromQuery] string instance,
|
[FromQuery] string instance,
|
||||||
[FromQuery] bool forceRefresh = false
|
[FromQuery] bool forceRefresh = false
|
||||||
|
@ -35,12 +36,12 @@ public class FediverseAuthController(
|
||||||
throw new ApiError.BadRequest("Not a valid domain.", "instance", instance);
|
throw new ApiError.BadRequest("Not a valid domain.", "instance", instance);
|
||||||
|
|
||||||
string url = await fediverseAuthService.GenerateAuthUrlAsync(instance, forceRefresh);
|
string url = await fediverseAuthService.GenerateAuthUrlAsync(instance, forceRefresh);
|
||||||
return Ok(new AuthController.SingleUrlResponse(url));
|
return Ok(new SingleUrlResponse(url));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("callback")]
|
[HttpPost("callback")]
|
||||||
[ProducesResponseType<CallbackResponse>(statusCode: StatusCodes.Status200OK)]
|
[ProducesResponseType<CallbackResponse>(statusCode: StatusCodes.Status200OK)]
|
||||||
public async Task<IActionResult> FediverseCallbackAsync([FromBody] CallbackRequest req)
|
public async Task<IActionResult> FediverseCallbackAsync([FromBody] FediverseCallbackRequest req)
|
||||||
{
|
{
|
||||||
FediverseApplication app = await fediverseAuthService.GetApplicationAsync(req.Instance);
|
FediverseApplication app = await fediverseAuthService.GetApplicationAsync(req.Instance);
|
||||||
FediverseAuthService.FediverseUser remoteUser =
|
FediverseAuthService.FediverseUser remoteUser =
|
||||||
|
@ -74,10 +75,8 @@ public class FediverseAuthController(
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("register")]
|
[HttpPost("register")]
|
||||||
[ProducesResponseType<AuthController.AuthResponse>(statusCode: StatusCodes.Status200OK)]
|
[ProducesResponseType<AuthResponse>(statusCode: StatusCodes.Status200OK)]
|
||||||
public async Task<IActionResult> RegisterAsync(
|
public async Task<IActionResult> RegisterAsync([FromBody] OauthRegisterRequest req)
|
||||||
[FromBody] AuthController.OauthRegisterRequest req
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
FediverseTicketData? ticketData = await keyCacheService.GetKeyAsync<FediverseTicketData>(
|
FediverseTicketData? ticketData = await keyCacheService.GetKeyAsync<FediverseTicketData>(
|
||||||
$"fediverse:{req.Ticket}",
|
$"fediverse:{req.Ticket}",
|
||||||
|
@ -138,12 +137,14 @@ public class FediverseAuthController(
|
||||||
);
|
);
|
||||||
|
|
||||||
string url = await fediverseAuthService.GenerateAuthUrlAsync(instance, forceRefresh, state);
|
string url = await fediverseAuthService.GenerateAuthUrlAsync(instance, forceRefresh, state);
|
||||||
return Ok(new AuthController.SingleUrlResponse(url));
|
return Ok(new SingleUrlResponse(url));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("add-account/callback")]
|
[HttpPost("add-account/callback")]
|
||||||
[Authorize("*")]
|
[Authorize("*")]
|
||||||
public async Task<IActionResult> AddAccountCallbackAsync([FromBody] CallbackRequest req)
|
public async Task<IActionResult> AddAccountCallbackAsync(
|
||||||
|
[FromBody] FediverseCallbackRequest req
|
||||||
|
)
|
||||||
{
|
{
|
||||||
await remoteAuthService.ValidateAddAccountStateAsync(
|
await remoteAuthService.ValidateAddAccountStateAsync(
|
||||||
req.State,
|
req.State,
|
||||||
|
@ -171,7 +172,7 @@ public class FediverseAuthController(
|
||||||
);
|
);
|
||||||
|
|
||||||
return Ok(
|
return Ok(
|
||||||
new AuthController.AddOauthAccountResponse(
|
new AddOauthAccountResponse(
|
||||||
authMethod.Id,
|
authMethod.Id,
|
||||||
AuthType.Fediverse,
|
AuthType.Fediverse,
|
||||||
authMethod.RemoteId,
|
authMethod.RemoteId,
|
||||||
|
@ -189,8 +190,6 @@ public class FediverseAuthController(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public record CallbackRequest(string Instance, string Code, string State);
|
|
||||||
|
|
||||||
private record FediverseTicketData(
|
private record FediverseTicketData(
|
||||||
Snowflake ApplicationId,
|
Snowflake ApplicationId,
|
||||||
FediverseAuthService.FediverseUser User
|
FediverseAuthService.FediverseUser User
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
using Coravel.Queuing.Interfaces;
|
using Coravel.Queuing.Interfaces;
|
||||||
using Foxnouns.Backend.Database;
|
using Foxnouns.Backend.Database;
|
||||||
using Foxnouns.Backend.Database.Models;
|
using Foxnouns.Backend.Database.Models;
|
||||||
|
using Foxnouns.Backend.Dto;
|
||||||
using Foxnouns.Backend.Jobs;
|
using Foxnouns.Backend.Jobs;
|
||||||
using Foxnouns.Backend.Middleware;
|
using Foxnouns.Backend.Middleware;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
@ -43,8 +44,6 @@ public class ExportsController(
|
||||||
private string ExportUrl(Snowflake userId, string filename) =>
|
private string ExportUrl(Snowflake userId, string filename) =>
|
||||||
$"{config.MediaBaseUrl}/data-exports/{userId}/{filename}.zip";
|
$"{config.MediaBaseUrl}/data-exports/{userId}/{filename}.zip";
|
||||||
|
|
||||||
private record DataExportResponse(string? Url, Instant? ExpiresAt);
|
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<IActionResult> QueueDataExportAsync()
|
public async Task<IActionResult> QueueDataExportAsync()
|
||||||
{
|
{
|
||||||
|
|
|
@ -1,65 +1,69 @@
|
||||||
using Coravel.Queuing.Interfaces;
|
using Coravel.Queuing.Interfaces;
|
||||||
using Foxnouns.Backend.Database;
|
using Foxnouns.Backend.Database;
|
||||||
using Foxnouns.Backend.Database.Models;
|
using Foxnouns.Backend.Database.Models;
|
||||||
using Foxnouns.Backend.Extensions;
|
using Foxnouns.Backend.Dto;
|
||||||
using Foxnouns.Backend.Jobs;
|
using Foxnouns.Backend.Jobs;
|
||||||
using Foxnouns.Backend.Middleware;
|
using Foxnouns.Backend.Middleware;
|
||||||
using Foxnouns.Backend.Services;
|
using Foxnouns.Backend.Services;
|
||||||
using Foxnouns.Backend.Utils;
|
using Foxnouns.Backend.Utils;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Storage;
|
|
||||||
|
|
||||||
namespace Foxnouns.Backend.Controllers;
|
namespace Foxnouns.Backend.Controllers;
|
||||||
|
|
||||||
[Route("/api/v2/users/@me/flags")]
|
[Route("/api/v2/users/@me/flags")]
|
||||||
public class FlagsController(
|
public class FlagsController(
|
||||||
ILogger logger,
|
|
||||||
DatabaseContext db,
|
DatabaseContext db,
|
||||||
UserRendererService userRenderer,
|
UserRendererService userRenderer,
|
||||||
ObjectStorageService objectStorageService,
|
|
||||||
ISnowflakeGenerator snowflakeGenerator,
|
ISnowflakeGenerator snowflakeGenerator,
|
||||||
IQueue queue
|
IQueue queue
|
||||||
) : ApiControllerBase
|
) : ApiControllerBase
|
||||||
{
|
{
|
||||||
private readonly ILogger _logger = logger.ForContext<FlagsController>();
|
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Authorize("identify")]
|
[Authorize("identify")]
|
||||||
[ProducesResponseType<IEnumerable<UserRendererService.PrideFlagResponse>>(
|
[ProducesResponseType<IEnumerable<PrideFlagResponse>>(statusCode: StatusCodes.Status200OK)]
|
||||||
statusCode: StatusCodes.Status200OK
|
|
||||||
)]
|
|
||||||
public async Task<IActionResult> GetFlagsAsync(CancellationToken ct = default)
|
public async Task<IActionResult> GetFlagsAsync(CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
List<PrideFlag> flags = await db
|
List<PrideFlag> flags = await db
|
||||||
.PrideFlags.Where(f => f.UserId == CurrentUser!.Id)
|
.PrideFlags.Where(f => f.UserId == CurrentUser!.Id)
|
||||||
|
.OrderBy(f => f.Name)
|
||||||
|
.ThenBy(f => f.Id)
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
return Ok(flags.Select(userRenderer.RenderPrideFlag));
|
return Ok(flags.Select(userRenderer.RenderPrideFlag));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public const int MaxFlagCount = 500;
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Authorize("user.update")]
|
[Authorize("user.update")]
|
||||||
[ProducesResponseType<UserRendererService.PrideFlagResponse>(
|
[ProducesResponseType<PrideFlagResponse>(statusCode: StatusCodes.Status202Accepted)]
|
||||||
statusCode: StatusCodes.Status202Accepted
|
public async Task<IActionResult> CreateFlagAsync([FromBody] CreateFlagRequest req)
|
||||||
)]
|
|
||||||
public IActionResult CreateFlag([FromBody] CreateFlagRequest req)
|
|
||||||
{
|
{
|
||||||
|
int flagCount = await db.PrideFlags.Where(f => f.UserId == CurrentUser!.Id).CountAsync();
|
||||||
|
if (flagCount >= MaxFlagCount)
|
||||||
|
throw new ApiError.BadRequest("Maximum number of flags reached");
|
||||||
|
|
||||||
ValidationUtils.Validate(ValidateFlag(req.Name, req.Description, req.Image));
|
ValidationUtils.Validate(ValidateFlag(req.Name, req.Description, req.Image));
|
||||||
|
|
||||||
Snowflake id = snowflakeGenerator.GenerateSnowflake();
|
var flag = new PrideFlag
|
||||||
|
{
|
||||||
|
Id = snowflakeGenerator.GenerateSnowflake(),
|
||||||
|
UserId = CurrentUser!.Id,
|
||||||
|
Name = req.Name,
|
||||||
|
Description = req.Description,
|
||||||
|
};
|
||||||
|
|
||||||
|
db.Add(flag);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
queue.QueueInvocableWithPayload<CreateFlagInvocable, CreateFlagPayload>(
|
queue.QueueInvocableWithPayload<CreateFlagInvocable, CreateFlagPayload>(
|
||||||
new CreateFlagPayload(id, CurrentUser!.Id, req.Name, req.Image, req.Description)
|
new CreateFlagPayload(flag.Id, CurrentUser!.Id, req.Name, req.Image, req.Description)
|
||||||
);
|
);
|
||||||
|
|
||||||
return Accepted(new CreateFlagResponse(id, req.Name, req.Description));
|
return Accepted(userRenderer.RenderPrideFlag(flag));
|
||||||
}
|
}
|
||||||
|
|
||||||
public record CreateFlagRequest(string Name, string Image, string? Description);
|
|
||||||
|
|
||||||
private record CreateFlagResponse(Snowflake Id, string Name, string? Description);
|
|
||||||
|
|
||||||
[HttpPatch("{id}")]
|
[HttpPatch("{id}")]
|
||||||
[Authorize("user.update")]
|
[Authorize("user.update")]
|
||||||
public async Task<IActionResult> UpdateFlagAsync(Snowflake id, [FromBody] UpdateFlagRequest req)
|
public async Task<IActionResult> UpdateFlagAsync(Snowflake id, [FromBody] UpdateFlagRequest req)
|
||||||
|
@ -84,52 +88,19 @@ public class FlagsController(
|
||||||
return Ok(userRenderer.RenderPrideFlag(flag));
|
return Ok(userRenderer.RenderPrideFlag(flag));
|
||||||
}
|
}
|
||||||
|
|
||||||
public class UpdateFlagRequest : PatchRequest
|
|
||||||
{
|
|
||||||
public string? Name { get; init; }
|
|
||||||
public string? Description { get; init; }
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpDelete("{id}")]
|
[HttpDelete("{id}")]
|
||||||
[Authorize("user.update")]
|
[Authorize("user.update")]
|
||||||
public async Task<IActionResult> DeleteFlagAsync(Snowflake id)
|
public async Task<IActionResult> DeleteFlagAsync(Snowflake id)
|
||||||
{
|
{
|
||||||
await using IDbContextTransaction tx = await db.Database.BeginTransactionAsync();
|
|
||||||
|
|
||||||
PrideFlag? flag = await db.PrideFlags.FirstOrDefaultAsync(f =>
|
PrideFlag? flag = await db.PrideFlags.FirstOrDefaultAsync(f =>
|
||||||
f.Id == id && f.UserId == CurrentUser!.Id
|
f.Id == id && f.UserId == CurrentUser!.Id
|
||||||
);
|
);
|
||||||
if (flag == null)
|
if (flag == null)
|
||||||
throw new ApiError.NotFound("Unknown flag ID, or it's not your flag.");
|
throw new ApiError.NotFound("Unknown flag ID, or it's not your flag.");
|
||||||
|
|
||||||
string hash = flag.Hash;
|
|
||||||
|
|
||||||
db.PrideFlags.Remove(flag);
|
db.PrideFlags.Remove(flag);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
int flagCount = await db.PrideFlags.CountAsync(f => f.Hash == flag.Hash);
|
|
||||||
if (flagCount == 0)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_logger.Information(
|
|
||||||
"Deleting flag file {Hash} as it is no longer used by any flags",
|
|
||||||
hash
|
|
||||||
);
|
|
||||||
await objectStorageService.DeleteFlagAsync(hash);
|
|
||||||
}
|
|
||||||
catch (Exception e)
|
|
||||||
{
|
|
||||||
_logger.Error(e, "Error deleting flag file {Hash}", hash);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_logger.Debug("Flag file {Hash} is used by other flags, not deleting", hash);
|
|
||||||
}
|
|
||||||
|
|
||||||
await tx.CommitAsync();
|
|
||||||
|
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using Foxnouns.Backend.Database;
|
using Foxnouns.Backend.Database;
|
||||||
|
using Foxnouns.Backend.Dto;
|
||||||
using Foxnouns.Backend.Utils;
|
using Foxnouns.Backend.Utils;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc.Controllers;
|
using Microsoft.AspNetCore.Mvc.Controllers;
|
||||||
|
@ -63,10 +64,6 @@ public partial class InternalController(DatabaseContext db) : ControllerBase
|
||||||
return Ok(new RequestDataResponse(userId, template));
|
return Ok(new RequestDataResponse(userId, template));
|
||||||
}
|
}
|
||||||
|
|
||||||
public record RequestDataRequest(string? Token, string Method, string Path);
|
|
||||||
|
|
||||||
public record RequestDataResponse(Snowflake? UserId, string Template);
|
|
||||||
|
|
||||||
private static RouteEndpoint? GetEndpoint(
|
private static RouteEndpoint? GetEndpoint(
|
||||||
HttpContext httpContext,
|
HttpContext httpContext,
|
||||||
string url,
|
string url,
|
||||||
|
|
|
@ -2,6 +2,7 @@ using Coravel.Queuing.Interfaces;
|
||||||
using EntityFramework.Exceptions.Common;
|
using EntityFramework.Exceptions.Common;
|
||||||
using Foxnouns.Backend.Database;
|
using Foxnouns.Backend.Database;
|
||||||
using Foxnouns.Backend.Database.Models;
|
using Foxnouns.Backend.Database.Models;
|
||||||
|
using Foxnouns.Backend.Dto;
|
||||||
using Foxnouns.Backend.Extensions;
|
using Foxnouns.Backend.Extensions;
|
||||||
using Foxnouns.Backend.Jobs;
|
using Foxnouns.Backend.Jobs;
|
||||||
using Foxnouns.Backend.Middleware;
|
using Foxnouns.Backend.Middleware;
|
||||||
|
@ -28,9 +29,7 @@ public class MembersController(
|
||||||
private readonly ILogger _logger = logger.ForContext<MembersController>();
|
private readonly ILogger _logger = logger.ForContext<MembersController>();
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[ProducesResponseType<IEnumerable<MemberRendererService.PartialMember>>(
|
[ProducesResponseType<IEnumerable<PartialMember>>(StatusCodes.Status200OK)]
|
||||||
StatusCodes.Status200OK
|
|
||||||
)]
|
|
||||||
public async Task<IActionResult> GetMembersAsync(string userRef, CancellationToken ct = default)
|
public async Task<IActionResult> GetMembersAsync(string userRef, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
User user = await db.ResolveUserAsync(userRef, CurrentToken, ct);
|
User user = await db.ResolveUserAsync(userRef, CurrentToken, ct);
|
||||||
|
@ -38,7 +37,7 @@ public class MembersController(
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{memberRef}")]
|
[HttpGet("{memberRef}")]
|
||||||
[ProducesResponseType<MemberRendererService.MemberResponse>(StatusCodes.Status200OK)]
|
[ProducesResponseType<MemberResponse>(StatusCodes.Status200OK)]
|
||||||
public async Task<IActionResult> GetMemberAsync(
|
public async Task<IActionResult> GetMemberAsync(
|
||||||
string userRef,
|
string userRef,
|
||||||
string memberRef,
|
string memberRef,
|
||||||
|
@ -52,7 +51,7 @@ public class MembersController(
|
||||||
public const int MaxMemberCount = 500;
|
public const int MaxMemberCount = 500;
|
||||||
|
|
||||||
[HttpPost("/api/v2/users/@me/members")]
|
[HttpPost("/api/v2/users/@me/members")]
|
||||||
[ProducesResponseType<MemberRendererService.MemberResponse>(StatusCodes.Status200OK)]
|
[ProducesResponseType<MemberResponse>(StatusCodes.Status200OK)]
|
||||||
[Authorize("member.create")]
|
[Authorize("member.create")]
|
||||||
public async Task<IActionResult> CreateMemberAsync(
|
public async Task<IActionResult> CreateMemberAsync(
|
||||||
[FromBody] CreateMemberRequest req,
|
[FromBody] CreateMemberRequest req,
|
||||||
|
@ -246,20 +245,6 @@ public class MembersController(
|
||||||
return Ok(memberRenderer.RenderMember(member, CurrentToken));
|
return Ok(memberRenderer.RenderMember(member, CurrentToken));
|
||||||
}
|
}
|
||||||
|
|
||||||
public class UpdateMemberRequest : PatchRequest
|
|
||||||
{
|
|
||||||
public string? Name { get; init; }
|
|
||||||
public string? DisplayName { get; init; }
|
|
||||||
public string? Bio { get; init; }
|
|
||||||
public string? Avatar { get; init; }
|
|
||||||
public string[]? Links { get; init; }
|
|
||||||
public FieldEntry[]? Names { get; init; }
|
|
||||||
public Pronoun[]? Pronouns { get; init; }
|
|
||||||
public Field[]? Fields { get; init; }
|
|
||||||
public Snowflake[]? Flags { get; init; }
|
|
||||||
public bool? Unlisted { get; init; }
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpDelete("/api/v2/users/@me/members/{memberRef}")]
|
[HttpDelete("/api/v2/users/@me/members/{memberRef}")]
|
||||||
[Authorize("member.update")]
|
[Authorize("member.update")]
|
||||||
public async Task<IActionResult> DeleteMemberAsync(string memberRef)
|
public async Task<IActionResult> DeleteMemberAsync(string memberRef)
|
||||||
|
@ -282,21 +267,9 @@ public class MembersController(
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
public record CreateMemberRequest(
|
|
||||||
string Name,
|
|
||||||
string? DisplayName,
|
|
||||||
string? Bio,
|
|
||||||
string? Avatar,
|
|
||||||
bool? Unlisted,
|
|
||||||
string[]? Links,
|
|
||||||
List<FieldEntry>? Names,
|
|
||||||
List<Pronoun>? Pronouns,
|
|
||||||
List<Field>? Fields
|
|
||||||
);
|
|
||||||
|
|
||||||
[HttpPost("/api/v2/users/@me/members/{memberRef}/reroll-sid")]
|
[HttpPost("/api/v2/users/@me/members/{memberRef}/reroll-sid")]
|
||||||
[Authorize("member.update")]
|
[Authorize("member.update")]
|
||||||
[ProducesResponseType<UserRendererService.UserResponse>(statusCode: StatusCodes.Status200OK)]
|
[ProducesResponseType<MemberResponse>(statusCode: StatusCodes.Status200OK)]
|
||||||
public async Task<IActionResult> RerollSidAsync(string memberRef)
|
public async Task<IActionResult> RerollSidAsync(string memberRef)
|
||||||
{
|
{
|
||||||
Member member = await db.ResolveMemberAsync(CurrentUser!.Id, memberRef);
|
Member member = await db.ResolveMemberAsync(CurrentUser!.Id, memberRef);
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
using Foxnouns.Backend.Dto;
|
||||||
using Foxnouns.Backend.Utils;
|
using Foxnouns.Backend.Utils;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
@ -17,17 +18,18 @@ public class MetaController : ApiControllerBase
|
||||||
BuildInfo.Version,
|
BuildInfo.Version,
|
||||||
BuildInfo.Hash,
|
BuildInfo.Hash,
|
||||||
(int)FoxnounsMetrics.MemberCount.Value,
|
(int)FoxnounsMetrics.MemberCount.Value,
|
||||||
new UserInfo(
|
new UserInfoResponse(
|
||||||
(int)FoxnounsMetrics.UsersCount.Value,
|
(int)FoxnounsMetrics.UsersCount.Value,
|
||||||
(int)FoxnounsMetrics.UsersActiveMonthCount.Value,
|
(int)FoxnounsMetrics.UsersActiveMonthCount.Value,
|
||||||
(int)FoxnounsMetrics.UsersActiveWeekCount.Value,
|
(int)FoxnounsMetrics.UsersActiveWeekCount.Value,
|
||||||
(int)FoxnounsMetrics.UsersActiveDayCount.Value
|
(int)FoxnounsMetrics.UsersActiveDayCount.Value
|
||||||
),
|
),
|
||||||
new Limits(
|
new LimitsResponse(
|
||||||
MembersController.MaxMemberCount,
|
MembersController.MaxMemberCount,
|
||||||
ValidationUtils.MaxBioLength,
|
ValidationUtils.MaxBioLength,
|
||||||
ValidationUtils.MaxCustomPreferences,
|
ValidationUtils.MaxCustomPreferences,
|
||||||
AuthUtils.MaxAuthMethodsPerType
|
AuthUtils.MaxAuthMethodsPerType,
|
||||||
|
FlagsController.MaxFlagCount
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
@ -35,23 +37,4 @@ public class MetaController : ApiControllerBase
|
||||||
[HttpGet("/api/v2/coffee")]
|
[HttpGet("/api/v2/coffee")]
|
||||||
public IActionResult BrewCoffee() =>
|
public IActionResult BrewCoffee() =>
|
||||||
Problem("Sorry, I'm a teapot!", statusCode: StatusCodes.Status418ImATeapot);
|
Problem("Sorry, I'm a teapot!", statusCode: StatusCodes.Status418ImATeapot);
|
||||||
|
|
||||||
private record MetaResponse(
|
|
||||||
string Repository,
|
|
||||||
string Version,
|
|
||||||
string Hash,
|
|
||||||
int Members,
|
|
||||||
UserInfo Users,
|
|
||||||
Limits Limits
|
|
||||||
);
|
|
||||||
|
|
||||||
private record UserInfo(int Total, int ActiveMonth, int ActiveWeek, int ActiveDay);
|
|
||||||
|
|
||||||
// All limits that the frontend should know about (for UI purposes)
|
|
||||||
private record Limits(
|
|
||||||
int MemberCount,
|
|
||||||
int BioLength,
|
|
||||||
int CustomPreferences,
|
|
||||||
int MaxAuthMethods
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
using System.Diagnostics.CodeAnalysis;
|
|
||||||
using Coravel.Queuing.Interfaces;
|
using Coravel.Queuing.Interfaces;
|
||||||
using EntityFramework.Exceptions.Common;
|
using EntityFramework.Exceptions.Common;
|
||||||
using Foxnouns.Backend.Database;
|
using Foxnouns.Backend.Database;
|
||||||
using Foxnouns.Backend.Database.Models;
|
using Foxnouns.Backend.Database.Models;
|
||||||
|
using Foxnouns.Backend.Dto;
|
||||||
using Foxnouns.Backend.Jobs;
|
using Foxnouns.Backend.Jobs;
|
||||||
using Foxnouns.Backend.Middleware;
|
using Foxnouns.Backend.Middleware;
|
||||||
using Foxnouns.Backend.Services;
|
using Foxnouns.Backend.Services;
|
||||||
|
@ -27,7 +27,7 @@ public class UsersController(
|
||||||
private readonly ILogger _logger = logger.ForContext<UsersController>();
|
private readonly ILogger _logger = logger.ForContext<UsersController>();
|
||||||
|
|
||||||
[HttpGet("{userRef}")]
|
[HttpGet("{userRef}")]
|
||||||
[ProducesResponseType<UserRendererService.UserResponse>(statusCode: StatusCodes.Status200OK)]
|
[ProducesResponseType<UserResponse>(statusCode: StatusCodes.Status200OK)]
|
||||||
public async Task<IActionResult> GetUserAsync(string userRef, CancellationToken ct = default)
|
public async Task<IActionResult> GetUserAsync(string userRef, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
User user = await db.ResolveUserAsync(userRef, CurrentToken, ct);
|
User user = await db.ResolveUserAsync(userRef, CurrentToken, ct);
|
||||||
|
@ -38,7 +38,7 @@ public class UsersController(
|
||||||
|
|
||||||
[HttpPatch("@me")]
|
[HttpPatch("@me")]
|
||||||
[Authorize("user.update")]
|
[Authorize("user.update")]
|
||||||
[ProducesResponseType<UserRendererService.UserResponse>(statusCode: StatusCodes.Status200OK)]
|
[ProducesResponseType<UserResponse>(statusCode: StatusCodes.Status200OK)]
|
||||||
public async Task<IActionResult> UpdateUserAsync(
|
public async Task<IActionResult> UpdateUserAsync(
|
||||||
[FromBody] UpdateUserRequest req,
|
[FromBody] UpdateUserRequest req,
|
||||||
CancellationToken ct = default
|
CancellationToken ct = default
|
||||||
|
@ -196,7 +196,7 @@ public class UsersController(
|
||||||
[Authorize("user.update")]
|
[Authorize("user.update")]
|
||||||
[ProducesResponseType<Dictionary<Snowflake, User.CustomPreference>>(StatusCodes.Status200OK)]
|
[ProducesResponseType<Dictionary<Snowflake, User.CustomPreference>>(StatusCodes.Status200OK)]
|
||||||
public async Task<IActionResult> UpdateCustomPreferencesAsync(
|
public async Task<IActionResult> UpdateCustomPreferencesAsync(
|
||||||
[FromBody] List<CustomPreferenceUpdate> req,
|
[FromBody] List<CustomPreferenceUpdateRequest> req,
|
||||||
CancellationToken ct = default
|
CancellationToken ct = default
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
|
@ -207,7 +207,7 @@ public class UsersController(
|
||||||
.CustomPreferences.Where(x => req.Any(r => r.Id == x.Key))
|
.CustomPreferences.Where(x => req.Any(r => r.Id == x.Key))
|
||||||
.ToDictionary();
|
.ToDictionary();
|
||||||
|
|
||||||
foreach (CustomPreferenceUpdate? r in req)
|
foreach (CustomPreferenceUpdateRequest? r in req)
|
||||||
{
|
{
|
||||||
if (r.Id != null && preferences.ContainsKey(r.Id.Value))
|
if (r.Id != null && preferences.ContainsKey(r.Id.Value))
|
||||||
{
|
{
|
||||||
|
@ -239,33 +239,6 @@ public class UsersController(
|
||||||
return Ok(user.CustomPreferences);
|
return Ok(user.CustomPreferences);
|
||||||
}
|
}
|
||||||
|
|
||||||
[SuppressMessage("ReSharper", "ClassNeverInstantiated.Global")]
|
|
||||||
public class CustomPreferenceUpdate
|
|
||||||
{
|
|
||||||
public Snowflake? Id { get; init; }
|
|
||||||
public required string Icon { get; set; }
|
|
||||||
public required string Tooltip { get; set; }
|
|
||||||
public PreferenceSize Size { get; set; }
|
|
||||||
public bool Muted { get; set; }
|
|
||||||
public bool Favourite { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public class UpdateUserRequest : PatchRequest
|
|
||||||
{
|
|
||||||
public string? Username { get; init; }
|
|
||||||
public string? DisplayName { get; init; }
|
|
||||||
public string? Bio { get; init; }
|
|
||||||
public string? Avatar { get; init; }
|
|
||||||
public string[]? Links { get; init; }
|
|
||||||
public FieldEntry[]? Names { get; init; }
|
|
||||||
public Pronoun[]? Pronouns { get; init; }
|
|
||||||
public Field[]? Fields { get; init; }
|
|
||||||
public Snowflake[]? Flags { get; init; }
|
|
||||||
public string? MemberTitle { get; init; }
|
|
||||||
public bool? MemberListHidden { get; init; }
|
|
||||||
public string? Timezone { get; init; }
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet("@me/settings")]
|
[HttpGet("@me/settings")]
|
||||||
[Authorize("user.read_hidden")]
|
[Authorize("user.read_hidden")]
|
||||||
[ProducesResponseType<UserSettings>(statusCode: StatusCodes.Status200OK)]
|
[ProducesResponseType<UserSettings>(statusCode: StatusCodes.Status200OK)]
|
||||||
|
@ -294,14 +267,9 @@ public class UsersController(
|
||||||
return Ok(user.Settings);
|
return Ok(user.Settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class UpdateUserSettingsRequest : PatchRequest
|
|
||||||
{
|
|
||||||
public bool? DarkMode { get; init; }
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPost("@me/reroll-sid")]
|
[HttpPost("@me/reroll-sid")]
|
||||||
[Authorize("user.update")]
|
[Authorize("user.update")]
|
||||||
[ProducesResponseType<UserRendererService.UserResponse>(statusCode: StatusCodes.Status200OK)]
|
[ProducesResponseType<UserResponse>(statusCode: StatusCodes.Status200OK)]
|
||||||
public async Task<IActionResult> RerollSidAsync()
|
public async Task<IActionResult> RerollSidAsync()
|
||||||
{
|
{
|
||||||
Instant minTimeAgo = clock.GetCurrentInstant() - Duration.FromHours(1);
|
Instant minTimeAgo = clock.GetCurrentInstant() - Duration.FromHours(1);
|
||||||
|
|
|
@ -0,0 +1,41 @@
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Foxnouns.Backend.Database.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
[DbContext(typeof(DatabaseContext))]
|
||||||
|
[Migration("20241209134148_NullableFlagHash")]
|
||||||
|
public partial class NullableFlagHash : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "hash",
|
||||||
|
table: "pride_flags",
|
||||||
|
type: "text",
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "text"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AlterColumn<string>(
|
||||||
|
name: "hash",
|
||||||
|
table: "pride_flags",
|
||||||
|
type: "text",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "",
|
||||||
|
oldClrType: typeof(string),
|
||||||
|
oldType: "text",
|
||||||
|
oldNullable: true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -282,7 +282,6 @@ namespace Foxnouns.Backend.Database.Migrations
|
||||||
.HasColumnName("description");
|
.HasColumnName("description");
|
||||||
|
|
||||||
b.Property<string>("Hash")
|
b.Property<string>("Hash")
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text")
|
.HasColumnType("text")
|
||||||
.HasColumnName("hash");
|
.HasColumnName("hash");
|
||||||
|
|
||||||
|
@ -546,7 +545,7 @@ namespace Foxnouns.Backend.Database.Migrations
|
||||||
modelBuilder.Entity("Foxnouns.Backend.Database.Models.DataExport", b =>
|
modelBuilder.Entity("Foxnouns.Backend.Database.Models.DataExport", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Foxnouns.Backend.Database.Models.User", "User")
|
b.HasOne("Foxnouns.Backend.Database.Models.User", "User")
|
||||||
.WithMany()
|
.WithMany("DataExports")
|
||||||
.HasForeignKey("UserId")
|
.HasForeignKey("UserId")
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
|
@ -645,6 +644,8 @@ namespace Foxnouns.Backend.Database.Migrations
|
||||||
{
|
{
|
||||||
b.Navigation("AuthMethods");
|
b.Navigation("AuthMethods");
|
||||||
|
|
||||||
|
b.Navigation("DataExports");
|
||||||
|
|
||||||
b.Navigation("Flags");
|
b.Navigation("Flags");
|
||||||
|
|
||||||
b.Navigation("Members");
|
b.Navigation("Members");
|
||||||
|
|
|
@ -3,7 +3,9 @@ namespace Foxnouns.Backend.Database.Models;
|
||||||
public class PrideFlag : BaseModel
|
public class PrideFlag : BaseModel
|
||||||
{
|
{
|
||||||
public required Snowflake UserId { get; init; }
|
public required Snowflake UserId { get; init; }
|
||||||
public required string Hash { get; init; }
|
|
||||||
|
// A null hash means the flag hasn't been processed yet.
|
||||||
|
public string? Hash { get; set; }
|
||||||
public required string Name { get; set; }
|
public required string Name { get; set; }
|
||||||
public string? Description { get; set; }
|
public string? Description { get; set; }
|
||||||
}
|
}
|
||||||
|
|
47
Foxnouns.Backend/Dto/Auth.cs
Normal file
47
Foxnouns.Backend/Dto/Auth.cs
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
// ReSharper disable NotAccessedPositionalProperty.Global
|
||||||
|
// ReSharper disable ClassNeverInstantiated.Global
|
||||||
|
using Foxnouns.Backend.Database;
|
||||||
|
using Foxnouns.Backend.Database.Models;
|
||||||
|
using Foxnouns.Backend.Utils;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using NodaTime;
|
||||||
|
|
||||||
|
namespace Foxnouns.Backend.Dto;
|
||||||
|
|
||||||
|
public record CallbackResponse(
|
||||||
|
bool HasAccount,
|
||||||
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? Ticket,
|
||||||
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? RemoteUsername,
|
||||||
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] UserResponse? User,
|
||||||
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? Token,
|
||||||
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Instant? ExpiresAt
|
||||||
|
);
|
||||||
|
|
||||||
|
public record UrlsResponse(bool EmailEnabled, string? Discord, string? Google, string? Tumblr);
|
||||||
|
|
||||||
|
public record AuthResponse(UserResponse User, string Token, Instant ExpiresAt);
|
||||||
|
|
||||||
|
public record SingleUrlResponse(string Url);
|
||||||
|
|
||||||
|
public record AddOauthAccountResponse(
|
||||||
|
Snowflake Id,
|
||||||
|
[property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] AuthType Type,
|
||||||
|
string RemoteId,
|
||||||
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? RemoteUsername
|
||||||
|
);
|
||||||
|
|
||||||
|
public record OauthRegisterRequest(string Ticket, string Username);
|
||||||
|
|
||||||
|
public record CallbackRequest(string Code, string State);
|
||||||
|
|
||||||
|
public record EmailLoginRequest(string Email, string Password);
|
||||||
|
|
||||||
|
public record EmailRegisterRequest(string Email);
|
||||||
|
|
||||||
|
public record EmailCompleteRegistrationRequest(string Ticket, string Username, string Password);
|
||||||
|
|
||||||
|
public record EmailCallbackRequest(string State);
|
||||||
|
|
||||||
|
public record EmailChangePasswordRequest(string Current, string New);
|
||||||
|
|
||||||
|
public record FediverseCallbackRequest(string Instance, string Code, string State);
|
6
Foxnouns.Backend/Dto/DataExport.cs
Normal file
6
Foxnouns.Backend/Dto/DataExport.cs
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
// ReSharper disable NotAccessedPositionalProperty.Global
|
||||||
|
using NodaTime;
|
||||||
|
|
||||||
|
namespace Foxnouns.Backend.Dto;
|
||||||
|
|
||||||
|
public record DataExportResponse(string? Url, Instant? ExpiresAt);
|
17
Foxnouns.Backend/Dto/Flag.cs
Normal file
17
Foxnouns.Backend/Dto/Flag.cs
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
// ReSharper disable NotAccessedPositionalProperty.Global
|
||||||
|
using Foxnouns.Backend.Database;
|
||||||
|
using Foxnouns.Backend.Utils;
|
||||||
|
|
||||||
|
namespace Foxnouns.Backend.Dto;
|
||||||
|
|
||||||
|
public record PrideFlagResponse(Snowflake Id, string? ImageUrl, string Name, string? Description);
|
||||||
|
|
||||||
|
public record CreateFlagRequest(string Name, string Image, string? Description);
|
||||||
|
|
||||||
|
public record CreateFlagResponse(Snowflake Id, string Name, string? Description);
|
||||||
|
|
||||||
|
public class UpdateFlagRequest : PatchRequest
|
||||||
|
{
|
||||||
|
public string? Name { get; init; }
|
||||||
|
public string? Description { get; init; }
|
||||||
|
}
|
8
Foxnouns.Backend/Dto/Internal.cs
Normal file
8
Foxnouns.Backend/Dto/Internal.cs
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
// ReSharper disable NotAccessedPositionalProperty.Global
|
||||||
|
using Foxnouns.Backend.Database;
|
||||||
|
|
||||||
|
namespace Foxnouns.Backend.Dto;
|
||||||
|
|
||||||
|
public record RequestDataRequest(string? Token, string Method, string Path);
|
||||||
|
|
||||||
|
public record RequestDataResponse(Snowflake? UserId, string Template);
|
61
Foxnouns.Backend/Dto/Member.cs
Normal file
61
Foxnouns.Backend/Dto/Member.cs
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
// ReSharper disable NotAccessedPositionalProperty.Global
|
||||||
|
using Foxnouns.Backend.Database;
|
||||||
|
using Foxnouns.Backend.Database.Models;
|
||||||
|
using Foxnouns.Backend.Utils;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
namespace Foxnouns.Backend.Dto;
|
||||||
|
|
||||||
|
public record CreateMemberRequest(
|
||||||
|
string Name,
|
||||||
|
string? DisplayName,
|
||||||
|
string? Bio,
|
||||||
|
string? Avatar,
|
||||||
|
bool? Unlisted,
|
||||||
|
string[]? Links,
|
||||||
|
List<FieldEntry>? Names,
|
||||||
|
List<Pronoun>? Pronouns,
|
||||||
|
List<Field>? Fields
|
||||||
|
);
|
||||||
|
|
||||||
|
public class UpdateMemberRequest : PatchRequest
|
||||||
|
{
|
||||||
|
public string? Name { get; init; }
|
||||||
|
public string? DisplayName { get; init; }
|
||||||
|
public string? Bio { get; init; }
|
||||||
|
public string? Avatar { get; init; }
|
||||||
|
public string[]? Links { get; init; }
|
||||||
|
public FieldEntry[]? Names { get; init; }
|
||||||
|
public Pronoun[]? Pronouns { get; init; }
|
||||||
|
public Field[]? Fields { get; init; }
|
||||||
|
public Snowflake[]? Flags { get; init; }
|
||||||
|
public bool? Unlisted { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public record PartialMember(
|
||||||
|
Snowflake Id,
|
||||||
|
string Sid,
|
||||||
|
string Name,
|
||||||
|
string DisplayName,
|
||||||
|
string? Bio,
|
||||||
|
string? AvatarUrl,
|
||||||
|
IEnumerable<FieldEntry> Names,
|
||||||
|
IEnumerable<Pronoun> Pronouns,
|
||||||
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] bool? Unlisted
|
||||||
|
);
|
||||||
|
|
||||||
|
public record MemberResponse(
|
||||||
|
Snowflake Id,
|
||||||
|
string Sid,
|
||||||
|
string Name,
|
||||||
|
string DisplayName,
|
||||||
|
string? Bio,
|
||||||
|
string? AvatarUrl,
|
||||||
|
string[] Links,
|
||||||
|
IEnumerable<FieldEntry> Names,
|
||||||
|
IEnumerable<Pronoun> Pronouns,
|
||||||
|
IEnumerable<Field> Fields,
|
||||||
|
IEnumerable<PrideFlagResponse> Flags,
|
||||||
|
PartialUser User,
|
||||||
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] bool? Unlisted
|
||||||
|
);
|
21
Foxnouns.Backend/Dto/Meta.cs
Normal file
21
Foxnouns.Backend/Dto/Meta.cs
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
// ReSharper disable NotAccessedPositionalProperty.Global
|
||||||
|
namespace Foxnouns.Backend.Dto;
|
||||||
|
|
||||||
|
public record MetaResponse(
|
||||||
|
string Repository,
|
||||||
|
string Version,
|
||||||
|
string Hash,
|
||||||
|
int Members,
|
||||||
|
UserInfoResponse Users,
|
||||||
|
LimitsResponse Limits
|
||||||
|
);
|
||||||
|
|
||||||
|
public record UserInfoResponse(int Total, int ActiveMonth, int ActiveWeek, int ActiveDay);
|
||||||
|
|
||||||
|
public record LimitsResponse(
|
||||||
|
int MemberCount,
|
||||||
|
int BioLength,
|
||||||
|
int CustomPreferences,
|
||||||
|
int MaxAuthMethods,
|
||||||
|
int MaxFlags
|
||||||
|
);
|
82
Foxnouns.Backend/Dto/User.cs
Normal file
82
Foxnouns.Backend/Dto/User.cs
Normal file
|
@ -0,0 +1,82 @@
|
||||||
|
// ReSharper disable NotAccessedPositionalProperty.Global
|
||||||
|
// ReSharper disable ClassNeverInstantiated.Global
|
||||||
|
using Foxnouns.Backend.Database;
|
||||||
|
using Foxnouns.Backend.Database.Models;
|
||||||
|
using Foxnouns.Backend.Utils;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using NodaTime;
|
||||||
|
|
||||||
|
namespace Foxnouns.Backend.Dto;
|
||||||
|
|
||||||
|
public record UserResponse(
|
||||||
|
Snowflake Id,
|
||||||
|
string Sid,
|
||||||
|
string Username,
|
||||||
|
string? DisplayName,
|
||||||
|
string? Bio,
|
||||||
|
string? MemberTitle,
|
||||||
|
string? AvatarUrl,
|
||||||
|
string[] Links,
|
||||||
|
IEnumerable<FieldEntry> Names,
|
||||||
|
IEnumerable<Pronoun> Pronouns,
|
||||||
|
IEnumerable<Field> Fields,
|
||||||
|
Dictionary<Snowflake, User.CustomPreference> CustomPreferences,
|
||||||
|
IEnumerable<PrideFlagResponse> Flags,
|
||||||
|
int? UtcOffset,
|
||||||
|
[property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] UserRole Role,
|
||||||
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||||
|
IEnumerable<PartialMember>? Members,
|
||||||
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||||
|
IEnumerable<AuthMethodResponse>? AuthMethods,
|
||||||
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] bool? MemberListHidden,
|
||||||
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Instant? LastActive,
|
||||||
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Instant? LastSidReroll,
|
||||||
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? Timezone
|
||||||
|
);
|
||||||
|
|
||||||
|
public record AuthMethodResponse(
|
||||||
|
Snowflake Id,
|
||||||
|
[property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] AuthType Type,
|
||||||
|
string RemoteId,
|
||||||
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? RemoteUsername
|
||||||
|
);
|
||||||
|
|
||||||
|
public record PartialUser(
|
||||||
|
Snowflake Id,
|
||||||
|
string Sid,
|
||||||
|
string Username,
|
||||||
|
string? DisplayName,
|
||||||
|
string? AvatarUrl,
|
||||||
|
Dictionary<Snowflake, User.CustomPreference> CustomPreferences
|
||||||
|
);
|
||||||
|
|
||||||
|
public class UpdateUserSettingsRequest : PatchRequest
|
||||||
|
{
|
||||||
|
public bool? DarkMode { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CustomPreferenceUpdateRequest
|
||||||
|
{
|
||||||
|
public Snowflake? Id { get; init; }
|
||||||
|
public required string Icon { get; set; }
|
||||||
|
public required string Tooltip { get; set; }
|
||||||
|
public PreferenceSize Size { get; set; }
|
||||||
|
public bool Muted { get; set; }
|
||||||
|
public bool Favourite { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class UpdateUserRequest : PatchRequest
|
||||||
|
{
|
||||||
|
public string? Username { get; init; }
|
||||||
|
public string? DisplayName { get; init; }
|
||||||
|
public string? Bio { get; init; }
|
||||||
|
public string? Avatar { get; init; }
|
||||||
|
public string[]? Links { get; init; }
|
||||||
|
public FieldEntry[]? Names { get; init; }
|
||||||
|
public Pronoun[]? Pronouns { get; init; }
|
||||||
|
public Field[]? Fields { get; init; }
|
||||||
|
public Snowflake[]? Flags { get; init; }
|
||||||
|
public string? MemberTitle { get; init; }
|
||||||
|
public bool? MemberListHidden { get; init; }
|
||||||
|
public string? Timezone { get; init; }
|
||||||
|
}
|
|
@ -108,6 +108,12 @@ public class CreateDataExportInvocable(
|
||||||
|
|
||||||
private async Task WritePrideFlag(ZipArchive zip, PrideFlag flag)
|
private async Task WritePrideFlag(ZipArchive zip, PrideFlag flag)
|
||||||
{
|
{
|
||||||
|
if (flag.Hash == null)
|
||||||
|
{
|
||||||
|
_logger.Debug("Flag {FlagId} has a null hash, ignoring it", flag.Id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
_logger.Debug("Writing flag {FlagId}", flag.Id);
|
_logger.Debug("Writing flag {FlagId}", flag.Id);
|
||||||
|
|
||||||
var flagData = $"""
|
var flagData = $"""
|
||||||
|
|
|
@ -3,6 +3,7 @@ using Foxnouns.Backend.Database;
|
||||||
using Foxnouns.Backend.Database.Models;
|
using Foxnouns.Backend.Database.Models;
|
||||||
using Foxnouns.Backend.Extensions;
|
using Foxnouns.Backend.Extensions;
|
||||||
using Foxnouns.Backend.Services;
|
using Foxnouns.Backend.Services;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace Foxnouns.Backend.Jobs;
|
namespace Foxnouns.Backend.Jobs;
|
||||||
|
|
||||||
|
@ -26,6 +27,18 @@ public class CreateFlagInvocable(
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
PrideFlag? flag = await db.PrideFlags.FirstOrDefaultAsync(f =>
|
||||||
|
f.Id == Payload.Id && f.UserId == Payload.UserId
|
||||||
|
);
|
||||||
|
if (flag == null)
|
||||||
|
{
|
||||||
|
_logger.Warning(
|
||||||
|
"Got a flag create job for {FlagId} but it doesn't exist, aborting",
|
||||||
|
Payload.Id
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
(string? hash, Stream? image) = await ImageObjectExtensions.ConvertBase64UriToImage(
|
(string? hash, Stream? image) = await ImageObjectExtensions.ConvertBase64UriToImage(
|
||||||
Payload.ImageData,
|
Payload.ImageData,
|
||||||
256,
|
256,
|
||||||
|
@ -33,16 +46,8 @@ public class CreateFlagInvocable(
|
||||||
);
|
);
|
||||||
await objectStorageService.PutObjectAsync(Path(hash), image, "image/webp");
|
await objectStorageService.PutObjectAsync(Path(hash), image, "image/webp");
|
||||||
|
|
||||||
var flag = new PrideFlag
|
flag.Hash = hash;
|
||||||
{
|
db.Update(flag);
|
||||||
Id = Payload.Id,
|
|
||||||
UserId = Payload.UserId,
|
|
||||||
Hash = hash,
|
|
||||||
Name = Payload.Name,
|
|
||||||
Description = Payload.Description,
|
|
||||||
};
|
|
||||||
db.Add(flag);
|
|
||||||
|
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
_logger.Information("Uploaded flag {FlagId} with hash {Hash}", flag.Id, flag.Hash);
|
_logger.Information("Uploaded flag {FlagId} with hash {Hash}", flag.Id, flag.Hash);
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using Foxnouns.Backend.Controllers.Authentication;
|
|
||||||
using Foxnouns.Backend.Database;
|
using Foxnouns.Backend.Database;
|
||||||
using Foxnouns.Backend.Database.Models;
|
using Foxnouns.Backend.Database.Models;
|
||||||
|
using Foxnouns.Backend.Dto;
|
||||||
using Foxnouns.Backend.Utils;
|
using Foxnouns.Backend.Utils;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
using Foxnouns.Backend.Database;
|
using Foxnouns.Backend.Database;
|
||||||
using Foxnouns.Backend.Database.Models;
|
using Foxnouns.Backend.Database.Models;
|
||||||
|
using Foxnouns.Backend.Dto;
|
||||||
using Foxnouns.Backend.Utils;
|
using Foxnouns.Backend.Utils;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
|
@ -49,7 +50,7 @@ public class MemberRendererService(DatabaseContext db, Config config)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private UserRendererService.PartialUser RenderPartialUser(User user) =>
|
private PartialUser RenderPartialUser(User user) =>
|
||||||
new(
|
new(
|
||||||
user.Id,
|
user.Id,
|
||||||
user.Sid,
|
user.Sid,
|
||||||
|
@ -82,36 +83,9 @@ public class MemberRendererService(DatabaseContext db, Config config)
|
||||||
? $"{config.MediaBaseUrl}/users/{user.Id}/avatars/{user.Avatar}.webp"
|
? $"{config.MediaBaseUrl}/users/{user.Id}/avatars/{user.Avatar}.webp"
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
private string ImageUrlFor(PrideFlag flag) => $"{config.MediaBaseUrl}/flags/{flag.Hash}.webp";
|
private string? ImageUrlFor(PrideFlag flag) =>
|
||||||
|
flag.Hash != null ? $"{config.MediaBaseUrl}/flags/{flag.Hash}.webp" : null;
|
||||||
|
|
||||||
private UserRendererService.PrideFlagResponse RenderPrideFlag(PrideFlag flag) =>
|
private PrideFlagResponse RenderPrideFlag(PrideFlag flag) =>
|
||||||
new(flag.Id, ImageUrlFor(flag), flag.Name, flag.Description);
|
new(flag.Id, ImageUrlFor(flag), flag.Name, flag.Description);
|
||||||
|
|
||||||
public record PartialMember(
|
|
||||||
Snowflake Id,
|
|
||||||
string Sid,
|
|
||||||
string Name,
|
|
||||||
string DisplayName,
|
|
||||||
string? Bio,
|
|
||||||
string? AvatarUrl,
|
|
||||||
IEnumerable<FieldEntry> Names,
|
|
||||||
IEnumerable<Pronoun> Pronouns,
|
|
||||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] bool? Unlisted
|
|
||||||
);
|
|
||||||
|
|
||||||
public record MemberResponse(
|
|
||||||
Snowflake Id,
|
|
||||||
string Sid,
|
|
||||||
string Name,
|
|
||||||
string DisplayName,
|
|
||||||
string? Bio,
|
|
||||||
string? AvatarUrl,
|
|
||||||
string[] Links,
|
|
||||||
IEnumerable<FieldEntry> Names,
|
|
||||||
IEnumerable<Pronoun> Pronouns,
|
|
||||||
IEnumerable<Field> Fields,
|
|
||||||
IEnumerable<UserRendererService.PrideFlagResponse> Flags,
|
|
||||||
UserRendererService.PartialUser User,
|
|
||||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] bool? Unlisted
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
using Foxnouns.Backend.Database;
|
using Foxnouns.Backend.Database;
|
||||||
using Foxnouns.Backend.Database.Models;
|
using Foxnouns.Backend.Database.Models;
|
||||||
|
using Foxnouns.Backend.Dto;
|
||||||
using Foxnouns.Backend.Utils;
|
using Foxnouns.Backend.Utils;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
|
@ -130,60 +131,9 @@ public class UserRendererService(
|
||||||
? $"{config.MediaBaseUrl}/users/{user.Id}/avatars/{user.Avatar}.webp"
|
? $"{config.MediaBaseUrl}/users/{user.Id}/avatars/{user.Avatar}.webp"
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
public string ImageUrlFor(PrideFlag flag) => $"{config.MediaBaseUrl}/flags/{flag.Hash}.webp";
|
public string? ImageUrlFor(PrideFlag flag) =>
|
||||||
|
flag.Hash != null ? $"{config.MediaBaseUrl}/flags/{flag.Hash}.webp" : null;
|
||||||
public record UserResponse(
|
|
||||||
Snowflake Id,
|
|
||||||
string Sid,
|
|
||||||
string Username,
|
|
||||||
string? DisplayName,
|
|
||||||
string? Bio,
|
|
||||||
string? MemberTitle,
|
|
||||||
string? AvatarUrl,
|
|
||||||
string[] Links,
|
|
||||||
IEnumerable<FieldEntry> Names,
|
|
||||||
IEnumerable<Pronoun> Pronouns,
|
|
||||||
IEnumerable<Field> Fields,
|
|
||||||
Dictionary<Snowflake, User.CustomPreference> CustomPreferences,
|
|
||||||
IEnumerable<PrideFlagResponse> Flags,
|
|
||||||
int? UtcOffset,
|
|
||||||
[property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] UserRole Role,
|
|
||||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
|
||||||
IEnumerable<MemberRendererService.PartialMember>? Members,
|
|
||||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
|
||||||
IEnumerable<AuthMethodResponse>? AuthMethods,
|
|
||||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
|
||||||
bool? MemberListHidden,
|
|
||||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Instant? LastActive,
|
|
||||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
|
||||||
Instant? LastSidReroll,
|
|
||||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? Timezone
|
|
||||||
);
|
|
||||||
|
|
||||||
public record AuthMethodResponse(
|
|
||||||
Snowflake Id,
|
|
||||||
[property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] AuthType Type,
|
|
||||||
string RemoteId,
|
|
||||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
|
||||||
string? RemoteUsername
|
|
||||||
);
|
|
||||||
|
|
||||||
public record PartialUser(
|
|
||||||
Snowflake Id,
|
|
||||||
string Sid,
|
|
||||||
string Username,
|
|
||||||
string? DisplayName,
|
|
||||||
string? AvatarUrl,
|
|
||||||
Dictionary<Snowflake, User.CustomPreference> CustomPreferences
|
|
||||||
);
|
|
||||||
|
|
||||||
public PrideFlagResponse RenderPrideFlag(PrideFlag flag) =>
|
public PrideFlagResponse RenderPrideFlag(PrideFlag flag) =>
|
||||||
new(flag.Id, ImageUrlFor(flag), flag.Name, flag.Description);
|
new(flag.Id, ImageUrlFor(flag), flag.Name, flag.Description);
|
||||||
|
|
||||||
public record PrideFlagResponse(
|
|
||||||
Snowflake Id,
|
|
||||||
string ImageUrl,
|
|
||||||
string Name,
|
|
||||||
string? Description
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,6 +14,7 @@
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
using Foxnouns.Backend.Controllers;
|
using Foxnouns.Backend.Controllers;
|
||||||
|
using Foxnouns.Backend.Dto;
|
||||||
|
|
||||||
namespace Foxnouns.Backend.Utils;
|
namespace Foxnouns.Backend.Utils;
|
||||||
|
|
||||||
|
@ -23,7 +24,7 @@ public static partial class ValidationUtils
|
||||||
public const int MaxPreferenceTooltipLength = 128;
|
public const int MaxPreferenceTooltipLength = 128;
|
||||||
|
|
||||||
public static List<(string, ValidationError?)> ValidateCustomPreferences(
|
public static List<(string, ValidationError?)> ValidateCustomPreferences(
|
||||||
List<UsersController.CustomPreferenceUpdate> preferences
|
List<CustomPreferenceUpdateRequest> preferences
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var errors = new List<(string, ValidationError?)>();
|
var errors = new List<(string, ValidationError?)>();
|
||||||
|
@ -46,11 +47,7 @@ public static partial class ValidationUtils
|
||||||
if (preferences.Count > 50)
|
if (preferences.Count > 50)
|
||||||
return errors;
|
return errors;
|
||||||
|
|
||||||
foreach (
|
foreach ((CustomPreferenceUpdateRequest? p, int i) in preferences.Select((p, i) => (p, i)))
|
||||||
(UsersController.CustomPreferenceUpdate? p, int i) in preferences.Select(
|
|
||||||
(p, i) => (p, i)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
if (!BootstrapIcons.IsValid(p.Icon))
|
if (!BootstrapIcons.IsValid(p.Icon))
|
||||||
{
|
{
|
||||||
|
|
|
@ -29,6 +29,7 @@
|
||||||
"prettier-plugin-svelte": "^3.2.6",
|
"prettier-plugin-svelte": "^3.2.6",
|
||||||
"sass": "^1.81.0",
|
"sass": "^1.81.0",
|
||||||
"svelte": "^5.0.0",
|
"svelte": "^5.0.0",
|
||||||
|
"svelte-bootstrap-icons": "^3.1.1",
|
||||||
"svelte-check": "^4.0.0",
|
"svelte-check": "^4.0.0",
|
||||||
"sveltekit-i18n": "^2.4.2",
|
"sveltekit-i18n": "^2.4.2",
|
||||||
"typescript": "^5.0.0",
|
"typescript": "^5.0.0",
|
||||||
|
|
8
Foxnouns.Frontend/pnpm-lock.yaml
generated
8
Foxnouns.Frontend/pnpm-lock.yaml
generated
|
@ -93,6 +93,9 @@ importers:
|
||||||
svelte:
|
svelte:
|
||||||
specifier: ^5.0.0
|
specifier: ^5.0.0
|
||||||
version: 5.2.2
|
version: 5.2.2
|
||||||
|
svelte-bootstrap-icons:
|
||||||
|
specifier: ^3.1.1
|
||||||
|
version: 3.1.1
|
||||||
svelte-check:
|
svelte-check:
|
||||||
specifier: ^4.0.0
|
specifier: ^4.0.0
|
||||||
version: 4.0.9(picomatch@4.0.2)(svelte@5.2.2)(typescript@5.6.3)
|
version: 4.0.9(picomatch@4.0.2)(svelte@5.2.2)(typescript@5.6.3)
|
||||||
|
@ -1321,6 +1324,9 @@ packages:
|
||||||
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
|
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
svelte-bootstrap-icons@3.1.1:
|
||||||
|
resolution: {integrity: sha512-ghJlt6TX3IX35M7wSvGyrmVgXeT5GMRF+7+q6L4OUT2RJWF09mQIvZTZ04Ii3FBfg10KdzFdvVuoB8M0cVHfzw==}
|
||||||
|
|
||||||
svelte-check@4.0.9:
|
svelte-check@4.0.9:
|
||||||
resolution: {integrity: sha512-SVNCz2L+9ZELGli7G0n3B3QE5kdf0u27RtKr2ZivWQhcWIXatZxwM4VrQ6AiA2k9zKp2mk5AxkEhdjbpjv7rEw==}
|
resolution: {integrity: sha512-SVNCz2L+9ZELGli7G0n3B3QE5kdf0u27RtKr2ZivWQhcWIXatZxwM4VrQ6AiA2k9zKp2mk5AxkEhdjbpjv7rEw==}
|
||||||
engines: {node: '>= 18.0.0'}
|
engines: {node: '>= 18.0.0'}
|
||||||
|
@ -2564,6 +2570,8 @@ snapshots:
|
||||||
|
|
||||||
supports-preserve-symlinks-flag@1.0.0: {}
|
supports-preserve-symlinks-flag@1.0.0: {}
|
||||||
|
|
||||||
|
svelte-bootstrap-icons@3.1.1: {}
|
||||||
|
|
||||||
svelte-check@4.0.9(picomatch@4.0.2)(svelte@5.2.2)(typescript@5.6.3):
|
svelte-check@4.0.9(picomatch@4.0.2)(svelte@5.2.2)(typescript@5.6.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/trace-mapping': 0.3.25
|
'@jridgewell/trace-mapping': 0.3.25
|
||||||
|
|
|
@ -17,4 +17,5 @@ export type Limits = {
|
||||||
bio_length: number;
|
bio_length: number;
|
||||||
custom_preferences: number;
|
custom_preferences: number;
|
||||||
max_auth_methods: number;
|
max_auth_methods: number;
|
||||||
|
max_flags: number;
|
||||||
};
|
};
|
||||||
|
|
|
@ -66,7 +66,7 @@ export type Field = {
|
||||||
|
|
||||||
export type PrideFlag = {
|
export type PrideFlag = {
|
||||||
id: string;
|
id: string;
|
||||||
image_url: string;
|
image_url: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
};
|
};
|
||||||
|
|
37
Foxnouns.Frontend/src/lib/components/ClientPaginator.svelte
Normal file
37
Foxnouns.Frontend/src/lib/components/ClientPaginator.svelte
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { Pagination, PaginationItem, PaginationLink } from "@sveltestrap/sveltestrap";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
currentPage: number;
|
||||||
|
pageCount: number;
|
||||||
|
center?: boolean;
|
||||||
|
};
|
||||||
|
let { currentPage = $bindable(), pageCount, center }: Props = $props();
|
||||||
|
|
||||||
|
let prevPage = $derived(currentPage > 0 ? currentPage - 1 : 0);
|
||||||
|
let nextPage = $derived(currentPage < pageCount - 1 ? currentPage + 1 : pageCount - 1);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if pageCount > 1}
|
||||||
|
<div>
|
||||||
|
<Pagination listClassName={center ? "justify-content-center" : undefined}>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationLink first onclick={() => (currentPage = 0)} />
|
||||||
|
</PaginationItem>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationLink previous onclick={() => (currentPage = prevPage)} />
|
||||||
|
</PaginationItem>
|
||||||
|
{#each new Array(pageCount) as _, page}
|
||||||
|
<PaginationItem active={page === currentPage}>
|
||||||
|
<PaginationLink onclick={() => (currentPage = page)}>{page + 1}</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
|
{/each}
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationLink next onclick={() => (currentPage = nextPage)} />
|
||||||
|
</PaginationItem>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationLink last onclick={() => (currentPage = pageCount - 1)} />
|
||||||
|
</PaginationItem>
|
||||||
|
</Pagination>
|
||||||
|
</div>
|
||||||
|
{/if}
|
|
@ -10,11 +10,18 @@
|
||||||
type?: "submit" | "reset" | "button";
|
type?: "submit" | "reset" | "button";
|
||||||
id?: string;
|
id?: string;
|
||||||
onclick?: MouseEventHandler<HTMLButtonElement>;
|
onclick?: MouseEventHandler<HTMLButtonElement>;
|
||||||
|
outline?: boolean;
|
||||||
};
|
};
|
||||||
let { icon, tooltip, color = "primary", type, id, onclick }: Props = $props();
|
let { icon, tooltip, color = "primary", type, id, onclick, outline }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button {id} {type} use:tippy={{ content: tooltip }} class="btn btn-{color}" {onclick}>
|
<button
|
||||||
|
{id}
|
||||||
|
{type}
|
||||||
|
use:tippy={{ content: tooltip }}
|
||||||
|
class="btn {outline ? `btn-outline-${color}` : `btn-${color}`}"
|
||||||
|
{onclick}
|
||||||
|
>
|
||||||
<Icon name={icon} />
|
<Icon name={icon} />
|
||||||
<span class="visually-hidden">{tooltip}</span>
|
<span class="visually-hidden">{tooltip}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
|
@ -0,0 +1,17 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { PrideFlag } from "$api/models/user";
|
||||||
|
import { DEFAULT_FLAG } from "$lib";
|
||||||
|
|
||||||
|
type Props = { flag: PrideFlag };
|
||||||
|
let { flag }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<img class="flag" src={flag.image_url ?? DEFAULT_FLAG} alt={flag.description ?? flag.name} />
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.flag {
|
||||||
|
height: 1.5rem;
|
||||||
|
max-width: 200px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,35 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { PrideFlag } from "$api/models";
|
||||||
|
import type { MouseEventHandler } from "svelte/elements";
|
||||||
|
import EditorFlagImage from "./EditorFlagImage.svelte";
|
||||||
|
import tippy from "$lib/tippy";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
flag: PrideFlag;
|
||||||
|
tooltip?: string;
|
||||||
|
class?: string;
|
||||||
|
onclick: MouseEventHandler<HTMLButtonElement>;
|
||||||
|
padding?: boolean;
|
||||||
|
};
|
||||||
|
let { flag, tooltip, class: className, onclick, padding }: Props = $props();
|
||||||
|
|
||||||
|
let tip = $derived(tooltip ? tippy : () => {});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-outline-secondary {className || ''}"
|
||||||
|
class:padding
|
||||||
|
{onclick}
|
||||||
|
use:tip={{ content: tooltip }}
|
||||||
|
>
|
||||||
|
<EditorFlagImage {flag} />
|
||||||
|
{flag.name}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.padding {
|
||||||
|
margin-right: 5px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,57 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { PrideFlag } from "$api/models";
|
||||||
|
import { DEFAULT_FLAG } from "$lib";
|
||||||
|
import { t } from "$lib/i18n";
|
||||||
|
|
||||||
|
type UpdateParams = { name: string; description: string | null };
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
flag: PrideFlag;
|
||||||
|
update(id: string, params: UpdateParams): Promise<void>;
|
||||||
|
deleteFlag(id: string): Promise<void>;
|
||||||
|
};
|
||||||
|
let { flag, update, deleteFlag }: Props = $props();
|
||||||
|
|
||||||
|
let name = $state(flag.name);
|
||||||
|
let description = $state(flag.description);
|
||||||
|
|
||||||
|
const saveChanges = () =>
|
||||||
|
update(flag.id, { name, description: description ? description : null });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="d-flex">
|
||||||
|
<span class="me-3">
|
||||||
|
<img class="flag" src={flag.image_url ?? DEFAULT_FLAG} alt={flag.description ?? flag.name} />
|
||||||
|
</span>
|
||||||
|
<div class="w-lg-50">
|
||||||
|
<input
|
||||||
|
class="mb-2 form-control"
|
||||||
|
placeholder={$t("settings.flag-name-placeholder")}
|
||||||
|
bind:value={name}
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
class="mb-2 form-control"
|
||||||
|
style="height: 5rem;"
|
||||||
|
placeholder={$t("settings.flag-description-placeholder")}
|
||||||
|
bind:value={description}
|
||||||
|
autocomplete="off"
|
||||||
|
></textarea>
|
||||||
|
<div class="btn-group">
|
||||||
|
<button type="button" class="btn btn-primary" onclick={saveChanges}>
|
||||||
|
{$t("save-changes")}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-danger" onclick={() => deleteFlag(flag.id)}>
|
||||||
|
{$t("settings.flag-delete-button")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.flag {
|
||||||
|
max-height: 6rem;
|
||||||
|
max-width: 256px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,48 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { PrideFlag } from "$api/models";
|
||||||
|
import InfoCircleFill from "svelte-bootstrap-icons/lib/InfoCircleFill.svelte";
|
||||||
|
import Search from "svelte-bootstrap-icons/lib/Search.svelte";
|
||||||
|
import FlagButton from "./FlagButton.svelte";
|
||||||
|
import { t } from "$lib/i18n";
|
||||||
|
|
||||||
|
type Props = { flags: PrideFlag[]; select(flag: PrideFlag): void };
|
||||||
|
let { flags, select }: Props = $props();
|
||||||
|
|
||||||
|
let query = $state("");
|
||||||
|
let filteredFlags = $derived(search(query));
|
||||||
|
|
||||||
|
function search(q: string) {
|
||||||
|
if (!q) return flags.slice(0, 20);
|
||||||
|
return flags.filter((f) => f.name.toLowerCase().indexOf(q.toLowerCase()) !== -1).slice(0, 20);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<input class="form-control" placeholder={$t("editor.flag-search-placeholder")} bind:value={query} />
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
{#each filteredFlags as flag (flag.id)}
|
||||||
|
<FlagButton {flag} onclick={() => select(flag)} padding />
|
||||||
|
{:else}
|
||||||
|
<div class="text-secondary text-center">
|
||||||
|
<p>
|
||||||
|
<Search class="no-flags-icon" height={64} width={64} aria-hidden />
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{#if query}
|
||||||
|
{$t("editor.flag-search-no-flags")}
|
||||||
|
{:else}
|
||||||
|
{$t("editor.flag-search-no-account-flags")}
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{#if flags.length > 0}
|
||||||
|
<p class="text-secondary mt-2">
|
||||||
|
<InfoCircleFill aria-hidden />
|
||||||
|
{$t("editor.flag-search-hint")}
|
||||||
|
<a href="/settings/flags">{$t("editor.flag-manage-your-flags")}</a>
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<p><a href="/settings/flags">{$t("editor.flag-manage-your-flags")}</a></p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
|
@ -0,0 +1,95 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { RawApiError } from "$api/error";
|
||||||
|
import type { PrideFlag } from "$api/models";
|
||||||
|
import FlagSearch from "$components/editor/FlagSearch.svelte";
|
||||||
|
import IconButton from "$components/IconButton.svelte";
|
||||||
|
import { t } from "$lib/i18n";
|
||||||
|
import FlagButton from "./FlagButton.svelte";
|
||||||
|
import FormStatusMarker from "./FormStatusMarker.svelte";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
profileFlags: PrideFlag[];
|
||||||
|
allFlags: PrideFlag[];
|
||||||
|
save(flags: string[]): Promise<void>;
|
||||||
|
form: { ok: boolean; error: RawApiError | null } | null;
|
||||||
|
};
|
||||||
|
let { profileFlags, allFlags, save, form }: Props = $props();
|
||||||
|
|
||||||
|
let flags = $state(profileFlags);
|
||||||
|
|
||||||
|
const select = (flag: PrideFlag) => {
|
||||||
|
flags = [...flags, flag];
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFlag = (flag: PrideFlag) => {
|
||||||
|
const idx = flags.indexOf(flag);
|
||||||
|
if (idx === -1) return;
|
||||||
|
flags.splice(idx, 1);
|
||||||
|
flags = [...flags];
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveFlag = (index: number, up: boolean) => {
|
||||||
|
if (up && index == 0) return;
|
||||||
|
if (!up && index == flags.length - 1) return;
|
||||||
|
|
||||||
|
const newIndex = up ? index - 1 : index + 1;
|
||||||
|
const temp = flags[index];
|
||||||
|
flags[index] = flags[newIndex];
|
||||||
|
flags[newIndex] = temp;
|
||||||
|
flags = [...flags];
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveChanges = () => save(flags.map((f) => f.id));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md">
|
||||||
|
<h4>
|
||||||
|
{$t("settings.flag-title")}
|
||||||
|
<button type="button" class="btn btn-primary" onclick={() => saveChanges()}>
|
||||||
|
{$t("save-changes")}
|
||||||
|
</button>
|
||||||
|
</h4>
|
||||||
|
<FormStatusMarker {form} />
|
||||||
|
{#each flags as flag, i}
|
||||||
|
<div class="d-block">
|
||||||
|
<div class="btn-group flag-group">
|
||||||
|
<IconButton
|
||||||
|
icon="chevron-up"
|
||||||
|
tooltip={$t("editor.move-flag-up")}
|
||||||
|
color="secondary"
|
||||||
|
outline
|
||||||
|
onclick={() => moveFlag(i, true)}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
icon="chevron-down"
|
||||||
|
tooltip={$t("editor.move-flag-down")}
|
||||||
|
color="secondary"
|
||||||
|
outline
|
||||||
|
onclick={() => moveFlag(i, false)}
|
||||||
|
/>
|
||||||
|
<FlagButton
|
||||||
|
{flag}
|
||||||
|
onclick={() => removeFlag(flag)}
|
||||||
|
tooltip={$t("editor.remove-this-flag")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="text-secondary">
|
||||||
|
{$t("editor.no-flags-hint")}
|
||||||
|
</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="col-md">
|
||||||
|
<h4>{$t("editor.add-flags-header")}</h4>
|
||||||
|
<FlagSearch flags={allFlags} {select} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.flag-group {
|
||||||
|
margin-right: 5px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -1,5 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PrideFlag } from "$api/models/user";
|
import type { PrideFlag } from "$api/models/user";
|
||||||
|
import { DEFAULT_FLAG } from "$lib";
|
||||||
import tippy from "$lib/tippy";
|
import tippy from "$lib/tippy";
|
||||||
|
|
||||||
type Props = { flag: PrideFlag };
|
type Props = { flag: PrideFlag };
|
||||||
|
@ -10,7 +11,7 @@
|
||||||
<img
|
<img
|
||||||
use:tippy={{ content: flag.description ?? flag.name }}
|
use:tippy={{ content: flag.description ?? flag.name }}
|
||||||
class="flag"
|
class="flag"
|
||||||
src={flag.image_url}
|
src={flag.image_url ?? DEFAULT_FLAG}
|
||||||
alt={flag.description ?? flag.name}
|
alt={flag.description ?? flag.name}
|
||||||
/>
|
/>
|
||||||
{flag.name}
|
{flag.name}
|
||||||
|
|
|
@ -1,188 +1,208 @@
|
||||||
{
|
{
|
||||||
"hello": "Hello, {{name}}!",
|
"hello": "Hello, {{name}}!",
|
||||||
"nav": {
|
"nav": {
|
||||||
"log-in": "Log in or sign up",
|
"log-in": "Log in or sign up",
|
||||||
"settings": "Settings"
|
"settings": "Settings"
|
||||||
},
|
},
|
||||||
"avatar-tooltip": "Avatar for {{name}}",
|
"avatar-tooltip": "Avatar for {{name}}",
|
||||||
"profile": {
|
"profile": {
|
||||||
"edit-member-profile-notice": "You are currently viewing the public profile of {{memberName}}.",
|
"edit-member-profile-notice": "You are currently viewing the public profile of {{memberName}}.",
|
||||||
"edit-user-profile-notice": "You are currently viewing your public profile.",
|
"edit-user-profile-notice": "You are currently viewing your public profile.",
|
||||||
"edit-profile-link": "Edit profile",
|
"edit-profile-link": "Edit profile",
|
||||||
"names-header": "Names",
|
"names-header": "Names",
|
||||||
"pronouns-header": "Pronouns",
|
"pronouns-header": "Pronouns",
|
||||||
"default-members-header": "Members",
|
"default-members-header": "Members",
|
||||||
"create-member-button": "Create member",
|
"create-member-button": "Create member",
|
||||||
"back-to-user": "Back to {{name}}"
|
"back-to-user": "Back to {{name}}"
|
||||||
},
|
},
|
||||||
"title": {
|
"title": {
|
||||||
"log-in": "Log in",
|
"log-in": "Log in",
|
||||||
"welcome": "Welcome",
|
"welcome": "Welcome",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"an-error-occurred": "An error occurred"
|
"an-error-occurred": "An error occurred"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"log-in-form-title": "Log in with email",
|
"log-in-form-title": "Log in with email",
|
||||||
"log-in-form-email-label": "Email address",
|
"log-in-form-email-label": "Email address",
|
||||||
"log-in-form-password-label": "Password",
|
"log-in-form-password-label": "Password",
|
||||||
"register-with-email-button": "Register with email",
|
"register-with-email-button": "Register with email",
|
||||||
"log-in-button": "Log in",
|
"log-in-button": "Log in",
|
||||||
"log-in-3rd-party-header": "Log in with another service",
|
"log-in-3rd-party-header": "Log in with another service",
|
||||||
"log-in-3rd-party-desc": "If you prefer, you can also log in with one of these services:",
|
"log-in-3rd-party-desc": "If you prefer, you can also log in with one of these services:",
|
||||||
"log-in-with-discord": "Log in with Discord",
|
"log-in-with-discord": "Log in with Discord",
|
||||||
"log-in-with-google": "Log in with Google",
|
"log-in-with-google": "Log in with Google",
|
||||||
"log-in-with-tumblr": "Log in with Tumblr",
|
"log-in-with-tumblr": "Log in with Tumblr",
|
||||||
"log-in-with-the-fediverse": "Log in with the Fediverse",
|
"log-in-with-the-fediverse": "Log in with the Fediverse",
|
||||||
"remote-fediverse-account-label": "Your Fediverse account",
|
"remote-fediverse-account-label": "Your Fediverse account",
|
||||||
"register-username-label": "Username",
|
"register-username-label": "Username",
|
||||||
"register-button": "Register account",
|
"register-button": "Register account",
|
||||||
"register-with-mastodon": "Register with a Fediverse account",
|
"register-with-mastodon": "Register with a Fediverse account",
|
||||||
"log-in-with-fediverse-error-blurb": "Is your instance returning an error?",
|
"log-in-with-fediverse-error-blurb": "Is your instance returning an error?",
|
||||||
"log-in-with-fediverse-force-refresh-button": "Force a refresh on our end",
|
"log-in-with-fediverse-force-refresh-button": "Force a refresh on our end",
|
||||||
"register-with-discord": "Register with a Discord account",
|
"register-with-discord": "Register with a Discord account",
|
||||||
"new-auth-method-added": "Successfully added authentication method!",
|
"new-auth-method-added": "Successfully added authentication method!",
|
||||||
"successful-link-discord": "Your account has successfully been linked to the following Discord account:",
|
"successful-link-discord": "Your account has successfully been linked to the following Discord account:",
|
||||||
"successful-link-google": "Your account has successfully been linked to the following Google account:",
|
"successful-link-google": "Your account has successfully been linked to the following Google account:",
|
||||||
"successful-link-tumblr": "Your account has successfully been linked to the following Tumblr account:",
|
"successful-link-tumblr": "Your account has successfully been linked to the following Tumblr account:",
|
||||||
"successful-link-fedi": "Your account has successfully been linked to the following fediverse account:",
|
"successful-link-fedi": "Your account has successfully been linked to the following fediverse account:",
|
||||||
"successful-link-profile-hint": "You now can close this page, or go back to your profile:",
|
"successful-link-profile-hint": "You now can close this page, or go back to your profile:",
|
||||||
"successful-link-profile-link": "Go to your profile",
|
"successful-link-profile-link": "Go to your profile",
|
||||||
"remote-discord-account-label": "Your Discord account",
|
"remote-discord-account-label": "Your Discord account",
|
||||||
"log-in-with-fediverse-instance-placeholder": "Your instance (i.e. mastodon.social)",
|
"log-in-with-fediverse-instance-placeholder": "Your instance (i.e. mastodon.social)",
|
||||||
"register-with-email": "Register with an email address",
|
"register-with-email": "Register with an email address",
|
||||||
"email-label": "Your email address",
|
"email-label": "Your email address",
|
||||||
"confirm-password-label": "Confirm password",
|
"confirm-password-label": "Confirm password",
|
||||||
"register-with-email-init-success": "Success! An email has been sent to your inbox, please press the link there to continue."
|
"register-with-email-init-success": "Success! An email has been sent to your inbox, please press the link there to continue."
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"bad-request-header": "Something was wrong with your input",
|
"bad-request-header": "Something was wrong with your input",
|
||||||
"generic-header": "Something went wrong",
|
"generic-header": "Something went wrong",
|
||||||
"raw-header": "Raw error",
|
"raw-header": "Raw error",
|
||||||
"authentication-error": "Something went wrong when logging you in.",
|
"authentication-error": "Something went wrong when logging you in.",
|
||||||
"bad-request": "Your input was rejected by the server, please check for any mistakes and try again.",
|
"bad-request": "Your input was rejected by the server, please check for any mistakes and try again.",
|
||||||
"forbidden": "You are not allowed to perform that action.",
|
"forbidden": "You are not allowed to perform that action.",
|
||||||
"internal-server-error": "Server experienced an internal error, please try again later.",
|
"internal-server-error": "Server experienced an internal error, please try again later.",
|
||||||
"authentication-required": "You need to log in first.",
|
"authentication-required": "You need to log in first.",
|
||||||
"missing-scopes": "The current token is missing a required scope. Did you manually edit your cookies?",
|
"missing-scopes": "The current token is missing a required scope. Did you manually edit your cookies?",
|
||||||
"generic-error": "An unknown error occurred.",
|
"generic-error": "An unknown error occurred.",
|
||||||
"user-not-found": "User not found, please check your spelling and try again. Remember that usernames are case sensitive.",
|
"user-not-found": "User not found, please check your spelling and try again. Remember that usernames are case sensitive.",
|
||||||
"member-not-found": "Member not found, please check your spelling and try again.",
|
"member-not-found": "Member not found, please check your spelling and try again.",
|
||||||
"account-already-linked": "This account is already linked with a pronouns.cc account.",
|
"account-already-linked": "This account is already linked with a pronouns.cc account.",
|
||||||
"last-auth-method": "You cannot remove your last authentication method.",
|
"last-auth-method": "You cannot remove your last authentication method.",
|
||||||
"validation-max-length-error": "Value is too long, maximum length is {{max}}, current length is {{actual}}.",
|
"validation-max-length-error": "Value is too long, maximum length is {{max}}, current length is {{actual}}.",
|
||||||
"validation-min-length-error": "Value is too short, minimum length is {{min}}, current length is {{actual}}.",
|
"validation-min-length-error": "Value is too short, minimum length is {{min}}, current length is {{actual}}.",
|
||||||
"validation-disallowed-value-1": "The following value is not allowed here",
|
"validation-disallowed-value-1": "The following value is not allowed here",
|
||||||
"validation-disallowed-value-2": "Allowed values are",
|
"validation-disallowed-value-2": "Allowed values are",
|
||||||
"validation-reason": "Reason",
|
"validation-reason": "Reason",
|
||||||
"validation-generic": "The value you entered is not allowed here. Reason",
|
"validation-generic": "The value you entered is not allowed here. Reason",
|
||||||
"extra-info-header": "Extra error information",
|
"extra-info-header": "Extra error information",
|
||||||
"noscript-title": "This page requires JavaScript",
|
"noscript-title": "This page requires JavaScript",
|
||||||
"noscript-info": "This page requires JavaScript to function correctly. Some buttons may not work, or the page may not work at all.",
|
"noscript-info": "This page requires JavaScript to function correctly. Some buttons may not work, or the page may not work at all.",
|
||||||
"noscript-short": "Requires JavaScript",
|
"noscript-short": "Requires JavaScript",
|
||||||
"404-description": "The page you were trying to visit was not found. If you're sure the page should exist, check your address bar for any typos.",
|
"404-description": "The page you were trying to visit was not found. If you're sure the page should exist, check your address bar for any typos.",
|
||||||
"back-to-profile-button": "Go back to your profile",
|
"back-to-profile-button": "Go back to your profile",
|
||||||
"back-to-main-page-button": "Go back to the main page",
|
"back-to-main-page-button": "Go back to the main page",
|
||||||
"back-to-prev-page-button": "Go back to the previous page",
|
"back-to-prev-page-button": "Go back to the previous page",
|
||||||
"400-description": "Something went wrong with your request. This error should never land you on this page, so it's probably a bug.",
|
"400-description": "Something went wrong with your request. This error should never land you on this page, so it's probably a bug.",
|
||||||
"500-description": "Something went wrong on the server. Please try again later.",
|
"500-description": "Something went wrong on the server. Please try again later.",
|
||||||
"unknown-status-description": "Something went wrong, but we're not sure what. Please try again."
|
"unknown-status-description": "Something went wrong, but we're not sure what. Please try again."
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"general-information-tab": "General information",
|
"general-information-tab": "General information",
|
||||||
"your-profile-tab": "Your profile",
|
"your-profile-tab": "Your profile",
|
||||||
"members-tab": "Members",
|
"members-tab": "Members",
|
||||||
"authentication-tab": "Authentication",
|
"authentication-tab": "Authentication",
|
||||||
"export-tab": "Export your data",
|
"export-tab": "Export your data",
|
||||||
"change-username-button": "Change username",
|
"change-username-button": "Change username",
|
||||||
"username-change-hint": "Changing your username will make any existing links to your or your members' profiles invalid.\nYour username must be unique, be at most 40 characters long, and only contain letters from the basic English alphabet, dashes, underscores, and periods. Your username is used as part of your profile link, you can set a separate display name.",
|
"username-change-hint": "Changing your username will make any existing links to your or your members' profiles invalid.\nYour username must be unique, be at most 40 characters long, and only contain letters from the basic English alphabet, dashes, underscores, and periods. Your username is used as part of your profile link, you can set a separate display name.",
|
||||||
"username-update-error": "Could not update your username as the new username is invalid:\n{{message}}",
|
"username-update-error": "Could not update your username as the new username is invalid:\n{{message}}",
|
||||||
"change-avatar-link": "Change your avatar here",
|
"change-avatar-link": "Change your avatar here",
|
||||||
"new-username": "New username",
|
"new-username": "New username",
|
||||||
"table-role": "Role",
|
"table-role": "Role",
|
||||||
"table-custom-preferences": "Custom preferences",
|
"table-custom-preferences": "Custom preferences",
|
||||||
"table-member-list-hidden": "Member list hidden?",
|
"table-member-list-hidden": "Member list hidden?",
|
||||||
"table-member-count": "Member count",
|
"table-member-count": "Member count",
|
||||||
"table-created-at": "Account created at",
|
"table-created-at": "Account created at",
|
||||||
"table-id": "Your ID",
|
"table-id": "Your ID",
|
||||||
"table-title": "Account information",
|
"table-title": "Account information",
|
||||||
"force-log-out-title": "Log out everywhere",
|
"force-log-out-title": "Log out everywhere",
|
||||||
"force-log-out-button": "Force log out",
|
"force-log-out-button": "Force log out",
|
||||||
"force-log-out-hint": "If you think one of your tokens might have been compromised, you can log out on all devices by clicking this button.",
|
"force-log-out-hint": "If you think one of your tokens might have been compromised, you can log out on all devices by clicking this button.",
|
||||||
"log-out-title": "Log out",
|
"log-out-title": "Log out",
|
||||||
"log-out-hint": "Use this button to log out on this device only.",
|
"log-out-hint": "Use this button to log out on this device only.",
|
||||||
"log-out-button": "Log out",
|
"log-out-button": "Log out",
|
||||||
"avatar": "Avatar",
|
"avatar": "Avatar",
|
||||||
"username-update-success": "Successfully changed your username!",
|
"username-update-success": "Successfully changed your username!",
|
||||||
"create-member-title": "Create a new member",
|
"create-member-title": "Create a new member",
|
||||||
"create-member-name-label": "Member name",
|
"create-member-name-label": "Member name",
|
||||||
"auth-remove-method": "Remove",
|
"auth-remove-method": "Remove",
|
||||||
"force-log-out-warning": "Make sure you're still able to log in before using this!",
|
"force-log-out-warning": "Make sure you're still able to log in before using this!",
|
||||||
"force-log-out-confirmation": "Are you sure you want to log out from all devices? If you just want to log out from this device, click the \"Log out\" button on your settings page.",
|
"force-log-out-confirmation": "Are you sure you want to log out from all devices? If you just want to log out from this device, click the \"Log out\" button on your settings page.",
|
||||||
"export-request-success": "Successfully requested a new export! Please note that it may take a few minutes to complete, especially if you have a lot of members.",
|
"export-request-success": "Successfully requested a new export! Please note that it may take a few minutes to complete, especially if you have a lot of members.",
|
||||||
"export-title": "Request a copy of your data",
|
"export-title": "Request a copy of your data",
|
||||||
"export-info": "You can request a copy of your data once every 24 hours. Exports are stored for 15 days (a little over two weeks) and then deleted.",
|
"export-info": "You can request a copy of your data once every 24 hours. Exports are stored for 15 days (a little over two weeks) and then deleted.",
|
||||||
"export-expires-at": "(expires {{expiresAt}})",
|
"export-expires-at": "(expires {{expiresAt}})",
|
||||||
"export-download": "Download export",
|
"export-download": "Download export",
|
||||||
"export-request-button": "Request a new export"
|
"export-request-button": "Request a new export",
|
||||||
},
|
"flag-delete-button": "Delete flag",
|
||||||
"yes": "Yes",
|
"flag-current-flags-title": "Current flags ({{count}}/{{max}})",
|
||||||
"no": "No",
|
"flag-title": "Flags",
|
||||||
"edit-profile": {
|
"flag-upload-title": "Upload a new flag",
|
||||||
"user-header": "Editing your profile",
|
"flag-upload-button": "Upload",
|
||||||
"general-tab": "General",
|
"flag-description-placeholder": "Description",
|
||||||
"names-pronouns-tab": "Names & pronouns",
|
"flag-name-placeholder": "Name",
|
||||||
"file-too-large": "This file is too large, please resize it (maximum is {{max}}, the file you're trying to upload is {{current}})",
|
"flag-upload-success": "Successfully uploaded your flag! It may take a few seconds before it's saved."
|
||||||
"sid-current": "Current short ID:",
|
},
|
||||||
"sid": "Short ID",
|
"yes": "Yes",
|
||||||
"sid-reroll": "Reroll short ID",
|
"no": "No",
|
||||||
"sid-hint": "This ID is used in prns.cc links. You can reroll one short ID every hour (shared between your main profile and all members) by pressing the button above.",
|
"edit-profile": {
|
||||||
"sid-copy": "Copy short link",
|
"user-header": "Editing your profile",
|
||||||
"update-avatar": "Update avatar",
|
"general-tab": "General",
|
||||||
"avatar-updated": "Avatar updated! It might take a moment to be reflected on your profile.",
|
"names-pronouns-tab": "Names & pronouns",
|
||||||
"member-header-label": "\"Members\" header text",
|
"file-too-large": "This file is too large, please resize it (maximum is {{max}}, the file you're trying to upload is {{current}})",
|
||||||
"member-header-info": "This is the text used for the \"Members\" heading. If you leave it blank, the default text will be used.",
|
"sid-current": "Current short ID:",
|
||||||
"hide-member-list-label": "Hide member list",
|
"sid": "Short ID",
|
||||||
"timezone-label": "Timezone",
|
"sid-reroll": "Reroll short ID",
|
||||||
"timezone-preview": "This will show up on your profile like this:",
|
"sid-hint": "This ID is used in prns.cc links. You can reroll one short ID every hour (shared between your main profile and all members) by pressing the button above.",
|
||||||
"timezone-info": "This is optional. Your timezone is never shared directly, only the difference between UTC and your current timezone is.",
|
"sid-copy": "Copy short link",
|
||||||
"hide-member-list-info": "This only hides your member list. Individual members will still be visible to anyone with a direct link to their pages.",
|
"update-avatar": "Update avatar",
|
||||||
"profile-options-header": "Profile options",
|
"avatar-updated": "Avatar updated! It might take a moment to be reflected on your profile.",
|
||||||
"bio-tab": "Bio",
|
"member-header-label": "\"Members\" header text",
|
||||||
"saved-changes": "Successfully saved changes!",
|
"member-header-info": "This is the text used for the \"Members\" heading. If you leave it blank, the default text will be used.",
|
||||||
"bio-length-hint": "Using {{length}}/{{maxLength}} characters",
|
"hide-member-list-label": "Hide member list",
|
||||||
"preview": "Preview",
|
"timezone-label": "Timezone",
|
||||||
"fields-tab": "Fields",
|
"timezone-preview": "This will show up on your profile like this:",
|
||||||
"flags-links-tab": "Flags & links",
|
"timezone-info": "This is optional. Your timezone is never shared directly, only the difference between UTC and your current timezone is.",
|
||||||
"back-to-settings-tab": "Back to settings",
|
"hide-member-list-info": "This only hides your member list. Individual members will still be visible to anyone with a direct link to their pages.",
|
||||||
"member-header": "Editing profile of {{name}}",
|
"profile-options-header": "Profile options",
|
||||||
"username": "Username",
|
"bio-tab": "Bio",
|
||||||
"change-username-info": "As changing your username will also change all of your members' links, you can only change it in your account settings.",
|
"saved-changes": "Successfully saved changes!",
|
||||||
"change-username-link": "Go to settings",
|
"bio-length-hint": "Using {{length}}/{{maxLength}} characters",
|
||||||
"member-name": "Name",
|
"preview": "Preview",
|
||||||
"change-member-name": "Change name",
|
"fields-tab": "Fields",
|
||||||
"display-name": "Display name",
|
"flags-links-tab": "Flags & links",
|
||||||
"unlisted-label": "Hide from member list",
|
"back-to-settings-tab": "Back to settings",
|
||||||
"unlisted-note": "This only hides this member from your public member list. They will still be visible to anyone at this link:",
|
"member-header": "Editing profile of {{name}}",
|
||||||
"edit-names-pronouns-header": "Edit names and pronouns",
|
"username": "Username",
|
||||||
"back-to-profile-tab": "Back to profile",
|
"change-username-info": "As changing your username will also change all of your members' links, you can only change it in your account settings.",
|
||||||
"editing-fields-header": "Editing fields"
|
"change-username-link": "Go to settings",
|
||||||
},
|
"member-name": "Name",
|
||||||
"save-changes": "Save changes",
|
"change-member-name": "Change name",
|
||||||
"change": "Change",
|
"display-name": "Display name",
|
||||||
"editor": {
|
"unlisted-label": "Hide from member list",
|
||||||
"remove-entry": "Remove entry",
|
"unlisted-note": "This only hides this member from your public member list. They will still be visible to anyone at this link:",
|
||||||
"move-entry-down": "Move entry down",
|
"edit-names-pronouns-header": "Edit names and pronouns",
|
||||||
"move-entry-up": "Move entry up",
|
"back-to-profile-tab": "Back to profile",
|
||||||
"add-entry": "Add entry",
|
"editing-fields-header": "Editing fields"
|
||||||
"change-display-text": "Change display text",
|
},
|
||||||
"display-text-example": "Optional display text (e.g. it/its)",
|
"save-changes": "Save changes",
|
||||||
"display-text-label": "Display text",
|
"change": "Change",
|
||||||
"display-text-info": "This is the short text shown on your profile page. If you leave it empty, it will default to the first two forms of the full set.",
|
"editor": {
|
||||||
"move-field-up": "Move field up",
|
"remove-entry": "Remove entry",
|
||||||
"move-field-down": "Move field down",
|
"move-entry-down": "Move entry down",
|
||||||
"remove-field": "Remove field",
|
"move-entry-up": "Move entry up",
|
||||||
"field-name": "Field name",
|
"add-entry": "Add entry",
|
||||||
"add-field": "Add field",
|
"change-display-text": "Change display text",
|
||||||
"new-entry": "New entry"
|
"display-text-example": "Optional display text (e.g. it/its)",
|
||||||
}
|
"display-text-label": "Display text",
|
||||||
|
"display-text-info": "This is the short text shown on your profile page. If you leave it empty, it will default to the first two forms of the full set.",
|
||||||
|
"move-field-up": "Move field up",
|
||||||
|
"move-field-down": "Move field down",
|
||||||
|
"remove-field": "Remove field",
|
||||||
|
"field-name": "Field name",
|
||||||
|
"add-field": "Add field",
|
||||||
|
"new-entry": "New entry",
|
||||||
|
"add-this-flag": "Add this flag",
|
||||||
|
"add-flags-header": "Add flags",
|
||||||
|
"move-flag-up": "Move flag up",
|
||||||
|
"move-flag-down": "Move flag down",
|
||||||
|
"remove-this-flag": "Remove this flag",
|
||||||
|
"no-flags-hint": "This profile doesn't have any flags yet! Add some with the search box.",
|
||||||
|
"flag-search-placeholder": "Type to start searching",
|
||||||
|
"flag-search-no-flags": "No flags matched your search query.",
|
||||||
|
"flag-search-no-account-flags": "You haven't uploaded any flags yet.",
|
||||||
|
"flag-search-hint": "Can't find the flag you're looking for? Try using the search bar above.",
|
||||||
|
"flag-manage-your-flags": "Manage your flags"
|
||||||
|
},
|
||||||
|
"cancel": "Cancel"
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,6 +11,7 @@ export const clearToken = (cookies: Cookies) => cookies.delete(TOKEN_COOKIE_NAME
|
||||||
|
|
||||||
// TODO: change this to something we actually clearly have the rights to use
|
// TODO: change this to something we actually clearly have the rights to use
|
||||||
export const DEFAULT_AVATAR = "https://pronouns.cc/default/512.webp";
|
export const DEFAULT_AVATAR = "https://pronouns.cc/default/512.webp";
|
||||||
|
export const DEFAULT_FLAG = "/unknown_flag.svg";
|
||||||
|
|
||||||
export const idTimestamp = (id: string) =>
|
export const idTimestamp = (id: string) =>
|
||||||
DateTime.fromMillis(parseInt(id, 10) / (1 << 22) + 1_640_995_200_000);
|
DateTime.fromMillis(parseInt(id, 10) / (1 << 22) + 1_640_995_200_000);
|
||||||
|
|
35
Foxnouns.Frontend/src/lib/paginate.ts
Normal file
35
Foxnouns.Frontend/src/lib/paginate.ts
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
export type PaginatedArray<T> = {
|
||||||
|
data: T[];
|
||||||
|
currentPage: number;
|
||||||
|
pageCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paginates an array.
|
||||||
|
* @param arr The array to paginate.
|
||||||
|
* @param page The zero-indexed page number.
|
||||||
|
* @param perPage How many items to display per page.
|
||||||
|
* @returns An object containing a slice of the array, the current page number, and the page count.
|
||||||
|
*/
|
||||||
|
export default function paginate<T>(
|
||||||
|
arr: T[] | null,
|
||||||
|
page: string | number | null,
|
||||||
|
perPage: number,
|
||||||
|
): PaginatedArray<T> {
|
||||||
|
if (arr && arr.length > 0) {
|
||||||
|
let currentPage = 0;
|
||||||
|
if (page && typeof page === "string") currentPage = parseInt(page);
|
||||||
|
if (page && typeof page === "number") currentPage = page;
|
||||||
|
|
||||||
|
const pageCount = Math.ceil(arr.length / perPage);
|
||||||
|
let data = arr.slice(currentPage * perPage, (currentPage + 1) * perPage);
|
||||||
|
if (data.length === 0) {
|
||||||
|
data = arr.slice(0, perPage);
|
||||||
|
currentPage = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { data, currentPage, pageCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { data: [], currentPage: 0, pageCount: 1 };
|
||||||
|
}
|
|
@ -1,7 +1,8 @@
|
||||||
import { apiRequest } from "$api";
|
import { apiRequest } from "$api";
|
||||||
import ApiError, { ErrorCode } from "$api/error.js";
|
import ApiError, { ErrorCode } from "$api/error.js";
|
||||||
import type { PartialMember, User, UserWithMembers } from "$api/models";
|
import type { UserWithMembers } from "$api/models";
|
||||||
import log from "$lib/log.js";
|
import log from "$lib/log.js";
|
||||||
|
import paginate from "$lib/paginate";
|
||||||
import { error } from "@sveltejs/kit";
|
import { error } from "@sveltejs/kit";
|
||||||
|
|
||||||
const MEMBERS_PER_PAGE = 20;
|
const MEMBERS_PER_PAGE = 20;
|
||||||
|
@ -20,22 +21,11 @@ export const load = async ({ params, fetch, cookies, url }) => {
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Paginate members on the server side
|
const { data, currentPage, pageCount } = paginate(
|
||||||
let currentPage = 0;
|
user.members,
|
||||||
let pageCount = 0;
|
url.searchParams.get("page"),
|
||||||
let members: PartialMember[] = [];
|
MEMBERS_PER_PAGE,
|
||||||
if (user.members) {
|
);
|
||||||
currentPage = Number(url.searchParams.get("page") || "0");
|
|
||||||
pageCount = Math.ceil(user.members.length / MEMBERS_PER_PAGE);
|
|
||||||
members = user.members.slice(
|
|
||||||
currentPage * MEMBERS_PER_PAGE,
|
|
||||||
(currentPage + 1) * MEMBERS_PER_PAGE,
|
|
||||||
);
|
|
||||||
if (members.length === 0) {
|
|
||||||
members = user.members.slice(0, MEMBERS_PER_PAGE);
|
|
||||||
currentPage = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { user, members, currentPage, pageCount };
|
return { user, members: data, currentPage, pageCount };
|
||||||
};
|
};
|
||||||
|
|
42
Foxnouns.Frontend/src/routes/settings/flags/+page.server.ts
Normal file
42
Foxnouns.Frontend/src/routes/settings/flags/+page.server.ts
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
import { apiRequest, fastRequest } from "$api";
|
||||||
|
import ApiError from "$api/error";
|
||||||
|
import type { PrideFlag } from "$api/models/user";
|
||||||
|
import log from "$lib/log";
|
||||||
|
import { encode } from "base64-arraybuffer";
|
||||||
|
|
||||||
|
export const load = async ({ url, fetch, cookies }) => {
|
||||||
|
const resp = await apiRequest<PrideFlag[]>("GET", "/users/@me/flags", { fetch, cookies });
|
||||||
|
|
||||||
|
return {
|
||||||
|
flags: resp,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions = {
|
||||||
|
upload: async ({ request, fetch, cookies }) => {
|
||||||
|
const body = await request.formData();
|
||||||
|
const name = body.get("name") as string;
|
||||||
|
const description = body.get("desc") as string;
|
||||||
|
const image = body.get("image") as File;
|
||||||
|
|
||||||
|
const buffer = await image.arrayBuffer();
|
||||||
|
const base64 = encode(buffer);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fastRequest("POST", "/users/@me/flags", {
|
||||||
|
body: {
|
||||||
|
name,
|
||||||
|
description: description ? description : null,
|
||||||
|
image: `data:${image.type};base64,${base64}`,
|
||||||
|
},
|
||||||
|
fetch,
|
||||||
|
cookies,
|
||||||
|
});
|
||||||
|
return { ok: true, error: null };
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError) return { ok: false, error: e.obj };
|
||||||
|
log.error("error uploading flag:", e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
129
Foxnouns.Frontend/src/routes/settings/flags/+page.svelte
Normal file
129
Foxnouns.Frontend/src/routes/settings/flags/+page.svelte
Normal file
|
@ -0,0 +1,129 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { Accordion, AccordionItem } from "@sveltestrap/sveltestrap";
|
||||||
|
import type { ActionData, PageData } from "./$types";
|
||||||
|
import EditorFlagImage from "$components/editor/EditorFlagImage.svelte";
|
||||||
|
import { t } from "$lib/i18n";
|
||||||
|
import type { PrideFlag } from "$api/models";
|
||||||
|
import paginate from "$lib/paginate";
|
||||||
|
import ClientPaginator from "$components/ClientPaginator.svelte";
|
||||||
|
import FlagEditor from "$components/editor/FlagEditor.svelte";
|
||||||
|
import NoscriptWarning from "$components/editor/NoscriptWarning.svelte";
|
||||||
|
import { fastRequest } from "$api";
|
||||||
|
import type { RawApiError } from "$api/error";
|
||||||
|
import ApiError from "$api/error";
|
||||||
|
import log from "$lib/log";
|
||||||
|
import FormStatusMarker from "$components/editor/FormStatusMarker.svelte";
|
||||||
|
|
||||||
|
type Props = { data: PageData; form: ActionData };
|
||||||
|
let { data, form }: Props = $props();
|
||||||
|
|
||||||
|
let flags = $state(data.flags);
|
||||||
|
|
||||||
|
let arr: PrideFlag[] = $state([]);
|
||||||
|
let currentPage = $state(0);
|
||||||
|
let pageCount = $state(0);
|
||||||
|
|
||||||
|
const FLAGS_PER_PAGE = 50;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const pages = paginate(flags, currentPage, FLAGS_PER_PAGE);
|
||||||
|
arr = pages.data;
|
||||||
|
pageCount = pages.pageCount;
|
||||||
|
});
|
||||||
|
|
||||||
|
let lastEditedFlag: string | null = $state(null);
|
||||||
|
let ok: { ok: boolean; error: RawApiError | null } | null = $state(null);
|
||||||
|
|
||||||
|
const update = async (
|
||||||
|
id: string,
|
||||||
|
{ name, description }: { name: string; description: string | null },
|
||||||
|
) => {
|
||||||
|
lastEditedFlag = id;
|
||||||
|
try {
|
||||||
|
await fastRequest("PATCH", `/users/@me/flags/${id}`, {
|
||||||
|
body: { name, description },
|
||||||
|
token: data.token,
|
||||||
|
});
|
||||||
|
ok = { ok: true, error: null };
|
||||||
|
|
||||||
|
const idx = flags.findIndex((f) => f.id === id);
|
||||||
|
if (idx === -1) return;
|
||||||
|
console.log("yippee");
|
||||||
|
flags[idx] = { ...flags[idx], name, description };
|
||||||
|
} catch (e) {
|
||||||
|
log.error("Could not update flag %s:", id, e);
|
||||||
|
if (e instanceof ApiError) ok = { ok: false, error: e.obj };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteFlag = async (id: string) => {
|
||||||
|
lastEditedFlag = id;
|
||||||
|
try {
|
||||||
|
await fastRequest("DELETE", `/users/@me/flags/${id}`, { token: data.token });
|
||||||
|
ok = { ok: true, error: null };
|
||||||
|
|
||||||
|
const idx = flags.findIndex((f) => f.id === id);
|
||||||
|
if (idx === -1) return;
|
||||||
|
flags.splice(idx, 1);
|
||||||
|
flags = [...flags];
|
||||||
|
} catch (e) {
|
||||||
|
log.error("Could not remove flag %s:", id, e);
|
||||||
|
if (e instanceof ApiError) ok = { ok: false, error: e.obj };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h3>{$t("settings.flag-title")}</h3>
|
||||||
|
|
||||||
|
<NoscriptWarning />
|
||||||
|
|
||||||
|
<form method="POST" action="?/upload" enctype="multipart/form-data">
|
||||||
|
<FormStatusMarker {form} successMessage={$t("settings.flag-upload-success")} />
|
||||||
|
<h4>{$t("settings.flag-upload-title")}</h4>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
name="image"
|
||||||
|
accept="image/png, image/jpeg, image/gif, image/webp"
|
||||||
|
class="mb-2 form-control"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
class="mb-2 form-control"
|
||||||
|
name="name"
|
||||||
|
placeholder={$t("settings.flag-name-placeholder")}
|
||||||
|
autocomplete="off"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
class="mb-2 form-control"
|
||||||
|
name="desc"
|
||||||
|
placeholder={$t("settings.flag-description-placeholder")}
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
<button type="submit" class="btn btn-primary">{$t("settings.flag-upload-button")}</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h4 class="mt-3">
|
||||||
|
{$t("settings.flag-current-flags-title", {
|
||||||
|
count: data.flags.length,
|
||||||
|
max: data.meta.limits.max_flags,
|
||||||
|
})}
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<ClientPaginator center bind:currentPage {pageCount} />
|
||||||
|
|
||||||
|
<Accordion>
|
||||||
|
{#each arr as flag (flag.id)}
|
||||||
|
<AccordionItem>
|
||||||
|
<span slot="header">
|
||||||
|
<EditorFlagImage {flag} />
|
||||||
|
{flag.name}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{#if lastEditedFlag === flag.id}<FormStatusMarker form={ok} />{/if}
|
||||||
|
<FlagEditor {flag} {update} {deleteFlag} />
|
||||||
|
</AccordionItem>
|
||||||
|
{/each}
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
<ClientPaginator center bind:currentPage {pageCount} />
|
|
@ -1,18 +1,15 @@
|
||||||
|
import paginate from "$lib/paginate";
|
||||||
|
|
||||||
const MEMBERS_PER_PAGE = 15;
|
const MEMBERS_PER_PAGE = 15;
|
||||||
|
|
||||||
export const load = async ({ url, parent }) => {
|
export const load = async ({ url, parent }) => {
|
||||||
const { user } = await parent();
|
const { user } = await parent();
|
||||||
|
|
||||||
let currentPage = Number(url.searchParams.get("page") || "0");
|
const { data, currentPage, pageCount } = paginate(
|
||||||
let pageCount = Math.ceil(user.members.length / MEMBERS_PER_PAGE);
|
user.members,
|
||||||
let members = user.members.slice(
|
url.searchParams.get("page"),
|
||||||
currentPage * MEMBERS_PER_PAGE,
|
MEMBERS_PER_PAGE,
|
||||||
(currentPage + 1) * MEMBERS_PER_PAGE,
|
|
||||||
);
|
);
|
||||||
if (members.length === 0) {
|
|
||||||
members = user.members.slice(0, MEMBERS_PER_PAGE);
|
|
||||||
currentPage = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { members, currentPage, pageCount };
|
return { members: data, currentPage, pageCount };
|
||||||
};
|
};
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
import ApiError from "$api/error";
|
import ApiError from "$api/error";
|
||||||
import log from "$lib/log";
|
import log from "$lib/log";
|
||||||
import { Icon, InputGroup } from "@sveltestrap/sveltestrap";
|
import { Icon, InputGroup } from "@sveltestrap/sveltestrap";
|
||||||
|
import InfoCircleFill from "svelte-bootstrap-icons/lib/InfoCircleFill.svelte";
|
||||||
import { t } from "$lib/i18n";
|
import { t } from "$lib/i18n";
|
||||||
import AvatarEditor from "$components/editor/AvatarEditor.svelte";
|
import AvatarEditor from "$components/editor/AvatarEditor.svelte";
|
||||||
import ErrorAlert from "$components/ErrorAlert.svelte";
|
import ErrorAlert from "$components/ErrorAlert.svelte";
|
||||||
|
@ -133,7 +134,7 @@
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-muted mt-1">
|
<p class="text-muted mt-1">
|
||||||
<Icon name="info-circle-fill" aria-hidden />
|
<InfoCircleFill aria-hidden />
|
||||||
{$t("edit-profile.unlisted-note")}
|
{$t("edit-profile.unlisted-note")}
|
||||||
<code>
|
<code>
|
||||||
{PUBLIC_BASE_URL.substring("https://".length)}/@{data.member.user.username}/{data.member
|
{PUBLIC_BASE_URL.substring("https://".length)}/@{data.member.user.username}/{data.member
|
||||||
|
|
|
@ -0,0 +1,32 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { apiRequest } from "$api";
|
||||||
|
import ApiError, { type RawApiError } from "$api/error";
|
||||||
|
import { mergePreferences, type User } from "$api/models/user";
|
||||||
|
import FieldsEditor from "$components/editor/FieldsEditor.svelte";
|
||||||
|
import log from "$lib/log";
|
||||||
|
import type { PageData } from "./$types";
|
||||||
|
|
||||||
|
type Props = { data: PageData };
|
||||||
|
let { data }: Props = $props();
|
||||||
|
|
||||||
|
let fields = $state(data.member.fields);
|
||||||
|
let ok: { ok: boolean; error: RawApiError | null } | null = $state(null);
|
||||||
|
let allPreferences = $derived(mergePreferences(data.user.custom_preferences));
|
||||||
|
|
||||||
|
const update = async () => {
|
||||||
|
try {
|
||||||
|
const resp = await apiRequest<User>("PATCH", `/users/@me/members/${data.member.id}`, {
|
||||||
|
body: { fields },
|
||||||
|
token: data.token,
|
||||||
|
});
|
||||||
|
fields = resp.fields;
|
||||||
|
ok = { ok: true, error: null };
|
||||||
|
} catch (e) {
|
||||||
|
ok = { ok: false, error: null };
|
||||||
|
log.error("Could not update fields:", e);
|
||||||
|
if (e instanceof ApiError) ok.error = e.obj;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<FieldsEditor bind:fields {ok} {allPreferences} {update} />
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { apiRequest } from "$api";
|
||||||
|
import type { PrideFlag } from "$api/models/user";
|
||||||
|
|
||||||
|
export const load = async ({ fetch, cookies }) => {
|
||||||
|
const flags = await apiRequest<PrideFlag[]>("GET", "/users/@me/flags", { fetch, cookies });
|
||||||
|
return { flags };
|
||||||
|
};
|
|
@ -0,0 +1,28 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { fastRequest } from "$api";
|
||||||
|
import type { RawApiError } from "$api/error";
|
||||||
|
import ApiError from "$api/error";
|
||||||
|
import ProfileFlagsEditor from "$components/editor/ProfileFlagsEditor.svelte";
|
||||||
|
import log from "$lib/log";
|
||||||
|
import type { PageData } from "./$types";
|
||||||
|
|
||||||
|
type Props = { data: PageData };
|
||||||
|
let { data }: Props = $props();
|
||||||
|
|
||||||
|
let form: { ok: boolean; error: RawApiError | null } | null = $state(null);
|
||||||
|
|
||||||
|
const save = async (flags: string[]) => {
|
||||||
|
try {
|
||||||
|
await fastRequest("PATCH", `/users/@me/members/${data.member.id}`, {
|
||||||
|
body: { flags },
|
||||||
|
token: data.token,
|
||||||
|
});
|
||||||
|
form = { ok: true, error: null };
|
||||||
|
} catch (e) {
|
||||||
|
log.error("Could not update profile flags for member %s:", data.member.id, e);
|
||||||
|
if (e instanceof ApiError) form = { ok: false, error: e.obj };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ProfileFlagsEditor profileFlags={data.member.flags} allFlags={data.flags} {save} {form} />
|
|
@ -29,4 +29,4 @@
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<FieldsEditor {fields} {ok} {allPreferences} {update} />
|
<FieldsEditor bind:fields {ok} {allPreferences} {update} />
|
||||||
|
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { apiRequest } from "$api";
|
||||||
|
import type { PrideFlag } from "$api/models/user";
|
||||||
|
|
||||||
|
export const load = async ({ fetch, cookies }) => {
|
||||||
|
const flags = await apiRequest<PrideFlag[]>("GET", "/users/@me/flags", { fetch, cookies });
|
||||||
|
return { flags };
|
||||||
|
};
|
|
@ -0,0 +1,28 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { fastRequest } from "$api";
|
||||||
|
import type { RawApiError } from "$api/error";
|
||||||
|
import ApiError from "$api/error";
|
||||||
|
import ProfileFlagsEditor from "$components/editor/ProfileFlagsEditor.svelte";
|
||||||
|
import log from "$lib/log";
|
||||||
|
import type { PageData } from "./$types";
|
||||||
|
|
||||||
|
type Props = { data: PageData };
|
||||||
|
let { data }: Props = $props();
|
||||||
|
|
||||||
|
let form: { ok: boolean; error: RawApiError | null } | null = $state(null);
|
||||||
|
|
||||||
|
const save = async (flags: string[]) => {
|
||||||
|
try {
|
||||||
|
await fastRequest("PATCH", "/users/@me", {
|
||||||
|
body: { flags },
|
||||||
|
token: data.token,
|
||||||
|
});
|
||||||
|
form = { ok: true, error: null };
|
||||||
|
} catch (e) {
|
||||||
|
log.error("Could not update profile flags:", e);
|
||||||
|
if (e instanceof ApiError) form = { ok: false, error: e.obj };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ProfileFlagsEditor profileFlags={data.user.flags} allFlags={data.flags} {save} {form} />
|
9
Foxnouns.Frontend/static/unknown_flag.svg
Normal file
9
Foxnouns.Frontend/static/unknown_flag.svg
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
<svg xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" version="1.0" width="225" height="150" id="svg2">
|
||||||
|
<defs id="defs5"/>
|
||||||
|
<path d="M 0,0 L 0,50 L 225,50 L 225,0 L 0,0 z " style="fill:#bbb" id="rect1308"/>
|
||||||
|
<path d="M 0,50 L 0,100 L 225,100 L 225,50 L 0,50 z " style="fill:#d4d4d4" id="rect1310"/>
|
||||||
|
<path d="M 0,100 L 0,150 L 225,150 L 225,100 L 0,100 z " style="fill:#e3e3e3" id="rect1313"/>
|
||||||
|
<path d="M 64.255859,62.075068 L 63.136719,62.075068 C 63.128904,62.047727 63.092772,61.948118 63.02832,61.77624 C 62.963866,61.604368 62.931639,61.453977 62.931641,61.325068 C 62.931639,61.078978 62.965819,60.84265 63.03418,60.616084 C 63.102537,60.389525 63.203123,60.177611 63.335938,59.980341 C 63.468748,59.783081 63.731443,59.467651 64.124023,59.034052 C 64.516598,58.600465 64.712887,58.243043 64.712891,57.961787 C 64.712887,57.414919 64.355466,57.141482 63.640625,57.141474 C 63.292967,57.141482 62.929686,57.31531 62.550781,57.662959 L 61.947266,56.532099 C 62.451171,56.137576 63.113279,55.940311 63.933594,55.940302 C 64.566403,55.940311 65.094723,56.116092 65.518555,56.467646 C 65.942378,56.819216 66.154292,57.286013 66.154297,57.868037 C 66.154292,58.266481 66.077144,58.603394 65.922852,58.878779 C 65.76855,59.154175 65.497066,59.477417 65.108398,59.848505 C 64.719723,60.219604 64.466794,60.528197 64.349609,60.774287 C 64.232419,61.020384 64.173825,61.289915 64.173828,61.58288 C 64.173825,61.645383 64.201169,61.809446 64.255859,62.075068 L 64.255859,62.075068 z M 63.757813,62.871943 C 64.023435,62.871945 64.249021,62.965695 64.43457,63.153193 C 64.620114,63.340694 64.712887,63.567257 64.712891,63.83288 C 64.712887,64.098506 64.620114,64.325068 64.43457,64.512568 C 64.249021,64.700068 64.023435,64.793818 63.757813,64.793818 C 63.492185,64.793818 63.265623,64.700068 63.078125,64.512568 C 62.890623,64.325068 62.796874,64.098506 62.796875,63.83288 C 62.796874,63.567257 62.890623,63.340694 63.078125,63.153193 C 63.265623,62.965695 63.492185,62.871945 63.757813,62.871943 L 63.757813,62.871943 z " transform="matrix(10.52848,0,0,10.52848,-561.8574,-560.5734)" style="font-size:12px;font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;text-align:start;line-height:125%;writing-mode:lr-tb;text-anchor:start;fill:white;font-family:Trebuchet MS" id="flowRoot1875"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 2.4 KiB |
|
@ -33,3 +33,8 @@ Code taken entirely or almost entirely from external sources:
|
||||||
taken from [PluralKit](https://github.com/PluralKit/PluralKit/blob/32a6e97342acc3b35e6f9e7b4dd169e21d888770/PluralKit.Core/Database/Functions/functions.sql)
|
taken from [PluralKit](https://github.com/PluralKit/PluralKit/blob/32a6e97342acc3b35e6f9e7b4dd169e21d888770/PluralKit.Core/Database/Functions/functions.sql)
|
||||||
- `Foxnouns.Backend/Database/prune-designer-cs-files.sh`,
|
- `Foxnouns.Backend/Database/prune-designer-cs-files.sh`,
|
||||||
taken from [Iceshrimp.NET](https://iceshrimp.dev/iceshrimp/Iceshrimp.NET/src/commit/7c93dcf79dda54fc1a4ea9772e3f80874e6bcefb/Iceshrimp.Backend/Core/Database/prune-designer-cs-files.sh)
|
taken from [Iceshrimp.NET](https://iceshrimp.dev/iceshrimp/Iceshrimp.NET/src/commit/7c93dcf79dda54fc1a4ea9772e3f80874e6bcefb/Iceshrimp.Backend/Core/Database/prune-designer-cs-files.sh)
|
||||||
|
|
||||||
|
Files under a different license:
|
||||||
|
|
||||||
|
- `Foxnouns.Frontend/static/unknown_flag.svg` is https://commons.wikimedia.org/wiki/File:Unknown_flag.svg,
|
||||||
|
by 8938e on Wikimedia Commons, licensed as CC BY-SA 4.0.
|
Loading…
Add table
Reference in a new issue