1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-25 10:43:13 +02:00

feat: Search filtering logic (#6968)

* commit

* controller/service/repository logic

* use enum

* openapi

* suggest people

* suggest place/camera

* cursor hover

* refactor

* Add try catch

* Remove get people with name service

* Remove deadcode

* people selection

* People placement

* sort people

* Update server/src/domain/repositories/metadata.repository.ts

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>

* pr feedback

* styling

* done

* open api

* fix test

* use string type

* remmove bad merge

* use correct type

* fix test

* fix lint

* remove unused code

* remove unused code

* pr feedback

* pr feedback

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
This commit is contained in:
Alex 2024-02-13 13:54:58 -06:00 committed by GitHub
parent 0c45f51a29
commit 4b3f8d1946
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 836 additions and 114 deletions

View File

@ -120,6 +120,7 @@ doc/SearchExploreResponseDto.md
doc/SearchFacetCountResponseDto.md
doc/SearchFacetResponseDto.md
doc/SearchResponseDto.md
doc/SearchSuggestionType.md
doc/ServerConfigDto.md
doc/ServerFeaturesDto.md
doc/ServerInfoApi.md
@ -313,6 +314,7 @@ lib/model/search_explore_response_dto.dart
lib/model/search_facet_count_response_dto.dart
lib/model/search_facet_response_dto.dart
lib/model/search_response_dto.dart
lib/model/search_suggestion_type.dart
lib/model/server_config_dto.dart
lib/model/server_features_dto.dart
lib/model/server_info_response_dto.dart
@ -485,6 +487,7 @@ test/search_explore_response_dto_test.dart
test/search_facet_count_response_dto_test.dart
test/search_facet_response_dto_test.dart
test/search_response_dto_test.dart
test/search_suggestion_type_test.dart
test/server_config_dto_test.dart
test/server_features_dto_test.dart
test/server_info_api_test.dart

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

BIN
mobile/openapi/doc/SearchSuggestionType.md generated Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -4370,7 +4370,6 @@
"name": "clip",
"required": false,
"in": "query",
"description": "@deprecated",
"deprecated": true,
"schema": {
"type": "boolean"
@ -5231,6 +5230,82 @@
]
}
},
"/search/suggestions": {
"get": {
"operationId": "getSearchSuggestions",
"parameters": [
{
"name": "country",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "make",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "model",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "state",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "type",
"required": true,
"in": "query",
"schema": {
"$ref": "#/components/schemas/SearchSuggestionType"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"items": {
"type": "string"
},
"type": "array"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Search"
]
}
},
"/server-info": {
"get": {
"operationId": "getServerInfo",
@ -9243,6 +9318,16 @@
],
"type": "object"
},
"SearchSuggestionType": {
"enum": [
"country",
"state",
"city",
"camera-make",
"camera-model"
],
"type": "string"
},
"ServerConfigDto": {
"properties": {
"externalDomain": {

View File

@ -2995,6 +2995,23 @@ export interface SearchResponseDto {
*/
'assets': SearchAssetResponseDto;
}
/**
*
* @export
* @enum {string}
*/
export const SearchSuggestionType = {
Country: 'country',
State: 'state',
City: 'city',
CameraMake: 'camera-make',
CameraModel: 'camera-model'
} as const;
export type SearchSuggestionType = typeof SearchSuggestionType[keyof typeof SearchSuggestionType];
/**
*
* @export
@ -14521,7 +14538,72 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio
},
/**
*
* @param {boolean} [clip] @deprecated
* @param {SearchSuggestionType} type
* @param {string} [country]
* @param {string} [make]
* @param {string} [model]
* @param {string} [state]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getSearchSuggestions: async (type: SearchSuggestionType, country?: string, make?: string, model?: string, state?: string, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'type' is not null or undefined
assertParamExists('getSearchSuggestions', 'type', type)
const localVarPath = `/search/suggestions`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication cookie required
// authentication api_key required
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
if (country !== undefined) {
localVarQueryParameter['country'] = country;
}
if (make !== undefined) {
localVarQueryParameter['make'] = make;
}
if (model !== undefined) {
localVarQueryParameter['model'] = model;
}
if (state !== undefined) {
localVarQueryParameter['state'] = state;
}
if (type !== undefined) {
localVarQueryParameter['type'] = type;
}
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {boolean} [clip]
* @param {boolean} [motion]
* @param {number} [page]
* @param {string} [q]
@ -15151,7 +15233,23 @@ export const SearchApiFp = function(configuration?: Configuration) {
},
/**
*
* @param {boolean} [clip] @deprecated
* @param {SearchSuggestionType} type
* @param {string} [country]
* @param {string} [make]
* @param {string} [model]
* @param {string} [state]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getSearchSuggestions(type: SearchSuggestionType, country?: string, make?: string, model?: string, state?: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<string>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getSearchSuggestions(type, country, make, model, state, options);
const index = configuration?.serverIndex ?? 0;
const operationBasePath = operationServerMap['SearchApi.getSearchSuggestions']?.[index]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, operationBasePath || basePath);
},
/**
*
* @param {boolean} [clip]
* @param {boolean} [motion]
* @param {number} [page]
* @param {string} [q]
@ -15296,6 +15394,15 @@ export const SearchApiFactory = function (configuration?: Configuration, basePat
getExploreData(options?: RawAxiosRequestConfig): AxiosPromise<Array<SearchExploreResponseDto>> {
return localVarFp.getExploreData(options).then((request) => request(axios, basePath));
},
/**
*
* @param {SearchApiGetSearchSuggestionsRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getSearchSuggestions(requestParameters: SearchApiGetSearchSuggestionsRequest, options?: RawAxiosRequestConfig): AxiosPromise<Array<string>> {
return localVarFp.getSearchSuggestions(requestParameters.type, requestParameters.country, requestParameters.make, requestParameters.model, requestParameters.state, options).then((request) => request(axios, basePath));
},
/**
*
* @param {SearchApiSearchRequest} requestParameters Request parameters.
@ -15336,6 +15443,48 @@ export const SearchApiFactory = function (configuration?: Configuration, basePat
};
};
/**
* Request parameters for getSearchSuggestions operation in SearchApi.
* @export
* @interface SearchApiGetSearchSuggestionsRequest
*/
export interface SearchApiGetSearchSuggestionsRequest {
/**
*
* @type {SearchSuggestionType}
* @memberof SearchApiGetSearchSuggestions
*/
readonly type: SearchSuggestionType
/**
*
* @type {string}
* @memberof SearchApiGetSearchSuggestions
*/
readonly country?: string
/**
*
* @type {string}
* @memberof SearchApiGetSearchSuggestions
*/
readonly make?: string
/**
*
* @type {string}
* @memberof SearchApiGetSearchSuggestions
*/
readonly model?: string
/**
*
* @type {string}
* @memberof SearchApiGetSearchSuggestions
*/
readonly state?: string
}
/**
* Request parameters for search operation in SearchApi.
* @export
@ -15343,7 +15492,7 @@ export const SearchApiFactory = function (configuration?: Configuration, basePat
*/
export interface SearchApiSearchRequest {
/**
* @deprecated
*
* @type {boolean}
* @memberof SearchApiSearch
*/
@ -15969,6 +16118,17 @@ export class SearchApi extends BaseAPI {
return SearchApiFp(this.configuration).getExploreData(options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {SearchApiGetSearchSuggestionsRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof SearchApi
*/
public getSearchSuggestions(requestParameters: SearchApiGetSearchSuggestionsRequest, options?: RawAxiosRequestConfig) {
return SearchApiFp(this.configuration).getSearchSuggestions(requestParameters.type, requestParameters.country, requestParameters.make, requestParameters.model, requestParameters.state, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {SearchApiSearchRequest} requestParameters Request parameters.

Binary file not shown.

View File

@ -145,7 +145,7 @@
"coverageDirectory": "./coverage",
"coverageThreshold": {
"./src/domain/": {
"branches": 80,
"branches": 79,
"functions": 80,
"lines": 90,
"statements": 90

View File

@ -39,4 +39,9 @@ export interface IMetadataRepository {
readTags(path: string): Promise<ImmichTags | null>;
writeTags(path: string, tags: Partial<Tags>): Promise<void>;
extractBinaryTag(tagName: string, path: string): Promise<Buffer>;
getCountries(userId: string): Promise<string[]>;
getStates(userId: string, country?: string): Promise<string[]>;
getCities(userId: string, country?: string, state?: string): Promise<string[]>;
getCameraMakes(userId: string, model?: string): Promise<string[]>;
getCameraModels(userId: string, make?: string): Promise<string[]>;
}

View File

@ -0,0 +1,33 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator';
export enum SearchSuggestionType {
COUNTRY = 'country',
STATE = 'state',
CITY = 'city',
CAMERA_MAKE = 'camera-make',
CAMERA_MODEL = 'camera-model',
}
export class SearchSuggestionRequestDto {
@IsEnum(SearchSuggestionType)
@IsNotEmpty()
@ApiProperty({ enumName: 'SearchSuggestionType', enum: SearchSuggestionType })
type!: SearchSuggestionType;
@IsString()
@IsOptional()
country?: string;
@IsString()
@IsOptional()
state?: string;
@IsString()
@IsOptional()
make?: string;
@IsString()
@IsOptional()
model?: string;
}

View File

@ -4,6 +4,7 @@ import {
authStub,
newAssetRepositoryMock,
newMachineLearningRepositoryMock,
newMetadataRepositoryMock,
newPartnerRepositoryMock,
newPersonRepositoryMock,
newSearchRepositoryMock,
@ -14,6 +15,7 @@ import { mapAsset } from '../asset';
import {
IAssetRepository,
IMachineLearningRepository,
IMetadataRepository,
IPartnerRepository,
IPersonRepository,
ISearchRepository,
@ -32,6 +34,7 @@ describe(SearchService.name, () => {
let personMock: jest.Mocked<IPersonRepository>;
let searchMock: jest.Mocked<ISearchRepository>;
let partnerMock: jest.Mocked<IPartnerRepository>;
let metadataMock: jest.Mocked<IMetadataRepository>;
beforeEach(() => {
assetMock = newAssetRepositoryMock();
@ -40,7 +43,9 @@ describe(SearchService.name, () => {
personMock = newPersonRepositoryMock();
searchMock = newSearchRepositoryMock();
partnerMock = newPartnerRepositoryMock();
sut = new SearchService(configMock, machineMock, personMock, searchMock, assetMock, partnerMock);
metadataMock = newMetadataRepositoryMock();
sut = new SearchService(configMock, machineMock, personMock, searchMock, assetMock, partnerMock, metadataMock);
});
it('should work', () => {

View File

@ -7,6 +7,7 @@ import { PersonResponseDto } from '../person';
import {
IAssetRepository,
IMachineLearningRepository,
IMetadataRepository,
IPartnerRepository,
IPersonRepository,
ISearchRepository,
@ -16,6 +17,7 @@ import {
} from '../repositories';
import { FeatureFlag, SystemConfigCore } from '../system-config';
import { MetadataSearchDto, SearchDto, SearchPeopleDto, SmartSearchDto } from './dto';
import { SearchSuggestionRequestDto, SearchSuggestionType } from './dto/search-suggestion.dto';
import { SearchResponseDto } from './response-dto';
@Injectable()
@ -30,6 +32,7 @@ export class SearchService {
@Inject(ISearchRepository) private searchRepository: ISearchRepository,
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IPartnerRepository) private partnerRepository: IPartnerRepository,
@Inject(IMetadataRepository) private metadataRepository: IMetadataRepository,
) {
this.configCore = SystemConfigCore.create(configRepository);
}
@ -176,4 +179,28 @@ export class SearchService {
},
};
}
async getSearchSuggestions(auth: AuthDto, dto: SearchSuggestionRequestDto): Promise<string[]> {
if (dto.type === SearchSuggestionType.COUNTRY) {
return this.metadataRepository.getCountries(auth.user.id);
}
if (dto.type === SearchSuggestionType.STATE) {
return this.metadataRepository.getStates(auth.user.id, dto.country);
}
if (dto.type === SearchSuggestionType.CITY) {
return this.metadataRepository.getCities(auth.user.id, dto.country, dto.state);
}
if (dto.type === SearchSuggestionType.CAMERA_MAKE) {
return this.metadataRepository.getCameraMakes(auth.user.id, dto.model);
}
if (dto.type === SearchSuggestionType.CAMERA_MODEL) {
return this.metadataRepository.getCameraModels(auth.user.id, dto.make);
}
return [];
}
}

View File

@ -9,6 +9,7 @@ import {
SearchService,
SmartSearchDto,
} from '@app/domain';
import { SearchSuggestionRequestDto } from '@app/domain/search/dto/search-suggestion.dto';
import { Controller, Get, Query } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { Auth, Authenticated } from '../app.guard';
@ -46,4 +47,9 @@ export class SearchController {
searchPerson(@Auth() auth: AuthDto, @Query() dto: SearchPeopleDto): Promise<PersonResponseDto[]> {
return this.service.searchPerson(auth, dto);
}
@Get('suggestions')
getSearchSuggestions(@Auth() auth: AuthDto, @Query() dto: SearchSuggestionRequestDto): Promise<string[]> {
return this.service.getSearchSuggestions(auth, dto);
}
}

View File

@ -10,7 +10,13 @@ import {
ISystemMetadataRepository,
ReverseGeocodeResult,
} from '@app/domain';
import { GeodataAdmin1Entity, GeodataAdmin2Entity, GeodataPlacesEntity, SystemMetadataKey } from '@app/infra/entities';
import {
ExifEntity,
GeodataAdmin1Entity,
GeodataAdmin2Entity,
GeodataPlacesEntity,
SystemMetadataKey,
} from '@app/infra/entities';
import { ImmichLogger } from '@app/infra/logger';
import { Inject } from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
@ -21,12 +27,14 @@ import { createReadStream, existsSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import * as readLine from 'node:readline';
import { DataSource, DeepPartial, QueryRunner, Repository } from 'typeorm';
import { DummyValue, GenerateSql } from '../infra.util';
type GeoEntity = GeodataPlacesEntity | GeodataAdmin1Entity | GeodataAdmin2Entity;
type GeoEntityClass = typeof GeodataPlacesEntity | typeof GeodataAdmin1Entity | typeof GeodataAdmin2Entity;
export class MetadataRepository implements IMetadataRepository {
constructor(
@InjectRepository(ExifEntity) private exifRepository: Repository<ExifEntity>,
@InjectRepository(GeodataPlacesEntity) private readonly geodataPlacesRepository: Repository<GeodataPlacesEntity>,
@InjectRepository(GeodataAdmin1Entity) private readonly geodataAdmin1Repository: Repository<GeodataAdmin1Entity>,
@InjectRepository(GeodataAdmin2Entity) private readonly geodataAdmin2Repository: Repository<GeodataAdmin2Entity>,
@ -213,4 +221,106 @@ export class MetadataRepository implements IMetadataRepository {
this.logger.warn(`Error writing exif data (${path}): ${error}`);
}
}
@GenerateSql({ params: [DummyValue.UUID] })
async getCountries(userId: string): Promise<string[]> {
const entity = await this.exifRepository
.createQueryBuilder('exif')
.leftJoin('exif.asset', 'asset')
.where('asset.ownerId = :userId', { userId })
.andWhere('exif.country IS NOT NULL')
.select('exif.country')
.distinctOn(['exif.country'])
.getMany();
return entity.map((e) => e.country ?? '').filter((c) => c !== '');
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
async getStates(userId: string, country: string | undefined): Promise<string[]> {
let result: ExifEntity[] = [];
const query = this.exifRepository
.createQueryBuilder('exif')
.leftJoin('exif.asset', 'asset')
.where('asset.ownerId = :userId', { userId })
.andWhere('exif.state IS NOT NULL')
.select('exif.state')
.distinctOn(['exif.state']);
if (country) {
query.andWhere('exif.country = :country', { country });
}
result = await query.getMany();
return result.map((entity) => entity.state ?? '').filter((s) => s !== '');
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING, DummyValue.STRING] })
async getCities(userId: string, country: string | undefined, state: string | undefined): Promise<string[]> {
let result: ExifEntity[] = [];
const query = this.exifRepository
.createQueryBuilder('exif')
.leftJoin('exif.asset', 'asset')
.where('asset.ownerId = :userId', { userId })
.andWhere('exif.city IS NOT NULL')
.select('exif.city')
.distinctOn(['exif.city']);
if (country) {
query.andWhere('exif.country = :country', { country });
}
if (state) {
query.andWhere('exif.state = :state', { state });
}
result = await query.getMany();
return result.map((entity) => entity.city ?? '').filter((c) => c !== '');
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
async getCameraMakes(userId: string, model: string | undefined): Promise<string[]> {
let result: ExifEntity[] = [];
const query = this.exifRepository
.createQueryBuilder('exif')
.leftJoin('exif.asset', 'asset')
.where('asset.ownerId = :userId', { userId })
.andWhere('exif.make IS NOT NULL')
.select('exif.make')
.distinctOn(['exif.make']);
if (model) {
query.andWhere('exif.model = :model', { model });
}
result = await query.getMany();
return result.map((entity) => entity.make ?? '').filter((m) => m !== '');
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
async getCameraModels(userId: string, make: string | undefined): Promise<string[]> {
let result: ExifEntity[] = [];
const query = this.exifRepository
.createQueryBuilder('exif')
.leftJoin('exif.asset', 'asset')
.where('asset.ownerId = :userId', { userId })
.andWhere('exif.model IS NOT NULL')
.select('exif.model')
.distinctOn(['exif.model']);
if (make) {
query.andWhere('exif.make = :make', { make });
}
result = await query.getMany();
return result.map((entity) => entity.model ?? '').filter((m) => m !== '');
}
}

View File

@ -8,5 +8,10 @@ export const newMetadataRepositoryMock = (): jest.Mocked<IMetadataRepository> =>
readTags: jest.fn(),
writeTags: jest.fn(),
extractBinaryTag: jest.fn(),
getCameraMakes: jest.fn(),
getCameraModels: jest.fn(),
getCities: jest.fn(),
getCountries: jest.fn(),
getStates: jest.fn(),
};
};

View File

@ -23,6 +23,7 @@
export let selectedOption: ComboBoxOption | undefined = undefined;
export let placeholder = '';
export const label = '';
export let noLabel = false;
let isOpen = false;
let searchQuery = '';
@ -31,11 +32,13 @@
const dispatch = createEventDispatcher<{
select: ComboBoxOption;
click: void;
}>();
let handleClick = () => {
searchQuery = '';
isOpen = !isOpen;
dispatch('click');
};
let handleOutClick = () => {
@ -52,7 +55,9 @@
<div class="relative" use:clickOutside on:outclick={handleOutClick}>
<button {type} class="immich-form-input text-sm text-left w-full min-h-[48px] transition-all" on:click={handleClick}
>{selectedOption?.label}
>{#if !noLabel}
{selectedOption?.label || ''}
{/if}
<div class="absolute right-0 top-0 h-full flex px-4 justify-center items-center content-between">
<Icon path={mdiUnfoldMoreHorizontal} />
</div>
@ -60,7 +65,7 @@
{#if isOpen}
<div
transition:fly={{ y: 25, duration: 250 }}
transition:fly={{ y: -25, duration: 250 }}
class="absolute w-full top-full mt-2 bg-white dark:bg-gray-800 rounded-lg border border-gray-300 dark:border-gray-900 z-10"
>
<div class="relative border-b flex">
@ -80,8 +85,8 @@
<button
{type}
class="block text-left w-full px-4 py-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 transition-all
${option.label === selectedOption?.label ? 'bg-gray-300 dark:bg-gray-600' : ''}
"
${option.label === selectedOption?.label ? 'bg-gray-300 dark:bg-gray-600' : ''}
"
class:bg-gray-300={option.label === selectedOption?.label}
on:click={() => handleSelect(option)}
>

View File

@ -2,6 +2,12 @@
import Button from '$lib/components/elements/buttons/button.svelte';
import { fly } from 'svelte/transition';
import Combobox, { type ComboBoxOption } from '../combobox.svelte';
import { SearchSuggestionType, api, type PersonResponseDto } from '@api';
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
import Icon from '$lib/components/elements/icon.svelte';
import { mdiArrowRight, mdiClose } from '@mdi/js';
import { handleError } from '$lib/utils/handle-error';
import { onMount } from 'svelte';
enum MediaType {
All = 'all',
@ -9,25 +15,274 @@
Video = 'video',
}
let selectedCountry: ComboBoxOption = { label: '', value: '' };
let selectedState: ComboBoxOption = { label: '', value: '' };
let selectedCity: ComboBoxOption = { label: '', value: '' };
type SearchSuggestion = {
people: PersonResponseDto[];
country: ComboBoxOption[];
state: ComboBoxOption[];
city: ComboBoxOption[];
cameraMake: ComboBoxOption[];
cameraModel: ComboBoxOption[];
};
let mediaType: MediaType = MediaType.All;
let notInAlbum = false;
let inArchive = false;
let inFavorite = false;
type SearchParams = {
state?: string;
country?: string;
city?: string;
cameraMake?: string;
cameraModel?: string;
};
type SearchFilter = {
context?: string;
people: PersonResponseDto[];
location: {
country?: ComboBoxOption;
state?: ComboBoxOption;
city?: ComboBoxOption;
};
camera: {
make?: ComboBoxOption;
model?: ComboBoxOption;
};
dateRange: {
startDate?: Date;
endDate?: Date;
};
inArchive?: boolean;
inFavorite?: boolean;
notInAlbum?: boolean;
mediaType: MediaType;
};
let suggestions: SearchSuggestion = {
people: [],
country: [],
state: [],
city: [],
cameraMake: [],
cameraModel: [],
};
let filter: SearchFilter = {
context: undefined,
people: [],
location: {
country: undefined,
state: undefined,
city: undefined,
},
camera: {
make: undefined,
model: undefined,
},
dateRange: {
startDate: undefined,
endDate: undefined,
},
inArchive: undefined,
inFavorite: undefined,
notInAlbum: undefined,
mediaType: MediaType.All,
};
let showAllPeople = false;
$: peopleList = showAllPeople ? suggestions.people : suggestions.people.slice(0, 11);
onMount(() => {
getPeople();
});
const showSelectedPeopleFirst = () => {
suggestions.people.sort((a, _) => {
if (filter.people.some((p) => p.id === a.id)) {
return -1;
}
return 1;
});
};
const getPeople = async () => {
try {
const { data } = await api.personApi.getAllPeople({ withHidden: false });
suggestions.people = data.people;
} catch (error) {
handleError(error, 'Failed to get people');
}
};
const handlePeopleSelection = (id: string) => {
if (filter.people.some((p) => p.id === id)) {
filter.people = filter.people.filter((p) => p.id !== id);
showSelectedPeopleFirst();
return;
}
const person = suggestions.people.find((p) => p.id === id);
if (person) {
filter.people = [...filter.people, person];
showSelectedPeopleFirst();
}
};
const updateSuggestion = async (type: SearchSuggestionType, params: SearchParams) => {
if (
type === SearchSuggestionType.City ||
type === SearchSuggestionType.State ||
type === SearchSuggestionType.Country
) {
suggestions = { ...suggestions, city: [], state: [], country: [] };
}
if (type === SearchSuggestionType.CameraMake || type === SearchSuggestionType.CameraModel) {
suggestions = { ...suggestions, cameraMake: [], cameraModel: [] };
}
try {
const { data } = await api.searchApi.getSearchSuggestions({
type: type,
country: params.country,
state: params.state,
make: params.cameraMake,
model: params.cameraModel,
});
switch (type) {
case SearchSuggestionType.Country: {
for (const country of data) {
suggestions.country = [...suggestions.country, { label: country, value: country }];
}
break;
}
case SearchSuggestionType.State: {
for (const state of data) {
suggestions.state = [...suggestions.state, { label: state, value: state }];
}
break;
}
case SearchSuggestionType.City: {
for (const city of data) {
suggestions.city = [...suggestions.city, { label: city, value: city }];
}
break;
}
case SearchSuggestionType.CameraMake: {
for (const make of data) {
suggestions.cameraMake = [...suggestions.cameraMake, { label: make, value: make }];
}
break;
}
case SearchSuggestionType.CameraModel: {
for (const model of data) {
suggestions.cameraModel = [...suggestions.cameraModel, { label: model, value: model }];
}
break;
}
}
} catch (error) {
handleError(error, 'Failed to get search suggestions');
}
};
const resetForm = () => {
filter = {
context: undefined,
people: [],
location: {
country: undefined,
state: undefined,
city: undefined,
},
camera: {
make: undefined,
model: undefined,
},
dateRange: {
startDate: undefined,
endDate: undefined,
},
inArchive: undefined,
inFavorite: undefined,
notInAlbum: undefined,
mediaType: MediaType.All,
};
};
const search = () => {};
</script>
<div
transition:fly={{ y: 25, duration: 250 }}
class="absolute w-full rounded-b-3xl border border-gray-200 bg-white pb-5 shadow-2xl transition-all dark:border-gray-800 dark:bg-immich-dark-gray dark:text-gray-300 p-6"
class="absolute w-full rounded-b-3xl border border-gray-200 bg-white shadow-2xl transition-all dark:border-gray-800 dark:bg-immich-dark-gray dark:text-gray-300 px-6 pt-6 overflow-y-auto max-h-[90vh] immich-scrollbar"
>
<p class="text-xs py-2">FILTERS</p>
<hr class="py-2" />
<hr class="border-slate-300 dark:border-slate-700 py-2" />
<form id="search-filter-form" autocomplete="off">
<div class="py-3">
<form id="search-filter-form relative" autocomplete="off" class="hover:cursor-auto">
<!-- PEOPLE -->
<div id="people-selection" class="my-4">
<div class="flex justify-between place-items-center gap-6">
<div class="flex-1">
<p class="immich-form-label">PEOPLE</p>
</div>
</div>
{#if suggestions.people.length > 0}
<div class="flex gap-1 mt-4 flex-wrap max-h-[300px] overflow-y-auto immich-scrollbar transition-all">
{#each peopleList as person (person.id)}
<button
type="button"
class="w-20 text-center rounded-3xl border-2 border-transparent hover:bg-immich-gray dark:hover:bg-immich-dark-primary/20 p-2 flex-col place-items-center transition-all {filter.people.some(
(p) => p.id === person.id,
)
? 'dark:border-slate-500 border-slate-300 bg-slate-200 dark:bg-slate-800 dark:text-white'
: ''}"
on:click={() => handlePeopleSelection(person.id)}
>
<ImageThumbnail
circle
shadow
url={api.getPeopleThumbnailUrl(person.id)}
altText={person.name}
widthStyle="100px"
/>
<p class="mt-2 text-ellipsis text-sm font-medium dark:text-white">{person.name}</p>
</button>
{/each}
</div>
<div class="flex justify-center mt-2">
<Button
shadow={false}
color="text-primary"
type="button"
class="flex gap-2 place-items-center place-content-center"
on:click={() => (showAllPeople = !showAllPeople)}
>
{#if showAllPeople}
<span><Icon path={mdiClose} /></span>
Collapse
{:else}
<span><Icon path={mdiArrowRight} /></span>
See all people
{/if}
</Button>
</div>
{/if}
</div>
<hr class="border-slate-300 dark:border-slate-700" />
<!-- CONTEXT -->
<div class="my-4">
<label class="immich-form-label" for="context">CONTEXT</label>
<input
class="immich-form-input hover:cursor-text w-full mt-3"
@ -35,9 +290,111 @@
id="context"
name="context"
placeholder="Sunrise on the beach"
bind:value={filter.context}
/>
</div>
<hr class="border-slate-300 dark:border-slate-700" />
<!-- LOCATION -->
<div id="location-selection" class="my-4">
<p class="immich-form-label">PLACE</p>
<div class="flex justify-between gap-5 mt-3">
<div class="w-full">
<p class="text-sm text-black dark:text-white">Country</p>
<Combobox
options={suggestions.country}
bind:selectedOption={filter.location.country}
placeholder="Search country..."
on:click={() => updateSuggestion(SearchSuggestionType.Country, {})}
/>
</div>
<div class="w-full">
<p class="text-sm text-black dark:text-white">State</p>
<Combobox
options={suggestions.state}
bind:selectedOption={filter.location.state}
placeholder="Search state..."
on:click={() => updateSuggestion(SearchSuggestionType.State, { country: filter.location.country?.value })}
/>
</div>
<div class="w-full">
<p class="text-sm text-black dark:text-white">City</p>
<Combobox
options={suggestions.city}
bind:selectedOption={filter.location.city}
placeholder="Search city..."
on:click={() =>
updateSuggestion(SearchSuggestionType.City, {
country: filter.location.country?.value,
state: filter.location.state?.value,
})}
/>
</div>
</div>
</div>
<hr class="border-slate-300 dark:border-slate-700" />
<!-- CAMERA MODEL -->
<div id="camera-selection" class="my-4">
<p class="immich-form-label">CAMERA</p>
<div class="flex justify-between gap-5 mt-3">
<div class="w-full">
<p class="text-sm text-black dark:text-white">Make</p>
<Combobox
options={suggestions.cameraMake}
bind:selectedOption={filter.camera.make}
placeholder="Search camera make..."
on:click={() =>
updateSuggestion(SearchSuggestionType.CameraMake, { cameraModel: filter.camera.model?.value })}
/>
</div>
<div class="w-full">
<p class="text-sm text-black dark:text-white">Model</p>
<Combobox
options={suggestions.cameraModel}
bind:selectedOption={filter.camera.model}
placeholder="Search camera model..."
on:click={() =>
updateSuggestion(SearchSuggestionType.CameraModel, { cameraMake: filter.camera.make?.value })}
/>
</div>
</div>
</div>
<hr class="border-slate-300 dark:border-slate-700" />
<!-- DATE RANGE -->
<div id="date-range-selection" class="my-4 flex justify-between gap-5">
<div class="mb-3 flex-1 mt">
<label class="immich-form-label" for="start-date">START DATE</label>
<input
class="immich-form-input w-full mt-3 hover:cursor-pointer"
type="date"
id="start-date"
name="start-date"
bind:value={filter.dateRange.startDate}
/>
</div>
<div class="mb-3 flex-1">
<label class="immich-form-label" for="end-date">END DATE</label>
<input
class="immich-form-input w-full mt-3 hover:cursor-pointer"
type="date"
id="end-date"
name="end-date"
placeholder=""
bind:value={filter.dateRange.endDate}
/>
</div>
</div>
<hr class="border-slate-300 dark:border-slate-700" />
<div class="py-3 grid grid-cols-2">
<!-- MEDIA TYPE -->
<div id="media-type-selection">
@ -49,7 +406,7 @@
class="text-base flex place-items-center gap-1 hover:cursor-pointer text-black dark:text-white"
>
<input
bind:group={mediaType}
bind:group={filter.mediaType}
value={MediaType.All}
type="radio"
name="radio-type"
@ -62,10 +419,10 @@
class="text-base flex place-items-center gap-1 hover:cursor-pointer text-black dark:text-white"
>
<input
bind:group={mediaType}
bind:group={filter.mediaType}
value={MediaType.Image}
type="radio"
name="radio-type"
name="media-type"
id="type-image"
/>Image</label
>
@ -75,7 +432,7 @@
class="text-base flex place-items-center gap-1 hover:cursor-pointer text-black dark:text-white"
>
<input
bind:group={mediaType}
bind:group={filter.mediaType}
value={MediaType.Video}
type="radio"
name="radio-type"
@ -91,108 +448,29 @@
<div class="flex gap-5 mt-3">
<label class="flex items-center mb-2">
<input type="checkbox" class="form-checkbox h-5 w-5 color" bind:checked={notInAlbum} />
<input type="checkbox" class="form-checkbox h-5 w-5 color" bind:checked={filter.notInAlbum} />
<span class="ml-2 text-sm text-black dark:text-white pt-1">Not in any album</span>
</label>
<label class="flex items-center mb-2">
<input type="checkbox" class="form-checkbox h-5 w-5 color" bind:checked={inArchive} />
<input type="checkbox" class="form-checkbox h-5 w-5 color" bind:checked={filter.inArchive} />
<span class="ml-2 text-sm text-black dark:text-white pt-1">Archive</span>
</label>
<label class="flex items-center mb-2">
<input type="checkbox" class="form-checkbox h-5 w-5 color" bind:checked={inFavorite} />
<input type="checkbox" class="form-checkbox h-5 w-5 color" bind:checked={filter.inFavorite} />
<span class="ml-2 text-sm text-black dark:text-white pt-1">Favorite</span>
</label>
</div>
</div>
</div>
<hr />
<!-- PEOPLE -->
<div id="people-selection" class="my-4">
<div class="flex justify-between place-items-center gap-6">
<div class="flex-1">
<p class="immich-form-label">PEOPLE</p>
</div>
<div class="flex-1">
<Combobox options={[]} selectedOption={selectedCountry} placeholder="Search people..." />
</div>
</div>
</div>
<hr />
<!-- LOCATION -->
<div id="location-selection" class="my-4">
<p class="immich-form-label">PLACE</p>
<div class="flex justify-between gap-5 mt-3">
<div class="w-full">
<p class="text-sm text-black dark:text-white">Country</p>
<Combobox options={[]} selectedOption={selectedCountry} placeholder="Search country..." />
</div>
<div class="w-full">
<p class="text-sm text-black dark:text-white">State</p>
<Combobox options={[]} selectedOption={selectedState} placeholder="Search state..." />
</div>
<div class="w-full">
<p class="text-sm text-black dark:text-white">City</p>
<Combobox options={[]} selectedOption={selectedCity} placeholder="Search city..." />
</div>
</div>
</div>
<hr />
<!-- CAMERA MODEL -->
<div id="camera-selection" class="my-4">
<p class="immich-form-label">CAMERA</p>
<div class="flex justify-between gap-5 mt-3">
<div class="w-full">
<p class="text-sm text-black dark:text-white">Make</p>
<Combobox options={[]} selectedOption={selectedCountry} placeholder="Search country..." />
</div>
<div class="w-full">
<p class="text-sm text-black dark:text-white">Model</p>
<Combobox options={[]} selectedOption={selectedState} placeholder="Search state..." />
</div>
</div>
</div>
<hr />
<!-- DATE RANGE -->
<div id="date-range-selection" class="my-4 flex justify-between gap-5">
<div class="mb-3 flex-1 mt">
<label class="immich-form-label" for="start-date">START DATE</label>
<input
class="immich-form-input w-full mt-3 hover:cursor-pointer"
type="date"
id="start-date"
name="start-date"
/>
</div>
<div class="mb-3 flex-1">
<label class="immich-form-label" for="end-date">END DATE</label>
<input
class="immich-form-input w-full mt-3 hover:cursor-pointer"
type="date"
id="end-date"
name="end-date"
placeholder=""
/>
</div>
</div>
<div id="button-row" class="flex justify-end gap-4 mt-5">
<Button color="gray">CLEAR ALL</Button>
<Button type="submit">SEARCH</Button>
<div
id="button-row"
class="flex justify-end gap-4 py-4 sticky bottom-0 dark:border-gray-800 dark:bg-immich-dark-gray"
>
<Button color="gray" on:click={resetForm}>CLEAR ALL</Button>
<Button type="button" on:click={search}>SEARCH</Button>
</div>
</form>
</div>