Compare commits
3 commits
c179669799
...
c0bb76580d
Author | SHA1 | Date | |
---|---|---|---|
c0bb76580d | |||
8bba5f6137 | |||
261435c252 |
46 changed files with 1361 additions and 168 deletions
|
@ -303,8 +303,8 @@ public class MembersController(
|
||||||
.SetProperty(u => u.LastActive, clock.GetCurrentInstant())
|
.SetProperty(u => u.LastActive, clock.GetCurrentInstant())
|
||||||
);
|
);
|
||||||
|
|
||||||
// Re-fetch member to fetch the new sid
|
// Fetch the new sid then pass that to RenderMember
|
||||||
var updatedMember = await db.ResolveMemberAsync(CurrentUser!.Id, memberRef);
|
var newSid = await db.Members.Where(m => m.Id == member.Id).Select(m => m.Sid).FirstAsync();
|
||||||
return Ok(memberRenderer.RenderMember(updatedMember, CurrentToken));
|
return Ok(memberRenderer.RenderMember(member, CurrentToken, newSid));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using Coravel.Mailer.Mail.Helpers;
|
||||||
using Coravel.Queuing.Interfaces;
|
using Coravel.Queuing.Interfaces;
|
||||||
using EntityFramework.Exceptions.Common;
|
using EntityFramework.Exceptions.Common;
|
||||||
using Foxnouns.Backend.Database;
|
using Foxnouns.Backend.Database;
|
||||||
|
@ -116,6 +117,42 @@ public class UsersController(
|
||||||
if (req.HasProperty(nameof(req.Avatar)))
|
if (req.HasProperty(nameof(req.Avatar)))
|
||||||
errors.Add(("avatar", ValidationUtils.ValidateAvatar(req.Avatar)));
|
errors.Add(("avatar", ValidationUtils.ValidateAvatar(req.Avatar)));
|
||||||
|
|
||||||
|
if (req.HasProperty(nameof(req.MemberTitle)))
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(req.MemberTitle))
|
||||||
|
{
|
||||||
|
user.MemberTitle = null;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
errors.Add(("member_title", ValidationUtils.ValidateDisplayName(req.MemberTitle)));
|
||||||
|
user.MemberTitle = req.MemberTitle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.HasProperty(nameof(req.MemberListHidden)))
|
||||||
|
user.ListHidden = req.MemberListHidden == true;
|
||||||
|
|
||||||
|
if (req.HasProperty(nameof(req.Timezone)))
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(req.Timezone))
|
||||||
|
{
|
||||||
|
user.Timezone = null;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (TimeZoneInfo.TryFindSystemTimeZoneById(req.Timezone, out _))
|
||||||
|
user.Timezone = req.Timezone;
|
||||||
|
else
|
||||||
|
errors.Add(
|
||||||
|
(
|
||||||
|
"timezone",
|
||||||
|
ValidationError.GenericValidationError("Invalid timezone", req.Timezone)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ValidationUtils.Validate(errors);
|
ValidationUtils.Validate(errors);
|
||||||
// This is fired off regardless of whether the transaction is committed
|
// This is fired off regardless of whether the transaction is committed
|
||||||
// (atomic operations are hard when combined with background jobs)
|
// (atomic operations are hard when combined with background jobs)
|
||||||
|
@ -253,6 +290,9 @@ public class UsersController(
|
||||||
public Pronoun[]? Pronouns { get; init; }
|
public Pronoun[]? Pronouns { get; init; }
|
||||||
public Field[]? Fields { get; init; }
|
public Field[]? Fields { get; init; }
|
||||||
public Snowflake[]? Flags { 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")]
|
||||||
|
@ -306,13 +346,20 @@ public class UsersController(
|
||||||
.SetProperty(u => u.LastActive, clock.GetCurrentInstant())
|
.SetProperty(u => u.LastActive, clock.GetCurrentInstant())
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Get the user's new sid
|
||||||
|
var newSid = await db
|
||||||
|
.Users.Where(u => u.Id == CurrentUser.Id)
|
||||||
|
.Select(u => u.Sid)
|
||||||
|
.FirstAsync();
|
||||||
|
|
||||||
var user = await db.ResolveUserAsync(CurrentUser.Id);
|
var user = await db.ResolveUserAsync(CurrentUser.Id);
|
||||||
return Ok(
|
return Ok(
|
||||||
await userRenderer.RenderUserAsync(
|
await userRenderer.RenderUserAsync(
|
||||||
user,
|
CurrentUser,
|
||||||
CurrentUser,
|
CurrentUser,
|
||||||
CurrentToken,
|
CurrentToken,
|
||||||
renderMembers: false
|
renderMembers: false,
|
||||||
|
overrideSid: newSid
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,30 @@
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Foxnouns.Backend.Database.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
[DbContext(typeof(DatabaseContext))]
|
||||||
|
[Migration("20241124201309_AddUserTimezone")]
|
||||||
|
public partial class AddUserTimezone : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "timezone",
|
||||||
|
table: "users",
|
||||||
|
type: "text",
|
||||||
|
nullable: true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(name: "timezone", table: "users");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -434,6 +434,10 @@ namespace Foxnouns.Backend.Database.Migrations
|
||||||
.HasColumnName("sid")
|
.HasColumnName("sid")
|
||||||
.HasDefaultValueSql("find_free_user_sid()");
|
.HasDefaultValueSql("find_free_user_sid()");
|
||||||
|
|
||||||
|
b.Property<string>("Timezone")
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("timezone");
|
||||||
|
|
||||||
b.Property<string>("Username")
|
b.Property<string>("Username")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text")
|
.HasColumnType("text")
|
||||||
|
|
|
@ -15,6 +15,7 @@ public class User : BaseModel
|
||||||
public string? Avatar { get; set; }
|
public string? Avatar { get; set; }
|
||||||
public string[] Links { get; set; } = [];
|
public string[] Links { get; set; } = [];
|
||||||
public bool ListHidden { get; set; }
|
public bool ListHidden { get; set; }
|
||||||
|
public string? Timezone { get; set; }
|
||||||
|
|
||||||
public List<FieldEntry> Names { get; set; } = [];
|
public List<FieldEntry> Names { get; set; } = [];
|
||||||
public List<Pronoun> Pronouns { get; set; } = [];
|
public List<Pronoun> Pronouns { get; set; } = [];
|
||||||
|
|
|
@ -4,6 +4,7 @@
|
||||||
"Development": {
|
"Development": {
|
||||||
"commandName": "Project",
|
"commandName": "Project",
|
||||||
"dotnetRunMessages": true,
|
"dotnetRunMessages": true,
|
||||||
|
"hotReloadEnabled": false,
|
||||||
"launchBrowser": false,
|
"launchBrowser": false,
|
||||||
"externalUrlConfiguration": true,
|
"externalUrlConfiguration": true,
|
||||||
"environmentVariables": {
|
"environmentVariables": {
|
||||||
|
@ -13,6 +14,7 @@
|
||||||
"Production": {
|
"Production": {
|
||||||
"commandName": "Project",
|
"commandName": "Project",
|
||||||
"dotnetRunMessages": true,
|
"dotnetRunMessages": true,
|
||||||
|
"hotReloadEnabled": false,
|
||||||
"launchBrowser": false,
|
"launchBrowser": false,
|
||||||
"externalUrlConfiguration": true,
|
"externalUrlConfiguration": true,
|
||||||
"environmentVariables": {
|
"environmentVariables": {
|
||||||
|
|
|
@ -24,13 +24,17 @@ public class MemberRendererService(DatabaseContext db, Config config)
|
||||||
return members.Select(m => RenderPartialMember(m, renderUnlisted));
|
return members.Select(m => RenderPartialMember(m, renderUnlisted));
|
||||||
}
|
}
|
||||||
|
|
||||||
public MemberResponse RenderMember(Member member, Token? token = null)
|
public MemberResponse RenderMember(
|
||||||
|
Member member,
|
||||||
|
Token? token = null,
|
||||||
|
string? overrideSid = null
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var renderUnlisted = token?.UserId == member.UserId && token.HasScope("user.read_hidden");
|
var renderUnlisted = token?.UserId == member.UserId && token.HasScope("user.read_hidden");
|
||||||
|
|
||||||
return new MemberResponse(
|
return new MemberResponse(
|
||||||
member.Id,
|
member.Id,
|
||||||
member.Sid,
|
overrideSid ?? member.Sid,
|
||||||
member.Name,
|
member.Name,
|
||||||
member.DisplayName ?? member.Name,
|
member.DisplayName ?? member.Name,
|
||||||
member.Bio,
|
member.Bio,
|
||||||
|
|
|
@ -4,6 +4,7 @@ using Foxnouns.Backend.Utils;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
|
using Org.BouncyCastle.Ocsp;
|
||||||
|
|
||||||
namespace Foxnouns.Backend.Services;
|
namespace Foxnouns.Backend.Services;
|
||||||
|
|
||||||
|
@ -19,6 +20,7 @@ public class UserRendererService(
|
||||||
Token? token = null,
|
Token? token = null,
|
||||||
bool renderMembers = true,
|
bool renderMembers = true,
|
||||||
bool renderAuthMethods = false,
|
bool renderAuthMethods = false,
|
||||||
|
string? overrideSid = null,
|
||||||
CancellationToken ct = default
|
CancellationToken ct = default
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
|
@ -49,9 +51,16 @@ public class UserRendererService(
|
||||||
.ToListAsync(ct)
|
.ToListAsync(ct)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
int? utcOffset = null;
|
||||||
|
if (
|
||||||
|
user.Timezone != null
|
||||||
|
&& TimeZoneInfo.TryFindSystemTimeZoneById(user.Timezone, out var tz)
|
||||||
|
)
|
||||||
|
utcOffset = (int)tz.GetUtcOffset(DateTimeOffset.UtcNow).TotalSeconds;
|
||||||
|
|
||||||
return new UserResponse(
|
return new UserResponse(
|
||||||
user.Id,
|
user.Id,
|
||||||
user.Sid,
|
overrideSid ?? user.Sid,
|
||||||
user.Username,
|
user.Username,
|
||||||
user.DisplayName,
|
user.DisplayName,
|
||||||
user.Bio,
|
user.Bio,
|
||||||
|
@ -63,6 +72,7 @@ public class UserRendererService(
|
||||||
user.Fields,
|
user.Fields,
|
||||||
user.CustomPreferences,
|
user.CustomPreferences,
|
||||||
flags.Select(f => RenderPrideFlag(f.PrideFlag)),
|
flags.Select(f => RenderPrideFlag(f.PrideFlag)),
|
||||||
|
utcOffset,
|
||||||
user.Role,
|
user.Role,
|
||||||
renderMembers
|
renderMembers
|
||||||
? members.Select(m => memberRenderer.RenderPartialMember(m, tokenHidden))
|
? members.Select(m => memberRenderer.RenderPartialMember(m, tokenHidden))
|
||||||
|
@ -70,7 +80,8 @@ public class UserRendererService(
|
||||||
renderAuthMethods ? authMethods.Select(RenderAuthMethod) : null,
|
renderAuthMethods ? authMethods.Select(RenderAuthMethod) : null,
|
||||||
tokenHidden ? user.ListHidden : null,
|
tokenHidden ? user.ListHidden : null,
|
||||||
tokenHidden ? user.LastActive : null,
|
tokenHidden ? user.LastActive : null,
|
||||||
tokenHidden ? user.LastSidReroll : null
|
tokenHidden ? user.LastSidReroll : null,
|
||||||
|
tokenHidden ? user.Timezone ?? "<none>" : null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -115,6 +126,7 @@ public class UserRendererService(
|
||||||
IEnumerable<Field> Fields,
|
IEnumerable<Field> Fields,
|
||||||
Dictionary<Snowflake, User.CustomPreference> CustomPreferences,
|
Dictionary<Snowflake, User.CustomPreference> CustomPreferences,
|
||||||
IEnumerable<PrideFlagResponse> Flags,
|
IEnumerable<PrideFlagResponse> Flags,
|
||||||
|
int? UtcOffset,
|
||||||
[property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] UserRole Role,
|
[property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] UserRole Role,
|
||||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||||
IEnumerable<MemberRendererService.PartialMember>? Members,
|
IEnumerable<MemberRendererService.PartialMember>? Members,
|
||||||
|
@ -124,7 +136,8 @@ public class UserRendererService(
|
||||||
bool? MemberListHidden,
|
bool? MemberListHidden,
|
||||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Instant? LastActive,
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Instant? LastActive,
|
||||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||||
Instant? LastSidReroll
|
Instant? LastSidReroll,
|
||||||
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? Timezone
|
||||||
);
|
);
|
||||||
|
|
||||||
public record AuthMethodResponse(
|
public record AuthMethodResponse(
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
# Example .env file--DO NOT EDIT
|
# Example .env file--DO NOT EDIT
|
||||||
PUBLIC_LANGUAGE=en
|
PUBLIC_LANGUAGE=en
|
||||||
|
PUBLIC_BASE_URL=https://pronouns.cc
|
||||||
|
PUBLIC_SHORT_URL=https://prns.cc
|
||||||
PUBLIC_API_BASE=https://pronouns.cc/api
|
PUBLIC_API_BASE=https://pronouns.cc/api
|
||||||
PRIVATE_API_HOST=http://localhost:5003/api
|
PRIVATE_API_HOST=http://localhost:5003/api
|
||||||
PRIVATE_INTERNAL_API_HOST=http://localhost:5000/api
|
PRIVATE_INTERNAL_API_HOST=http://localhost:5000/api
|
||||||
|
|
|
@ -38,9 +38,11 @@
|
||||||
"packageManager": "pnpm@9.12.3+sha512.cce0f9de9c5a7c95bef944169cc5dfe8741abfb145078c0d508b868056848a87c81e626246cb60967cbd7fd29a6c062ef73ff840d96b3c86c40ac92cf4a813ee",
|
"packageManager": "pnpm@9.12.3+sha512.cce0f9de9c5a7c95bef944169cc5dfe8741abfb145078c0d508b868056848a87c81e626246cb60967cbd7fd29a6c062ef73ff840d96b3c86c40ac92cf4a813ee",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/firago": "^5.1.0",
|
"@fontsource/firago": "^5.1.0",
|
||||||
|
"base64-arraybuffer": "^1.0.2",
|
||||||
"bootstrap-icons": "^1.11.3",
|
"bootstrap-icons": "^1.11.3",
|
||||||
"luxon": "^3.5.0",
|
"luxon": "^3.5.0",
|
||||||
"markdown-it": "^14.1.0",
|
"markdown-it": "^14.1.0",
|
||||||
|
"pretty-bytes": "^6.1.1",
|
||||||
"sanitize-html": "^2.13.1",
|
"sanitize-html": "^2.13.1",
|
||||||
"tslog": "^4.9.3"
|
"tslog": "^4.9.3"
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,6 +11,9 @@ importers:
|
||||||
'@fontsource/firago':
|
'@fontsource/firago':
|
||||||
specifier: ^5.1.0
|
specifier: ^5.1.0
|
||||||
version: 5.1.0
|
version: 5.1.0
|
||||||
|
base64-arraybuffer:
|
||||||
|
specifier: ^1.0.2
|
||||||
|
version: 1.0.2
|
||||||
bootstrap-icons:
|
bootstrap-icons:
|
||||||
specifier: ^1.11.3
|
specifier: ^1.11.3
|
||||||
version: 1.11.3
|
version: 1.11.3
|
||||||
|
@ -20,6 +23,9 @@ importers:
|
||||||
markdown-it:
|
markdown-it:
|
||||||
specifier: ^14.1.0
|
specifier: ^14.1.0
|
||||||
version: 14.1.0
|
version: 14.1.0
|
||||||
|
pretty-bytes:
|
||||||
|
specifier: ^6.1.1
|
||||||
|
version: 6.1.1
|
||||||
sanitize-html:
|
sanitize-html:
|
||||||
specifier: ^2.13.1
|
specifier: ^2.13.1
|
||||||
version: 2.13.1
|
version: 2.13.1
|
||||||
|
@ -704,6 +710,10 @@ packages:
|
||||||
balanced-match@1.0.2:
|
balanced-match@1.0.2:
|
||||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||||
|
|
||||||
|
base64-arraybuffer@1.0.2:
|
||||||
|
resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==}
|
||||||
|
engines: {node: '>= 0.6.0'}
|
||||||
|
|
||||||
bootstrap-icons@1.11.3:
|
bootstrap-icons@1.11.3:
|
||||||
resolution: {integrity: sha512-+3lpHrCw/it2/7lBL15VR0HEumaBss0+f/Lb6ZvHISn1mlK83jjFpooTLsMWbIjJMDjDjOExMsTxnXSIT4k4ww==}
|
resolution: {integrity: sha512-+3lpHrCw/it2/7lBL15VR0HEumaBss0+f/Lb6ZvHISn1mlK83jjFpooTLsMWbIjJMDjDjOExMsTxnXSIT4k4ww==}
|
||||||
|
|
||||||
|
@ -1211,6 +1221,10 @@ packages:
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
pretty-bytes@6.1.1:
|
||||||
|
resolution: {integrity: sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==}
|
||||||
|
engines: {node: ^14.13.1 || >=16.0.0}
|
||||||
|
|
||||||
punycode.js@2.3.1:
|
punycode.js@2.3.1:
|
||||||
resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==}
|
resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
@ -1938,6 +1952,8 @@ snapshots:
|
||||||
|
|
||||||
balanced-match@1.0.2: {}
|
balanced-match@1.0.2: {}
|
||||||
|
|
||||||
|
base64-arraybuffer@1.0.2: {}
|
||||||
|
|
||||||
bootstrap-icons@1.11.3: {}
|
bootstrap-icons@1.11.3: {}
|
||||||
|
|
||||||
bootstrap@5.3.3(@popperjs/core@2.11.8):
|
bootstrap@5.3.3(@popperjs/core@2.11.8):
|
||||||
|
@ -2432,6 +2448,8 @@ snapshots:
|
||||||
|
|
||||||
prettier@3.3.3: {}
|
prettier@3.3.3: {}
|
||||||
|
|
||||||
|
pretty-bytes@6.1.1: {}
|
||||||
|
|
||||||
punycode.js@2.3.1: {}
|
punycode.js@2.3.1: {}
|
||||||
|
|
||||||
punycode@2.3.1: {}
|
punycode@2.3.1: {}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
export type PartialUser = {
|
export type PartialUser = {
|
||||||
id: string;
|
id: string;
|
||||||
|
sid: string;
|
||||||
username: string;
|
username: string;
|
||||||
display_name: string | null;
|
display_name: string | null;
|
||||||
avatar_url: string | null;
|
avatar_url: string | null;
|
||||||
|
@ -14,17 +15,20 @@ export type User = PartialUser & {
|
||||||
pronouns: Pronoun[];
|
pronouns: Pronoun[];
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
flags: PrideFlag[];
|
flags: PrideFlag[];
|
||||||
|
utc_offset: number | null;
|
||||||
role: "USER" | "MODERATOR" | "ADMIN";
|
role: "USER" | "MODERATOR" | "ADMIN";
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MeUser = UserWithMembers & {
|
export type MeUser = UserWithMembers & {
|
||||||
|
members: PartialMember[];
|
||||||
auth_methods: AuthMethod[];
|
auth_methods: AuthMethod[];
|
||||||
member_list_hidden: boolean;
|
member_list_hidden: boolean;
|
||||||
last_active: string;
|
last_active: string;
|
||||||
last_sid_reroll: string;
|
last_sid_reroll: string;
|
||||||
|
timezone: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UserWithMembers = User & { members: PartialMember[] };
|
export type UserWithMembers = User & { members: PartialMember[] | null };
|
||||||
|
|
||||||
export type UserWithHiddenFields = User & {
|
export type UserWithHiddenFields = User & {
|
||||||
auth_methods?: unknown[];
|
auth_methods?: unknown[];
|
||||||
|
@ -38,6 +42,7 @@ export type UserSettings = {
|
||||||
|
|
||||||
export type PartialMember = {
|
export type PartialMember = {
|
||||||
id: string;
|
id: string;
|
||||||
|
sid: string;
|
||||||
name: string;
|
name: string;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
bio: string | null;
|
bio: string | null;
|
||||||
|
|
|
@ -1,14 +1,23 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { DEFAULT_AVATAR } from "$lib";
|
import { DEFAULT_AVATAR } from "$lib";
|
||||||
|
|
||||||
type Props = { url: string | null; alt: string; lazyLoad?: boolean; width?: number };
|
type Props = { url: string | null; alt: string; lazyLoad?: boolean; size?: number };
|
||||||
let { url, alt, lazyLoad, width }: Props = $props();
|
let { url, alt, lazyLoad, size }: Props = $props();
|
||||||
|
|
||||||
|
let width = $derived(size || 200);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<img
|
<img
|
||||||
class="rounded-circle img-fluid"
|
class="rounded-circle img-fluid"
|
||||||
|
style="height: {width}px; width: {width}px"
|
||||||
src={url || DEFAULT_AVATAR}
|
src={url || DEFAULT_AVATAR}
|
||||||
{alt}
|
{alt}
|
||||||
width={width || 200}
|
{width}
|
||||||
loading={lazyLoad ? "lazy" : "eager"}
|
loading={lazyLoad ? "lazy" : "eager"}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
img {
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
35
Foxnouns.Frontend/src/lib/components/Paginator.svelte
Normal file
35
Foxnouns.Frontend/src/lib/components/Paginator.svelte
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { Pagination, PaginationItem, PaginationLink } from "@sveltestrap/sveltestrap";
|
||||||
|
|
||||||
|
type Props = { currentPage: number; pageCount: number; href: string; center?: boolean };
|
||||||
|
let { currentPage, pageCount, href, center }: Props = $props();
|
||||||
|
|
||||||
|
let prevPage = $derived(currentPage > 0 ? currentPage - 1 : 0);
|
||||||
|
let prevLink = $derived(prevPage !== 0 ? `${href}?page=${prevPage}` : href);
|
||||||
|
|
||||||
|
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 {href} />
|
||||||
|
</PaginationItem>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationLink previous href={prevLink} />
|
||||||
|
</PaginationItem>
|
||||||
|
{#each new Array(pageCount) as _, page}
|
||||||
|
<PaginationItem active={page === currentPage}>
|
||||||
|
<PaginationLink href="{href}?page={page}">{page + 1}</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
|
{/each}
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationLink next href="{href}?page={nextPage}" />
|
||||||
|
</PaginationItem>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationLink last href="{href}?page={pageCount - 1}" />
|
||||||
|
</PaginationItem>
|
||||||
|
</Pagination>
|
||||||
|
</div>
|
||||||
|
{/if}
|
|
@ -0,0 +1,80 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import Avatar from "$components/Avatar.svelte";
|
||||||
|
import { t } from "$lib/i18n";
|
||||||
|
import { Icon, InputGroup } from "@sveltestrap/sveltestrap";
|
||||||
|
import { encode } from "base64-arraybuffer";
|
||||||
|
import prettyBytes from "pretty-bytes";
|
||||||
|
import ShortNoscriptWarning from "./ShortNoscriptWarning.svelte";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
current: string | null;
|
||||||
|
alt: string;
|
||||||
|
update: (avatar: string) => Promise<void>;
|
||||||
|
updated: boolean;
|
||||||
|
};
|
||||||
|
let { current, alt, update: onclick, updated }: Props = $props();
|
||||||
|
|
||||||
|
const MAX_AVATAR_BYTES = 1_000_000;
|
||||||
|
|
||||||
|
let avatarFiles: FileList | null = $state(null);
|
||||||
|
let avatar: string = $state("");
|
||||||
|
let avatarExists = $derived(avatar !== "");
|
||||||
|
let avatarTooLarge = $derived(avatar !== "" && avatar.length > MAX_AVATAR_BYTES);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
getAvatar(avatarFiles);
|
||||||
|
});
|
||||||
|
|
||||||
|
const getAvatar = async (list: FileList | null) => {
|
||||||
|
if (!list || list.length === 0) {
|
||||||
|
avatar = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = await list[0].arrayBuffer();
|
||||||
|
const base64 = encode(buffer);
|
||||||
|
|
||||||
|
const uri = `data:${list[0].type};base64,${base64}`;
|
||||||
|
avatar = uri;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<p class="text-center">
|
||||||
|
<Avatar url={avatarExists ? avatar : current} {alt} />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<InputGroup class="mb-2">
|
||||||
|
<input
|
||||||
|
class="form-control"
|
||||||
|
id="avatar"
|
||||||
|
type="file"
|
||||||
|
bind:files={avatarFiles}
|
||||||
|
accept="image/png, image/jpeg, image/gif, image/webp"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="btn btn-secondary"
|
||||||
|
disabled={!avatarExists || avatarTooLarge}
|
||||||
|
onclick={() => onclick(avatar)}
|
||||||
|
>
|
||||||
|
{$t("edit-profile.update-avatar")}
|
||||||
|
</button>
|
||||||
|
</InputGroup>
|
||||||
|
|
||||||
|
<ShortNoscriptWarning />
|
||||||
|
|
||||||
|
{#if updated}
|
||||||
|
<p class="text-success-emphasis">
|
||||||
|
<Icon name="check-circle-fill" />
|
||||||
|
{$t("edit-profile.avatar-updated")}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if avatarTooLarge}
|
||||||
|
<p class="text-danger-emphasis">
|
||||||
|
<Icon name="exclamation-circle-fill" />
|
||||||
|
{$t("edit-profile.file-too-large", {
|
||||||
|
max: prettyBytes(MAX_AVATAR_BYTES),
|
||||||
|
current: prettyBytes(avatar.length),
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
{/if}
|
26
Foxnouns.Frontend/src/lib/components/editor/BioEditor.svelte
Normal file
26
Foxnouns.Frontend/src/lib/components/editor/BioEditor.svelte
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { t } from "$lib/i18n";
|
||||||
|
import { renderMarkdown } from "$lib/markdown";
|
||||||
|
|
||||||
|
type Props = { value: string; maxLength: number };
|
||||||
|
let { value = $bindable(), maxLength }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<textarea name="bio" class="form-control" style="height: 200px;" bind:value></textarea>
|
||||||
|
<button disabled={value.length > maxLength} type="submit" class="btn btn-primary mt-2 my-1">
|
||||||
|
{$t("save-changes")}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p class="text-muted mt-1">
|
||||||
|
{$t("edit-profile.bio-length-hint", {
|
||||||
|
length: value.length,
|
||||||
|
maxLength,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#if value !== ""}
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">{$t("edit-profile.preview")}</div>
|
||||||
|
<div class="card-body">{@html renderMarkdown(value)}</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
|
@ -0,0 +1,18 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { Icon } from "@sveltestrap/sveltestrap";
|
||||||
|
import { t } from "$lib/i18n";
|
||||||
|
import type { RawApiError } from "$api/error";
|
||||||
|
import ErrorAlert from "$components/ErrorAlert.svelte";
|
||||||
|
|
||||||
|
type Props = { form: { error: RawApiError | null; ok: boolean } | null };
|
||||||
|
let { form }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if form?.error}
|
||||||
|
<ErrorAlert error={form.error} />
|
||||||
|
{:else if form?.ok}
|
||||||
|
<p class="text-success-emphasis">
|
||||||
|
<Icon name="check-circle-fill" />
|
||||||
|
{$t("edit-profile.saved-changes")}
|
||||||
|
</p>
|
||||||
|
{/if}
|
|
@ -0,0 +1,12 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { t } from "$lib/i18n";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<noscript>
|
||||||
|
<div class="alert alert-secondary">
|
||||||
|
<h4>{$t("error.noscript-title")}</h4>
|
||||||
|
<p>
|
||||||
|
{$t("error.noscript-info")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</noscript>
|
|
@ -0,0 +1,11 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { t } from "$lib/i18n";
|
||||||
|
import { Icon } from "@sveltestrap/sveltestrap";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<noscript>
|
||||||
|
<p class="text-danger-emphasis">
|
||||||
|
<Icon name="exclamation-circle-fill" aria-hidden />
|
||||||
|
{$t("error.noscript-short")}
|
||||||
|
</p>
|
||||||
|
</noscript>
|
30
Foxnouns.Frontend/src/lib/components/editor/SidEditor.svelte
Normal file
30
Foxnouns.Frontend/src/lib/components/editor/SidEditor.svelte
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { PUBLIC_SHORT_URL } from "$env/static/public";
|
||||||
|
import { t } from "$lib/i18n";
|
||||||
|
import { ButtonGroup, Button, Icon } from "@sveltestrap/sveltestrap";
|
||||||
|
import ShortNoscriptWarning from "./ShortNoscriptWarning.svelte";
|
||||||
|
|
||||||
|
type Props = { sid: string; rerollSid: () => Promise<void>; canRerollSid: boolean };
|
||||||
|
let { sid, rerollSid, canRerollSid }: Props = $props();
|
||||||
|
|
||||||
|
const copySid = async () => {
|
||||||
|
const url = `${PUBLIC_SHORT_URL}/${sid}`;
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{$t("edit-profile.sid-current")} <code>{sid}</code>
|
||||||
|
<ButtonGroup class="mb-1">
|
||||||
|
<Button color="secondary" onclick={() => rerollSid()} disabled={!canRerollSid}>
|
||||||
|
{$t("edit-profile.sid-reroll")}
|
||||||
|
</Button>
|
||||||
|
<Button color="secondary" onclick={() => copySid()}>
|
||||||
|
<Icon name="link-45deg" aria-hidden />
|
||||||
|
<span class="visually-hidden">{$t("edit-profile.sid-copy")}</span>
|
||||||
|
</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
<ShortNoscriptWarning />
|
||||||
|
<p class="text-muted">
|
||||||
|
<Icon name="info-circle-fill" aria-hidden />
|
||||||
|
{$t("edit-profile.sid-hint")}
|
||||||
|
</p>
|
|
@ -35,11 +35,15 @@
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<a href="/@{username}/{member.name}">
|
<a href="/@{username}/{member.name}">
|
||||||
<Avatar url={member.avatar_url} lazyLoad alt={$t("avatar-tooltip", { name: member.name })} />
|
<Avatar
|
||||||
|
url={member.avatar_url}
|
||||||
|
lazyLoad
|
||||||
|
alt={$t("avatar-tooltip", { name: member.display_name })}
|
||||||
|
/>
|
||||||
</a>
|
</a>
|
||||||
<p class="m-2">
|
<p class="m-2">
|
||||||
<a class="text-reset fs-5 text-break" href="/@{username}/{member.name}">
|
<a class="text-reset fs-5 text-break" href="/@{username}/{member.name}">
|
||||||
{member.name}
|
{member.display_name}
|
||||||
</a>
|
</a>
|
||||||
{#if pronouns}
|
{#if pronouns}
|
||||||
<br />
|
<br />
|
||||||
|
|
|
@ -1,91 +1,136 @@
|
||||||
{
|
{
|
||||||
"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}}"
|
||||||
"title": {
|
},
|
||||||
"log-in": "Log in",
|
"title": {
|
||||||
"welcome": "Welcome",
|
"log-in": "Log in",
|
||||||
"settings": "Settings"
|
"welcome": "Welcome",
|
||||||
},
|
"settings": "Settings"
|
||||||
"auth": {
|
},
|
||||||
"log-in-form-title": "Log in with email",
|
"auth": {
|
||||||
"log-in-form-email-label": "Email address",
|
"log-in-form-title": "Log in with email",
|
||||||
"log-in-form-password-label": "Password",
|
"log-in-form-email-label": "Email address",
|
||||||
"register-with-email-button": "Register with email",
|
"log-in-form-password-label": "Password",
|
||||||
"log-in-button": "Log in",
|
"register-with-email-button": "Register with email",
|
||||||
"log-in-3rd-party-header": "Log in with another service",
|
"log-in-button": "Log in",
|
||||||
"log-in-3rd-party-desc": "If you prefer, you can also log in with one of these services:",
|
"log-in-3rd-party-header": "Log in with another service",
|
||||||
"log-in-with-discord": "Log in with Discord",
|
"log-in-3rd-party-desc": "If you prefer, you can also log in with one of these services:",
|
||||||
"log-in-with-google": "Log in with Google",
|
"log-in-with-discord": "Log in with Discord",
|
||||||
"log-in-with-tumblr": "Log in with Tumblr",
|
"log-in-with-google": "Log in with Google",
|
||||||
"log-in-with-the-fediverse": "Log in with the Fediverse",
|
"log-in-with-tumblr": "Log in with Tumblr",
|
||||||
"remote-fediverse-account-label": "Your Fediverse account",
|
"log-in-with-the-fediverse": "Log in with the Fediverse",
|
||||||
"register-username-label": "Username",
|
"remote-fediverse-account-label": "Your Fediverse account",
|
||||||
"register-button": "Register account",
|
"register-username-label": "Username",
|
||||||
"register-with-mastodon": "Register with a Fediverse account",
|
"register-button": "Register account",
|
||||||
"log-in-with-fediverse-error-blurb": "Is your instance returning an error?",
|
"register-with-mastodon": "Register with a Fediverse account",
|
||||||
"log-in-with-fediverse-force-refresh-button": "Force a refresh on our end"
|
"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"
|
||||||
"error": {
|
},
|
||||||
"bad-request-header": "Something was wrong with your input",
|
"error": {
|
||||||
"generic-header": "Something went wrong",
|
"bad-request-header": "Something was wrong with your input",
|
||||||
"raw-header": "Raw error",
|
"generic-header": "Something went wrong",
|
||||||
"authentication-error": "Something went wrong when logging you in.",
|
"raw-header": "Raw error",
|
||||||
"bad-request": "Your input was rejected by the server, please check for any mistakes and try again.",
|
"authentication-error": "Something went wrong when logging you in.",
|
||||||
"forbidden": "You are not allowed to perform that action.",
|
"bad-request": "Your input was rejected by the server, please check for any mistakes and try again.",
|
||||||
"internal-server-error": "Server experienced an internal error, please try again later.",
|
"forbidden": "You are not allowed to perform that action.",
|
||||||
"authentication-required": "You need to log in first.",
|
"internal-server-error": "Server experienced an internal error, please try again later.",
|
||||||
"missing-scopes": "The current token is missing a required scope. Did you manually edit your cookies?",
|
"authentication-required": "You need to log in first.",
|
||||||
"generic-error": "An unknown error occurred.",
|
"missing-scopes": "The current token is missing a required scope. Did you manually edit your cookies?",
|
||||||
"user-not-found": "User not found, please check your spelling and try again. Remember that usernames are case sensitive.",
|
"generic-error": "An unknown error occurred.",
|
||||||
"member-not-found": "Member not found, please check your spelling and try again.",
|
"user-not-found": "User not found, please check your spelling and try again. Remember that usernames are case sensitive.",
|
||||||
"account-already-linked": "This account is already linked with a pronouns.cc account.",
|
"member-not-found": "Member not found, please check your spelling and try again.",
|
||||||
"last-auth-method": "You cannot remove your last authentication method.",
|
"account-already-linked": "This account is already linked with a pronouns.cc account.",
|
||||||
"validation-max-length-error": "Value is too long, maximum length is {{max}}, current length is {{actual}}.",
|
"last-auth-method": "You cannot remove your last authentication method.",
|
||||||
"validation-min-length-error": "Value is too long, minimum length is {{min}}, current length is {{actual}}.",
|
"validation-max-length-error": "Value is too long, maximum length is {{max}}, current length is {{actual}}.",
|
||||||
"validation-disallowed-value-1": "The following value is not allowed here",
|
"validation-min-length-error": "Value is too long, minimum length is {{min}}, current length is {{actual}}.",
|
||||||
"validation-disallowed-value-2": "Allowed values are",
|
"validation-disallowed-value-1": "The following value is not allowed here",
|
||||||
"validation-reason": "Reason",
|
"validation-disallowed-value-2": "Allowed values are",
|
||||||
"validation-generic": "The value you entered is not allowed here. Reason",
|
"validation-reason": "Reason",
|
||||||
"extra-info-header": "Extra error information"
|
"validation-generic": "The value you entered is not allowed here. Reason",
|
||||||
},
|
"extra-info-header": "Extra error information",
|
||||||
"settings": {
|
"noscript-title": "This page requires JavaScript",
|
||||||
"general-information-tab": "General information",
|
"noscript-info": "This page requires JavaScript to function correctly. Some buttons may not work, or the page may not work at all.",
|
||||||
"your-profile-tab": "Your profile",
|
"noscript-short": "Requires JavaScript"
|
||||||
"members-tab": "Members",
|
},
|
||||||
"authentication-tab": "Authentication",
|
"settings": {
|
||||||
"export-tab": "Export your data",
|
"general-information-tab": "General information",
|
||||||
"change-username-button": "Change username",
|
"your-profile-tab": "Your profile",
|
||||||
"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.",
|
"members-tab": "Members",
|
||||||
"username-update-error": "Could not update your username as the new username is invalid:\n{{message}}",
|
"authentication-tab": "Authentication",
|
||||||
"change-avatar-link": "Change your avatar here",
|
"export-tab": "Export your data",
|
||||||
"new-username": "New username",
|
"change-username-button": "Change username",
|
||||||
"table-role": "Role",
|
"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.",
|
||||||
"table-custom-preferences": "Custom preferences",
|
"username-update-error": "Could not update your username as the new username is invalid:\n{{message}}",
|
||||||
"table-member-list-hidden": "Member list hidden?",
|
"change-avatar-link": "Change your avatar here",
|
||||||
"table-member-count": "Member count",
|
"new-username": "New username",
|
||||||
"table-created-at": "Account created at",
|
"table-role": "Role",
|
||||||
"table-id": "Your ID",
|
"table-custom-preferences": "Custom preferences",
|
||||||
"table-title": "Account information",
|
"table-member-list-hidden": "Member list hidden?",
|
||||||
"force-log-out-title": "Log out everywhere",
|
"table-member-count": "Member count",
|
||||||
"force-log-out-button": "Force log out",
|
"table-created-at": "Account created at",
|
||||||
"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.",
|
"table-id": "Your ID",
|
||||||
"log-out-title": "Log out",
|
"table-title": "Account information",
|
||||||
"log-out-hint": "Use this button to log out on this device only.",
|
"force-log-out-title": "Log out everywhere",
|
||||||
"log-out-button": "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.",
|
||||||
"yes": "Yes",
|
"log-out-title": "Log out",
|
||||||
"no": "No"
|
"log-out-hint": "Use this button to log out on this device only.",
|
||||||
|
"log-out-button": "Log out",
|
||||||
|
"avatar": "Avatar",
|
||||||
|
"username-update-success": "Successfully changed your username!",
|
||||||
|
"create-member-title": "Create a new member",
|
||||||
|
"create-member-name-label": "Member name"
|
||||||
|
},
|
||||||
|
"yes": "Yes",
|
||||||
|
"no": "No",
|
||||||
|
"edit-profile": {
|
||||||
|
"user-header": "Editing your profile",
|
||||||
|
"general-tab": "General",
|
||||||
|
"names-pronouns-tab": "Names & pronouns",
|
||||||
|
"file-too-large": "This file is too large, please resize it (maximum is {{max}}, the file you're trying to upload is {{current}})",
|
||||||
|
"sid-current": "Current short ID:",
|
||||||
|
"sid": "Short ID",
|
||||||
|
"sid-reroll": "Reroll short ID",
|
||||||
|
"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.",
|
||||||
|
"sid-copy": "Copy short link",
|
||||||
|
"update-avatar": "Update avatar",
|
||||||
|
"avatar-updated": "Avatar updated! It might take a moment to be reflected on your profile.",
|
||||||
|
"member-header-label": "\"Members\" header text",
|
||||||
|
"member-header-info": "This is the text used for the \"Members\" heading. If you leave it blank, the default text will be used.",
|
||||||
|
"hide-member-list-label": "Hide member list",
|
||||||
|
"timezone-label": "Timezone",
|
||||||
|
"timezone-preview": "This will show up on your profile like this:",
|
||||||
|
"timezone-info": "This is optional. Your timezone is never shared directly, only the difference between UTC and your current timezone is.",
|
||||||
|
"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.",
|
||||||
|
"profile-options-header": "Profile options",
|
||||||
|
"bio-tab": "Bio",
|
||||||
|
"saved-changes": "Successfully saved changes!",
|
||||||
|
"bio-length-hint": "Using {{length}}/{{maxLength}} characters",
|
||||||
|
"preview": "Preview",
|
||||||
|
"fields-tab": "Fields",
|
||||||
|
"flags-links-tab": "Flags & links",
|
||||||
|
"back-to-settings-tab": "Back to settings",
|
||||||
|
"member-header": "Editing member {{name}}",
|
||||||
|
"username": "Username",
|
||||||
|
"change-username-info": "As changing your username will also change all of your members' links, you can only change it in your account settings.",
|
||||||
|
"change-username-link": "Go to settings",
|
||||||
|
"member-name": "Name",
|
||||||
|
"change-member-name": "Change name",
|
||||||
|
"display-name": "Display name"
|
||||||
|
},
|
||||||
|
"save-changes": "Save changes",
|
||||||
|
"change": "Change"
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,10 +6,12 @@ import log from "$lib/log";
|
||||||
import type { LayoutServerLoad } from "./$types";
|
import type { LayoutServerLoad } from "./$types";
|
||||||
|
|
||||||
export const load = (async ({ fetch, cookies }) => {
|
export const load = (async ({ fetch, cookies }) => {
|
||||||
|
let token: string | null = null;
|
||||||
let meUser: MeUser | null = null;
|
let meUser: MeUser | null = null;
|
||||||
if (cookies.get(TOKEN_COOKIE_NAME)) {
|
if (cookies.get(TOKEN_COOKIE_NAME)) {
|
||||||
try {
|
try {
|
||||||
meUser = await apiRequest<MeUser>("GET", "/users/@me", { fetch, cookies });
|
meUser = await apiRequest<MeUser>("GET", "/users/@me", { fetch, cookies });
|
||||||
|
token = cookies.get(TOKEN_COOKIE_NAME) || null;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof ApiError && e.code === ErrorCode.AuthenticationRequired) clearToken(cookies);
|
if (e instanceof ApiError && e.code === ErrorCode.AuthenticationRequired) clearToken(cookies);
|
||||||
else log.error("Could not fetch /users/@me and token has not expired:", e);
|
else log.error("Could not fetch /users/@me and token has not expired:", e);
|
||||||
|
@ -17,5 +19,5 @@ export const load = (async ({ fetch, cookies }) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const meta = await apiRequest<Meta>("GET", "/meta", { fetch, cookies });
|
const meta = await apiRequest<Meta>("GET", "/meta", { fetch, cookies });
|
||||||
return { meta, meUser };
|
return { meta, meUser, token };
|
||||||
}) satisfies LayoutServerLoad;
|
}) satisfies LayoutServerLoad;
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
import { apiRequest } from "$api";
|
import { apiRequest } from "$api";
|
||||||
import type { UserWithMembers } from "$api/models";
|
import type { PartialMember, UserWithMembers } from "$api/models";
|
||||||
|
|
||||||
|
const MEMBERS_PER_PAGE = 20;
|
||||||
|
|
||||||
export const load = async ({ params, fetch, cookies, url }) => {
|
export const load = async ({ params, fetch, cookies, url }) => {
|
||||||
const user = await apiRequest<UserWithMembers>("GET", `/users/${params.username}`, {
|
const user = await apiRequest<UserWithMembers>("GET", `/users/${params.username}`, {
|
||||||
|
@ -8,12 +10,20 @@ export const load = async ({ params, fetch, cookies, url }) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Paginate members on the server side
|
// Paginate members on the server side
|
||||||
let currentPage = Number(url.searchParams.get("page") || "0");
|
let currentPage = 0;
|
||||||
const pageCount = Math.ceil(user.members.length / 20);
|
let pageCount = 0;
|
||||||
let members = user.members.slice(currentPage * 20, (currentPage + 1) * 20);
|
let members: PartialMember[] = [];
|
||||||
if (members.length === 0) {
|
if (user.members) {
|
||||||
members = user.members.slice(0, 20);
|
currentPage = Number(url.searchParams.get("page") || "0");
|
||||||
currentPage = 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, currentPage, pageCount };
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
import ProfileFields from "$components/profile/ProfileFields.svelte";
|
import ProfileFields from "$components/profile/ProfileFields.svelte";
|
||||||
import { t } from "$lib/i18n";
|
import { t } from "$lib/i18n";
|
||||||
import { Icon } from "@sveltestrap/sveltestrap";
|
import { Icon } from "@sveltestrap/sveltestrap";
|
||||||
import Paginator from "./Paginator.svelte";
|
import Paginator from "$components/Paginator.svelte";
|
||||||
import MemberCard from "$components/profile/user/MemberCard.svelte";
|
import MemberCard from "$components/profile/user/MemberCard.svelte";
|
||||||
|
|
||||||
type Props = { data: PageData };
|
type Props = { data: PageData };
|
||||||
|
@ -25,7 +25,7 @@
|
||||||
<OwnProfileNotice editLink="/settings/profile" />
|
<OwnProfileNotice editLink="/settings/profile" />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<ProfileHeader name="@{data.user.username}" profile={data.user} lazyLoadAvatar={true} />
|
<ProfileHeader name="@{data.user.username}" profile={data.user}/>
|
||||||
<ProfileFields profile={data.user} {allPreferences} />
|
<ProfileFields profile={data.user} {allPreferences} />
|
||||||
|
|
||||||
{#if data.members.length > 0}
|
{#if data.members.length > 0}
|
||||||
|
@ -34,27 +34,27 @@
|
||||||
{data.user.member_title || $t("profile.default-members-header")}
|
{data.user.member_title || $t("profile.default-members-header")}
|
||||||
{#if isMeUser}
|
{#if isMeUser}
|
||||||
<a class="btn btn-success" href="/settings/create-member">
|
<a class="btn btn-success" href="/settings/create-member">
|
||||||
<Icon name="person-plus-fill" aria-hidden={true} />
|
<Icon name="person-plus-fill" aria-hidden />
|
||||||
{$t("profile.create-member-button")}
|
{$t("profile.create-member-button")}
|
||||||
</a>
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
<Paginator
|
|
||||||
currentPage={data.currentPage}
|
|
||||||
pageCount={data.pageCount}
|
|
||||||
href="/@{data.user.username}"
|
|
||||||
/>
|
|
||||||
</h2>
|
</h2>
|
||||||
|
<Paginator
|
||||||
|
center
|
||||||
|
currentPage={data.currentPage}
|
||||||
|
pageCount={data.pageCount}
|
||||||
|
href="/@{data.user.username}"
|
||||||
|
/>
|
||||||
<div class="row row-cols-2 row-cols-md-3 row-cols-lg-4 row-cols-xl-5 text-center">
|
<div class="row row-cols-2 row-cols-md-3 row-cols-lg-4 row-cols-xl-5 text-center">
|
||||||
{#each data.members as member (member.id)}
|
{#each data.members as member (member.id)}
|
||||||
<MemberCard username={data.user.username} {member} {allPreferences} />
|
<MemberCard username={data.user.username} {member} {allPreferences} />
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-center">
|
<Paginator
|
||||||
<Paginator
|
center
|
||||||
currentPage={data.currentPage}
|
currentPage={data.currentPage}
|
||||||
pageCount={data.pageCount}
|
pageCount={data.pageCount}
|
||||||
href="/@{data.user.username}"
|
href="/@{data.user.username}"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,31 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { Pagination, PaginationItem, PaginationLink } from "@sveltestrap/sveltestrap";
|
|
||||||
|
|
||||||
type Props = { currentPage: number; pageCount: number; href: string };
|
|
||||||
let { currentPage, pageCount, href }: Props = $props();
|
|
||||||
|
|
||||||
let prevPage = $derived(currentPage > 0 ? currentPage - 1 : 0);
|
|
||||||
let prevLink = $derived(prevPage !== 0 ? `${href}?page=${prevPage}` : href);
|
|
||||||
|
|
||||||
let nextPage = $derived(currentPage < pageCount - 1 ? currentPage + 1 : pageCount - 1);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if pageCount > 1}
|
|
||||||
<Pagination>
|
|
||||||
<PaginationItem>
|
|
||||||
<PaginationLink first {href} />
|
|
||||||
</PaginationItem>
|
|
||||||
<PaginationItem>
|
|
||||||
<PaginationLink previous href={prevLink} />
|
|
||||||
</PaginationItem>
|
|
||||||
<PaginationItem active>
|
|
||||||
<PaginationLink href="{href}?page={currentPage}">{currentPage + 1}</PaginationLink>
|
|
||||||
</PaginationItem>
|
|
||||||
<PaginationItem>
|
|
||||||
<PaginationLink next href="{href}?page={nextPage}" />
|
|
||||||
</PaginationItem>
|
|
||||||
<PaginationItem>
|
|
||||||
<PaginationLink last href="{href}?page={pageCount - 1}" />
|
|
||||||
</PaginationItem>
|
|
||||||
</Pagination>
|
|
||||||
{/if}
|
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { apiRequest } from "$api";
|
||||||
|
import type { Member } from "$api/models/member";
|
||||||
|
|
||||||
|
export const load = async ({ params, fetch, cookies }) => {
|
||||||
|
const member = await apiRequest<Member>(
|
||||||
|
"GET",
|
||||||
|
`/users/${params.username}/members/${params.memberName}`,
|
||||||
|
{
|
||||||
|
fetch,
|
||||||
|
cookies,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return { member };
|
||||||
|
};
|
|
@ -0,0 +1,40 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { PageData } from "./$types";
|
||||||
|
import ProfileHeader from "$components/profile/ProfileHeader.svelte";
|
||||||
|
import OwnProfileNotice from "$components/profile/OwnProfileNotice.svelte";
|
||||||
|
import { mergePreferences } from "$api/models";
|
||||||
|
import ProfileFields from "$components/profile/ProfileFields.svelte";
|
||||||
|
import { Icon } from "@sveltestrap/sveltestrap";
|
||||||
|
import { t } from "$lib/i18n";
|
||||||
|
|
||||||
|
type Props = { data: PageData };
|
||||||
|
let { data }: Props = $props();
|
||||||
|
|
||||||
|
let allPreferences = $derived(mergePreferences(data.member.user.custom_preferences));
|
||||||
|
let isMeUser = $derived(data.meUser && data.meUser.id === data.member.user.id);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{data.member.display_name} • @{data.member.user.username} • pronouns.cc</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
{#if isMeUser}
|
||||||
|
<OwnProfileNotice
|
||||||
|
memberName={data.member.display_name}
|
||||||
|
editLink="/settings/members/{data.member.id}"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="m-3">
|
||||||
|
<a class="btn btn-secondary" href="/@{data.member.user.username}">
|
||||||
|
<Icon name="arrow-left" aria-hidden />
|
||||||
|
{$t("profile.back-to-user", {
|
||||||
|
name: data.member.user.display_name ?? data.member.user.username,
|
||||||
|
})}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ProfileHeader name="{data.member.name} (@{data.member.user.username})" profile={data.member} />
|
||||||
|
<ProfileFields profile={data.member} {allPreferences} />
|
||||||
|
</div>
|
|
@ -4,5 +4,5 @@ export const load = async ({ parent }) => {
|
||||||
const data = await parent();
|
const data = await parent();
|
||||||
if (!data.meUser) redirect(303, "/auth/log-in");
|
if (!data.meUser) redirect(303, "/auth/log-in");
|
||||||
|
|
||||||
return { user: data.meUser! };
|
return { user: data.meUser!, token: data.token! };
|
||||||
};
|
};
|
||||||
|
|
|
@ -29,7 +29,8 @@
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
{#if form?.ok}
|
{#if form?.ok}
|
||||||
<p class="text-success-emphasis">
|
<p class="text-success-emphasis">
|
||||||
<Icon name="check-circle-fill" /> Successfully changed your username!
|
<Icon name="check-circle-fill" />
|
||||||
|
{$t("settings.username-update-success")}
|
||||||
</p>
|
</p>
|
||||||
{:else if usernameError}
|
{:else if usernameError}
|
||||||
<p class="text-danger-emphasis text-has-newline">
|
<p class="text-danger-emphasis text-has-newline">
|
||||||
|
@ -46,7 +47,7 @@
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3 text-center">
|
<div class="col-md-3 text-center">
|
||||||
<h5>Avatar</h5>
|
<h5>{$t("settings.avatar")}</h5>
|
||||||
<Avatar
|
<Avatar
|
||||||
url={data.user.avatar_url}
|
url={data.user.avatar_url}
|
||||||
alt={$t("avatar-tooltip", { name: "@" + data.user.username })}
|
alt={$t("avatar-tooltip", { name: "@" + data.user.username })}
|
||||||
|
|
|
@ -0,0 +1,18 @@
|
||||||
|
const MEMBERS_PER_PAGE = 15;
|
||||||
|
|
||||||
|
export const load = async ({ url, parent }) => {
|
||||||
|
const { user } = await parent();
|
||||||
|
|
||||||
|
let currentPage = Number(url.searchParams.get("page") || "0");
|
||||||
|
let pageCount = Math.ceil(user.members.length / MEMBERS_PER_PAGE);
|
||||||
|
let 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 { members, currentPage, pageCount };
|
||||||
|
};
|
48
Foxnouns.Frontend/src/routes/settings/members/+page.svelte
Normal file
48
Foxnouns.Frontend/src/routes/settings/members/+page.svelte
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import Paginator from "$components/Paginator.svelte";
|
||||||
|
import { Icon, ListGroup, ListGroupItem } from "@sveltestrap/sveltestrap";
|
||||||
|
import { t } from "$lib/i18n";
|
||||||
|
import type { PageData } from "./$types";
|
||||||
|
import Avatar from "$components/Avatar.svelte";
|
||||||
|
|
||||||
|
type Props = { data: PageData };
|
||||||
|
let { data }: Props = $props();
|
||||||
|
|
||||||
|
let canCreateMember = $derived(data.user.members.length < data.meta.limits.member_count);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h3 class="mb-3">{$t("settings.members-tab")} ({data.user.members.length})</h3>
|
||||||
|
|
||||||
|
<Paginator
|
||||||
|
center
|
||||||
|
currentPage={data.currentPage}
|
||||||
|
pageCount={data.pageCount}
|
||||||
|
href="/settings/members"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ListGroup class="mb-3">
|
||||||
|
{#if canCreateMember}
|
||||||
|
<ListGroupItem tag="a" href="/settings/members/new">
|
||||||
|
<Icon name="person-fill-add" aria-hidden />
|
||||||
|
<strong>{$t("profile.create-member-button")}</strong>
|
||||||
|
</ListGroupItem>
|
||||||
|
{/if}
|
||||||
|
{#each data.members as member (member.id)}
|
||||||
|
<ListGroupItem tag="a" href="/settings/members/{member.id}" data-sveltekit-preload-data="tap">
|
||||||
|
<Avatar
|
||||||
|
url={member.avatar_url}
|
||||||
|
alt={$t("avatar-tooltip", { name: member.display_name })}
|
||||||
|
size={20}
|
||||||
|
/>
|
||||||
|
{member.display_name}
|
||||||
|
{#if member.display_name !== member.name}({member.name}){/if}
|
||||||
|
</ListGroupItem>
|
||||||
|
{/each}
|
||||||
|
</ListGroup>
|
||||||
|
|
||||||
|
<Paginator
|
||||||
|
center
|
||||||
|
currentPage={data.currentPage}
|
||||||
|
pageCount={data.pageCount}
|
||||||
|
href="/settings/members"
|
||||||
|
/>
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { apiRequest } from "$api";
|
||||||
|
import ApiError from "$api/error";
|
||||||
|
import type { Member } from "$api/models";
|
||||||
|
import log from "$lib/log";
|
||||||
|
import { redirect } from "@sveltejs/kit";
|
||||||
|
|
||||||
|
export const load = async ({ parent, params, fetch, cookies }) => {
|
||||||
|
const { meUser, token } = await parent();
|
||||||
|
if (!meUser) redirect(303, "/");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const member = await apiRequest<Member>("GET", `/users/@me/members/${params.id}`, {
|
||||||
|
fetch,
|
||||||
|
cookies,
|
||||||
|
});
|
||||||
|
return { user: meUser, token: token!, member };
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError) throw e.obj;
|
||||||
|
log.error("Error trying to fetch member %s:", params.id, e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
|
@ -0,0 +1,65 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
import { page } from "$app/stores";
|
||||||
|
import { t } from "$lib/i18n";
|
||||||
|
import type { LayoutData } from "./$types";
|
||||||
|
|
||||||
|
type Props = { data: LayoutData; children: Snippet };
|
||||||
|
let { data, children }: Props = $props();
|
||||||
|
|
||||||
|
const isActive = (path: string) => $page.url.pathname === path;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{$t("edit-profile.member-header", { name: data.member.name })} • pronouns.cc</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<h3>{$t("edit-profile.member-header", { name: data.member.name })}</h3>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-3 mt-1 mb-3">
|
||||||
|
<div class="list-group">
|
||||||
|
<a
|
||||||
|
href="/settings/members/{data.member.id}"
|
||||||
|
class="list-group-item list-group-item-action"
|
||||||
|
class:active={isActive(`/settings/members/${data.member.id}`)}
|
||||||
|
>
|
||||||
|
{$t("edit-profile.general-tab")}
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/settings/members/{data.member.id}/names-pronouns"
|
||||||
|
class="list-group-item list-group-item-action"
|
||||||
|
class:active={isActive(`/settings/members/${data.member.id}/names-pronouns`)}
|
||||||
|
>
|
||||||
|
{$t("edit-profile.names-pronouns-tab")}
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/settings/members/{data.member.id}/fields"
|
||||||
|
class="list-group-item list-group-item-action"
|
||||||
|
class:active={isActive(`/settings/members/${data.member.id}/fields`)}
|
||||||
|
>
|
||||||
|
{$t("edit-profile.fields-tab")}
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/settings/members/{data.member.id}/flags-links"
|
||||||
|
class="list-group-item list-group-item-action"
|
||||||
|
class:active={isActive(`/settings/members/${data.member.id}/flags-links`)}
|
||||||
|
>
|
||||||
|
{$t("edit-profile.flags-links-tab")}
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/@{data.user.username}/{data.member.name}"
|
||||||
|
class="list-group-item list-group-item-action text-danger"
|
||||||
|
>
|
||||||
|
Back to member
|
||||||
|
</a>
|
||||||
|
<a href="/settings/members" class="list-group-item list-group-item-action text-danger">
|
||||||
|
{$t("edit-profile.back-to-settings-tab")}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-9">
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -0,0 +1,82 @@
|
||||||
|
import { apiRequest, fastRequest } from "$api";
|
||||||
|
import ApiError, { ErrorCode, type RawApiError } from "$api/error";
|
||||||
|
import type { Member } from "$api/models/member";
|
||||||
|
import log from "$lib/log.js";
|
||||||
|
|
||||||
|
export const load = async ({ params, fetch, cookies }) => {
|
||||||
|
try {
|
||||||
|
const member = await apiRequest<Member>("GET", `/users/@me/members/${params.id}`, {
|
||||||
|
fetch,
|
||||||
|
cookies,
|
||||||
|
});
|
||||||
|
return { member };
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError) throw e.obj;
|
||||||
|
log.error("Error trying to fetch member %s:", params.id, e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions = {
|
||||||
|
changeName: async ({ params, request, fetch, cookies }) => {
|
||||||
|
const body = await request.formData();
|
||||||
|
const name = body.get("name") as string | null;
|
||||||
|
if (!name)
|
||||||
|
return {
|
||||||
|
error: {
|
||||||
|
message: "You must pass a name.",
|
||||||
|
status: 403,
|
||||||
|
code: ErrorCode.BadRequest,
|
||||||
|
} as RawApiError,
|
||||||
|
ok: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fastRequest("PATCH", `/users/@me/members/${params.id}`, {
|
||||||
|
body: { name },
|
||||||
|
fetch,
|
||||||
|
cookies,
|
||||||
|
});
|
||||||
|
return { error: null, ok: true };
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError) return { error: e.obj, ok: false };
|
||||||
|
log.error("Error updating name for member %s:", params.id, e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
changeDisplayName: async ({ params, request, fetch, cookies }) => {
|
||||||
|
const body = await request.formData();
|
||||||
|
let displayName = body.get("display-name") as string | null;
|
||||||
|
if (!displayName || displayName === "") displayName = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fastRequest("PATCH", `/users/@me/members/${params.id}`, {
|
||||||
|
body: { display_name: displayName },
|
||||||
|
fetch,
|
||||||
|
cookies,
|
||||||
|
});
|
||||||
|
return { error: null, ok: true };
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError) return { error: e.obj, ok: false };
|
||||||
|
log.error("Error updating name for member %s:", params.id, e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
bio: async ({ params, request, fetch, cookies }) => {
|
||||||
|
const body = await request.formData();
|
||||||
|
const bio = body.get("bio") as string | null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fastRequest("PATCH", `/users/@me/members/${params.id}`, {
|
||||||
|
body: { bio },
|
||||||
|
fetch,
|
||||||
|
cookies,
|
||||||
|
});
|
||||||
|
return { error: null, ok: true };
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError) return { error: e.obj, ok: false };
|
||||||
|
log.error("Error updating bio for member %s:", params.id, e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
115
Foxnouns.Frontend/src/routes/settings/members/[id]/+page.svelte
Normal file
115
Foxnouns.Frontend/src/routes/settings/members/[id]/+page.svelte
Normal file
|
@ -0,0 +1,115 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { DateTime } from "luxon";
|
||||||
|
import type { ActionData, PageData } from "./$types";
|
||||||
|
import type { Member } from "$api/models";
|
||||||
|
import { apiRequest, fastRequest } from "$api";
|
||||||
|
import ApiError from "$api/error";
|
||||||
|
import log from "$lib/log";
|
||||||
|
import { InputGroup } from "@sveltestrap/sveltestrap";
|
||||||
|
import { t } from "$lib/i18n";
|
||||||
|
import AvatarEditor from "$components/editor/AvatarEditor.svelte";
|
||||||
|
import ErrorAlert from "$components/ErrorAlert.svelte";
|
||||||
|
import NoscriptWarning from "$components/editor/NoscriptWarning.svelte";
|
||||||
|
import FormStatusMarker from "$components/editor/FormStatusMarker.svelte";
|
||||||
|
import SidEditor from "$components/editor/SidEditor.svelte";
|
||||||
|
import BioEditor from "$components/editor/BioEditor.svelte";
|
||||||
|
|
||||||
|
type Props = { data: PageData; form: ActionData };
|
||||||
|
let { data, form }: Props = $props();
|
||||||
|
|
||||||
|
// SID reroll code
|
||||||
|
// We compare the current time with the user's last SID reroll time. If it's more than an hour ago, it can be rerolled.
|
||||||
|
let error: ApiError | null = $state(null);
|
||||||
|
let sid = $state(data.member.sid);
|
||||||
|
let lastSidReroll = $state(data.user.last_sid_reroll);
|
||||||
|
let canRerollSid = $derived(
|
||||||
|
DateTime.now().toLocal().diff(DateTime.fromISO(lastSidReroll).toLocal(), "hours").hours >= 1,
|
||||||
|
);
|
||||||
|
const rerollSid = async () => {
|
||||||
|
try {
|
||||||
|
const resp = await apiRequest<Member>(
|
||||||
|
"POST",
|
||||||
|
`/users/@me/members/${data.member.id}/reroll-sid`,
|
||||||
|
{ token: data.token },
|
||||||
|
);
|
||||||
|
sid = resp.sid;
|
||||||
|
lastSidReroll = DateTime.now().toUTC().toISO();
|
||||||
|
error = null;
|
||||||
|
} catch (e) {
|
||||||
|
log.error("Could not reroll sid:", e);
|
||||||
|
if (e instanceof ApiError) error = e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Passed to AvatarEditor
|
||||||
|
let avatarUpdated = $state(false);
|
||||||
|
const updateAvatar = async (avatar: string) => {
|
||||||
|
try {
|
||||||
|
await fastRequest("PATCH", `/users/@me/members/${data.member.id}`, {
|
||||||
|
body: { avatar },
|
||||||
|
token: data.token,
|
||||||
|
});
|
||||||
|
avatarUpdated = true;
|
||||||
|
error = null;
|
||||||
|
} catch (e) {
|
||||||
|
log.error("Could not update avatar:", e);
|
||||||
|
if (e instanceof ApiError) error = e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Bio is stored in a $state() so we have a markdown preview
|
||||||
|
let bio = $state(data.member.bio || "");
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<ErrorAlert error={error.obj} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if form}
|
||||||
|
<FormStatusMarker {form} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md">
|
||||||
|
<h4>{$t("settings.avatar")}</h4>
|
||||||
|
<AvatarEditor
|
||||||
|
current={data.member.avatar_url}
|
||||||
|
alt={$t("avatar-tooltip", { name: data.member.name })}
|
||||||
|
update={updateAvatar}
|
||||||
|
updated={avatarUpdated}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-md">
|
||||||
|
<h4>{$t("edit-profile.member-name")}</h4>
|
||||||
|
<form method="POST" action="?/changeName" class="mb-3">
|
||||||
|
<InputGroup>
|
||||||
|
<input name="name" class="form-control" type="text" value={data.member.name} />
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
{$t("change")}
|
||||||
|
</button>
|
||||||
|
</InputGroup>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h4>{$t("edit-profile.display-name")}</h4>
|
||||||
|
<form class="mb-3" method="POST" action="?/changeDisplayName">
|
||||||
|
<InputGroup>
|
||||||
|
<input
|
||||||
|
class="form-control"
|
||||||
|
name="display-name"
|
||||||
|
placeholder={data.member.name}
|
||||||
|
value={data.member.display_name !== data.member.name ? data.member.display_name : null}
|
||||||
|
/>
|
||||||
|
<button class="btn btn-primary" type="submit">{$t("change")}</button>
|
||||||
|
</InputGroup>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h4>{$t("edit-profile.sid")}</h4>
|
||||||
|
<SidEditor {rerollSid} {sid} {canRerollSid} />
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<h4>{$t("edit-profile.bio-tab")}</h4>
|
||||||
|
<form method="POST" action="?/bio">
|
||||||
|
<BioEditor bind:value={bio} maxLength={data.meta.limits.bio_length} />
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { apiRequest } from "$api";
|
||||||
|
import ApiError, { ErrorCode, type RawApiError } from "$api/error";
|
||||||
|
import type { Member } from "$api/models/member";
|
||||||
|
import log from "$lib/log.js";
|
||||||
|
import { isRedirect, redirect } from "@sveltejs/kit";
|
||||||
|
|
||||||
|
export const actions = {
|
||||||
|
default: async ({ request, fetch, cookies }) => {
|
||||||
|
const body = await request.formData();
|
||||||
|
const name = body.get("name") as string | null;
|
||||||
|
if (!name)
|
||||||
|
return {
|
||||||
|
error: {
|
||||||
|
message: "No name supplied.",
|
||||||
|
status: 403,
|
||||||
|
code: ErrorCode.BadRequest,
|
||||||
|
} as RawApiError,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const member = await apiRequest<Member>("POST", "/users/@me/members", {
|
||||||
|
body: { name },
|
||||||
|
fetch,
|
||||||
|
cookies,
|
||||||
|
});
|
||||||
|
redirect(303, `/settings/members/${member.id}`);
|
||||||
|
} catch (e) {
|
||||||
|
if (isRedirect(e)) throw e;
|
||||||
|
if (e instanceof ApiError) return { error: e.obj };
|
||||||
|
log.error("Could not create member:", e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
|
@ -0,0 +1,22 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import ErrorAlert from "$components/ErrorAlert.svelte";
|
||||||
|
import { t } from "$lib/i18n";
|
||||||
|
import type { ActionData } from "./$types";
|
||||||
|
|
||||||
|
type Props = { form: ActionData };
|
||||||
|
let { form }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h3>{$t("settings.create-member-title")}</h3>
|
||||||
|
|
||||||
|
{#if form?.error}
|
||||||
|
<ErrorAlert error={form.error} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<form method="POST">
|
||||||
|
<div class="my-3">
|
||||||
|
<label class="form-label" for="name">{$t("settings.create-member-name-label")}</label>
|
||||||
|
<input class="form-control" type="text" id="name" name="name" required />
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" type="submit">{$t("profile.create-member-button")}</button>
|
||||||
|
</form>
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { redirect } from "@sveltejs/kit";
|
||||||
|
|
||||||
|
export const load = async ({ parent }) => {
|
||||||
|
const { meUser, token } = await parent();
|
||||||
|
if (!meUser) redirect(303, "/");
|
||||||
|
return { user: meUser!, token: token! };
|
||||||
|
};
|
|
@ -0,0 +1,65 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
import { page } from "$app/stores";
|
||||||
|
import { t } from "$lib/i18n";
|
||||||
|
|
||||||
|
type Props = { children: Snippet };
|
||||||
|
let { children }: Props = $props();
|
||||||
|
|
||||||
|
const isActive = (path: string) => $page.url.pathname === path;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{$t("edit-profile.user-header")} • pronouns.cc</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<h3>{$t("edit-profile.user-header")}</h3>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-3 mt-1 mb-3">
|
||||||
|
<div class="list-group">
|
||||||
|
<a
|
||||||
|
href="/settings/profile"
|
||||||
|
class="list-group-item list-group-item-action"
|
||||||
|
class:active={isActive("/settings/profile")}
|
||||||
|
>
|
||||||
|
{$t("edit-profile.general-tab")}
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/settings/profile/names-pronouns"
|
||||||
|
class="list-group-item list-group-item-action"
|
||||||
|
class:active={isActive("/settings/profile/names-pronouns")}
|
||||||
|
>
|
||||||
|
{$t("edit-profile.names-pronouns-tab")}
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/settings/profile/bio"
|
||||||
|
class="list-group-item list-group-item-action"
|
||||||
|
class:active={isActive("/settings/profile/bio")}
|
||||||
|
>
|
||||||
|
{$t("edit-profile.bio-tab")}
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/settings/profile/fields"
|
||||||
|
class="list-group-item list-group-item-action"
|
||||||
|
class:active={isActive("/settings/profile/fields")}
|
||||||
|
>
|
||||||
|
{$t("edit-profile.fields-tab")}
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/settings/profile/flags-links"
|
||||||
|
class="list-group-item list-group-item-action"
|
||||||
|
class:active={isActive("/settings/profile/flags-links")}
|
||||||
|
>
|
||||||
|
{$t("edit-profile.flags-links-tab")}
|
||||||
|
</a>
|
||||||
|
<a href="/settings" class="list-group-item list-group-item-action text-danger">
|
||||||
|
{$t("edit-profile.back-to-settings-tab")}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-9">
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { fastRequest } from "$api";
|
||||||
|
import ApiError from "$api/error";
|
||||||
|
import log from "$lib/log.js";
|
||||||
|
|
||||||
|
export const actions = {
|
||||||
|
options: async ({ request, fetch, cookies }) => {
|
||||||
|
const body = await request.formData();
|
||||||
|
let memberTitle = body.get("member-title") as string | null;
|
||||||
|
if (!memberTitle || memberTitle === "") memberTitle = null;
|
||||||
|
|
||||||
|
let timezone = body.get("timezone") as string | null;
|
||||||
|
if (!timezone || timezone === "") timezone = null;
|
||||||
|
|
||||||
|
let hideMemberList = !!body.get("hide-member-list");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fastRequest("PATCH", "/users/@me", {
|
||||||
|
body: { timezone, member_title: memberTitle, member_list_hidden: hideMemberList },
|
||||||
|
fetch,
|
||||||
|
cookies,
|
||||||
|
});
|
||||||
|
return { error: null, ok: true };
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError) return { error: e.obj, ok: false };
|
||||||
|
log.error("Error patching user:", e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
changeDisplayName: async ({ request, fetch, cookies }) => {
|
||||||
|
const body = await request.formData();
|
||||||
|
let displayName = body.get("display-name") as string | null;
|
||||||
|
if (!displayName || displayName === "") displayName = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fastRequest("PATCH", "/users/@me", {
|
||||||
|
body: { display_name: displayName },
|
||||||
|
fetch,
|
||||||
|
cookies,
|
||||||
|
});
|
||||||
|
return { error: null, ok: true };
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError) return { error: e.obj, ok: false };
|
||||||
|
log.error("Error patching user:", e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
196
Foxnouns.Frontend/src/routes/settings/profile/+page.svelte
Normal file
196
Foxnouns.Frontend/src/routes/settings/profile/+page.svelte
Normal file
|
@ -0,0 +1,196 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { ActionData, PageData } from "./$types";
|
||||||
|
import { t } from "$lib/i18n";
|
||||||
|
import { Icon, InputGroup } from "@sveltestrap/sveltestrap";
|
||||||
|
import AvatarEditor from "$components/editor/AvatarEditor.svelte";
|
||||||
|
import { apiRequest, fastRequest } from "$api";
|
||||||
|
import ApiError from "$api/error";
|
||||||
|
import ErrorAlert from "$components/ErrorAlert.svelte";
|
||||||
|
import type { User } from "$api/models/user";
|
||||||
|
import log from "$lib/log";
|
||||||
|
import { DateTime, FixedOffsetZone } from "luxon";
|
||||||
|
import FormStatusMarker from "$components/editor/FormStatusMarker.svelte";
|
||||||
|
import SidEditor from "$components/editor/SidEditor.svelte";
|
||||||
|
|
||||||
|
type Props = { data: PageData; form: ActionData };
|
||||||
|
let { data, form }: Props = $props();
|
||||||
|
|
||||||
|
let error: ApiError | null = $state(null);
|
||||||
|
|
||||||
|
// Timezone code
|
||||||
|
let tz = $state(data.user.timezone === "<none>" ? null : data.user.timezone);
|
||||||
|
const validTimezones = Intl.supportedValuesOf("timeZone");
|
||||||
|
const detectTimezone = () => {
|
||||||
|
tz = DateTime.local().zoneName;
|
||||||
|
};
|
||||||
|
|
||||||
|
let currentTime = $state("");
|
||||||
|
let displayTimezone = $state("");
|
||||||
|
$effect(() => {
|
||||||
|
if (!tz || tz === "") {
|
||||||
|
currentTime = "";
|
||||||
|
displayTimezone = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const offset = DateTime.now().setZone(tz).offset;
|
||||||
|
const zone = FixedOffsetZone.instance(offset);
|
||||||
|
|
||||||
|
currentTime = DateTime.now().setZone(zone).toLocaleString(DateTime.TIME_SIMPLE);
|
||||||
|
displayTimezone = zone.formatOffset(DateTime.now().toUnixInteger(), "narrow");
|
||||||
|
});
|
||||||
|
|
||||||
|
// SID reroll code
|
||||||
|
// We compare the current time with the user's last SID reroll time. If it's more than an hour ago, it can be rerolled.
|
||||||
|
let sid = $state(data.user.sid);
|
||||||
|
let lastSidReroll = $state(data.user.last_sid_reroll);
|
||||||
|
let canRerollSid = $derived(
|
||||||
|
DateTime.now().toLocal().diff(DateTime.fromISO(lastSidReroll).toLocal(), "hours").hours >= 1,
|
||||||
|
);
|
||||||
|
const rerollSid = async () => {
|
||||||
|
try {
|
||||||
|
const resp = await apiRequest<User>("POST", "/users/@me/reroll-sid", { token: data.token });
|
||||||
|
sid = resp.sid;
|
||||||
|
lastSidReroll = DateTime.now().toUTC().toISO();
|
||||||
|
error = null;
|
||||||
|
} catch (e) {
|
||||||
|
log.error("Could not reroll sid:", e);
|
||||||
|
if (e instanceof ApiError) error = e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Avatar update code
|
||||||
|
// AvatarEditor handles converting the uploaded image to a base64 string
|
||||||
|
let avatarUpdated = $state(false);
|
||||||
|
const updateAvatar = async (avatar: string) => {
|
||||||
|
try {
|
||||||
|
await fastRequest("PATCH", "/users/@me", {
|
||||||
|
body: { avatar },
|
||||||
|
token: data.token,
|
||||||
|
});
|
||||||
|
avatarUpdated = true;
|
||||||
|
error = null;
|
||||||
|
} catch (e) {
|
||||||
|
log.error("Could not update avatar:", e);
|
||||||
|
if (e instanceof ApiError) error = e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<ErrorAlert error={error.obj} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if form}
|
||||||
|
<div class="row">
|
||||||
|
<FormStatusMarker {form} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md">
|
||||||
|
<h4>{$t("settings.avatar")}</h4>
|
||||||
|
<AvatarEditor
|
||||||
|
current={data.user.avatar_url}
|
||||||
|
alt={$t("avatar-tooltip", { name: "@" + data.user.username })}
|
||||||
|
update={updateAvatar}
|
||||||
|
updated={avatarUpdated}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-md">
|
||||||
|
<h4>{$t("edit-profile.username")}</h4>
|
||||||
|
<input class="form-control" type="text" value={data.user.username} disabled readonly />
|
||||||
|
<p class="mt-1 mb-3 text-muted">
|
||||||
|
<Icon name="info-circle-fill" aria-hidden />
|
||||||
|
{$t("edit-profile.change-username-info")}
|
||||||
|
<a href="/settings">{$t("edit-profile.change-username-link")}</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h4>{$t("edit-profile.display-name")}</h4>
|
||||||
|
<form class="mb-3" method="POST" action="?/changeDisplayName">
|
||||||
|
<InputGroup>
|
||||||
|
<input
|
||||||
|
class="form-control"
|
||||||
|
name="display-name"
|
||||||
|
placeholder={data.user.username}
|
||||||
|
value={data.user.display_name}
|
||||||
|
/>
|
||||||
|
<button class="btn btn-primary" type="submit">{$t("change")}</button>
|
||||||
|
</InputGroup>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h4>{$t("edit-profile.sid")}</h4>
|
||||||
|
<SidEditor {sid} {rerollSid} {canRerollSid} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
<h4>{$t("edit-profile.profile-options-header")}</h4>
|
||||||
|
<form method="POST" action="?/options">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label" for="member-title">{$t("edit-profile.member-header-label")}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="member-title"
|
||||||
|
name="member-title"
|
||||||
|
class="form-control"
|
||||||
|
value={data.user.member_title}
|
||||||
|
placeholder={$t("profile.default-members-header")}
|
||||||
|
/>
|
||||||
|
<p class="text-muted mt-1">
|
||||||
|
<Icon name="info-circle-fill" aria-hidden />
|
||||||
|
{$t("edit-profile.member-header-info")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label" for="timezone">{$t("edit-profile.timezone-label")}</label>
|
||||||
|
<InputGroup>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="timezone"
|
||||||
|
name="timezone"
|
||||||
|
class="form-control"
|
||||||
|
list="timezones"
|
||||||
|
bind:value={tz}
|
||||||
|
/>
|
||||||
|
<datalist id="timezones">
|
||||||
|
{#each validTimezones as timezone}<option value={timezone}></option>{/each}
|
||||||
|
</datalist>
|
||||||
|
<button type="button" class="btn btn-secondary" onclick={() => detectTimezone()}>
|
||||||
|
Detect timezone
|
||||||
|
</button>
|
||||||
|
</InputGroup>
|
||||||
|
{#if tz && tz !== "" && validTimezones.includes(tz)}
|
||||||
|
<div class="mt-1">
|
||||||
|
{$t("edit-profile.timezone-preview")}
|
||||||
|
<Icon name="clock" aria-hidden />
|
||||||
|
{currentTime} <span class="text-body-secondary">(UTC{displayTimezone})</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<p class="text-muted mt-1">
|
||||||
|
<Icon name="info-circle-fill" aria-hidden />
|
||||||
|
{$t("edit-profile.timezone-info")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input
|
||||||
|
class="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
checked={data.user.member_list_hidden}
|
||||||
|
value="true"
|
||||||
|
name="hide-member-list"
|
||||||
|
id="hide-member-list"
|
||||||
|
/>
|
||||||
|
<label class="form-check-label" for="hide-member-list">
|
||||||
|
{$t("edit-profile.hide-member-list-label")}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted mt-1">
|
||||||
|
<Icon name="info-circle-fill" aria-hidden />
|
||||||
|
{$t("edit-profile.hide-member-list-info")}
|
||||||
|
</p>
|
||||||
|
<div class="mt-2">
|
||||||
|
<button type="submit" class="btn btn-primary">{$t("save-changes")}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { fastRequest } from "$api";
|
||||||
|
import ApiError from "$api/error";
|
||||||
|
import log from "$lib/log.js";
|
||||||
|
|
||||||
|
export const actions = {
|
||||||
|
default: async ({ request, fetch, cookies }) => {
|
||||||
|
const body = await request.formData();
|
||||||
|
const bio = body.get("bio") as string | null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fastRequest("PATCH", "/users/@me", { body: { bio }, fetch, cookies });
|
||||||
|
return { error: null, ok: true };
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError) return { error: e.obj, ok: false };
|
||||||
|
log.error("Error updating bio:", e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
|
@ -0,0 +1,17 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import FormStatusMarker from "$components/editor/FormStatusMarker.svelte";
|
||||||
|
import type { ActionData, PageData } from "./$types";
|
||||||
|
import BioEditor from "$components/editor/BioEditor.svelte";
|
||||||
|
import { t } from "$lib/i18n";
|
||||||
|
|
||||||
|
type Props = { data: PageData; form: ActionData };
|
||||||
|
let { data, form }: Props = $props();
|
||||||
|
|
||||||
|
let bio = $state(data.user.bio || "");
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h4>{$t("edit-profile.bio-tab")}</h4>
|
||||||
|
<FormStatusMarker {form} />
|
||||||
|
<form method="POST">
|
||||||
|
<BioEditor bind:value={bio} maxLength={data.meta.limits.bio_length} />
|
||||||
|
</form>
|
|
@ -3,7 +3,8 @@
|
||||||
"concurrently": "^9.0.1"
|
"concurrently": "^9.0.1"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently -n .net,node,rate -c magenta,yellow,blue -i 'cd Foxnouns.Backend && dotnet watch --no-hot-reload' 'cd Foxnouns.Frontend && pnpm dev' 'cd rate && go run -v .'",
|
"watch:be": "dotnet watch --no-hot-reload --project Foxnouns.Backend -- --migrate-and-start",
|
||||||
|
"dev": "concurrently -n .net,node,rate -c magenta,yellow,blue -i 'pnpm watch:be' 'cd Foxnouns.Frontend && pnpm dev' 'cd rate && go run -v .'",
|
||||||
"format": "dotnet csharpier . && cd Foxnouns.Frontend && pnpm format"
|
"format": "dotnet csharpier . && cd Foxnouns.Frontend && pnpm format"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@9.12.3+sha512.cce0f9de9c5a7c95bef944169cc5dfe8741abfb145078c0d508b868056848a87c81e626246cb60967cbd7fd29a6c062ef73ff840d96b3c86c40ac92cf4a813ee"
|
"packageManager": "pnpm@9.12.3+sha512.cce0f9de9c5a7c95bef944169cc5dfe8741abfb145078c0d508b868056848a87c81e626246cb60967cbd7fd29a6c062ef73ff840d96b3c86c40ac92cf4a813ee"
|
||||||
|
|
|
@ -134,12 +134,12 @@ func (l *Limiter) bucketLimiter(user, method, bucket string) *httprate.RateLimit
|
||||||
func requestLimitFor(method string) (int, time.Duration) {
|
func requestLimitFor(method string) (int, time.Duration) {
|
||||||
switch strings.ToUpper(method) {
|
switch strings.ToUpper(method) {
|
||||||
case "PATCH", "POST":
|
case "PATCH", "POST":
|
||||||
return 1, time.Second
|
|
||||||
case "DELETE":
|
|
||||||
return 1, 5 * time.Second
|
|
||||||
case "GET":
|
|
||||||
return 3, time.Second
|
return 3, time.Second
|
||||||
|
case "DELETE":
|
||||||
|
return 2, 5 * time.Second
|
||||||
|
case "GET":
|
||||||
|
return 10, time.Second
|
||||||
default:
|
default:
|
||||||
return 2, time.Second
|
return 5, time.Second
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue