From 2ca560ebf80aadf1f7904bd88d7447d9a2a59ed3 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Sun, 5 Mar 2023 15:44:31 -0500 Subject: [PATCH] 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 Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> --- mobile/openapi/.openapi-generator/FILES | 6 + mobile/openapi/README.md | Bin 14911 -> 15123 bytes mobile/openapi/doc/SearchApi.md | Bin 4826 -> 6577 bytes mobile/openapi/doc/SearchExploreItem.md | Bin 0 -> 473 bytes .../openapi/doc/SearchExploreResponseDto.md | Bin 0 -> 514 bytes mobile/openapi/lib/api.dart | Bin 4993 -> 5079 bytes mobile/openapi/lib/api/search_api.dart | Bin 6113 -> 7985 bytes mobile/openapi/lib/api_client.dart | Bin 16846 -> 17032 bytes .../lib/model/search_explore_item.dart | Bin 0 -> 3577 bytes .../model/search_explore_response_dto.dart | Bin 0 -> 3791 bytes mobile/openapi/test/search_api_test.dart | Bin 976 -> 1151 bytes .../test/search_explore_item_test.dart | Bin 0 -> 672 bytes .../search_explore_response_dto_test.dart | Bin 0 -> 736 bytes .../src/controllers/search.controller.ts | 16 +- .../metadata-extraction.processor.ts | 6 +- server/immich-openapi-specs.json | 84 ++++++++- server/libs/domain/src/asset/asset.core.ts | 12 +- .../domain/src/asset/asset.service.spec.ts | 11 +- server/libs/domain/src/asset/asset.service.ts | 4 +- .../libs/domain/src/search/dto/search.dto.ts | 10 + .../domain/src/search/response-dto/index.ts | 1 + .../search-explore.response.dto.ts | 11 ++ .../domain/src/search/search.repository.ts | 12 ++ .../libs/domain/src/search/search.service.ts | 23 ++- .../domain/test/search.repository.mock.ts | 1 + .../infra/src/search/schemas/asset.schema.ts | 7 +- .../infra/src/search/typesense.repository.ts | 87 ++++++++- web/src/api/open-api/api.ts | 130 ++++++++++++- .../shared-components/immich-thumbnail.svelte | 6 +- .../side-bar/side-bar.svelte | 13 ++ web/src/lib/constants.ts | 2 +- web/src/routes/(user)/explore/+page.server.ts | 13 ++ web/src/routes/(user)/explore/+page.svelte | 173 ++++++++++++++++++ web/src/routes/(user)/search/+page.server.ts | 3 +- web/src/routes/(user)/search/+page.svelte | 34 +++- 35 files changed, 608 insertions(+), 57 deletions(-) create mode 100644 mobile/openapi/doc/SearchExploreItem.md create mode 100644 mobile/openapi/doc/SearchExploreResponseDto.md create mode 100644 mobile/openapi/lib/model/search_explore_item.dart create mode 100644 mobile/openapi/lib/model/search_explore_response_dto.dart create mode 100644 mobile/openapi/test/search_explore_item_test.dart create mode 100644 mobile/openapi/test/search_explore_response_dto_test.dart create mode 100644 server/libs/domain/src/search/response-dto/search-explore.response.dto.ts create mode 100644 web/src/routes/(user)/explore/+page.server.ts create mode 100644 web/src/routes/(user)/explore/+page.svelte diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index 7a37ef8886..f01b4603b8 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -66,6 +66,8 @@ doc/SearchApi.md doc/SearchAssetDto.md doc/SearchAssetResponseDto.md doc/SearchConfigResponseDto.md +doc/SearchExploreItem.md +doc/SearchExploreResponseDto.md doc/SearchFacetCountResponseDto.md doc/SearchFacetResponseDto.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_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_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_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_response_dto_test.dart test/search_response_dto_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 4717cf9704c9fc1709c9f8405f25a1b7891934e1..98eabb4abc0e488e0a113e9ce0fa675729a910d5 100644 GIT binary patch delta 162 zcmdmAGP!KSQWC^C}+jU?EiswNkh XicZcq=9(O8EHF97h<9^<@q0D^2iZDE delta 29 lcmbPSw!dVK6y@~Pl2nLv3Q$^8p+-SV z%iT3ZOG`n&7_3eoDh-sGczL27idkHC3O<>|B{mQfp;AGq#Rd6!#i=eO`F09GrMHFhC#%PWI+k TglY5Umz!L}D!n;Su#*u0Quu#; delta 39 xcmV+?0NDSrGukDP+LI{(inEOeod&b<0sR8AHxWGole81tv!4_o1G5bnqybg=510S| diff --git a/mobile/openapi/doc/SearchExploreItem.md b/mobile/openapi/doc/SearchExploreItem.md new file mode 100644 index 0000000000000000000000000000000000000000..75eaabd8b1aa2d387a0b25da9d94331ae441f9d9 GIT binary patch literal 473 zcma)2L2AP=5WMRZ0v{3!9=zLFbgnoFyTnAw?G zb_$Ljobsl!s9~xOpu%`=-~EIE)&!QZqTtV{G^aE0S;V~>U)2fNFbrA+V#az5#1~io z;TpNhv01Qq%+fh&7t%10fylPX0{`SMA3lE##4#9823@cad=@CBQIiv-%JMI) zv_3fOY);Wy)LQR2I)5C1U=cXRJ`K3mdg&5B+_bQe@uSww_!nFCb|MyXYl{bS?iiS$ zmC6|ErctV{pQWQru5!E3iFwnEt7^KPKh0KiF(@px=N>(q{O9sOu^ie>6ZqbK+ARLL VD_xi9Z6N3Q#^NjRNBBf3^$GZdqAvgd literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 3f0c9efe45d5670befaf369cf8f6f97d2584e602..d70ad049936a6182942ad2f835930b577d69a775 100644 GIT binary patch delta 41 rcmZovzplO^otG!Iq97-~C^bH_BsF*PM;>W*sK8`FUX9J^yi#lcO_UC` delta 16 Xcmcbv-l)DIop+@y*Rlix~BNGK))Wf>RTV zk~5&ngHnqN^7D#QT}txp?CliNQ%jJ|@ng|MHg|I~%WFo_w9LH39EBpNMyLFgN(Ebm z$%Wzy0@hp#U{I7=Qd*R!pfNc>T(KTmp^kz&ekZ7FDkK&wfYfM0%~DX%OHRb+Y7Hcn zCHWw&8k*KzVBNJqa`Ioch|Mn;RoEvx2@10nr6#B5l}xr16lcrLFUic$+swgf%qW$V zpPyq7^b$x?M*+fv%3E^nXXOL=Dl<(%1FF4&&s-hCRM&y((gfNGQU$USq86fmGEgl) xgb7p&HS-<+4kjGVStMkL*Etu3XE8#pw1PVrVyqR^cpZh!6Ik?_CeIRg2LM6%voQbw delta 48 zcmV-00MGxiKH)F0`~kDX2M`Cd4i7H@0ezF*2Til55N`#u(iW})v$`2A0kZ=em;tl9 G9CiZCoDt0c diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 676e09dd6f64495610d65fb934eecd55f697dee9..3b2399f23e031da887b8b8373d516e9e41747d8a 100644 GIT binary patch delta 87 zcmX@t%-GS&xS>mr%eA5)C%-7wvm|x$Mg>v9;MByTmLvx~lqFaRuF1x5e> diff --git a/mobile/openapi/lib/model/search_explore_item.dart b/mobile/openapi/lib/model/search_explore_item.dart new file mode 100644 index 0000000000000000000000000000000000000000..f7529496c5da1652b4319dfb44ad44b2535430dc GIT binary patch literal 3577 zcmds4U2oeq6n*!vxG92K!4!MV)8Nc*gT)!rj~GbP0)t@)v_wa2Wl|%ldPeI1zI#bg zRw8$6p9ZWz5{#6!u+-u9%@X!MoA_BMER$3Zh zAo&L79m53(S76Eq=+W7ZC=Nh4IoMp7!53CkJU+0IG0SLucY>8+G^>TY2WRdUE{x&! zmYWhm;g?ncIk=ry>I>HzF+#VcQU#q-GpjU=$I)#0iKlpWn{#~v;|X{yQ&vP&PaJl> ze)YeBIE2Xa=dNOJ;?ID=eRc47lggsKgXIZn;htN_naO`s8OMTL;D&C*GRaeJ4AWa; z2{u0GMi^%nIXDKv`viXS%am)s+5x4;(klN;~fwp^cJTajH_7)yh zPb(s)3q&3}BF&GH7MRSytJi?A#<$bJN~?7p%tL}aU!g&tlrTpW7BR@GK%Nn)Ho`di zw^~?gzCsN;`RXb~CYppenxLAkND~x(t}<$=U+7Bbr7OZATO;j@3ov6no(n`*tSGDl zJZgZaOUjJxv?B5{BP6l%sWg@5*aT+r2ZuE)0n4`x1@0Ca5r5`eLu#)bZ-8gQK7)NBSmJlEAe#@&WkS2FoynZJ+uKG z;aOKO$GEeNe;$d+Yo$T9Q?{`iWH4$8%5s~2|P3_t0Zd>x2Nt1{`iy>(E%QB3MORf6N+4WWTBD2C~}v@MPi`J+0v z+EB~LH6$gh{HE{dxH|~;pke8)=I#eTO{8{AqCXvEz_QGbiI%5_mj))Ke*|{zWfh$( z3Sf;O{N|##L0fkMCr>k2x(y-X9BfCZ)t{YaP3x}c#tFAZ8+pw9kHEVQjeTRjHUDVv z`A6Oen)g_OO2tKb;rTlkTeNQ?uIg=QBFhFL$~^-#JeBu>CsH#kWL3! zK}HfaB7_2kZ&^}CJ%0I)h|bX=lGk%{qb<#!};_# D+^Toe literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/search_explore_response_dto.dart b/mobile/openapi/lib/model/search_explore_response_dto.dart new file mode 100644 index 0000000000000000000000000000000000000000..812aceecf7bd2dc9b79e9f545c49fb3e69887ef1 GIT binary patch literal 3791 zcmds4U2oeq6n*!vxG92K!4zff(~#C~i^Uz1wlR=q0|vto7@3aQ%92Xb&5Shvedk_E zvfRY&)|UYbkl3Q`7tcNSkU2W)9UalD-!I39zm3jE7q2cyC-nOC?I@;`F`bVu>D~C` z^!1;6Fk{P?GiCkYY4YT0j~~r-QCgkOOPkJ3u8(O|tel>uI@e2G__%pC%c`{gK@XMR zsI^P0l~1kyv@#a%g<9for7S+ZwhqCqd)PfI3+GkgQ!_(CGdJ3byL&KMZVK(vyezI^ z<`=3+fB8Ah7RtI_59vIWb1ExcsLCYxeb(z`xpI!)Yh|;==&vfrb}zK6kX4`fl5TrM zM*!kN`;{#mDJt|0O?KH&$w;}GPT;u9D&nX_4IJbb#wFNUK05~>vAmQuYYDrOYa!2tkApH;rnj^?&p z)-fltgvpu1pwA;V0W8y)id7DNG8u1-lY#1Vy0UtX8JEehs|*ZwDRVo8T6mMC5SB$b z<*&$m}l4q0er;iFzVmXX@=y!4u$9E(kjA~U@2DO>35qn;6Dnrk;Rfe^H+e1V?(bbF%jFdisb^0l_3Xdp|x; z`oNhpg`ZBo2|Ll=Kp6F}b3rUv_L+<|4~qNwSe2X1_3(JR(#_&sbg&^o{eIUTqc%`G z%E!!-KX`J+^UHOmYl;mcMl+`F&>!rD^Mi|#fj-`5{wWTGB*!`N8n_ATIR>b%Xa%7jtow&fM+%28Xe(JWVxjO`IfAZjd84j!=sC?*?sR&wx4WE?2SDf!fQLyuGM_?wFcy5=Hd7 zZVx1@Ris^%8#2FILUeo7lpK&?t?HaXJHs`HQnwwb%Zxenp+BLiSkseFHL^#pu*mrsj;3mOQq-YS7q)~^a#m(nd z!{ORa9JY@~mLox{4EMDg;}=-sIph27uC5#gb9mRMFfJN!;|o7W1=Sv446|(eM%f|$ zP84{|4_e>UGp-euKyQvMIQ#hq&r?C>jYr+PEr@qa+NBf7*zg{x$KHRnB_)O=&X zJ!ZMWt1C^olsVMT{-0yRZN?Cv%;FN8;MByTSZK0RY=sFdhH^ delta 16 Ycmey*ae;lqZN|+7Oe+~D8?*QT06y~uPyhe` diff --git a/mobile/openapi/test/search_explore_item_test.dart b/mobile/openapi/test/search_explore_item_test.dart new file mode 100644 index 0000000000000000000000000000000000000000..d4fae1dbffdfd414a775a4c84548d508039c934e GIT binary patch literal 672 zcma)&K~DlP5QXpiE5;L;Agi1V#6*%^42iOd;K5U!><&xgZd={GnmaE^8sW9EQ%F871@0DdcjyA?<=(aZZy0ZC83nH z(w4+pTd|f6jiFPWqe@UiEh#@Z*0MF)`m+jbK5^%S>AbM?(Mij4g)N^3E&n}P$L7|Z zmzP>OkID;KaiCmFvQh5NhBZ==6OC3iV}655++K@vgVrU+zmB5nFmYe*`G`ys_r#9JYSwV|Z^)KN&0bqPB6*eH_6eYpk zsj7cDNtmsz?o7WI>`$5A0Ddi_>C#)NYS?1a5lbO9#abON;5P)u@_^Rw;4M;b2M2UJ sRmRqWirZTiSrHxfhc4pd)H(9c1KDkJ5i6~cXzU>P-T2{IdL)~f$i5_>F8Eq*l<7_7|UZYjtzNmVQA!gY&BTPKjf3Twi=gFei zxtzUm>dE0N2vg*IEYE<^bnGL@O5l<()a&xz8(Go?MK4*YV3*xG-9V4pPG|5h0}xiL zEgH~in3~||P4aYhmNjbqYK(CG@H)s-*I;4V7M=>&V~#8-2mlAA^<}VcJlOgJHNuGsq8< Vz90O)p1qqU@jsSCv{Isn>=DuP@YMhS literal 0 HcmV?d00001 diff --git a/server/apps/immich/src/controllers/search.controller.ts b/server/apps/immich/src/controllers/search.controller.ts index 7f67927cf4..2c2248c3fc 100644 --- a/server/apps/immich/src/controllers/search.controller.ts +++ b/server/apps/immich/src/controllers/search.controller.ts @@ -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 { ApiTags } from '@nestjs/swagger'; import { GetAuthUser } from '../decorators/auth-user.decorator'; @@ -10,7 +17,6 @@ import { Authenticated } from '../decorators/authenticated.decorator'; export class SearchController { constructor(private readonly searchService: SearchService) {} - @Authenticated() @Get() async search( @GetAuthUser() authUser: AuthUserDto, @@ -19,9 +25,13 @@ export class SearchController { return this.searchService.search(authUser, dto); } - @Authenticated() @Get('config') getSearchConfig(): SearchConfigResponseDto { return this.searchService.getConfig(); } + + @Get('explore') + getExploreData(@GetAuthUser() authUser: AuthUserDto): Promise { + return this.searchService.getExploreData(authUser) as Promise; + } } diff --git a/server/apps/microservices/src/processors/metadata-extraction.processor.ts b/server/apps/microservices/src/processors/metadata-extraction.processor.ts index 7fb1fc9da5..5bc8c4e786 100644 --- a/server/apps/microservices/src/processors/metadata-extraction.processor.ts +++ b/server/apps/microservices/src/processors/metadata-extraction.processor.ts @@ -2,8 +2,8 @@ import { AssetCore, IAssetRepository, IAssetUploadedJob, + IJobRepository, IReverseGeocodingJob, - ISearchRepository, JobName, QueueName, } from '@app/domain'; @@ -86,14 +86,14 @@ export class MetadataExtractionProcessor { constructor( @Inject(IAssetRepository) assetRepository: IAssetRepository, - @Inject(ISearchRepository) searchRepository: ISearchRepository, + @Inject(IJobRepository) jobRepository: IJobRepository, @InjectRepository(ExifEntity) private exifRepository: Repository, configService: ConfigService, ) { - this.assetCore = new AssetCore(assetRepository, searchRepository); + this.assetCore = new AssetCore(assetRepository, jobRepository); if (!configService.get('DISABLE_REVERSE_GEOCODING')) { this.logger.log('Initializing Reverse Geocoding'); diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index fdf2ac31ca..2c21d6214a 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -640,6 +640,22 @@ "type": "string" } } + }, + { + "name": "recent", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "motion", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } } ], "responses": { @@ -658,12 +674,6 @@ "Search" ], "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, { "bearer": [] }, @@ -699,7 +709,34 @@ }, { "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": [] }, @@ -4149,6 +4186,39 @@ "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": { "type": "string", "enum": [ diff --git a/server/libs/domain/src/asset/asset.core.ts b/server/libs/domain/src/asset/asset.core.ts index e923f29d95..46b4231ff4 100644 --- a/server/libs/domain/src/asset/asset.core.ts +++ b/server/libs/domain/src/asset/asset.core.ts @@ -1,21 +1,21 @@ 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'; export class AssetCore { - constructor(private repository: IAssetRepository, private searchRepository: ISearchRepository) {} + constructor(private assetRepository: IAssetRepository, private jobRepository: IJobRepository) {} getAll(options: AssetSearchOptions) { - return this.repository.getAll(options); + return this.assetRepository.getAll(options); } async save(asset: Partial) { - const _asset = await this.repository.save(asset); - await this.searchRepository.index(SearchCollection.ASSETS, _asset); + const _asset = await this.assetRepository.save(asset); + await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { asset: _asset } }); return _asset; } findLivePhotoMatch(livePhotoCID: string, otherAssetId: string, type: AssetType): Promise { - return this.repository.findLivePhotoMatch(livePhotoCID, otherAssetId, type); + return this.assetRepository.findLivePhotoMatch(livePhotoCID, otherAssetId, type); } } diff --git a/server/libs/domain/src/asset/asset.service.spec.ts b/server/libs/domain/src/asset/asset.service.spec.ts index bff4efa20c..536a0c148c 100644 --- a/server/libs/domain/src/asset/asset.service.spec.ts +++ b/server/libs/domain/src/asset/asset.service.spec.ts @@ -1,15 +1,12 @@ import { AssetEntity, AssetType } from '@app/infra/db/entities'; import { assetEntityStub, newAssetRepositoryMock, newJobRepositoryMock } from '../../test'; -import { newSearchRepositoryMock } from '../../test/search.repository.mock'; import { AssetService, IAssetRepository } from '../asset'; import { IJobRepository, JobName } from '../job'; -import { ISearchRepository } from '../search'; describe(AssetService.name, () => { let sut: AssetService; let assetMock: jest.Mocked; let jobMock: jest.Mocked; - let searchMock: jest.Mocked; it('should work', () => { expect(sut).toBeDefined(); @@ -18,8 +15,7 @@ describe(AssetService.name, () => { beforeEach(async () => { assetMock = newAssetRepositoryMock(); jobMock = newJobRepositoryMock(); - searchMock = newSearchRepositoryMock(); - sut = new AssetService(assetMock, jobMock, searchMock); + sut = new AssetService(assetMock, jobMock); }); describe(`handle asset upload`, () => { @@ -56,7 +52,10 @@ describe(AssetService.name, () => { await sut.save(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 }, + }); }); }); }); diff --git a/server/libs/domain/src/asset/asset.service.ts b/server/libs/domain/src/asset/asset.service.ts index 06e8c7aa96..22d6b4dc48 100644 --- a/server/libs/domain/src/asset/asset.service.ts +++ b/server/libs/domain/src/asset/asset.service.ts @@ -1,7 +1,6 @@ import { AssetEntity, AssetType } from '@app/infra/db/entities'; import { Inject } from '@nestjs/common'; import { IAssetUploadedJob, IJobRepository, JobName } from '../job'; -import { ISearchRepository } from '../search'; import { AssetCore } from './asset.core'; import { IAssetRepository } from './asset.repository'; @@ -11,9 +10,8 @@ export class AssetService { constructor( @Inject(IAssetRepository) assetRepository: IAssetRepository, @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) { diff --git a/server/libs/domain/src/search/dto/search.dto.ts b/server/libs/domain/src/search/dto/search.dto.ts index c080ff5eac..1610e2e713 100644 --- a/server/libs/domain/src/search/dto/search.dto.ts +++ b/server/libs/domain/src/search/dto/search.dto.ts @@ -54,4 +54,14 @@ export class SearchDto { @IsOptional() @Transform(({ value }) => value.split(',')) 'smartInfo.tags'?: string[]; + + @IsBoolean() + @IsOptional() + @Transform(toBoolean) + recent?: boolean; + + @IsBoolean() + @IsOptional() + @Transform(toBoolean) + motion?: boolean; } diff --git a/server/libs/domain/src/search/response-dto/index.ts b/server/libs/domain/src/search/response-dto/index.ts index e55378686d..e74cc29b37 100644 --- a/server/libs/domain/src/search/response-dto/index.ts +++ b/server/libs/domain/src/search/response-dto/index.ts @@ -1,2 +1,3 @@ export * from './search-config-response.dto'; +export * from './search-explore.response.dto'; export * from './search-response.dto'; diff --git a/server/libs/domain/src/search/response-dto/search-explore.response.dto.ts b/server/libs/domain/src/search/response-dto/search-explore.response.dto.ts new file mode 100644 index 0000000000..37398d9dec --- /dev/null +++ b/server/libs/domain/src/search/response-dto/search-explore.response.dto.ts @@ -0,0 +1,11 @@ +import { AssetResponseDto } from '../../asset'; + +class SearchExploreItem { + value!: string; + data!: AssetResponseDto; +} + +export class SearchExploreResponseDto { + fieldName!: string; + items!: SearchExploreItem[]; +} diff --git a/server/libs/domain/src/search/search.repository.ts b/server/libs/domain/src/search/search.repository.ts index f288578502..4508b14514 100644 --- a/server/libs/domain/src/search/search.repository.ts +++ b/server/libs/domain/src/search/search.repository.ts @@ -17,6 +17,8 @@ export interface SearchFilter { model?: string; objects?: string[]; tags?: string[]; + recent?: boolean; + motion?: boolean; } export interface SearchResult { @@ -39,6 +41,14 @@ export interface SearchFacet { }>; } +export interface SearchExploreItem { + fieldName: string; + items: Array<{ + value: string; + data: T; + }>; +} + export type SearchCollectionIndexStatus = Record; export const ISearchRepository = 'ISearchRepository'; @@ -57,4 +67,6 @@ export interface ISearchRepository { search(collection: SearchCollection.ASSETS, query: string, filters: SearchFilter): Promise>; search(collection: SearchCollection.ALBUMS, query: string, filters: SearchFilter): Promise>; + + explore(userId: string): Promise[]>; } diff --git a/server/libs/domain/src/search/search.service.ts b/server/libs/domain/src/search/search.service.ts index 322644167b..f350e19b45 100644 --- a/server/libs/domain/src/search/search.service.ts +++ b/server/libs/domain/src/search/search.service.ts @@ -1,3 +1,4 @@ +import { AssetEntity } from '@app/infra/db/entities'; import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { IAlbumRepository } from '../album/album.repository'; @@ -6,7 +7,7 @@ import { AuthUserDto } from '../auth'; import { IAlbumJob, IAssetJob, IDeleteJob, IJobRepository, JobName } from '../job'; import { SearchDto } from './dto'; import { SearchConfigResponseDto, SearchResponseDto } from './response-dto'; -import { ISearchRepository, SearchCollection } from './search.repository'; +import { ISearchRepository, SearchCollection, SearchExploreItem } from './search.repository'; @Injectable() export class SearchService { @@ -52,10 +53,13 @@ export class SearchService { } } + async getExploreData(authUser: AuthUserDto): Promise[]> { + this.assertEnabled(); + return this.searchRepository.explore(authUser.id); + } + async search(authUser: AuthUserDto, dto: SearchDto): Promise { - if (!this.enabled) { - throw new BadRequestException('Search is disabled'); - } + this.assertEnabled(); const query = dto.query || '*'; @@ -83,6 +87,7 @@ export class SearchService { this.logger.log(`Indexing ${assets.length} assets`); await this.searchRepository.import(SearchCollection.ASSETS, assets, true); + this.logger.debug('Finished re-indexing all assets'); } catch (error: any) { this.logger.error(`Unable to index all assets`, error?.stack); } @@ -94,6 +99,9 @@ export class SearchService { } const { asset } = data; + if (!asset.isVisible) { + return; + } try { await this.searchRepository.index(SearchCollection.ASSETS, asset); @@ -111,6 +119,7 @@ export class SearchService { const albums = await this.albumRepository.getAll(); this.logger.log(`Indexing ${albums.length} albums`); await this.searchRepository.import(SearchCollection.ALBUMS, albums, true); + this.logger.debug('Finished re-indexing all albums'); } catch (error: any) { 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); } } + + private assertEnabled() { + if (!this.enabled) { + throw new BadRequestException('Search is disabled'); + } + } } diff --git a/server/libs/domain/test/search.repository.mock.ts b/server/libs/domain/test/search.repository.mock.ts index b1918f3933..0ba2dd4f9c 100644 --- a/server/libs/domain/test/search.repository.mock.ts +++ b/server/libs/domain/test/search.repository.mock.ts @@ -8,5 +8,6 @@ export const newSearchRepositoryMock = (): jest.Mocked => { import: jest.fn(), search: jest.fn(), delete: jest.fn(), + explore: jest.fn(), }; }; diff --git a/server/libs/infra/src/search/schemas/asset.schema.ts b/server/libs/infra/src/search/schemas/asset.schema.ts index 962f4e9b2a..d379048c97 100644 --- a/server/libs/infra/src/search/schemas/asset.schema.ts +++ b/server/libs/infra/src/search/schemas/asset.schema.ts @@ -1,6 +1,6 @@ import { CollectionCreateSchema } from 'typesense/lib/Typesense/Collections'; -export const assetSchemaVersion = 1; +export const assetSchemaVersion = 2; export const assetSchema: CollectionCreateSchema = { name: `assets-v${assetSchemaVersion}`, fields: [ @@ -22,7 +22,6 @@ export const assetSchema: CollectionCreateSchema = { { name: 'exifInfo.state', type: 'string', facet: true, optional: true }, { name: 'exifInfo.description', 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.model', type: 'string', facet: true, optional: true }, { name: 'exifInfo.orientation', type: 'string', optional: true }, @@ -30,6 +29,10 @@ export const assetSchema: CollectionCreateSchema = { // smart info { name: 'smartInfo.objects', 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: ['.'], enable_nested_fields: true, diff --git a/server/libs/infra/src/search/typesense.repository.ts b/server/libs/infra/src/search/typesense.repository.ts index b24da06546..a656d4b24e 100644 --- a/server/libs/infra/src/search/typesense.repository.ts +++ b/server/libs/infra/src/search/typesense.repository.ts @@ -2,11 +2,13 @@ import { ISearchRepository, SearchCollection, SearchCollectionIndexStatus, + SearchExploreItem, SearchFilter, SearchResult, } from '@app/domain'; import { Injectable, Logger } from '@nestjs/common'; import _, { Dictionary } from 'lodash'; +import { filter, firstValueFrom, from, map, mergeMap, toArray } from 'rxjs'; import { Client } from 'typesense'; import { CollectionCreateSchema } from 'typesense/lib/Typesense/Collections'; import { DocumentSchema, SearchResponse } from 'typesense/lib/Typesense/Documents'; @@ -14,8 +16,9 @@ import { AlbumEntity, AssetEntity } from '../db'; import { albumSchema } from './schemas/album.schema'; import { assetSchema } from './schemas/asset.schema'; -interface GeoAssetEntity extends AssetEntity { +interface CustomAssetEntity extends AssetEntity { geo?: [number, number]; + motion?: boolean; } function removeNil>(item: T): Partial { @@ -85,6 +88,12 @@ export class TypesenseRepository implements ISearchRepository { } async setup(): Promise { + 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 for (const [collectionName, schema] of schemas) { const collection = await this.client @@ -172,6 +181,59 @@ export class TypesenseRepository implements ISearchRepository { } } + async explore(userId: string): Promise[]> { + const alias = await this.client.aliases(SearchCollection.ASSETS).retrieve(); + + const common = { + q: '*', + filter_by: `ownerId:${userId}`, + per_page: 100, + }; + + const asset$ = this.client.collections(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>; search(collection: SearchCollection.ALBUMS, query: string, filter: SearchFilter): Promise>; async search(collection: SearchCollection, query: string, filters: SearchFilter) { @@ -213,10 +275,8 @@ export class TypesenseRepository implements ISearchRepository { ].join(','), filter_by: _filters.join(' && '), per_page: 250, - facet_by: (assetSchema.fields || []) - .filter((field) => field.facet) - .map((field) => field.name) - .join(','), + sort_by: filters.recent ? 'createdAt:desc' : undefined, + facet_by: this.getFacetFieldNames(SearchCollection.ASSETS), }); 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 lng = asset.exifInfo?.longitude; 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(','); } } diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 586edb6df6..69a66a5679 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -1539,6 +1539,44 @@ export interface SearchConfigResponseDto { */ '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} + * @memberof SearchExploreResponseDto + */ + 'items': Array; +} /** * * @export @@ -6629,6 +6667,41 @@ export class OAuthApi extends BaseAPI { */ export const SearchApiAxiosParamCreator = function (configuration?: Configuration) { return { + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getExploreData: async (options: AxiosRequestConfig = {}): Promise => { + 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. @@ -6676,10 +6749,12 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio * @param {string} [exifInfoModel] * @param {Array} [smartInfoObjects] * @param {Array} [smartInfoTags] + * @param {boolean} [recent] + * @param {boolean} [motion] * @param {*} [options] Override http request option. * @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, smartInfoTags?: Array, options: AxiosRequestConfig = {}): Promise => { + search: async (query?: string, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array, smartInfoTags?: Array, recent?: boolean, motion?: boolean, options: AxiosRequestConfig = {}): Promise => { const localVarPath = `/search`; // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); @@ -6738,6 +6813,14 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio localVarQueryParameter['smartInfo.tags'] = smartInfoTags; } + if (recent !== undefined) { + localVarQueryParameter['recent'] = recent; + } + + if (motion !== undefined) { + localVarQueryParameter['motion'] = motion; + } + setSearchParams(localVarUrlObj, localVarQueryParameter); @@ -6759,6 +6842,15 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio export const SearchApiFp = function(configuration?: Configuration) { const localVarAxiosParamCreator = SearchApiAxiosParamCreator(configuration) return { + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getExploreData(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getExploreData(options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @param {*} [options] Override http request option. @@ -6780,11 +6872,13 @@ export const SearchApiFp = function(configuration?: Configuration) { * @param {string} [exifInfoModel] * @param {Array} [smartInfoObjects] * @param {Array} [smartInfoTags] + * @param {boolean} [recent] + * @param {boolean} [motion] * @param {*} [options] Override http request option. * @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, smartInfoTags?: Array, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.search(query, type, isFavorite, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, smartInfoObjects, smartInfoTags, options); + async search(query?: string, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array, smartInfoTags?: Array, recent?: boolean, motion?: boolean, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + 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); }, } @@ -6797,6 +6891,14 @@ export const SearchApiFp = function(configuration?: Configuration) { export const SearchApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { const localVarFp = SearchApiFp(configuration) return { + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getExploreData(options?: any): AxiosPromise> { + return localVarFp.getExploreData(options).then((request) => request(axios, basePath)); + }, /** * * @param {*} [options] Override http request option. @@ -6817,11 +6919,13 @@ export const SearchApiFactory = function (configuration?: Configuration, basePat * @param {string} [exifInfoModel] * @param {Array} [smartInfoObjects] * @param {Array} [smartInfoTags] + * @param {boolean} [recent] + * @param {boolean} [motion] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - search(query?: string, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array, smartInfoTags?: Array, options?: any): AxiosPromise { - return localVarFp.search(query, type, isFavorite, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, smartInfoObjects, smartInfoTags, options).then((request) => request(axios, basePath)); + search(query?: string, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array, smartInfoTags?: Array, recent?: boolean, motion?: boolean, options?: any): AxiosPromise { + 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} */ 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. @@ -6855,12 +6969,14 @@ export class SearchApi extends BaseAPI { * @param {string} [exifInfoModel] * @param {Array} [smartInfoObjects] * @param {Array} [smartInfoTags] + * @param {boolean} [recent] + * @param {boolean} [motion] * @param {*} [options] Override http request option. * @throws {RequiredError} * @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, smartInfoTags?: Array, 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)); + public search(query?: string, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array, smartInfoTags?: Array, recent?: boolean, motion?: boolean, options?: AxiosRequestConfig) { + 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)); } } diff --git a/web/src/lib/components/shared-components/immich-thumbnail.svelte b/web/src/lib/components/shared-components/immich-thumbnail.svelte index f2cb9b4ea5..5ef9ab6270 100644 --- a/web/src/lib/components/shared-components/immich-thumbnail.svelte +++ b/web/src/lib/components/shared-components/immich-thumbnail.svelte @@ -19,6 +19,7 @@ export let format: ThumbnailFormat = ThumbnailFormat.Webp; export let selected = false; export let disabled = false; + export let readonly = false; export let publicSharedKey = ''; export let isRoundedCorner = false; @@ -56,6 +57,7 @@ }; const parseVideoDuration = (duration: string) => { + duration = duration || '0:00:00.00000'; const timePart = duration.split(':'); const hours = timePart[0]; const minutes = timePart[1]; @@ -118,7 +120,7 @@ } else if (disabled) { return 'border-[20px] border-gray-300'; } else if (isRoundedCorner) { - return 'rounded-[20px]'; + return 'rounded-lg'; } else { return ''; } @@ -157,7 +159,7 @@ on:click={thumbnailClickedHandler} on:keydown={thumbnailClickedHandler} > - {#if mouseOver || selected || disabled} + {#if (mouseOver || selected || disabled) && !readonly}
+ + + { + const { user } = await parent(); + if (!user) { + throw redirect(302, '/auth/login'); + } + + const { data: items } = await locals.api.searchApi.getExploreData(); + + return { user, items }; +}) satisfies PageServerLoad; diff --git a/web/src/routes/(user)/explore/+page.svelte b/web/src/routes/(user)/explore/+page.svelte new file mode 100644 index 0000000000..c9cf4a48b9 --- /dev/null +++ b/web/src/routes/(user)/explore/+page.svelte @@ -0,0 +1,173 @@ + + +
+ +
+ +
+ + +
+
+ +
+
+

Explore

+
+
+ +
+
+
+ +
+ {#if places.length > 0} + + {/if} + + {#if things.length > 0} +
+
+

Things

+
+
+ {#each things as item} + +
+ +
+ + {item.value} + +
+ {/each} +
+
+ {/if} + +
+ +
+ +
+

CATEGORIES

+ +
+
+
+
+
+
diff --git a/web/src/routes/(user)/search/+page.server.ts b/web/src/routes/(user)/search/+page.server.ts index 26eefac329..1abb5294dc 100644 --- a/web/src/routes/(user)/search/+page.server.ts +++ b/web/src/routes/(user)/search/+page.server.ts @@ -8,7 +8,6 @@ export const load = (async ({ locals, parent, url }) => { } const term = url.searchParams.get('q') || undefined; - const { data: results } = await locals.api.searchApi.search( term, undefined, @@ -20,6 +19,8 @@ export const load = (async ({ locals, parent, url }) => { undefined, undefined, undefined, + undefined, + undefined, { params: url.searchParams } ); return { user, term, results }; diff --git a/web/src/routes/(user)/search/+page.svelte b/web/src/routes/(user)/search/+page.svelte index 6bcf8f9568..8f38109516 100644 --- a/web/src/routes/(user)/search/+page.svelte +++ b/web/src/routes/(user)/search/+page.svelte @@ -1,16 +1,34 @@
- + goto(goBackRoute)} backIcon={ArrowLeft}> + +

+ Search + {#if term} + - {term} + {/if} +

+
+
@@ -19,8 +37,16 @@ id="search-content" 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} + {:else} +
+
+ +

No results

+

Try a synonym or more general keyword

+
+
{/if}