From 01706ccf5c7c9d7f1d53222741485aa81cbd853d Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Sat, 6 Jul 2024 00:02:07 -0400 Subject: [PATCH 01/14] docs: cursed knowledge (#10907) --- docs/src/pages/cursed-knowledge.tsx | 77 +++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 docs/src/pages/cursed-knowledge.tsx diff --git a/docs/src/pages/cursed-knowledge.tsx b/docs/src/pages/cursed-knowledge.tsx new file mode 100644 index 0000000000..9dc78807fa --- /dev/null +++ b/docs/src/pages/cursed-knowledge.tsx @@ -0,0 +1,77 @@ +import { mdiCalendarToday, mdiLeadPencil, mdiLockOutline, mdiSpeedometerSlow, mdiWeb } from '@mdi/js'; +import Layout from '@theme/Layout'; +import React from 'react'; +import { Item as TimelineItem, Timeline } from '../components/timeline'; + +const withLanguage = (date: Date) => (language: string) => date.toLocaleDateString(language); + +type Item = Omit & { date: Date }; + +const items: Item[] = [ + { + icon: mdiLeadPencil, + iconColor: 'gold', + title: 'PostgreSQL NOTIFY is cursed', + description: + 'PostgreSQL does everything in a transaction, including NOTIFY. This means using the socket.io postgres-adapter writes to WAL every 5 seconds.', + link: { url: 'https://github.com/immich-app/immich/pull/10801', text: '#10801' }, + date: new Date(2024, 6, 3), + }, + { + icon: mdiWeb, + iconColor: 'lightskyblue', + title: 'npm scripts are cursed', + description: + 'npm scripts make a http call to the npm registry each time they run, which means they are a terrible way to execute a health check.', + link: { url: 'https://github.com/immich-app/immich/issues/10796', text: '#10796' }, + date: new Date(2024, 6, 3), + }, + { + icon: mdiSpeedometerSlow, + iconColor: 'brown', + title: '50 extra packages are cursed', + description: + 'There is a user in the JavaScript community who goes around adding "backwards compatibility" to projects. They do this by adding 50 extra package dependencies to your project, which are maintained by them.', + link: { url: 'https://github.com/immich-app/immich/pull/10690', text: '#10690' }, + date: new Date(2024, 5, 28), + }, + { + icon: mdiLockOutline, + iconColor: 'gold', + title: 'Long passwords are cursed', + description: + 'The bcrypt implementation only uses the first 72 bytes of a string. Any characters after that are ignored.', + // link: GHSA-4p64-9f7h-3432 + date: new Date(2024, 5, 25), + }, + { + icon: mdiCalendarToday, + iconColor: 'greenyellow', + title: 'JavaScript Date objects are cursed', + description: 'JavaScript date objects are 1 indexed for years and days, but 0 indexed for months.', + link: { url: 'https://github.com/immich-app/immich/pulls/6787', text: '#6787' }, + date: new Date(2024, 0, 31), + }, +]; + +export default function CursedKnowledgePage(): JSX.Element { + return ( + +
+

+ Cursed Knowledge +

+

+ Cursed knowledge we have learned as a result of building Immich that we wish we never knew. +

+
+ b.date.getTime() - a.date.getTime()) + .map((item) => ({ ...item, getDateLabel: withLanguage(item.date) }))} + /> +
+
+
+ ); +} From 59cdbdc4924668314d7faac7fdacaa4685600874 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Sat, 6 Jul 2024 10:32:38 -0400 Subject: [PATCH 02/14] refactor(server): use .toSorted (#10904) --- server/src/repositories/storage.repository.spec.ts | 2 +- server/src/services/asset-media.service.spec.ts | 6 ++---- server/src/utils/mime-types.spec.ts | 4 ++-- server/tsconfig.json | 1 + 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/server/src/repositories/storage.repository.spec.ts b/server/src/repositories/storage.repository.spec.ts index e770ce6c1b..22a0ff8ddc 100644 --- a/server/src/repositories/storage.repository.spec.ts +++ b/server/src/repositories/storage.repository.spec.ts @@ -202,7 +202,7 @@ describe(StorageRepository.name, () => { .filter((entry) => entry[1]) .map(([file]) => file); - expect(actual.sort()).toEqual(expected.sort()); + expect(actual.toSorted()).toEqual(expected.toSorted()); }); } }); diff --git a/server/src/services/asset-media.service.spec.ts b/server/src/services/asset-media.service.spec.ts index bf545ff3b3..3990b4c3de 100644 --- a/server/src/services/asset-media.service.spec.ts +++ b/server/src/services/asset-media.service.spec.ts @@ -255,13 +255,11 @@ describe(AssetMediaService.name, () => { } it('should be sorted (valid)', () => { - // TODO: use toSorted in NodeJS 20. - expect(valid).toEqual([...valid].sort()); + expect(valid).toEqual(valid.toSorted()); }); it('should be sorted (invalid)', () => { - // TODO: use toSorted in NodeJS 20. - expect(invalid).toEqual([...invalid].sort()); + expect(invalid).toEqual(invalid.toSorted()); }); }); } diff --git a/server/src/utils/mime-types.spec.ts b/server/src/utils/mime-types.spec.ts index cbbf751bc5..996ea6c744 100644 --- a/server/src/utils/mime-types.spec.ts +++ b/server/src/utils/mime-types.spec.ts @@ -145,7 +145,7 @@ describe('mimeTypes', () => { it('should be a sorted list', () => { const keys = Object.keys(mimeTypes.video); - expect(keys).toEqual([...keys].sort()); + expect(keys).toEqual(keys.toSorted()); }); it('should contain only video mime types', () => { @@ -171,7 +171,7 @@ describe('mimeTypes', () => { it('should be a sorted list', () => { const keys = Object.keys(mimeTypes.sidecar); - expect(keys).toEqual([...keys].sort()); + expect(keys).toEqual(keys.toSorted()); }); it('should contain only xml mime types', () => { diff --git a/server/tsconfig.json b/server/tsconfig.json index 3e84bcf7d7..1ffc110e83 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -10,6 +10,7 @@ "resolveJsonModule": true, "target": "es2022", "moduleResolution": "node16", + "lib": ["dom", "es2023"], "sourceMap": true, "outDir": "./dist", "incremental": true, From d582ec02b1b763b3de64deaaa26ac8529f976713 Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Sun, 7 Jul 2024 13:20:28 +0200 Subject: [PATCH 03/14] fix(server): reset-admin-password command (#10939) * fix(server): reset-admin-password command * fix immichCli --- .../specs/immich-admin.e2e-spec.ts | 21 ++++++++++++++++++- e2e/src/utils.ts | 6 +++--- server/src/dtos/user.dto.ts | 2 +- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/e2e/src/immich-admin/specs/immich-admin.e2e-spec.ts b/e2e/src/immich-admin/specs/immich-admin.e2e-spec.ts index 707093ab25..d025b7a338 100644 --- a/e2e/src/immich-admin/specs/immich-admin.e2e-spec.ts +++ b/e2e/src/immich-admin/specs/immich-admin.e2e-spec.ts @@ -9,11 +9,30 @@ describe(`immich-admin`, () => { describe('list-users', () => { it('should list the admin user', async () => { - const { stdout, stderr, exitCode } = await immichAdmin(['list-users']); + const { stdout, stderr, exitCode } = await immichAdmin(['list-users']).promise; expect(exitCode).toBe(0); expect(stderr).toBe(''); expect(stdout).toContain("email: 'admin@immich.cloud'"); expect(stdout).toContain("name: 'Immich Admin'"); }); }); + + describe('reset-admin-password', () => { + it('should reset admin password', async () => { + const { child, promise } = immichAdmin(['reset-admin-password']); + + let data = ''; + child.stdout.on('data', (chunk) => { + data += chunk; + if (data.includes('Please choose a new password (optional)')) { + child.stdin.end('\n'); + } + }); + + const { stderr, stdout, exitCode } = await promise; + expect(exitCode).toBe(0); + expect(stderr).toBe(''); + expect(stdout).toContain('The admin password has been updated to:'); + }); + }); }); diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index e018e87b59..d58fbe0b69 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -64,13 +64,13 @@ export const tempDir = tmpdir(); export const asBearerAuth = (accessToken: string) => ({ Authorization: `Bearer ${accessToken}` }); export const asKeyAuth = (key: string) => ({ 'x-api-key': key }); export const immichCli = (args: string[]) => - executeCommand('node', ['node_modules/.bin/immich', '-d', `/${tempDir}/immich/`, ...args]); + executeCommand('node', ['node_modules/.bin/immich', '-d', `/${tempDir}/immich/`, ...args]).promise; export const immichAdmin = (args: string[]) => executeCommand('docker', ['exec', '-i', 'immich-e2e-server', '/bin/bash', '-c', `immich-admin ${args.join(' ')}`]); const executeCommand = (command: string, args: string[]) => { let _resolve: (value: CommandResponse) => void; - const deferred = new Promise((resolve) => (_resolve = resolve)); + const promise = new Promise((resolve) => (_resolve = resolve)); const child = spawn(command, args, { stdio: 'pipe' }); let stdout = ''; @@ -86,7 +86,7 @@ const executeCommand = (command: string, args: string[]) => { }); }); - return deferred; + return { promise, child }; }; let client: pg.Client | null = null; diff --git a/server/src/dtos/user.dto.ts b/server/src/dtos/user.dto.ts index ce9ff5c99f..54020a7397 100644 --- a/server/src/dtos/user.dto.ts +++ b/server/src/dtos/user.dto.ts @@ -140,7 +140,7 @@ export class UserAdminResponseDto extends UserResponseDto { } export function mapUserAdmin(entity: UserEntity): UserAdminResponseDto { - const license = entity.metadata.find( + const license = entity.metadata?.find( (item): item is UserMetadataEntity => item.key === UserMetadataKey.LICENSE, )?.value; return { From a5467d60eaa9e140758f61ab2cfc4d9680514b20 Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Sun, 7 Jul 2024 13:21:17 +0200 Subject: [PATCH 04/14] fix(web): external library disk usage unit (#10935) --- web/src/routes/admin/library-management/+page.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/routes/admin/library-management/+page.svelte b/web/src/routes/admin/library-management/+page.svelte index 51be89ae40..011726e63c 100644 --- a/web/src/routes/admin/library-management/+page.svelte +++ b/web/src/routes/admin/library-management/+page.svelte @@ -337,7 +337,7 @@ {totalCount[index]} - {diskUsage[index]} {diskUsageUnit[index]} + {diskUsage[index]} {ByteUnit[diskUsageUnit[index]]} {/if} From 39221c8d1f08646464f13780db55c05cded0e7a8 Mon Sep 17 00:00:00 2001 From: Ben <45583362+ben-basten@users.noreply.github.com> Date: Sun, 7 Jul 2024 22:29:56 +0000 Subject: [PATCH 05/14] chore(web): translate alt text (#10922) * chore(web): translate image alt text * fix: capitalize translations, improve unit test * fix: unit testing against the actual en.json file * fix: use derived store to generate alt text --- .../asset-viewer/photo-viewer.svelte | 6 +- .../assets/thumbnail/thumbnail.svelte | 2 +- .../components/photos-page/memory-lane.svelte | 2 +- .../duplicates/duplicate-asset.svelte | 2 +- web/src/lib/i18n/en.json | 4 ++ web/src/lib/utils/thumbnail-util.spec.ts | 68 +++++++++++++++++++ web/src/lib/utils/thumbnail-util.ts | 58 +++++++++------- 7 files changed, 113 insertions(+), 29 deletions(-) create mode 100644 web/src/lib/utils/thumbnail-util.spec.ts diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index cd4010b135..966f382838 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -123,7 +123,7 @@ {getAltText(asset)} ((imageLoaded = true), (assetFileUrl = imageLoaderUrl))} on:error={() => (imageError = imageLoaded = true)} /> @@ -136,7 +136,7 @@ {#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground} {getAltText(asset)} @@ -144,7 +144,7 @@ {getAltText(asset)}

diff --git a/web/src/lib/components/utilities-page/duplicates/duplicate-asset.svelte b/web/src/lib/components/utilities-page/duplicates/duplicate-asset.svelte index 53a02a79ae..74d17c621d 100644 --- a/web/src/lib/components/utilities-page/duplicates/duplicate-asset.svelte +++ b/web/src/lib/components/utilities-page/duplicates/duplicate-asset.svelte @@ -32,7 +32,7 @@ {getAltText(asset)} { + beforeAll(async () => { + await init({ fallbackLocale: 'en-US' }); + register('en-US', () => import('$lib/i18n/en.json')); + await waitLocale('en-US'); + }); + + it('defaults to the description, if available', () => { + const asset = { + exifInfo: { description: 'description' }, + } as AssetResponseDto; + + getAltText.subscribe((fn) => { + expect(fn(asset)).toEqual('description'); + }); + }); + + it('includes the city and country', () => { + const asset = { + exifInfo: { city: 'city', country: 'country' }, + localDateTime: '2024-01-01T12:00:00.000Z', + } as AssetResponseDto; + + getAltText.subscribe((fn) => { + expect(fn(asset)).toEqual('Image taken in city, country on January 1, 2024'); + }); + }); + + // convert the people tests into an it.each + it.each([ + [[{ name: 'person' }], 'Image taken with person on January 1, 2024'], + [[{ name: 'person1' }, { name: 'person2' }], 'Image taken with person1 and person2 on January 1, 2024'], + [ + [{ name: 'person1' }, { name: 'person2' }, { name: 'person3' }], + 'Image taken with person1, person2, and person3 on January 1, 2024', + ], + [ + [{ name: 'person1' }, { name: 'person2' }, { name: 'person3' }, { name: 'person4' }], + 'Image taken with person1, person2, and 2 others on January 1, 2024', + ], + ])('includes people, correctly formatted', (people, expected) => { + const asset = { + localDateTime: '2024-01-01T12:00:00.000Z', + people, + } as AssetResponseDto; + + getAltText.subscribe((fn) => { + expect(fn(asset)).toEqual(expected); + }); + }); + + it('handles videos, location, people, and date', () => { + const asset = { + exifInfo: { city: 'city', country: 'country' }, + localDateTime: '2024-01-01T12:00:00.000Z', + people: [{ name: 'person1' }, { name: 'person2' }, { name: 'person3' }, { name: 'person4' }, { name: 'person5' }], + type: AssetTypeEnum.Video, + } as AssetResponseDto; + + getAltText.subscribe((fn) => { + expect(fn(asset)).toEqual('Video taken in city, country with person1, person2, and 3 others on January 1, 2024'); + }); + }); +}); diff --git a/web/src/lib/utils/thumbnail-util.ts b/web/src/lib/utils/thumbnail-util.ts index d349c4e830..fef0c6dd6a 100644 --- a/web/src/lib/utils/thumbnail-util.ts +++ b/web/src/lib/utils/thumbnail-util.ts @@ -1,4 +1,6 @@ -import type { AssetResponseDto } from '@immich/sdk'; +import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk'; +import { t } from 'svelte-i18n'; +import { derived } from 'svelte/store'; import { fromLocalDateTime } from './timeline-util'; /** @@ -35,29 +37,39 @@ export function getThumbnailSize(assetCount: number, viewWidth: number): number return 300; } -export function getAltText(asset: AssetResponseDto) { - if (asset.exifInfo?.description) { - return asset.exifInfo.description; - } +export const getAltText = derived(t, ($t) => { + return (asset: AssetResponseDto) => { + if (asset.exifInfo?.description) { + return asset.exifInfo.description; + } - let altText = 'Image taken'; - if (asset.exifInfo?.city && asset.exifInfo.country) { - altText += ` in ${asset.exifInfo.city}, ${asset.exifInfo.country}`; - } + let altText = $t('image_taken', { values: { isVideo: asset.type === AssetTypeEnum.Video } }); - const names = asset.people?.filter((p) => p.name).map((p) => p.name) ?? []; - if (names.length == 1) { - altText += ` with ${names[0]}`; - } - if (names.length > 1 && names.length <= 3) { - altText += ` with ${names.slice(0, -1).join(', ')} and ${names.at(-1)}`; - } - if (names.length > 3) { - altText += ` with ${names.slice(0, 2).join(', ')}, and ${names.length - 2} others`; - } + if (asset.exifInfo?.city && asset.exifInfo?.country) { + const placeText = $t('image_alt_text_place', { + values: { city: asset.exifInfo.city, country: asset.exifInfo.country }, + }); + altText += ` ${placeText}`; + } - const date = fromLocalDateTime(asset.localDateTime).toLocaleString({ dateStyle: 'long' }); - altText += ` on ${date}`; + const names = asset.people?.filter((p) => p.name).map((p) => p.name) ?? []; + if (names.length > 0) { + const namesText = $t('image_alt_text_people', { + values: { + count: names.length, + person1: names[0], + person2: names[1], + person3: names[2], + others: names.length > 3 ? names.length - 2 : 0, + }, + }); + altText += ` ${namesText}`; + } - return altText; -} + const date = fromLocalDateTime(asset.localDateTime).toLocaleString({ dateStyle: 'long' }); + const dateText = $t('image_alt_text_date', { values: { date } }); + altText += ` ${dateText}`; + + return altText; + }; +}); From cb40db955530642a050aa4105281797fe2e831c7 Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Mon, 8 Jul 2024 03:33:07 +0200 Subject: [PATCH 06/14] refactor(web): focus trap (#10915) --- .../actions/__test__/focus-trap-test.svelte | 18 + .../lib/actions/__test__/focus-trap.spec.ts | 40 ++ web/src/lib/actions/focus-trap.ts | 55 +++ .../asset-viewer/asset-viewer.svelte | 451 +++++++++--------- .../shared-components/focus-trap.svelte | 64 --- .../full-screen-modal.svelte | 45 +- .../navigation-bar/account-info-panel.svelte | 99 ++-- 7 files changed, 407 insertions(+), 365 deletions(-) create mode 100644 web/src/lib/actions/__test__/focus-trap-test.svelte create mode 100644 web/src/lib/actions/__test__/focus-trap.spec.ts create mode 100644 web/src/lib/actions/focus-trap.ts delete mode 100644 web/src/lib/components/shared-components/focus-trap.svelte diff --git a/web/src/lib/actions/__test__/focus-trap-test.svelte b/web/src/lib/actions/__test__/focus-trap-test.svelte new file mode 100644 index 0000000000..207c880cd9 --- /dev/null +++ b/web/src/lib/actions/__test__/focus-trap-test.svelte @@ -0,0 +1,18 @@ + + + + +{#if show} +

+
+ text + +
+ + +
+{/if} diff --git a/web/src/lib/actions/__test__/focus-trap.spec.ts b/web/src/lib/actions/__test__/focus-trap.spec.ts new file mode 100644 index 0000000000..be3a97db3f --- /dev/null +++ b/web/src/lib/actions/__test__/focus-trap.spec.ts @@ -0,0 +1,40 @@ +import FocusTrapTest from '$lib/actions/__test__/focus-trap-test.svelte'; +import { render, screen } from '@testing-library/svelte'; +import userEvent from '@testing-library/user-event'; +import { tick } from 'svelte'; + +describe('focusTrap action', () => { + const user = userEvent.setup(); + + it('sets focus to the first focusable element', () => { + render(FocusTrapTest, { show: true }); + expect(document.activeElement).toEqual(screen.getByTestId('one')); + }); + + it('supports backward focus wrapping', async () => { + render(FocusTrapTest, { show: true }); + await user.keyboard('{Shift>}{Tab}{/Shift}'); + expect(document.activeElement).toEqual(screen.getByTestId('three')); + }); + + it('supports forward focus wrapping', async () => { + render(FocusTrapTest, { show: true }); + screen.getByTestId('three').focus(); + await user.keyboard('{Tab}'); + expect(document.activeElement).toEqual(screen.getByTestId('one')); + }); + + it('restores focus to the triggering element', async () => { + render(FocusTrapTest, { show: false }); + const openButton = screen.getByText('Open'); + + openButton.focus(); + openButton.click(); + await tick(); + expect(document.activeElement).toEqual(screen.getByTestId('one')); + + screen.getByText('Close').click(); + await tick(); + expect(document.activeElement).toEqual(openButton); + }); +}); diff --git a/web/src/lib/actions/focus-trap.ts b/web/src/lib/actions/focus-trap.ts new file mode 100644 index 0000000000..c854199600 --- /dev/null +++ b/web/src/lib/actions/focus-trap.ts @@ -0,0 +1,55 @@ +import { shortcuts } from '$lib/actions/shortcut'; + +const selectors = + 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; + +export function focusTrap(container: HTMLElement) { + const triggerElement = document.activeElement; + + const focusableElement = container.querySelector(selectors); + focusableElement?.focus(); + + const getFocusableElements = (): [HTMLElement | null, HTMLElement | null] => { + const focusableElements = container.querySelectorAll(selectors); + return [ + focusableElements.item(0), // + focusableElements.item(focusableElements.length - 1), + ]; + }; + + const { destroy: destroyShortcuts } = shortcuts(container, [ + { + ignoreInputFields: false, + preventDefault: false, + shortcut: { key: 'Tab' }, + onShortcut: (event) => { + const [firstElement, lastElement] = getFocusableElements(); + if (document.activeElement === lastElement) { + event.preventDefault(); + firstElement?.focus(); + } + }, + }, + { + ignoreInputFields: false, + preventDefault: false, + shortcut: { key: 'Tab', shift: true }, + onShortcut: (event) => { + const [firstElement, lastElement] = getFocusableElements(); + if (document.activeElement === firstElement) { + event.preventDefault(); + lastElement?.focus(); + } + }, + }, + ]); + + return { + destroy() { + destroyShortcuts?.(); + if (triggerElement instanceof HTMLElement) { + triggerElement.focus(); + } + }, + }; +} diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index ce4430edf1..f216d73382 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -1,7 +1,6 @@ - -
{ - trapFocus('forward', event); - }, - preventDefault: false, - }, - { - ignoreInputFields: false, - shortcut: { key: 'Tab', shift: true }, - onShortcut: (event) => { - trapFocus('backward', event); - }, - preventDefault: false, - }, - ]} -> - -
diff --git a/web/src/lib/components/shared-components/full-screen-modal.svelte b/web/src/lib/components/shared-components/full-screen-modal.svelte index d0d629ee4d..bc1253a546 100644 --- a/web/src/lib/components/shared-components/full-screen-modal.svelte +++ b/web/src/lib/components/shared-components/full-screen-modal.svelte @@ -1,7 +1,7 @@ - +
-
-
- -
- (isShowSelectAvatar = true)} - /> +
+ +
+ (isShowSelectAvatar = true)} + /> +
+
+
+

+ {$user.name} +

+

{$user.email}

+
+ + dispatch('close')}> +
-
-

- {$user.name} -

-

{$user.email}

-
- -
dispatch('close')}> - - -
- -
- -
+ +
- + +
+ +
+
{#if isShowSelectAvatar} Date: Mon, 8 Jul 2024 03:33:59 +0200 Subject: [PATCH 07/14] refactor(web): show & hide people (#10933) --- .../assets/thumbnail/image-thumbnail.svelte | 4 +- .../manage-people-visibility.spec.ts | 108 +++++++++ .../manage-people-visibility.svelte | 162 ++++++++++++++ .../components/faces-page/show-hide.svelte | 81 ------- web/src/routes/(user)/people/+page.svelte | 205 +++--------------- web/src/test-data/factories/person-factory.ts | 12 + 6 files changed, 312 insertions(+), 260 deletions(-) create mode 100644 web/src/lib/components/faces-page/manage-people-visibility.spec.ts create mode 100644 web/src/lib/components/faces-page/manage-people-visibility.svelte delete mode 100644 web/src/lib/components/faces-page/show-hide.svelte create mode 100644 web/src/test-data/factories/person-factory.ts diff --git a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte index 8e5b739001..73a63a4a8a 100644 --- a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte @@ -19,7 +19,7 @@ export let hidden = false; export let border = false; export let preload = true; - export let eyeColor: 'black' | 'white' = 'white'; + export let hiddenIconClass = 'text-white'; let complete = false; let img: HTMLImageElement; @@ -54,7 +54,7 @@ {#if hidden}
- +
{/if} diff --git a/web/src/lib/components/faces-page/manage-people-visibility.spec.ts b/web/src/lib/components/faces-page/manage-people-visibility.spec.ts new file mode 100644 index 0000000000..e9120553ae --- /dev/null +++ b/web/src/lib/components/faces-page/manage-people-visibility.spec.ts @@ -0,0 +1,108 @@ +import { sdkMock } from '$lib/__mocks__/sdk.mock'; +import ManagePeopleVisibility from '$lib/components/faces-page/manage-people-visibility.svelte'; +import type { PersonResponseDto } from '@immich/sdk'; +import { personFactory } from '@test-data/factories/person-factory'; +import { render } from '@testing-library/svelte'; + +describe('ManagePeopleVisibility Component', () => { + let personVisible: PersonResponseDto; + let personHidden: PersonResponseDto; + let personWithoutName: PersonResponseDto; + + beforeAll(() => { + // Prevents errors from `img.decode()` in ImageThumbnail + Object.defineProperty(HTMLImageElement.prototype, 'decode', { + value: vi.fn(), + }); + }); + + beforeEach(() => { + personVisible = personFactory.build({ isHidden: false }); + personHidden = personFactory.build({ isHidden: true }); + personWithoutName = personFactory.build({ isHidden: false, name: undefined }); + sdkMock.updatePeople.mockResolvedValue([]); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it('does not update people when no changes are made', () => { + const { getByText } = render(ManagePeopleVisibility, { + props: { + people: [personVisible, personHidden, personWithoutName], + onClose: vi.fn(), + }, + }); + + const saveButton = getByText('done'); + saveButton.click(); + expect(sdkMock.updatePeople).not.toHaveBeenCalled(); + }); + + it('hides unnamed people on first button press', () => { + const { getByText, getByTitle } = render(ManagePeopleVisibility, { + props: { + people: [personVisible, personHidden, personWithoutName], + onClose: vi.fn(), + }, + }); + + const toggleButton = getByTitle('toggle_visibility'); + toggleButton.click(); + const saveButton = getByText('done'); + saveButton.click(); + + expect(sdkMock.updatePeople).toHaveBeenCalledWith({ + peopleUpdateDto: { + people: [{ id: personWithoutName.id, isHidden: true }], + }, + }); + }); + + it('hides all people on second button press', () => { + const { getByText, getByTitle } = render(ManagePeopleVisibility, { + props: { + people: [personVisible, personHidden, personWithoutName], + onClose: vi.fn(), + }, + }); + + const toggleButton = getByTitle('toggle_visibility'); + toggleButton.click(); + toggleButton.click(); + const saveButton = getByText('done'); + saveButton.click(); + + expect(sdkMock.updatePeople).toHaveBeenCalledWith({ + peopleUpdateDto: { + people: expect.arrayContaining([ + { id: personVisible.id, isHidden: true }, + { id: personWithoutName.id, isHidden: true }, + ]), + }, + }); + }); + + it('shows all people on third button press', () => { + const { getByText, getByTitle } = render(ManagePeopleVisibility, { + props: { + people: [personVisible, personHidden, personWithoutName], + onClose: vi.fn(), + }, + }); + + const toggleButton = getByTitle('toggle_visibility'); + toggleButton.click(); + toggleButton.click(); + toggleButton.click(); + const saveButton = getByText('done'); + saveButton.click(); + + expect(sdkMock.updatePeople).toHaveBeenCalledWith({ + peopleUpdateDto: { + people: [{ id: personHidden.id, isHidden: false }], + }, + }); + }); +}); diff --git a/web/src/lib/components/faces-page/manage-people-visibility.svelte b/web/src/lib/components/faces-page/manage-people-visibility.svelte new file mode 100644 index 0000000000..8744950111 --- /dev/null +++ b/web/src/lib/components/faces-page/manage-people-visibility.svelte @@ -0,0 +1,162 @@ + + + + + + +
+
+ +
+

{$t('show_and_hide_people')}

+

({people.length.toLocaleString($locale)})

+
+
+
+
+ + +
+ {#if !showLoadingSpinner} + + {:else} + + {/if} +
+
+ +
+
+ {#each people as person, index (person.id)} + + {/each} +
+
diff --git a/web/src/lib/components/faces-page/show-hide.svelte b/web/src/lib/components/faces-page/show-hide.svelte deleted file mode 100644 index 5c476d396b..0000000000 --- a/web/src/lib/components/faces-page/show-hide.svelte +++ /dev/null @@ -1,81 +0,0 @@ - - - - -
-
-
- -
-

{$t('show_and_hide_people')}

-

({countTotalPeople.toLocaleString($locale)})

-
-
-
-
- - onChange(getNextVisibility(toggleVisibility))} - /> -
- {#if !showLoadingSpinner} - - {:else} - - {/if} -
-
- -
- -
-
diff --git a/web/src/routes/(user)/people/+page.svelte b/web/src/routes/(user)/people/+page.svelte index 5a70ecde86..1b646d7d08 100644 --- a/web/src/routes/(user)/people/+page.svelte +++ b/web/src/routes/(user)/people/+page.svelte @@ -1,13 +1,14 @@ - + {#if showMergeModal} - {#if countTotalPeople > 0} + {#if people.length > 0}