diff --git a/web/src/lib/components/forms/tag-asset-form.svelte b/web/src/lib/components/forms/tag-asset-form.svelte index a95b67494e..3419e62a18 100644 --- a/web/src/lib/components/forms/tag-asset-form.svelte +++ b/web/src/lib/components/forms/tag-asset-form.svelte @@ -5,10 +5,8 @@ import Combobox, { type ComboBoxOption } from '../shared-components/combobox.svelte'; import FullScreenModal from '../shared-components/full-screen-modal.svelte'; import { onMount } from 'svelte'; - import { getAllTags, type TagResponseDto } from '@immich/sdk'; + import { getAllTags, upsertTags, type TagResponseDto } from '@immich/sdk'; import Icon from '$lib/components/elements/icon.svelte'; - import { AppRoute } from '$lib/constants'; - import FormatMessage from '$lib/components/i18n/format-message.svelte'; import { SvelteSet } from 'svelte/reactivity'; interface Props { @@ -22,6 +20,7 @@ let tagMap = $derived(Object.fromEntries(allTags.map((tag) => [tag.id, tag]))); let selectedIds = $state(new SvelteSet()); let disabled = $derived(selectedIds.size === 0); + let allowCreate: boolean = $state(true); onMount(async () => { allTags = await getAllTags(); @@ -29,12 +28,18 @@ const handleSubmit = () => onTag([...selectedIds]); - const handleSelect = (option?: ComboBoxOption) => { + const handleSelect = async (option?: ComboBoxOption) => { if (!option) { return; } - selectedIds.add(option.value); + if (option.id) { + selectedIds.add(option.value); + } else { + const [newTag] = await upsertTags({ tagUpsertDto: { tags: [option.label] } }); + allTags.push(newTag); + selectedIds.add(newTag.id); + } }; const handleRemove = (tag: string) => { @@ -48,22 +53,13 @@ -
-

- - {#snippet children({ message })} - - {message} - - {/snippet} - -

-
({ id: tag.id, label: tag.value, value: tag.id }))} placeholder={$t('search_tags')} /> diff --git a/web/src/lib/components/shared-components/combobox.svelte b/web/src/lib/components/shared-components/combobox.svelte index 9dcb4d8f25..a6a1422eef 100644 --- a/web/src/lib/components/shared-components/combobox.svelte +++ b/web/src/lib/components/shared-components/combobox.svelte @@ -36,6 +36,14 @@ options?: ComboBoxOption[]; selectedOption?: ComboBoxOption | undefined; placeholder?: string; + /** + * whether creating new items is allowed. + */ + allowCreate?: boolean; + /** + * select first matching option on enter key. + */ + defaultFirstOption?: boolean; onSelect?: (option: ComboBoxOption | undefined) => void; } @@ -45,6 +53,8 @@ options = [], selectedOption = $bindable(), placeholder = '', + allowCreate = false, + defaultFirstOption = false, onSelect = () => {}, }: Props = $props(); @@ -141,7 +151,7 @@ const onInput: FormEventHandler = (event) => { openDropdown(); searchQuery = event.currentTarget.value; - selectedIndex = undefined; + selectedIndex = defaultFirstOption ? 0 : undefined; optionRefs[0]?.scrollIntoView({ block: 'nearest' }); }; @@ -221,9 +231,15 @@ searchQuery = selectedOption ? selectedOption.label : ''; }); - let filteredOptions = $derived( - options.filter((option) => option.label.toLowerCase().includes(searchQuery.toLowerCase())), - ); + let filteredOptions = $derived.by(() => { + const _options = options.filter((option) => option.label.toLowerCase().includes(searchQuery.toLowerCase())); + + if (allowCreate && searchQuery !== '' && _options.filter((option) => option.label === searchQuery).length === 0) { + _options.unshift({ label: searchQuery, value: searchQuery }); + } + + return _options; + }); let position = $derived(calculatePosition(bounds)); let dropdownDirection: 'bottom' | 'top' = $derived(getComboboxDirection(bounds, visualViewport)); @@ -352,7 +368,7 @@ id={`${listboxId}-${0}`} onclick={() => closeDropdown()} > - {$t('no_results')} + {allowCreate ? searchQuery : $t('no_results')} {/if} {#each filteredOptions as option, index (option.id || option.label)}