- make open-api-typescript

- Allow combobox to have thumbnails
- Add the search-albums-section
- Update search-display-section to disable isNotInAlbum checkbox when albums are selected
- In search filter modal, put albums and tags side by side. Saves space and neither need to be full width
- Add album names filter chips to search page
This commit is contained in:
CJPeckover
2025-06-06 22:19:09 -04:00
parent fb1d3bd7f4
commit cb91a5a558
6 changed files with 165 additions and 9 deletions

View File

@@ -5,6 +5,7 @@
id?: string;
label: string;
value: string;
thumbnail?: string;
};
export const asComboboxOptions = (values: string[]) =>
@@ -380,12 +381,34 @@
<li
aria-selected={index === selectedIndex}
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}`}
onclick={() => handleSelect(option)}
role="option"
>
{option.label}
{#if option.thumbnail}
<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 === 'no-thumbnail'}
<div class="h-6 w-6 bg-cover rounded hover:shadow-lg"></div>
{:else}
<img
src={option.thumbnail}
alt={option.label}
class="h-6 w-6 bg-cover rounded hover:shadow-lg"
data-testid="album-image"
draggable="false"
/>
{/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>
{/each}
{/if}

View File

@@ -0,0 +1,86 @@
<script lang="ts">
import Icon from '$lib/components/elements/icon.svelte';
import Combobox, { type ComboBoxOption } from '$lib/components/shared-components/combobox.svelte';
import { preferences } from '$lib/stores/user.store';
import { getAssetThumbnailUrl } from '$lib/utils';
import { getAllAlbums, type AlbumResponseDto } from '@immich/sdk';
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);
onMount(async () => {
allAlbums = await getAllAlbums({});
});
const handleSelect = (option?: ComboBoxOption) => {
if (!option || !option.id) {
return;
}
selectedAlbums.add(option.value);
selectedOption = undefined;
};
const handleRemove = (tag: string) => {
selectedAlbums.delete(tag);
};
</script>
{#if $preferences?.tags?.enabled}
<div id="location-selection">
<form autocomplete="off" id="create-album-form">
<div class="my-4 flex flex-col gap-2">
<Combobox
onSelect={handleSelect}
label={$t('albums').toUpperCase()}
defaultFirstOption
options={allAlbums.map((album) => ({
id: album.id,
label: album.albumName,
value: album.id,
thumbnail: album.albumThumbnailAssetId ? getAssetThumbnailUrl(album.albumThumbnailAssetId) : 'no-thumbnail',
}))}
bind:selectedOption
placeholder={$t('search_albums')}
/>
</div>
</form>
<section class="flex flex-wrap pt-2 gap-1">
{#each selectedAlbums as albumId (albumId)}
{@const album = albumMap[albumId]}
{#if album}
<div class="flex group transition-all">
<span
class="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.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="Remove tag"
onclick={() => handleRemove(albumId)}
>
<Icon path={mdiClose} />
</button>
</div>
{/if}
{/each}
</section>
</div>
{/if}

View File

@@ -10,12 +10,21 @@
import { Checkbox, Label } from '@immich/ui';
import { t } from 'svelte-i18n';
import type { SvelteSet } from 'svelte/reactivity';
interface Props {
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>
<div id="display-options-selection">
@@ -23,7 +32,12 @@
<legend class="immich-form-label">{$t('display_options').toUpperCase()}</legend>
<div class="flex flex-wrap gap-x-5 gap-y-2 mt-1">
<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" />
</div>
<div class="flex items-center gap-2">

View File

@@ -8,6 +8,7 @@
query: string;
queryType: 'smart' | 'metadata' | 'description';
personIds: SvelteSet<string>;
albumIds: SvelteSet<string>;
tagIds: SvelteSet<string>;
location: SearchLocationFilter;
camera: SearchCameraFilter;
@@ -19,6 +20,7 @@
</script>
<script lang="ts">
import SearchAlbumsSection from '$lib/components/shared-components/search-bar/search-albums-section.svelte';
import SearchCameraSection, {
type SearchCameraFilter,
} from '$lib/components/shared-components/search-bar/search-camera-section.svelte';
@@ -68,6 +70,7 @@
query: 'query' in searchQuery ? searchQuery.query : searchQuery.originalFileName || '',
queryType: defaultQueryType(),
personIds: new SvelteSet('personIds' in searchQuery ? searchQuery.personIds : []),
albumIds: new SvelteSet('albumIds' in searchQuery ? searchQuery.albumIds : []),
tagIds: new SvelteSet('tagIds' in searchQuery ? searchQuery.tagIds : []),
location: {
country: withNullAsUndefined(searchQuery.country),
@@ -101,6 +104,7 @@
query: '',
queryType: defaultQueryType(), // retain from localStorage or default
personIds: new SvelteSet(),
albumIds: new SvelteSet(),
tagIds: new SvelteSet(),
location: {},
camera: {},
@@ -140,6 +144,7 @@
isFavorite: filter.display.isFavorite || undefined,
isNotInAlbum: filter.display.isNotInAlbum || undefined,
personIds: filter.personIds.size > 0 ? [...filter.personIds] : undefined,
albumIds: filter.albumIds.size > 0 ? [...filter.albumIds] : undefined,
tagIds: filter.tagIds.size > 0 ? [...filter.tagIds] : undefined,
type,
rating: filter.rating,
@@ -175,8 +180,15 @@
<!-- TEXT -->
<SearchTextSection bind:query={filter.query} bind:queryType={filter.queryType} />
<!-- TAGS -->
<SearchTagsSection bind:selectedTags={filter.tagIds} />
<div class="grid grid-auto-fit-40 gap-5">
<!-- ALBUMS -->
<SearchAlbumsSection bind:selectedAlbums={filter.albumIds} />
<!-- TAGS -->
<SearchTagsSection bind:selectedTags={filter.tagIds} />
</div>
<!-- LOCATION -->
<SearchLocationSection bind:filters={filter.location} />
@@ -197,7 +209,7 @@
<SearchMediaSection bind:filteredMedia={filter.mediaType} />
<!-- DISPLAY OPTIONS -->
<SearchDisplaySection bind:filters={filter.display} />
<SearchDisplaySection bind:filters={filter.display} selectedAlbums={filter.albumIds} />
</div>
</div>
</form>

View File

@@ -23,10 +23,10 @@
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import SearchBar from '$lib/components/shared-components/search-bar/search-bar.svelte';
import { AppRoute, QueryParameter } from '$lib/constants';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { AssetStore } from '$lib/managers/timeline-manager/asset-store.svelte';
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { lang, locale } from '$lib/stores/preferences.store';
import { featureFlags } from '$lib/stores/server-config.store';
import { preferences } from '$lib/stores/user.store';
@@ -38,6 +38,7 @@
import { toTimelineAsset } from '$lib/utils/timeline-util';
import {
type AlbumResponseDto,
getAlbumInfo,
getPerson,
getTagById,
type MetadataSearchDto,
@@ -210,6 +211,7 @@
model: $t('camera_model'),
lensModel: $t('lens_model'),
personIds: $t('people'),
albumIds: $t('albums'),
tagIds: $t('tags'),
originalFileName: $t('file_name'),
description: $t('description'),
@@ -233,6 +235,18 @@
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[]) {
const tagNames = await Promise.all(
tagIds.map(async (tagId) => {
@@ -343,6 +357,10 @@
{#await getPersonName(value) then personName}
{personName}
{/await}
{:else if searchKey === 'albumIds' && Array.isArray(value)}
{#await getAlbumNames(value) then albumNames}
{albumNames}
{/await}
{:else if searchKey === 'tagIds' && Array.isArray(value)}
{#await getTagNames(value) then tagNames}
{tagNames}