feat(frontend): user profile flag editor
This commit is contained in:
		
							parent
							
								
									d9d48c3cbf
								
							
						
					
					
						commit
						2a0df335bc
					
				
					 12 changed files with 270 additions and 13 deletions
				
			
		|  | @ -10,11 +10,18 @@ | |||
| 		type?: "submit" | "reset" | "button"; | ||||
| 		id?: string; | ||||
| 		onclick?: MouseEventHandler<HTMLButtonElement>; | ||||
| 		outline?: boolean; | ||||
| 	}; | ||||
| 	let { icon, tooltip, color = "primary", type, id, onclick }: Props = $props(); | ||||
| 	let { icon, tooltip, color = "primary", type, id, onclick, outline }: Props = $props(); | ||||
| </script> | ||||
| 
 | ||||
| <button {id} {type} use:tippy={{ content: tooltip }} class="btn btn-{color}" {onclick}> | ||||
| <button | ||||
| 	{id} | ||||
| 	{type} | ||||
| 	use:tippy={{ content: tooltip }} | ||||
| 	class="btn {outline ? `btn-outline-${color}` : `btn-${color}`}" | ||||
| 	{onclick} | ||||
| > | ||||
| 	<Icon name={icon} /> | ||||
| 	<span class="visually-hidden">{tooltip}</span> | ||||
| </button> | ||||
|  |  | |||
|  | @ -0,0 +1,35 @@ | |||
| <script lang="ts"> | ||||
| 	import type { PrideFlag } from "$api/models"; | ||||
| 	import type { MouseEventHandler } from "svelte/elements"; | ||||
| 	import EditorFlagImage from "./EditorFlagImage.svelte"; | ||||
| 	import tippy from "$lib/tippy"; | ||||
| 
 | ||||
| 	type Props = { | ||||
| 		flag: PrideFlag; | ||||
| 		tooltip?: string; | ||||
| 		class?: string; | ||||
| 		onclick: MouseEventHandler<HTMLButtonElement>; | ||||
| 		padding?: boolean; | ||||
| 	}; | ||||
| 	let { flag, tooltip, class: className, onclick, padding }: Props = $props(); | ||||
| 
 | ||||
| 	let tip = $derived(tooltip ? tippy : () => {}); | ||||
| </script> | ||||
| 
 | ||||
| <button | ||||
| 	type="button" | ||||
| 	class="btn btn-outline-secondary {className || ''}" | ||||
| 	class:padding | ||||
| 	{onclick} | ||||
| 	use:tip={{ content: tooltip }} | ||||
| > | ||||
| 	<EditorFlagImage {flag} /> | ||||
| 	{flag.name} | ||||
| </button> | ||||
| 
 | ||||
| <style> | ||||
| 	.padding { | ||||
| 		margin-right: 5px; | ||||
| 		margin-bottom: 5px; | ||||
| 	} | ||||
| </style> | ||||
|  | @ -24,11 +24,16 @@ | |||
| 		<img class="flag" src={flag.image_url ?? DEFAULT_FLAG} alt={flag.description ?? flag.name} /> | ||||
| 	</span> | ||||
| 	<div class="w-lg-50"> | ||||
| 		<input class="mb-2 form-control" placeholder="Name" bind:value={name} autocomplete="off" /> | ||||
| 		<input | ||||
| 			class="mb-2 form-control" | ||||
| 			placeholder={$t("settings.flag-name-placeholder")} | ||||
| 			bind:value={name} | ||||
| 			autocomplete="off" | ||||
| 		/> | ||||
| 		<textarea | ||||
| 			class="mb-2 form-control" | ||||
| 			style="height: 5rem;" | ||||
| 			placeholder="Description" | ||||
| 			placeholder={$t("settings.flag-description-placeholder")} | ||||
| 			bind:value={description} | ||||
| 			autocomplete="off" | ||||
| 		></textarea> | ||||
|  |  | |||
|  | @ -0,0 +1,48 @@ | |||
| <script lang="ts"> | ||||
| 	import type { PrideFlag } from "$api/models"; | ||||
| 	import InfoCircleFill from "svelte-bootstrap-icons/lib/InfoCircleFill.svelte"; | ||||
| 	import Search from "svelte-bootstrap-icons/lib/Search.svelte"; | ||||
| 	import FlagButton from "./FlagButton.svelte"; | ||||
| 	import { t } from "$lib/i18n"; | ||||
| 
 | ||||
| 	type Props = { flags: PrideFlag[]; select(flag: PrideFlag): void }; | ||||
| 	let { flags, select }: Props = $props(); | ||||
| 
 | ||||
| 	let query = $state(""); | ||||
| 	let filteredFlags = $derived(search(query)); | ||||
| 
 | ||||
| 	function search(q: string) { | ||||
| 		if (!q) return flags.slice(0, 20); | ||||
| 		return flags.filter((f) => f.name.toLowerCase().indexOf(q.toLowerCase()) !== -1).slice(0, 20); | ||||
| 	} | ||||
| </script> | ||||
| 
 | ||||
| <input class="form-control" placeholder={$t("editor.flag-search-placeholder")} bind:value={query} /> | ||||
| 
 | ||||
| <div class="mt-3"> | ||||
| 	{#each filteredFlags as flag (flag.id)} | ||||
| 		<FlagButton {flag} onclick={() => select(flag)} padding /> | ||||
| 	{:else} | ||||
| 		<div class="text-secondary text-center"> | ||||
| 			<p> | ||||
| 				<Search class="no-flags-icon" height={64} width={64} aria-hidden /> | ||||
| 			</p> | ||||
| 			<p> | ||||
| 				{#if query} | ||||
| 					{$t("editor.flag-search-no-flags")} | ||||
| 				{:else} | ||||
| 					{$t("editor.flag-search-no-account-flags")} | ||||
| 				{/if} | ||||
| 			</p> | ||||
| 		</div> | ||||
| 	{/each} | ||||
| 	{#if flags.length > 0} | ||||
| 		<p class="text-secondary mt-2"> | ||||
| 			<InfoCircleFill aria-hidden /> | ||||
| 			{$t("editor.flag-search-hint")} | ||||
| 			<a href="/settings/flags">{$t("editor.flag-manage-your-flags")}</a> | ||||
| 		</p> | ||||
| 	{:else} | ||||
| 		<p><a href="/settings/flags">{$t("editor.flag-manage-your-flags")}</a></p> | ||||
| 	{/if} | ||||
| </div> | ||||
|  | @ -0,0 +1,95 @@ | |||
| <script lang="ts"> | ||||
| 	import type { RawApiError } from "$api/error"; | ||||
| 	import type { PrideFlag } from "$api/models"; | ||||
| 	import FlagSearch from "$components/editor/FlagSearch.svelte"; | ||||
| 	import IconButton from "$components/IconButton.svelte"; | ||||
| 	import { t } from "$lib/i18n"; | ||||
| 	import FlagButton from "./FlagButton.svelte"; | ||||
| 	import FormStatusMarker from "./FormStatusMarker.svelte"; | ||||
| 
 | ||||
| 	type Props = { | ||||
| 		profileFlags: PrideFlag[]; | ||||
| 		allFlags: PrideFlag[]; | ||||
| 		save(flags: string[]): Promise<void>; | ||||
| 		form: { ok: boolean; error: RawApiError | null } | null; | ||||
| 	}; | ||||
| 	let { profileFlags, allFlags, save, form }: Props = $props(); | ||||
| 
 | ||||
| 	let flags = $state(profileFlags); | ||||
| 
 | ||||
| 	const select = (flag: PrideFlag) => { | ||||
| 		flags = [...flags, flag]; | ||||
| 	}; | ||||
| 
 | ||||
| 	const removeFlag = (flag: PrideFlag) => { | ||||
| 		const idx = flags.indexOf(flag); | ||||
| 		if (idx === -1) return; | ||||
| 		flags.splice(idx, 1); | ||||
| 		flags = [...flags]; | ||||
| 	}; | ||||
| 
 | ||||
| 	const moveFlag = (index: number, up: boolean) => { | ||||
| 		if (up && index == 0) return; | ||||
| 		if (!up && index == flags.length - 1) return; | ||||
| 
 | ||||
| 		const newIndex = up ? index - 1 : index + 1; | ||||
| 		const temp = flags[index]; | ||||
| 		flags[index] = flags[newIndex]; | ||||
| 		flags[newIndex] = temp; | ||||
| 		flags = [...flags]; | ||||
| 	}; | ||||
| 
 | ||||
| 	const saveChanges = () => save(flags.map((f) => f.id)); | ||||
| </script> | ||||
| 
 | ||||
| <div class="row"> | ||||
| 	<div class="col-md"> | ||||
| 		<h4> | ||||
| 			{$t("settings.flag-title")} | ||||
| 			<button type="button" class="btn btn-primary" onclick={() => saveChanges()}> | ||||
| 				{$t("save-changes")} | ||||
| 			</button> | ||||
| 		</h4> | ||||
| 		<FormStatusMarker {form} /> | ||||
| 		{#each flags as flag, i} | ||||
| 			<div class="d-block"> | ||||
| 				<div class="btn-group flag-group"> | ||||
| 					<IconButton | ||||
| 						icon="chevron-up" | ||||
| 						tooltip={$t("editor.move-flag-up")} | ||||
| 						color="secondary" | ||||
| 						outline | ||||
| 						onclick={() => moveFlag(i, true)} | ||||
| 					/> | ||||
| 					<IconButton | ||||
| 						icon="chevron-down" | ||||
| 						tooltip={$t("editor.move-flag-down")} | ||||
| 						color="secondary" | ||||
| 						outline | ||||
| 						onclick={() => moveFlag(i, false)} | ||||
| 					/> | ||||
| 					<FlagButton | ||||
| 						{flag} | ||||
| 						onclick={() => removeFlag(flag)} | ||||
| 						tooltip={$t("editor.remove-this-flag")} | ||||
| 					/> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 		{:else} | ||||
| 			<p class="text-secondary"> | ||||
| 				{$t("editor.no-flags-hint")} | ||||
| 			</p> | ||||
| 		{/each} | ||||
| 	</div> | ||||
| 	<div class="col-md"> | ||||
| 		<h4>{$t("editor.add-flags-header")}</h4> | ||||
| 		<FlagSearch flags={allFlags} {select} /> | ||||
| 	</div> | ||||
| </div> | ||||
| 
 | ||||
| <style> | ||||
| 	.flag-group { | ||||
| 		margin-right: 5px; | ||||
| 		margin-bottom: 5px; | ||||
| 	} | ||||
| </style> | ||||
|  | @ -128,7 +128,10 @@ | |||
| 		"flag-current-flags-title": "Current flags ({{count}}/{{max}})", | ||||
| 		"flag-title": "Flags", | ||||
| 		"flag-upload-title": "Upload a new flag", | ||||
| 		"flag-upload-button": "Upload" | ||||
| 		"flag-upload-button": "Upload", | ||||
| 		"flag-description-placeholder": "Description", | ||||
| 		"flag-name-placeholder": "Name", | ||||
| 		"flag-upload-success": "Successfully uploaded your flag! It may take a few seconds before it's saved." | ||||
| 	}, | ||||
| 	"yes": "Yes", | ||||
| 	"no": "No", | ||||
|  | @ -188,7 +191,18 @@ | |||
| 		"remove-field": "Remove field", | ||||
| 		"field-name": "Field name", | ||||
| 		"add-field": "Add field", | ||||
| 		"new-entry": "New entry" | ||||
| 		"new-entry": "New entry", | ||||
| 		"add-this-flag": "Add this flag", | ||||
| 		"add-flags-header": "Add flags", | ||||
| 		"move-flag-up": "Move flag up", | ||||
| 		"move-flag-down": "Move flag down", | ||||
| 		"remove-this-flag": "Remove this flag", | ||||
| 		"no-flags-hint": "This profile doesn't have any flags yet! Add some with the search box.", | ||||
| 		"flag-search-placeholder": "Type to start searching", | ||||
| 		"flag-search-no-flags": "No flags matched your search query.", | ||||
| 		"flag-search-no-account-flags": "You haven't uploaded any flags yet.", | ||||
| 		"flag-search-hint": "Can't find the flag you're looking for? Try using the search bar above.", | ||||
| 		"flag-manage-your-flags": "Manage your flags" | ||||
| 	}, | ||||
| 	"cancel": "Cancel" | ||||
| } | ||||
|  |  | |||
|  | @ -78,10 +78,7 @@ | |||
| <NoscriptWarning /> | ||||
| 
 | ||||
| <form method="POST" action="?/upload" enctype="multipart/form-data"> | ||||
| 	<FormStatusMarker | ||||
| 		{form} | ||||
| 		successMessage="Successfully uploaded your flag! It may take a few seconds before it's saved." | ||||
| 	/> | ||||
| 	<FormStatusMarker {form} successMessage={$t("settings.flag-upload-success")} /> | ||||
| 	<h4>{$t("settings.flag-upload-title")}</h4> | ||||
| 	<input | ||||
| 		type="file" | ||||
|  | @ -90,8 +87,19 @@ | |||
| 		class="mb-2 form-control" | ||||
| 		required | ||||
| 	/> | ||||
| 	<input class="mb-2 form-control" name="name" placeholder="Name" autocomplete="off" required /> | ||||
| 	<input class="mb-2 form-control" name="desc" placeholder="Description" autocomplete="off" /> | ||||
| 	<input | ||||
| 		class="mb-2 form-control" | ||||
| 		name="name" | ||||
| 		placeholder={$t("settings.flag-name-placeholder")} | ||||
| 		autocomplete="off" | ||||
| 		required | ||||
| 	/> | ||||
| 	<input | ||||
| 		class="mb-2 form-control" | ||||
| 		name="desc" | ||||
| 		placeholder={$t("settings.flag-description-placeholder")} | ||||
| 		autocomplete="off" | ||||
| 	/> | ||||
| 	<button type="submit" class="btn btn-primary">{$t("settings.flag-upload-button")}</button> | ||||
| </form> | ||||
| 
 | ||||
|  |  | |||
|  | @ -6,6 +6,7 @@ | |||
| 	import ApiError from "$api/error"; | ||||
| 	import log from "$lib/log"; | ||||
| 	import { Icon, InputGroup } from "@sveltestrap/sveltestrap"; | ||||
| 	import InfoCircleFill from "svelte-bootstrap-icons/lib/InfoCircleFill.svelte"; | ||||
| 	import { t } from "$lib/i18n"; | ||||
| 	import AvatarEditor from "$components/editor/AvatarEditor.svelte"; | ||||
| 	import ErrorAlert from "$components/ErrorAlert.svelte"; | ||||
|  | @ -133,7 +134,7 @@ | |||
| 				</label> | ||||
| 			</div> | ||||
| 			<p class="text-muted mt-1"> | ||||
| 				<Icon name="info-circle-fill" aria-hidden /> | ||||
| 				<InfoCircleFill aria-hidden /> | ||||
| 				{$t("edit-profile.unlisted-note")} | ||||
| 				<code> | ||||
| 					{PUBLIC_BASE_URL.substring("https://".length)}/@{data.member.user.username}/{data.member | ||||
|  |  | |||
|  | @ -0,0 +1,7 @@ | |||
| import { apiRequest } from "$api"; | ||||
| import type { PrideFlag } from "$api/models/user"; | ||||
| 
 | ||||
| export const load = async ({ fetch, cookies }) => { | ||||
| 	const flags = await apiRequest<PrideFlag[]>("GET", "/users/@me/flags", { fetch, cookies }); | ||||
| 	return { flags }; | ||||
| }; | ||||
|  | @ -0,0 +1,28 @@ | |||
| <script lang="ts"> | ||||
| 	import { fastRequest } from "$api"; | ||||
| 	import type { RawApiError } from "$api/error"; | ||||
| 	import ApiError from "$api/error"; | ||||
| 	import ProfileFlagsEditor from "$components/editor/ProfileFlagsEditor.svelte"; | ||||
| 	import log from "$lib/log"; | ||||
| 	import type { PageData } from "./$types"; | ||||
| 
 | ||||
| 	type Props = { data: PageData }; | ||||
| 	let { data }: Props = $props(); | ||||
| 
 | ||||
| 	let form: { ok: boolean; error: RawApiError | null } | null = $state(null); | ||||
| 
 | ||||
| 	const save = async (flags: string[]) => { | ||||
| 		try { | ||||
| 			await fastRequest("PATCH", "/users/@me", { | ||||
| 				body: { flags }, | ||||
| 				token: data.token, | ||||
| 			}); | ||||
| 			form = { ok: true, error: null }; | ||||
| 		} catch (e) { | ||||
| 			log.error("Could not update profile flags:", e); | ||||
| 			if (e instanceof ApiError) form = { ok: false, error: e.obj }; | ||||
| 		} | ||||
| 	}; | ||||
| </script> | ||||
| 
 | ||||
| <ProfileFlagsEditor profileFlags={data.user.flags} allFlags={data.flags} {save} {form} /> | ||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue