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; | ||||
| 		onclick?: MouseEventHandler<HTMLButtonElement>; | ||||
| 		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> | ||||
| 
 | ||||
| <button | ||||
| 	{id} | ||||
| 	{type} | ||||
| 	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} | ||||
| > | ||||
| 	<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-description-placeholder": "Description", | ||||
| 		"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", | ||||
| 	"no": "No", | ||||
|  | @ -218,7 +219,18 @@ | |||
| 		"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", | ||||
| 		"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" | ||||
| } | ||||
|  |  | |||
|  | @ -29,6 +29,12 @@ | |||
| 		<NavLink active={isActive("/settings/profile", true)} href="/settings/profile"> | ||||
| 			{$t("settings.your-profile-tab")} | ||||
| 		</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"> | ||||
| 			{$t("settings.members-tab")} | ||||
| 		</NavLink> | ||||
|  |  | |||
|  | @ -112,7 +112,7 @@ | |||
| 
 | ||||
| <ClientPaginator center bind:currentPage {pageCount} /> | ||||
| 
 | ||||
| <Accordion> | ||||
| <Accordion class="mb-3"> | ||||
| 	{#each arr as flag (flag.id)} | ||||
| 		<AccordionItem> | ||||
| 			<span slot="header"> | ||||
|  |  | |||
|  | @ -147,7 +147,7 @@ | |||
| 	</div> | ||||
| 	<div class="row mb-3"> | ||||
| 		<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} /> | ||||
| 		</form> | ||||
| 	</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 BioEditor from "$components/editor/BioEditor.svelte"; | ||||
| 	import { t } from "$lib/i18n"; | ||||
| 	import { enhance } from "$app/forms"; | ||||
| 
 | ||||
| 	type Props = { data: PageData; form: ActionData }; | ||||
| 	let { data, form }: Props = $props(); | ||||
|  | @ -13,6 +12,6 @@ | |||
| 
 | ||||
| <h4>{$t("edit-profile.bio-tab")}</h4> | ||||
| <FormStatusMarker {form} /> | ||||
| <form method="POST" use:enhance> | ||||
| <form method="POST"> | ||||
| 	<BioEditor bind:value={bio} maxLength={data.meta.limits.bio_length} /> | ||||
| </form> | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue