feat: so much more frontend stuff

This commit is contained in:
sam 2024-11-24 22:19:53 +01:00
parent c179669799
commit 261435c252
Signed by: sam
GPG key ID: B4EF20DDE721CAA1
24 changed files with 682 additions and 107 deletions

View file

@ -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")]

View file

@ -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");
}
}
}

View file

@ -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")

View file

@ -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; } = [];

View file

@ -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": {

View file

@ -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;
@ -49,6 +50,13 @@ 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, user.Sid,
@ -63,6 +71,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 +79,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 +125,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 +135,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(

View file

@ -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

View file

@ -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"
} }

View file

@ -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: {}

View file

@ -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;

View file

@ -1,14 +1,22 @@
<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 };
let { url, alt, lazyLoad, width }: Props = $props(); let { url, alt, lazyLoad }: Props = $props();
</script> </script>
<img <img
class="rounded-circle img-fluid" class="rounded-circle img-fluid"
src={url || DEFAULT_AVATAR} src={url || DEFAULT_AVATAR}
{alt} {alt}
width={width || 200} width={200}
loading={lazyLoad ? "lazy" : "eager"} loading={lazyLoad ? "lazy" : "eager"}
/> />
<style>
img {
object-fit: cover;
height: 200px;
width: 200px;
}
</style>

View file

@ -0,0 +1,77 @@
<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";
type Props = {
current: string | null;
alt: string;
onclick: (avatar: string) => Promise<void>;
updated: boolean;
};
let { current, alt, 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>
{#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}

View file

@ -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}

View file

@ -1,91 +1,118 @@
{ {
"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"
}, },
"title": { "title": {
"log-in": "Log in", "log-in": "Log in",
"welcome": "Welcome", "welcome": "Welcome",
"settings": "Settings" "settings": "Settings"
}, },
"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"
}, },
"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 long, minimum length is {{min}}, current length is {{actual}}.", "validation-min-length-error": "Value is too long, 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"
}, },
"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",
"yes": "Yes", "username-update-success": "Successfully changed your username!"
"no": "No" },
"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"
},
"save-changes": "Save changes"
} }

View file

@ -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;

View file

@ -1,5 +1,5 @@
import { apiRequest } from "$api"; import { apiRequest } from "$api";
import type { UserWithMembers } from "$api/models"; import type { PartialMember, UserWithMembers } from "$api/models";
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 +8,17 @@ 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 / 20);
members = user.members.slice(currentPage * 20, (currentPage + 1) * 20);
if (members.length === 0) {
members = user.members.slice(0, 20);
currentPage = 0;
}
} }
return { user, members, currentPage, pageCount }; return { user, members, currentPage, pageCount };

View file

@ -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! };
}; };

View file

@ -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 })}

View file

@ -0,0 +1,42 @@
<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>
<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>
</div>
</div>
<div class="col-md-9">
{@render children?.()}
</div>
</div>

View file

@ -0,0 +1,29 @@
import { apiRequest, 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;
}
},
};

View file

@ -0,0 +1,190 @@
<script lang="ts">
import type { ActionData, PageData } from "./$types";
import { t } from "$lib/i18n";
import { Button, ButtonGroup, Icon, InputGroup } from "@sveltestrap/sveltestrap";
import { PUBLIC_SHORT_URL } from "$env/static/public";
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 { MeUser } from "$api/models/user";
import log from "$lib/log";
import { DateTime, FixedOffsetZone } from "luxon";
import FormStatusMarker from "$components/editor/FormStatusMarker.svelte";
type Props = { data: PageData; form: ActionData };
let { data, form }: Props = $props();
let error: ApiError | null = $state(null);
const copySid = async () => {
const url = `${PUBLIC_SHORT_URL}/${data.user.sid}`;
await navigator.clipboard.writeText(url);
};
// Editable properties
let sid = $state(data.user.sid);
let lastSidReroll = $state(data.user.last_sid_reroll);
let tz = $state(data.user.timezone === "<none>" ? null : data.user.timezone);
// Timezone code
const validTimezones = Intl.supportedValuesOf("timeZone");
const detectTimezone = () => {
tz = DateTime.local().zoneName;
};
// Timezone code
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 canRerollSid = $derived(
DateTime.now().toLocal().diff(DateTime.fromISO(lastSidReroll).toLocal(), "hours").hours >= 1,
);
const rerollSid = async () => {
try {
const resp = await apiRequest<MeUser>("POST", "/users/@me/reroll-sid", { token: data.token });
sid = resp.sid;
lastSidReroll = resp.last_sid_reroll;
error = null;
} catch (e) {
log.error("Could not reroll sid:", e);
if (e instanceof ApiError) error = e;
}
};
// Passed to AvatarEditor
let updated = $state(false);
const updateAvatar = async (avatar: string) => {
try {
await fastRequest("PATCH", "/users/@me", {
body: { avatar },
token: data.token,
});
updated = 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}
<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 })}
onclick={updateAvatar}
{updated}
/>
</div>
<div class="col-md">
<h4>{$t("edit-profile.sid")}</h4>
{$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>
<p class="text-muted">
<Icon name="info-circle-fill" aria-hidden />
{$t("edit-profile.sid-hint")}
</p>
</div>
</div>
<div class="mt-3">
<h4>{$t("edit-profile.profile-options-header")}</h4>
<FormStatusMarker {form} />
<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>

View file

@ -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;
}
},
};

View file

@ -0,0 +1,40 @@
<script lang="ts">
import FormStatusMarker from "$components/editor/FormStatusMarker.svelte";
import { renderMarkdown } from "$lib/markdown";
import { t } from "$lib/i18n";
import type { ActionData, PageData } from "./$types";
type Props = { data: PageData; form: ActionData };
let { data, form }: Props = $props();
let bio = $state(data.user.bio || "");
</script>
<h4>Bio</h4>
<FormStatusMarker {form} />
<form method="POST">
<textarea name="bio" class="form-control" style="height: 200px;" bind:value={bio}></textarea>
<button
disabled={bio.length > data.meta.limits.bio_length}
type="submit"
class="btn btn-primary mt-2 my-1"
>
{$t("save-changes")}
</button>
</form>
<p class="text-muted mt-1">
{$t("edit-profile.bio-length-hint", {
length: bio.length,
maxLength: data.meta.limits.bio_length,
})}
</p>
{#if bio !== ""}
<div class="card">
<div class="card-header">Preview</div>
<div class="card-body">{@html renderMarkdown(bio)}</div>
</div>
{/if}

View file

@ -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 .'", "dev": "concurrently -n .net,node,rate -c magenta,yellow,blue -i 'pnpm watch:be' 'cd Foxnouns.Frontend && pnpm dev' 'cd rate && go run -v .'",
"watch:be": "dotnet watch --no-hot-reload --project Foxnouns.Backend -- --migrate-and-start",
"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"