This commit is contained in:
Chris Peckover 2025-10-17 18:27:33 +02:00 committed by GitHub
commit 4e7cbbe37c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 359 additions and 16 deletions

View file

@ -1635,6 +1635,7 @@
"remote_assets": "Remote Assets", "remote_assets": "Remote Assets",
"remote_media_summary": "Remote Media Summary", "remote_media_summary": "Remote Media Summary",
"remove": "Remove", "remove": "Remove",
"remove_album": "Remove album",
"remove_assets_album_confirmation": "Are you sure you want to remove {count, plural, one {# asset} other {# assets}} from the album?", "remove_assets_album_confirmation": "Are you sure you want to remove {count, plural, one {# asset} other {# assets}} from the album?",
"remove_assets_shared_link_confirmation": "Are you sure you want to remove {count, plural, one {# asset} other {# assets}} from this shared link?", "remove_assets_shared_link_confirmation": "Are you sure you want to remove {count, plural, one {# asset} other {# assets}} from this shared link?",
"remove_assets_title": "Remove assets?", "remove_assets_title": "Remove assets?",

View file

@ -91,6 +91,7 @@ Class | Method | HTTP request | Description
*AlbumsApi* | [**getAlbumInfo**](doc//AlbumsApi.md#getalbuminfo) | **GET** /albums/{id} | *AlbumsApi* | [**getAlbumInfo**](doc//AlbumsApi.md#getalbuminfo) | **GET** /albums/{id} |
*AlbumsApi* | [**getAlbumStatistics**](doc//AlbumsApi.md#getalbumstatistics) | **GET** /albums/statistics | *AlbumsApi* | [**getAlbumStatistics**](doc//AlbumsApi.md#getalbumstatistics) | **GET** /albums/statistics |
*AlbumsApi* | [**getAllAlbums**](doc//AlbumsApi.md#getallalbums) | **GET** /albums | *AlbumsApi* | [**getAllAlbums**](doc//AlbumsApi.md#getallalbums) | **GET** /albums |
*AlbumsApi* | [**getAllAlbumsSlim**](doc//AlbumsApi.md#getallalbumsslim) | **GET** /albums/slim |
*AlbumsApi* | [**removeAssetFromAlbum**](doc//AlbumsApi.md#removeassetfromalbum) | **DELETE** /albums/{id}/assets | *AlbumsApi* | [**removeAssetFromAlbum**](doc//AlbumsApi.md#removeassetfromalbum) | **DELETE** /albums/{id}/assets |
*AlbumsApi* | [**removeUserFromAlbum**](doc//AlbumsApi.md#removeuserfromalbum) | **DELETE** /albums/{id}/user/{userId} | *AlbumsApi* | [**removeUserFromAlbum**](doc//AlbumsApi.md#removeuserfromalbum) | **DELETE** /albums/{id}/user/{userId} |
*AlbumsApi* | [**updateAlbumInfo**](doc//AlbumsApi.md#updatealbuminfo) | **PATCH** /albums/{id} | *AlbumsApi* | [**updateAlbumInfo**](doc//AlbumsApi.md#updatealbuminfo) | **PATCH** /albums/{id} |

View file

@ -499,6 +499,74 @@ class AlbumsApi {
return null; return null;
} }
/// This endpoint requires the `album.read` permission.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [String] assetId:
/// Only returns albums that contain the asset Ignores the shared parameter undefined: get all albums
///
/// * [bool] shared:
Future<Response> getAllAlbumsSlimWithHttpInfo({ String? assetId, bool? shared, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/albums/slim';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (assetId != null) {
queryParams.addAll(_queryParams('', 'assetId', assetId));
}
if (shared != null) {
queryParams.addAll(_queryParams('', 'shared', shared));
}
const contentTypes = <String>[];
return apiClient.invokeAPI(
apiPath,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// This endpoint requires the `album.read` permission.
///
/// Parameters:
///
/// * [String] assetId:
/// Only returns albums that contain the asset Ignores the shared parameter undefined: get all albums
///
/// * [bool] shared:
Future<List<AlbumResponseDto>?> getAllAlbumsSlim({ String? assetId, bool? shared, }) async {
final response = await getAllAlbumsSlimWithHttpInfo( assetId: assetId, shared: shared, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
final responseBody = await _decodeBodyBytes(response);
return (await apiClient.deserializeAsync(responseBody, 'List<AlbumResponseDto>') as List)
.cast<AlbumResponseDto>()
.toList(growable: false);
}
return null;
}
/// This endpoint requires the `albumAsset.delete` permission. /// This endpoint requires the `albumAsset.delete` permission.
/// ///
/// Note: This method returns the HTTP [Response]. /// Note: This method returns the HTTP [Response].

View file

@ -1001,6 +1001,62 @@
"description": "This endpoint requires the `albumAsset.create` permission." "description": "This endpoint requires the `albumAsset.create` permission."
} }
}, },
"/albums/slim": {
"get": {
"operationId": "getAllAlbumsSlim",
"parameters": [
{
"name": "assetId",
"required": false,
"in": "query",
"description": "Only returns albums that contain the asset\nIgnores the shared parameter\nundefined: get all albums",
"schema": {
"format": "uuid",
"type": "string"
}
},
{
"name": "shared",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/AlbumResponseDto"
},
"type": "array"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Albums"
],
"x-immich-permission": "album.read",
"description": "This endpoint requires the `album.read` permission."
}
},
"/albums/statistics": { "/albums/statistics": {
"get": { "get": {
"operationId": "getAlbumStatistics", "operationId": "getAlbumStatistics",

View file

@ -1925,6 +1925,23 @@ export function addAssetsToAlbums({ key, slug, albumsAddAssetsDto }: {
body: albumsAddAssetsDto body: albumsAddAssetsDto
}))); })));
} }
/**
* This endpoint requires the `album.read` permission.
*/
export function getAllAlbumsSlim({ assetId, shared }: {
assetId?: string;
shared?: boolean;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: AlbumResponseDto[];
}>(`/albums/slim${QS.query(QS.explode({
assetId,
shared
}))}`, {
...opts
}));
}
/** /**
* This endpoint requires the `album.statistics` permission. * This endpoint requires the `album.statistics` permission.
*/ */

View file

@ -37,6 +37,25 @@ describe(AlbumController.name, () => {
}); });
}); });
describe('GET /albums/slim', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).get('/albums/slim');
expect(ctx.authenticate).toHaveBeenCalled();
});
it('should reject an invalid shared param', async () => {
const { status, body } = await request(ctx.getHttpServer()).get('/albums/slim?shared=invalid');
expect(status).toEqual(400);
expect(body).toEqual(factory.responses.badRequest(['shared must be a boolean value']));
});
it('should reject an invalid assetId param', async () => {
const { status, body } = await request(ctx.getHttpServer()).get('/albums/slim?assetId=invalid');
expect(status).toEqual(400);
expect(body).toEqual(factory.responses.badRequest(['assetId must be a UUID']));
});
});
describe('GET /albums/:id', () => { describe('GET /albums/:id', () => {
it('should be an authenticated route', async () => { it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).get(`/albums/${factory.uuid()}`); await request(ctx.getHttpServer()).get(`/albums/${factory.uuid()}`);

View file

@ -30,6 +30,12 @@ export class AlbumController {
return this.service.getAll(auth, query); return this.service.getAll(auth, query);
} }
@Get('slim')
@Authenticated({ permission: Permission.AlbumRead })
getAllAlbumsSlim(@Auth() auth: AuthDto, @Query() query: GetAlbumsDto): Promise<AlbumResponseDto[]> {
return this.service.getAll(auth, query, true);
}
@Post() @Post()
@Authenticated({ permission: Permission.AlbumCreate }) @Authenticated({ permission: Permission.AlbumCreate })
createAlbum(@Auth() auth: AuthDto, @Body() dto: CreateAlbumDto): Promise<AlbumResponseDto> { createAlbum(@Auth() auth: AuthDto, @Body() dto: CreateAlbumDto): Promise<AlbumResponseDto> {

View file

@ -39,7 +39,11 @@ export class AlbumService extends BaseService {
}; };
} }
async getAll({ user: { id: ownerId } }: AuthDto, { assetId, shared }: GetAlbumsDto): Promise<AlbumResponseDto[]> { async getAll(
{ user: { id: ownerId } }: AuthDto,
{ assetId, shared }: GetAlbumsDto,
slim: boolean = false,
): Promise<AlbumResponseDto[]> {
await this.albumRepository.updateThumbnails(); await this.albumRepository.updateThumbnails();
let albums: MapAlbumDto[]; let albums: MapAlbumDto[];
@ -55,20 +59,24 @@ export class AlbumService extends BaseService {
// Get asset count for each album. Then map the result to an object: // Get asset count for each album. Then map the result to an object:
// { [albumId]: assetCount } // { [albumId]: assetCount }
const results = await this.albumRepository.getMetadataForIds(albums.map((album) => album.id));
const albumMetadata: Record<string, AlbumAssetCount> = {}; const albumMetadata: Record<string, AlbumAssetCount> = {};
for (const metadata of results) { if (!slim) {
albumMetadata[metadata.albumId] = metadata; const results = await this.albumRepository.getMetadataForIds(albums.map((album) => album.id));
for (const metadata of results) {
albumMetadata[metadata.albumId] = metadata;
}
} }
return albums.map((album) => ({ return albums.map((album) => ({
...mapAlbumWithoutAssets(album), ...mapAlbumWithoutAssets(album),
sharedLinks: undefined, sharedLinks: undefined,
startDate: albumMetadata[album.id]?.startDate ?? undefined, ...(!slim && {
endDate: albumMetadata[album.id]?.endDate ?? undefined, startDate: albumMetadata[album.id]?.startDate ?? undefined,
assetCount: albumMetadata[album.id]?.assetCount ?? 0, endDate: albumMetadata[album.id]?.endDate ?? undefined,
// lastModifiedAssetTimestamp is only used in mobile app, please remove if not need assetCount: albumMetadata[album.id]?.assetCount ?? 0,
lastModifiedAssetTimestamp: albumMetadata[album.id]?.lastModifiedAssetTimestamp ?? undefined, // lastModifiedAssetTimestamp is only used in mobile app, please remove if not need
lastModifiedAssetTimestamp: albumMetadata[album.id]?.lastModifiedAssetTimestamp ?? undefined,
}),
})); }));
} }

View file

@ -5,6 +5,7 @@
id?: string; id?: string;
label: string; label: string;
value: string; value: string;
thumbnail?: string;
}; };
export const asComboboxOptions = (values: string[]) => export const asComboboxOptions = (values: string[]) =>
@ -45,6 +46,7 @@
* select first matching option on enter key. * select first matching option on enter key.
*/ */
defaultFirstOption?: boolean; defaultFirstOption?: boolean;
hasThumbnails?: boolean;
onSelect?: (option: ComboBoxOption | undefined) => void; onSelect?: (option: ComboBoxOption | undefined) => void;
forceFocus?: boolean; forceFocus?: boolean;
} }
@ -58,6 +60,7 @@
placeholder = '', placeholder = '',
allowCreate = false, allowCreate = false,
defaultFirstOption = false, defaultFirstOption = false,
hasThumbnails = false,
onSelect = () => {}, onSelect = () => {},
forceFocus = false, forceFocus = false,
}: Props = $props(); }: Props = $props();
@ -391,12 +394,34 @@
<li <li
aria-selected={index === selectedIndex} aria-selected={index === selectedIndex}
bind:this={optionRefs[index]} bind:this={optionRefs[index]}
class="text-start w-full px-4 py-2 hover:bg-gray-200 dark:hover:bg-gray-700 transition-all cursor-pointer aria-selected:bg-gray-200 aria-selected:dark:bg-gray-700 break-words"
id={`${listboxId}-${index}`} id={`${listboxId}-${index}`}
onclick={() => handleSelect(option)} onclick={() => handleSelect(option)}
role="option" role="option"
> >
{option.label} {#if hasThumbnails}
<div
class="text-start flex w-full place-items-center gap-4 rounded-e-full py-3 transition-[padding] delay-100 duration-100 hover:cursor-pointer hover:bg-subtle hover:text-immich-primary dark:text-immich-dark-fg dark:hover:bg-immich-dark-gray dark:hover:text-immich-dark-primary px-4"
>
{#if option.thumbnail}
<img
src={option.thumbnail}
alt={option.label}
class="h-6 w-6 bg-cover rounded hover:shadow-lg"
data-testid="album-image"
draggable="false"
/>
{:else}
<div class="h-6 w-6 bg-cover rounded hover:shadow-lg"></div>
{/if}
{option.label}
</div>
{:else}
<div
class="text-start w-full px-4 py-2 hover:bg-gray-200 dark:hover:bg-gray-700 transition-all cursor-pointer aria-selected:bg-gray-200 aria-selected:dark:bg-gray-700 break-words"
>
{option.label}
</div>
{/if}
</li> </li>
{/each} {/each}
{/if} {/if}

View file

@ -0,0 +1,100 @@
<script lang="ts">
import Combobox, { type ComboBoxOption } from '$lib/components/shared-components/combobox.svelte';
import { getAssetThumbnailUrl } from '$lib/utils';
import { getAllAlbumsSlim, type AlbumResponseDto } from '@immich/sdk';
import { Icon } from '@immich/ui';
import { mdiClose } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import { SvelteSet } from 'svelte/reactivity';
interface Props {
selectedAlbums: SvelteSet<string>;
}
let { selectedAlbums = $bindable() }: Props = $props();
let allAlbums: AlbumResponseDto[] = $state([]);
let albumMap = $derived(Object.fromEntries(allAlbums.map((album) => [album.id, album])));
let selectedOption = $state(undefined);
let sortedSelectedAlbums = $derived(
Array.from(selectedAlbums).sort((a, b) => albumMap[b]?.albumName?.length - albumMap[a]?.albumName?.length),
);
onMount(async () => {
allAlbums = await getAllAlbumsSlim({});
});
const handleSelect = (option?: ComboBoxOption) => {
if (!option || !option.id) {
return;
}
selectedAlbums.add(option.value);
selectedOption = undefined;
};
const handleRemove = (album: string) => {
selectedAlbums.delete(album);
};
</script>
<div id="location-selection">
<form autocomplete="off" id="create-album-form">
<div class="my-4 flex flex-col gap-2">
<Combobox
onSelect={handleSelect}
hasThumbnails
label={$t('albums').toUpperCase()}
defaultFirstOption
options={allAlbums.map((album) => ({
id: album.id,
label: album.albumName,
value: album.id,
thumbnail: album.albumThumbnailAssetId ? getAssetThumbnailUrl(album.albumThumbnailAssetId) : undefined,
}))}
bind:selectedOption
placeholder={$t('search_albums')}
/>
</div>
</form>
<section class="flex flex-wrap pt-2 gap-1">
{#each sortedSelectedAlbums as albumId (albumId)}
{@const album = albumMap[albumId]}
{#if album}
<div class="album-chip-container flex group transition-all">
<span
class="album-chip inline-block h-min whitespace-nowrap ps-3 pe-1 group-hover:ps-3 py-1 text-center align-baseline leading-none text-gray-100 dark:text-immich-dark-gray bg-immich-primary dark:bg-immich-dark-primary roudned-s-full hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
>
<p class="text-sm album-chip">
{album.albumName}
</p>
</span>
<button
type="button"
class="text-gray-100 dark:text-immich-dark-gray bg-immich-primary/95 dark:bg-immich-dark-primary/95 rounded-e-full place-items-center place-content-center pe-2 ps-1 py-1 hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
title={$t('remove_album')}
onclick={() => handleRemove(albumId)}
>
<Icon icon={mdiClose} />
</button>
</div>
{/if}
{/each}
</section>
</div>
<style>
.album-chip-container {
max-width: 100%;
}
.album-chip {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

View file

@ -10,12 +10,21 @@
import { Checkbox, Label } from '@immich/ui'; import { Checkbox, Label } from '@immich/ui';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import type { SvelteSet } from 'svelte/reactivity';
interface Props { interface Props {
filters: SearchDisplayFilters; filters: SearchDisplayFilters;
selectedAlbums: SvelteSet<string>;
} }
let { filters = $bindable() }: Props = $props(); let { filters = $bindable(), selectedAlbums }: Props = $props();
//disable the filter if albums get selected
$effect(() => {
if (selectedAlbums?.size > 0) {
filters.isNotInAlbum = false;
}
});
</script> </script>
<div id="display-options-selection"> <div id="display-options-selection">
@ -23,7 +32,12 @@
<legend class="uppercase immich-form-label">{$t('display_options')}</legend> <legend class="uppercase immich-form-label">{$t('display_options')}</legend>
<div class="flex flex-wrap gap-x-5 gap-y-2 mt-1"> <div class="flex flex-wrap gap-x-5 gap-y-2 mt-1">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Checkbox id="not-in-album-checkbox" size="tiny" bind:checked={filters.isNotInAlbum} /> <Checkbox
disabled={selectedAlbums?.size > 0}
id="not-in-album-checkbox"
size="tiny"
bind:checked={filters.isNotInAlbum}
/>
<Label label={$t('not_in_any_album')} for="not-in-album-checkbox" /> <Label label={$t('not_in_any_album')} for="not-in-album-checkbox" />
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">

View file

@ -8,6 +8,7 @@
query: string; query: string;
queryType: 'smart' | 'metadata' | 'description'; queryType: 'smart' | 'metadata' | 'description';
personIds: SvelteSet<string>; personIds: SvelteSet<string>;
albumIds: SvelteSet<string>;
tagIds: SvelteSet<string> | null; tagIds: SvelteSet<string> | null;
location: SearchLocationFilter; location: SearchLocationFilter;
camera: SearchCameraFilter; camera: SearchCameraFilter;
@ -19,6 +20,7 @@
</script> </script>
<script lang="ts"> <script lang="ts">
import SearchAlbumsSection from '$lib/components/shared-components/search-bar/search-albums-section.svelte';
import SearchCameraSection, { import SearchCameraSection, {
type SearchCameraFilter, type SearchCameraFilter,
} from '$lib/components/shared-components/search-bar/search-camera-section.svelte'; } from '$lib/components/shared-components/search-bar/search-camera-section.svelte';
@ -76,6 +78,7 @@
query, query,
queryType: defaultQueryType(), queryType: defaultQueryType(),
personIds: new SvelteSet('personIds' in searchQuery ? searchQuery.personIds : []), personIds: new SvelteSet('personIds' in searchQuery ? searchQuery.personIds : []),
albumIds: new SvelteSet('albumIds' in searchQuery ? searchQuery.albumIds : []),
tagIds: tagIds:
'tagIds' in searchQuery 'tagIds' in searchQuery
? searchQuery.tagIds === null ? searchQuery.tagIds === null
@ -114,6 +117,7 @@
query: '', query: '',
queryType: defaultQueryType(), // retain from localStorage or default queryType: defaultQueryType(), // retain from localStorage or default
personIds: new SvelteSet(), personIds: new SvelteSet(),
albumIds: new SvelteSet(),
tagIds: new SvelteSet(), tagIds: new SvelteSet(),
location: {}, location: {},
camera: {}, camera: {},
@ -153,6 +157,7 @@
isFavorite: filter.display.isFavorite || undefined, isFavorite: filter.display.isFavorite || undefined,
isNotInAlbum: filter.display.isNotInAlbum || undefined, isNotInAlbum: filter.display.isNotInAlbum || undefined,
personIds: filter.personIds.size > 0 ? [...filter.personIds] : undefined, personIds: filter.personIds.size > 0 ? [...filter.personIds] : undefined,
albumIds: filter.albumIds.size > 0 ? [...filter.albumIds] : undefined,
tagIds: filter.tagIds === null ? null : filter.tagIds.size > 0 ? [...filter.tagIds] : undefined, tagIds: filter.tagIds === null ? null : filter.tagIds.size > 0 ? [...filter.tagIds] : undefined,
type, type,
rating: filter.rating, rating: filter.rating,
@ -188,8 +193,13 @@
<!-- TEXT --> <!-- TEXT -->
<SearchTextSection bind:query={filter.query} bind:queryType={filter.queryType} /> <SearchTextSection bind:query={filter.query} bind:queryType={filter.queryType} />
<!-- TAGS --> <div class="grid grid-auto-fit-40 gap-5">
<SearchTagsSection bind:selectedTags={filter.tagIds} /> <!-- ALBUMS -->
<SearchAlbumsSection bind:selectedAlbums={filter.albumIds} />
<!-- TAGS -->
<SearchTagsSection bind:selectedTags={filter.tagIds} />
</div>
<!-- LOCATION --> <!-- LOCATION -->
<SearchLocationSection bind:filters={filter.location} /> <SearchLocationSection bind:filters={filter.location} />
@ -210,7 +220,7 @@
<SearchMediaSection bind:filteredMedia={filter.mediaType} /> <SearchMediaSection bind:filteredMedia={filter.mediaType} />
<!-- DISPLAY OPTIONS --> <!-- DISPLAY OPTIONS -->
<SearchDisplaySection bind:filters={filter.display} /> <SearchDisplaySection bind:filters={filter.display} selectedAlbums={filter.albumIds} />
</div> </div>
</div> </div>
</form> </form>

View file

@ -35,6 +35,7 @@
import { toTimelineAsset } from '$lib/utils/timeline-util'; import { toTimelineAsset } from '$lib/utils/timeline-util';
import { import {
type AlbumResponseDto, type AlbumResponseDto,
getAlbumInfo,
getPerson, getPerson,
getTagById, getTagById,
type MetadataSearchDto, type MetadataSearchDto,
@ -201,6 +202,7 @@
model: $t('camera_model'), model: $t('camera_model'),
lensModel: $t('lens_model'), lensModel: $t('lens_model'),
personIds: $t('people'), personIds: $t('people'),
albumIds: $t('albums'),
tagIds: $t('tags'), tagIds: $t('tags'),
originalFileName: $t('file_name'), originalFileName: $t('file_name'),
description: $t('description'), description: $t('description'),
@ -225,6 +227,18 @@
return personNames.join(', '); return personNames.join(', ');
} }
async function getAlbumNames(albumIds: string[]) {
const albumNames = await Promise.all(
albumIds.map(async (albumId) => {
const album = await getAlbumInfo({ id: albumId, withoutAssets: true });
return album.albumName;
}),
);
return albumNames.join(', ');
}
async function getTagNames(tagIds: string[] | null) { async function getTagNames(tagIds: string[] | null) {
if (tagIds === null) { if (tagIds === null) {
return $t('untagged'); return $t('untagged');
@ -338,6 +352,10 @@
{#await getPersonName(value) then personName} {#await getPersonName(value) then personName}
{personName} {personName}
{/await} {/await}
{:else if searchKey === 'albumIds' && Array.isArray(value)}
{#await getAlbumNames(value) then albumNames}
{albumNames}
{/await}
{:else if searchKey === 'tagIds' && (Array.isArray(value) || value === null)} {:else if searchKey === 'tagIds' && (Array.isArray(value) || value === null)}
{#await getTagNames(value) then tagNames} {#await getTagNames(value) then tagNames}
{tagNames} {tagNames}