mirror of
https://github.com/immich-app/immich.git
synced 2024-12-29 15:11:58 +00:00
feat(web): create tag on the fly (#14726)
This commit is contained in:
parent
0250a7a23a
commit
34ce61d03a
2 changed files with 33 additions and 21 deletions
|
@ -5,10 +5,8 @@
|
||||||
import Combobox, { type ComboBoxOption } from '../shared-components/combobox.svelte';
|
import Combobox, { type ComboBoxOption } from '../shared-components/combobox.svelte';
|
||||||
import FullScreenModal from '../shared-components/full-screen-modal.svelte';
|
import FullScreenModal from '../shared-components/full-screen-modal.svelte';
|
||||||
import { onMount } from '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 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';
|
import { SvelteSet } from 'svelte/reactivity';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
@ -22,6 +20,7 @@
|
||||||
let tagMap = $derived(Object.fromEntries(allTags.map((tag) => [tag.id, tag])));
|
let tagMap = $derived(Object.fromEntries(allTags.map((tag) => [tag.id, tag])));
|
||||||
let selectedIds = $state(new SvelteSet<string>());
|
let selectedIds = $state(new SvelteSet<string>());
|
||||||
let disabled = $derived(selectedIds.size === 0);
|
let disabled = $derived(selectedIds.size === 0);
|
||||||
|
let allowCreate: boolean = $state(true);
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
allTags = await getAllTags();
|
allTags = await getAllTags();
|
||||||
|
@ -29,12 +28,18 @@
|
||||||
|
|
||||||
const handleSubmit = () => onTag([...selectedIds]);
|
const handleSubmit = () => onTag([...selectedIds]);
|
||||||
|
|
||||||
const handleSelect = (option?: ComboBoxOption) => {
|
const handleSelect = async (option?: ComboBoxOption) => {
|
||||||
if (!option) {
|
if (!option) {
|
||||||
return;
|
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) => {
|
const handleRemove = (tag: string) => {
|
||||||
|
@ -48,22 +53,13 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<FullScreenModal title={$t('tag_assets')} icon={mdiTag} onClose={onCancel}>
|
<FullScreenModal title={$t('tag_assets')} icon={mdiTag} onClose={onCancel}>
|
||||||
<div class="text-sm">
|
|
||||||
<p>
|
|
||||||
<FormatMessage key="tag_not_found_question">
|
|
||||||
{#snippet children({ message })}
|
|
||||||
<a href={AppRoute.TAGS} class="text-immich-primary dark:text-immich-dark-primary underline">
|
|
||||||
{message}
|
|
||||||
</a>
|
|
||||||
{/snippet}
|
|
||||||
</FormatMessage>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<form {onsubmit} autocomplete="off" id="create-tag-form">
|
<form {onsubmit} autocomplete="off" id="create-tag-form">
|
||||||
<div class="my-4 flex flex-col gap-2">
|
<div class="my-4 flex flex-col gap-2">
|
||||||
<Combobox
|
<Combobox
|
||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
label={$t('tag')}
|
label={$t('tag')}
|
||||||
|
{allowCreate}
|
||||||
|
defaultFirstOption
|
||||||
options={allTags.map((tag) => ({ id: tag.id, label: tag.value, value: tag.id }))}
|
options={allTags.map((tag) => ({ id: tag.id, label: tag.value, value: tag.id }))}
|
||||||
placeholder={$t('search_tags')}
|
placeholder={$t('search_tags')}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -36,6 +36,14 @@
|
||||||
options?: ComboBoxOption[];
|
options?: ComboBoxOption[];
|
||||||
selectedOption?: ComboBoxOption | undefined;
|
selectedOption?: ComboBoxOption | undefined;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
/**
|
||||||
|
* whether creating new items is allowed.
|
||||||
|
*/
|
||||||
|
allowCreate?: boolean;
|
||||||
|
/**
|
||||||
|
* select first matching option on enter key.
|
||||||
|
*/
|
||||||
|
defaultFirstOption?: boolean;
|
||||||
onSelect?: (option: ComboBoxOption | undefined) => void;
|
onSelect?: (option: ComboBoxOption | undefined) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -45,6 +53,8 @@
|
||||||
options = [],
|
options = [],
|
||||||
selectedOption = $bindable(),
|
selectedOption = $bindable(),
|
||||||
placeholder = '',
|
placeholder = '',
|
||||||
|
allowCreate = false,
|
||||||
|
defaultFirstOption = false,
|
||||||
onSelect = () => {},
|
onSelect = () => {},
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
|
@ -141,7 +151,7 @@
|
||||||
const onInput: FormEventHandler<HTMLInputElement> = (event) => {
|
const onInput: FormEventHandler<HTMLInputElement> = (event) => {
|
||||||
openDropdown();
|
openDropdown();
|
||||||
searchQuery = event.currentTarget.value;
|
searchQuery = event.currentTarget.value;
|
||||||
selectedIndex = undefined;
|
selectedIndex = defaultFirstOption ? 0 : undefined;
|
||||||
optionRefs[0]?.scrollIntoView({ block: 'nearest' });
|
optionRefs[0]?.scrollIntoView({ block: 'nearest' });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -221,9 +231,15 @@
|
||||||
searchQuery = selectedOption ? selectedOption.label : '';
|
searchQuery = selectedOption ? selectedOption.label : '';
|
||||||
});
|
});
|
||||||
|
|
||||||
let filteredOptions = $derived(
|
let filteredOptions = $derived.by(() => {
|
||||||
options.filter((option) => option.label.toLowerCase().includes(searchQuery.toLowerCase())),
|
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 position = $derived(calculatePosition(bounds));
|
||||||
let dropdownDirection: 'bottom' | 'top' = $derived(getComboboxDirection(bounds, visualViewport));
|
let dropdownDirection: 'bottom' | 'top' = $derived(getComboboxDirection(bounds, visualViewport));
|
||||||
</script>
|
</script>
|
||||||
|
@ -352,7 +368,7 @@
|
||||||
id={`${listboxId}-${0}`}
|
id={`${listboxId}-${0}`}
|
||||||
onclick={() => closeDropdown()}
|
onclick={() => closeDropdown()}
|
||||||
>
|
>
|
||||||
{$t('no_results')}
|
{allowCreate ? searchQuery : $t('no_results')}
|
||||||
</li>
|
</li>
|
||||||
{/if}
|
{/if}
|
||||||
{#each filteredOptions as option, index (option.id || option.label)}
|
{#each filteredOptions as option, index (option.id || option.label)}
|
||||||
|
|
Loading…
Reference in a new issue