From d3e14fd662cd970f6f985038fdaaabf5a11eefbb Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Wed, 21 Feb 2024 16:50:50 +0100 Subject: [PATCH 01/18] feat(web): search improvements and refactor (#7291) --- .../search-bar/search-bar.svelte | 24 +- .../search-bar/search-filter-box.svelte | 60 ++--- web/src/lib/constants.ts | 1 - web/src/lib/stores/search.store.ts | 2 - web/src/routes/(user)/search/+page.svelte | 248 ++++++++---------- web/src/routes/(user)/search/+page.ts | 27 +- 6 files changed, 151 insertions(+), 211 deletions(-) diff --git a/web/src/lib/components/shared-components/search-bar/search-bar.svelte b/web/src/lib/components/shared-components/search-bar/search-bar.svelte index 7e52b6b7b4..3ca9e35832 100644 --- a/web/src/lib/components/shared-components/search-bar/search-bar.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-bar.svelte @@ -2,12 +2,7 @@ import { AppRoute } from '$lib/constants'; import Icon from '$lib/components/elements/icon.svelte'; import { goto } from '$app/navigation'; - import { - isSearchEnabled, - preventRaceConditionSearchBar, - savedSearchTerms, - searchQuery, - } from '$lib/stores/search.store'; + import { isSearchEnabled, preventRaceConditionSearchBar, savedSearchTerms } from '$lib/stores/search.store'; import { clickOutside } from '$lib/utils/click-outside'; import { mdiClose, mdiMagnify, mdiTune } from '@mdi/js'; import IconButton from '$lib/components/elements/buttons/icon-button.svelte'; @@ -15,8 +10,10 @@ import SearchFilterBox from './search-filter-box.svelte'; import type { MetadataSearchDto, SmartSearchDto } from '@immich/sdk'; import { getMetadataSearchQuery } from '$lib/utils/metadata-search'; + export let value = ''; export let grayTheme: boolean; + export let searchQuery: MetadataSearchDto | SmartSearchDto = {}; let input: HTMLInputElement; @@ -30,8 +27,7 @@ showHistory = false; showFilter = false; $isSearchEnabled = false; - $searchQuery = payload; - goto(`${AppRoute.SEARCH}?${params}`, { invalidateAll: true }); + goto(`${AppRoute.SEARCH}?${params}`); }; const clearSearchTerm = (searchTerm: string) => { @@ -87,11 +83,11 @@ }; -
+
(value = '')} on:submit|preventDefault={onSubmit} @@ -148,9 +144,9 @@ on:selectSearchTerm={({ detail: searchTerm }) => onHistoryTermClick(searchTerm)} /> {/if} - - {#if showFilter} - onSearch(detail)} /> - {/if} + + {#if showFilter} + onSearch(detail)} /> + {/if}
diff --git a/web/src/lib/components/shared-components/search-bar/search-filter-box.svelte b/web/src/lib/components/shared-components/search-bar/search-filter-box.svelte index c433be947f..71b2c9d3a5 100644 --- a/web/src/lib/components/shared-components/search-bar/search-filter-box.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-filter-box.svelte @@ -17,7 +17,6 @@ import { fly } from 'svelte/transition'; import Combobox, { type ComboBoxOption } from '../combobox.svelte'; import { DateTime } from 'luxon'; - import { searchQuery } from '$lib/stores/search.store'; enum MediaType { All = 'all', @@ -44,7 +43,7 @@ type SearchFilter = { context?: string; - people: PersonResponseDto[]; + people: (PersonResponseDto | Pick)[]; location: { country?: ComboBoxOption; @@ -69,6 +68,8 @@ mediaType: MediaType; }; + export let searchQuery: MetadataSearchDto | SmartSearchDto; + let suggestions: SearchSuggestion = { people: [], country: [], @@ -112,19 +113,19 @@ populateExistingFilters(); }); - const showSelectedPeopleFirst = () => { - suggestions.people.sort((a, _) => { + function orderBySelectedPeopleFirst>(people: T[]) { + return people.sort((a, _) => { if (filter.people.some((p) => p.id === a.id)) { return -1; } return 1; }); - }; + } const getPeople = async () => { try { const { people } = await getAllPeople({ withHidden: false }); - suggestions.people = people; + suggestions.people = orderBySelectedPeopleFirst(people); } catch (error) { handleError(error, 'Failed to get people'); } @@ -133,14 +134,12 @@ const handlePeopleSelection = (id: string) => { if (filter.people.some((p) => p.id === id)) { filter.people = filter.people.filter((p) => p.id !== id); - showSelectedPeopleFirst(); return; } const person = suggestions.people.find((p) => p.id === id); if (person) { filter.people = [...filter.people, person]; - showSelectedPeopleFirst(); } }; @@ -280,35 +279,36 @@ }; function populateExistingFilters() { - if ($searchQuery) { + if (searchQuery) { + const personIds = 'personIds' in searchQuery && searchQuery.personIds ? searchQuery.personIds : []; + filter = { - context: 'query' in $searchQuery ? $searchQuery.query : '', - people: - 'personIds' in $searchQuery ? ($searchQuery.personIds?.map((id) => ({ id })) as PersonResponseDto[]) : [], + context: 'query' in searchQuery ? searchQuery.query : '', + people: orderBySelectedPeopleFirst(personIds.map((id) => ({ id }))), location: { - country: $searchQuery.country ? { label: $searchQuery.country, value: $searchQuery.country } : undefined, - state: $searchQuery.state ? { label: $searchQuery.state, value: $searchQuery.state } : undefined, - city: $searchQuery.city ? { label: $searchQuery.city, value: $searchQuery.city } : undefined, + country: searchQuery.country ? { label: searchQuery.country, value: searchQuery.country } : undefined, + state: searchQuery.state ? { label: searchQuery.state, value: searchQuery.state } : undefined, + city: searchQuery.city ? { label: searchQuery.city, value: searchQuery.city } : undefined, }, camera: { - make: $searchQuery.make ? { label: $searchQuery.make, value: $searchQuery.make } : undefined, - model: $searchQuery.model ? { label: $searchQuery.model, value: $searchQuery.model } : undefined, + make: searchQuery.make ? { label: searchQuery.make, value: searchQuery.make } : undefined, + model: searchQuery.model ? { label: searchQuery.model, value: searchQuery.model } : undefined, }, date: { - takenAfter: $searchQuery.takenAfter - ? DateTime.fromISO($searchQuery.takenAfter).toUTC().toFormat('yyyy-MM-dd') + takenAfter: searchQuery.takenAfter + ? DateTime.fromISO(searchQuery.takenAfter).toUTC().toFormat('yyyy-MM-dd') : undefined, - takenBefore: $searchQuery.takenBefore - ? DateTime.fromISO($searchQuery.takenBefore).toUTC().toFormat('yyyy-MM-dd') + takenBefore: searchQuery.takenBefore + ? DateTime.fromISO(searchQuery.takenBefore).toUTC().toFormat('yyyy-MM-dd') : undefined, }, - isArchive: $searchQuery.isArchived, - isFavorite: $searchQuery.isFavorite, - isNotInAlbum: 'isNotInAlbum' in $searchQuery ? $searchQuery.isNotInAlbum : undefined, + isArchive: searchQuery.isArchived, + isFavorite: searchQuery.isFavorite, + isNotInAlbum: 'isNotInAlbum' in searchQuery ? searchQuery.isNotInAlbum : undefined, mediaType: - $searchQuery.type === AssetTypeEnum.Image + searchQuery.type === AssetTypeEnum.Image ? MediaType.Image - : $searchQuery.type === AssetTypeEnum.Video + : searchQuery.type === AssetTypeEnum.Video ? MediaType.Video : MediaType.All, }; @@ -344,7 +344,7 @@ {#each peopleList as person (person.id)} {/each}
@@ -498,7 +498,7 @@
-
+

MEDIA TYPE

diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index 239df2f844..6608550200 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -69,7 +69,6 @@ export enum QueryParameter { PREVIOUS_ROUTE = 'previousRoute', QUERY = 'query', SEARCHED_PEOPLE = 'searchedPeople', - SEARCH_TERM = 'q', SMART_SEARCH = 'smartSearch', PAGE = 'page', } diff --git a/web/src/lib/stores/search.store.ts b/web/src/lib/stores/search.store.ts index ded7dc17ae..41fd287f4c 100644 --- a/web/src/lib/stores/search.store.ts +++ b/web/src/lib/stores/search.store.ts @@ -1,8 +1,6 @@ -import type { MetadataSearchDto, SmartSearchDto } from '@immich/sdk'; import { persisted } from 'svelte-local-storage-store'; import { writable } from 'svelte/store'; export const savedSearchTerms = persisted('search-terms', [], {}); export const isSearchEnabled = writable(false); export const preventRaceConditionSearchBar = writable(false); -export const searchQuery = writable(undefined); diff --git a/web/src/routes/(user)/search/+page.svelte b/web/src/routes/(user)/search/+page.svelte index 354a84bd89..fd84071d32 100644 --- a/web/src/routes/(user)/search/+page.svelte +++ b/web/src/routes/(user)/search/+page.svelte @@ -1,5 +1,4 @@ + +
{#if isMultiSelectionMode}
@@ -252,44 +221,43 @@
goto(previousRoute)} backIcon={mdiArrowLeft}>
- +
{/if}
-{#if terms} -
- {#each Object.keys(terms) as key, index (index)} -
-
- {getHumanReadableSearchKey(key)} -
- - {#if terms[key] !== true} -
- {#if key === 'takenAfter' || key === 'takenBefore'} - {getHumanReadableDate(terms[key])} - {:else if key === 'personIds'} - {#await getPersonName(terms[key]) then personName} - {personName} - {/await} - {:else} - {terms[key]} - {/if} -
- {/if} +
+ {#each getObjectKeys(terms) as key (key)} + {@const value = terms[key]} +
+
+ {getHumanReadableSearchKey(key)}
- {/each} -
-{/if} + + {#if value !== true} +
+ {#if (key === 'takenAfter' || key === 'takenBefore') && typeof value === 'string'} + {getHumanReadableDate(value)} + {:else if key === 'personIds' && Array.isArray(value)} + {#await getPersonName(value) then personName} + {personName} + {/await} + {:else} + {value} + {/if} +
+ {/if} +
+ {/each} +
- {#if albums && albums.length > 0} + {#if searchResultAlbums.length > 0}
ALBUMS
- {#each albums as album, index (album.id)} + {#each searchResultAlbums as album, index (album.id)} {/if}
- {#if searchResultAssets && searchResultAssets.length > 0} + {#if isLoading} +
+ +
+ {:else if searchResultAssets.length > 0} { +export const load = (async () => { await authenticate(); - const url = new URL(data.url.href); - const term = - url.searchParams.get(QueryParameter.SEARCH_TERM) || url.searchParams.get(QueryParameter.QUERY) || undefined; - let results: SearchResponseDto | null = null; - if (term) { - const payload = JSON.parse(term) as SmartSearchDto | MetadataSearchDto; - searchQuery.set(payload); - - results = - payload && 'query' in payload - ? await searchSmart({ smartSearchDto: { ...payload, withExif: true, isVisible: true } }) - : await searchMetadata({ metadataSearchDto: { ...payload, withExif: true, isVisible: true } }); - } - return { - term, - results, meta: { title: 'Search', }, From 173b47033a8d5910c2d46e8ed4252eaf206d27ec Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Wed, 21 Feb 2024 16:52:38 +0100 Subject: [PATCH 02/18] fix(server): search with same face multiple times (#7306) --- server/src/infra/infra.utils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/src/infra/infra.utils.ts b/server/src/infra/infra.utils.ts index 1538958e0f..745f5a38ff 100644 --- a/server/src/infra/infra.utils.ts +++ b/server/src/infra/infra.utils.ts @@ -213,9 +213,9 @@ export function searchAssetBuilder( if (personIds && personIds.length > 0) { builder .leftJoin(`${builder.alias}.faces`, 'faces') - .andWhere('faces.personId IN (:...personIds)', { personIds: personIds }) + .andWhere('faces.personId IN (:...personIds)', { personIds }) .addGroupBy(`${builder.alias}.id`) - .having('COUNT(faces.id) = :personCount', { personCount: personIds.length }); + .having('COUNT(DISTINCT faces.personId) = :personCount', { personCount: personIds.length }); if (withExif) { builder.addGroupBy('exifInfo.assetId'); From 546edc2e9104f28f0846f228ffb86e197e1bdd98 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 21 Feb 2024 16:52:13 -0500 Subject: [PATCH 03/18] refactor: album e2e (#7320) * refactor: album e2e * refactor: user e2e --- .../src}/api/specs/album.e2e-spec.ts | 493 +++++++++++------- e2e/src/api/specs/shared-link.e2e-spec.ts | 62 +-- e2e/src/api/specs/user.e2e-spec.ts | 117 ++--- e2e/src/utils.ts | 14 + 4 files changed, 390 insertions(+), 296 deletions(-) rename {server/e2e => e2e/src}/api/specs/album.e2e-spec.ts (52%) diff --git a/server/e2e/api/specs/album.e2e-spec.ts b/e2e/src/api/specs/album.e2e-spec.ts similarity index 52% rename from server/e2e/api/specs/album.e2e-spec.ts rename to e2e/src/api/specs/album.e2e-spec.ts index 312816035c..c131edc49c 100644 --- a/server/e2e/api/specs/album.e2e-spec.ts +++ b/e2e/src/api/specs/album.e2e-spec.ts @@ -1,11 +1,15 @@ -import { AlbumResponseDto, LoginResponseDto } from '@app/domain'; -import { AlbumController } from '@app/immich'; -import { AssetFileUploadResponseDto } from '@app/immich/api-v1/asset/response-dto/asset-file-upload-response.dto'; -import { SharedLinkType } from '@app/infra/entities'; -import { errorStub, userDto, uuidStub } from '@test/fixtures'; +import { + AlbumResponseDto, + AssetResponseDto, + LoginResponseDto, + SharedLinkType, + deleteUser, +} from '@immich/sdk'; +import { createUserDto, uuidDto } from 'src/fixtures'; +import { errorDto } from 'src/responses'; +import { apiUtils, app, asBearerAuth, dbUtils } from 'src/utils'; import request from 'supertest'; -import { api } from '../../client'; -import { testApp } from '../utils'; +import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; const user1SharedUser = 'user1SharedUser'; const user1SharedLink = 'user1SharedLink'; @@ -14,193 +18,327 @@ const user2SharedUser = 'user2SharedUser'; const user2SharedLink = 'user2SharedLink'; const user2NotShared = 'user2NotShared'; -describe(`${AlbumController.name} (e2e)`, () => { - let server: any; +describe('/album', () => { let admin: LoginResponseDto; let user1: LoginResponseDto; - let user1Asset: AssetFileUploadResponseDto; + let user1Asset1: AssetResponseDto; + let user1Asset2: AssetResponseDto; let user1Albums: AlbumResponseDto[]; let user2: LoginResponseDto; let user2Albums: AlbumResponseDto[]; + let user3: LoginResponseDto; // deleted beforeAll(async () => { - server = (await testApp.create()).getHttpServer(); - }); + apiUtils.setup(); + await dbUtils.reset(); - afterAll(async () => { - await testApp.teardown(); - }); + admin = await apiUtils.adminSetup(); - beforeEach(async () => { - await testApp.reset(); - await api.authApi.adminSignUp(server); - admin = await api.authApi.adminLogin(server); - - await Promise.all([ - api.userApi.create(server, admin.accessToken, userDto.user1), - api.userApi.create(server, admin.accessToken, userDto.user2), + [user1, user2, user3] = await Promise.all([ + apiUtils.userSetup(admin.accessToken, createUserDto.user1), + apiUtils.userSetup(admin.accessToken, createUserDto.user2), + apiUtils.userSetup(admin.accessToken, createUserDto.user3), ]); - [user1, user2] = await Promise.all([ - api.authApi.login(server, userDto.user1), - api.authApi.login(server, userDto.user2), + [user1Asset1, user1Asset2] = await Promise.all([ + apiUtils.createAsset(user1.accessToken), + apiUtils.createAsset(user1.accessToken), ]); - user1Asset = await api.assetApi.upload(server, user1.accessToken, 'example'); - const albums = await Promise.all([ // user 1 - api.albumApi.create(server, user1.accessToken, { + apiUtils.createAlbum(user1.accessToken, { albumName: user1SharedUser, sharedWithUserIds: [user2.userId], - assetIds: [user1Asset.id], + assetIds: [user1Asset1.id], + }), + apiUtils.createAlbum(user1.accessToken, { + albumName: user1SharedLink, + assetIds: [user1Asset1.id], + }), + apiUtils.createAlbum(user1.accessToken, { + albumName: user1NotShared, + assetIds: [user1Asset1.id, user1Asset2.id], }), - api.albumApi.create(server, user1.accessToken, { albumName: user1SharedLink, assetIds: [user1Asset.id] }), - api.albumApi.create(server, user1.accessToken, { albumName: user1NotShared, assetIds: [user1Asset.id] }), // user 2 - api.albumApi.create(server, user2.accessToken, { + apiUtils.createAlbum(user2.accessToken, { albumName: user2SharedUser, sharedWithUserIds: [user1.userId], - assetIds: [user1Asset.id], + assetIds: [user1Asset1.id], + }), + apiUtils.createAlbum(user2.accessToken, { albumName: user2SharedLink }), + apiUtils.createAlbum(user2.accessToken, { albumName: user2NotShared }), + + // user 3 + apiUtils.createAlbum(user3.accessToken, { + albumName: 'Deleted', + sharedWithUserIds: [user1.userId], }), - api.albumApi.create(server, user2.accessToken, { albumName: user2SharedLink }), - api.albumApi.create(server, user2.accessToken, { albumName: user2NotShared }), ]); user1Albums = albums.slice(0, 3); - user2Albums = albums.slice(3); + user2Albums = albums.slice(3, 6); await Promise.all([ // add shared link to user1SharedLink album - api.sharedLinkApi.create(server, user1.accessToken, { - type: SharedLinkType.ALBUM, + apiUtils.createSharedLink(user1.accessToken, { + type: SharedLinkType.Album, albumId: user1Albums[1].id, }), - // add shared link to user2SharedLink album - api.sharedLinkApi.create(server, user2.accessToken, { - type: SharedLinkType.ALBUM, + apiUtils.createSharedLink(user2.accessToken, { + type: SharedLinkType.Album, albumId: user2Albums[1].id, }), ]); + + await deleteUser( + { id: user3.userId }, + { headers: asBearerAuth(admin.accessToken) } + ); }); describe('GET /album', () => { it('should require authentication', async () => { - const { status, body } = await request(server).get('/album'); + const { status, body } = await request(app).get('/album'); expect(status).toBe(401); - expect(body).toEqual(errorStub.unauthorized); + expect(body).toEqual(errorDto.unauthorized); }); it('should reject an invalid shared param', async () => { - const { status, body } = await request(server) + const { status, body } = await request(app) .get('/album?shared=invalid') .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toEqual(400); - expect(body).toEqual(errorStub.badRequest(['shared must be a boolean value'])); + expect(body).toEqual( + errorDto.badRequest(['shared must be a boolean value']) + ); }); it('should reject an invalid assetId param', async () => { - const { status, body } = await request(server) + const { status, body } = await request(app) .get('/album?assetId=invalid') .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toEqual(400); - expect(body).toEqual(errorStub.badRequest(['assetId must be a UUID'])); + expect(body).toEqual(errorDto.badRequest(['assetId must be a UUID'])); }); it('should not return shared albums with a deleted owner', async () => { - await api.userApi.delete(server, admin.accessToken, user1.userId); - const { status, body } = await request(server) + const { status, body } = await request(app) .get('/album?shared=true') - .set('Authorization', `Bearer ${user2.accessToken}`); + .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(200); - expect(body).toHaveLength(1); + expect(body).toHaveLength(3); expect(body).toEqual( expect.arrayContaining([ - expect.objectContaining({ ownerId: user2.userId, albumName: user2SharedLink, shared: true }), - ]), + expect.objectContaining({ + ownerId: user1.userId, + albumName: user1SharedLink, + shared: true, + }), + expect.objectContaining({ + ownerId: user1.userId, + albumName: user1SharedUser, + shared: true, + }), + expect.objectContaining({ + ownerId: user2.userId, + albumName: user2SharedUser, + shared: true, + }), + ]) ); }); it('should return the album collection including owned and shared', async () => { - const { status, body } = await request(server).get('/album').set('Authorization', `Bearer ${user1.accessToken}`); + const { status, body } = await request(app) + .get('/album') + .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(200); expect(body).toHaveLength(3); expect(body).toEqual( expect.arrayContaining([ - expect.objectContaining({ ownerId: user1.userId, albumName: user1SharedUser, shared: true }), - expect.objectContaining({ ownerId: user1.userId, albumName: user1SharedLink, shared: true }), - expect.objectContaining({ ownerId: user1.userId, albumName: user1NotShared, shared: false }), - ]), + expect.objectContaining({ + ownerId: user1.userId, + albumName: user1SharedUser, + shared: true, + }), + expect.objectContaining({ + ownerId: user1.userId, + albumName: user1SharedLink, + shared: true, + }), + expect.objectContaining({ + ownerId: user1.userId, + albumName: user1NotShared, + shared: false, + }), + ]) ); }); it('should return the album collection filtered by shared', async () => { - const { status, body } = await request(server) + const { status, body } = await request(app) .get('/album?shared=true') .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(200); expect(body).toHaveLength(3); expect(body).toEqual( expect.arrayContaining([ - expect.objectContaining({ ownerId: user1.userId, albumName: user1SharedUser, shared: true }), - expect.objectContaining({ ownerId: user1.userId, albumName: user1SharedLink, shared: true }), - expect.objectContaining({ ownerId: user2.userId, albumName: user2SharedUser, shared: true }), - ]), + expect.objectContaining({ + ownerId: user1.userId, + albumName: user1SharedUser, + shared: true, + }), + expect.objectContaining({ + ownerId: user1.userId, + albumName: user1SharedLink, + shared: true, + }), + expect.objectContaining({ + ownerId: user2.userId, + albumName: user2SharedUser, + shared: true, + }), + ]) ); }); it('should return the album collection filtered by NOT shared', async () => { - const { status, body } = await request(server) + const { status, body } = await request(app) .get('/album?shared=false') .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(200); expect(body).toHaveLength(1); expect(body).toEqual( expect.arrayContaining([ - expect.objectContaining({ ownerId: user1.userId, albumName: user1NotShared, shared: false }), - ]), + expect.objectContaining({ + ownerId: user1.userId, + albumName: user1NotShared, + shared: false, + }), + ]) ); }); it('should return the album collection filtered by assetId', async () => { - const asset = await api.assetApi.upload(server, user1.accessToken, 'example2'); - await api.albumApi.addAssets(server, user1.accessToken, user1Albums[0].id, { ids: [asset.id] }); - const { status, body } = await request(server) - .get(`/album?assetId=${asset.id}`) + const { status, body } = await request(app) + .get(`/album?assetId=${user1Asset2.id}`) .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(200); expect(body).toHaveLength(1); }); it('should return the album collection filtered by assetId and ignores shared=true', async () => { - const { status, body } = await request(server) - .get(`/album?shared=true&assetId=${user1Asset.id}`) + const { status, body } = await request(app) + .get(`/album?shared=true&assetId=${user1Asset1.id}`) .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(200); expect(body).toHaveLength(4); }); it('should return the album collection filtered by assetId and ignores shared=false', async () => { - const { status, body } = await request(server) - .get(`/album?shared=false&assetId=${user1Asset.id}`) + const { status, body } = await request(app) + .get(`/album?shared=false&assetId=${user1Asset1.id}`) .set('Authorization', `Bearer ${user1.accessToken}`); expect(status).toBe(200); expect(body).toHaveLength(4); }); }); + describe('GET /album/:id', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).get( + `/album/${user1Albums[0].id}` + ); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should return album info for own album', async () => { + const { status, body } = await request(app) + .get(`/album/${user1Albums[0].id}?withoutAssets=false`) + .set('Authorization', `Bearer ${user1.accessToken}`); + + expect(status).toBe(200); + expect(body).toEqual({ + ...user1Albums[0], + assets: [expect.objectContaining(user1Albums[0].assets[0])], + }); + }); + + it('should return album info for shared album', async () => { + const { status, body } = await request(app) + .get(`/album/${user2Albums[0].id}?withoutAssets=false`) + .set('Authorization', `Bearer ${user1.accessToken}`); + + expect(status).toBe(200); + expect(body).toEqual({ + ...user2Albums[0], + assets: [expect.objectContaining(user2Albums[0].assets[0])], + }); + }); + + it('should return album info with assets when withoutAssets is undefined', async () => { + const { status, body } = await request(app) + .get(`/album/${user1Albums[0].id}`) + .set('Authorization', `Bearer ${user1.accessToken}`); + + expect(status).toBe(200); + expect(body).toEqual({ + ...user1Albums[0], + assets: [expect.objectContaining(user1Albums[0].assets[0])], + }); + }); + + it('should return album info without assets when withoutAssets is true', async () => { + const { status, body } = await request(app) + .get(`/album/${user1Albums[0].id}?withoutAssets=true`) + .set('Authorization', `Bearer ${user1.accessToken}`); + + expect(status).toBe(200); + expect(body).toEqual({ + ...user1Albums[0], + assets: [], + assetCount: 1, + }); + }); + }); + + describe('GET /album/count', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).get('/album/count'); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should return total count of albums the user has access to', async () => { + const { status, body } = await request(app) + .get('/album/count') + .set('Authorization', `Bearer ${user1.accessToken}`); + + expect(status).toBe(200); + expect(body).toEqual({ owned: 3, shared: 3, notShared: 1 }); + }); + }); + describe('POST /album', () => { it('should require authentication', async () => { - const { status, body } = await request(server).post('/album').send({ albumName: 'New album' }); + const { status, body } = await request(app) + .post('/album') + .send({ albumName: 'New album' }); expect(status).toBe(401); - expect(body).toEqual(errorStub.unauthorized); + expect(body).toEqual(errorDto.unauthorized); }); it('should create an album', async () => { - const body = await api.albumApi.create(server, user1.accessToken, { albumName: 'New album' }); + const { status, body } = await request(app) + .post('/album') + .send({ albumName: 'New album' }) + .set('Authorization', `Bearer ${user1.accessToken}`); + expect(status).toBe(201); expect(body).toEqual({ id: expect.any(String), createdAt: expect.any(String), @@ -220,113 +358,56 @@ describe(`${AlbumController.name} (e2e)`, () => { }); }); - describe('GET /album/count', () => { - it('should require authentication', async () => { - const { status, body } = await request(server).get('/album/count'); - expect(status).toBe(401); - expect(body).toEqual(errorStub.unauthorized); - }); - - it('should return total count of albums the user has access to', async () => { - const { status, body } = await request(server) - .get('/album/count') - .set('Authorization', `Bearer ${user1.accessToken}`); - - expect(status).toBe(200); - expect(body).toEqual({ owned: 3, shared: 3, notShared: 1 }); - }); - }); - - describe('GET /album/:id', () => { - it('should require authentication', async () => { - const { status, body } = await request(server).get(`/album/${user1Albums[0].id}`); - expect(status).toBe(401); - expect(body).toEqual(errorStub.unauthorized); - }); - - it('should return album info for own album', async () => { - const { status, body } = await request(server) - .get(`/album/${user1Albums[0].id}?withoutAssets=false`) - .set('Authorization', `Bearer ${user1.accessToken}`); - - expect(status).toBe(200); - expect(body).toEqual({ ...user1Albums[0], assets: [expect.objectContaining(user1Albums[0].assets[0])] }); - }); - - it('should return album info for shared album', async () => { - const { status, body } = await request(server) - .get(`/album/${user2Albums[0].id}?withoutAssets=false`) - .set('Authorization', `Bearer ${user1.accessToken}`); - - expect(status).toBe(200); - expect(body).toEqual({ ...user2Albums[0], assets: [expect.objectContaining(user2Albums[0].assets[0])] }); - }); - - it('should return album info with assets when withoutAssets is undefined', async () => { - const { status, body } = await request(server) - .get(`/album/${user1Albums[0].id}`) - .set('Authorization', `Bearer ${user1.accessToken}`); - - expect(status).toBe(200); - expect(body).toEqual({ ...user1Albums[0], assets: [expect.objectContaining(user1Albums[0].assets[0])] }); - }); - - it('should return album info without assets when withoutAssets is true', async () => { - const { status, body } = await request(server) - .get(`/album/${user1Albums[0].id}?withoutAssets=true`) - .set('Authorization', `Bearer ${user1.accessToken}`); - - expect(status).toBe(200); - expect(body).toEqual({ - ...user1Albums[0], - assets: [], - assetCount: 1, - }); - }); - }); - describe('PUT /album/:id/assets', () => { it('should require authentication', async () => { - const { status, body } = await request(server).put(`/album/${user1Albums[0].id}/assets`); + const { status, body } = await request(app).put( + `/album/${user1Albums[0].id}/assets` + ); expect(status).toBe(401); - expect(body).toEqual(errorStub.unauthorized); + expect(body).toEqual(errorDto.unauthorized); }); it('should be able to add own asset to own album', async () => { - const asset = await api.assetApi.upload(server, user1.accessToken, 'example1'); - const { status, body } = await request(server) + const asset = await apiUtils.createAsset(user1.accessToken); + const { status, body } = await request(app) .put(`/album/${user1Albums[0].id}/assets`) .set('Authorization', `Bearer ${user1.accessToken}`) .send({ ids: [asset.id] }); expect(status).toBe(200); - expect(body).toEqual([expect.objectContaining({ id: asset.id, success: true })]); + expect(body).toEqual([ + expect.objectContaining({ id: asset.id, success: true }), + ]); }); it('should be able to add own asset to shared album', async () => { - const asset = await api.assetApi.upload(server, user1.accessToken, 'example1'); - const { status, body } = await request(server) + const asset = await apiUtils.createAsset(user1.accessToken); + const { status, body } = await request(app) .put(`/album/${user2Albums[0].id}/assets`) .set('Authorization', `Bearer ${user1.accessToken}`) .send({ ids: [asset.id] }); expect(status).toBe(200); - expect(body).toEqual([expect.objectContaining({ id: asset.id, success: true })]); + expect(body).toEqual([ + expect.objectContaining({ id: asset.id, success: true }), + ]); }); }); describe('PATCH /album/:id', () => { it('should require authentication', async () => { - const { status, body } = await request(server) - .patch(`/album/${uuidStub.notFound}`) + const { status, body } = await request(app) + .patch(`/album/${uuidDto.notFound}`) .send({ albumName: 'New album name' }); expect(status).toBe(401); - expect(body).toEqual(errorStub.unauthorized); + expect(body).toEqual(errorDto.unauthorized); }); it('should update an album', async () => { - const album = await api.albumApi.create(server, user1.accessToken, { albumName: 'New album' }); - const { status, body } = await request(server) + const album = await apiUtils.createAlbum(user1.accessToken, { + albumName: 'New album', + }); + const { status, body } = await request(app) .patch(`/album/${album.id}`) .set('Authorization', `Bearer ${user1.accessToken}`) .send({ @@ -345,52 +426,68 @@ describe(`${AlbumController.name} (e2e)`, () => { describe('DELETE /album/:id/assets', () => { it('should require authentication', async () => { - const { status, body } = await request(server) + const { status, body } = await request(app) .delete(`/album/${user1Albums[0].id}/assets`) - .send({ ids: [user1Asset.id] }); + .send({ ids: [user1Asset1.id] }); expect(status).toBe(401); - expect(body).toEqual(errorStub.unauthorized); - }); - - it('should be able to remove own asset from own album', async () => { - const { status, body } = await request(server) - .delete(`/album/${user1Albums[0].id}/assets`) - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ ids: [user1Asset.id] }); - - expect(status).toBe(200); - expect(body).toEqual([expect.objectContaining({ id: user1Asset.id, success: true })]); - }); - - it('should be able to remove own asset from shared album', async () => { - const { status, body } = await request(server) - .delete(`/album/${user2Albums[0].id}/assets`) - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ ids: [user1Asset.id] }); - - expect(status).toBe(200); - expect(body).toEqual([expect.objectContaining({ id: user1Asset.id, success: true })]); + expect(body).toEqual(errorDto.unauthorized); }); it('should not be able to remove foreign asset from own album', async () => { - const { status, body } = await request(server) + const { status, body } = await request(app) .delete(`/album/${user2Albums[0].id}/assets`) .set('Authorization', `Bearer ${user2.accessToken}`) - .send({ ids: [user1Asset.id] }); + .send({ ids: [user1Asset1.id] }); expect(status).toBe(200); - expect(body).toEqual([expect.objectContaining({ id: user1Asset.id, success: false, error: 'no_permission' })]); + expect(body).toEqual([ + expect.objectContaining({ + id: user1Asset1.id, + success: false, + error: 'no_permission', + }), + ]); }); it('should not be able to remove foreign asset from foreign album', async () => { - const { status, body } = await request(server) + const { status, body } = await request(app) .delete(`/album/${user1Albums[0].id}/assets`) .set('Authorization', `Bearer ${user2.accessToken}`) - .send({ ids: [user1Asset.id] }); + .send({ ids: [user1Asset1.id] }); expect(status).toBe(200); - expect(body).toEqual([expect.objectContaining({ id: user1Asset.id, success: false, error: 'no_permission' })]); + expect(body).toEqual([ + expect.objectContaining({ + id: user1Asset1.id, + success: false, + error: 'no_permission', + }), + ]); + }); + + it('should be able to remove own asset from own album', async () => { + const { status, body } = await request(app) + .delete(`/album/${user1Albums[0].id}/assets`) + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ ids: [user1Asset1.id] }); + + expect(status).toBe(200); + expect(body).toEqual([ + expect.objectContaining({ id: user1Asset1.id, success: true }), + ]); + }); + + it('should be able to remove own asset from shared album', async () => { + const { status, body } = await request(app) + .delete(`/album/${user2Albums[0].id}/assets`) + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ ids: [user1Asset1.id] }); + + expect(status).toBe(200); + expect(body).toEqual([ + expect.objectContaining({ id: user1Asset1.id, success: true }), + ]); }); }); @@ -398,51 +495,57 @@ describe(`${AlbumController.name} (e2e)`, () => { let album: AlbumResponseDto; beforeEach(async () => { - album = await api.albumApi.create(server, user1.accessToken, { albumName: 'testAlbum' }); + album = await apiUtils.createAlbum(user1.accessToken, { + albumName: 'testAlbum', + }); }); it('should require authentication', async () => { - const { status, body } = await request(server) + const { status, body } = await request(app) .put(`/album/${user1Albums[0].id}/users`) .send({ sharedUserIds: [] }); expect(status).toBe(401); - expect(body).toEqual(errorStub.unauthorized); + expect(body).toEqual(errorDto.unauthorized); }); it('should be able to add user to own album', async () => { - const { status, body } = await request(server) + const { status, body } = await request(app) .put(`/album/${album.id}/users`) .set('Authorization', `Bearer ${user1.accessToken}`) .send({ sharedUserIds: [user2.userId] }); expect(status).toBe(200); - expect(body).toEqual(expect.objectContaining({ sharedUsers: [expect.objectContaining({ id: user2.userId })] })); + expect(body).toEqual( + expect.objectContaining({ + sharedUsers: [expect.objectContaining({ id: user2.userId })], + }) + ); }); it('should not be able to share album with owner', async () => { - const { status, body } = await request(server) + const { status, body } = await request(app) .put(`/album/${album.id}/users`) .set('Authorization', `Bearer ${user1.accessToken}`) .send({ sharedUserIds: [user1.userId] }); expect(status).toBe(400); - expect(body).toEqual(errorStub.badRequest('Cannot be shared with owner')); + expect(body).toEqual(errorDto.badRequest('Cannot be shared with owner')); }); it('should not be able to add existing user to shared album', async () => { - await request(server) + await request(app) .put(`/album/${album.id}/users`) .set('Authorization', `Bearer ${user1.accessToken}`) .send({ sharedUserIds: [user2.userId] }); - const { status, body } = await request(server) + const { status, body } = await request(app) .put(`/album/${album.id}/users`) .set('Authorization', `Bearer ${user1.accessToken}`) .send({ sharedUserIds: [user2.userId] }); expect(status).toBe(400); - expect(body).toEqual(errorStub.badRequest('User already added')); + expect(body).toEqual(errorDto.badRequest('User already added')); }); }); }); diff --git a/e2e/src/api/specs/shared-link.e2e-spec.ts b/e2e/src/api/specs/shared-link.e2e-spec.ts index df57c57137..e791c447ac 100644 --- a/e2e/src/api/specs/shared-link.e2e-spec.ts +++ b/e2e/src/api/specs/shared-link.e2e-spec.ts @@ -15,9 +15,6 @@ import { apiUtils, app, asBearerAuth, dbUtils } from 'src/utils'; import request from 'supertest'; import { beforeAll, describe, expect, it } from 'vitest'; -const createSharedLink = (dto: SharedLinkCreateDto, accessToken: string) => - create({ sharedLinkCreateDto: dto }, { headers: asBearerAuth(accessToken) }); - describe('/shared-link', () => { let admin: LoginResponseDto; let asset1: AssetResponseDto; @@ -78,38 +75,33 @@ describe('/shared-link', () => { linkWithMetadata, linkWithoutMetadata, ] = await Promise.all([ - createSharedLink( - { type: SharedLinkType.Album, albumId: deletedAlbum.id }, - user2.accessToken - ), - createSharedLink( - { type: SharedLinkType.Album, albumId: album.id }, - user1.accessToken - ), - createSharedLink( - { type: SharedLinkType.Individual, assetIds: [asset1.id] }, - user1.accessToken - ), - createSharedLink( - { type: SharedLinkType.Album, albumId: album.id, password: 'foo' }, - user1.accessToken - ), - createSharedLink( - { - type: SharedLinkType.Album, - albumId: metadataAlbum.id, - showMetadata: true, - }, - user1.accessToken - ), - createSharedLink( - { - type: SharedLinkType.Album, - albumId: metadataAlbum.id, - showMetadata: false, - }, - user1.accessToken - ), + apiUtils.createSharedLink(user2.accessToken, { + type: SharedLinkType.Album, + albumId: deletedAlbum.id, + }), + apiUtils.createSharedLink(user1.accessToken, { + type: SharedLinkType.Album, + albumId: album.id, + }), + apiUtils.createSharedLink(user1.accessToken, { + type: SharedLinkType.Individual, + assetIds: [asset1.id], + }), + apiUtils.createSharedLink(user1.accessToken, { + type: SharedLinkType.Album, + albumId: album.id, + password: 'foo', + }), + apiUtils.createSharedLink(user1.accessToken, { + type: SharedLinkType.Album, + albumId: metadataAlbum.id, + showMetadata: true, + }), + apiUtils.createSharedLink(user1.accessToken, { + type: SharedLinkType.Album, + albumId: metadataAlbum.id, + showMetadata: false, + }), ]); await deleteUser( diff --git a/e2e/src/api/specs/user.e2e-spec.ts b/e2e/src/api/specs/user.e2e-spec.ts index 74e1646802..9bfb47284a 100644 --- a/e2e/src/api/specs/user.e2e-spec.ts +++ b/e2e/src/api/specs/user.e2e-spec.ts @@ -1,26 +1,31 @@ -import { - LoginResponseDto, - UserResponseDto, - createUser, - deleteUser, - getUserById, -} from '@immich/sdk'; +import { LoginResponseDto, deleteUser, getUserById } from '@immich/sdk'; import { createUserDto, userDto } from 'src/fixtures'; import { errorDto } from 'src/responses'; import { apiUtils, app, asBearerAuth, dbUtils } from 'src/utils'; import request from 'supertest'; -import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { beforeAll, describe, expect, it } from 'vitest'; describe('/server-info', () => { let admin: LoginResponseDto; + let deletedUser: LoginResponseDto; + let userToDelete: LoginResponseDto; + let nonAdmin: LoginResponseDto; beforeAll(async () => { apiUtils.setup(); - }); - - beforeEach(async () => { await dbUtils.reset(); admin = await apiUtils.adminSetup({ onboarding: false }); + + [deletedUser, nonAdmin, userToDelete] = await Promise.all([ + apiUtils.userSetup(admin.accessToken, createUserDto.user1), + apiUtils.userSetup(admin.accessToken, createUserDto.user2), + apiUtils.userSetup(admin.accessToken, createUserDto.user3), + ]); + + await deleteUser( + { id: deletedUser.userId }, + { headers: asBearerAuth(admin.accessToken) } + ); }); describe('GET /user', () => { @@ -30,60 +35,54 @@ describe('/server-info', () => { expect(body).toEqual(errorDto.unauthorized); }); - it('should start with the admin', async () => { + it('should get users', async () => { const { status, body } = await request(app) .get('/user') .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toEqual(200); - expect(body).toHaveLength(1); - expect(body[0]).toMatchObject({ email: 'admin@immich.cloud' }); + expect(body).toHaveLength(4); + expect(body).toEqual( + expect.arrayContaining([ + expect.objectContaining({ email: 'admin@immich.cloud' }), + expect.objectContaining({ email: 'user1@immich.cloud' }), + expect.objectContaining({ email: 'user2@immich.cloud' }), + expect.objectContaining({ email: 'user3@immich.cloud' }), + ]) + ); }); it('should hide deleted users', async () => { - const user1 = await apiUtils.userSetup( - admin.accessToken, - createUserDto.user1 - ); - await deleteUser( - { id: user1.userId }, - { headers: asBearerAuth(admin.accessToken) } - ); - const { status, body } = await request(app) .get(`/user`) .query({ isAll: true }) .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(200); - expect(body).toHaveLength(1); - expect(body[0]).toMatchObject({ email: 'admin@immich.cloud' }); + expect(body).toHaveLength(3); + expect(body).toEqual( + expect.arrayContaining([ + expect.objectContaining({ email: 'admin@immich.cloud' }), + expect.objectContaining({ email: 'user2@immich.cloud' }), + expect.objectContaining({ email: 'user3@immich.cloud' }), + ]) + ); }); it('should include deleted users', async () => { - const user1 = await apiUtils.userSetup( - admin.accessToken, - createUserDto.user1 - ); - await deleteUser( - { id: user1.userId }, - { headers: asBearerAuth(admin.accessToken) } - ); - const { status, body } = await request(app) .get(`/user`) .query({ isAll: false }) .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(200); - expect(body).toHaveLength(2); - expect(body[0]).toMatchObject({ - id: user1.userId, - email: 'user1@immich.cloud', - deletedAt: expect.any(String), - }); - expect(body[1]).toMatchObject({ - id: admin.userId, - email: 'admin@immich.cloud', - }); + expect(body).toHaveLength(4); + expect(body).toEqual( + expect.arrayContaining([ + expect.objectContaining({ email: 'admin@immich.cloud' }), + expect.objectContaining({ email: 'user1@immich.cloud' }), + expect.objectContaining({ email: 'user2@immich.cloud' }), + expect.objectContaining({ email: 'user3@immich.cloud' }), + ]) + ); }); }); @@ -149,13 +148,13 @@ describe('/server-info', () => { .post(`/user`) .send({ isAdmin: true, - email: 'user1@immich.cloud', - password: 'Password123', + email: 'user4@immich.cloud', + password: 'password123', name: 'Immich', }) .set('Authorization', `Bearer ${admin.accessToken}`); expect(body).toMatchObject({ - email: 'user1@immich.cloud', + email: 'user4@immich.cloud', isAdmin: false, shouldChangePassword: true, }); @@ -181,18 +180,9 @@ describe('/server-info', () => { }); describe('DELETE /user/:id', () => { - let userToDelete: UserResponseDto; - - beforeEach(async () => { - userToDelete = await createUser( - { createUserDto: createUserDto.user1 }, - { headers: asBearerAuth(admin.accessToken) } - ); - }); - it('should require authentication', async () => { const { status, body } = await request(app).delete( - `/user/${userToDelete.id}` + `/user/${userToDelete.userId}` ); expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); @@ -200,12 +190,12 @@ describe('/server-info', () => { it('should delete user', async () => { const { status, body } = await request(app) - .delete(`/user/${userToDelete.id}`) + .delete(`/user/${userToDelete.userId}`) .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(200); - expect(body).toEqual({ - ...userToDelete, + expect(body).toMatchObject({ + id: userToDelete.userId, updatedAt: expect.any(String), deletedAt: expect.any(String), }); @@ -231,14 +221,9 @@ describe('/server-info', () => { } it('should not allow a non-admin to become an admin', async () => { - const user = await apiUtils.userSetup( - admin.accessToken, - createUserDto.user1 - ); - const { status, body } = await request(app) .put(`/user`) - .send({ isAdmin: true, id: user.userId }) + .send({ isAdmin: true, id: nonAdmin.userId }) .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(400); diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index 1c7382879d..a6374aff52 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -1,10 +1,14 @@ import { AssetResponseDto, + CreateAlbumDto, CreateAssetDto, CreateUserDto, PersonUpdateDto, + SharedLinkCreateDto, + createAlbum, createApiKey, createPerson, + createSharedLink, createUser, defaults, login, @@ -181,6 +185,11 @@ export const apiUtils = { { headers: asBearerAuth(accessToken) } ); }, + createAlbum: (accessToken: string, dto: CreateAlbumDto) => + createAlbum( + { createAlbumDto: dto }, + { headers: asBearerAuth(accessToken) } + ), createAsset: async ( accessToken: string, dto?: Omit @@ -211,6 +220,11 @@ export const apiUtils = { { headers: asBearerAuth(accessToken) } ); }, + createSharedLink: (accessToken: string, dto: SharedLinkCreateDto) => + createSharedLink( + { sharedLinkCreateDto: dto }, + { headers: asBearerAuth(accessToken) } + ), }; export const cliUtils = { From 5c0c98473dc53e2e343a5ecbf475757a852aa106 Mon Sep 17 00:00:00 2001 From: martin <74269598+martabal@users.noreply.github.com> Date: Wed, 21 Feb 2024 23:03:45 +0100 Subject: [PATCH 04/18] fix(server, web): people page (#7319) * fix: people page * fix: use locale * fix: e2e * fix: remove useless w-full * fix: don't count people without thumbnail * fix: es6 template string Co-authored-by: Jason Rasmussen --------- Co-authored-by: Jason Rasmussen --- e2e/src/api/specs/person.e2e-spec.ts | 2 ++ mobile/openapi/doc/PeopleResponseDto.md | 1 + .../lib/model/people_response_dto.dart | 10 ++++++- .../test/people_response_dto_test.dart | 5 ++++ open-api/immich-openapi-specs.json | 4 +++ open-api/typescript-sdk/axios-client/api.ts | 6 +++++ open-api/typescript-sdk/fetch-client.ts | 1 + server/src/domain/person/person.dto.ts | 3 ++- .../src/domain/person/person.service.spec.ts | 27 ++----------------- server/src/domain/person/person.service.ts | 9 +++---- .../domain/repositories/person.repository.ts | 7 ++++- .../infra/repositories/person.repository.ts | 22 +++++++++++---- server/src/infra/sql/person.repository.sql | 11 +++++++- .../components/faces-page/show-hide.svelte | 9 +++++-- web/src/routes/(user)/people/+page.svelte | 23 +++++++++++----- 15 files changed, 92 insertions(+), 48 deletions(-) diff --git a/e2e/src/api/specs/person.e2e-spec.ts b/e2e/src/api/specs/person.e2e-spec.ts index d384fde2dc..3f17eac220 100644 --- a/e2e/src/api/specs/person.e2e-spec.ts +++ b/e2e/src/api/specs/person.e2e-spec.ts @@ -56,6 +56,7 @@ describe('/activity', () => { expect(status).toBe(200); expect(body).toEqual({ total: 2, + hidden: 1, people: [ expect.objectContaining({ name: 'visible_person' }), expect.objectContaining({ name: 'hidden_person' }), @@ -71,6 +72,7 @@ describe('/activity', () => { expect(status).toBe(200); expect(body).toEqual({ total: 2, + hidden: 1, people: [expect.objectContaining({ name: 'visible_person' })], }); }); diff --git a/mobile/openapi/doc/PeopleResponseDto.md b/mobile/openapi/doc/PeopleResponseDto.md index 2f87f19993..78f9b2207c 100644 --- a/mobile/openapi/doc/PeopleResponseDto.md +++ b/mobile/openapi/doc/PeopleResponseDto.md @@ -8,6 +8,7 @@ import 'package:openapi/api.dart'; ## Properties Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- +**hidden** | **int** | | **people** | [**List**](PersonResponseDto.md) | | [default to const []] **total** | **int** | | diff --git a/mobile/openapi/lib/model/people_response_dto.dart b/mobile/openapi/lib/model/people_response_dto.dart index 80abedfc72..02a82cadf1 100644 --- a/mobile/openapi/lib/model/people_response_dto.dart +++ b/mobile/openapi/lib/model/people_response_dto.dart @@ -13,30 +13,36 @@ part of openapi.api; class PeopleResponseDto { /// Returns a new [PeopleResponseDto] instance. PeopleResponseDto({ + required this.hidden, this.people = const [], required this.total, }); + int hidden; + List people; int total; @override bool operator ==(Object other) => identical(this, other) || other is PeopleResponseDto && + other.hidden == hidden && _deepEquality.equals(other.people, people) && other.total == total; @override int get hashCode => // ignore: unnecessary_parenthesis + (hidden.hashCode) + (people.hashCode) + (total.hashCode); @override - String toString() => 'PeopleResponseDto[people=$people, total=$total]'; + String toString() => 'PeopleResponseDto[hidden=$hidden, people=$people, total=$total]'; Map toJson() { final json = {}; + json[r'hidden'] = this.hidden; json[r'people'] = this.people; json[r'total'] = this.total; return json; @@ -50,6 +56,7 @@ class PeopleResponseDto { final json = value.cast(); return PeopleResponseDto( + hidden: mapValueOfType(json, r'hidden')!, people: PersonResponseDto.listFromJson(json[r'people']), total: mapValueOfType(json, r'total')!, ); @@ -99,6 +106,7 @@ class PeopleResponseDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { + 'hidden', 'people', 'total', }; diff --git a/mobile/openapi/test/people_response_dto_test.dart b/mobile/openapi/test/people_response_dto_test.dart index ad669eeced..94db6eb86b 100644 --- a/mobile/openapi/test/people_response_dto_test.dart +++ b/mobile/openapi/test/people_response_dto_test.dart @@ -16,6 +16,11 @@ void main() { // final instance = PeopleResponseDto(); group('test PeopleResponseDto', () { + // int hidden + test('to test the property `hidden`', () async { + // TODO + }); + // List people (default value: const []) test('to test the property `people`', () async { // TODO diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 87f0fb4158..cac1d663bd 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -8593,6 +8593,9 @@ }, "PeopleResponseDto": { "properties": { + "hidden": { + "type": "integer" + }, "people": { "items": { "$ref": "#/components/schemas/PersonResponseDto" @@ -8604,6 +8607,7 @@ } }, "required": [ + "hidden", "people", "total" ], diff --git a/open-api/typescript-sdk/axios-client/api.ts b/open-api/typescript-sdk/axios-client/api.ts index bef5ceab1b..c01b200d03 100644 --- a/open-api/typescript-sdk/axios-client/api.ts +++ b/open-api/typescript-sdk/axios-client/api.ts @@ -2801,6 +2801,12 @@ export type PathType = typeof PathType[keyof typeof PathType]; * @interface PeopleResponseDto */ export interface PeopleResponseDto { + /** + * + * @type {number} + * @memberof PeopleResponseDto + */ + 'hidden': number; /** * * @type {Array} diff --git a/open-api/typescript-sdk/fetch-client.ts b/open-api/typescript-sdk/fetch-client.ts index d7ecb906e3..0ee871ca60 100644 --- a/open-api/typescript-sdk/fetch-client.ts +++ b/open-api/typescript-sdk/fetch-client.ts @@ -524,6 +524,7 @@ export type UpdatePartnerDto = { inTimeline: boolean; }; export type PeopleResponseDto = { + hidden: number; people: PersonResponseDto[]; total: number; }; diff --git a/server/src/domain/person/person.dto.ts b/server/src/domain/person/person.dto.ts index 360a9b2348..b8ad8f0451 100644 --- a/server/src/domain/person/person.dto.ts +++ b/server/src/domain/person/person.dto.ts @@ -127,7 +127,8 @@ export class PersonStatisticsResponseDto { export class PeopleResponseDto { @ApiProperty({ type: 'integer' }) total!: number; - + @ApiProperty({ type: 'integer' }) + hidden!: number; people!: PersonResponseDto[]; } diff --git a/server/src/domain/person/person.service.spec.ts b/server/src/domain/person/person.service.spec.ts index 5da8666016..ffda9034bd 100644 --- a/server/src/domain/person/person.service.spec.ts +++ b/server/src/domain/person/person.service.spec.ts @@ -114,35 +114,12 @@ describe(PersonService.name, () => { }); describe('getAll', () => { - it('should get all people with thumbnails', async () => { - personMock.getAllForUser.mockResolvedValue([personStub.withName, personStub.noThumbnail]); - personMock.getNumberOfPeople.mockResolvedValue(1); - await expect(sut.getAll(authStub.admin, { withHidden: undefined })).resolves.toEqual({ - total: 1, - people: [responseDto], - }); - expect(personMock.getAllForUser).toHaveBeenCalledWith(authStub.admin.user.id, { - minimumFaceCount: 3, - withHidden: false, - }); - }); - it('should get all visible people with thumbnails', async () => { - personMock.getAllForUser.mockResolvedValue([personStub.withName, personStub.hidden]); - personMock.getNumberOfPeople.mockResolvedValue(2); - await expect(sut.getAll(authStub.admin, { withHidden: false })).resolves.toEqual({ - total: 2, - people: [responseDto], - }); - expect(personMock.getAllForUser).toHaveBeenCalledWith(authStub.admin.user.id, { - minimumFaceCount: 3, - withHidden: false, - }); - }); it('should get all hidden and visible people with thumbnails', async () => { personMock.getAllForUser.mockResolvedValue([personStub.withName, personStub.hidden]); - personMock.getNumberOfPeople.mockResolvedValue(2); + personMock.getNumberOfPeople.mockResolvedValue({ total: 2, hidden: 1 }); await expect(sut.getAll(authStub.admin, { withHidden: true })).resolves.toEqual({ total: 2, + hidden: 1, people: [ responseDto, { diff --git a/server/src/domain/person/person.service.ts b/server/src/domain/person/person.service.ts index 6fbc409bf8..6300cc743c 100644 --- a/server/src/domain/person/person.service.ts +++ b/server/src/domain/person/person.service.ts @@ -82,15 +82,12 @@ export class PersonService { minimumFaceCount: machineLearning.facialRecognition.minFaces, withHidden: dto.withHidden || false, }); - const total = await this.repository.getNumberOfPeople(auth.user.id); - const persons: PersonResponseDto[] = people - // with thumbnails - .filter((person) => !!person.thumbnailPath) - .map((person) => mapPerson(person)); + const { total, hidden } = await this.repository.getNumberOfPeople(auth.user.id); return { - people: persons.filter((person) => dto.withHidden || !person.isHidden), + people: people.map((person) => mapPerson(person)), total, + hidden, }; } diff --git a/server/src/domain/repositories/person.repository.ts b/server/src/domain/repositories/person.repository.ts index 80240091a9..85c11fe921 100644 --- a/server/src/domain/repositories/person.repository.ts +++ b/server/src/domain/repositories/person.repository.ts @@ -28,6 +28,11 @@ export interface PersonStatistics { assets: number; } +export interface PeopleStatistics { + total: number; + hidden: number; +} + export interface IPersonRepository { getAll(pagination: PaginationOptions, options?: FindManyOptions): Paginated; getAllForUser(userId: string, options: PersonSearchOptions): Promise; @@ -54,7 +59,7 @@ export interface IPersonRepository { getRandomFace(personId: string): Promise; getStatistics(personId: string): Promise; reassignFace(assetFaceId: string, newPersonId: string): Promise; - getNumberOfPeople(userId: string): Promise; + getNumberOfPeople(userId: string): Promise; reassignFaces(data: UpdateFacesData): Promise; update(entity: Partial): Promise; } diff --git a/server/src/infra/repositories/person.repository.ts b/server/src/infra/repositories/person.repository.ts index 85423b74dd..63b3d570ef 100644 --- a/server/src/infra/repositories/person.repository.ts +++ b/server/src/infra/repositories/person.repository.ts @@ -3,6 +3,7 @@ import { IPersonRepository, Paginated, PaginationOptions, + PeopleStatistics, PersonNameSearchOptions, PersonSearchOptions, PersonStatistics, @@ -69,6 +70,7 @@ export class PersonRepository implements IPersonRepository { .addOrderBy("NULLIF(person.name, '') IS NULL", 'ASC') .addOrderBy('COUNT(face.assetId)', 'DESC') .addOrderBy("NULLIF(person.name, '')", 'ASC', 'NULLS LAST') + .andWhere("person.thumbnailPath != ''") .having("person.name != '' OR COUNT(face.assetId) >= :faces", { faces: options?.minimumFaceCount || 1 }) .groupBy('person.id') .limit(500); @@ -207,15 +209,25 @@ export class PersonRepository implements IPersonRepository { } @GenerateSql({ params: [DummyValue.UUID] }) - async getNumberOfPeople(userId: string): Promise { - return this.personRepository + async getNumberOfPeople(userId: string): Promise { + const items = await this.personRepository .createQueryBuilder('person') .leftJoin('person.faces', 'face') .where('person.ownerId = :userId', { userId }) + .innerJoin('face.asset', 'asset') + .andWhere('asset.isArchived = false') + .andWhere("person.thumbnailPath != ''") + .select('COUNT(DISTINCT(person.id))', 'total') + .addSelect('COUNT(DISTINCT(person.id)) FILTER (WHERE person.isHidden = true)', 'hidden') .having('COUNT(face.assetId) != 0') - .groupBy('person.id') - .withDeleted() - .getCount(); + .getRawOne(); + + const result: PeopleStatistics = { + total: items ? Number.parseInt(items.total) : 0, + hidden: items ? Number.parseInt(items.hidden) : 0, + }; + + return result; } create(entity: Partial): Promise { diff --git a/server/src/infra/sql/person.repository.sql b/server/src/infra/sql/person.repository.sql index bd4a523e86..c2cc45ee88 100644 --- a/server/src/infra/sql/person.repository.sql +++ b/server/src/infra/sql/person.repository.sql @@ -26,6 +26,7 @@ FROM WHERE "person"."ownerId" = $1 AND "asset"."isArchived" = false + AND "person"."thumbnailPath" != '' AND "person"."isHidden" = false GROUP BY "person"."id" @@ -344,12 +345,20 @@ LIMIT -- PersonRepository.getNumberOfPeople SELECT - COUNT(DISTINCT ("person"."id")) AS "cnt" + COUNT(DISTINCT ("person"."id")) AS "total", + COUNT(DISTINCT ("person"."id")) FILTER ( + WHERE + "person"."isHidden" = true + ) AS "hidden" FROM "person" "person" LEFT JOIN "asset_faces" "face" ON "face"."personId" = "person"."id" + INNER JOIN "assets" "asset" ON "asset"."id" = "face"."assetId" + AND ("asset"."deletedAt" IS NULL) WHERE "person"."ownerId" = $1 + AND "asset"."isArchived" = false + AND "person"."thumbnailPath" != '' HAVING COUNT("face"."assetId") != 0 diff --git a/web/src/lib/components/faces-page/show-hide.svelte b/web/src/lib/components/faces-page/show-hide.svelte index e766262280..bee1e98fb7 100644 --- a/web/src/lib/components/faces-page/show-hide.svelte +++ b/web/src/lib/components/faces-page/show-hide.svelte @@ -6,6 +6,7 @@ import { createEventDispatcher } from 'svelte'; import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte'; import { mdiClose, mdiEye, mdiEyeOff, mdiRestart } from '@mdi/js'; + import { locale } from '$lib/stores/preferences.store'; const dispatch = createEventDispatcher<{ close: void; @@ -17,6 +18,7 @@ export let showLoadingSpinner: boolean; export let toggleVisibility: boolean; export let screenHeight: number; + export let countTotalPeople: number;
dispatch('close')} /> - +
+

Show & hide people

+

({countTotalPeople.toLocaleString($locale)})

+
@@ -47,7 +52,7 @@
-
+
diff --git a/web/src/routes/(user)/people/+page.svelte b/web/src/routes/(user)/people/+page.svelte index c28f4d1f6c..eba6ed2764 100644 --- a/web/src/routes/(user)/people/+page.svelte +++ b/web/src/routes/(user)/people/+page.svelte @@ -40,11 +40,13 @@ import { mdiAccountOff, mdiEyeOutline } from '@mdi/js'; import { onDestroy, onMount } from 'svelte'; import type { PageData } from './$types'; + import { locale } from '$lib/stores/preferences.store'; export let data: PageData; let people = data.people.people; let countTotalPeople = data.people.total; + let countHiddenPeople = data.people.hidden; let selectHidden = false; let initialHiddenValues: Record = {}; @@ -75,7 +77,7 @@ $: searchedPeopleLocal = searchName ? searchNameLocal(searchName, searchedPeople, maximumLengthSearchPeople) : []; - $: countVisiblePeople = people.filter((person) => !person.isHidden).length; + $: countVisiblePeople = countTotalPeople - countHiddenPeople; const onKeyboardPress = (event: KeyboardEvent) => handleKeyboardPress(event); @@ -152,6 +154,11 @@ for (const person of people) { if (person.isHidden !== initialHiddenValues[person.id]) { changed.push({ id: person.id, isHidden: person.isHidden }); + if (person.isHidden) { + countHiddenPeople++; + } else { + countHiddenPeople--; + } // Update the initial hidden values initialHiddenValues[person.id] = person.isHidden; @@ -203,10 +210,10 @@ const mergedPerson = await getPerson({ id: personToBeMergedIn.id }); - countVisiblePeople--; people = people.filter((person: PersonResponseDto) => person.id !== personToMerge.id); people = people.map((person: PersonResponseDto) => (person.id === personToBeMergedIn.id ? mergedPerson : person)); - + countHiddenPeople--; + countTotalPeople--; notificationController.show({ message: 'Merge people successfully', type: NotificationType.Info, @@ -274,7 +281,7 @@ } showChangeNameModal = false; - + countHiddenPeople++; notificationController.show({ message: 'Changed visibility successfully', type: NotificationType.Info, @@ -423,7 +430,10 @@ {/if} - + {#if countTotalPeople > 0}
@@ -522,9 +532,10 @@ on:change={handleToggleVisibility} bind:showLoadingSpinner bind:toggleVisibility + {countTotalPeople} screenHeight={innerHeight} > -
+
{#each people as person, index (person.id)}
- +
diff --git a/web/src/lib/components/shared-components/combobox.svelte b/web/src/lib/components/shared-components/combobox.svelte index d0ce0e25a9..44994c2955 100644 --- a/web/src/lib/components/shared-components/combobox.svelte +++ b/web/src/lib/components/shared-components/combobox.svelte @@ -1,12 +1,7 @@ @@ -15,34 +10,32 @@ import Icon from '$lib/components/elements/icon.svelte'; import { clickOutside } from '$lib/utils/click-outside'; - import { mdiMagnify, mdiUnfoldMoreHorizontal } from '@mdi/js'; + import { mdiMagnify, mdiUnfoldMoreHorizontal, mdiClose } from '@mdi/js'; import { createEventDispatcher } from 'svelte'; + import IconButton from '../elements/buttons/icon-button.svelte'; - export let type: Type = 'button'; + export let id: string | undefined = undefined; export let options: ComboBoxOption[] = []; - export let selectedOption: ComboBoxOption | undefined = undefined; + export let selectedOption: ComboBoxOption | undefined; export let placeholder = ''; - export const label = ''; - export let noLabel = false; let isOpen = false; - let searchQuery = ''; + let searchQuery = selectedOption?.label || ''; $: filteredOptions = options.filter((option) => option.label.toLowerCase().includes(searchQuery.toLowerCase())); const dispatch = createEventDispatcher<{ - select: ComboBoxOption; + select: ComboBoxOption | undefined; click: void; }>(); - let handleClick = () => { + const handleClick = () => { searchQuery = ''; - isOpen = !isOpen; + isOpen = true; dispatch('click'); }; let handleOutClick = () => { - searchQuery = ''; isOpen = false; }; @@ -51,49 +44,77 @@ dispatch('select', option); isOpen = false; }; + + const onClear = () => { + selectedOption = undefined; + searchQuery = ''; + dispatch('select', selectedOption); + };
- +
{#if isOpen}
-
-
-
- -
-
- - - -
-
- {#each filteredOptions as option (option.label)} - - {/each} -
+ {#if filteredOptions.length === 0} +
No results
+ {/if} + {#each filteredOptions as option (option.label)} + {@const selected = option.label === selectedOption?.label} + + {/each}
{/if}
diff --git a/web/src/lib/components/shared-components/search-bar/search-filter-box.svelte b/web/src/lib/components/shared-components/search-bar/search-filter-box.svelte index 71b2c9d3a5..00ceb6a872 100644 --- a/web/src/lib/components/shared-components/search-bar/search-filter-box.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-filter-box.svelte @@ -404,8 +404,9 @@
-

Country

+
-

State

+
-

City

+
-

Make

+
-

Model

+ Date: Thu, 22 Feb 2024 08:16:56 -0500 Subject: [PATCH 08/18] chore(deps): update base-image to v20240222 (major) (#7338) chore(deps): update base-image to v20240222 Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- server/Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/Dockerfile b/server/Dockerfile index 9a7fc31fa2..7ea2795ea7 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,5 +1,5 @@ # dev build -FROM ghcr.io/immich-app/base-server-dev:20240213@sha256:16646a37bae065b51e68cb2ba7a63027b29504d43a30644625382afbe326114a as dev +FROM ghcr.io/immich-app/base-server-dev:20240222@sha256:2ff467d6ae5c00a2317eb7b13cb40ba5be0fd33c160175dba621b1bf72bc1cd1 as dev RUN apt-get install --no-install-recommends -yqq tini WORKDIR /usr/src/app @@ -40,7 +40,7 @@ RUN npm run build # prod build -FROM ghcr.io/immich-app/base-server-prod:20240213@sha256:61d159d069c5b522f16de9733fb79feb0e82c0b099d16f026196f344d12a1e5e +FROM ghcr.io/immich-app/base-server-prod:20240222@sha256:9ae5eebf95cf7759eec9dcfbd9e48a722701075ac855209f2e0b01c631b76f5c WORKDIR /usr/src/app ENV NODE_ENV=production \ From ec55acc98c293ccbf3eb73c4fa1d788eba079639 Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Thu, 22 Feb 2024 14:48:27 +0100 Subject: [PATCH 09/18] perf(server): optimize mapAsset (#7331) --- .../asset/response-dto/asset-response.dto.ts | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/server/src/domain/asset/response-dto/asset-response.dto.ts b/server/src/domain/asset/response-dto/asset-response.dto.ts index 94a9f8a42d..4cc0bd6672 100644 --- a/server/src/domain/asset/response-dto/asset-response.dto.ts +++ b/server/src/domain/asset/response-dto/asset-response.dto.ts @@ -73,23 +73,21 @@ const peopleWithFaces = (faces: AssetFaceEntity[]): PersonWithFacesResponseDto[] export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): AssetResponseDto { const { stripMetadata = false, withStack = false } = options; - const sanitizedAssetResponse: SanitizedAssetResponseDto = { - id: entity.id, - type: entity.type, - thumbhash: entity.thumbhash?.toString('base64') ?? null, - localDateTime: entity.localDateTime, - resized: !!entity.resizePath, - duration: entity.duration ?? '0:00:00.00000', - livePhotoVideoId: entity.livePhotoVideoId, - hasMetadata: false, - }; - if (stripMetadata) { + const sanitizedAssetResponse: SanitizedAssetResponseDto = { + id: entity.id, + type: entity.type, + thumbhash: entity.thumbhash?.toString('base64') ?? null, + localDateTime: entity.localDateTime, + resized: !!entity.resizePath, + duration: entity.duration ?? '0:00:00.00000', + livePhotoVideoId: entity.livePhotoVideoId, + hasMetadata: false, + }; return sanitizedAssetResponse as AssetResponseDto; } return { - ...sanitizedAssetResponse, id: entity.id, deviceAssetId: entity.deviceAssetId, ownerId: entity.ownerId, From e3cccba78c836f969af9f42654637b4f139ec45c Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Thu, 22 Feb 2024 14:50:46 +0100 Subject: [PATCH 10/18] fix(server): out of memory when unstacking assets (#7332) --- server/src/domain/asset/asset.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/domain/asset/asset.service.ts b/server/src/domain/asset/asset.service.ts index 325bb8ea4c..f328b5dcf6 100644 --- a/server/src/domain/asset/asset.service.ts +++ b/server/src/domain/asset/asset.service.ts @@ -326,7 +326,7 @@ export class AssetService { const stackIdsToCheckForDelete: string[] = []; if (removeParent) { (options as Partial).stack = null; - const assets = await this.assetRepository.getByIds(ids); + const assets = await this.assetRepository.getByIds(ids, { stack: true }); stackIdsToCheckForDelete.push(...new Set(assets.filter((a) => !!a.stackId).map((a) => a.stackId!))); // This updates the updatedAt column of the parents to indicate that one of its children is removed // All the unique parent's -> parent is set to null From 75947ab6c28740e46ef9108f93c107d8693b5684 Mon Sep 17 00:00:00 2001 From: martin <74269598+martabal@users.noreply.github.com> Date: Thu, 22 Feb 2024 15:04:43 +0100 Subject: [PATCH 11/18] feat(web): search albums (#7322) * feat: search albums * pr feedback * fix: comparison * pr feedback * simplify * chore: more compact album padding --------- Co-authored-by: Jason Rasmussen --- .../{faces-page => elements}/search-bar.svelte | 9 +++++---- .../lib/components/faces-page/people-list.svelte | 5 +++-- .../lib/components/layouts/user-page-layout.svelte | 2 +- web/src/routes/(user)/albums/+page.svelte | 13 ++++++++++--- web/src/routes/(user)/albums/[albumId]/+page.svelte | 8 ++++---- web/src/routes/(user)/people/+page.svelte | 5 +++-- 6 files changed, 26 insertions(+), 16 deletions(-) rename web/src/lib/components/{faces-page => elements}/search-bar.svelte (88%) diff --git a/web/src/lib/components/faces-page/search-bar.svelte b/web/src/lib/components/elements/search-bar.svelte similarity index 88% rename from web/src/lib/components/faces-page/search-bar.svelte rename to web/src/lib/components/elements/search-bar.svelte index e1f999dbca..9c6eded224 100644 --- a/web/src/lib/components/faces-page/search-bar.svelte +++ b/web/src/lib/components/elements/search-bar.svelte @@ -1,12 +1,13 @@ diff --git a/web/src/routes/(user)/albums/+page.svelte b/web/src/routes/(user)/albums/+page.svelte index fa0115d828..07000d9136 100644 --- a/web/src/routes/(user)/albums/+page.svelte +++ b/web/src/routes/(user)/albums/+page.svelte @@ -43,11 +43,13 @@ import { flip } from 'svelte/animate'; import type { PageData } from './$types'; import { useAlbums } from './albums.bloc'; + import SearchBar from '$lib/components/elements/search-bar.svelte'; export let data: PageData; let shouldShowEditUserForm = false; let selectedAlbum: AlbumResponseDto; + let searchAlbum = ''; let sortByOptions: Record = { albumTitle: { @@ -180,6 +182,8 @@ } } + $: albumsFiltered = $albums.filter((album) => album.albumName.toLowerCase().includes(searchAlbum.toLowerCase())); + const searchSort = (searched: string): Sort => { for (const key in sortByOptions) { if (sortByOptions[key].title === searched) { @@ -243,6 +247,9 @@
+
@@ -285,7 +292,7 @@ {#if $albumViewSettings.view === AlbumViewMode.Cover}
- {#each $albums as album, index (album.id)} + {#each albumsFiltered as album, index (album.id)} {:else if $albumViewSettings.view === AlbumViewMode.List} - +
@@ -310,7 +317,7 @@ - {#each $albums as album (album.id)} + {#each albumsFiltered as album (album.id)} goto(`${AppRoute.ALBUMS}/${album.id}`)} diff --git a/web/src/routes/(user)/albums/[albumId]/+page.svelte b/web/src/routes/(user)/albums/[albumId]/+page.svelte index 52b780acc2..7c9ca39acd 100644 --- a/web/src/routes/(user)/albums/[albumId]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId]/+page.svelte @@ -603,7 +603,7 @@ e.key === 'Enter' && titleInput.blur()} on:blur={handleUpdateName} - class="w-[99%] border-b-2 border-transparent text-6xl text-immich-primary outline-none transition-all dark:text-immich-dark-primary {isOwned + class="w-[99%] mb-2 border-b-2 border-transparent text-6xl text-immich-primary outline-none transition-all dark:text-immich-dark-primary {isOwned ? 'hover:border-gray-400' : 'hover:border-transparent'} bg-immich-bg focus:border-b-2 focus:border-immich-primary focus:outline-none dark:bg-immich-dark-bg dark:focus:border-immich-dark-primary dark:focus:bg-immich-dark-gray" type="text" @@ -616,7 +616,7 @@ {#if album.assetCount > 0} - +

{getDateRange()}

·

{album.assetCount} items

@@ -625,7 +625,7 @@ {#if album.sharedUsers.length > 0 || (album.hasSharedLink && isOwned)} -
+
{#if album.hasSharedLink && isOwned} {#if isOwned}