feat(frontend): edit names/pronouns
This commit is contained in:
		
							parent
							
								
									b6d42fb15d
								
							
						
					
					
						commit
						59496a8cd8
					
				
					 18 changed files with 470 additions and 14 deletions
				
			
		|  | @ -1,6 +1,6 @@ | |||
| <script lang="ts"> | ||||
| 	import { ErrorCode, type RawApiError } from "$api/error"; | ||||
| 	import errorDescription from "$lib/errorCodes.svelte"; | ||||
| 	import errorDescription from "$lib/errorCodes"; | ||||
| 	import { t } from "$lib/i18n"; | ||||
| 	import KeyedValidationErrors from "./errors/KeyedValidationErrors.svelte"; | ||||
| 
 | ||||
|  |  | |||
							
								
								
									
										20
									
								
								Foxnouns.Frontend/src/lib/components/IconButton.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								Foxnouns.Frontend/src/lib/components/IconButton.svelte
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,20 @@ | |||
| <script lang="ts"> | ||||
| 	import { Icon } from "@sveltestrap/sveltestrap"; | ||||
| 	import type { MouseEventHandler } from "svelte/elements"; | ||||
| 	import tippy from "$lib/tippy"; | ||||
| 
 | ||||
| 	type Props = { | ||||
| 		icon: string; | ||||
| 		tooltip: string; | ||||
| 		color?: "primary" | "secondary" | "success" | "danger"; | ||||
| 		type?: "submit" | "reset" | "button"; | ||||
| 		id?: string; | ||||
| 		onclick?: MouseEventHandler<HTMLButtonElement>; | ||||
| 	}; | ||||
| 	let { icon, tooltip, color = "primary", type, id, onclick }: Props = $props(); | ||||
| </script> | ||||
| 
 | ||||
| <button {id} {type} use:tippy={{ content: tooltip }} class="btn btn-{color}" {onclick}> | ||||
| 	<Icon name={icon} /> | ||||
| 	<span class="visually-hidden">{tooltip}</span> | ||||
| </button> | ||||
|  | @ -0,0 +1,56 @@ | |||
| <script lang="ts"> | ||||
| 	import type { CustomPreference, FieldEntry } from "$api/models"; | ||||
| 	import IconButton from "$components/IconButton.svelte"; | ||||
| 	import { t } from "$lib/i18n"; | ||||
| 	import FieldEntryEditor from "./FieldEntryEditor.svelte"; | ||||
| 
 | ||||
| 	type Props = { | ||||
| 		name: string; | ||||
| 		entries: FieldEntry[]; | ||||
| 		allPreferences: Record<string, CustomPreference>; | ||||
| 	}; | ||||
| 	let { name, entries = $bindable(), allPreferences }: Props = $props(); | ||||
| 
 | ||||
| 	let newEntry = $state(""); | ||||
| 
 | ||||
| 	const moveValue = (index: number, up: boolean) => { | ||||
| 		if (up && index == 0) return; | ||||
| 		if (!up && index == entries.length - 1) return; | ||||
| 
 | ||||
| 		const newIndex = up ? index - 1 : index + 1; | ||||
| 		const temp = entries[index]; | ||||
| 		entries[index] = entries[newIndex]; | ||||
| 		entries[newIndex] = temp; | ||||
| 		entries = [...entries]; | ||||
| 	}; | ||||
| 
 | ||||
| 	const removeValue = (index: number) => { | ||||
| 		entries.splice(index, 1); | ||||
| 		entries = [...entries]; | ||||
| 	}; | ||||
| 
 | ||||
| 	const addEntry = (event: Event) => { | ||||
| 		event.preventDefault(); | ||||
| 		if (!newEntry) return; | ||||
| 
 | ||||
| 		entries = [...entries, { value: newEntry, status: "okay" }]; | ||||
| 		newEntry = ""; | ||||
| 	}; | ||||
| </script> | ||||
| 
 | ||||
| <h4>{name}</h4> | ||||
| 
 | ||||
| {#each entries as _, index} | ||||
| 	<FieldEntryEditor | ||||
| 		{index} | ||||
| 		bind:value={entries[index]} | ||||
| 		{allPreferences} | ||||
| 		{moveValue} | ||||
| 		{removeValue} | ||||
| 	/> | ||||
| {/each} | ||||
| 
 | ||||
| <form class="input-group m-1" onsubmit={addEntry}> | ||||
| 	<input type="text" class="form-control" bind:value={newEntry} /> | ||||
| 	<IconButton type="submit" color="success" icon="plus" tooltip={$t("editor.add-entry")} /> | ||||
| </form> | ||||
|  | @ -0,0 +1,65 @@ | |||
| <script lang="ts"> | ||||
| 	import { defaultPreferences, type CustomPreference, type FieldEntry } from "$api/models"; | ||||
| 	import IconButton from "$components/IconButton.svelte"; | ||||
| 	import { t } from "$lib/i18n"; | ||||
| 	import tippy from "$lib/tippy"; | ||||
| 	import { | ||||
| 		ButtonDropdown, | ||||
| 		DropdownItem, | ||||
| 		DropdownMenu, | ||||
| 		DropdownToggle, | ||||
| 		Icon, | ||||
| 	} from "@sveltestrap/sveltestrap"; | ||||
| 
 | ||||
| 	type Props = { | ||||
| 		index: number; | ||||
| 		value: FieldEntry; | ||||
| 		allPreferences: Record<string, CustomPreference>; | ||||
| 		moveValue: (index: number, up: boolean) => void; | ||||
| 		removeValue: (index: number) => void; | ||||
| 	}; | ||||
| 	let { index, value = $bindable(), allPreferences, moveValue, removeValue }: Props = $props(); | ||||
| 
 | ||||
| 	let status = $derived( | ||||
| 		value.status in allPreferences ? allPreferences[value.status] : defaultPreferences.missing, | ||||
| 	); | ||||
| 
 | ||||
| 	let prefIds = $derived(Object.keys(allPreferences).filter((s) => s !== "missing")); | ||||
| </script> | ||||
| 
 | ||||
| <div class="input-group m-1"> | ||||
| 	<IconButton | ||||
| 		icon="chevron-up" | ||||
| 		color="secondary" | ||||
| 		tooltip={$t("editor.move-entry-up")} | ||||
| 		onclick={() => moveValue(index, true)} | ||||
| 	/> | ||||
| 	<IconButton | ||||
| 		icon="chevron-down" | ||||
| 		color="secondary" | ||||
| 		tooltip={$t("editor.move-entry-down")} | ||||
| 		onclick={() => moveValue(index, true)} | ||||
| 	/> | ||||
| 	<input type="text" class="form-control" bind:value={value.value} /> | ||||
| 	<ButtonDropdown> | ||||
| 		<span use:tippy={{ content: status.tooltip }}> | ||||
| 			<DropdownToggle color="secondary" caret> | ||||
| 				<Icon name={status.icon} /> | ||||
| 			</DropdownToggle> | ||||
| 		</span> | ||||
| 		<DropdownMenu> | ||||
| 			{#each prefIds as id} | ||||
| 				<DropdownItem on:click={() => (value.status = id)} active={value.status === id}> | ||||
| 					<Icon name={allPreferences[id].icon} aria-hidden /> | ||||
| 					{allPreferences[id].tooltip} | ||||
| 				</DropdownItem> | ||||
| 			{/each} | ||||
| 		</DropdownMenu> | ||||
| 	</ButtonDropdown> | ||||
| 	<IconButton | ||||
| 		color="danger" | ||||
| 		icon="trash3" | ||||
| 		tooltip={$t("editor.remove-entry")} | ||||
| 		onclick={() => removeValue(index)} | ||||
| 	/> | ||||
| </div> | ||||
|  | @ -0,0 +1,100 @@ | |||
| <script lang="ts"> | ||||
| 	import { defaultPreferences, type CustomPreference, type Pronoun } from "$api/models"; | ||||
| 	import IconButton from "$components/IconButton.svelte"; | ||||
| 	import { t } from "$lib/i18n"; | ||||
| 	import tippy from "$lib/tippy"; | ||||
| 	import { | ||||
| 		ButtonDropdown, | ||||
| 		Collapse, | ||||
| 		DropdownItem, | ||||
| 		DropdownMenu, | ||||
| 		DropdownToggle, | ||||
| 		Icon, | ||||
| 		InputGroupText, | ||||
| 		Popover, | ||||
| 	} from "@sveltestrap/sveltestrap"; | ||||
| 
 | ||||
| 	type Props = { | ||||
| 		index: number; | ||||
| 		value: Pronoun; | ||||
| 		allPreferences: Record<string, CustomPreference>; | ||||
| 		moveValue: (index: number, up: boolean) => void; | ||||
| 		removeValue: (index: number) => void; | ||||
| 	}; | ||||
| 	let { index, value = $bindable(), allPreferences, moveValue, removeValue }: Props = $props(); | ||||
| 
 | ||||
| 	$effect(() => { | ||||
| 		if (!value.display_text) value.display_text = null; | ||||
| 	}); | ||||
| 
 | ||||
| 	let status = $derived( | ||||
| 		value.status in allPreferences ? allPreferences[value.status] : defaultPreferences.missing, | ||||
| 	); | ||||
| 
 | ||||
| 	let prefIds = $derived(Object.keys(allPreferences).filter((s) => s !== "missing")); | ||||
| 
 | ||||
| 	let displayOpen = $state(false); | ||||
| </script> | ||||
| 
 | ||||
| <div class="m-1"> | ||||
| 	<div class="input-group"> | ||||
| 		<IconButton | ||||
| 			icon="chevron-up" | ||||
| 			color="secondary" | ||||
| 			tooltip={$t("editor.move-entry-up")} | ||||
| 			onclick={() => moveValue(index, true)} | ||||
| 		/> | ||||
| 		<IconButton | ||||
| 			icon="chevron-down" | ||||
| 			color="secondary" | ||||
| 			tooltip={$t("editor.move-entry-down")} | ||||
| 			onclick={() => moveValue(index, true)} | ||||
| 		/> | ||||
| 		<input type="text" class="form-control" bind:value={value.value} /> | ||||
| 		<ButtonDropdown> | ||||
| 			<span use:tippy={{ content: status.tooltip }}> | ||||
| 				<DropdownToggle color="secondary" caret> | ||||
| 					<Icon name={status.icon} /> | ||||
| 				</DropdownToggle> | ||||
| 			</span> | ||||
| 			<DropdownMenu> | ||||
| 				{#each prefIds as id} | ||||
| 					<DropdownItem on:click={() => (value.status = id)} active={value.status === id}> | ||||
| 						<Icon name={allPreferences[id].icon} aria-hidden /> | ||||
| 						{allPreferences[id].tooltip} | ||||
| 					</DropdownItem> | ||||
| 				{/each} | ||||
| 			</DropdownMenu> | ||||
| 		</ButtonDropdown> | ||||
| 		<IconButton | ||||
| 			color="secondary" | ||||
| 			icon={value.display_text ? "tag-fill" : "tag"} | ||||
| 			tooltip={$t("editor.change-display-text")} | ||||
| 			onclick={() => (displayOpen = !displayOpen)} | ||||
| 		/> | ||||
| 		<IconButton | ||||
| 			color="danger" | ||||
| 			icon="trash3" | ||||
| 			tooltip={$t("editor.remove-entry")} | ||||
| 			onclick={() => removeValue(index)} | ||||
| 		/> | ||||
| 	</div> | ||||
| 
 | ||||
| 	<Collapse class="mt-1" isOpen={displayOpen}> | ||||
| 		<div class="input-group"> | ||||
| 			<InputGroupText>{$t("editor.display-text-label")}</InputGroupText> | ||||
| 			<input | ||||
| 				placeholder={$t("editor.display-text-example")} | ||||
| 				type="text" | ||||
| 				class="form-control" | ||||
| 				bind:value={value.display_text} | ||||
| 			/> | ||||
| 			<IconButton id="display-help" icon="question" tooltip="Help" color="secondary" /> | ||||
| 			<!-- TODO: remove children={false} once sveltestrap is updated | ||||
| 			 This component is too complex to write manually, sadly --> | ||||
| 			<Popover target="display-help" placement="bottom" children={false}> | ||||
| 				{$t("editor.display-text-info")} | ||||
| 			</Popover> | ||||
| 		</div> | ||||
| 	</Collapse> | ||||
| </div> | ||||
|  | @ -0,0 +1,65 @@ | |||
| <script lang="ts"> | ||||
| 	import type { CustomPreference, Pronoun } from "$api/models"; | ||||
| 	import IconButton from "$components/IconButton.svelte"; | ||||
| 	import { PUBLIC_LANGUAGE } from "$env/static/public"; | ||||
| 	import defaultPronouns from "$lib/defaultPronouns"; | ||||
| 	import { t } from "$lib/i18n"; | ||||
| 	import PronounEntryEditor from "./PronounEntryEditor.svelte"; | ||||
| 
 | ||||
| 	type Props = { | ||||
| 		entries: Pronoun[]; | ||||
| 		allPreferences: Record<string, CustomPreference>; | ||||
| 	}; | ||||
| 	let { entries = $bindable(), allPreferences }: Props = $props(); | ||||
| 
 | ||||
| 	let newEntry = $state(""); | ||||
| 
 | ||||
| 	const moveValue = (index: number, up: boolean) => { | ||||
| 		if (up && index == 0) return; | ||||
| 		if (!up && index == entries.length - 1) return; | ||||
| 
 | ||||
| 		const newIndex = up ? index - 1 : index + 1; | ||||
| 		const temp = entries[index]; | ||||
| 		entries[index] = entries[newIndex]; | ||||
| 		entries[newIndex] = temp; | ||||
| 		entries = [...entries]; | ||||
| 	}; | ||||
| 
 | ||||
| 	const removeValue = (index: number) => { | ||||
| 		entries.splice(index, 1); | ||||
| 		entries = [...entries]; | ||||
| 	}; | ||||
| 
 | ||||
| 	const addEntry = (event: Event) => { | ||||
| 		event.preventDefault(); | ||||
| 		if (!newEntry) return; | ||||
| 
 | ||||
| 		// Some pronouns are commonly abbreviated and should be expanded by the editor, or people will get confused | ||||
| 		let entry = { value: newEntry, display_text: null as string | null, status: "okay" }; | ||||
| 		if (PUBLIC_LANGUAGE in defaultPronouns && newEntry in defaultPronouns[PUBLIC_LANGUAGE]) { | ||||
| 			const def = defaultPronouns[PUBLIC_LANGUAGE][newEntry]; | ||||
| 			if (def.pronouns) entry.value = def.pronouns.join("/"); | ||||
| 			if (def.display) entry.display_text = def.display; | ||||
| 		} | ||||
| 
 | ||||
| 		entries = [...entries, entry]; | ||||
| 		newEntry = ""; | ||||
| 	}; | ||||
| </script> | ||||
| 
 | ||||
| <h4>{$t("profile.pronouns-header")}</h4> | ||||
| 
 | ||||
| {#each entries as _, index} | ||||
| 	<PronounEntryEditor | ||||
| 		{index} | ||||
| 		bind:value={entries[index]} | ||||
| 		{allPreferences} | ||||
| 		{moveValue} | ||||
| 		{removeValue} | ||||
| 	/> | ||||
| {/each} | ||||
| 
 | ||||
| <form class="input-group m-1" onsubmit={addEntry}> | ||||
| 	<input type="text" class="form-control" bind:value={newEntry} /> | ||||
| 	<IconButton type="submit" color="success" icon="plus" tooltip={$t("editor.add-entry")} /> | ||||
| </form> | ||||
							
								
								
									
										16
									
								
								Foxnouns.Frontend/src/lib/defaultPronouns/en.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								Foxnouns.Frontend/src/lib/defaultPronouns/en.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,16 @@ | |||
| const enPronouns = { | ||||
| 	"they/them": { pronouns: ["they", "them", "their", "theirs", "themself"] }, | ||||
| 	"they/them (singular)": { | ||||
| 		pronouns: ["they", "them", "their", "theirs", "themself"], | ||||
| 		display: "they/them (singular)", | ||||
| 	}, | ||||
| 	"they/them (plural)": { | ||||
| 		pronouns: ["they", "them", "their", "theirs", "themselves"], | ||||
| 		display: "they/them (plural)", | ||||
| 	}, | ||||
| 	"he/him": { pronouns: ["he", "him", "his", "his", "himself"] }, | ||||
| 	"she/her": { pronouns: ["she", "her", "her", "hers", "herself"] }, | ||||
| 	"it/its": { pronouns: ["it", "it", "its", "its", "itself"], display: "it/its" }, | ||||
| } as Record<string, { pronouns: string[]; display?: string }>; | ||||
| 
 | ||||
| export default enPronouns; | ||||
							
								
								
									
										7
									
								
								Foxnouns.Frontend/src/lib/defaultPronouns/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								Foxnouns.Frontend/src/lib/defaultPronouns/index.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,7 @@ | |||
| import enPronouns from "./en"; | ||||
| 
 | ||||
| const defaultPronouns = { | ||||
| 	en: enPronouns, | ||||
| } as Record<string, Record<string, { pronouns: string[]; display?: string }>>; | ||||
| 
 | ||||
| export default defaultPronouns; | ||||
|  | @ -55,7 +55,7 @@ | |||
| 		"account-already-linked": "This account is already linked with a pronouns.cc account.", | ||||
| 		"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-min-length-error": "Value is too long, minimum length is {{min}}, current length is {{actual}}.", | ||||
| 		"validation-min-length-error": "Value is too short, minimum length is {{min}}, current length is {{actual}}.", | ||||
| 		"validation-disallowed-value-1": "The following value is not allowed here", | ||||
| 		"validation-disallowed-value-2": "Allowed values are", | ||||
| 		"validation-reason": "Reason", | ||||
|  | @ -123,7 +123,7 @@ | |||
| 		"fields-tab": "Fields", | ||||
| 		"flags-links-tab": "Flags & links", | ||||
| 		"back-to-settings-tab": "Back to settings", | ||||
| 		"member-header": "Editing member {{name}}", | ||||
| 		"member-header": "Editing profile of {{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", | ||||
|  | @ -131,8 +131,20 @@ | |||
| 		"change-member-name": "Change name", | ||||
| 		"display-name": "Display name", | ||||
| 		"unlisted-label": "Hide from member list", | ||||
| 		"unlisted-note": "This only hides this member from your public member list. They will still be visible to anyone at this link:" | ||||
| 		"unlisted-note": "This only hides this member from your public member list. They will still be visible to anyone at this link:", | ||||
| 		"edit-names-pronouns-header": "Edit names and pronouns", | ||||
| 		"back-to-profile-tab": "Back to profile" | ||||
| 	}, | ||||
| 	"save-changes": "Save changes", | ||||
| 	"change": "Change" | ||||
| 	"change": "Change", | ||||
| 	"editor": { | ||||
| 		"remove-entry": "Remove entry", | ||||
| 		"move-entry-down": "Move entry down", | ||||
| 		"move-entry-up": "Move entry up", | ||||
| 		"add-entry": "Add entry", | ||||
| 		"change-display-text": "Change display text", | ||||
| 		"display-text-example": "Optional display text (e.g. it/its)", | ||||
| 		"display-text-label": "Display text", | ||||
| 		"display-text-info": "This is the short text shown on your profile page. If you leave it empty, it will default to the first two forms of the full set." | ||||
| 	} | ||||
| } | ||||
|  |  | |||
|  | @ -5,6 +5,7 @@ import { createTippy } from "svelte-tippy"; | |||
| // temporary (probably) until sveltestrap works with svelte 5
 | ||||
| const tippy = createTippy({ | ||||
| 	animation: "scale-subtle", | ||||
| 	delay: [null, 0], | ||||
| }); | ||||
| 
 | ||||
| export default tippy; | ||||
|  |  | |||
|  | @ -33,7 +33,7 @@ | |||
| 		<h2> | ||||
| 			{data.user.member_title || $t("profile.default-members-header")} | ||||
| 			{#if isMeUser} | ||||
| 				<a class="btn btn-success" href="/settings/create-member"> | ||||
| 				<a class="btn btn-success" href="/settings/members/new"> | ||||
| 					<Icon name="person-plus-fill" aria-hidden /> | ||||
| 					{$t("profile.create-member-button")} | ||||
| 				</a> | ||||
|  |  | |||
|  | @ -8,14 +8,20 @@ | |||
| 	let { data, children }: Props = $props(); | ||||
| 
 | ||||
| 	const isActive = (path: string) => $page.url.pathname === path; | ||||
| 
 | ||||
| 	let name = $derived( | ||||
| 		data.member.display_name === data.member.name | ||||
| 			? data.member.name | ||||
| 			: `${data.member.display_name} (${data.member.name})`, | ||||
| 	); | ||||
| </script> | ||||
| 
 | ||||
| <svelte:head> | ||||
| 	<title>{$t("edit-profile.member-header", { name: data.member.name })} • pronouns.cc</title> | ||||
| 	<title>{$t("edit-profile.member-header", { name })} • pronouns.cc</title> | ||||
| </svelte:head> | ||||
| 
 | ||||
| <div class="container"> | ||||
| 	<h3>{$t("edit-profile.member-header", { name: data.member.name })}</h3> | ||||
| 	<h3>{$t("edit-profile.member-header", { name })}</h3> | ||||
| 	<div class="row"> | ||||
| 		<div class="col-md-3 mt-1 mb-3"> | ||||
| 			<div class="list-group"> | ||||
|  | @ -51,7 +57,7 @@ | |||
| 					href="/@{data.user.username}/{data.member.name}" | ||||
| 					class="list-group-item list-group-item-action text-danger" | ||||
| 				> | ||||
| 					Back to member | ||||
| 					{$t("edit-profile.back-to-profile-tab")} | ||||
| 				</a> | ||||
| 				<a href="/settings/members" class="list-group-item list-group-item-action text-danger"> | ||||
| 					{$t("edit-profile.back-to-settings-tab")} | ||||
|  |  | |||
|  | @ -64,7 +64,8 @@ export const actions = { | |||
| 	}, | ||||
| 	bio: async ({ params, request, fetch, cookies }) => { | ||||
| 		const body = await request.formData(); | ||||
| 		const bio = body.get("bio") as string | null; | ||||
| 		let bio = body.get("bio") as string | null; | ||||
| 		if (!bio || bio === "") bio = null; | ||||
| 
 | ||||
| 		try { | ||||
| 			await fastRequest("PATCH", `/users/@me/members/${params.id}`, { | ||||
|  |  | |||
|  | @ -0,0 +1,52 @@ | |||
| <script lang="ts"> | ||||
| 	import { apiRequest } from "$api"; | ||||
| 	import ApiError, { type RawApiError } from "$api/error"; | ||||
| 	import type { Member } from "$api/models"; | ||||
| 	import { mergePreferences } from "$api/models/user"; | ||||
| 	import FieldEditor from "$components/editor/FieldEditor.svelte"; | ||||
| 	import FormStatusMarker from "$components/editor/FormStatusMarker.svelte"; | ||||
| 	import PronounsEditor from "$components/editor/PronounsEditor.svelte"; | ||||
| 	import { t } from "$lib/i18n"; | ||||
| 	import log from "$lib/log"; | ||||
| 	import type { PageData } from "./$types"; | ||||
| 
 | ||||
| 	type Props = { data: PageData }; | ||||
| 	let { data }: Props = $props(); | ||||
| 
 | ||||
| 	let names = $state(data.member.names); | ||||
| 	let pronouns = $state(data.member.pronouns); | ||||
| 
 | ||||
| 	let ok: { ok: boolean; error: RawApiError | null } | null = $state(null); | ||||
| 
 | ||||
| 	let allPreferences = $derived(mergePreferences(data.user.custom_preferences)); | ||||
| 
 | ||||
| 	const update = async () => { | ||||
| 		try { | ||||
| 			const resp = await apiRequest<Member>("PATCH", `/users/@me/members/${data.member.id}`, { | ||||
| 				body: { names, pronouns }, | ||||
| 				token: data.token, | ||||
| 			}); | ||||
| 			names = resp.names; | ||||
| 			pronouns = resp.pronouns; | ||||
| 			ok = { ok: true, error: null }; | ||||
| 		} catch (e) { | ||||
| 			ok = { ok: false, error: null }; | ||||
| 			log.error("Could not update names/pronouns:", e); | ||||
| 			if (e instanceof ApiError) ok.error = e.obj; | ||||
| 		} | ||||
| 	}; | ||||
| </script> | ||||
| 
 | ||||
| <FormStatusMarker form={ok} /> | ||||
| 
 | ||||
| <div> | ||||
| 	<FieldEditor name={$t("profile.names-header")} bind:entries={names} {allPreferences} /> | ||||
| </div> | ||||
| 
 | ||||
| <div> | ||||
| 	<PronounsEditor bind:entries={pronouns} {allPreferences} /> | ||||
| </div> | ||||
| 
 | ||||
| <div> | ||||
| 	<button class="btn btn-primary" onclick={() => update()}>{$t("save-changes")}</button> | ||||
| </div> | ||||
|  | @ -2,9 +2,10 @@ | |||
| 	import type { Snippet } from "svelte"; | ||||
| 	import { page } from "$app/stores"; | ||||
| 	import { t } from "$lib/i18n"; | ||||
| 	import type { LayoutData } from "./$types"; | ||||
| 
 | ||||
| 	type Props = { children: Snippet }; | ||||
| 	let { children }: Props = $props(); | ||||
| 	type Props = { data: LayoutData; children: Snippet }; | ||||
| 	let { data, children }: Props = $props(); | ||||
| 
 | ||||
| 	const isActive = (path: string) => $page.url.pathname === path; | ||||
| </script> | ||||
|  | @ -53,6 +54,9 @@ | |||
| 				> | ||||
| 					{$t("edit-profile.flags-links-tab")} | ||||
| 				</a> | ||||
| 				<a href="/@{data.user.username}" class="list-group-item list-group-item-action text-danger"> | ||||
| 					{$t("edit-profile.back-to-profile-tab")} | ||||
| 				</a> | ||||
| 				<a href="/settings" class="list-group-item list-group-item-action text-danger"> | ||||
| 					{$t("edit-profile.back-to-settings-tab")} | ||||
| 				</a> | ||||
|  |  | |||
|  | @ -0,0 +1,51 @@ | |||
| <script lang="ts"> | ||||
| 	import { apiRequest } from "$api"; | ||||
| 	import ApiError, { type RawApiError } from "$api/error"; | ||||
| 	import { mergePreferences, type User } from "$api/models/user"; | ||||
| 	import FieldEditor from "$components/editor/FieldEditor.svelte"; | ||||
| 	import FormStatusMarker from "$components/editor/FormStatusMarker.svelte"; | ||||
| 	import PronounsEditor from "$components/editor/PronounsEditor.svelte"; | ||||
| 	import { t } from "$lib/i18n"; | ||||
| 	import log from "$lib/log"; | ||||
| 	import type { PageData } from "./$types"; | ||||
| 
 | ||||
| 	type Props = { data: PageData }; | ||||
| 	let { data }: Props = $props(); | ||||
| 
 | ||||
| 	let names = $state(data.user.names); | ||||
| 	let pronouns = $state(data.user.pronouns); | ||||
| 
 | ||||
| 	let ok: { ok: boolean; error: RawApiError | null } | null = $state(null); | ||||
| 
 | ||||
| 	let allPreferences = $derived(mergePreferences(data.user.custom_preferences)); | ||||
| 
 | ||||
| 	const update = async () => { | ||||
| 		try { | ||||
| 			const resp = await apiRequest<User>("PATCH", "/users/@me", { | ||||
| 				body: { names, pronouns }, | ||||
| 				token: data.token, | ||||
| 			}); | ||||
| 			names = resp.names; | ||||
| 			pronouns = resp.pronouns; | ||||
| 			ok = { ok: true, error: null }; | ||||
| 		} catch (e) { | ||||
| 			ok = { ok: false, error: null }; | ||||
| 			log.error("Could not update names/pronouns:", e); | ||||
| 			if (e instanceof ApiError) ok.error = e.obj; | ||||
| 		} | ||||
| 	}; | ||||
| </script> | ||||
| 
 | ||||
| <FormStatusMarker form={ok} /> | ||||
| 
 | ||||
| <div> | ||||
| 	<FieldEditor name={$t("profile.names-header")} bind:entries={names} {allPreferences} /> | ||||
| </div> | ||||
| 
 | ||||
| <div> | ||||
| 	<PronounsEditor bind:entries={pronouns} {allPreferences} /> | ||||
| </div> | ||||
| 
 | ||||
| <div> | ||||
| 	<button class="btn btn-primary" onclick={() => update()}>{$t("save-changes")}</button> | ||||
| </div> | ||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue