feat: add list/upload flag UI
This commit is contained in:
		
							parent
							
								
									a4698e179a
								
							
						
					
					
						commit
						8b03521382
					
				
					 10 changed files with 223 additions and 12 deletions
				
			
		|  | @ -19,6 +19,7 @@ import ( | |||
| 
 | ||||
| const ErrInvalidDataURI = errors.Sentinel("invalid data URI") | ||||
| const ErrInvalidContentType = errors.Sentinel("invalid avatar content type") | ||||
| const ErrFileTooLarge = errors.Sentinel("file to be converted exceeds maximum size") | ||||
| 
 | ||||
| // ConvertAvatar parses an avatar from a data URI, converts it to WebP and JPEG, and returns the results. | ||||
| func (db *DB) ConvertAvatar(data string) ( | ||||
|  |  | |||
|  | @ -59,7 +59,7 @@ const ( | |||
| ) | ||||
| 
 | ||||
| func (db *DB) AccountFlags(ctx context.Context, userID xid.ID) (fs []PrideFlag, err error) { | ||||
| 	sql, args, err := sq.Select("*").From("pride_flags").Where("user_id = ?", userID).OrderBy("id").ToSql() | ||||
| 	sql, args, err := sq.Select("*").From("pride_flags").Where("user_id = ?", userID).OrderBy("lower(name)").ToSql() | ||||
| 	if err != nil { | ||||
| 		return nil, errors.Wrap(err, "building query") | ||||
| 	} | ||||
|  | @ -285,6 +285,8 @@ func (db *DB) FlagObject(ctx context.Context, flagID xid.ID, hash string) (io.Re | |||
| 	return obj, nil | ||||
| } | ||||
| 
 | ||||
| const MaxFlagInputSize = 512_000 | ||||
| 
 | ||||
| // ConvertFlag parses a flag from a data URI, converts it to WebP, and returns the result. | ||||
| func (db *DB) ConvertFlag(data string) (webpOut *bytes.Buffer, err error) { | ||||
| 	defer vips.ShutdownThread() | ||||
|  | @ -300,6 +302,10 @@ func (db *DB) ConvertFlag(data string) (webpOut *bytes.Buffer, err error) { | |||
| 		return nil, errors.Wrap(err, "invalid base64 data") | ||||
| 	} | ||||
| 
 | ||||
| 	if len(rawData) > MaxFlagInputSize { | ||||
| 		return nil, ErrFileTooLarge | ||||
| 	} | ||||
| 
 | ||||
| 	image, err := vips.LoadImageFromBuffer(rawData, nil) | ||||
| 	if err != nil { | ||||
| 		return nil, errors.Wrap(err, "decoding image") | ||||
|  |  | |||
|  | @ -91,6 +91,8 @@ func (s *Server) postUserFlag(w http.ResponseWriter, r *http.Request) error { | |||
| 	if err != nil { | ||||
| 		if err == db.ErrInvalidDataURI { | ||||
| 			return server.APIError{Code: server.ErrBadRequest, Message: "invalid data URI"} | ||||
| 		} else if err == db.ErrFileTooLarge { | ||||
| 			return server.APIError{Code: server.ErrBadRequest, Message: "data URI exceeds 512 KB"} | ||||
| 		} | ||||
| 		return errors.Wrap(err, "converting flag") | ||||
| 	} | ||||
|  |  | |||
|  | @ -96,6 +96,13 @@ export interface MemberPartialUser { | |||
|   custom_preferences: CustomPreferences; | ||||
| } | ||||
| 
 | ||||
| export interface PrideFlag { | ||||
|   id: string; | ||||
|   hash: string; | ||||
|   name: string; | ||||
|   description: string | null; | ||||
| } | ||||
| 
 | ||||
| export interface Invite { | ||||
|   code: string; | ||||
|   created: string; | ||||
|  | @ -192,6 +199,8 @@ export const memberAvatars = (member: Member | PartialMember) => { | |||
|   ]; | ||||
| }; | ||||
| 
 | ||||
| export const flagURL = ({ hash }: PrideFlag) => `${PUBLIC_MEDIA_URL}/flags/${hash}.webp`; | ||||
| 
 | ||||
| export const defaultAvatars = [ | ||||
|   `${PUBLIC_BASE_URL}/default/512.webp`, | ||||
|   `${PUBLIC_BASE_URL}/default/512.jpg`, | ||||
|  |  | |||
|  | @ -42,20 +42,13 @@ | |||
| 
 | ||||
| <div class="grid"> | ||||
|   <div class="row"> | ||||
|     <div class="col-md-3 m-3"> | ||||
|     <div class="col-md-3 p-3"> | ||||
|       <h1>Settings</h1> | ||||
| 
 | ||||
|       <ListGroup> | ||||
|         <ListGroupItem tag="a" active={$page.url.pathname === "/settings"} href="/settings"> | ||||
|           Your profile | ||||
|         </ListGroupItem> | ||||
|         <ListGroupItem | ||||
|           tag="a" | ||||
|           active={$page.url.pathname === "/settings/auth"} | ||||
|           href="/settings/auth" | ||||
|         > | ||||
|           Authentication | ||||
|         </ListGroupItem> | ||||
|         {#if hasHiddenMembers} | ||||
|           <ListGroupItem | ||||
|             tag="a" | ||||
|  | @ -65,6 +58,14 @@ | |||
|             Hidden members | ||||
|           </ListGroupItem> | ||||
|         {/if} | ||||
|         <ListGroupItem | ||||
|           tag="a" | ||||
|           active={$page.url.pathname === "/settings/flags"} | ||||
|           href="/settings/flags">Flags</ListGroupItem | ||||
|         > | ||||
|       </ListGroup> | ||||
|       <br /> | ||||
|       <ListGroup> | ||||
|         {#if data.invitesEnabled} | ||||
|           <ListGroupItem | ||||
|             tag="a" | ||||
|  | @ -101,7 +102,7 @@ | |||
|         <ListGroupItem tag="button" on:click={toggle}>Log out</ListGroupItem> | ||||
|       </ListGroup> | ||||
|     </div> | ||||
|     <div class="col-md m-3"> | ||||
|     <div class="col-md p-3"> | ||||
|       <slot /> | ||||
|     </div> | ||||
|   </div> | ||||
|  |  | |||
|  | @ -155,8 +155,9 @@ | |||
|       {/if} | ||||
|     </div> | ||||
|     <div class="col-lg-4"> | ||||
|       <FallbackImage width={200} urls={userAvatars(data.user)} alt="Your avatar" /> | ||||
|       <p> | ||||
|       <p class="text-center"> | ||||
|         <FallbackImage width={200} urls={userAvatars(data.user)} alt="Your avatar" /> | ||||
|         <br /> | ||||
|         To change your avatar, go to <a href="/edit/profile">edit profile</a>. | ||||
|       </p> | ||||
|     </div> | ||||
|  |  | |||
							
								
								
									
										164
									
								
								frontend/src/routes/settings/flags/+page.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										164
									
								
								frontend/src/routes/settings/flags/+page.svelte
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,164 @@ | |||
| <script lang="ts"> | ||||
|   import type { APIError, PrideFlag } from "$lib/api/entities"; | ||||
|   import { Button, Icon, Input, Modal, ModalBody, ModalFooter, ModalHeader } from "sveltestrap"; | ||||
|   import type { PageData } from "./$types"; | ||||
|   import Flag from "./Flag.svelte"; | ||||
|   import prettyBytes from "pretty-bytes"; | ||||
|   import { addToast } from "$lib/toast"; | ||||
|   import { encode } from "base64-arraybuffer"; | ||||
|   import unknownFlag from "./unknown_flag.png"; | ||||
|   import { apiFetchClient } from "$lib/api/fetch"; | ||||
|   import ErrorAlert from "$lib/components/ErrorAlert.svelte"; | ||||
| 
 | ||||
|   const MAX_FLAG_BYTES = 500_000; | ||||
| 
 | ||||
|   export let data: PageData; | ||||
| 
 | ||||
|   let search = ""; | ||||
|   let error: APIError | null = null; | ||||
| 
 | ||||
|   let filtered: PrideFlag[]; | ||||
|   $: filtered = search | ||||
|     ? data.flags.filter((flag) => | ||||
|         flag.name.toLocaleLowerCase().includes(search.toLocaleLowerCase()), | ||||
|       ) | ||||
|     : data.flags; | ||||
| 
 | ||||
|   // NEW FLAG UPLOADING CODE | ||||
|   let modalOpen = false; | ||||
|   const toggleModal = () => (modalOpen = !modalOpen); | ||||
|   let canUpload: boolean; | ||||
|   $: canUpload = !!(newFlag && newName); | ||||
| 
 | ||||
|   let newFlag: string | null; | ||||
|   let flagFiles: FileList | null; | ||||
|   $: getFlag(flagFiles).then((b64) => (newFlag = b64)); | ||||
| 
 | ||||
|   let newName = ""; | ||||
|   let newDescription = ""; | ||||
| 
 | ||||
|   const getFlag = async (list: FileList | null) => { | ||||
|     if (!list || list.length === 0) return null; | ||||
|     if (list[0].size > MAX_FLAG_BYTES) { | ||||
|       addToast({ | ||||
|         header: "Flag too large", | ||||
|         body: `This flag file is too large, please resize it (maximum is ${prettyBytes( | ||||
|           MAX_FLAG_BYTES, | ||||
|         )}, the file you tried to upload is ${prettyBytes(list[0].size)})`, | ||||
|       }); | ||||
|       return null; | ||||
|     } | ||||
| 
 | ||||
|     const buffer = await list[0].arrayBuffer(); | ||||
|     const base64 = encode(buffer); | ||||
| 
 | ||||
|     const uri = `data:${list[0].type};base64,${base64}`; | ||||
| 
 | ||||
|     return uri; | ||||
|   }; | ||||
| 
 | ||||
|   const uploadFlag = async () => { | ||||
|     try { | ||||
|       const resp = await apiFetchClient<PrideFlag>("/users/@me/flags", "POST", { | ||||
|         flag: newFlag, | ||||
|         name: newName, | ||||
|         description: newDescription || null, | ||||
|       }); | ||||
| 
 | ||||
|       error = null; | ||||
|       data.flags.push(resp); | ||||
|       data.flags.sort((a, b) => a.name.localeCompare(b.name)); | ||||
|       data.flags = [...data.flags]; | ||||
| 
 | ||||
|       // reset flag | ||||
|       newFlag = null; | ||||
|       newName = ""; | ||||
|       newDescription = ""; | ||||
| 
 | ||||
|       addToast({ header: "Uploaded flag", body: "Successfully uploaded flag!" }); | ||||
|       toggleModal(); | ||||
|     } catch (e) { | ||||
|       error = e as APIError; | ||||
|     } | ||||
|   }; | ||||
| </script> | ||||
| 
 | ||||
| <h1>Pride flags ({data.flags.length})</h1> | ||||
| 
 | ||||
| <p> | ||||
|   You can upload pride flags to use on your profiles here. Flags you upload here will <em>not</em> automatically | ||||
|   show up on your profile. | ||||
| </p> | ||||
| 
 | ||||
| <div class="input-group"> | ||||
|   <Input placeholder="Filter flags" bind:value={search} disabled={data.flags.length === 0} /> | ||||
|   <Button color="success" on:click={toggleModal}> | ||||
|     <Icon name="upload" aria-hidden /> Upload flag | ||||
|   </Button> | ||||
| </div> | ||||
| 
 | ||||
| <div class="p-2"> | ||||
|   {#each filtered as flag} | ||||
|     <Flag {flag} /> | ||||
|   {:else} | ||||
|     {#if data.flags.length === 0} | ||||
|       You haven't uploaded any flags yet, press the button above to do so. | ||||
|     {:else} | ||||
|       There are no flags matching your search <strong>{search}</strong> | ||||
|     {/if} | ||||
|   {/each} | ||||
| </div> | ||||
| 
 | ||||
| <Modal isOpen={modalOpen} toggle={toggleModal}> | ||||
|   <ModalHeader toggle={toggleModal}>Upload flag</ModalHeader> | ||||
|   <ModalBody> | ||||
|     {#if error} | ||||
|       <ErrorAlert {error} /> | ||||
|     {/if} | ||||
|     <div class="d-flex align-items-center"> | ||||
|       <img src={newFlag || unknownFlag} alt="New flag" class="flag m-1" /> | ||||
|       <input | ||||
|         class="form-control" | ||||
|         id="flag-file" | ||||
|         type="file" | ||||
|         bind:files={flagFiles} | ||||
|         accept="image/png, image/jpeg, image/gif, image/webp, image/svg+xml" | ||||
|       /> | ||||
|     </div> | ||||
|     <p class="text-muted mt-2"> | ||||
|       <Icon name="info-circle-fill" aria-hidden /> Only PNG, JPEG, GIF, and WebP images can be uploaded | ||||
|       as flags. The file cannot be larger than 512 kilobytes. | ||||
|     </p> | ||||
|     <p> | ||||
|       <label for="newName" class="form-label">Name</label> | ||||
|       <Input id="newName" bind:value={newName} /> | ||||
|     </p> | ||||
|     <p class="text-muted"> | ||||
|       <Icon name="info-circle-fill" aria-hidden /> This name will be shown beside the flag. | ||||
|     </p> | ||||
|     <p> | ||||
|       <label for="description" class="form-label">Description</label> | ||||
|       <textarea id="description" class="form-control" bind:value={newDescription} /> | ||||
|     </p> | ||||
|     <p class="text-muted"> | ||||
|       <Icon name="info-circle-fill" aria-hidden /> This text will be used as the alt text of the flag | ||||
|       image, and will also be shown on hover. Optional, but <strong>strongly recommended</strong> as | ||||
|       it improves accessibility. | ||||
|     </p> | ||||
|   </ModalBody> | ||||
|   <ModalFooter> | ||||
|     <Button disabled={!canUpload} color="success" on:click={() => uploadFlag()}>Upload flag</Button> | ||||
|   </ModalFooter> | ||||
| </Modal> | ||||
| 
 | ||||
| <style> | ||||
|   .flag { | ||||
|     height: 2rem; | ||||
|     max-width: 200px; | ||||
|     border-radius: 3px; | ||||
|   } | ||||
| 
 | ||||
|   textarea { | ||||
|     height: 100px; | ||||
|   } | ||||
| </style> | ||||
							
								
								
									
										7
									
								
								frontend/src/routes/settings/flags/+page.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								frontend/src/routes/settings/flags/+page.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,7 @@ | |||
| import { apiFetchClient } from "$lib/api/fetch"; | ||||
| import type { PrideFlag } from "$lib/api/entities"; | ||||
| 
 | ||||
| export const load = async () => { | ||||
|   const data = await apiFetchClient<PrideFlag[]>("/users/@me/flags"); | ||||
|   return { flags: data }; | ||||
| }; | ||||
							
								
								
									
										20
									
								
								frontend/src/routes/settings/flags/Flag.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								frontend/src/routes/settings/flags/Flag.svelte
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,20 @@ | |||
| <script lang="ts"> | ||||
|   import { flagURL, type PrideFlag } from "$lib/api/entities"; | ||||
|   import { Button } from "sveltestrap"; | ||||
| 
 | ||||
|   export let flag: PrideFlag; | ||||
| </script> | ||||
| 
 | ||||
| <Button outline class="m-1"> | ||||
|   <img class="flag" src={flagURL(flag)} alt={flag.description ?? flag.name} /> | ||||
|   {flag.name} | ||||
| </Button> | ||||
| 
 | ||||
| <style> | ||||
|   .flag { | ||||
|     height: 2rem; | ||||
|     max-width: 200px; | ||||
|     border-radius: 3px; | ||||
|     margin-left: -5px; | ||||
|   } | ||||
| </style> | ||||
							
								
								
									
										
											BIN
										
									
								
								frontend/src/routes/settings/flags/unknown_flag.png
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								frontend/src/routes/settings/flags/unknown_flag.png
									
										
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 4.7 KiB | 
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue