feat: member edit page
This commit is contained in:
		
							parent
							
								
									3678f5a3e8
								
							
						
					
					
						commit
						ec6043df30
					
				
					 6 changed files with 446 additions and 4 deletions
				
			
		|  | @ -160,9 +160,9 @@ func (db *DB) UpdateMember( | |||
| 	builder := sq.Update("members").Where("id = ?", id).Suffix("RETURNING *") | ||||
| 	if name != nil { | ||||
| 		if *name == "" { | ||||
| 			builder = builder.Set("name", nil) | ||||
| 			return m, errors.Wrap(err, "name was empty") | ||||
| 		} else { | ||||
| 			builder = builder.Set("name", *displayName) | ||||
| 			builder = builder.Set("name", *name) | ||||
| 		} | ||||
| 	} | ||||
| 	if displayName != nil { | ||||
|  |  | |||
|  | @ -32,6 +32,7 @@ | |||
|   import { PUBLIC_BASE_URL } from "$env/static/public"; | ||||
|   import { apiFetchClient } from "$lib/api/fetch"; | ||||
|   import ErrorAlert from "$lib/components/ErrorAlert.svelte"; | ||||
|   import { goto } from "$app/navigation"; | ||||
| 
 | ||||
|   export let data: PageData; | ||||
| 
 | ||||
|  | @ -73,6 +74,8 @@ | |||
|       data.members = [...data.members, member]; | ||||
| 
 | ||||
|       toggleModal(); | ||||
| 
 | ||||
|       goto(`/@${data.name}/${member.name}`); | ||||
|     } catch (e) { | ||||
|       newMemberError = e as APIError; | ||||
|     } | ||||
|  |  | |||
|  | @ -8,9 +8,10 @@ | |||
|   import StatusIcon from "$lib/components/StatusIcon.svelte"; | ||||
|   import PronounLink from "$lib/components/PronounLink.svelte"; | ||||
|   import FallbackImage from "$lib/components/FallbackImage.svelte"; | ||||
|   import { Button, Icon } from "sveltestrap"; | ||||
|   import { Alert, Button, Icon } from "sveltestrap"; | ||||
|   import { memberAvatars, pronounDisplay, WordStatus } from "$lib/api/entities"; | ||||
|   import { PUBLIC_BASE_URL } from "$env/static/public"; | ||||
|   import { userStore } from "$lib/store"; | ||||
| 
 | ||||
|   export let data: PageData; | ||||
| 
 | ||||
|  | @ -22,6 +23,12 @@ | |||
| </script> | ||||
| 
 | ||||
| <div class="container"> | ||||
|   {#if $userStore && $userStore.id === data.user.id} | ||||
|     <Alert color="secondary" fade={false}> | ||||
|       You are currently viewing the <strong>public</strong> profile of {data.name}. | ||||
|       <br /><a href="/edit/member/{data.id}">Edit profile</a> | ||||
|     </Alert> | ||||
|   {/if} | ||||
|   <div> | ||||
|     <Button color="secondary" href="/@{data.user.name}"> | ||||
|       <Icon name="arrow-left" /> Back to {data.user.display_name ?? data.user.name} | ||||
|  |  | |||
							
								
								
									
										412
									
								
								frontend/src/routes/edit/member/[id]/+page.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										412
									
								
								frontend/src/routes/edit/member/[id]/+page.svelte
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,412 @@ | |||
| <script lang="ts"> | ||||
|   import { goto } from "$app/navigation"; | ||||
|   import { | ||||
|     MAX_DESCRIPTION_LENGTH, | ||||
|     memberAvatars, | ||||
|     WordStatus, | ||||
|     type APIError, | ||||
|     type Field, | ||||
|     type FieldEntry, | ||||
|     type Member, | ||||
|     type Pronoun, | ||||
|   } from "$lib/api/entities"; | ||||
|   import FallbackImage from "$lib/components/FallbackImage.svelte"; | ||||
|   import { userStore } from "$lib/store"; | ||||
|   import { | ||||
|     Alert, | ||||
|     Button, | ||||
|     ButtonGroup, | ||||
|     FormGroup, | ||||
|     Input, | ||||
|     Modal, | ||||
|     ModalBody, | ||||
|     ModalFooter, | ||||
|   } from "sveltestrap"; | ||||
|   import { encode } from "base64-arraybuffer"; | ||||
|   import { apiFetchClient } from "$lib/api/fetch"; | ||||
|   import IconButton from "$lib/components/IconButton.svelte"; | ||||
|   import EditableField from "../../EditableField.svelte"; | ||||
|   import EditableName from "../../EditableName.svelte"; | ||||
|   import EditablePronouns from "../../EditablePronouns.svelte"; | ||||
|   import ErrorAlert from "$lib/components/ErrorAlert.svelte"; | ||||
|   import type { PageData } from "./$types"; | ||||
| 
 | ||||
|   const MAX_AVATAR_BYTES = 1_000_000; | ||||
| 
 | ||||
|   export let data: PageData; | ||||
| 
 | ||||
|   if (!$userStore || $userStore.id !== data.member.user.id) { | ||||
|     goto(`/@${data.member.user.name}/${data.member.name}`); | ||||
|   } | ||||
| 
 | ||||
|   let error: APIError | null = null; | ||||
| 
 | ||||
|   let bio: string = data.member.bio || ""; | ||||
|   let name: string = data.member.name; | ||||
|   let display_name: string = data.member.display_name || ""; | ||||
|   let links: string[] = window.structuredClone(data.member.links); | ||||
|   let names: FieldEntry[] = window.structuredClone(data.member.names); | ||||
|   let pronouns: Pronoun[] = window.structuredClone(data.member.pronouns); | ||||
|   let fields: Field[] = window.structuredClone(data.member.fields); | ||||
| 
 | ||||
|   let avatar: string | null; | ||||
|   let avatar_files: FileList | null; | ||||
| 
 | ||||
|   let newName = ""; | ||||
|   let newPronouns = ""; | ||||
|   let newPronounsDisplay = ""; | ||||
|   let newLink = ""; | ||||
| 
 | ||||
|   let modified = false; | ||||
| 
 | ||||
|   $: modified = isModified(bio, display_name, links, names, pronouns, fields, avatar); | ||||
|   $: getAvatar(avatar_files).then((b64) => (avatar = b64)); | ||||
| 
 | ||||
|   const isModified = ( | ||||
|     bio: string, | ||||
|     display_name: string, | ||||
|     links: string[], | ||||
|     names: FieldEntry[], | ||||
|     pronouns: Pronoun[], | ||||
|     fields: Field[], | ||||
|     avatar: string | null, | ||||
|   ) => { | ||||
|     if (bio !== data.member.bio) return true; | ||||
|     if (display_name !== data.member.display_name) return true; | ||||
|     if (!linksEqual(links, data.member.links)) return true; | ||||
|     if (!fieldsEqual(fields, data.member.fields)) return true; | ||||
|     if (!namesEqual(names, data.member.names)) return true; | ||||
|     if (!pronounsEqual(pronouns, data.member.pronouns)) return true; | ||||
|     if (avatar !== null) return true; | ||||
| 
 | ||||
|     return false; | ||||
|   }; | ||||
| 
 | ||||
|   const fieldsEqual = (arr1: Field[], arr2: Field[]) => { | ||||
|     if (arr1?.length !== arr2?.length) return false; | ||||
|     if (!arr1.every((_, i) => arr1[i].name === arr2[i].name)) return false; | ||||
| 
 | ||||
|     return arr1.every((_, i) => | ||||
|       arr1[i].entries.every( | ||||
|         (entry, j) => | ||||
|           entry.value === arr2[i].entries[j].value && entry.status === arr2[i].entries[j].status, | ||||
|       ), | ||||
|     ); | ||||
|   }; | ||||
| 
 | ||||
|   const namesEqual = (arr1: FieldEntry[], arr2: FieldEntry[]) => { | ||||
|     if (arr1?.length !== arr2?.length) return false; | ||||
|     if (!arr1.every((_, i) => arr1[i].value === arr2[i].value)) return false; | ||||
|     if (!arr1.every((_, i) => arr1[i].status === arr2[i].status)) return false; | ||||
| 
 | ||||
|     return true; | ||||
|   }; | ||||
| 
 | ||||
|   const pronounsEqual = (arr1: Pronoun[], arr2: Pronoun[]) => { | ||||
|     if (arr1?.length !== arr2?.length) return false; | ||||
|     if (!arr1.every((_, i) => arr1[i].pronouns === arr2[i].pronouns)) return false; | ||||
|     if (!arr1.every((_, i) => arr1[i].display_text === arr2[i].display_text)) return false; | ||||
|     if (!arr1.every((_, i) => arr1[i].status === arr2[i].status)) return false; | ||||
| 
 | ||||
|     return true; | ||||
|   }; | ||||
| 
 | ||||
|   const linksEqual = (arr1: string[], arr2: string[]) => { | ||||
|     if (arr1.length !== arr2.length) return false; | ||||
|     return arr1.every((_, i) => arr1[i] === arr2[i]); | ||||
|   }; | ||||
| 
 | ||||
|   const getAvatar = async (list: FileList | null) => { | ||||
|     if (!list || list.length === 0) return null; | ||||
|     if (list[0].size > MAX_AVATAR_BYTES) return null; | ||||
| 
 | ||||
|     const buffer = await list[0].arrayBuffer(); | ||||
|     const base64 = encode(buffer); | ||||
| 
 | ||||
|     const uri = `data:${dataTypeFromFilename(list[0].name)};base64,${base64}`; | ||||
| 
 | ||||
|     return uri; | ||||
|   }; | ||||
| 
 | ||||
|   const dataTypeFromFilename = (filename: string) => { | ||||
|     if (filename.endsWith(".webp")) { | ||||
|       return "image/webp"; | ||||
|     } else if (filename.endsWith(".jpg") || filename.endsWith(".jpeg")) { | ||||
|       return "image/jpeg"; | ||||
|     } else if (filename.endsWith(".png")) { | ||||
|       return "image/png"; | ||||
|     } else if (filename.endsWith(".gif")) { | ||||
|       return "image/gif"; | ||||
|     } else { | ||||
|       return "application/octet-stream"; | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   const moveName = (index: number, up: boolean) => { | ||||
|     if (up && index == 0) return; | ||||
|     if (!up && index == names.length - 1) return; | ||||
| 
 | ||||
|     const newIndex = up ? index - 1 : index + 1; | ||||
| 
 | ||||
|     const temp = names[index]; | ||||
|     names[index] = names[newIndex]; | ||||
|     names[newIndex] = temp; | ||||
|   }; | ||||
| 
 | ||||
|   const movePronoun = (index: number, up: boolean) => { | ||||
|     if (up && index == 0) return; | ||||
|     if (!up && index == pronouns.length - 1) return; | ||||
| 
 | ||||
|     const newIndex = up ? index - 1 : index + 1; | ||||
| 
 | ||||
|     const temp = pronouns[index]; | ||||
|     pronouns[index] = pronouns[newIndex]; | ||||
|     pronouns[newIndex] = temp; | ||||
|   }; | ||||
| 
 | ||||
|   const addName = () => { | ||||
|     names = [...names, { value: newName, status: WordStatus.Okay }]; | ||||
|     newName = ""; | ||||
|   }; | ||||
| 
 | ||||
|   const addPronouns = () => { | ||||
|     pronouns = [ | ||||
|       ...pronouns, | ||||
|       { pronouns: newPronouns, display_text: newPronounsDisplay || null, status: WordStatus.Okay }, | ||||
|     ]; | ||||
|     newPronouns = ""; | ||||
|     newPronounsDisplay = ""; | ||||
|   }; | ||||
| 
 | ||||
|   const addLink = () => { | ||||
|     links = [...links, newLink]; | ||||
|     newLink = ""; | ||||
|   }; | ||||
| 
 | ||||
|   const removeName = (index: number) => { | ||||
|     if (names.length === 1) names = []; | ||||
|     else if (index === 0) names = names.slice(1); | ||||
|     else if (index === names.length - 1) names = names.slice(0, names.length - 1); | ||||
|     else names = [...names.slice(0, index - 1), ...names.slice(0, index + 1)]; | ||||
|   }; | ||||
| 
 | ||||
|   const removePronoun = (index: number) => { | ||||
|     if (pronouns.length === 1) pronouns = []; | ||||
|     else if (index === 0) pronouns = pronouns.slice(1); | ||||
|     else if (index === pronouns.length - 1) pronouns = pronouns.slice(0, pronouns.length - 1); | ||||
|     else pronouns = [...pronouns.slice(0, index - 1), ...pronouns.slice(0, index + 1)]; | ||||
|   }; | ||||
| 
 | ||||
|   const removeLink = (index: number) => { | ||||
|     if (links.length === 1) links = []; | ||||
|     else if (index === 0) links = links.slice(1); | ||||
|     else if (index === links.length - 1) links = links.slice(0, links.length - 1); | ||||
|     else links = [...links.slice(0, index - 1), ...links.slice(0, index + 1)]; | ||||
|   }; | ||||
| 
 | ||||
|   const removeField = (index: number) => { | ||||
|     if (fields.length === 1) fields = []; | ||||
|     else if (index === 0) fields = fields.slice(1); | ||||
|     else if (index === fields.length - 1) fields = fields.slice(0, fields.length - 1); | ||||
|     else fields = [...fields.slice(0, index - 1), ...fields.slice(0, index + 1)]; | ||||
|   }; | ||||
| 
 | ||||
|   const updateMember = async () => { | ||||
|     try { | ||||
|       const resp = await apiFetchClient<Member>(`/members/${data.member.id}`, "PATCH", { | ||||
|         name, | ||||
|         display_name, | ||||
|         avatar, | ||||
|         bio, | ||||
|         links, | ||||
|         names, | ||||
|         pronouns, | ||||
|         fields, | ||||
|       }); | ||||
| 
 | ||||
|       data.member = resp; | ||||
|       avatar = null; | ||||
|       error = null; | ||||
|       modified = false; | ||||
|     } catch (e) { | ||||
|       error = e as APIError; | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   const deleteMember = async () => { | ||||
|     try { | ||||
|       await apiFetchClient<any>(`/members/${data.member.id}`, "DELETE"); | ||||
| 
 | ||||
|       toggleDeleteOpen(); | ||||
|       goto(`/@${data.member.user.name}`); | ||||
|     } catch (e) { | ||||
|       deleteName = ""; | ||||
|       deleteError = e as APIError; | ||||
|     } | ||||
|   }; | ||||
| 
 | ||||
|   let deleteOpen = false; | ||||
|   const toggleDeleteOpen = () => (deleteOpen = !deleteOpen); | ||||
|   let deleteName = ""; | ||||
|   let deleteError: APIError | null = null; | ||||
| </script> | ||||
| 
 | ||||
| <svelte:head> | ||||
|   <title>Edit profile - pronouns.cc</title> | ||||
| </svelte:head> | ||||
| 
 | ||||
| <h1> | ||||
|   Edit profile | ||||
|   <ButtonGroup> | ||||
|     {#if modified} | ||||
|       <Button color="success" on:click={() => updateMember()}>Save changes</Button> | ||||
|     {/if} | ||||
|     <Button color="danger" on:click={toggleDeleteOpen}>Delete {data.member.name}</Button> | ||||
|   </ButtonGroup> | ||||
| </h1> | ||||
| 
 | ||||
| <Modal header="Delete member" isOpen={deleteOpen} toggle={toggleDeleteOpen}> | ||||
|   <ModalBody> | ||||
|     <p>If you want to delete this member, type their name below:</p> | ||||
|     <p> | ||||
|       <input type="text" class="form-control" bind:value={deleteName} /> | ||||
|     </p> | ||||
|     {#if deleteError} | ||||
|       <ErrorAlert error={deleteError} /> | ||||
|     {/if} | ||||
|   </ModalBody> | ||||
|   <ModalFooter> | ||||
|     <Button color="danger" disabled={deleteName !== data.member.name} on:click={deleteMember}> | ||||
|       Delete member | ||||
|     </Button> | ||||
|     <Button color="secondary" on:click={toggleDeleteOpen}>Cancel</Button> | ||||
|   </ModalFooter> | ||||
| </Modal> | ||||
| 
 | ||||
| {#if error} | ||||
|   <ErrorAlert {error} /> | ||||
| {/if} | ||||
| 
 | ||||
| {#if !$userStore} | ||||
|   <Alert color="danger" fade={false}>Error: No user object</Alert> | ||||
| {:else} | ||||
|   <div class="grid"> | ||||
|     <div class="row m-1"> | ||||
|       <div class="col-md"> | ||||
|         <h4>Avatar</h4> | ||||
|         <div class="row"> | ||||
|           <div class="col-md"> | ||||
|             {#if avatar} | ||||
|               <img width={200} src={avatar} alt="New avatar" class="rounded-circle img-fluid" /> | ||||
|             {:else} | ||||
|               <FallbackImage alt="Current avatar" urls={memberAvatars(data.member)} width={200} /> | ||||
|             {/if} | ||||
|           </div> | ||||
|           <div class="col-md"> | ||||
|             <input class="form-control" id="avatar" type="file" bind:files={avatar_files} /> | ||||
|           </div> | ||||
|         </div> | ||||
|       </div> | ||||
|       <div class="col-md"> | ||||
|         <div> | ||||
|           <FormGroup floating label="Name"> | ||||
|             <Input bind:value={name} /> | ||||
|           </FormGroup> | ||||
|         </div> | ||||
|         <div> | ||||
|           <FormGroup floating label="Display name"> | ||||
|             <Input bind:value={display_name} /> | ||||
|           </FormGroup> | ||||
|         </div> | ||||
|         <div> | ||||
|           <FormGroup floating label="Bio ({bio.length}/{MAX_DESCRIPTION_LENGTH})"> | ||||
|             <textarea style="min-height: 100px;" class="form-control" bind:value={bio} /> | ||||
|           </FormGroup> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|     <div class="row m-1"> | ||||
|       <div class="col-md"> | ||||
|         <h4>Names</h4> | ||||
|         {#each names as _, index} | ||||
|           <EditableName | ||||
|             bind:value={names[index].value} | ||||
|             bind:status={names[index].status} | ||||
|             moveUp={() => moveName(index, true)} | ||||
|             moveDown={() => moveName(index, false)} | ||||
|             remove={() => removeName(index)} | ||||
|           /> | ||||
|         {/each} | ||||
|         <div class="input-group m-1"> | ||||
|           <input type="text" class="form-control" bind:value={newName} /> | ||||
|           <IconButton color="success" icon="plus" tooltip="Add name" click={() => addName()} /> | ||||
|         </div> | ||||
|       </div> | ||||
|       <div class="col-md"> | ||||
|         <h4>Links</h4> | ||||
|         {#each links as _, index} | ||||
|           <div class="input-group m-1"> | ||||
|             <input type="text" class="form-control" bind:value={links[index]} /> | ||||
|             <IconButton | ||||
|               color="danger" | ||||
|               icon="trash3" | ||||
|               tooltip="Remove link" | ||||
|               click={() => removeLink(index)} | ||||
|             /> | ||||
|           </div> | ||||
|         {/each} | ||||
|         <div class="input-group m-1"> | ||||
|           <input type="text" class="form-control" bind:value={newLink} /> | ||||
|           <IconButton color="success" icon="plus" tooltip="Add link" click={() => addLink()} /> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|     <div class="row m-1"> | ||||
|       <div class="col-md"> | ||||
|         <h4>Pronouns</h4> | ||||
|         {#each pronouns as _, index} | ||||
|           <EditablePronouns | ||||
|             bind:pronoun={pronouns[index]} | ||||
|             moveUp={() => movePronoun(index, true)} | ||||
|             moveDown={() => movePronoun(index, false)} | ||||
|             remove={() => removePronoun(index)} | ||||
|           /> | ||||
|         {/each} | ||||
|         <div class="input-group m-1"> | ||||
|           <input | ||||
|             type="text" | ||||
|             class="form-control" | ||||
|             placeholder="Full set (e.g. it/it/its/its/itself)" | ||||
|             bind:value={newPronouns} | ||||
|             required | ||||
|           /> | ||||
|           <input | ||||
|             type="text" | ||||
|             class="form-control" | ||||
|             placeholder="Optional display text (e.g. it/its)" | ||||
|             bind:value={newPronounsDisplay} | ||||
|           /> | ||||
|           <IconButton | ||||
|             color="success" | ||||
|             icon="plus" | ||||
|             tooltip="Add pronouns" | ||||
|             click={() => addPronouns()} | ||||
|           /> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|     <hr /> | ||||
|     <h4> | ||||
|       Fields <Button on:click={() => (fields = [...fields, { name: "New field", entries: [] }])}> | ||||
|         Add new field | ||||
|       </Button> | ||||
|     </h4> | ||||
|   </div> | ||||
|   <div class="grid gap-3"> | ||||
|     <div class="row row-cols-1 row-cols-md-2"> | ||||
|       {#each fields as _, index} | ||||
|         <EditableField bind:field={fields[index]} deleteField={() => removeField(index)} /> | ||||
|       {/each} | ||||
|     </div> | ||||
|   </div> | ||||
| {/if} | ||||
							
								
								
									
										17
									
								
								frontend/src/routes/edit/member/[id]/+page.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								frontend/src/routes/edit/member/[id]/+page.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,17 @@ | |||
| import type { APIError, Member } from "$lib/api/entities"; | ||||
| import { apiFetch } from "$lib/api/fetch"; | ||||
| import { error } from "@sveltejs/kit"; | ||||
| 
 | ||||
| export const ssr = false; | ||||
| 
 | ||||
| export const load = async ({ params }) => { | ||||
|   try { | ||||
|     const member = await apiFetch<Member>(`/members/${params.id}`, {}); | ||||
| 
 | ||||
|     return { | ||||
|       member, | ||||
|     }; | ||||
|   } catch (e) { | ||||
|     throw error((e as APIError).code, (e as APIError).message); | ||||
|   } | ||||
| }; | ||||
|  | @ -44,8 +44,11 @@ | |||
|     try { | ||||
|       await apiFetchClient<any>("/users/@me", "DELETE"); | ||||
| 
 | ||||
|       userStore.set(null); | ||||
|       localStorage.removeItem("pronouns-token"); | ||||
|       localStorage.removeItem("pronouns-user"); | ||||
|       toggleDeleteOpen(); | ||||
|       goto("/auth/logout"); | ||||
|       goto("/"); | ||||
|     } catch (e) { | ||||
|       deleteUsername = ""; | ||||
|       deleteError = e as APIError; | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue