feat(frontend): custom preference editor
This commit is contained in:
		
							parent
							
								
									41a008799a
								
							
						
					
					
						commit
						507b9c3f4c
					
				
					 10 changed files with 302 additions and 8 deletions
				
			
		|  | @ -11,15 +11,29 @@ | ||||||
| 		id?: string; | 		id?: string; | ||||||
| 		onclick?: MouseEventHandler<HTMLButtonElement>; | 		onclick?: MouseEventHandler<HTMLButtonElement>; | ||||||
| 		outline?: boolean; | 		outline?: boolean; | ||||||
|  | 		active?: boolean; | ||||||
|  | 		class?: string; | ||||||
| 	}; | 	}; | ||||||
| 	let { icon, tooltip, color = "primary", type, id, onclick, outline }: Props = $props(); | 	let { | ||||||
|  | 		icon, | ||||||
|  | 		tooltip, | ||||||
|  | 		color = "primary", | ||||||
|  | 		type, | ||||||
|  | 		id, | ||||||
|  | 		onclick, | ||||||
|  | 		outline, | ||||||
|  | 		active, | ||||||
|  | 		class: className, | ||||||
|  | 	}: Props = $props(); | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
| <button | <button | ||||||
| 	{id} | 	{id} | ||||||
| 	{type} | 	{type} | ||||||
| 	use:tippy={{ content: tooltip }} | 	use:tippy={{ content: tooltip }} | ||||||
| 	class="btn {outline ? `btn-outline-${color}` : `btn-${color}`}" | 	class="btn {outline ? `btn-outline-${color}` : `btn-${color}`} {className || ''}" | ||||||
|  | 	class:active | ||||||
|  | 	aria-pressed={active} | ||||||
| 	{onclick} | 	{onclick} | ||||||
| > | > | ||||||
| 	<Icon name={icon} /> | 	<Icon name={icon} /> | ||||||
|  |  | ||||||
|  | @ -0,0 +1,41 @@ | ||||||
|  | <script lang="ts"> | ||||||
|  | 	import { type CustomPreference } from "$api/models"; | ||||||
|  | 	import IconButton from "$components/IconButton.svelte"; | ||||||
|  | 	import { t } from "$lib/i18n"; | ||||||
|  | 	import PreferenceIconSelector from "./PreferenceIconSelector.svelte"; | ||||||
|  | 	import PreferenceSizeEditor from "./PreferenceSizeEditor.svelte"; | ||||||
|  | 
 | ||||||
|  | 	type Props = { pref: CustomPreference & { id: string | undefined }; remove: () => void }; | ||||||
|  | 	let { pref = $bindable(), remove }: Props = $props(); | ||||||
|  | </script> | ||||||
|  | 
 | ||||||
|  | <div class="input-group my-1"> | ||||||
|  | 	<PreferenceIconSelector bind:icon={pref.icon} /> | ||||||
|  | 	<input | ||||||
|  | 		type="text" | ||||||
|  | 		bind:value={pref.tooltip} | ||||||
|  | 		class="form-control" | ||||||
|  | 		placeholder={$t("editor.tooltip-hint")} | ||||||
|  | 	/> | ||||||
|  | 	<PreferenceSizeEditor bind:size={pref.size} /> | ||||||
|  | 	<IconButton | ||||||
|  | 		color="secondary" | ||||||
|  | 		icon={pref.favourite ? "star-fill" : "star"} | ||||||
|  | 		onclick={() => (pref.favourite = !pref.favourite)} | ||||||
|  | 		active={pref.favourite} | ||||||
|  | 		tooltip={$t("editor.custom-preference-favourite")} | ||||||
|  | 	/> | ||||||
|  | 	<IconButton | ||||||
|  | 		color="secondary" | ||||||
|  | 		icon="fonts" | ||||||
|  | 		onclick={() => (pref.muted = !pref.muted)} | ||||||
|  | 		active={pref.muted} | ||||||
|  | 		tooltip={$t("editor.custom-preference-muted")} | ||||||
|  | 	/> | ||||||
|  | 	<IconButton | ||||||
|  | 		color="danger" | ||||||
|  | 		icon="trash3" | ||||||
|  | 		tooltip={$t("editor.remove-entry")} | ||||||
|  | 		onclick={() => remove()} | ||||||
|  | 	/> | ||||||
|  | </div> | ||||||
|  | @ -0,0 +1,80 @@ | ||||||
|  | <script lang="ts"> | ||||||
|  | 	import IconButton from "$components/IconButton.svelte"; | ||||||
|  | 	import { t } from "$lib/i18n"; | ||||||
|  | 	import icons from "$lib/icons"; | ||||||
|  | 	import { | ||||||
|  | 		ButtonDropdown, | ||||||
|  | 		DropdownToggle, | ||||||
|  | 		Icon, | ||||||
|  | 		DropdownMenu, | ||||||
|  | 		DropdownItem, | ||||||
|  | 	} from "@sveltestrap/sveltestrap"; | ||||||
|  | 	import tippy from "tippy.js"; | ||||||
|  | 
 | ||||||
|  | 	const MAX_VISIBLE_ITEMS = 20; | ||||||
|  | 
 | ||||||
|  | 	type Props = { icon: string }; | ||||||
|  | 	let { icon = $bindable() }: Props = $props(); | ||||||
|  | 
 | ||||||
|  | 	let search = $state(""); | ||||||
|  | 	let showAll = $state(false); | ||||||
|  | 	let filteredIcons = $derived( | ||||||
|  | 		icons | ||||||
|  | 			.filter((icon) => icon.toLowerCase().includes(search.toLowerCase())) | ||||||
|  | 			.sort() | ||||||
|  | 			.slice(0, showAll ? undefined : MAX_VISIBLE_ITEMS), | ||||||
|  | 	); | ||||||
|  | 
 | ||||||
|  | 	let totalIcons = $derived( | ||||||
|  | 		icons.filter((icon) => icon.toLowerCase().includes(search.toLowerCase())).length, | ||||||
|  | 	); | ||||||
|  | </script> | ||||||
|  | 
 | ||||||
|  | <ButtonDropdown autoClose={false}> | ||||||
|  | 	<span use:tippy={{ content: $t("editor.icons-change-icon") }}> | ||||||
|  | 		<DropdownToggle color="secondary" caret> | ||||||
|  | 			<Icon name={icon} /> | ||||||
|  | 			<span class="visually-hidden">{$t("editor.icons-change-icon")}</span> | ||||||
|  | 		</DropdownToggle> | ||||||
|  | 	</span> | ||||||
|  | 	<DropdownMenu> | ||||||
|  | 		<p class="px-2"> | ||||||
|  | 			<input | ||||||
|  | 				type="text" | ||||||
|  | 				class="form-control" | ||||||
|  | 				bind:value={search} | ||||||
|  | 				placeholder={$t("editor.flag-search-placeholder")} | ||||||
|  | 			/> | ||||||
|  | 		</p> | ||||||
|  | 		<ul class="list-unstyled icon-list text-center"> | ||||||
|  | 			{#each filteredIcons as selectable} | ||||||
|  | 				<IconButton | ||||||
|  | 					icon={selectable} | ||||||
|  | 					tooltip={selectable} | ||||||
|  | 					type="button" | ||||||
|  | 					color="secondary" | ||||||
|  | 					outline | ||||||
|  | 					class="border-0" | ||||||
|  | 					onclick={() => (icon = selectable)} | ||||||
|  | 				/> | ||||||
|  | 			{/each} | ||||||
|  | 		</ul> | ||||||
|  | 		{#if totalIcons > MAX_VISIBLE_ITEMS || showAll} | ||||||
|  | 			<DropdownItem divider /> | ||||||
|  | 			<DropdownItem toggle onclick={() => (showAll = !showAll)}> | ||||||
|  | 				{#if showAll} | ||||||
|  | 					{$t("editor.icons-stop-showing-all")} | ||||||
|  | 				{:else} | ||||||
|  | 					{$t("editor.icons-show-all", { count: totalIcons })} | ||||||
|  | 				{/if} | ||||||
|  | 			</DropdownItem> | ||||||
|  | 		{/if} | ||||||
|  | 	</DropdownMenu> | ||||||
|  | </ButtonDropdown> | ||||||
|  | 
 | ||||||
|  | <style> | ||||||
|  | 	.icon-list { | ||||||
|  | 		max-height: 200px; | ||||||
|  | 		overflow-y: auto; | ||||||
|  | 	} | ||||||
|  | </style> | ||||||
|  | @ -0,0 +1,43 @@ | ||||||
|  | <script lang="ts"> | ||||||
|  | 	import { PreferenceSize } from "$api/models"; | ||||||
|  | 	import { t } from "$lib/i18n"; | ||||||
|  | 	import tippy from "$lib/tippy"; | ||||||
|  | 	import { | ||||||
|  | 		ButtonDropdown, | ||||||
|  | 		DropdownItem, | ||||||
|  | 		DropdownMenu, | ||||||
|  | 		DropdownToggle, | ||||||
|  | 	} from "@sveltestrap/sveltestrap"; | ||||||
|  | 	import Type from "svelte-bootstrap-icons/lib/Type.svelte"; | ||||||
|  | 
 | ||||||
|  | 	type Props = { size: PreferenceSize }; | ||||||
|  | 	let { size = $bindable() }: Props = $props(); | ||||||
|  | </script> | ||||||
|  | 
 | ||||||
|  | <ButtonDropdown> | ||||||
|  | 	<span use:tippy={{ content: $t("editor.custom-preference-size") }}> | ||||||
|  | 		<DropdownToggle color="secondary" caret> | ||||||
|  | 			<Type /> <span class="visually-hidden">{$t("editor.custom-preference-size")}</span> | ||||||
|  | 		</DropdownToggle> | ||||||
|  | 	</span> | ||||||
|  | 	<DropdownMenu> | ||||||
|  | 		<DropdownItem | ||||||
|  | 			active={size === PreferenceSize.Large} | ||||||
|  | 			on:click={() => (size = PreferenceSize.Large)} | ||||||
|  | 		> | ||||||
|  | 			{$t("editor.custom-preference-size-large")} | ||||||
|  | 		</DropdownItem> | ||||||
|  | 		<DropdownItem | ||||||
|  | 			active={size === PreferenceSize.Normal} | ||||||
|  | 			on:click={() => (size = PreferenceSize.Normal)} | ||||||
|  | 		> | ||||||
|  | 			{$t("editor.custom-preference-size-medium")} | ||||||
|  | 		</DropdownItem> | ||||||
|  | 		<DropdownItem | ||||||
|  | 			active={size === PreferenceSize.Small} | ||||||
|  | 			on:click={() => (size = PreferenceSize.Small)} | ||||||
|  | 		> | ||||||
|  | 			{$t("editor.custom-preference-size-small")} | ||||||
|  | 		</DropdownItem> | ||||||
|  | 	</DropdownMenu> | ||||||
|  | </ButtonDropdown> | ||||||
|  | @ -146,7 +146,8 @@ | ||||||
| 		"flag-upload-button": "Upload", | 		"flag-upload-button": "Upload", | ||||||
| 		"flag-description-placeholder": "Description", | 		"flag-description-placeholder": "Description", | ||||||
| 		"flag-name-placeholder": "Name", | 		"flag-name-placeholder": "Name", | ||||||
| 		"flag-upload-success": "Successfully uploaded your flag! It may take a few seconds before it's saved." | 		"flag-upload-success": "Successfully uploaded your flag! It may take a few seconds before it's saved.", | ||||||
|  | 		"custom-preferences-title": "Custom preferences" | ||||||
| 	}, | 	}, | ||||||
| 	"yes": "Yes", | 	"yes": "Yes", | ||||||
| 	"no": "No", | 	"no": "No", | ||||||
|  | @ -218,7 +219,18 @@ | ||||||
| 		"flag-search-no-account-flags": "You haven't uploaded any flags yet.", | 		"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-search-hint": "Can't find the flag you're looking for? Try using the search bar above.", | ||||||
| 		"flag-manage-your-flags": "Manage your flags", | 		"flag-manage-your-flags": "Manage your flags", | ||||||
| 		"links-header": "Links" | 		"links-header": "Links", | ||||||
|  | 		"icons-stop-showing-all": "Stop showing all results", | ||||||
|  | 		"icons-show-all": "Show all results ({{count}})", | ||||||
|  | 		"icons-change-icon": "Change icon", | ||||||
|  | 		"tooltip-hint": "Tooltip", | ||||||
|  | 		"add-custom-preference": "Add", | ||||||
|  | 		"custom-preference-size-large": "Large", | ||||||
|  | 		"custom-preference-size-medium": "Medium", | ||||||
|  | 		"custom-preference-size-small": "Small", | ||||||
|  | 		"custom-preference-size": "Text size", | ||||||
|  | 		"custom-preference-muted": "Show as muted text", | ||||||
|  | 		"custom-preference-favourite": "Treat like favourite" | ||||||
| 	}, | 	}, | ||||||
| 	"cancel": "Cancel" | 	"cancel": "Cancel" | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -29,6 +29,12 @@ | ||||||
| 		<NavLink active={isActive("/settings/profile", true)} href="/settings/profile"> | 		<NavLink active={isActive("/settings/profile", true)} href="/settings/profile"> | ||||||
| 			{$t("settings.your-profile-tab")} | 			{$t("settings.your-profile-tab")} | ||||||
| 		</NavLink> | 		</NavLink> | ||||||
|  | 		<NavLink active={isActive("/settings/flags")} href="/settings/flags"> | ||||||
|  | 			{$t("settings.flag-title")} | ||||||
|  | 		</NavLink> | ||||||
|  | 		<NavLink active={isActive("/settings/prefs")} href="/settings/prefs"> | ||||||
|  | 			{$t("settings.custom-preferences-title")} | ||||||
|  | 		</NavLink> | ||||||
| 		<NavLink active={isActive("/settings/members", true)} href="/settings/members"> | 		<NavLink active={isActive("/settings/members", true)} href="/settings/members"> | ||||||
| 			{$t("settings.members-tab")} | 			{$t("settings.members-tab")} | ||||||
| 		</NavLink> | 		</NavLink> | ||||||
|  |  | ||||||
|  | @ -112,7 +112,7 @@ | ||||||
| 
 | 
 | ||||||
| <ClientPaginator center bind:currentPage {pageCount} /> | <ClientPaginator center bind:currentPage {pageCount} /> | ||||||
| 
 | 
 | ||||||
| <Accordion> | <Accordion class="mb-3"> | ||||||
| 	{#each arr as flag (flag.id)} | 	{#each arr as flag (flag.id)} | ||||||
| 		<AccordionItem> | 		<AccordionItem> | ||||||
| 			<span slot="header"> | 			<span slot="header"> | ||||||
|  |  | ||||||
|  | @ -147,7 +147,7 @@ | ||||||
| 	</div> | 	</div> | ||||||
| 	<div class="row mb-3"> | 	<div class="row mb-3"> | ||||||
| 		<h4>{$t("edit-profile.bio-tab")}</h4> | 		<h4>{$t("edit-profile.bio-tab")}</h4> | ||||||
| 		<form method="POST" action="?/bio" use:enhance> | 		<form method="POST" action="?/bio"> | ||||||
| 			<BioEditor bind:value={bio} maxLength={data.meta.limits.bio_length} /> | 			<BioEditor bind:value={bio} maxLength={data.meta.limits.bio_length} /> | ||||||
| 		</form> | 		</form> | ||||||
| 	</div> | 	</div> | ||||||
|  |  | ||||||
							
								
								
									
										99
									
								
								Foxnouns.Frontend/src/routes/settings/prefs/+page.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										99
									
								
								Foxnouns.Frontend/src/routes/settings/prefs/+page.svelte
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,99 @@ | ||||||
|  | <script lang="ts"> | ||||||
|  | 	import { apiRequest } from "$api"; | ||||||
|  | 	import ApiError, { type RawApiError } from "$api/error"; | ||||||
|  | 	import { PreferenceSize, type CustomPreference } from "$api/models"; | ||||||
|  | 	import CustomPreferenceEditor from "$components/editor/CustomPreferenceEditor.svelte"; | ||||||
|  | 	import FormStatusMarker from "$components/editor/FormStatusMarker.svelte"; | ||||||
|  | 	import { t } from "$lib/i18n"; | ||||||
|  | 	import log from "$lib/log"; | ||||||
|  | 	import type { PageData } from "./$types"; | ||||||
|  | 
 | ||||||
|  | 	type Props = { data: PageData }; | ||||||
|  | 	let { data }: Props = $props(); | ||||||
|  | 
 | ||||||
|  | 	type EditableCustomPreference = CustomPreference & { | ||||||
|  | 		id: string | undefined; | ||||||
|  | 	}; | ||||||
|  | 
 | ||||||
|  | 	let customPreferences = $state( | ||||||
|  | 		Object.keys(data.user.custom_preferences) | ||||||
|  | 			.sort() | ||||||
|  | 			.map( | ||||||
|  | 				(id) => | ||||||
|  | 					({ | ||||||
|  | 						...data.user.custom_preferences[id], | ||||||
|  | 						id, | ||||||
|  | 					}) as EditableCustomPreference, | ||||||
|  | 			), | ||||||
|  | 	); | ||||||
|  | 
 | ||||||
|  | 	let canAdd = $derived(customPreferences.length < data.meta.limits.custom_preferences); | ||||||
|  | 	// Used for form status | ||||||
|  | 	let ok: { ok: boolean; error: RawApiError | null } | null = $state(null); | ||||||
|  | 
 | ||||||
|  | 	const remove = (idx: number) => { | ||||||
|  | 		customPreferences.splice(idx, 1); | ||||||
|  | 		customPreferences = [...customPreferences]; | ||||||
|  | 	}; | ||||||
|  | 
 | ||||||
|  | 	const add = () => { | ||||||
|  | 		customPreferences = [ | ||||||
|  | 			...customPreferences, | ||||||
|  | 			{ | ||||||
|  | 				id: undefined, | ||||||
|  | 				tooltip: "", | ||||||
|  | 				icon: "question-lg", | ||||||
|  | 				size: PreferenceSize.Normal, | ||||||
|  | 				muted: false, | ||||||
|  | 				favourite: false, | ||||||
|  | 			}, | ||||||
|  | 		]; | ||||||
|  | 	}; | ||||||
|  | 
 | ||||||
|  | 	const save = async () => { | ||||||
|  | 		try { | ||||||
|  | 			const resp = await apiRequest<Record<string, CustomPreference>>( | ||||||
|  | 				"PATCH", | ||||||
|  | 				"/users/@me/custom-preferences", | ||||||
|  | 				{ | ||||||
|  | 					body: customPreferences, | ||||||
|  | 					token: data.token, | ||||||
|  | 				}, | ||||||
|  | 			); | ||||||
|  | 
 | ||||||
|  | 			customPreferences = Object.keys(resp) | ||||||
|  | 				.sort() | ||||||
|  | 				.map( | ||||||
|  | 					(id) => | ||||||
|  | 						({ | ||||||
|  | 							...resp[id], | ||||||
|  | 							id, | ||||||
|  | 						}) as EditableCustomPreference, | ||||||
|  | 				); | ||||||
|  | 
 | ||||||
|  | 			ok = { ok: true, error: null }; | ||||||
|  | 		} catch (e) { | ||||||
|  | 			log.error("error saving custom preferences:", e); | ||||||
|  | 			if (e instanceof ApiError) ok = { ok: false, error: e.obj }; | ||||||
|  | 			else ok = null; | ||||||
|  | 		} | ||||||
|  | 	}; | ||||||
|  | </script> | ||||||
|  | 
 | ||||||
|  | <h3> | ||||||
|  | 	{$t("settings.custom-preferences-title")} | ||||||
|  | 	<div class="btn-group"> | ||||||
|  | 		<button class="btn btn-primary" onclick={() => save()}>{$t("save-changes")}</button> | ||||||
|  | 		<button class="btn btn-success" onclick={add}>{$t("editor.add-custom-preference")}</button> | ||||||
|  | 	</div> | ||||||
|  | </h3> | ||||||
|  | 
 | ||||||
|  | <FormStatusMarker form={ok} /> | ||||||
|  | 
 | ||||||
|  | <div> | ||||||
|  | 	{#each customPreferences as _, idx} | ||||||
|  | 		<CustomPreferenceEditor bind:pref={customPreferences[idx]} remove={() => remove(idx)} /> | ||||||
|  | 	{/each} | ||||||
|  | </div> | ||||||
|  | 
 | ||||||
|  | <form></form> | ||||||
|  | @ -3,7 +3,6 @@ | ||||||
| 	import type { ActionData, PageData } from "./$types"; | 	import type { ActionData, PageData } from "./$types"; | ||||||
| 	import BioEditor from "$components/editor/BioEditor.svelte"; | 	import BioEditor from "$components/editor/BioEditor.svelte"; | ||||||
| 	import { t } from "$lib/i18n"; | 	import { t } from "$lib/i18n"; | ||||||
| 	import { enhance } from "$app/forms"; |  | ||||||
| 
 | 
 | ||||||
| 	type Props = { data: PageData; form: ActionData }; | 	type Props = { data: PageData; form: ActionData }; | ||||||
| 	let { data, form }: Props = $props(); | 	let { data, form }: Props = $props(); | ||||||
|  | @ -13,6 +12,6 @@ | ||||||
| 
 | 
 | ||||||
| <h4>{$t("edit-profile.bio-tab")}</h4> | <h4>{$t("edit-profile.bio-tab")}</h4> | ||||||
| <FormStatusMarker {form} /> | <FormStatusMarker {form} /> | ||||||
| <form method="POST" use:enhance> | <form method="POST"> | ||||||
| 	<BioEditor bind:value={bio} maxLength={data.meta.limits.bio_length} /> | 	<BioEditor bind:value={bio} maxLength={data.meta.limits.bio_length} /> | ||||||
| </form> | </form> | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue