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

feat: enhance search (#7127)

* feat: hybrid search

* fixing normal search

* building out the query

* okla

* filters

* date

* order by date

* Remove hybrid search endpoint

* remove search hybrid endpoint

* faces query

* search for person

* search and pagination

* with exif

* with exif

* justify gallery viewer

* memory view

* Fixed userId is null

* openapi and styling

* searchdto

* lint and format

* remove term

* generate sql

* fix test

* chips

* not showing true

* pr feedback

* pr feedback

* nit name

* linting

* pr feedback

* styling

* linting
This commit is contained in:
Alex 2024-02-17 11:00:55 -06:00 committed by GitHub
parent 60ba37b3a7
commit 69983ff83a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
40 changed files with 1298 additions and 1752 deletions

View File

@ -90,6 +90,7 @@ doc/MapMarkerResponseDto.md
doc/MapTheme.md
doc/MemoryLaneResponseDto.md
doc/MergePersonDto.md
doc/MetadataSearchDto.md
doc/ModelType.md
doc/OAuthApi.md
doc/OAuthAuthorizeResponseDto.md
@ -137,6 +138,7 @@ doc/SharedLinkResponseDto.md
doc/SharedLinkType.md
doc/SignUpDto.md
doc/SmartInfoResponseDto.md
doc/SmartSearchDto.md
doc/SystemConfigApi.md
doc/SystemConfigDto.md
doc/SystemConfigFFmpegDto.md
@ -288,6 +290,7 @@ lib/model/map_marker_response_dto.dart
lib/model/map_theme.dart
lib/model/memory_lane_response_dto.dart
lib/model/merge_person_dto.dart
lib/model/metadata_search_dto.dart
lib/model/model_type.dart
lib/model/o_auth_authorize_response_dto.dart
lib/model/o_auth_callback_dto.dart
@ -329,6 +332,7 @@ lib/model/shared_link_response_dto.dart
lib/model/shared_link_type.dart
lib/model/sign_up_dto.dart
lib/model/smart_info_response_dto.dart
lib/model/smart_search_dto.dart
lib/model/system_config_dto.dart
lib/model/system_config_f_fmpeg_dto.dart
lib/model/system_config_job_dto.dart
@ -457,6 +461,7 @@ test/map_marker_response_dto_test.dart
test/map_theme_test.dart
test/memory_lane_response_dto_test.dart
test/merge_person_dto_test.dart
test/metadata_search_dto_test.dart
test/model_type_test.dart
test/o_auth_api_test.dart
test/o_auth_authorize_response_dto_test.dart
@ -504,6 +509,7 @@ test/shared_link_response_dto_test.dart
test/shared_link_type_test.dart
test/sign_up_dto_test.dart
test/smart_info_response_dto_test.dart
test/smart_search_dto_test.dart
test/system_config_api_test.dart
test/system_config_dto_test.dart
test/system_config_f_fmpeg_dto_test.dart

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

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

Binary file not shown.

Binary file not shown.

BIN
mobile/openapi/doc/SmartSearchDto.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.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -2256,6 +2256,14 @@
"type": "boolean"
}
},
{
"name": "isNotInAlbum",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "isOffline",
"required": false,
@ -2345,6 +2353,17 @@
"type": "number"
}
},
{
"name": "personIds",
"required": false,
"in": "query",
"schema": {
"type": "array",
"items": {
"type": "string"
}
}
},
{
"name": "resizePath",
"required": false,
@ -4526,350 +4545,21 @@
}
},
"/search/metadata": {
"get": {
"post": {
"operationId": "searchMetadata",
"parameters": [
{
"name": "checksum",
"required": false,
"in": "query",
"schema": {
"type": "string"
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MetadataSearchDto"
}
}
},
{
"name": "city",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "country",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "createdAfter",
"required": false,
"in": "query",
"schema": {
"format": "date-time",
"type": "string"
}
},
{
"name": "createdBefore",
"required": false,
"in": "query",
"schema": {
"format": "date-time",
"type": "string"
}
},
{
"name": "deviceAssetId",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "deviceId",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "encodedVideoPath",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "id",
"required": false,
"in": "query",
"schema": {
"format": "uuid",
"type": "string"
}
},
{
"name": "isArchived",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "isEncoded",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "isExternal",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "isFavorite",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "isMotion",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "isOffline",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "isReadOnly",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "isVisible",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "lensModel",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "libraryId",
"required": false,
"in": "query",
"schema": {
"format": "uuid",
"type": "string"
}
},
{
"name": "make",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "model",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "order",
"required": false,
"in": "query",
"schema": {
"$ref": "#/components/schemas/AssetOrder"
}
},
{
"name": "originalFileName",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "originalPath",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "page",
"required": false,
"in": "query",
"schema": {
"type": "number"
}
},
{
"name": "resizePath",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "size",
"required": false,
"in": "query",
"schema": {
"type": "number"
}
},
{
"name": "state",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "takenAfter",
"required": false,
"in": "query",
"schema": {
"format": "date-time",
"type": "string"
}
},
{
"name": "takenBefore",
"required": false,
"in": "query",
"schema": {
"format": "date-time",
"type": "string"
}
},
{
"name": "trashedAfter",
"required": false,
"in": "query",
"schema": {
"format": "date-time",
"type": "string"
}
},
{
"name": "trashedBefore",
"required": false,
"in": "query",
"schema": {
"format": "date-time",
"type": "string"
}
},
{
"name": "type",
"required": false,
"in": "query",
"schema": {
"$ref": "#/components/schemas/AssetTypeEnum"
}
},
{
"name": "updatedAfter",
"required": false,
"in": "query",
"schema": {
"format": "date-time",
"type": "string"
}
},
{
"name": "updatedBefore",
"required": false,
"in": "query",
"schema": {
"format": "date-time",
"type": "string"
}
},
{
"name": "webpPath",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "withArchived",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "withDeleted",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "withExif",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "withPeople",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "withStacked",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
}
],
"required": true
},
"responses": {
"200": {
"201": {
"content": {
"application/json": {
"schema": {
@ -4949,269 +4639,21 @@
}
},
"/search/smart": {
"get": {
"post": {
"operationId": "searchSmart",
"parameters": [
{
"name": "city",
"required": false,
"in": "query",
"schema": {
"type": "string"
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SmartSearchDto"
}
}
},
{
"name": "country",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "createdAfter",
"required": false,
"in": "query",
"schema": {
"format": "date-time",
"type": "string"
}
},
{
"name": "createdBefore",
"required": false,
"in": "query",
"schema": {
"format": "date-time",
"type": "string"
}
},
{
"name": "deviceId",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "isArchived",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "isEncoded",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "isExternal",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "isFavorite",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "isMotion",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "isOffline",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "isReadOnly",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "isVisible",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "lensModel",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "libraryId",
"required": false,
"in": "query",
"schema": {
"format": "uuid",
"type": "string"
}
},
{
"name": "make",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "model",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "page",
"required": false,
"in": "query",
"schema": {
"type": "number"
}
},
{
"name": "query",
"required": true,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "size",
"required": false,
"in": "query",
"schema": {
"type": "number"
}
},
{
"name": "state",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "takenAfter",
"required": false,
"in": "query",
"schema": {
"format": "date-time",
"type": "string"
}
},
{
"name": "takenBefore",
"required": false,
"in": "query",
"schema": {
"format": "date-time",
"type": "string"
}
},
{
"name": "trashedAfter",
"required": false,
"in": "query",
"schema": {
"format": "date-time",
"type": "string"
}
},
{
"name": "trashedBefore",
"required": false,
"in": "query",
"schema": {
"format": "date-time",
"type": "string"
}
},
{
"name": "type",
"required": false,
"in": "query",
"schema": {
"$ref": "#/components/schemas/AssetTypeEnum"
}
},
{
"name": "updatedAfter",
"required": false,
"in": "query",
"schema": {
"format": "date-time",
"type": "string"
}
},
{
"name": "updatedBefore",
"required": false,
"in": "query",
"schema": {
"format": "date-time",
"type": "string"
}
},
{
"name": "withArchived",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "withDeleted",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "withExif",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
}
],
"required": true
},
"responses": {
"200": {
"201": {
"content": {
"application/json": {
"schema": {
@ -8805,6 +8247,153 @@
],
"type": "object"
},
"MetadataSearchDto": {
"properties": {
"checksum": {
"type": "string"
},
"city": {
"type": "string"
},
"country": {
"type": "string"
},
"createdAfter": {
"format": "date-time",
"type": "string"
},
"createdBefore": {
"format": "date-time",
"type": "string"
},
"deviceAssetId": {
"type": "string"
},
"deviceId": {
"type": "string"
},
"encodedVideoPath": {
"type": "string"
},
"id": {
"format": "uuid",
"type": "string"
},
"isArchived": {
"type": "boolean"
},
"isEncoded": {
"type": "boolean"
},
"isExternal": {
"type": "boolean"
},
"isFavorite": {
"type": "boolean"
},
"isMotion": {
"type": "boolean"
},
"isNotInAlbum": {
"type": "boolean"
},
"isOffline": {
"type": "boolean"
},
"isReadOnly": {
"type": "boolean"
},
"isVisible": {
"type": "boolean"
},
"lensModel": {
"type": "string"
},
"libraryId": {
"format": "uuid",
"type": "string"
},
"make": {
"type": "string"
},
"model": {
"type": "string"
},
"order": {
"$ref": "#/components/schemas/AssetOrder"
},
"originalFileName": {
"type": "string"
},
"originalPath": {
"type": "string"
},
"page": {
"type": "number"
},
"personIds": {
"items": {
"type": "string"
},
"type": "array"
},
"resizePath": {
"type": "string"
},
"size": {
"type": "number"
},
"state": {
"type": "string"
},
"takenAfter": {
"format": "date-time",
"type": "string"
},
"takenBefore": {
"format": "date-time",
"type": "string"
},
"trashedAfter": {
"format": "date-time",
"type": "string"
},
"trashedBefore": {
"format": "date-time",
"type": "string"
},
"type": {
"$ref": "#/components/schemas/AssetTypeEnum"
},
"updatedAfter": {
"format": "date-time",
"type": "string"
},
"updatedBefore": {
"format": "date-time",
"type": "string"
},
"webpPath": {
"type": "string"
},
"withArchived": {
"type": "boolean"
},
"withDeleted": {
"type": "boolean"
},
"withExif": {
"type": "boolean"
},
"withPeople": {
"type": "boolean"
},
"withStacked": {
"type": "boolean"
}
},
"type": "object"
},
"ModelType": {
"enum": [
"facial-recognition",
@ -9760,6 +9349,116 @@
},
"type": "object"
},
"SmartSearchDto": {
"properties": {
"city": {
"type": "string"
},
"country": {
"type": "string"
},
"createdAfter": {
"format": "date-time",
"type": "string"
},
"createdBefore": {
"format": "date-time",
"type": "string"
},
"deviceId": {
"type": "string"
},
"isArchived": {
"type": "boolean"
},
"isEncoded": {
"type": "boolean"
},
"isExternal": {
"type": "boolean"
},
"isFavorite": {
"type": "boolean"
},
"isMotion": {
"type": "boolean"
},
"isOffline": {
"type": "boolean"
},
"isReadOnly": {
"type": "boolean"
},
"isVisible": {
"type": "boolean"
},
"lensModel": {
"type": "string"
},
"libraryId": {
"format": "uuid",
"type": "string"
},
"make": {
"type": "string"
},
"model": {
"type": "string"
},
"page": {
"type": "number"
},
"query": {
"type": "string"
},
"size": {
"type": "number"
},
"state": {
"type": "string"
},
"takenAfter": {
"format": "date-time",
"type": "string"
},
"takenBefore": {
"format": "date-time",
"type": "string"
},
"trashedAfter": {
"format": "date-time",
"type": "string"
},
"trashedBefore": {
"format": "date-time",
"type": "string"
},
"type": {
"$ref": "#/components/schemas/AssetTypeEnum"
},
"updatedAfter": {
"format": "date-time",
"type": "string"
},
"updatedBefore": {
"format": "date-time",
"type": "string"
},
"withArchived": {
"type": "boolean"
},
"withDeleted": {
"type": "boolean"
},
"withExif": {
"type": "boolean"
}
},
"required": [
"query"
],
"type": "object"
},
"SystemConfigDto": {
"properties": {
"ffmpeg": {

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@ -66,13 +66,14 @@ export interface SearchAssetIDOptions {
id?: string;
}
export interface SearchUserIDOptions {
export interface SearchUserIdOptions {
deviceId?: string;
libraryId?: string;
ownerId?: string;
userIds?: string[];
}
export type SearchIDOptions = SearchAssetIDOptions & SearchUserIDOptions;
export type SearchIdOptions = SearchAssetIDOptions & SearchUserIdOptions;
export interface SearchStatusOptions {
isArchived?: boolean;
@ -83,6 +84,7 @@ export interface SearchStatusOptions {
isOffline?: boolean;
isReadOnly?: boolean;
isVisible?: boolean;
isNotInAlbum?: boolean;
type?: AssetType;
withArchived?: boolean;
withDeleted?: boolean;
@ -132,6 +134,10 @@ export interface SearchEmbeddingOptions {
userIds: string[];
}
export interface SearchPeopleOptions {
personIds?: string[];
}
export interface SearchOrderOptions {
orderDirection?: 'ASC' | 'DESC';
}
@ -142,12 +148,14 @@ export interface SearchPaginationOptions {
}
export type AssetSearchOptions = SearchDateOptions &
SearchIDOptions &
SearchIdOptions &
SearchExifOptions &
SearchOrderOptions &
SearchPathOptions &
SearchRelationOptions &
SearchStatusOptions;
SearchStatusOptions &
SearchUserIdOptions &
SearchPeopleOptions;
export type AssetSearchBuilderOptions = Omit<AssetSearchOptions, 'orderDirection'>;
@ -156,7 +164,8 @@ export type SmartSearchOptions = SearchDateOptions &
SearchExifOptions &
SearchOneToOneRelationOptions &
SearchStatusOptions &
SearchUserIDOptions;
SearchUserIdOptions &
SearchPeopleOptions;
export interface FaceEmbeddingSearch extends SearchEmbeddingOptions {
hasPerson?: boolean;

View File

@ -169,6 +169,12 @@ export class MetadataSearchDto extends BaseSearchDto {
@Optional()
@ApiProperty({ enumName: 'AssetOrder', enum: AssetOrder })
order?: AssetOrder;
@QueryBoolean({ optional: true })
isNotInAlbum?: boolean;
@Optional()
personIds?: string[];
}
export class SmartSearchDto extends BaseSearchDto {

View File

@ -60,6 +60,7 @@ export class SearchService {
async searchMetadata(auth: AuthDto, dto: MetadataSearchDto): Promise<SearchResponseDto> {
let checksum: Buffer | undefined;
const userIds = await this.getUserIdsToSearch(auth);
if (dto.checksum) {
const encoding = dto.checksum.length === 28 ? 'base64' : 'hex';
@ -74,7 +75,7 @@ export class SearchService {
{
...dto,
checksum,
ownerId: auth.user.id,
userIds,
orderDirection: dto.order ? enumToOrder[dto.order] : 'DESC',
},
);

View File

@ -10,7 +10,7 @@ import {
SmartSearchDto,
} from '@app/domain';
import { SearchSuggestionRequestDto } from '@app/domain/search/dto/search-suggestion.dto';
import { Controller, Get, Query } from '@nestjs/common';
import { Body, Controller, Get, Post, Query } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { Auth, Authenticated } from '../app.guard';
import { UseValidation } from '../app.utils';
@ -22,13 +22,13 @@ import { UseValidation } from '../app.utils';
export class SearchController {
constructor(private service: SearchService) {}
@Get('metadata')
searchMetadata(@Auth() auth: AuthDto, @Query() dto: MetadataSearchDto): Promise<SearchResponseDto> {
@Post('metadata')
searchMetadata(@Auth() auth: AuthDto, @Body() dto: MetadataSearchDto): Promise<SearchResponseDto> {
return this.service.searchMetadata(auth, dto);
}
@Get('smart')
searchSmart(@Auth() auth: AuthDto, @Query() dto: SmartSearchDto): Promise<SearchResponseDto> {
@Post('smart')
searchSmart(@Auth() auth: AuthDto, @Body() dto: SmartSearchDto): Promise<SearchResponseDto> {
return this.service.searchSmart(auth, dto);
}

View File

@ -139,14 +139,27 @@ export function searchAssetBuilder(
);
const exifInfo = _.omitBy(_.pick(options, ['city', 'country', 'lensModel', 'make', 'model', 'state']), _.isUndefined);
if (Object.keys(exifInfo).length > 0) {
builder.leftJoin(`${builder.alias}.exifInfo`, 'exifInfo');
const hasExifQuery = Object.keys(exifInfo).length > 0;
if (options.withExif && !hasExifQuery) {
builder.leftJoinAndSelect(`${builder.alias}.exifInfo`, 'exifInfo');
}
if (hasExifQuery) {
options.withExif
? builder.leftJoinAndSelect(`${builder.alias}.exifInfo`, 'exifInfo')
: builder.leftJoin(`${builder.alias}.exifInfo`, 'exifInfo');
builder.andWhere({ exifInfo });
}
const id = _.pick(options, ['checksum', 'deviceAssetId', 'deviceId', 'id', 'libraryId', 'ownerId']);
const id = _.pick(options, ['checksum', 'deviceAssetId', 'deviceId', 'id', 'libraryId']);
builder.andWhere(_.omitBy(id, _.isUndefined));
if (options.userIds) {
builder.andWhere(`${builder.alias}.ownerId IN (:...userIds)`, { userIds: options.userIds });
}
const path = _.pick(options, ['encodedVideoPath', 'originalFileName', 'originalPath', 'resizePath', 'webpPath']);
builder.andWhere(_.omitBy(path, _.isUndefined));
@ -164,8 +177,8 @@ export function searchAssetBuilder(
),
);
if (options.withExif) {
builder.leftJoinAndSelect(`${builder.alias}.exifInfo`, 'exifInfo');
if (options.isNotInAlbum) {
builder.leftJoin(`${builder.alias}.albums`, 'albums').andWhere('albums.id IS NULL');
}
if (options.withFaces || options.withPeople) {
@ -180,6 +193,18 @@ export function searchAssetBuilder(
builder.leftJoinAndSelect(`${builder.alias}.smartInfo`, 'smartInfo');
}
if (options.personIds && options.personIds.length > 0) {
builder
.leftJoin(`${builder.alias}.faces`, 'faces')
.andWhere('faces.personId IN (:...personIds)', { personIds: options.personIds })
.addGroupBy(`${builder.alias}.id`)
.having('COUNT(faces.id) = :personCount', { personCount: options.personIds.length });
if (options.withExif) {
builder.addGroupBy('exifInfo.assetId');
}
}
if (options.withStacked) {
builder
.leftJoinAndSelect(`${builder.alias}.stack`, 'stack')

View File

@ -58,6 +58,7 @@ export class SearchRepository implements ISearchRepository {
ownerId: DummyValue.UUID,
withStacked: true,
isFavorite: true,
ownerIds: [DummyValue.UUID],
},
],
})
@ -66,7 +67,6 @@ export class SearchRepository implements ISearchRepository {
builder = searchAssetBuilder(builder, options);
builder.orderBy('asset.fileCreatedAt', options.orderDirection ?? 'DESC');
return paginatedBuilder<AssetEntity>(builder, {
mode: PaginationMode.SKIP_TAKE,
skip: (pagination.page - 1) * pagination.size,

View File

@ -77,9 +77,9 @@ FROM
(
"asset"."fileCreatedAt" >= $1
AND "exifInfo"."lensModel" = $2
AND "asset"."ownerId" = $3
AND 1 = 1
AND "asset"."isFavorite" = $4
AND 1 = 1
AND "asset"."isFavorite" = $3
AND (
"stack"."primaryAssetId" = "asset"."id"
OR "asset"."stackId" IS NULL

View File

@ -38,6 +38,7 @@ module.exports = {
'unicorn/prevent-abbreviations': 'off',
'unicorn/no-nested-ternary': 'off',
'unicorn/consistent-function-scoping': 'off',
'unicorn/prefer-top-level-await': 'off',
'@typescript-eslint/no-unused-vars': [
'warn',
{

View File

@ -7,6 +7,7 @@
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte';
import { AppRoute, QueryParameter } from '$lib/constants';
import type { Viewport } from '$lib/stores/assets.store';
import { memoryStore } from '$lib/stores/memory.store';
import { getAssetThumbnailUrl } from '$lib/utils';
import { fromLocalDateTime } from '$lib/utils/timeline-util';
@ -34,6 +35,7 @@
$: canGoForward = !!(nextMemory || nextAsset);
$: canGoBack = !!(previousMemory || previousAsset);
const viewport: Viewport = { width: 0, height: 0 };
const toNextMemory = () => goto(`?${QueryParameter.MEMORY_INDEX}=${memoryIndex + 1}`);
const toPreviousMemory = () => goto(`?${QueryParameter.MEMORY_INDEX}=${memoryIndex - 1}`);
@ -251,7 +253,7 @@
<!-- GALERY VIEWER -->
<section class="bg-immich-dark-gray pl-4">
<section class="bg-immich-dark-gray m-4">
<div
class="sticky mb-10 mt-4 flex place-content-center place-items-center transition-all"
class:opacity-0={galleryInView}
@ -268,8 +270,13 @@
on:hidden={() => (galleryInView = false)}
bottom={-200}
>
<div id="gallery-memory" bind:this={memoryGallery}>
<GalleryViewer assets={currentMemory.assets} />
<div
id="gallery-memory"
bind:this={memoryGallery}
bind:clientHeight={viewport.height}
bind:clientWidth={viewport.width}
>
<GalleryViewer assets={currentMemory.assets} {viewport} />
</div>
</IntersectionObserver>
</section>

View File

@ -5,7 +5,12 @@
import type { AssetStore, Viewport } from '$lib/stores/assets.store';
import { locale } from '$lib/stores/preferences.store';
import { getAssetRatio } from '$lib/utils/asset-utils';
import { formatGroupTitle, fromLocalDateTime, splitBucketIntoDateGroups } from '$lib/utils/timeline-util';
import {
calculateWidth,
formatGroupTitle,
fromLocalDateTime,
splitBucketIntoDateGroups,
} from '$lib/utils/timeline-util';
import type { AssetResponseDto } from '@immich/sdk';
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
import justifiedLayout from 'justified-layout';
@ -36,12 +41,6 @@
let actualBucketHeight: number;
let hoveredDateGroup = '';
interface LayoutBox {
top: number;
left: number;
width: number;
}
$: assetsGroupByDate = splitBucketIntoDateGroups(assets, $locale);
$: geometry = (() => {
@ -80,17 +79,6 @@
});
}
const calculateWidth = (boxes: LayoutBox[]): number => {
let width = 0;
for (const box of boxes) {
if (box.top < 100) {
width = box.left + box.width;
}
}
return width;
};
const assetClickHandler = (asset: AssetResponseDto, assetsInDateGroup: AssetResponseDto[], groupTitle: string) => {
if (isSelectionMode || $isMultiSelectState) {
assetSelectHandler(asset, assetsInDateGroup, groupTitle);

View File

@ -16,10 +16,12 @@
import GalleryViewer from '../shared-components/gallery-viewer/gallery-viewer.svelte';
import ImmichLogo from '../shared-components/immich-logo.svelte';
import { NotificationType, notificationController } from '../shared-components/notification/notification';
import type { Viewport } from '$lib/stores/assets.store';
export let sharedLink: SharedLinkResponseDto;
export let isOwned: boolean;
const viewport: Viewport = { width: 0, height: 0 };
let selectedAssets: Set<AssetResponseDto> = new Set();
$: assets = sharedLink.assets;
@ -97,7 +99,7 @@
</svelte:fragment>
</ControlAppBar>
{/if}
<section class="my-[160px] flex flex-col px-6 sm:px-12 md:px-24 lg:px-40">
<GalleryViewer {assets} bind:selectedAssets />
<section class="my-[160px] mx-4" bind:clientHeight={viewport.height} bind:clientWidth={viewport.width}>
<GalleryViewer {assets} bind:selectedAssets {viewport} />
</section>
</section>

View File

@ -51,7 +51,7 @@
<div in:fly={{ y: 10, duration: 200 }} class="absolute top-0 w-full z-[100] bg-transparent">
<div
id="asset-selection-app-bar"
class={`grid grid-cols-[10%_80%_10%] justify-between md:grid-cols-[20%_60%_20%] lg:grid-cols-3 ${appBarBorder} mx-2 mt-2 place-items-center rounded-lg p-2 transition-all ${tailwindClasses} dark:bg-immich-dark-gray ${
class={`grid grid-cols-[10%_80%_10%] justify-between md:grid-cols-[15%_70%_15%] lg:grid-cols-[25%_50%_25%] ${appBarBorder} mx-2 mt-2 place-items-center rounded-lg p-2 transition-all ${tailwindClasses} dark:bg-immich-dark-gray ${
forceDark && 'bg-immich-dark-gray text-white'
}`}
>

View File

@ -2,13 +2,14 @@
import { page } from '$app/stores';
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import type { BucketPosition } from '$lib/stores/assets.store';
import type { BucketPosition, Viewport } from '$lib/stores/assets.store';
import { handleError } from '$lib/utils/handle-error';
import { getThumbnailSize } from '$lib/utils/thumbnail-util';
import { ThumbnailFormat, type AssetResponseDto } from '@immich/sdk';
import { type AssetResponseDto } from '@immich/sdk';
import { createEventDispatcher, onDestroy } from 'svelte';
import { flip } from 'svelte/animate';
import AssetViewer from '../../asset-viewer/asset-viewer.svelte';
import justifiedLayout from 'justified-layout';
import { getAssetRatio } from '$lib/utils/asset-utils';
import { calculateWidth } from '$lib/utils/timeline-util';
const dispatch = createEventDispatcher<{ intersected: { container: HTMLDivElement; position: BucketPosition } }>();
@ -16,14 +17,12 @@
export let selectedAssets: Set<AssetResponseDto> = new Set();
export let disableAssetSelect = false;
export let showArchiveIcon = false;
export let viewport: Viewport;
let { isViewing: showAssetViewer } = assetViewingStore;
let selectedAsset: AssetResponseDto;
let currentViewAssetIndex = 0;
let viewWidth: number;
$: thumbnailSize = getThumbnailSize(assets.length, viewWidth);
$: isMultiSelectionMode = selectedAssets.size > 0;
const viewAssetHandler = (event: CustomEvent) => {
@ -86,23 +85,45 @@
onDestroy(() => {
$showAssetViewer = false;
});
$: geometry = (() => {
const justifiedLayoutResult = justifiedLayout(
assets.map((asset) => getAssetRatio(asset)),
{
boxSpacing: 2,
containerWidth: Math.floor(viewport.width),
containerPadding: 0,
targetRowHeightTolerance: 0.15,
targetRowHeight: 235,
},
);
return {
...justifiedLayoutResult,
containerWidth: calculateWidth(justifiedLayoutResult.boxes),
};
})();
</script>
{#if assets.length > 0}
<div class="flex w-full flex-wrap gap-1 pb-20" bind:clientWidth={viewWidth}>
{#each assets as asset, i (asset.id)}
<div animate:flip={{ duration: 500 }}>
<div class="relative" style="height: {geometry.containerHeight}px;width: {geometry.containerWidth}px ">
{#each assets as asset, i (i)}
<div
class="absolute"
style="width: {geometry.boxes[i].width}px; height: {geometry.boxes[i].height}px; top: {geometry.boxes[i]
.top}px; left: {geometry.boxes[i].left}px"
>
<Thumbnail
{asset}
{thumbnailSize}
readonly={disableAssetSelect}
format={assets.length < 7 ? ThumbnailFormat.Jpeg : ThumbnailFormat.Webp}
on:click={(e) => (isMultiSelectionMode ? selectAssetHandler(e) : viewAssetHandler(e))}
on:select={selectAssetHandler}
on:intersected={(event) =>
i === Math.max(1, assets.length - 7) ? dispatch('intersected', event.detail) : undefined}
selected={selectedAssets.has(asset)}
{showArchiveIcon}
thumbnailWidth={geometry.boxes[i].width}
thumbnailHeight={geometry.boxes[i].height}
/>
</div>
{/each}

View File

@ -2,12 +2,18 @@
import { AppRoute } from '$lib/constants';
import Icon from '$lib/components/elements/icon.svelte';
import { goto } from '$app/navigation';
import { isSearchEnabled, preventRaceConditionSearchBar, savedSearchTerms } from '$lib/stores/search.store';
import {
isSearchEnabled,
preventRaceConditionSearchBar,
savedSearchTerms,
searchQuery,
} from '$lib/stores/search.store';
import { clickOutside } from '$lib/utils/click-outside';
import { mdiClose, mdiMagnify, mdiTune } from '@mdi/js';
import IconButton from '$lib/components/elements/buttons/icon-button.svelte';
import SearchHistoryBox from './search-history-box.svelte';
import SearchFilterBox from './search-filter-box.svelte';
import type { MetadataSearchDto, SmartSearchDto } from '@immich/sdk';
export let value = '';
export let grayTheme: boolean;
@ -17,28 +23,17 @@
let showFilter = false;
$: showClearIcon = value.length > 0;
function onSearch() {
let smartSearch = 'true';
let searchValue = value;
if (value.slice(0, 2) == 'm:') {
smartSearch = 'false';
searchValue = value.slice(2);
}
$savedSearchTerms = $savedSearchTerms.filter((item) => item !== value);
saveSearchTerm(value);
const onSearch = (payload: SmartSearchDto | MetadataSearchDto) => {
const parameters = new URLSearchParams({
q: searchValue,
smart: smartSearch,
take: '100',
query: JSON.stringify(payload),
});
showHistory = false;
showFilter = false;
$isSearchEnabled = false;
$searchQuery = payload;
goto(`${AppRoute.SEARCH}?${parameters}`, { invalidateAll: true });
}
};
const clearSearchTerm = (searchTerm: string) => {
input.focus();
@ -70,6 +65,26 @@
showHistory = false;
$isSearchEnabled = false;
showFilter = false;
};
const onHistoryTermClick = (searchTerm: string) => {
const searchPayload = { query: searchTerm };
onSearch(searchPayload);
};
const onFilterClick = () => {
showFilter = !showFilter;
value = '';
if (showFilter) {
showHistory = false;
}
};
const onSubmit = () => {
onSearch({ query: value });
saveSearchTerm(value);
};
</script>
@ -80,7 +95,7 @@
class="relative select-text text-sm"
action={AppRoute.SEARCH}
on:reset={() => (value = '')}
on:submit|preventDefault={() => onSearch()}
on:submit|preventDefault={onSubmit}
>
<label>
<div class="absolute inset-y-0 left-0 flex items-center pl-6">
@ -107,9 +122,9 @@
disabled={showFilter}
/>
<div class="absolute inset-y-0 right-5 flex items-center pl-6">
<div class="absolute inset-y-0 {showClearIcon ? 'right-14' : 'right-5'} flex items-center pl-6 transition-all">
<div class="dark:text-immich-dark-fg/75">
<IconButton on:click={() => (showFilter = !showFilter)} title="Show search options">
<IconButton on:click={onFilterClick} title="Show search options">
<Icon path={mdiTune} size="1.5em" />
</IconButton>
</div>
@ -131,15 +146,12 @@
<SearchHistoryBox
on:clearAllSearchTerms={clearAllSearchTerms}
on:clearSearchTerm={({ detail: searchTerm }) => clearSearchTerm(searchTerm)}
on:selectSearchTerm={({ detail: searchTerm }) => {
value = searchTerm;
onSearch();
}}
on:selectSearchTerm={({ detail: searchTerm }) => onHistoryTermClick(searchTerm)}
/>
{/if}
{#if showFilter}
<SearchFilterBox />
<SearchFilterBox on:search={({ detail }) => onSearch(detail)} />
{/if}
</form>
</div>

View File

@ -4,12 +4,20 @@
import Icon from '$lib/components/elements/icon.svelte';
import { getPeopleThumbnailUrl } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { SearchSuggestionType, type PersonResponseDto } from '@immich/sdk';
import {
AssetTypeEnum,
SearchSuggestionType,
type PersonResponseDto,
type SmartSearchDto,
type MetadataSearchDto,
} from '@immich/sdk';
import { getAllPeople, getSearchSuggestions } from '@immich/sdk';
import { mdiArrowRight, mdiClose } from '@mdi/js';
import { onMount } from 'svelte';
import { createEventDispatcher, onMount } from 'svelte';
import { fly } from 'svelte/transition';
import Combobox, { type ComboBoxOption } from '../combobox.svelte';
import { DateTime } from 'luxon';
import { searchQuery } from '$lib/stores/search.store';
enum MediaType {
All = 'all',
@ -22,8 +30,8 @@
country: ComboBoxOption[];
state: ComboBoxOption[];
city: ComboBoxOption[];
cameraMake: ComboBoxOption[];
cameraModel: ComboBoxOption[];
make: ComboBoxOption[];
model: ComboBoxOption[];
};
type SearchParams = {
@ -49,14 +57,14 @@
model?: ComboBoxOption;
};
dateRange: {
startDate?: Date;
endDate?: Date;
date: {
takenAfter?: string;
takenBefore?: string;
};
inArchive?: boolean;
inFavorite?: boolean;
notInAlbum?: boolean;
isArchive?: boolean;
isFavorite?: boolean;
isNotInAlbum?: boolean;
mediaType: MediaType;
};
@ -66,8 +74,8 @@
country: [],
state: [],
city: [],
cameraMake: [],
cameraModel: [],
make: [],
model: [],
};
let filter: SearchFilter = {
@ -82,21 +90,23 @@
make: undefined,
model: undefined,
},
dateRange: {
startDate: undefined,
endDate: undefined,
date: {
takenAfter: undefined,
takenBefore: undefined,
},
inArchive: undefined,
inFavorite: undefined,
notInAlbum: undefined,
isArchive: undefined,
isFavorite: undefined,
isNotInAlbum: undefined,
mediaType: MediaType.All,
};
const dispatch = createEventDispatcher<{ search: SmartSearchDto | MetadataSearchDto }>();
let showAllPeople = false;
$: peopleList = showAllPeople ? suggestions.people : suggestions.people.slice(0, 11);
onMount(() => {
getPeople();
populateExistingFilters();
});
const showSelectedPeopleFirst = () => {
@ -141,7 +151,7 @@
}
if (type === SearchSuggestionType.CameraMake || type === SearchSuggestionType.CameraModel) {
suggestions = { ...suggestions, cameraMake: [], cameraModel: [] };
suggestions = { ...suggestions, make: [], model: [] };
}
try {
@ -178,14 +188,14 @@
case SearchSuggestionType.CameraMake: {
for (const make of data) {
suggestions.cameraMake = [...suggestions.cameraMake, { label: make, value: make }];
suggestions.make = [...suggestions.make, { label: make, value: make }];
}
break;
}
case SearchSuggestionType.CameraModel: {
for (const model of data) {
suggestions.cameraModel = [...suggestions.cameraModel, { label: model, value: model }];
suggestions.model = [...suggestions.model, { label: model, value: model }];
}
break;
}
@ -208,18 +218,99 @@
make: undefined,
model: undefined,
},
dateRange: {
startDate: undefined,
endDate: undefined,
date: {
takenAfter: undefined,
takenBefore: undefined,
},
inArchive: undefined,
inFavorite: undefined,
notInAlbum: undefined,
isArchive: undefined,
isFavorite: undefined,
isNotInAlbum: undefined,
mediaType: MediaType.All,
};
};
const search = () => {};
const search = async () => {
let type: AssetTypeEnum | undefined = undefined;
if (filter.mediaType === MediaType.Image) {
type = AssetTypeEnum.Image;
} else if (filter.mediaType === MediaType.Video) {
type = AssetTypeEnum.Video;
}
let payload: SmartSearchDto | MetadataSearchDto = {
country: filter.location.country?.value,
state: filter.location.state?.value,
city: filter.location.city?.value,
make: filter.camera.make?.value,
model: filter.camera.model?.value,
takenAfter: filter.date.takenAfter
? DateTime.fromFormat(filter.date.takenAfter, 'yyyy-MM-dd').toUTC().startOf('day').toString()
: undefined,
takenBefore: filter.date.takenBefore
? DateTime.fromFormat(filter.date.takenBefore, 'yyyy-MM-dd').toUTC().endOf('day').toString()
: undefined,
/* eslint-disable unicorn/prefer-logical-operator-over-ternary */
isArchived: filter.isArchive ? filter.isArchive : undefined,
isFavorite: filter.isFavorite ? filter.isFavorite : undefined,
isNotInAlbum: filter.isNotInAlbum ? filter.isNotInAlbum : undefined,
personIds: filter.people && filter.people.length > 0 ? filter.people.map((p) => p.id) : undefined,
type,
};
if (filter.context) {
if (payload.personIds && payload.personIds.length > 0) {
handleError(
new Error('Context search does not support people filter'),
'Context search does not support people filter',
);
return;
}
payload = {
...payload,
query: filter.context,
};
}
dispatch('search', payload);
};
function populateExistingFilters() {
if ($searchQuery) {
filter = {
context: 'query' in $searchQuery ? $searchQuery.query : '',
people:
'personIds' in $searchQuery ? ($searchQuery.personIds?.map((id) => ({ id })) as PersonResponseDto[]) : [],
location: {
country: $searchQuery.country ? { label: $searchQuery.country, value: $searchQuery.country } : undefined,
state: $searchQuery.state ? { label: $searchQuery.state, value: $searchQuery.state } : undefined,
city: $searchQuery.city ? { label: $searchQuery.city, value: $searchQuery.city } : undefined,
},
camera: {
make: $searchQuery.make ? { label: $searchQuery.make, value: $searchQuery.make } : undefined,
model: $searchQuery.model ? { label: $searchQuery.model, value: $searchQuery.model } : undefined,
},
date: {
takenAfter: $searchQuery.takenAfter
? DateTime.fromISO($searchQuery.takenAfter).toUTC().toFormat('yyyy-MM-dd')
: undefined,
takenBefore: $searchQuery.takenBefore
? DateTime.fromISO($searchQuery.takenBefore).toUTC().toFormat('yyyy-MM-dd')
: undefined,
},
isArchive: $searchQuery.isArchived,
isFavorite: $searchQuery.isFavorite,
isNotInAlbum: 'isNotInAlbum' in $searchQuery ? $searchQuery.isNotInAlbum : undefined,
mediaType:
$searchQuery.type === AssetTypeEnum.Image
? MediaType.Image
: $searchQuery.type === AssetTypeEnum.Video
? MediaType.Video
: MediaType.All,
};
}
}
</script>
<div
@ -347,7 +438,7 @@
<div class="w-full">
<p class="text-sm text-black dark:text-white">Make</p>
<Combobox
options={suggestions.cameraMake}
options={suggestions.make}
bind:selectedOption={filter.camera.make}
placeholder="Search camera make..."
on:click={() =>
@ -358,7 +449,7 @@
<div class="w-full">
<p class="text-sm text-black dark:text-white">Model</p>
<Combobox
options={suggestions.cameraModel}
options={suggestions.model}
bind:selectedOption={filter.camera.model}
placeholder="Search camera model..."
on:click={() =>
@ -379,7 +470,7 @@
type="date"
id="start-date"
name="start-date"
bind:value={filter.dateRange.startDate}
bind:value={filter.date.takenAfter}
/>
</div>
@ -391,7 +482,7 @@
id="end-date"
name="end-date"
placeholder=""
bind:value={filter.dateRange.endDate}
bind:value={filter.date.takenBefore}
/>
</div>
</div>
@ -450,17 +541,17 @@
<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={filter.notInAlbum} />
<input type="checkbox" class="form-checkbox h-5 w-5 color" bind:checked={filter.isNotInAlbum} />
<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={filter.inArchive} />
<input type="checkbox" class="form-checkbox h-5 w-5 color" bind:checked={filter.isArchive} />
<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={filter.inFavorite} />
<input type="checkbox" class="form-checkbox h-5 w-5 color" bind:checked={filter.isFavorite} />
<span class="ml-2 text-sm text-black dark:text-white pt-1">Favorite</span>
</label>
</div>

View File

@ -16,15 +16,6 @@
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"
>
<div class="flex px-5 pt-5 text-left text-sm">
<p>
Smart search is enabled by default, to search for metadata use the syntax <span
class="rounded-lg bg-gray-100 p-2 font-mono font-semibold leading-7 text-immich-primary dark:bg-gray-900 dark:text-immich-dark-primary"
>m:your-search-term</span
>
</p>
</div>
{#if $savedSearchTerms.length > 0}
<div class="flex items-center justify-between px-5 pt-5 text-xs">
<p>RECENT SEARCHES</p>

View File

@ -71,6 +71,7 @@ export enum QueryParameter {
SEARCHED_PEOPLE = 'searchedPeople',
SEARCH_TERM = 'q',
SMART_SEARCH = 'smartSearch',
PAGE = 'page',
}
export enum OpenSettingQueryParameterValue {

View File

@ -1,6 +1,8 @@
import type { MetadataSearchDto, SmartSearchDto } from '@immich/sdk';
import { persisted } from 'svelte-local-storage-store';
import { writable } from 'svelte/store';
export const savedSearchTerms = persisted<string[]>('search-terms', [], {});
export const isSearchEnabled = writable<boolean>(false);
export const preventRaceConditionSearchBar = writable<boolean>(false);
export const searchQuery = writable<SmartSearchDto | MetadataSearchDto | undefined>(undefined);

View File

@ -50,3 +50,20 @@ export function splitBucketIntoDateGroups(
);
return sortBy(grouped, (group) => assets.indexOf(group[0]));
}
export type LayoutBox = {
top: number;
left: number;
width: number;
};
export function calculateWidth(boxes: LayoutBox[]): number {
let width = 0;
for (const box of boxes) {
if (box.top < 100) {
width = box.left + box.width;
}
}
return width;
}

View File

@ -20,25 +20,30 @@
import SearchBar from '$lib/components/shared-components/search-bar/search-bar.svelte';
import { AppRoute, QueryParameter } from '$lib/constants';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { preventRaceConditionSearchBar } from '$lib/stores/search.store';
import { preventRaceConditionSearchBar, searchQuery } from '$lib/stores/search.store';
import { authenticate } from '$lib/utils/auth';
import { shouldIgnoreShortcut } from '$lib/utils/shortcut';
import { search, type AssetResponseDto, type SearchResponseDto } from '@immich/sdk';
import { type AssetResponseDto, type SearchResponseDto, searchSmart, searchMetadata, getPerson } from '@immich/sdk';
import { mdiArrowLeft, mdiDotsVertical, mdiImageOffOutline, mdiPlus, mdiSelectAll } from '@mdi/js';
import { onDestroy, onMount } from 'svelte';
import { flip } from 'svelte/animate';
import type { PageData } from './$types';
import type { Viewport } from '$lib/stores/assets.store';
import { locale } from '$lib/stores/preferences.store';
export let data: PageData;
const MAX_ASSET_COUNT = 5000;
let { isViewing: showAssetViewer } = assetViewingStore;
const viewport: Viewport = { width: 0, height: 0 };
// The GalleryViewer pushes it's own history state, which causes weird
// behavior for history.back(). To prevent that we store the previous page
// manually and navigate back to that.
let previousRoute = AppRoute.EXPLORE as string;
$: curPage = data.results?.assets.nextPage;
/* eslint-disable @typescript-eslint/no-explicit-any */
let terms: any;
$: currentPage = data.results?.assets.nextPage;
$: albums = data.results?.albums.items;
const onKeyboardPress = (event: KeyboardEvent) => handleKeyboardPress(event);
@ -87,16 +92,9 @@
if (from?.route.id === '/(user)/albums/[albumId]') {
previousRoute = AppRoute.EXPLORE;
}
});
$: term = (() => {
let term = $page.url.searchParams.get(QueryParameter.SEARCH_TERM) || data.term || '';
const isMetadataSearch = $page.url.searchParams.get(QueryParameter.SMART_SEARCH) === 'false';
if (isMetadataSearch && term !== '') {
term = `m:${term}`;
}
return term;
})();
updateInformationChip();
});
let selectedAssets: Set<AssetResponseDto> = new Set();
$: isMultiSelectionMode = selectedAssets.size > 0;
@ -111,32 +109,120 @@
selectedAssets = new Set(searchResultAssets);
};
function updateInformationChip() {
let query = $page.url.searchParams.get(QueryParameter.SEARCH_TERM) || data.term || '';
terms = JSON.parse(query);
}
export const loadNextPage = async () => {
if (curPage == null || !term || (searchResultAssets && searchResultAssets.length >= MAX_ASSET_COUNT)) {
if (currentPage == null || !terms || (searchResultAssets && searchResultAssets.length >= MAX_ASSET_COUNT)) {
return;
}
await authenticate();
let results: SearchResponseDto | null = null;
$page.url.searchParams.set('page', curPage.toString());
const res = await search({ ...$page.url.searchParams });
$page.url.searchParams.set(QueryParameter.PAGE, currentPage.toString());
const payload = $searchQuery;
let responses: SearchResponseDto;
responses =
payload && 'query' in payload
? await searchSmart({
smartSearchDto: { ...payload, page: Number.parseInt(currentPage), withExif: true },
})
: await searchMetadata({
metadataSearchDto: { ...payload, page: Number.parseInt(currentPage), withExif: true },
});
if (searchResultAssets) {
searchResultAssets.push(...res.assets.items);
searchResultAssets.push(...responses.assets.items);
} else {
searchResultAssets = res.assets.items;
searchResultAssets = responses.assets.items;
}
const assets = {
...res.assets,
...responses.assets,
items: searchResultAssets,
};
results = {
assets,
albums: res.albums,
albums: responses.albums,
};
data.results = results;
};
function getHumanReadableDate(date: string) {
const d = new Date(date);
return d.toLocaleDateString($locale, {
year: 'numeric',
month: 'long',
day: 'numeric',
});
}
function getHumanReadableSearchKey(key: string): string {
switch (key) {
case 'takenAfter': {
return 'Start date';
}
case 'takenBefore': {
return 'End date';
}
case 'isArchived': {
return 'In archive';
}
case 'isFavorite': {
return 'Favorite';
}
case 'isNotInAlbum': {
return 'Not in any album';
}
case 'type': {
return 'Media type';
}
case 'query': {
return 'Context';
}
case 'city': {
return 'City';
}
case 'country': {
return 'Country';
}
case 'state': {
return 'State';
}
case 'make': {
return 'Camera brand';
}
case 'model': {
return 'Camera model';
}
case 'personIds': {
return 'People';
}
default: {
return key;
}
}
}
async function getPersonName(personIds: string[]) {
const personNames = await Promise.all(
personIds.map(async (personId) => {
const person = await getPerson({ id: personId });
if (person.name == '') {
return 'No Name';
}
return person.name;
}),
);
return personNames.join(', ');
}
</script>
<section>
@ -161,15 +247,53 @@
</AssetSelectControlBar>
</div>
{:else}
<ControlAppBar on:close={() => goto(previousRoute)} backIcon={mdiArrowLeft}>
<div class="w-full flex-1 pl-4">
<SearchBar grayTheme={false} value={term} />
</div>
</ControlAppBar>
<div class="fixed z-[100] top-0 left-0 w-full">
<ControlAppBar on:close={() => goto(previousRoute)} backIcon={mdiArrowLeft}>
<div class="w-full flex-1 pl-4">
<SearchBar grayTheme={false} />
</div>
</ControlAppBar>
</div>
{/if}
</section>
<section class="relative mb-12 bg-immich-bg pt-32 dark:bg-immich-dark-bg">
{#if terms}
<section
id="search-chips"
class="mt-24 text-center w-full flex gap-5 place-content-center place-items-center flex-wrap px-24"
>
{#each Object.keys(terms) as key, index (index)}
<div class="flex place-content-center place-items-center text-xs">
<div
class="bg-immich-primary py-2 px-4 text-white dark:text-black dark:bg-immich-dark-primary
{terms[key] === true ? 'rounded-full' : 'rounded-tl-full rounded-bl-full'}"
>
{getHumanReadableSearchKey(key)}
</div>
{#if terms[key] !== true}
<div class="bg-gray-300 py-2 px-4 dark:bg-gray-800 dark:text-white rounded-tr-full rounded-br-full">
{#if key === 'takenAfter' || key === 'takenBefore'}
{getHumanReadableDate(terms[key])}
{:else if key === 'personIds'}
{#await getPersonName(terms[key]) then personName}
{personName}
{/await}
{:else}
{terms[key]}
{/if}
</div>
{/if}
</div>
{/each}
</section>
{/if}
<section
class="relative mb-12 bg-immich-bg dark:bg-immich-dark-bg m-4"
bind:clientHeight={viewport.height}
bind:clientWidth={viewport.width}
>
<section class="immich-scrollbar relative overflow-y-auto">
{#if albums && albums.length > 0}
<section>
@ -193,14 +317,13 @@
{/if}
<section id="search-content" class="relative bg-immich-bg dark:bg-immich-dark-bg">
{#if searchResultAssets && searchResultAssets.length > 0}
<div class="pl-4">
<GalleryViewer
assets={searchResultAssets}
bind:selectedAssets
on:intersected={loadNextPage}
showArchiveIcon={true}
/>
</div>
<GalleryViewer
assets={searchResultAssets}
bind:selectedAssets
on:intersected={loadNextPage}
showArchiveIcon={true}
{viewport}
/>
{:else}
<div class="flex min-h-[calc(66vh_-_11rem)] w-full place-content-center items-center dark:text-white">
<div class="flex flex-col content-center items-center text-center">

View File

@ -1,6 +1,13 @@
import { QueryParameter } from '$lib/constants';
import { searchQuery } from '$lib/stores/search.store';
import { authenticate } from '$lib/utils/auth';
import { search, type AssetResponseDto, type SearchResponseDto } from '@immich/sdk';
import {
searchMetadata,
searchSmart,
type MetadataSearchDto,
type SearchResponseDto,
type SmartSearchDto,
} from '@immich/sdk';
import type { PageLoad } from './$types';
export const load = (async (data) => {
@ -10,22 +17,13 @@ export const load = (async (data) => {
url.searchParams.get(QueryParameter.SEARCH_TERM) || url.searchParams.get(QueryParameter.QUERY) || undefined;
let results: SearchResponseDto | null = null;
if (term) {
let params = {};
for (const [key, value] of data.url.searchParams) {
params = { ...params, [key]: value };
}
const response = await search({ ...params });
let items: AssetResponseDto[] = (data as unknown as { results: SearchResponseDto }).results?.assets.items;
if (items) {
items.push(...response.assets.items);
} else {
items = response.assets.items;
}
const assets = { ...response.assets, items };
results = {
assets,
albums: response.albums,
};
const payload = JSON.parse(term) as SmartSearchDto | MetadataSearchDto;
searchQuery.set(payload);
results =
payload && 'query' in payload
? await searchSmart({ smartSearchDto: { ...payload, withExif: true } })
: await searchMetadata({ metadataSearchDto: { ...payload, withExif: true } });
}
return {