mirror of
https://github.com/immich-app/immich.git
synced 2026-02-15 05:18:37 +03:00
- 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:
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user