Compare commits

...

6 Commits

Author SHA1 Message Date
renovate[bot]
0a676faced chore(deps): update dependency lodash to v4.17.23 [security] 2026-01-22 05:00:19 +00:00
Daniel Dietzler
2dcb4efc40 fix: lock tags column on update (#25435) 2026-01-21 21:20:05 -05:00
Alex
2f1d1edf10 chore: use context menu for library table (#25429)
* chore: use context menu for library table

* chore: add user detail link and menu divider

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
2026-01-21 15:07:11 -06:00
Jason Rasmussen
1b032339aa refactor(web): asset job actions (#25426) 2026-01-21 13:13:16 -05:00
Jason Rasmussen
dc82c13ddc refactor(web): user setting actions (#25424) 2026-01-21 13:13:07 -05:00
Jason Rasmussen
417af66f30 refactor(web): on person thumbnail (#25422) 2026-01-21 13:13:02 -05:00
18 changed files with 225 additions and 148 deletions

53
pnpm-lock.yaml generated
View File

@@ -36,7 +36,7 @@ importers:
version: 1.20.1
lodash-es:
specifier: ^4.17.21
version: 4.17.22
version: 4.17.23
micromatch:
specifier: ^4.0.8
version: 4.0.8
@@ -489,7 +489,7 @@ importers:
version: 3.0.0(kysely@0.28.2)(postgres@3.4.8)
lodash:
specifier: ^4.17.21
version: 4.17.21
version: 4.17.23
luxon:
specifier: ^3.4.2
version: 3.7.2
@@ -802,7 +802,7 @@ importers:
version: 4.1.0
lodash-es:
specifier: ^4.17.21
version: 4.17.22
version: 4.17.23
luxon:
specifier: ^3.4.4
version: 3.7.2
@@ -8916,8 +8916,8 @@ packages:
lodash-es@4.17.21:
resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==}
lodash-es@4.17.22:
resolution: {integrity: sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==}
lodash-es@4.17.23:
resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==}
lodash.camelcase@4.3.0:
resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==}
@@ -8964,6 +8964,9 @@ packages:
lodash@4.17.21:
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
lodash@4.17.23:
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
log-symbols@4.1.0:
resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==}
engines: {node: '>=10'}
@@ -14542,7 +14545,7 @@ snapshots:
html-tags: 3.3.1
html-webpack-plugin: 5.6.5(webpack@5.104.1)
leven: 3.1.0
lodash: 4.17.21
lodash: 4.17.23
open: 8.4.2
p-map: 4.0.0
prompts: 2.4.2
@@ -14659,7 +14662,7 @@ snapshots:
cheerio: 1.0.0-rc.12
feed: 4.2.2
fs-extra: 11.3.2
lodash: 4.17.21
lodash: 4.17.23
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
schema-dts: 1.1.5
@@ -14701,7 +14704,7 @@ snapshots:
combine-promises: 1.2.0
fs-extra: 11.3.2
js-yaml: 4.1.1
lodash: 4.17.21
lodash: 4.17.23
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
schema-dts: 1.1.5
@@ -15014,7 +15017,7 @@ snapshots:
'@mdx-js/react': 3.1.1(@types/react@19.2.8)(react@18.3.1)
clsx: 2.1.1
infima: 0.2.0-alpha.45
lodash: 4.17.21
lodash: 4.17.23
nprogress: 0.2.0
postcss: 8.5.6
prism-react-renderer: 2.4.1(react@18.3.1)
@@ -15112,7 +15115,7 @@ snapshots:
clsx: 2.1.1
eta: 2.2.0
fs-extra: 11.3.2
lodash: 4.17.21
lodash: 4.17.23
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
tslib: 2.8.1
@@ -15187,7 +15190,7 @@ snapshots:
fs-extra: 11.3.2
joi: 17.13.3
js-yaml: 4.1.1
lodash: 4.17.21
lodash: 4.17.23
tslib: 2.8.1
transitivePeerDependencies:
- '@swc/core'
@@ -15212,7 +15215,7 @@ snapshots:
gray-matter: 4.0.3
jiti: 1.21.7
js-yaml: 4.1.1
lodash: 4.17.21
lodash: 4.17.23
micromatch: 4.0.8
p-queue: 6.6.2
prompts: 2.4.2
@@ -15597,7 +15600,7 @@ snapshots:
dependencies:
'@nestjs/common': 11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': 11.1.12(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.12)(@nestjs/websockets@11.1.12)(reflect-metadata@0.2.2)(rxjs@7.8.2)
lodash: 4.17.21
lodash: 4.17.23
'@grpc/grpc-js@1.14.3':
dependencies:
@@ -18842,7 +18845,7 @@ snapshots:
graceful-fs: 4.2.11
is-stream: 2.0.1
lazystream: 1.0.1
lodash: 4.17.21
lodash: 4.17.23
normalize-path: 3.0.0
readable-stream: 4.7.0
@@ -19332,7 +19335,7 @@ snapshots:
chevrotain-allstar@0.3.1(chevrotain@11.0.3):
dependencies:
chevrotain: 11.0.3
lodash-es: 4.17.22
lodash-es: 4.17.23
chevrotain@11.0.3:
dependencies:
@@ -20027,7 +20030,7 @@ snapshots:
dagre-d3-es@7.0.13:
dependencies:
d3: 7.9.0
lodash-es: 4.17.22
lodash-es: 4.17.23
data-urls@3.0.2:
dependencies:
@@ -21575,7 +21578,7 @@ snapshots:
dependencies:
'@types/html-minifier-terser': 6.1.0
html-minifier-terser: 6.1.0
lodash: 4.17.21
lodash: 4.17.23
pretty-error: 4.0.0
tapable: 2.3.0
optionalDependencies:
@@ -21769,7 +21772,7 @@ snapshots:
cli-cursor: 3.1.0
cli-width: 3.0.0
figures: 3.2.0
lodash: 4.17.21
lodash: 4.17.23
mute-stream: 0.0.8
ora: 5.4.1
run-async: 2.4.1
@@ -22376,7 +22379,7 @@ snapshots:
lodash-es@4.17.21: {}
lodash-es@4.17.22: {}
lodash-es@4.17.23: {}
lodash.camelcase@4.3.0: {}
@@ -22408,6 +22411,8 @@ snapshots:
lodash@4.17.21: {}
lodash@4.17.23: {}
log-symbols@4.1.0:
dependencies:
chalk: 4.1.2
@@ -22810,7 +22815,7 @@ snapshots:
dompurify: 3.3.1
katex: 0.16.27
khroma: 2.1.0
lodash-es: 4.17.22
lodash-es: 4.17.23
marked: 16.4.2
roughjs: 4.6.6
stylis: 4.3.6
@@ -23383,7 +23388,7 @@ snapshots:
node-emoji@1.11.0:
dependencies:
lodash: 4.17.21
lodash: 4.17.23
node-emoji@2.2.0:
dependencies:
@@ -24384,7 +24389,7 @@ snapshots:
pretty-error@4.0.0:
dependencies:
lodash: 4.17.21
lodash: 4.17.23
renderkid: 3.0.0
pretty-format@27.5.1:
@@ -24857,7 +24862,7 @@ snapshots:
css-select: 4.3.0
dom-converter: 0.2.0
htmlparser2: 6.1.0
lodash: 4.17.21
lodash: 4.17.23
strip-ansi: 6.0.1
repeat-string@1.6.1: {}
@@ -25714,7 +25719,7 @@ snapshots:
json-source-map: 0.6.1
jsonpath-plus: 10.3.0
jsonrepair: 3.13.1
lodash-es: 4.17.22
lodash-es: 4.17.23
memoize-one: 6.0.0
natural-compare-lite: 1.4.0
sass: 1.97.1

View File

@@ -206,15 +206,15 @@ describe(TagService.name, () => {
count: 6,
});
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
{ assetId: 'asset-1', tags: ['tag-1', 'tag-2'] },
{ assetId: 'asset-1', lockedProperties: ['tags'], tags: ['tag-1', 'tag-2'] },
{ lockedPropertiesBehavior: 'append' },
);
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
{ assetId: 'asset-2', tags: ['tag-1', 'tag-2'] },
{ assetId: 'asset-2', lockedProperties: ['tags'], tags: ['tag-1', 'tag-2'] },
{ lockedPropertiesBehavior: 'append' },
);
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
{ assetId: 'asset-3', tags: ['tag-1', 'tag-2'] },
{ assetId: 'asset-3', lockedProperties: ['tags'], tags: ['tag-1', 'tag-2'] },
{ lockedPropertiesBehavior: 'append' },
);
expect(mocks.tag.upsertAssetIds).toHaveBeenCalledWith([
@@ -255,11 +255,11 @@ describe(TagService.name, () => {
]);
expect(mocks.asset.upsertExif).not.toHaveBeenCalledWith(
{ assetId: 'asset-1', tags: ['tag-1'] },
{ assetId: 'asset-1', lockedProperties: ['tags'], tags: ['tag-1'] },
{ lockedPropertiesBehavior: 'append' },
);
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
{ assetId: 'asset-2', tags: ['tag-1'] },
{ assetId: 'asset-2', lockedProperties: ['tags'], tags: ['tag-1'] },
{ lockedPropertiesBehavior: 'append' },
);
expect(mocks.tag.getAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1', 'asset-2']);

View File

@@ -16,6 +16,7 @@ import { JobName, JobStatus, Permission, QueueName } from 'src/enum';
import { TagAssetTable } from 'src/schema/tables/tag-asset.table';
import { BaseService } from 'src/services/base.service';
import { addAssets, removeAssets } from 'src/utils/asset.util';
import { updateLockedColumns } from 'src/utils/database';
import { upsertTags } from 'src/utils/tag';
@Injectable()
@@ -152,7 +153,7 @@ export class TagService extends BaseService {
private async updateTags(assetId: string) {
const asset = await this.assetRepository.getById(assetId, { tags: true });
await this.assetRepository.upsertExif(
{ assetId, tags: asset?.tags?.map(({ value }) => value) ?? [] },
updateLockedColumns({ assetId, tags: asset?.tags?.map(({ value }) => value) ?? [] }),
{ lockedPropertiesBehavior: 'append' },
);
}

View File

@@ -1,12 +1,15 @@
import { Kysely } from 'kysely';
import { JobStatus } from 'src/enum';
import { AccessRepository } from 'src/repositories/access.repository';
import { AssetRepository } from 'src/repositories/asset.repository';
import { EventRepository } from 'src/repositories/event.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { TagRepository } from 'src/repositories/tag.repository';
import { DB } from 'src/schema';
import { TagService } from 'src/services/tag.service';
import { upsertTags } from 'src/utils/tag';
import { newMediumService } from 'test/medium.factory';
import { factory } from 'test/small.factory';
import { getKyselyDB } from 'test/utils';
let defaultDatabase: Kysely<DB>;
@@ -14,8 +17,8 @@ let defaultDatabase: Kysely<DB>;
const setup = (db?: Kysely<DB>) => {
return newMediumService(TagService, {
database: db || defaultDatabase,
real: [TagRepository, AccessRepository],
mock: [LoggingRepository],
real: [AssetRepository, TagRepository, AccessRepository],
mock: [EventRepository, LoggingRepository],
});
};
@@ -24,6 +27,32 @@ beforeAll(async () => {
});
describe(TagService.name, () => {
describe('addAssets', () => {
it('should lock exif column', async () => {
const { sut, ctx } = setup();
ctx.getMock(EventRepository).emit.mockResolvedValue();
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
const [tag] = await upsertTags(ctx.get(TagRepository), { userId: user.id, tags: ['tag-1'] });
const authDto = factory.auth({ user });
await sut.addAssets(authDto, tag.id, { ids: [asset.id] });
await expect(
ctx.database
.selectFrom('asset_exif')
.select(['lockedProperties', 'tags'])
.where('assetId', '=', asset.id)
.executeTakeFirstOrThrow(),
).resolves.toEqual({
lockedProperties: ['tags'],
tags: ['tag-1'],
});
await expect(ctx.get(TagRepository).getByValue(user.id, 'tag-1')).resolves.toEqual(
expect.objectContaining({ id: tag.id }),
);
await expect(ctx.get(TagRepository).getAssetIds(tag.id, [asset.id])).resolves.toContain(asset.id);
});
});
describe('deleteEmptyTags', () => {
it('single tag exists, not connected to any assets, and is deleted', async () => {
const { sut, ctx } = setup();

View File

@@ -16,7 +16,7 @@ describe('AssetViewerNavBar component', () => {
preAction: () => {},
onZoomImage: () => {},
onAction: () => {},
onRunJob: () => {},
onEdit: () => {},
onPlaySlideshow: () => {},
onClose: () => {},
playOriginalVideo: false,

View File

@@ -28,12 +28,11 @@
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
import { user } from '$lib/stores/user.store';
import { photoZoomState } from '$lib/stores/zoom-image.store';
import { getAssetJobName, getSharedLink, withoutIcons } from '$lib/utils';
import { getSharedLink, withoutIcons } from '$lib/utils';
import type { OnUndoDelete } from '$lib/utils/actions';
import { canCopyImageToClipboard } from '$lib/utils/asset-utils';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import {
AssetJobName,
AssetTypeEnum,
AssetVisibility,
type AlbumResponseDto,
@@ -44,13 +43,9 @@
import { CommandPaletteDefaultProvider, IconButton, type ActionItem } from '@immich/ui';
import {
mdiArrowLeft,
mdiCogRefreshOutline,
mdiCompare,
mdiContentCopy,
mdiDatabaseRefreshOutline,
mdiDotsVertical,
mdiHeadSyncOutline,
mdiImageRefreshOutline,
mdiImageSearch,
mdiMagnifyMinusOutline,
mdiMagnifyPlusOutline,
@@ -71,7 +66,6 @@
preAction: PreAction;
onAction: OnAction;
onUndoDelete?: OnUndoDelete;
onRunJob: (name: AssetJobName) => void;
onPlaySlideshow: () => void;
onEdit: () => void;
onClose?: () => void;
@@ -90,7 +84,6 @@
preAction,
onAction,
onUndoDelete = undefined,
onRunJob,
onPlaySlideshow,
onClose,
onEdit,
@@ -124,6 +117,10 @@
PlayMotionPhoto,
StopMotionPhoto,
Info,
RefreshFacesJob,
RefreshMetadataJob,
RegenerateThumbnailJob,
TranscodeVideoJob,
} = $derived(getAssetActions($t, asset));
const sharedLink = getSharedLink();
@@ -140,7 +137,24 @@
<CommandPaletteDefaultProvider
name={$t('assets')}
actions={withoutIcons([Close, Share, Offline, Favorite, Unfavorite, PlayMotionPhoto, StopMotionPhoto, Info])}
actions={withoutIcons([
Close,
Cast,
Share,
Download,
DownloadOriginal,
SharedLinkDownload,
Offline,
Favorite,
Unfavorite,
PlayMotionPhoto,
StopMotionPhoto,
Info,
RefreshFacesJob,
RefreshMetadataJob,
RegenerateThumbnailJob,
TranscodeVideoJob,
])}
/>
<div
@@ -275,28 +289,10 @@
{/if}
{#if isOwner}
<hr />
<MenuOption
icon={mdiHeadSyncOutline}
onClick={() => onRunJob(AssetJobName.RefreshFaces)}
text={$getAssetJobName(AssetJobName.RefreshFaces)}
/>
<MenuOption
icon={mdiDatabaseRefreshOutline}
onClick={() => onRunJob(AssetJobName.RefreshMetadata)}
text={$getAssetJobName(AssetJobName.RefreshMetadata)}
/>
<MenuOption
icon={mdiImageRefreshOutline}
onClick={() => onRunJob(AssetJobName.RegenerateThumbnail)}
text={$getAssetJobName(AssetJobName.RegenerateThumbnail)}
/>
{#if asset.type === AssetTypeEnum.Video}
<MenuOption
icon={mdiCogRefreshOutline}
onClick={() => onRunJob(AssetJobName.TranscodeVideo)}
text={$getAssetJobName(AssetJobName.TranscodeVideo)}
/>
{/if}
<ActionMenuItem action={RefreshFacesJob} />
<ActionMenuItem action={RefreshMetadataJob} />
<ActionMenuItem action={RegenerateThumbnailJob} />
<ActionMenuItem action={TranscodeVideoJob} />
{/if}
</ButtonContextMenu>
{/if}

View File

@@ -19,7 +19,7 @@
import { alwaysLoadOriginalVideo } from '$lib/stores/preferences.store';
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { user } from '$lib/stores/user.store';
import { getAssetJobMessage, getAssetUrl, getSharedLink, handlePromiseError } from '$lib/utils';
import { getAssetUrl, getSharedLink, handlePromiseError } from '$lib/utils';
import type { OnUndoDelete } from '$lib/utils/actions';
import { navigateToAsset } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
@@ -28,18 +28,15 @@
import { preloadImageUrl } from '$lib/utils/sw-messaging';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import {
AssetJobName,
AssetTypeEnum,
getAllAlbums,
getAssetInfo,
getStack,
runAssetJobs,
type AlbumResponseDto,
type AssetResponseDto,
type PersonResponseDto,
type StackResponseDto,
} from '@immich/sdk';
import { toastManager } from '@immich/ui';
import { onDestroy, onMount, untrack } from 'svelte';
import { t } from 'svelte-i18n';
import { fly } from 'svelte/transition';
@@ -262,15 +259,6 @@
isShowEditor = !isShowEditor;
};
const handleRunJob = async (name: AssetJobName) => {
try {
await runAssetJobs({ assetJobsDto: { assetIds: [asset.id], name } });
toastManager.success($getAssetJobMessage(name));
} catch (error) {
handleError(error, $t('errors.unable_to_submit_job'));
}
};
/**
* Slide show mode
*/
@@ -473,7 +461,6 @@
onAction={handleAction}
{onUndoDelete}
onEdit={showEditor}
onRunJob={handleRunJob}
onPlaySlideshow={() => ($slideshowState = SlideshowState.PlaySlideshow)}
onClose={onClose ? () => onClose(asset) : undefined}
{playOriginalVideo}

View File

@@ -1,9 +1,9 @@
<script lang="ts">
import OnEvents from '$lib/components/OnEvents.svelte';
import { timeBeforeShowLoadingSpinner } from '$lib/constants';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
import { boundingBoxesArray } from '$lib/stores/people.store';
import { websocketEvents } from '$lib/stores/websocket';
import { getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { zoomImageToBase64 } from '$lib/utils/people-utils';
@@ -70,8 +70,8 @@
isShowLoadingPeople = false;
}
const onPersonThumbnail = (personId: string) => {
assetFaceGenerated.push(personId);
const onPersonThumbnailReady = ({ id }: { id: string }) => {
assetFaceGenerated.push(id);
if (
isEqual(assetFaceGenerated, peopleToCreate) &&
loaderLoadingDoneTimeout &&
@@ -86,7 +86,6 @@
onMount(() => {
handlePromiseError(loadPeople());
return websocketEvents.on('on_person_thumbnail', onPersonThumbnail);
});
const isEqual = (a: string[], b: string[]): boolean => {
@@ -184,6 +183,8 @@
};
</script>
<OnEvents {onPersonThumbnailReady} />
<section
transition:fly={{ x: 360, duration: 100, easing: linear }}
class="absolute top-0 h-full w-90 overflow-x-hidden p-2 dark:text-immich-dark-fg bg-light"

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
import { getAssetJobIcon, getAssetJobMessage, getAssetJobName } from '$lib/utils';
import { getAssetJobIcon, getAssetJobName } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { AssetJobName, runAssetJobs } from '@immich/sdk';
import { toastManager } from '@immich/ui';
@@ -22,7 +22,7 @@
try {
const ids = [...getOwnedAssets()].map(({ id }) => id);
await runAssetJobs({ assetJobsDto: { assetIds: ids, name } });
toastManager.success($getAssetJobMessage(name));
toastManager.success(getAssetJobName($t, name));
clearSelect();
} catch (error) {
handleError(error, $t('errors.unable_to_submit_job'));
@@ -32,6 +32,6 @@
{#each jobs as job (job)}
{#if isAllVideos || job !== AssetJobName.TranscodeVideo}
<MenuOption text={$getAssetJobName(job)} icon={getAssetJobIcon(job)} onClick={() => handleRunJob(job)} />
<MenuOption text={getAssetJobName($t, job)} icon={getAssetJobIcon(job)} onClick={() => handleRunJob(job)} />
{/if}
{/each}

View File

@@ -44,6 +44,7 @@ export type Events = {
AlbumUserDelete: [{ albumId: string; userId: string }];
PersonUpdate: [PersonResponseDto];
PersonThumbnailReady: [{ id: string }];
BackupDeleteStatus: [{ filename: string; isDeleting: boolean }];
BackupDeleted: [{ filename: string }];

View File

@@ -3,28 +3,36 @@ import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
import { user as authUser, preferences } from '$lib/stores/user.store';
import { getSharedLink, sleep } from '$lib/utils';
import { getAssetJobName, getSharedLink, sleep } from '$lib/utils';
import { downloadUrl } from '$lib/utils/asset-utils';
import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n';
import { asQueryString } from '$lib/utils/shared-links';
import {
AssetJobName,
AssetTypeEnum,
AssetVisibility,
copyAsset,
deleteAssets,
getAssetInfo,
getBaseUrl,
runAssetJobs,
updateAsset,
type AssetJobsDto,
type AssetResponseDto,
} from '@immich/sdk';
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
import {
mdiAlertOutline,
mdiCogRefreshOutline,
mdiDatabaseRefreshOutline,
mdiDownload,
mdiDownloadBox,
mdiHeadSyncOutline,
mdiHeart,
mdiHeartOutline,
mdiImageRefreshOutline,
mdiInformationOutline,
mdiMotionPauseOutline,
mdiMotionPlayOutline,
@@ -124,6 +132,31 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
shortcuts: [{ key: 'i' }],
};
const RefreshFacesJob: ActionItem = {
title: getAssetJobName($t, AssetJobName.RefreshFaces),
icon: mdiHeadSyncOutline,
onAction: () => handleRunAssetJob({ name: AssetJobName.RefreshFaces, assetIds: [asset.id] }),
};
const RefreshMetadataJob: ActionItem = {
title: getAssetJobName($t, AssetJobName.RefreshMetadata),
icon: mdiDatabaseRefreshOutline,
onAction: () => handleRunAssetJob({ name: AssetJobName.RefreshMetadata, assetIds: [asset.id] }),
};
const RegenerateThumbnailJob: ActionItem = {
title: getAssetJobName($t, AssetJobName.RegenerateThumbnail),
icon: mdiImageRefreshOutline,
onAction: () => handleRunAssetJob({ name: AssetJobName.RegenerateThumbnail, assetIds: [asset.id] }),
};
const TranscodeVideoJob: ActionItem = {
title: getAssetJobName($t, AssetJobName.TranscodeVideo),
icon: mdiCogRefreshOutline,
onAction: () => handleRunAssetJob({ name: AssetJobName.TranscodeVideo, assetIds: [asset.id] }),
$if: () => asset.type === AssetTypeEnum.Video,
};
return {
Share,
Download,
@@ -135,6 +168,10 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
Unfavorite,
PlayMotionPhoto,
StopMotionPhoto,
RefreshFacesJob,
RefreshMetadataJob,
RegenerateThumbnailJob,
TranscodeVideoJob,
};
};
@@ -217,3 +254,14 @@ export const handleReplaceAsset = async (oldAssetId: string) => {
eventManager.emit('AssetReplace', { oldAssetId, newAssetId });
};
const handleRunAssetJob = async (dto: AssetJobsDto) => {
const $t = await getFormatter();
try {
await runAssetJobs({ assetJobsDto: dto });
toastManager.success(getAssetJobName($t, dto.name));
} catch (error) {
handleError(error, $t('errors.unable_to_submit_job'));
}
};

View File

@@ -0,0 +1,14 @@
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
import { modalManager, type ActionItem } from '@immich/ui';
import { mdiKeyboard } from '@mdi/js';
import type { MessageFormatter } from 'svelte-i18n';
export const getKeyboardActions = ($t: MessageFormatter) => {
const KeyboardShortcuts: ActionItem = {
title: $t('show_keyboard_shortcuts'),
icon: mdiKeyboard,
onAction: () => modalManager.show(ShortcutsModal, {}),
};
return { KeyboardShortcuts };
};

View File

@@ -20,7 +20,7 @@ import {
type UpdateLibraryDto,
} from '@immich/sdk';
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
import { mdiPencilOutline, mdiPlusBoxOutline, mdiSync, mdiTrashCanOutline } from '@mdi/js';
import { mdiInformationOutline, mdiPencilOutline, mdiPlusBoxOutline, mdiSync, mdiTrashCanOutline } from '@mdi/js';
import type { MessageFormatter } from 'svelte-i18n';
export const getLibrariesActions = ($t: MessageFormatter, libraries: LibraryResponseDto[]) => {
@@ -45,6 +45,13 @@ export const getLibrariesActions = ($t: MessageFormatter, libraries: LibraryResp
};
export const getLibraryActions = ($t: MessageFormatter, library: LibraryResponseDto) => {
const Detail: ActionItem = {
icon: mdiInformationOutline,
type: $t('command'),
title: $t('details'),
onAction: () => goto(Route.viewLibrary(library)),
};
const Edit: ActionItem = {
icon: mdiPencilOutline,
type: $t('command'),
@@ -84,7 +91,7 @@ export const getLibraryActions = ($t: MessageFormatter, library: LibraryResponse
shortcuts: { shift: true, key: 'r' },
};
return { Edit, Delete, AddFolder, AddExclusionPattern, Scan };
return { Detail, Edit, Delete, AddFolder, AddExclusionPattern, Scan };
};
export const getLibraryFolderActions = ($t: MessageFormatter, library: LibraryResponseDto, folder: string) => {

View File

@@ -76,6 +76,7 @@ websocket
.on('on_new_release', (event) => eventManager.emit('ReleaseEvent', event))
.on('on_session_delete', () => authManager.logout())
.on('on_user_delete', (id) => eventManager.emit('UserAdminDeleted', { id }))
.on('on_person_thumbnail', (id) => eventManager.emit('PersonThumbnailReady', { id }))
.on('on_notification', () => notificationManager.refresh())
.on('connect_error', (e) => console.log('Websocket Connect Error', e));

View File

@@ -28,7 +28,7 @@ import {
} from '@immich/sdk';
import { toastManager, type ActionItem, type IfLike } from '@immich/ui';
import { mdiCogRefreshOutline, mdiDatabaseRefreshOutline, mdiHeadSyncOutline, mdiImageRefreshOutline } from '@mdi/js';
import { init, register, t } from 'svelte-i18n';
import { init, register, t, type MessageFormatter } from 'svelte-i18n';
import { derived, get } from 'svelte/store';
interface DownloadRequestOptions<T = unknown> {
@@ -259,31 +259,16 @@ export const getProfileImageUrl = (user: UserResponseDto) =>
export const getPeopleThumbnailUrl = (person: PersonResponseDto, updatedAt?: string) =>
createUrl(getPeopleThumbnailPath(person.id), { updatedAt: updatedAt ?? person.updatedAt });
export const getAssetJobName = derived(t, ($t) => {
return (job: AssetJobName) => {
const names: Record<AssetJobName, string> = {
[AssetJobName.RefreshFaces]: $t('refresh_faces'),
[AssetJobName.RefreshMetadata]: $t('refresh_metadata'),
[AssetJobName.RegenerateThumbnail]: $t('refresh_thumbnails'),
[AssetJobName.TranscodeVideo]: $t('refresh_encoded_videos'),
};
return names[job];
export const getAssetJobName = ($t: MessageFormatter, job: AssetJobName) => {
const messages: Record<AssetJobName, string> = {
[AssetJobName.RefreshFaces]: $t('refreshing_faces'),
[AssetJobName.RefreshMetadata]: $t('refreshing_metadata'),
[AssetJobName.RegenerateThumbnail]: $t('regenerating_thumbnails'),
[AssetJobName.TranscodeVideo]: $t('refreshing_encoded_video'),
};
});
export const getAssetJobMessage = derived(t, ($t) => {
return (job: AssetJobName) => {
const messages: Record<AssetJobName, string> = {
[AssetJobName.RefreshFaces]: $t('refreshing_faces'),
[AssetJobName.RefreshMetadata]: $t('refreshing_metadata'),
[AssetJobName.RegenerateThumbnail]: $t('regenerating_thumbnails'),
[AssetJobName.TranscodeVideo]: $t('refreshing_encoded_video'),
};
return messages[job];
};
});
return messages[job];
};
export const getAssetJobIcon = (job: AssetJobName) => {
const names: Record<AssetJobName, string> = {

View File

@@ -1,15 +1,14 @@
<script lang="ts">
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import SingleGridRow from '$lib/components/shared-components/single-grid-row.svelte';
import { Route } from '$lib/route';
import { websocketEvents } from '$lib/stores/websocket';
import { getAssetThumbnailUrl, getPeopleThumbnailUrl } from '$lib/utils';
import { AssetMediaSize, type SearchExploreResponseDto } from '@immich/sdk';
import { Icon } from '@immich/ui';
import { mdiHeart } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
@@ -29,17 +28,17 @@
let hasPeople = $derived(data.response.total > 0);
onMount(() => {
return websocketEvents.on('on_person_thumbnail', (personId: string) => {
people.map((person) => {
if (person.id === personId) {
person.updatedAt = Date.now().toString();
}
});
});
});
const onPersonThumbnailReady = ({ id }: { id: string }) => {
for (const person of people) {
if (person.id === id) {
person.updatedAt = new Date().toISOString();
}
}
};
</script>
<OnEvents {onPersonThumbnailReady} />
<UserPageLayout title={data.meta.title}>
{#if hasPeople}
<div class="mb-6 mt-2">

View File

@@ -1,30 +1,21 @@
<script lang="ts">
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import UserSettingsList from '$lib/components/user-settings-page/user-settings-list.svelte';
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
import { Container, IconButton, modalManager } from '@immich/ui';
import { mdiKeyboard } from '@mdi/js';
import { getKeyboardActions } from '$lib/services/keyboard.service';
import { Container } from '@immich/ui';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
interface Props {
type Props = {
data: PageData;
}
};
let { data }: Props = $props();
const { KeyboardShortcuts } = $derived(getKeyboardActions($t));
</script>
<UserPageLayout title={data.meta.title}>
{#snippet buttons()}
<IconButton
shape="round"
color="secondary"
variant="ghost"
icon={mdiKeyboard}
aria-label={$t('show_keyboard_shortcuts')}
onclick={() => modalManager.show(ShortcutsModal, {})}
/>
{/snippet}
<UserPageLayout title={data.meta.title} actions={[KeyboardShortcuts]}>
<Container size="medium" center>
<UserSettingsList keys={data.keys} sessions={data.sessions} />
</Container>

View File

@@ -4,14 +4,16 @@
import OnEvents from '$lib/components/OnEvents.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import { Route } from '$lib/route';
import { getLibrariesActions } from '$lib/services/library.service';
import { getLibrariesActions, getLibraryActions } from '$lib/services/library.service';
import { locale } from '$lib/stores/preferences.store';
import { getBytesWithUnit } from '$lib/utils/byte-units';
import { getLibrary, getLibraryStatistics, type LibraryResponseDto } from '@immich/sdk';
import {
Button,
CommandPaletteDefaultProvider,
Container,
ContextMenuButton,
Link,
MenuItemType,
Table,
TableBody,
TableCell,
@@ -58,13 +60,18 @@
const { Create, ScanAll } = $derived(getLibrariesActions($t, libraries));
const getActionsForLibrary = (library: LibraryResponseDto) => {
const { Detail, Scan, Edit, Delete } = getLibraryActions($t, library);
return [Detail, Scan, Edit, MenuItemType.Divider, Delete];
};
const classes = {
column1: 'w-4/12',
column2: 'w-4/12',
column3: 'w-2/12',
column4: 'w-2/12',
column5: 'w-2/12',
column6: 'w-2/12',
column3: 'w-1/12',
column4: 'w-1/12',
column5: 'w-1/12',
column6: 'w-1/12 flex justify-end',
};
</script>
@@ -89,14 +96,19 @@
{#each libraries as library (library.id + library.name)}
{@const { photos, usage, videos } = statistics[library.id]}
{@const [diskUsage, diskUsageUnit] = getBytesWithUnit(usage, 0)}
{@const owner = owners[library.id]}
<TableRow>
<TableCell class={classes.column1}>{library.name}</TableCell>
<TableCell class={classes.column2}>{owners[library.id].name}</TableCell>
<TableCell class={classes.column1}>
<Link href={Route.viewLibrary(library)}>{library.name}</Link>
</TableCell>
<TableCell class={classes.column2}>
<Link href={Route.viewUser(owner)}>{owner.name}</Link>
</TableCell>
<TableCell class={classes.column3}>{photos.toLocaleString($locale)}</TableCell>
<TableCell class={classes.column4}>{videos.toLocaleString($locale)}</TableCell>
<TableCell class={classes.column5}>{diskUsage} {diskUsageUnit}</TableCell>
<TableCell class={classes.column6}>
<Button size="small" href={Route.viewLibrary(library)}>{$t('view')}</Button>
<ContextMenuButton color="primary" aria-label={$t('open')} items={getActionsForLibrary(library)} />
</TableCell>
</TableRow>
{/each}