feat(frontend): user profile flag editor
This commit is contained in:
		
							parent
							
								
									d9d48c3cbf
								
							
						
					
					
						commit
						2a0df335bc
					
				
					 12 changed files with 270 additions and 13 deletions
				
			
		|  | @ -29,6 +29,7 @@ | ||||||
| 		"prettier-plugin-svelte": "^3.2.6", | 		"prettier-plugin-svelte": "^3.2.6", | ||||||
| 		"sass": "^1.81.0", | 		"sass": "^1.81.0", | ||||||
| 		"svelte": "^5.0.0", | 		"svelte": "^5.0.0", | ||||||
|  | 		"svelte-bootstrap-icons": "^3.1.1", | ||||||
| 		"svelte-check": "^4.0.0", | 		"svelte-check": "^4.0.0", | ||||||
| 		"sveltekit-i18n": "^2.4.2", | 		"sveltekit-i18n": "^2.4.2", | ||||||
| 		"typescript": "^5.0.0", | 		"typescript": "^5.0.0", | ||||||
|  |  | ||||||
							
								
								
									
										8
									
								
								Foxnouns.Frontend/pnpm-lock.yaml
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										8
									
								
								Foxnouns.Frontend/pnpm-lock.yaml
									
										
									
										generated
									
									
									
								
							|  | @ -93,6 +93,9 @@ importers: | ||||||
|       svelte: |       svelte: | ||||||
|         specifier: ^5.0.0 |         specifier: ^5.0.0 | ||||||
|         version: 5.2.2 |         version: 5.2.2 | ||||||
|  |       svelte-bootstrap-icons: | ||||||
|  |         specifier: ^3.1.1 | ||||||
|  |         version: 3.1.1 | ||||||
|       svelte-check: |       svelte-check: | ||||||
|         specifier: ^4.0.0 |         specifier: ^4.0.0 | ||||||
|         version: 4.0.9(picomatch@4.0.2)(svelte@5.2.2)(typescript@5.6.3) |         version: 4.0.9(picomatch@4.0.2)(svelte@5.2.2)(typescript@5.6.3) | ||||||
|  | @ -1321,6 +1324,9 @@ packages: | ||||||
|     resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} |     resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} | ||||||
|     engines: {node: '>= 0.4'} |     engines: {node: '>= 0.4'} | ||||||
| 
 | 
 | ||||||
|  |   svelte-bootstrap-icons@3.1.1: | ||||||
|  |     resolution: {integrity: sha512-ghJlt6TX3IX35M7wSvGyrmVgXeT5GMRF+7+q6L4OUT2RJWF09mQIvZTZ04Ii3FBfg10KdzFdvVuoB8M0cVHfzw==} | ||||||
|  | 
 | ||||||
|   svelte-check@4.0.9: |   svelte-check@4.0.9: | ||||||
|     resolution: {integrity: sha512-SVNCz2L+9ZELGli7G0n3B3QE5kdf0u27RtKr2ZivWQhcWIXatZxwM4VrQ6AiA2k9zKp2mk5AxkEhdjbpjv7rEw==} |     resolution: {integrity: sha512-SVNCz2L+9ZELGli7G0n3B3QE5kdf0u27RtKr2ZivWQhcWIXatZxwM4VrQ6AiA2k9zKp2mk5AxkEhdjbpjv7rEw==} | ||||||
|     engines: {node: '>= 18.0.0'} |     engines: {node: '>= 18.0.0'} | ||||||
|  | @ -2564,6 +2570,8 @@ snapshots: | ||||||
| 
 | 
 | ||||||
|   supports-preserve-symlinks-flag@1.0.0: {} |   supports-preserve-symlinks-flag@1.0.0: {} | ||||||
| 
 | 
 | ||||||
|  |   svelte-bootstrap-icons@3.1.1: {} | ||||||
|  | 
 | ||||||
|   svelte-check@4.0.9(picomatch@4.0.2)(svelte@5.2.2)(typescript@5.6.3): |   svelte-check@4.0.9(picomatch@4.0.2)(svelte@5.2.2)(typescript@5.6.3): | ||||||
|     dependencies: |     dependencies: | ||||||
|       '@jridgewell/trace-mapping': 0.3.25 |       '@jridgewell/trace-mapping': 0.3.25 | ||||||
|  |  | ||||||
|  | @ -10,11 +10,18 @@ | ||||||
| 		type?: "submit" | "reset" | "button"; | 		type?: "submit" | "reset" | "button"; | ||||||
| 		id?: string; | 		id?: string; | ||||||
| 		onclick?: MouseEventHandler<HTMLButtonElement>; | 		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> | </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} /> | 	<Icon name={icon} /> | ||||||
| 	<span class="visually-hidden">{tooltip}</span> | 	<span class="visually-hidden">{tooltip}</span> | ||||||
| </button> | </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} /> | 		<img class="flag" src={flag.image_url ?? DEFAULT_FLAG} alt={flag.description ?? flag.name} /> | ||||||
| 	</span> | 	</span> | ||||||
| 	<div class="w-lg-50"> | 	<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 | 		<textarea | ||||||
| 			class="mb-2 form-control" | 			class="mb-2 form-control" | ||||||
| 			style="height: 5rem;" | 			style="height: 5rem;" | ||||||
| 			placeholder="Description" | 			placeholder={$t("settings.flag-description-placeholder")} | ||||||
| 			bind:value={description} | 			bind:value={description} | ||||||
| 			autocomplete="off" | 			autocomplete="off" | ||||||
| 		></textarea> | 		></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-current-flags-title": "Current flags ({{count}}/{{max}})", | ||||||
| 		"flag-title": "Flags", | 		"flag-title": "Flags", | ||||||
| 		"flag-upload-title": "Upload a new flag", | 		"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", | 	"yes": "Yes", | ||||||
| 	"no": "No", | 	"no": "No", | ||||||
|  | @ -188,7 +191,18 @@ | ||||||
| 		"remove-field": "Remove field", | 		"remove-field": "Remove field", | ||||||
| 		"field-name": "Field name", | 		"field-name": "Field name", | ||||||
| 		"add-field": "Add field", | 		"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" | 	"cancel": "Cancel" | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -78,10 +78,7 @@ | ||||||
| <NoscriptWarning /> | <NoscriptWarning /> | ||||||
| 
 | 
 | ||||||
| <form method="POST" action="?/upload" enctype="multipart/form-data"> | <form method="POST" action="?/upload" enctype="multipart/form-data"> | ||||||
| 	<FormStatusMarker | 	<FormStatusMarker {form} successMessage={$t("settings.flag-upload-success")} /> | ||||||
| 		{form} |  | ||||||
| 		successMessage="Successfully uploaded your flag! It may take a few seconds before it's saved." |  | ||||||
| 	/> |  | ||||||
| 	<h4>{$t("settings.flag-upload-title")}</h4> | 	<h4>{$t("settings.flag-upload-title")}</h4> | ||||||
| 	<input | 	<input | ||||||
| 		type="file" | 		type="file" | ||||||
|  | @ -90,8 +87,19 @@ | ||||||
| 		class="mb-2 form-control" | 		class="mb-2 form-control" | ||||||
| 		required | 		required | ||||||
| 	/> | 	/> | ||||||
| 	<input class="mb-2 form-control" name="name" placeholder="Name" autocomplete="off" required /> | 	<input | ||||||
| 	<input class="mb-2 form-control" name="desc" placeholder="Description" autocomplete="off" /> | 		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> | 	<button type="submit" class="btn btn-primary">{$t("settings.flag-upload-button")}</button> | ||||||
| </form> | </form> | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -6,6 +6,7 @@ | ||||||
| 	import ApiError from "$api/error"; | 	import ApiError from "$api/error"; | ||||||
| 	import log from "$lib/log"; | 	import log from "$lib/log"; | ||||||
| 	import { Icon, InputGroup } from "@sveltestrap/sveltestrap"; | 	import { Icon, InputGroup } from "@sveltestrap/sveltestrap"; | ||||||
|  | 	import InfoCircleFill from "svelte-bootstrap-icons/lib/InfoCircleFill.svelte"; | ||||||
| 	import { t } from "$lib/i18n"; | 	import { t } from "$lib/i18n"; | ||||||
| 	import AvatarEditor from "$components/editor/AvatarEditor.svelte"; | 	import AvatarEditor from "$components/editor/AvatarEditor.svelte"; | ||||||
| 	import ErrorAlert from "$components/ErrorAlert.svelte"; | 	import ErrorAlert from "$components/ErrorAlert.svelte"; | ||||||
|  | @ -133,7 +134,7 @@ | ||||||
| 				</label> | 				</label> | ||||||
| 			</div> | 			</div> | ||||||
| 			<p class="text-muted mt-1"> | 			<p class="text-muted mt-1"> | ||||||
| 				<Icon name="info-circle-fill" aria-hidden /> | 				<InfoCircleFill aria-hidden /> | ||||||
| 				{$t("edit-profile.unlisted-note")} | 				{$t("edit-profile.unlisted-note")} | ||||||
| 				<code> | 				<code> | ||||||
| 					{PUBLIC_BASE_URL.substring("https://".length)}/@{data.member.user.username}/{data.member | 					{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