1
0
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:
Jason Rasmussen 2023-03-05 15:44:31 -05:00 committed by GitHub
parent 1f631eafce
commit 2ca560ebf8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 608 additions and 57 deletions

View File

@ -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

Binary file not shown.

Binary file not shown.

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

View File

@ -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[]>;
}
} }

View File

@ -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');

View File

@ -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": [

View File

@ -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);
} }
} }

View File

@ -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 },
});
}); });
}); });
}); });

View File

@ -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) {

View File

@ -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;
} }

View File

@ -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';

View File

@ -0,0 +1,11 @@
import { AssetResponseDto } from '../../asset';
class SearchExploreItem {
value!: string;
data!: AssetResponseDto;
}
export class SearchExploreResponseDto {
fieldName!: string;
items!: SearchExploreItem[];
}

View File

@ -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>[]>;
} }

View File

@ -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');
}
}
} }

View File

@ -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(),
}; };
}; };

View File

@ -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,

View File

@ -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(',');
} }
} }

View File

@ -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));
} }
} }

View File

@ -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`}

View File

@ -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"

View File

@ -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'
} }

View 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;

View 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>

View File

@ -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 };

View File

@ -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>