feat: so much more frontend stuff
This commit is contained in:
		
							parent
							
								
									c179669799
								
							
						
					
					
						commit
						261435c252
					
				
					 24 changed files with 682 additions and 107 deletions
				
			
		|  | @ -1,4 +1,5 @@ | |||
| using System.Diagnostics.CodeAnalysis; | ||||
| using Coravel.Mailer.Mail.Helpers; | ||||
| using Coravel.Queuing.Interfaces; | ||||
| using EntityFramework.Exceptions.Common; | ||||
| using Foxnouns.Backend.Database; | ||||
|  | @ -116,6 +117,42 @@ public class UsersController( | |||
|         if (req.HasProperty(nameof(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); | ||||
|         // This is fired off regardless of whether the transaction is committed | ||||
|         // (atomic operations are hard when combined with background jobs) | ||||
|  | @ -253,6 +290,9 @@ public class UsersController( | |||
|         public Pronoun[]? Pronouns { get; init; } | ||||
|         public Field[]? Fields { get; init; } | ||||
|         public Snowflake[]? Flags { get; init; } | ||||
|         public string? MemberTitle { get; init; } | ||||
|         public bool? MemberListHidden { get; init; } | ||||
|         public string? Timezone { get; init; } | ||||
|     } | ||||
| 
 | ||||
|     [HttpGet("@me/settings")] | ||||
|  |  | |||
|  | @ -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") | ||||
|                         .HasDefaultValueSql("find_free_user_sid()"); | ||||
| 
 | ||||
|                     b.Property<string>("Timezone") | ||||
|                         .HasColumnType("text") | ||||
|                         .HasColumnName("timezone"); | ||||
| 
 | ||||
|                     b.Property<string>("Username") | ||||
|                         .IsRequired() | ||||
|                         .HasColumnType("text") | ||||
|  |  | |||
|  | @ -15,6 +15,7 @@ public class User : BaseModel | |||
|     public string? Avatar { get; set; } | ||||
|     public string[] Links { get; set; } = []; | ||||
|     public bool ListHidden { get; set; } | ||||
|     public string? Timezone { get; set; } | ||||
| 
 | ||||
|     public List<FieldEntry> Names { get; set; } = []; | ||||
|     public List<Pronoun> Pronouns { get; set; } = []; | ||||
|  |  | |||
|  | @ -4,6 +4,7 @@ | |||
|     "Development": { | ||||
|       "commandName": "Project", | ||||
|       "dotnetRunMessages": true, | ||||
|       "hotReloadEnabled": false, | ||||
|       "launchBrowser": false, | ||||
|       "externalUrlConfiguration": true, | ||||
|       "environmentVariables": { | ||||
|  | @ -13,6 +14,7 @@ | |||
|     "Production": { | ||||
|       "commandName": "Project", | ||||
|       "dotnetRunMessages": true, | ||||
|       "hotReloadEnabled": false, | ||||
|       "launchBrowser": false, | ||||
|       "externalUrlConfiguration": true, | ||||
|       "environmentVariables": { | ||||
|  |  | |||
|  | @ -4,6 +4,7 @@ using Foxnouns.Backend.Utils; | |||
| using Microsoft.EntityFrameworkCore; | ||||
| using Newtonsoft.Json; | ||||
| using NodaTime; | ||||
| using Org.BouncyCastle.Ocsp; | ||||
| 
 | ||||
| namespace Foxnouns.Backend.Services; | ||||
| 
 | ||||
|  | @ -49,6 +50,13 @@ public class UserRendererService( | |||
|                 .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( | ||||
|             user.Id, | ||||
|             user.Sid, | ||||
|  | @ -63,6 +71,7 @@ public class UserRendererService( | |||
|             user.Fields, | ||||
|             user.CustomPreferences, | ||||
|             flags.Select(f => RenderPrideFlag(f.PrideFlag)), | ||||
|             utcOffset, | ||||
|             user.Role, | ||||
|             renderMembers | ||||
|                 ? members.Select(m => memberRenderer.RenderPartialMember(m, tokenHidden)) | ||||
|  | @ -70,7 +79,8 @@ public class UserRendererService( | |||
|             renderAuthMethods ? authMethods.Select(RenderAuthMethod) : null, | ||||
|             tokenHidden ? user.ListHidden : 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, | ||||
|         Dictionary<Snowflake, User.CustomPreference> CustomPreferences, | ||||
|         IEnumerable<PrideFlagResponse> Flags, | ||||
|         int? UtcOffset, | ||||
|         [property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] UserRole Role, | ||||
|         [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] | ||||
|             IEnumerable<MemberRendererService.PartialMember>? Members, | ||||
|  | @ -124,7 +135,8 @@ public class UserRendererService( | |||
|             bool? MemberListHidden, | ||||
|         [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Instant? LastActive, | ||||
|         [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] | ||||
|             Instant? LastSidReroll | ||||
|             Instant? LastSidReroll, | ||||
|         [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? Timezone | ||||
|     ); | ||||
| 
 | ||||
|     public record AuthMethodResponse( | ||||
|  |  | |||
|  | @ -1,5 +1,7 @@ | |||
| # Example .env file--DO NOT EDIT | ||||
| PUBLIC_LANGUAGE=en | ||||
| PUBLIC_BASE_URL=https://pronouns.cc | ||||
| PUBLIC_SHORT_URL=https://prns.cc | ||||
| PUBLIC_API_BASE=https://pronouns.cc/api | ||||
| PRIVATE_API_HOST=http://localhost:5003/api | ||||
| PRIVATE_INTERNAL_API_HOST=http://localhost:5000/api | ||||
|  |  | |||
|  | @ -38,9 +38,11 @@ | |||
| 	"packageManager": "pnpm@9.12.3+sha512.cce0f9de9c5a7c95bef944169cc5dfe8741abfb145078c0d508b868056848a87c81e626246cb60967cbd7fd29a6c062ef73ff840d96b3c86c40ac92cf4a813ee", | ||||
| 	"dependencies": { | ||||
| 		"@fontsource/firago": "^5.1.0", | ||||
| 		"base64-arraybuffer": "^1.0.2", | ||||
| 		"bootstrap-icons": "^1.11.3", | ||||
| 		"luxon": "^3.5.0", | ||||
| 		"markdown-it": "^14.1.0", | ||||
| 		"pretty-bytes": "^6.1.1", | ||||
| 		"sanitize-html": "^2.13.1", | ||||
| 		"tslog": "^4.9.3" | ||||
| 	} | ||||
|  |  | |||
							
								
								
									
										18
									
								
								Foxnouns.Frontend/pnpm-lock.yaml
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										18
									
								
								Foxnouns.Frontend/pnpm-lock.yaml
									
										
									
										generated
									
									
									
								
							|  | @ -11,6 +11,9 @@ importers: | |||
|       '@fontsource/firago': | ||||
|         specifier: ^5.1.0 | ||||
|         version: 5.1.0 | ||||
|       base64-arraybuffer: | ||||
|         specifier: ^1.0.2 | ||||
|         version: 1.0.2 | ||||
|       bootstrap-icons: | ||||
|         specifier: ^1.11.3 | ||||
|         version: 1.11.3 | ||||
|  | @ -20,6 +23,9 @@ importers: | |||
|       markdown-it: | ||||
|         specifier: ^14.1.0 | ||||
|         version: 14.1.0 | ||||
|       pretty-bytes: | ||||
|         specifier: ^6.1.1 | ||||
|         version: 6.1.1 | ||||
|       sanitize-html: | ||||
|         specifier: ^2.13.1 | ||||
|         version: 2.13.1 | ||||
|  | @ -704,6 +710,10 @@ packages: | |||
|   balanced-match@1.0.2: | ||||
|     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: | ||||
|     resolution: {integrity: sha512-+3lpHrCw/it2/7lBL15VR0HEumaBss0+f/Lb6ZvHISn1mlK83jjFpooTLsMWbIjJMDjDjOExMsTxnXSIT4k4ww==} | ||||
| 
 | ||||
|  | @ -1211,6 +1221,10 @@ packages: | |||
|     engines: {node: '>=14'} | ||||
|     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: | ||||
|     resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} | ||||
|     engines: {node: '>=6'} | ||||
|  | @ -1938,6 +1952,8 @@ snapshots: | |||
| 
 | ||||
|   balanced-match@1.0.2: {} | ||||
| 
 | ||||
|   base64-arraybuffer@1.0.2: {} | ||||
| 
 | ||||
|   bootstrap-icons@1.11.3: {} | ||||
| 
 | ||||
|   bootstrap@5.3.3(@popperjs/core@2.11.8): | ||||
|  | @ -2432,6 +2448,8 @@ snapshots: | |||
| 
 | ||||
|   prettier@3.3.3: {} | ||||
| 
 | ||||
|   pretty-bytes@6.1.1: {} | ||||
| 
 | ||||
|   punycode.js@2.3.1: {} | ||||
| 
 | ||||
|   punycode@2.3.1: {} | ||||
|  |  | |||
|  | @ -1,5 +1,6 @@ | |||
| export type PartialUser = { | ||||
| 	id: string; | ||||
| 	sid: string; | ||||
| 	username: string; | ||||
| 	display_name: string | null; | ||||
| 	avatar_url: string | null; | ||||
|  | @ -14,17 +15,20 @@ export type User = PartialUser & { | |||
| 	pronouns: Pronoun[]; | ||||
| 	fields: Field[]; | ||||
| 	flags: PrideFlag[]; | ||||
| 	utc_offset: number | null; | ||||
| 	role: "USER" | "MODERATOR" | "ADMIN"; | ||||
| }; | ||||
| 
 | ||||
| export type MeUser = UserWithMembers & { | ||||
| 	members: PartialMember[]; | ||||
| 	auth_methods: AuthMethod[]; | ||||
| 	member_list_hidden: boolean; | ||||
| 	last_active: string; | ||||
| 	last_sid_reroll: string; | ||||
| 	timezone: string; | ||||
| }; | ||||
| 
 | ||||
| export type UserWithMembers = User & { members: PartialMember[] }; | ||||
| export type UserWithMembers = User & { members: PartialMember[] | null }; | ||||
| 
 | ||||
| export type UserWithHiddenFields = User & { | ||||
| 	auth_methods?: unknown[]; | ||||
|  | @ -38,6 +42,7 @@ export type UserSettings = { | |||
| 
 | ||||
| export type PartialMember = { | ||||
| 	id: string; | ||||
| 	sid: string; | ||||
| 	name: string; | ||||
| 	display_name: string; | ||||
| 	bio: string | null; | ||||
|  |  | |||
|  | @ -1,14 +1,22 @@ | |||
| <script lang="ts"> | ||||
| 	import { DEFAULT_AVATAR } from "$lib"; | ||||
| 
 | ||||
| 	type Props = { url: string | null; alt: string; lazyLoad?: boolean; width?: number }; | ||||
| 	let { url, alt, lazyLoad, width }: Props = $props(); | ||||
| 	type Props = { url: string | null; alt: string; lazyLoad?: boolean }; | ||||
| 	let { url, alt, lazyLoad }: Props = $props(); | ||||
| </script> | ||||
| 
 | ||||
| <img | ||||
| 	class="rounded-circle img-fluid" | ||||
| 	src={url || DEFAULT_AVATAR} | ||||
| 	{alt} | ||||
| 	width={width || 200} | ||||
| 	width={200} | ||||
| 	loading={lazyLoad ? "lazy" : "eager"} | ||||
| /> | ||||
| 
 | ||||
| <style> | ||||
| 	img { | ||||
| 		object-fit: cover; | ||||
| 		height: 200px; | ||||
| 		width: 200px; | ||||
| 	} | ||||
| </style> | ||||
|  |  | |||
|  | @ -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} | ||||
|  | @ -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} | ||||
|  | @ -84,8 +84,35 @@ | |||
|     "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-hint": "Use this button to log out on this device only.", | ||||
| 		"log-out-button": "Log out" | ||||
|     "log-out-button": "Log out", | ||||
|     "avatar": "Avatar", | ||||
|     "username-update-success": "Successfully changed your username!" | ||||
|   }, | ||||
|   "yes": "Yes", | ||||
| 	"no": "No" | ||||
|   "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" | ||||
| } | ||||
|  |  | |||
|  | @ -6,10 +6,12 @@ import log from "$lib/log"; | |||
| import type { LayoutServerLoad } from "./$types"; | ||||
| 
 | ||||
| export const load = (async ({ fetch, cookies }) => { | ||||
| 	let token: string | null = null; | ||||
| 	let meUser: MeUser | null = null; | ||||
| 	if (cookies.get(TOKEN_COOKIE_NAME)) { | ||||
| 		try { | ||||
| 			meUser = await apiRequest<MeUser>("GET", "/users/@me", { fetch, cookies }); | ||||
| 			token = cookies.get(TOKEN_COOKIE_NAME) || null; | ||||
| 		} catch (e) { | ||||
| 			if (e instanceof ApiError && e.code === ErrorCode.AuthenticationRequired) clearToken(cookies); | ||||
| 			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 }); | ||||
| 	return { meta, meUser }; | ||||
| 	return { meta, meUser, token }; | ||||
| }) satisfies LayoutServerLoad; | ||||
|  |  | |||
|  | @ -1,5 +1,5 @@ | |||
| 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 }) => { | ||||
| 	const user = await apiRequest<UserWithMembers>("GET", `/users/${params.username}`, { | ||||
|  | @ -8,13 +8,18 @@ export const load = async ({ params, fetch, cookies, url }) => { | |||
| 	}); | ||||
| 
 | ||||
| 	// Paginate members on the server side
 | ||||
| 	let currentPage = Number(url.searchParams.get("page") || "0"); | ||||
| 	const pageCount = Math.ceil(user.members.length / 20); | ||||
| 	let members = user.members.slice(currentPage * 20, (currentPage + 1) * 20); | ||||
| 	let currentPage = 0; | ||||
| 	let pageCount = 0; | ||||
| 	let members: PartialMember[] = []; | ||||
| 	if (user.members) { | ||||
| 		currentPage = Number(url.searchParams.get("page") || "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 }; | ||||
| }; | ||||
|  |  | |||
|  | @ -4,5 +4,5 @@ export const load = async ({ parent }) => { | |||
| 	const data = await parent(); | ||||
| 	if (!data.meUser) redirect(303, "/auth/log-in"); | ||||
| 
 | ||||
| 	return { user: data.meUser! }; | ||||
| 	return { user: data.meUser!, token: data.token! }; | ||||
| }; | ||||
|  |  | |||
|  | @ -29,7 +29,8 @@ | |||
| 			</FormGroup> | ||||
| 			{#if form?.ok} | ||||
| 				<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> | ||||
| 			{:else if usernameError} | ||||
| 				<p class="text-danger-emphasis text-has-newline"> | ||||
|  | @ -46,7 +47,7 @@ | |||
| 		</p> | ||||
| 	</div> | ||||
| 	<div class="col-md-3 text-center"> | ||||
| 		<h5>Avatar</h5> | ||||
| 		<h5>{$t("settings.avatar")}</h5> | ||||
| 		<Avatar | ||||
| 			url={data.user.avatar_url} | ||||
| 			alt={$t("avatar-tooltip", { name: "@" + data.user.username })} | ||||
|  |  | |||
							
								
								
									
										42
									
								
								Foxnouns.Frontend/src/routes/settings/profile/+layout.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								Foxnouns.Frontend/src/routes/settings/profile/+layout.svelte
									
										
									
									
									
										Normal 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> | ||||
|  | @ -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; | ||||
| 		} | ||||
| 	}, | ||||
| }; | ||||
							
								
								
									
										190
									
								
								Foxnouns.Frontend/src/routes/settings/profile/+page.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										190
									
								
								Foxnouns.Frontend/src/routes/settings/profile/+page.svelte
									
										
									
									
									
										Normal 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> | ||||
|  | @ -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,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} | ||||
|  | @ -3,7 +3,8 @@ | |||
|     "concurrently": "^9.0.1" | ||||
|   }, | ||||
|   "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" | ||||
|   }, | ||||
|   "packageManager": "pnpm@9.12.3+sha512.cce0f9de9c5a7c95bef944169cc5dfe8741abfb145078c0d508b868056848a87c81e626246cb60967cbd7fd29a6c062ef73ff840d96b3c86c40ac92cf4a813ee" | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue