even more frontend stuff
This commit is contained in:
		
							parent
							
								
									8bba5f6137
								
							
						
					
					
						commit
						c0bb76580d
					
				
					 33 changed files with 796 additions and 178 deletions
				
			
		|  | @ -303,8 +303,8 @@ public class MembersController( | |||
|                     .SetProperty(u => u.LastActive, clock.GetCurrentInstant()) | ||||
|             ); | ||||
| 
 | ||||
|         // Re-fetch member to fetch the new sid | ||||
|         var updatedMember = await db.ResolveMemberAsync(CurrentUser!.Id, memberRef); | ||||
|         return Ok(memberRenderer.RenderMember(updatedMember, CurrentToken)); | ||||
|         // Fetch the new sid then pass that to RenderMember | ||||
|         var newSid = await db.Members.Where(m => m.Id == member.Id).Select(m => m.Sid).FirstAsync(); | ||||
|         return Ok(memberRenderer.RenderMember(member, CurrentToken, newSid)); | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -346,13 +346,20 @@ public class UsersController( | |||
|                     .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); | ||||
|         return Ok( | ||||
|             await userRenderer.RenderUserAsync( | ||||
|                 user, | ||||
|                 CurrentUser, | ||||
|                 CurrentUser, | ||||
|                 CurrentToken, | ||||
|                 renderMembers: false | ||||
|                 renderMembers: false, | ||||
|                 overrideSid: newSid | ||||
|             ) | ||||
|         ); | ||||
|     } | ||||
|  |  | |||
|  | @ -24,13 +24,17 @@ public class MemberRendererService(DatabaseContext db, Config config) | |||
|         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"); | ||||
| 
 | ||||
|         return new MemberResponse( | ||||
|             member.Id, | ||||
|             member.Sid, | ||||
|             overrideSid ?? member.Sid, | ||||
|             member.Name, | ||||
|             member.DisplayName ?? member.Name, | ||||
|             member.Bio, | ||||
|  |  | |||
|  | @ -20,6 +20,7 @@ public class UserRendererService( | |||
|         Token? token = null, | ||||
|         bool renderMembers = true, | ||||
|         bool renderAuthMethods = false, | ||||
|         string? overrideSid = null, | ||||
|         CancellationToken ct = default | ||||
|     ) | ||||
|     { | ||||
|  | @ -59,7 +60,7 @@ public class UserRendererService( | |||
| 
 | ||||
|         return new UserResponse( | ||||
|             user.Id, | ||||
|             user.Sid, | ||||
|             overrideSid ?? user.Sid, | ||||
|             user.Username, | ||||
|             user.DisplayName, | ||||
|             user.Bio, | ||||
|  |  | |||
|  | @ -1,22 +1,23 @@ | |||
| <script lang="ts"> | ||||
| 	import { DEFAULT_AVATAR } from "$lib"; | ||||
| 
 | ||||
| 	type Props = { url: string | null; alt: string; lazyLoad?: boolean }; | ||||
| 	let { url, alt, lazyLoad }: Props = $props(); | ||||
| 	type Props = { url: string | null; alt: string; lazyLoad?: boolean; size?: number }; | ||||
| 	let { url, alt, lazyLoad, size }: Props = $props(); | ||||
| 
 | ||||
| 	let width = $derived(size || 200); | ||||
| </script> | ||||
| 
 | ||||
| <img | ||||
| 	class="rounded-circle img-fluid" | ||||
| 	style="height: {width}px; width: {width}px" | ||||
| 	src={url || DEFAULT_AVATAR} | ||||
| 	{alt} | ||||
| 	width={200} | ||||
| 	{width} | ||||
| 	loading={lazyLoad ? "lazy" : "eager"} | ||||
| /> | ||||
| 
 | ||||
| <style> | ||||
| 	img { | ||||
| 		object-fit: cover; | ||||
| 		height: 200px; | ||||
| 		width: 200px; | ||||
| 	} | ||||
| </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} | ||||
|  | @ -4,14 +4,15 @@ | |||
| 	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; | ||||
| 		onclick: (avatar: string) => Promise<void>; | ||||
| 		update: (avatar: string) => Promise<void>; | ||||
| 		updated: boolean; | ||||
| 	}; | ||||
| 	let { current, alt, onclick, updated }: Props = $props(); | ||||
| 	let { current, alt, update: onclick, updated }: Props = $props(); | ||||
| 
 | ||||
| 	const MAX_AVATAR_BYTES = 1_000_000; | ||||
| 
 | ||||
|  | @ -59,6 +60,8 @@ | |||
| 	</button> | ||||
| </InputGroup> | ||||
| 
 | ||||
| <ShortNoscriptWarning /> | ||||
| 
 | ||||
| {#if updated} | ||||
| 	<p class="text-success-emphasis"> | ||||
| 		<Icon name="check-circle-fill" /> | ||||
|  |  | |||
							
								
								
									
										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,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> | ||||
| 	<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> | ||||
| 	<p class="m-2"> | ||||
| 		<a class="text-reset fs-5 text-break" href="/@{username}/{member.name}"> | ||||
| 			{member.name} | ||||
| 			{member.display_name} | ||||
| 		</a> | ||||
| 		{#if pronouns} | ||||
| 			<br /> | ||||
|  |  | |||
|  | @ -6,13 +6,14 @@ | |||
|   }, | ||||
|   "avatar-tooltip": "Avatar for {{name}}", | ||||
|   "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-profile-link": "Edit profile", | ||||
|     "names-header": "Names", | ||||
|     "pronouns-header": "Pronouns", | ||||
|     "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", | ||||
|  | @ -59,7 +60,10 @@ | |||
|     "validation-disallowed-value-2": "Allowed values are", | ||||
|     "validation-reason": "Reason", | ||||
|     "validation-generic": "The value you entered is not allowed here. Reason", | ||||
|     "extra-info-header": "Extra error information" | ||||
|     "extra-info-header": "Extra error information", | ||||
|     "noscript-title": "This page requires JavaScript", | ||||
|     "noscript-info": "This page requires JavaScript to function correctly. Some buttons may not work, or the page may not work at all.", | ||||
|     "noscript-short": "Requires JavaScript" | ||||
|   }, | ||||
|   "settings": { | ||||
|     "general-information-tab": "General information", | ||||
|  | @ -86,7 +90,9 @@ | |||
|     "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!" | ||||
|     "username-update-success": "Successfully changed your username!", | ||||
|     "create-member-title": "Create a new member", | ||||
|     "create-member-name-label": "Member name" | ||||
|   }, | ||||
|   "yes": "Yes", | ||||
|   "no": "No", | ||||
|  | @ -112,7 +118,19 @@ | |||
|     "profile-options-header": "Profile options", | ||||
|     "bio-tab": "Bio", | ||||
|     "saved-changes": "Successfully saved changes!", | ||||
|     "bio-length-hint": "Using {{length}}/{{maxLength}} characters" | ||||
|     "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" | ||||
|   "save-changes": "Save changes", | ||||
|   "change": "Change" | ||||
| } | ||||
|  |  | |||
|  | @ -1,6 +1,8 @@ | |||
| import { apiRequest } from "$api"; | ||||
| import type { PartialMember, UserWithMembers } from "$api/models"; | ||||
| 
 | ||||
| const MEMBERS_PER_PAGE = 20; | ||||
| 
 | ||||
| export const load = async ({ params, fetch, cookies, url }) => { | ||||
| 	const user = await apiRequest<UserWithMembers>("GET", `/users/${params.username}`, { | ||||
| 		fetch, | ||||
|  | @ -13,10 +15,13 @@ export const load = async ({ params, fetch, cookies, url }) => { | |||
| 	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); | ||||
| 		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, 20); | ||||
| 			members = user.members.slice(0, MEMBERS_PER_PAGE); | ||||
| 			currentPage = 0; | ||||
| 		} | ||||
| 	} | ||||
|  |  | |||
|  | @ -6,7 +6,7 @@ | |||
| 	import ProfileFields from "$components/profile/ProfileFields.svelte"; | ||||
| 	import { t } from "$lib/i18n"; | ||||
| 	import { Icon } from "@sveltestrap/sveltestrap"; | ||||
| 	import Paginator from "./Paginator.svelte"; | ||||
| 	import Paginator from "$components/Paginator.svelte"; | ||||
| 	import MemberCard from "$components/profile/user/MemberCard.svelte"; | ||||
| 
 | ||||
| 	type Props = { data: PageData }; | ||||
|  | @ -25,7 +25,7 @@ | |||
| 		<OwnProfileNotice editLink="/settings/profile" /> | ||||
| 	{/if} | ||||
| 
 | ||||
| 	<ProfileHeader name="@{data.user.username}" profile={data.user} lazyLoadAvatar={true} /> | ||||
| 	<ProfileHeader name="@{data.user.username}" profile={data.user}/> | ||||
| 	<ProfileFields profile={data.user} {allPreferences} /> | ||||
| 
 | ||||
| 	{#if data.members.length > 0} | ||||
|  | @ -34,27 +34,27 @@ | |||
| 			{data.user.member_title || $t("profile.default-members-header")} | ||||
| 			{#if isMeUser} | ||||
| 				<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")} | ||||
| 				</a> | ||||
| 			{/if} | ||||
| 		</h2> | ||||
| 		<Paginator | ||||
| 			center | ||||
| 			currentPage={data.currentPage} | ||||
| 			pageCount={data.pageCount} | ||||
| 			href="/@{data.user.username}" | ||||
| 		/> | ||||
| 		</h2> | ||||
| 		<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)} | ||||
| 				<MemberCard username={data.user.username} {member} {allPreferences} /> | ||||
| 			{/each} | ||||
| 		</div> | ||||
| 		<div class="text-center"> | ||||
| 		<Paginator | ||||
| 			center | ||||
| 			currentPage={data.currentPage} | ||||
| 			pageCount={data.pageCount} | ||||
| 			href="/@{data.user.username}" | ||||
| 		/> | ||||
| 		</div> | ||||
| 	{/if} | ||||
| </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> | ||||
|  | @ -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! }; | ||||
| }; | ||||
|  | @ -1,42 +0,0 @@ | |||
| <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,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> | ||||
|  | @ -1,4 +1,4 @@ | |||
| import { apiRequest, fastRequest } from "$api"; | ||||
| import { fastRequest } from "$api"; | ||||
| import ApiError from "$api/error"; | ||||
| import log from "$lib/log.js"; | ||||
| 
 | ||||
|  | @ -26,4 +26,22 @@ export const actions = { | |||
| 			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; | ||||
| 		} | ||||
| 	}, | ||||
| }; | ||||
|  |  | |||
|  | @ -1,39 +1,29 @@ | |||
| <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 { 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 { MeUser } from "$api/models/user"; | ||||
| 	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); | ||||
| 
 | ||||
| 	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 | ||||
| 	let tz = $state(data.user.timezone === "<none>" ? null : data.user.timezone); | ||||
| 	const validTimezones = Intl.supportedValuesOf("timeZone"); | ||||
| 	const detectTimezone = () => { | ||||
| 		tz = DateTime.local().zoneName; | ||||
| 	}; | ||||
| 
 | ||||
| 	// Timezone code | ||||
| 	let currentTime = $state(""); | ||||
| 	let displayTimezone = $state(""); | ||||
| 	$effect(() => { | ||||
|  | @ -52,14 +42,16 @@ | |||
| 
 | ||||
| 	// 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<MeUser>("POST", "/users/@me/reroll-sid", { token: data.token }); | ||||
| 			const resp = await apiRequest<User>("POST", "/users/@me/reroll-sid", { token: data.token }); | ||||
| 			sid = resp.sid; | ||||
| 			lastSidReroll = resp.last_sid_reroll; | ||||
| 			lastSidReroll = DateTime.now().toUTC().toISO(); | ||||
| 			error = null; | ||||
| 		} catch (e) { | ||||
| 			log.error("Could not reroll sid:", e); | ||||
|  | @ -67,15 +59,16 @@ | |||
| 		} | ||||
| 	}; | ||||
| 
 | ||||
| 	// Passed to AvatarEditor | ||||
| 	let updated = $state(false); | ||||
| 	// 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, | ||||
| 			}); | ||||
| 			updated = true; | ||||
| 			avatarUpdated = true; | ||||
| 			error = null; | ||||
| 		} catch (e) { | ||||
| 			log.error("Could not update avatar:", e); | ||||
|  | @ -88,38 +81,51 @@ | |||
| 	<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 })} | ||||
| 			onclick={updateAvatar} | ||||
| 			{updated} | ||||
| 			update={updateAvatar} | ||||
| 			updated={avatarUpdated} | ||||
| 		/> | ||||
| 	</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"> | ||||
| 		<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.sid-hint")} | ||||
| 			{$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> | ||||
| 	<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> | ||||
|  |  | |||
|  | @ -1,8 +1,8 @@ | |||
| <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"; | ||||
| 	import BioEditor from "$components/editor/BioEditor.svelte"; | ||||
| 	import { t } from "$lib/i18n"; | ||||
| 
 | ||||
| 	type Props = { data: PageData; form: ActionData }; | ||||
| 	let { data, form }: Props = $props(); | ||||
|  | @ -10,31 +10,8 @@ | |||
| 	let bio = $state(data.user.bio || ""); | ||||
| </script> | ||||
| 
 | ||||
| <h4>Bio</h4> | ||||
| 
 | ||||
| <h4>{$t("edit-profile.bio-tab")}</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> | ||||
| 	<BioEditor bind:value={bio} maxLength={data.meta.limits.bio_length} /> | ||||
| </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,8 +3,8 @@ | |||
|     "concurrently": "^9.0.1" | ||||
|   }, | ||||
|   "scripts": { | ||||
|     "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", | ||||
|     "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" | ||||
|   }, | ||||
|   "packageManager": "pnpm@9.12.3+sha512.cce0f9de9c5a7c95bef944169cc5dfe8741abfb145078c0d508b868056848a87c81e626246cb60967cbd7fd29a6c062ef73ff840d96b3c86c40ac92cf4a813ee" | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue