mirror of
https://github.com/immich-app/immich.git
synced 2024-12-25 10:43:13 +02:00
feat(web,server): explore (#1926)
* feat: explore * chore: generate open api * styling explore page * styling no result page * style overlay * style: bluring text on thumbnail card for readability * explore page tweaks * fix(web): search urls * feat(web): use objects for things * feat(server): filter by motion, sort by createdAt * More styling * better navigation --------- Co-authored-by: Alex Tran <alex.tran1502@gmail.com> Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com>
This commit is contained in:
parent
1f631eafce
commit
2ca560ebf8
6
mobile/openapi/.openapi-generator/FILES
generated
6
mobile/openapi/.openapi-generator/FILES
generated
@ -66,6 +66,8 @@ doc/SearchApi.md
|
|||||||
doc/SearchAssetDto.md
|
doc/SearchAssetDto.md
|
||||||
doc/SearchAssetResponseDto.md
|
doc/SearchAssetResponseDto.md
|
||||||
doc/SearchConfigResponseDto.md
|
doc/SearchConfigResponseDto.md
|
||||||
|
doc/SearchExploreItem.md
|
||||||
|
doc/SearchExploreResponseDto.md
|
||||||
doc/SearchFacetCountResponseDto.md
|
doc/SearchFacetCountResponseDto.md
|
||||||
doc/SearchFacetResponseDto.md
|
doc/SearchFacetResponseDto.md
|
||||||
doc/SearchResponseDto.md
|
doc/SearchResponseDto.md
|
||||||
@ -179,6 +181,8 @@ lib/model/search_album_response_dto.dart
|
|||||||
lib/model/search_asset_dto.dart
|
lib/model/search_asset_dto.dart
|
||||||
lib/model/search_asset_response_dto.dart
|
lib/model/search_asset_response_dto.dart
|
||||||
lib/model/search_config_response_dto.dart
|
lib/model/search_config_response_dto.dart
|
||||||
|
lib/model/search_explore_item.dart
|
||||||
|
lib/model/search_explore_response_dto.dart
|
||||||
lib/model/search_facet_count_response_dto.dart
|
lib/model/search_facet_count_response_dto.dart
|
||||||
lib/model/search_facet_response_dto.dart
|
lib/model/search_facet_response_dto.dart
|
||||||
lib/model/search_response_dto.dart
|
lib/model/search_response_dto.dart
|
||||||
@ -273,6 +277,8 @@ test/search_api_test.dart
|
|||||||
test/search_asset_dto_test.dart
|
test/search_asset_dto_test.dart
|
||||||
test/search_asset_response_dto_test.dart
|
test/search_asset_response_dto_test.dart
|
||||||
test/search_config_response_dto_test.dart
|
test/search_config_response_dto_test.dart
|
||||||
|
test/search_explore_item_test.dart
|
||||||
|
test/search_explore_response_dto_test.dart
|
||||||
test/search_facet_count_response_dto_test.dart
|
test/search_facet_count_response_dto_test.dart
|
||||||
test/search_facet_response_dto_test.dart
|
test/search_facet_response_dto_test.dart
|
||||||
test/search_response_dto_test.dart
|
test/search_response_dto_test.dart
|
||||||
|
BIN
mobile/openapi/README.md
generated
BIN
mobile/openapi/README.md
generated
Binary file not shown.
BIN
mobile/openapi/doc/SearchApi.md
generated
BIN
mobile/openapi/doc/SearchApi.md
generated
Binary file not shown.
BIN
mobile/openapi/doc/SearchExploreItem.md
generated
Normal file
BIN
mobile/openapi/doc/SearchExploreItem.md
generated
Normal file
Binary file not shown.
BIN
mobile/openapi/doc/SearchExploreResponseDto.md
generated
Normal file
BIN
mobile/openapi/doc/SearchExploreResponseDto.md
generated
Normal file
Binary file not shown.
BIN
mobile/openapi/lib/api.dart
generated
BIN
mobile/openapi/lib/api.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/api/search_api.dart
generated
BIN
mobile/openapi/lib/api/search_api.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/api_client.dart
generated
BIN
mobile/openapi/lib/api_client.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/model/search_explore_item.dart
generated
Normal file
BIN
mobile/openapi/lib/model/search_explore_item.dart
generated
Normal file
Binary file not shown.
BIN
mobile/openapi/lib/model/search_explore_response_dto.dart
generated
Normal file
BIN
mobile/openapi/lib/model/search_explore_response_dto.dart
generated
Normal file
Binary file not shown.
BIN
mobile/openapi/test/search_api_test.dart
generated
BIN
mobile/openapi/test/search_api_test.dart
generated
Binary file not shown.
BIN
mobile/openapi/test/search_explore_item_test.dart
generated
Normal file
BIN
mobile/openapi/test/search_explore_item_test.dart
generated
Normal file
Binary file not shown.
BIN
mobile/openapi/test/search_explore_response_dto_test.dart
generated
Normal file
BIN
mobile/openapi/test/search_explore_response_dto_test.dart
generated
Normal file
Binary file not shown.
@ -1,4 +1,11 @@
|
|||||||
import { AuthUserDto, SearchConfigResponseDto, SearchDto, SearchResponseDto, SearchService } from '@app/domain';
|
import {
|
||||||
|
AuthUserDto,
|
||||||
|
SearchConfigResponseDto,
|
||||||
|
SearchDto,
|
||||||
|
SearchExploreResponseDto,
|
||||||
|
SearchResponseDto,
|
||||||
|
SearchService,
|
||||||
|
} from '@app/domain';
|
||||||
import { Controller, Get, Query, ValidationPipe } from '@nestjs/common';
|
import { Controller, Get, Query, ValidationPipe } from '@nestjs/common';
|
||||||
import { ApiTags } from '@nestjs/swagger';
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
import { GetAuthUser } from '../decorators/auth-user.decorator';
|
import { GetAuthUser } from '../decorators/auth-user.decorator';
|
||||||
@ -10,7 +17,6 @@ import { Authenticated } from '../decorators/authenticated.decorator';
|
|||||||
export class SearchController {
|
export class SearchController {
|
||||||
constructor(private readonly searchService: SearchService) {}
|
constructor(private readonly searchService: SearchService) {}
|
||||||
|
|
||||||
@Authenticated()
|
|
||||||
@Get()
|
@Get()
|
||||||
async search(
|
async search(
|
||||||
@GetAuthUser() authUser: AuthUserDto,
|
@GetAuthUser() authUser: AuthUserDto,
|
||||||
@ -19,9 +25,13 @@ export class SearchController {
|
|||||||
return this.searchService.search(authUser, dto);
|
return this.searchService.search(authUser, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Authenticated()
|
|
||||||
@Get('config')
|
@Get('config')
|
||||||
getSearchConfig(): SearchConfigResponseDto {
|
getSearchConfig(): SearchConfigResponseDto {
|
||||||
return this.searchService.getConfig();
|
return this.searchService.getConfig();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('explore')
|
||||||
|
getExploreData(@GetAuthUser() authUser: AuthUserDto): Promise<SearchExploreResponseDto[]> {
|
||||||
|
return this.searchService.getExploreData(authUser) as Promise<SearchExploreResponseDto[]>;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -2,8 +2,8 @@ import {
|
|||||||
AssetCore,
|
AssetCore,
|
||||||
IAssetRepository,
|
IAssetRepository,
|
||||||
IAssetUploadedJob,
|
IAssetUploadedJob,
|
||||||
|
IJobRepository,
|
||||||
IReverseGeocodingJob,
|
IReverseGeocodingJob,
|
||||||
ISearchRepository,
|
|
||||||
JobName,
|
JobName,
|
||||||
QueueName,
|
QueueName,
|
||||||
} from '@app/domain';
|
} from '@app/domain';
|
||||||
@ -86,14 +86,14 @@ export class MetadataExtractionProcessor {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(IAssetRepository) assetRepository: IAssetRepository,
|
@Inject(IAssetRepository) assetRepository: IAssetRepository,
|
||||||
@Inject(ISearchRepository) searchRepository: ISearchRepository,
|
@Inject(IJobRepository) jobRepository: IJobRepository,
|
||||||
|
|
||||||
@InjectRepository(ExifEntity)
|
@InjectRepository(ExifEntity)
|
||||||
private exifRepository: Repository<ExifEntity>,
|
private exifRepository: Repository<ExifEntity>,
|
||||||
|
|
||||||
configService: ConfigService,
|
configService: ConfigService,
|
||||||
) {
|
) {
|
||||||
this.assetCore = new AssetCore(assetRepository, searchRepository);
|
this.assetCore = new AssetCore(assetRepository, jobRepository);
|
||||||
|
|
||||||
if (!configService.get('DISABLE_REVERSE_GEOCODING')) {
|
if (!configService.get('DISABLE_REVERSE_GEOCODING')) {
|
||||||
this.logger.log('Initializing Reverse Geocoding');
|
this.logger.log('Initializing Reverse Geocoding');
|
||||||
|
@ -640,6 +640,22 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "recent",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "motion",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
@ -658,12 +674,6 @@
|
|||||||
"Search"
|
"Search"
|
||||||
],
|
],
|
||||||
"security": [
|
"security": [
|
||||||
{
|
|
||||||
"bearer": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cookie": []
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"bearer": []
|
"bearer": []
|
||||||
},
|
},
|
||||||
@ -699,7 +709,34 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"cookie": []
|
"cookie": []
|
||||||
},
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/search/explore": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "getExploreData",
|
||||||
|
"description": "",
|
||||||
|
"parameters": [],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/SearchExploreResponseDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
"Search"
|
||||||
|
],
|
||||||
|
"security": [
|
||||||
{
|
{
|
||||||
"bearer": []
|
"bearer": []
|
||||||
},
|
},
|
||||||
@ -4149,6 +4186,39 @@
|
|||||||
"enabled"
|
"enabled"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"SearchExploreItem": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"value": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"$ref": "#/components/schemas/AssetResponseDto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"value",
|
||||||
|
"data"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"SearchExploreResponseDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"fieldName": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"items": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/SearchExploreItem"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"fieldName",
|
||||||
|
"items"
|
||||||
|
]
|
||||||
|
},
|
||||||
"SharedLinkType": {
|
"SharedLinkType": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
|
@ -1,21 +1,21 @@
|
|||||||
import { AssetEntity, AssetType } from '@app/infra/db/entities';
|
import { AssetEntity, AssetType } from '@app/infra/db/entities';
|
||||||
import { ISearchRepository, SearchCollection } from '../search/search.repository';
|
import { IJobRepository, JobName } from '../job';
|
||||||
import { AssetSearchOptions, IAssetRepository } from './asset.repository';
|
import { AssetSearchOptions, IAssetRepository } from './asset.repository';
|
||||||
|
|
||||||
export class AssetCore {
|
export class AssetCore {
|
||||||
constructor(private repository: IAssetRepository, private searchRepository: ISearchRepository) {}
|
constructor(private assetRepository: IAssetRepository, private jobRepository: IJobRepository) {}
|
||||||
|
|
||||||
getAll(options: AssetSearchOptions) {
|
getAll(options: AssetSearchOptions) {
|
||||||
return this.repository.getAll(options);
|
return this.assetRepository.getAll(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
async save(asset: Partial<AssetEntity>) {
|
async save(asset: Partial<AssetEntity>) {
|
||||||
const _asset = await this.repository.save(asset);
|
const _asset = await this.assetRepository.save(asset);
|
||||||
await this.searchRepository.index(SearchCollection.ASSETS, _asset);
|
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { asset: _asset } });
|
||||||
return _asset;
|
return _asset;
|
||||||
}
|
}
|
||||||
|
|
||||||
findLivePhotoMatch(livePhotoCID: string, otherAssetId: string, type: AssetType): Promise<AssetEntity | null> {
|
findLivePhotoMatch(livePhotoCID: string, otherAssetId: string, type: AssetType): Promise<AssetEntity | null> {
|
||||||
return this.repository.findLivePhotoMatch(livePhotoCID, otherAssetId, type);
|
return this.assetRepository.findLivePhotoMatch(livePhotoCID, otherAssetId, type);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,15 +1,12 @@
|
|||||||
import { AssetEntity, AssetType } from '@app/infra/db/entities';
|
import { AssetEntity, AssetType } from '@app/infra/db/entities';
|
||||||
import { assetEntityStub, newAssetRepositoryMock, newJobRepositoryMock } from '../../test';
|
import { assetEntityStub, newAssetRepositoryMock, newJobRepositoryMock } from '../../test';
|
||||||
import { newSearchRepositoryMock } from '../../test/search.repository.mock';
|
|
||||||
import { AssetService, IAssetRepository } from '../asset';
|
import { AssetService, IAssetRepository } from '../asset';
|
||||||
import { IJobRepository, JobName } from '../job';
|
import { IJobRepository, JobName } from '../job';
|
||||||
import { ISearchRepository } from '../search';
|
|
||||||
|
|
||||||
describe(AssetService.name, () => {
|
describe(AssetService.name, () => {
|
||||||
let sut: AssetService;
|
let sut: AssetService;
|
||||||
let assetMock: jest.Mocked<IAssetRepository>;
|
let assetMock: jest.Mocked<IAssetRepository>;
|
||||||
let jobMock: jest.Mocked<IJobRepository>;
|
let jobMock: jest.Mocked<IJobRepository>;
|
||||||
let searchMock: jest.Mocked<ISearchRepository>;
|
|
||||||
|
|
||||||
it('should work', () => {
|
it('should work', () => {
|
||||||
expect(sut).toBeDefined();
|
expect(sut).toBeDefined();
|
||||||
@ -18,8 +15,7 @@ describe(AssetService.name, () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
assetMock = newAssetRepositoryMock();
|
assetMock = newAssetRepositoryMock();
|
||||||
jobMock = newJobRepositoryMock();
|
jobMock = newJobRepositoryMock();
|
||||||
searchMock = newSearchRepositoryMock();
|
sut = new AssetService(assetMock, jobMock);
|
||||||
sut = new AssetService(assetMock, jobMock, searchMock);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe(`handle asset upload`, () => {
|
describe(`handle asset upload`, () => {
|
||||||
@ -56,7 +52,10 @@ describe(AssetService.name, () => {
|
|||||||
await sut.save(assetEntityStub.image);
|
await sut.save(assetEntityStub.image);
|
||||||
|
|
||||||
expect(assetMock.save).toHaveBeenCalledWith(assetEntityStub.image);
|
expect(assetMock.save).toHaveBeenCalledWith(assetEntityStub.image);
|
||||||
expect(searchMock.index).toHaveBeenCalledWith('assets', assetEntityStub.image);
|
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||||
|
name: JobName.SEARCH_INDEX_ASSET,
|
||||||
|
data: { asset: assetEntityStub.image },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import { AssetEntity, AssetType } from '@app/infra/db/entities';
|
import { AssetEntity, AssetType } from '@app/infra/db/entities';
|
||||||
import { Inject } from '@nestjs/common';
|
import { Inject } from '@nestjs/common';
|
||||||
import { IAssetUploadedJob, IJobRepository, JobName } from '../job';
|
import { IAssetUploadedJob, IJobRepository, JobName } from '../job';
|
||||||
import { ISearchRepository } from '../search';
|
|
||||||
import { AssetCore } from './asset.core';
|
import { AssetCore } from './asset.core';
|
||||||
import { IAssetRepository } from './asset.repository';
|
import { IAssetRepository } from './asset.repository';
|
||||||
|
|
||||||
@ -11,9 +10,8 @@ export class AssetService {
|
|||||||
constructor(
|
constructor(
|
||||||
@Inject(IAssetRepository) assetRepository: IAssetRepository,
|
@Inject(IAssetRepository) assetRepository: IAssetRepository,
|
||||||
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
||||||
@Inject(ISearchRepository) searchRepository: ISearchRepository,
|
|
||||||
) {
|
) {
|
||||||
this.assetCore = new AssetCore(assetRepository, searchRepository);
|
this.assetCore = new AssetCore(assetRepository, jobRepository);
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleAssetUpload(data: IAssetUploadedJob) {
|
async handleAssetUpload(data: IAssetUploadedJob) {
|
||||||
|
@ -54,4 +54,14 @@ export class SearchDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Transform(({ value }) => value.split(','))
|
@Transform(({ value }) => value.split(','))
|
||||||
'smartInfo.tags'?: string[];
|
'smartInfo.tags'?: string[];
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(toBoolean)
|
||||||
|
recent?: boolean;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(toBoolean)
|
||||||
|
motion?: boolean;
|
||||||
}
|
}
|
||||||
|
@ -1,2 +1,3 @@
|
|||||||
export * from './search-config-response.dto';
|
export * from './search-config-response.dto';
|
||||||
|
export * from './search-explore.response.dto';
|
||||||
export * from './search-response.dto';
|
export * from './search-response.dto';
|
||||||
|
@ -0,0 +1,11 @@
|
|||||||
|
import { AssetResponseDto } from '../../asset';
|
||||||
|
|
||||||
|
class SearchExploreItem {
|
||||||
|
value!: string;
|
||||||
|
data!: AssetResponseDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SearchExploreResponseDto {
|
||||||
|
fieldName!: string;
|
||||||
|
items!: SearchExploreItem[];
|
||||||
|
}
|
@ -17,6 +17,8 @@ export interface SearchFilter {
|
|||||||
model?: string;
|
model?: string;
|
||||||
objects?: string[];
|
objects?: string[];
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
|
recent?: boolean;
|
||||||
|
motion?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SearchResult<T> {
|
export interface SearchResult<T> {
|
||||||
@ -39,6 +41,14 @@ export interface SearchFacet {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SearchExploreItem<T> {
|
||||||
|
fieldName: string;
|
||||||
|
items: Array<{
|
||||||
|
value: string;
|
||||||
|
data: T;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
export type SearchCollectionIndexStatus = Record<SearchCollection, boolean>;
|
export type SearchCollectionIndexStatus = Record<SearchCollection, boolean>;
|
||||||
|
|
||||||
export const ISearchRepository = 'ISearchRepository';
|
export const ISearchRepository = 'ISearchRepository';
|
||||||
@ -57,4 +67,6 @@ export interface ISearchRepository {
|
|||||||
|
|
||||||
search(collection: SearchCollection.ASSETS, query: string, filters: SearchFilter): Promise<SearchResult<AssetEntity>>;
|
search(collection: SearchCollection.ASSETS, query: string, filters: SearchFilter): Promise<SearchResult<AssetEntity>>;
|
||||||
search(collection: SearchCollection.ALBUMS, query: string, filters: SearchFilter): Promise<SearchResult<AlbumEntity>>;
|
search(collection: SearchCollection.ALBUMS, query: string, filters: SearchFilter): Promise<SearchResult<AlbumEntity>>;
|
||||||
|
|
||||||
|
explore(userId: string): Promise<SearchExploreItem<AssetEntity>[]>;
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { AssetEntity } from '@app/infra/db/entities';
|
||||||
import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common';
|
import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { IAlbumRepository } from '../album/album.repository';
|
import { IAlbumRepository } from '../album/album.repository';
|
||||||
@ -6,7 +7,7 @@ import { AuthUserDto } from '../auth';
|
|||||||
import { IAlbumJob, IAssetJob, IDeleteJob, IJobRepository, JobName } from '../job';
|
import { IAlbumJob, IAssetJob, IDeleteJob, IJobRepository, JobName } from '../job';
|
||||||
import { SearchDto } from './dto';
|
import { SearchDto } from './dto';
|
||||||
import { SearchConfigResponseDto, SearchResponseDto } from './response-dto';
|
import { SearchConfigResponseDto, SearchResponseDto } from './response-dto';
|
||||||
import { ISearchRepository, SearchCollection } from './search.repository';
|
import { ISearchRepository, SearchCollection, SearchExploreItem } from './search.repository';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SearchService {
|
export class SearchService {
|
||||||
@ -52,10 +53,13 @@ export class SearchService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getExploreData(authUser: AuthUserDto): Promise<SearchExploreItem<AssetEntity>[]> {
|
||||||
|
this.assertEnabled();
|
||||||
|
return this.searchRepository.explore(authUser.id);
|
||||||
|
}
|
||||||
|
|
||||||
async search(authUser: AuthUserDto, dto: SearchDto): Promise<SearchResponseDto> {
|
async search(authUser: AuthUserDto, dto: SearchDto): Promise<SearchResponseDto> {
|
||||||
if (!this.enabled) {
|
this.assertEnabled();
|
||||||
throw new BadRequestException('Search is disabled');
|
|
||||||
}
|
|
||||||
|
|
||||||
const query = dto.query || '*';
|
const query = dto.query || '*';
|
||||||
|
|
||||||
@ -83,6 +87,7 @@ export class SearchService {
|
|||||||
|
|
||||||
this.logger.log(`Indexing ${assets.length} assets`);
|
this.logger.log(`Indexing ${assets.length} assets`);
|
||||||
await this.searchRepository.import(SearchCollection.ASSETS, assets, true);
|
await this.searchRepository.import(SearchCollection.ASSETS, assets, true);
|
||||||
|
this.logger.debug('Finished re-indexing all assets');
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(`Unable to index all assets`, error?.stack);
|
this.logger.error(`Unable to index all assets`, error?.stack);
|
||||||
}
|
}
|
||||||
@ -94,6 +99,9 @@ export class SearchService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { asset } = data;
|
const { asset } = data;
|
||||||
|
if (!asset.isVisible) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.searchRepository.index(SearchCollection.ASSETS, asset);
|
await this.searchRepository.index(SearchCollection.ASSETS, asset);
|
||||||
@ -111,6 +119,7 @@ export class SearchService {
|
|||||||
const albums = await this.albumRepository.getAll();
|
const albums = await this.albumRepository.getAll();
|
||||||
this.logger.log(`Indexing ${albums.length} albums`);
|
this.logger.log(`Indexing ${albums.length} albums`);
|
||||||
await this.searchRepository.import(SearchCollection.ALBUMS, albums, true);
|
await this.searchRepository.import(SearchCollection.ALBUMS, albums, true);
|
||||||
|
this.logger.debug('Finished re-indexing all albums');
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.error(`Unable to index all albums`, error?.stack);
|
this.logger.error(`Unable to index all albums`, error?.stack);
|
||||||
}
|
}
|
||||||
@ -151,4 +160,10 @@ export class SearchService {
|
|||||||
this.logger.error(`Unable to remove ${collection}: ${id}`, error?.stack);
|
this.logger.error(`Unable to remove ${collection}: ${id}`, error?.stack);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private assertEnabled() {
|
||||||
|
if (!this.enabled) {
|
||||||
|
throw new BadRequestException('Search is disabled');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -8,5 +8,6 @@ export const newSearchRepositoryMock = (): jest.Mocked<ISearchRepository> => {
|
|||||||
import: jest.fn(),
|
import: jest.fn(),
|
||||||
search: jest.fn(),
|
search: jest.fn(),
|
||||||
delete: jest.fn(),
|
delete: jest.fn(),
|
||||||
|
explore: jest.fn(),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { CollectionCreateSchema } from 'typesense/lib/Typesense/Collections';
|
import { CollectionCreateSchema } from 'typesense/lib/Typesense/Collections';
|
||||||
|
|
||||||
export const assetSchemaVersion = 1;
|
export const assetSchemaVersion = 2;
|
||||||
export const assetSchema: CollectionCreateSchema = {
|
export const assetSchema: CollectionCreateSchema = {
|
||||||
name: `assets-v${assetSchemaVersion}`,
|
name: `assets-v${assetSchemaVersion}`,
|
||||||
fields: [
|
fields: [
|
||||||
@ -22,7 +22,6 @@ export const assetSchema: CollectionCreateSchema = {
|
|||||||
{ name: 'exifInfo.state', type: 'string', facet: true, optional: true },
|
{ name: 'exifInfo.state', type: 'string', facet: true, optional: true },
|
||||||
{ name: 'exifInfo.description', type: 'string', facet: false, optional: true },
|
{ name: 'exifInfo.description', type: 'string', facet: false, optional: true },
|
||||||
{ name: 'exifInfo.imageName', type: 'string', facet: false, optional: true },
|
{ name: 'exifInfo.imageName', type: 'string', facet: false, optional: true },
|
||||||
{ name: 'geo', type: 'geopoint', facet: false, optional: true },
|
|
||||||
{ name: 'exifInfo.make', type: 'string', facet: true, optional: true },
|
{ name: 'exifInfo.make', type: 'string', facet: true, optional: true },
|
||||||
{ name: 'exifInfo.model', type: 'string', facet: true, optional: true },
|
{ name: 'exifInfo.model', type: 'string', facet: true, optional: true },
|
||||||
{ name: 'exifInfo.orientation', type: 'string', optional: true },
|
{ name: 'exifInfo.orientation', type: 'string', optional: true },
|
||||||
@ -30,6 +29,10 @@ export const assetSchema: CollectionCreateSchema = {
|
|||||||
// smart info
|
// smart info
|
||||||
{ name: 'smartInfo.objects', type: 'string[]', facet: true, optional: true },
|
{ name: 'smartInfo.objects', type: 'string[]', facet: true, optional: true },
|
||||||
{ name: 'smartInfo.tags', type: 'string[]', facet: true, optional: true },
|
{ name: 'smartInfo.tags', type: 'string[]', facet: true, optional: true },
|
||||||
|
|
||||||
|
// computed
|
||||||
|
{ name: 'geo', type: 'geopoint', facet: false, optional: true },
|
||||||
|
{ name: 'motion', type: 'bool', facet: true },
|
||||||
],
|
],
|
||||||
token_separators: ['.'],
|
token_separators: ['.'],
|
||||||
enable_nested_fields: true,
|
enable_nested_fields: true,
|
||||||
|
@ -2,11 +2,13 @@ import {
|
|||||||
ISearchRepository,
|
ISearchRepository,
|
||||||
SearchCollection,
|
SearchCollection,
|
||||||
SearchCollectionIndexStatus,
|
SearchCollectionIndexStatus,
|
||||||
|
SearchExploreItem,
|
||||||
SearchFilter,
|
SearchFilter,
|
||||||
SearchResult,
|
SearchResult,
|
||||||
} from '@app/domain';
|
} from '@app/domain';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import _, { Dictionary } from 'lodash';
|
import _, { Dictionary } from 'lodash';
|
||||||
|
import { filter, firstValueFrom, from, map, mergeMap, toArray } from 'rxjs';
|
||||||
import { Client } from 'typesense';
|
import { Client } from 'typesense';
|
||||||
import { CollectionCreateSchema } from 'typesense/lib/Typesense/Collections';
|
import { CollectionCreateSchema } from 'typesense/lib/Typesense/Collections';
|
||||||
import { DocumentSchema, SearchResponse } from 'typesense/lib/Typesense/Documents';
|
import { DocumentSchema, SearchResponse } from 'typesense/lib/Typesense/Documents';
|
||||||
@ -14,8 +16,9 @@ import { AlbumEntity, AssetEntity } from '../db';
|
|||||||
import { albumSchema } from './schemas/album.schema';
|
import { albumSchema } from './schemas/album.schema';
|
||||||
import { assetSchema } from './schemas/asset.schema';
|
import { assetSchema } from './schemas/asset.schema';
|
||||||
|
|
||||||
interface GeoAssetEntity extends AssetEntity {
|
interface CustomAssetEntity extends AssetEntity {
|
||||||
geo?: [number, number];
|
geo?: [number, number];
|
||||||
|
motion?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeNil<T extends Dictionary<any>>(item: T): Partial<T> {
|
function removeNil<T extends Dictionary<any>>(item: T): Partial<T> {
|
||||||
@ -85,6 +88,12 @@ export class TypesenseRepository implements ISearchRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async setup(): Promise<void> {
|
async setup(): Promise<void> {
|
||||||
|
const collections = await this.client.collections().retrieve();
|
||||||
|
for (const collection of collections) {
|
||||||
|
this.logger.debug(`${collection.name} => ${collection.num_documents}`);
|
||||||
|
// await this.client.collections(collection.name).delete();
|
||||||
|
}
|
||||||
|
|
||||||
// upsert collections
|
// upsert collections
|
||||||
for (const [collectionName, schema] of schemas) {
|
for (const [collectionName, schema] of schemas) {
|
||||||
const collection = await this.client
|
const collection = await this.client
|
||||||
@ -172,6 +181,59 @@ export class TypesenseRepository implements ISearchRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async explore(userId: string): Promise<SearchExploreItem<AssetEntity>[]> {
|
||||||
|
const alias = await this.client.aliases(SearchCollection.ASSETS).retrieve();
|
||||||
|
|
||||||
|
const common = {
|
||||||
|
q: '*',
|
||||||
|
filter_by: `ownerId:${userId}`,
|
||||||
|
per_page: 100,
|
||||||
|
};
|
||||||
|
|
||||||
|
const asset$ = this.client.collections<AssetEntity>(alias.collection_name).documents();
|
||||||
|
|
||||||
|
const { facet_counts: facets } = await asset$.search({
|
||||||
|
...common,
|
||||||
|
query_by: 'exifInfo.imageName',
|
||||||
|
facet_by: this.getFacetFieldNames(SearchCollection.ASSETS),
|
||||||
|
max_facet_values: 50,
|
||||||
|
});
|
||||||
|
|
||||||
|
return firstValueFrom(
|
||||||
|
from(facets || []).pipe(
|
||||||
|
mergeMap(
|
||||||
|
(facet) =>
|
||||||
|
from(facet.counts).pipe(
|
||||||
|
mergeMap(
|
||||||
|
(count) =>
|
||||||
|
from(
|
||||||
|
asset$.search({
|
||||||
|
...common,
|
||||||
|
query_by: 'exifInfo.imageName',
|
||||||
|
filter_by: `${facet.field_name}:${count.value}`,
|
||||||
|
}),
|
||||||
|
).pipe(
|
||||||
|
map((result) => ({
|
||||||
|
value: count.value,
|
||||||
|
data: result.hits?.[0]?.document as AssetEntity,
|
||||||
|
})),
|
||||||
|
filter((item) => !!item.data),
|
||||||
|
),
|
||||||
|
5,
|
||||||
|
),
|
||||||
|
toArray(),
|
||||||
|
map((items) => ({
|
||||||
|
fieldName: facet.field_name as string,
|
||||||
|
items,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
3,
|
||||||
|
),
|
||||||
|
toArray(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
search(collection: SearchCollection.ASSETS, query: string, filter: SearchFilter): Promise<SearchResult<AssetEntity>>;
|
search(collection: SearchCollection.ASSETS, query: string, filter: SearchFilter): Promise<SearchResult<AssetEntity>>;
|
||||||
search(collection: SearchCollection.ALBUMS, query: string, filter: SearchFilter): Promise<SearchResult<AlbumEntity>>;
|
search(collection: SearchCollection.ALBUMS, query: string, filter: SearchFilter): Promise<SearchResult<AlbumEntity>>;
|
||||||
async search(collection: SearchCollection, query: string, filters: SearchFilter) {
|
async search(collection: SearchCollection, query: string, filters: SearchFilter) {
|
||||||
@ -213,10 +275,8 @@ export class TypesenseRepository implements ISearchRepository {
|
|||||||
].join(','),
|
].join(','),
|
||||||
filter_by: _filters.join(' && '),
|
filter_by: _filters.join(' && '),
|
||||||
per_page: 250,
|
per_page: 250,
|
||||||
facet_by: (assetSchema.fields || [])
|
sort_by: filters.recent ? 'createdAt:desc' : undefined,
|
||||||
.filter((field) => field.facet)
|
facet_by: this.getFacetFieldNames(SearchCollection.ASSETS),
|
||||||
.map((field) => field.name)
|
|
||||||
.join(','),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.asResponse(results);
|
return this.asResponse(results);
|
||||||
@ -313,13 +373,24 @@ export class TypesenseRepository implements ISearchRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private patchAsset(asset: AssetEntity): GeoAssetEntity {
|
private patchAsset(asset: AssetEntity): CustomAssetEntity {
|
||||||
|
let custom = asset as CustomAssetEntity;
|
||||||
|
|
||||||
const lat = asset.exifInfo?.latitude;
|
const lat = asset.exifInfo?.latitude;
|
||||||
const lng = asset.exifInfo?.longitude;
|
const lng = asset.exifInfo?.longitude;
|
||||||
if (lat && lng && lat !== 0 && lng !== 0) {
|
if (lat && lng && lat !== 0 && lng !== 0) {
|
||||||
return { ...asset, geo: [lat, lng] };
|
custom = { ...custom, geo: [lat, lng] };
|
||||||
}
|
}
|
||||||
|
|
||||||
return asset;
|
custom = { ...custom, motion: !!asset.livePhotoVideoId };
|
||||||
|
|
||||||
|
return custom;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getFacetFieldNames(collection: SearchCollection) {
|
||||||
|
return (schemaMap[collection].fields || [])
|
||||||
|
.filter((field) => field.facet)
|
||||||
|
.map((field) => field.name)
|
||||||
|
.join(',');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
130
web/src/api/open-api/api.ts
generated
130
web/src/api/open-api/api.ts
generated
@ -1539,6 +1539,44 @@ export interface SearchConfigResponseDto {
|
|||||||
*/
|
*/
|
||||||
'enabled': boolean;
|
'enabled': boolean;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @interface SearchExploreItem
|
||||||
|
*/
|
||||||
|
export interface SearchExploreItem {
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {string}
|
||||||
|
* @memberof SearchExploreItem
|
||||||
|
*/
|
||||||
|
'value': string;
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {AssetResponseDto}
|
||||||
|
* @memberof SearchExploreItem
|
||||||
|
*/
|
||||||
|
'data': AssetResponseDto;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @export
|
||||||
|
* @interface SearchExploreResponseDto
|
||||||
|
*/
|
||||||
|
export interface SearchExploreResponseDto {
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {string}
|
||||||
|
* @memberof SearchExploreResponseDto
|
||||||
|
*/
|
||||||
|
'fieldName': string;
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {Array<SearchExploreItem>}
|
||||||
|
* @memberof SearchExploreResponseDto
|
||||||
|
*/
|
||||||
|
'items': Array<SearchExploreItem>;
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @export
|
* @export
|
||||||
@ -6629,6 +6667,41 @@ export class OAuthApi extends BaseAPI {
|
|||||||
*/
|
*/
|
||||||
export const SearchApiAxiosParamCreator = function (configuration?: Configuration) {
|
export const SearchApiAxiosParamCreator = function (configuration?: Configuration) {
|
||||||
return {
|
return {
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
getExploreData: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||||
|
const localVarPath = `/search/explore`;
|
||||||
|
// 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 bearer required
|
||||||
|
// http bearer authentication required
|
||||||
|
await setBearerAuthToObject(localVarHeaderParameter, configuration)
|
||||||
|
|
||||||
|
// authentication cookie required
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||||
|
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
||||||
|
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: toPathString(localVarUrlObj),
|
||||||
|
options: localVarRequestOptions,
|
||||||
|
};
|
||||||
|
},
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {*} [options] Override http request option.
|
* @param {*} [options] Override http request option.
|
||||||
@ -6676,10 +6749,12 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio
|
|||||||
* @param {string} [exifInfoModel]
|
* @param {string} [exifInfoModel]
|
||||||
* @param {Array<string>} [smartInfoObjects]
|
* @param {Array<string>} [smartInfoObjects]
|
||||||
* @param {Array<string>} [smartInfoTags]
|
* @param {Array<string>} [smartInfoTags]
|
||||||
|
* @param {boolean} [recent]
|
||||||
|
* @param {boolean} [motion]
|
||||||
* @param {*} [options] Override http request option.
|
* @param {*} [options] Override http request option.
|
||||||
* @throws {RequiredError}
|
* @throws {RequiredError}
|
||||||
*/
|
*/
|
||||||
search: async (query?: string, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array<string>, smartInfoTags?: Array<string>, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
|
search: async (query?: string, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array<string>, smartInfoTags?: Array<string>, recent?: boolean, motion?: boolean, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||||
const localVarPath = `/search`;
|
const localVarPath = `/search`;
|
||||||
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||||
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
||||||
@ -6738,6 +6813,14 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio
|
|||||||
localVarQueryParameter['smartInfo.tags'] = smartInfoTags;
|
localVarQueryParameter['smartInfo.tags'] = smartInfoTags;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (recent !== undefined) {
|
||||||
|
localVarQueryParameter['recent'] = recent;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (motion !== undefined) {
|
||||||
|
localVarQueryParameter['motion'] = motion;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||||
@ -6759,6 +6842,15 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio
|
|||||||
export const SearchApiFp = function(configuration?: Configuration) {
|
export const SearchApiFp = function(configuration?: Configuration) {
|
||||||
const localVarAxiosParamCreator = SearchApiAxiosParamCreator(configuration)
|
const localVarAxiosParamCreator = SearchApiAxiosParamCreator(configuration)
|
||||||
return {
|
return {
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
async getExploreData(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<SearchExploreResponseDto>>> {
|
||||||
|
const localVarAxiosArgs = await localVarAxiosParamCreator.getExploreData(options);
|
||||||
|
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
|
||||||
|
},
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {*} [options] Override http request option.
|
* @param {*} [options] Override http request option.
|
||||||
@ -6780,11 +6872,13 @@ export const SearchApiFp = function(configuration?: Configuration) {
|
|||||||
* @param {string} [exifInfoModel]
|
* @param {string} [exifInfoModel]
|
||||||
* @param {Array<string>} [smartInfoObjects]
|
* @param {Array<string>} [smartInfoObjects]
|
||||||
* @param {Array<string>} [smartInfoTags]
|
* @param {Array<string>} [smartInfoTags]
|
||||||
|
* @param {boolean} [recent]
|
||||||
|
* @param {boolean} [motion]
|
||||||
* @param {*} [options] Override http request option.
|
* @param {*} [options] Override http request option.
|
||||||
* @throws {RequiredError}
|
* @throws {RequiredError}
|
||||||
*/
|
*/
|
||||||
async search(query?: string, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array<string>, smartInfoTags?: Array<string>, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<SearchResponseDto>> {
|
async search(query?: string, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array<string>, smartInfoTags?: Array<string>, recent?: boolean, motion?: boolean, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<SearchResponseDto>> {
|
||||||
const localVarAxiosArgs = await localVarAxiosParamCreator.search(query, type, isFavorite, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, smartInfoObjects, smartInfoTags, options);
|
const localVarAxiosArgs = await localVarAxiosParamCreator.search(query, type, isFavorite, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, smartInfoObjects, smartInfoTags, recent, motion, options);
|
||||||
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
|
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@ -6797,6 +6891,14 @@ export const SearchApiFp = function(configuration?: Configuration) {
|
|||||||
export const SearchApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
|
export const SearchApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
|
||||||
const localVarFp = SearchApiFp(configuration)
|
const localVarFp = SearchApiFp(configuration)
|
||||||
return {
|
return {
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
getExploreData(options?: any): AxiosPromise<Array<SearchExploreResponseDto>> {
|
||||||
|
return localVarFp.getExploreData(options).then((request) => request(axios, basePath));
|
||||||
|
},
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {*} [options] Override http request option.
|
* @param {*} [options] Override http request option.
|
||||||
@ -6817,11 +6919,13 @@ export const SearchApiFactory = function (configuration?: Configuration, basePat
|
|||||||
* @param {string} [exifInfoModel]
|
* @param {string} [exifInfoModel]
|
||||||
* @param {Array<string>} [smartInfoObjects]
|
* @param {Array<string>} [smartInfoObjects]
|
||||||
* @param {Array<string>} [smartInfoTags]
|
* @param {Array<string>} [smartInfoTags]
|
||||||
|
* @param {boolean} [recent]
|
||||||
|
* @param {boolean} [motion]
|
||||||
* @param {*} [options] Override http request option.
|
* @param {*} [options] Override http request option.
|
||||||
* @throws {RequiredError}
|
* @throws {RequiredError}
|
||||||
*/
|
*/
|
||||||
search(query?: string, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array<string>, smartInfoTags?: Array<string>, options?: any): AxiosPromise<SearchResponseDto> {
|
search(query?: string, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array<string>, smartInfoTags?: Array<string>, recent?: boolean, motion?: boolean, options?: any): AxiosPromise<SearchResponseDto> {
|
||||||
return localVarFp.search(query, type, isFavorite, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, smartInfoObjects, smartInfoTags, options).then((request) => request(axios, basePath));
|
return localVarFp.search(query, type, isFavorite, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, smartInfoObjects, smartInfoTags, recent, motion, options).then((request) => request(axios, basePath));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@ -6833,6 +6937,16 @@ export const SearchApiFactory = function (configuration?: Configuration, basePat
|
|||||||
* @extends {BaseAPI}
|
* @extends {BaseAPI}
|
||||||
*/
|
*/
|
||||||
export class SearchApi extends BaseAPI {
|
export class SearchApi extends BaseAPI {
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
* @memberof SearchApi
|
||||||
|
*/
|
||||||
|
public getExploreData(options?: AxiosRequestConfig) {
|
||||||
|
return SearchApiFp(this.configuration).getExploreData(options).then((request) => request(this.axios, this.basePath));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {*} [options] Override http request option.
|
* @param {*} [options] Override http request option.
|
||||||
@ -6855,12 +6969,14 @@ export class SearchApi extends BaseAPI {
|
|||||||
* @param {string} [exifInfoModel]
|
* @param {string} [exifInfoModel]
|
||||||
* @param {Array<string>} [smartInfoObjects]
|
* @param {Array<string>} [smartInfoObjects]
|
||||||
* @param {Array<string>} [smartInfoTags]
|
* @param {Array<string>} [smartInfoTags]
|
||||||
|
* @param {boolean} [recent]
|
||||||
|
* @param {boolean} [motion]
|
||||||
* @param {*} [options] Override http request option.
|
* @param {*} [options] Override http request option.
|
||||||
* @throws {RequiredError}
|
* @throws {RequiredError}
|
||||||
* @memberof SearchApi
|
* @memberof SearchApi
|
||||||
*/
|
*/
|
||||||
public search(query?: string, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array<string>, smartInfoTags?: Array<string>, options?: AxiosRequestConfig) {
|
public search(query?: string, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array<string>, smartInfoTags?: Array<string>, recent?: boolean, motion?: boolean, options?: AxiosRequestConfig) {
|
||||||
return SearchApiFp(this.configuration).search(query, type, isFavorite, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, smartInfoObjects, smartInfoTags, options).then((request) => request(this.axios, this.basePath));
|
return SearchApiFp(this.configuration).search(query, type, isFavorite, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, smartInfoObjects, smartInfoTags, recent, motion, options).then((request) => request(this.axios, this.basePath));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -19,6 +19,7 @@
|
|||||||
export let format: ThumbnailFormat = ThumbnailFormat.Webp;
|
export let format: ThumbnailFormat = ThumbnailFormat.Webp;
|
||||||
export let selected = false;
|
export let selected = false;
|
||||||
export let disabled = false;
|
export let disabled = false;
|
||||||
|
export let readonly = false;
|
||||||
export let publicSharedKey = '';
|
export let publicSharedKey = '';
|
||||||
export let isRoundedCorner = false;
|
export let isRoundedCorner = false;
|
||||||
|
|
||||||
@ -56,6 +57,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const parseVideoDuration = (duration: string) => {
|
const parseVideoDuration = (duration: string) => {
|
||||||
|
duration = duration || '0:00:00.00000';
|
||||||
const timePart = duration.split(':');
|
const timePart = duration.split(':');
|
||||||
const hours = timePart[0];
|
const hours = timePart[0];
|
||||||
const minutes = timePart[1];
|
const minutes = timePart[1];
|
||||||
@ -118,7 +120,7 @@
|
|||||||
} else if (disabled) {
|
} else if (disabled) {
|
||||||
return 'border-[20px] border-gray-300';
|
return 'border-[20px] border-gray-300';
|
||||||
} else if (isRoundedCorner) {
|
} else if (isRoundedCorner) {
|
||||||
return 'rounded-[20px]';
|
return 'rounded-lg';
|
||||||
} else {
|
} else {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@ -157,7 +159,7 @@
|
|||||||
on:click={thumbnailClickedHandler}
|
on:click={thumbnailClickedHandler}
|
||||||
on:keydown={thumbnailClickedHandler}
|
on:keydown={thumbnailClickedHandler}
|
||||||
>
|
>
|
||||||
{#if mouseOver || selected || disabled}
|
{#if (mouseOver || selected || disabled) && !readonly}
|
||||||
<div
|
<div
|
||||||
in:fade={{ duration: 200 }}
|
in:fade={{ duration: 200 }}
|
||||||
class={`w-full ${getOverlaySelectorIconStyle()} via-white/0 to-white/0 absolute p-2 z-10`}
|
class={`w-full ${getOverlaySelectorIconStyle()} via-white/0 to-white/0 absolute p-2 z-10`}
|
||||||
|
@ -4,6 +4,7 @@
|
|||||||
import AccountMultipleOutline from 'svelte-material-icons/AccountMultipleOutline.svelte';
|
import AccountMultipleOutline from 'svelte-material-icons/AccountMultipleOutline.svelte';
|
||||||
import ImageAlbum from 'svelte-material-icons/ImageAlbum.svelte';
|
import ImageAlbum from 'svelte-material-icons/ImageAlbum.svelte';
|
||||||
import ImageOutline from 'svelte-material-icons/ImageOutline.svelte';
|
import ImageOutline from 'svelte-material-icons/ImageOutline.svelte';
|
||||||
|
import Magnify from 'svelte-material-icons/Magnify.svelte';
|
||||||
import StarOutline from 'svelte-material-icons/StarOutline.svelte';
|
import StarOutline from 'svelte-material-icons/StarOutline.svelte';
|
||||||
import { AppRoute } from '../../../constants';
|
import { AppRoute } from '../../../constants';
|
||||||
import LoadingSpinner from '../loading-spinner.svelte';
|
import LoadingSpinner from '../loading-spinner.svelte';
|
||||||
@ -62,6 +63,18 @@
|
|||||||
</svelte:fragment>
|
</svelte:fragment>
|
||||||
</SideBarButton>
|
</SideBarButton>
|
||||||
</a>
|
</a>
|
||||||
|
<a
|
||||||
|
data-sveltekit-preload-data="hover"
|
||||||
|
data-sveltekit-noscroll
|
||||||
|
href={AppRoute.EXPLORE}
|
||||||
|
draggable="false"
|
||||||
|
>
|
||||||
|
<SideBarButton
|
||||||
|
title="Explore"
|
||||||
|
logo={Magnify}
|
||||||
|
isSelected={$page.route.id === '/(user)/explore'}
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
<a data-sveltekit-preload-data="hover" href={AppRoute.SHARING} draggable="false">
|
<a data-sveltekit-preload-data="hover" href={AppRoute.SHARING} draggable="false">
|
||||||
<SideBarButton
|
<SideBarButton
|
||||||
title="Sharing"
|
title="Sharing"
|
||||||
|
@ -10,7 +10,7 @@ export enum AppRoute {
|
|||||||
ALBUMS = '/albums',
|
ALBUMS = '/albums',
|
||||||
FAVORITES = '/favorites',
|
FAVORITES = '/favorites',
|
||||||
PHOTOS = '/photos',
|
PHOTOS = '/photos',
|
||||||
|
EXPLORE = '/explore',
|
||||||
SHARING = '/sharing',
|
SHARING = '/sharing',
|
||||||
|
|
||||||
AUTH_LOGIN = '/auth/login'
|
AUTH_LOGIN = '/auth/login'
|
||||||
}
|
}
|
||||||
|
13
web/src/routes/(user)/explore/+page.server.ts
Normal file
13
web/src/routes/(user)/explore/+page.server.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load = (async ({ locals, parent }) => {
|
||||||
|
const { user } = await parent();
|
||||||
|
if (!user) {
|
||||||
|
throw redirect(302, '/auth/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: items } = await locals.api.searchApi.getExploreData();
|
||||||
|
|
||||||
|
return { user, items };
|
||||||
|
}) satisfies PageServerLoad;
|
173
web/src/routes/(user)/explore/+page.svelte
Normal file
173
web/src/routes/(user)/explore/+page.svelte
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ImmichThumbnail from '$lib/components/shared-components/immich-thumbnail.svelte';
|
||||||
|
import NavigationBar from '$lib/components/shared-components/navigation-bar/navigation-bar.svelte';
|
||||||
|
import SideBar from '$lib/components/shared-components/side-bar/side-bar.svelte';
|
||||||
|
import { AppRoute } from '$lib/constants';
|
||||||
|
import { AssetTypeEnum, SearchExploreItem } from '@api';
|
||||||
|
import ClockOutline from 'svelte-material-icons/ClockOutline.svelte';
|
||||||
|
import MotionPlayOutline from 'svelte-material-icons/MotionPlayOutline.svelte';
|
||||||
|
import PlayCircleOutline from 'svelte-material-icons/PlayCircleOutline.svelte';
|
||||||
|
import StarOutline from 'svelte-material-icons/StarOutline.svelte';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
|
export let data: PageData;
|
||||||
|
|
||||||
|
enum Field {
|
||||||
|
CITY = 'exifInfo.city',
|
||||||
|
TAGS = 'smartInfo.tags',
|
||||||
|
OBJECTS = 'smartInfo.objects'
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_ITEMS = 12;
|
||||||
|
|
||||||
|
let things: SearchExploreItem[] = [];
|
||||||
|
let places: SearchExploreItem[] = [];
|
||||||
|
|
||||||
|
for (const item of data.items) {
|
||||||
|
switch (item.fieldName) {
|
||||||
|
case Field.OBJECTS:
|
||||||
|
things = item.items;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case Field.CITY:
|
||||||
|
places = item.items;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
things = things.slice(0, MAX_ITEMS);
|
||||||
|
places = places.slice(0, MAX_ITEMS);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<NavigationBar user={data.user} shouldShowUploadButton={false} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
class="grid grid-cols-[250px_auto] relative pt-[72px] h-screen bg-immich-bg dark:bg-immich-dark-bg"
|
||||||
|
>
|
||||||
|
<SideBar />
|
||||||
|
|
||||||
|
<section class="overflow-y-auto relative immich-scrollbar">
|
||||||
|
<section
|
||||||
|
id="album-content"
|
||||||
|
class="relative pt-8 pl-4 mb-12 bg-immich-bg dark:bg-immich-dark-bg"
|
||||||
|
>
|
||||||
|
<!-- Main Section -->
|
||||||
|
<div class="px-4 flex justify-between place-items-center dark:text-immich-dark-fg">
|
||||||
|
<div>
|
||||||
|
<p class="font-medium">Explore</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="my-4">
|
||||||
|
<hr class="dark:border-immich-dark-gray" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mx-4 flex flex-col">
|
||||||
|
{#if places.length > 0}
|
||||||
|
<div class="mb-6 mt-2">
|
||||||
|
<div>
|
||||||
|
<p class="mb-4 dark:text-immich-dark-fg font-medium">Places</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-row flex-wrap gap-4">
|
||||||
|
{#each places as item}
|
||||||
|
<a class="relative" href="/search?{Field.CITY}={item.value}" draggable="false">
|
||||||
|
<div class="filter brightness-75 rounded-xl overflow-hidden">
|
||||||
|
<ImmichThumbnail
|
||||||
|
isRoundedCorner={true}
|
||||||
|
thumbnailSize={156}
|
||||||
|
asset={item.data}
|
||||||
|
readonly={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="capitalize absolute bottom-2 w-full text-center text-sm font-medium text-white text-ellipsis w-100 px-1 hover:cursor-pointer backdrop-blur-[1px]"
|
||||||
|
>
|
||||||
|
{item.value}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if things.length > 0}
|
||||||
|
<div class="mb-6 mt-2">
|
||||||
|
<div>
|
||||||
|
<p class="mb-4 dark:text-immich-dark-fg font-medium">Things</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-row flex-wrap gap-4">
|
||||||
|
{#each things as item}
|
||||||
|
<a class="relative" href="/search?{Field.OBJECTS}={item.value}" draggable="false">
|
||||||
|
<div class="filter brightness-75 rounded-xl overflow-hidden">
|
||||||
|
<ImmichThumbnail
|
||||||
|
isRoundedCorner={true}
|
||||||
|
thumbnailSize={156}
|
||||||
|
asset={item.data}
|
||||||
|
readonly={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="capitalize absolute bottom-2 w-full text-center text-sm font-medium text-white text-ellipsis w-100 px-1 hover:cursor-pointer backdrop-blur-[1px]"
|
||||||
|
>
|
||||||
|
{item.value}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<hr class="dark:border-immich-dark-gray mb-4" />
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-8"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-6 dark:text-immich-dark-fg">
|
||||||
|
<p class="text-sm">YOUR ACTIVITY</p>
|
||||||
|
<div class="flex flex-col gap-4 dark:text-immich-dark-fg/80">
|
||||||
|
<a
|
||||||
|
href={AppRoute.FAVORITES}
|
||||||
|
class="w-full flex text-sm font-medium hover:text-immich-primary dark:hover:text-immich-dark-primary content-center gap-2"
|
||||||
|
draggable="false"
|
||||||
|
>
|
||||||
|
<StarOutline size={24} />
|
||||||
|
<span>Favorites</span>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/search?recent=true"
|
||||||
|
class="w-full flex text-sm font-medium hover:text-immich-primary dark:hover:text-immich-dark-primary content-center gap-2"
|
||||||
|
draggable="false"
|
||||||
|
>
|
||||||
|
<ClockOutline size={24} />
|
||||||
|
<span>Recently added</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-6 dark:text-immich-dark-fg">
|
||||||
|
<p class="text-sm">CATEGORIES</p>
|
||||||
|
<div class="flex flex-col gap-4 dark:text-immich-dark-fg/80">
|
||||||
|
<a
|
||||||
|
href="/search?type={AssetTypeEnum.Video}"
|
||||||
|
class="w-full flex text-sm font-medium hover:text-immich-primary dark:hover:text-immich-dark-primary items-center gap-2"
|
||||||
|
>
|
||||||
|
<PlayCircleOutline size={24} />
|
||||||
|
<span>Videos</span>
|
||||||
|
</a>
|
||||||
|
<div>
|
||||||
|
<a
|
||||||
|
href="/search?motion=true"
|
||||||
|
class="w-full flex text-sm font-medium hover:text-immich-primary dark:hover:text-immich-dark-primary items-center gap-2"
|
||||||
|
>
|
||||||
|
<MotionPlayOutline size={24} />
|
||||||
|
<span>Motion photos</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</section>
|
@ -8,7 +8,6 @@ export const load = (async ({ locals, parent, url }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const term = url.searchParams.get('q') || undefined;
|
const term = url.searchParams.get('q') || undefined;
|
||||||
|
|
||||||
const { data: results } = await locals.api.searchApi.search(
|
const { data: results } = await locals.api.searchApi.search(
|
||||||
term,
|
term,
|
||||||
undefined,
|
undefined,
|
||||||
@ -20,6 +19,8 @@ export const load = (async ({ locals, parent, url }) => {
|
|||||||
undefined,
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
{ params: url.searchParams }
|
{ params: url.searchParams }
|
||||||
);
|
);
|
||||||
return { user, term, results };
|
return { user, term, results };
|
||||||
|
@ -1,16 +1,34 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
|
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
|
||||||
import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte';
|
import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte';
|
||||||
import NavigationBar from '$lib/components/shared-components/navigation-bar/navigation-bar.svelte';
|
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
|
import ArrowLeft from 'svelte-material-icons/ArrowLeft.svelte';
|
||||||
|
import ImageOffOutline from 'svelte-material-icons/ImageOffOutline.svelte';
|
||||||
|
import { afterNavigate, goto } from '$app/navigation';
|
||||||
|
|
||||||
export let data: PageData;
|
export let data: PageData;
|
||||||
|
const term = $page.url.searchParams.get('q') || data.term || '';
|
||||||
|
|
||||||
const term = $page.url.searchParams.get('q') || '';
|
let goBackRoute = '/explore';
|
||||||
|
afterNavigate((r) => {
|
||||||
|
if (r.from) {
|
||||||
|
goBackRoute = r.from.url.href;
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<NavigationBar {term} user={data.user} shouldShowUploadButton={false} />
|
<ControlAppBar on:close-button-click={() => goto(goBackRoute)} backIcon={ArrowLeft}>
|
||||||
|
<svelte:fragment slot="leading">
|
||||||
|
<p class="text-xl capitalize">
|
||||||
|
Search
|
||||||
|
{#if term}
|
||||||
|
- {term}
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</svelte:fragment>
|
||||||
|
</ControlAppBar>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="relative pt-[72px] h-screen bg-immich-bg dark:bg-immich-dark-bg">
|
<section class="relative pt-[72px] h-screen bg-immich-bg dark:bg-immich-dark-bg">
|
||||||
@ -19,8 +37,16 @@
|
|||||||
id="search-content"
|
id="search-content"
|
||||||
class="relative pt-8 pl-4 mb-12 bg-immich-bg dark:bg-immich-dark-bg"
|
class="relative pt-8 pl-4 mb-12 bg-immich-bg dark:bg-immich-dark-bg"
|
||||||
>
|
>
|
||||||
{#if data.results?.assets?.items}
|
{#if data.results?.assets?.items.length != 0}
|
||||||
<GalleryViewer assets={data.results.assets.items} />
|
<GalleryViewer assets={data.results.assets.items} />
|
||||||
|
{:else}
|
||||||
|
<div class="w-full text-center dark:text-white ">
|
||||||
|
<div class="mt-60 flex flex-col place-content-center place-items-center">
|
||||||
|
<ImageOffOutline size="56" />
|
||||||
|
<p class="font-medium text-3xl mt-5">No results</p>
|
||||||
|
<p class="text-base font-normal">Try a synonym or more general keyword</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
|
Loading…
Reference in New Issue
Block a user