diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml
index 1933b9d572..8c0bf76f30 100644
--- a/.github/workflows/docs-deploy.yml
+++ b/.github/workflows/docs-deploy.yml
@@ -131,7 +131,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
- uses: immich-app/devtools/actions/use-mise@b868e6e7c8cc212beec876330b4059e661ee44bb # use-mise-action-v1.1.1
+ uses: immich-app/devtools/actions/use-mise@cd24790a7f5f6439ac32cc94f5523cb2de8bfa8c # use-mise-action-v1.1.0
- name: Load parameters
id: parameters
diff --git a/.github/workflows/docs-destroy.yml b/.github/workflows/docs-destroy.yml
index 80cc17d32b..a7d068cb43 100644
--- a/.github/workflows/docs-destroy.yml
+++ b/.github/workflows/docs-destroy.yml
@@ -29,7 +29,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup Mise
- uses: immich-app/devtools/actions/use-mise@b868e6e7c8cc212beec876330b4059e661ee44bb # use-mise-action-v1.1.1
+ uses: immich-app/devtools/actions/use-mise@cd24790a7f5f6439ac32cc94f5523cb2de8bfa8c # use-mise-action-v1.1.0
- name: Destroy Docs Subdomain
env:
diff --git a/mobile/openapi/lib/model/permission.dart b/mobile/openapi/lib/model/permission.dart
index d5b9bf5086..37aecc8b9c 100644
--- a/mobile/openapi/lib/model/permission.dart
+++ b/mobile/openapi/lib/model/permission.dart
@@ -82,6 +82,8 @@ class Permission {
static const timelinePeriodRead = Permission._(r'timeline.read');
static const timelinePeriodDownload = Permission._(r'timeline.download');
static const maintenance = Permission._(r'maintenance');
+ static const mapPeriodRead = Permission._(r'map.read');
+ static const mapPeriodSearch = Permission._(r'map.search');
static const memoryPeriodCreate = Permission._(r'memory.create');
static const memoryPeriodRead = Permission._(r'memory.read');
static const memoryPeriodUpdate = Permission._(r'memory.update');
@@ -238,6 +240,8 @@ class Permission {
timelinePeriodRead,
timelinePeriodDownload,
maintenance,
+ mapPeriodRead,
+ mapPeriodSearch,
memoryPeriodCreate,
memoryPeriodRead,
memoryPeriodUpdate,
@@ -429,6 +433,8 @@ class PermissionTypeTransformer {
case r'timeline.read': return Permission.timelinePeriodRead;
case r'timeline.download': return Permission.timelinePeriodDownload;
case r'maintenance': return Permission.maintenance;
+ case r'map.read': return Permission.mapPeriodRead;
+ case r'map.search': return Permission.mapPeriodSearch;
case r'memory.create': return Permission.memoryPeriodCreate;
case r'memory.read': return Permission.memoryPeriodRead;
case r'memory.update': return Permission.memoryPeriodUpdate;
diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json
index 07bf2b6945..bc9a49c48b 100644
--- a/open-api/immich-openapi-specs.json
+++ b/open-api/immich-openapi-specs.json
@@ -6574,6 +6574,7 @@
"state": "Stable"
}
],
+ "x-immich-permission": "map.read",
"x-immich-state": "Stable"
}
},
@@ -6645,6 +6646,7 @@
"state": "Stable"
}
],
+ "x-immich-permission": "map.search",
"x-immich-state": "Stable"
}
},
@@ -19348,6 +19350,8 @@
"timeline.read",
"timeline.download",
"maintenance",
+ "map.read",
+ "map.search",
"memory.create",
"memory.read",
"memory.update",
diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts
index a1d2817234..6af3a144ae 100644
--- a/open-api/typescript-sdk/src/fetch-client.ts
+++ b/open-api/typescript-sdk/src/fetch-client.ts
@@ -5639,6 +5639,8 @@ export enum Permission {
TimelineRead = "timeline.read",
TimelineDownload = "timeline.download",
Maintenance = "maintenance",
+ MapRead = "map.read",
+ MapSearch = "map.search",
MemoryCreate = "memory.create",
MemoryRead = "memory.read",
MemoryUpdate = "memory.update",
diff --git a/readme_i18n/README_de_DE.md b/readme_i18n/README_de_DE.md
index a8685e0902..488b05abcc 100644
--- a/readme_i18n/README_de_DE.md
+++ b/readme_i18n/README_de_DE.md
@@ -38,11 +38,6 @@
ภาษาไทย
-## Warnung
-
-- ⚠️ Das Projekt befindet sich in **sehr aktiver** Entwicklung.
-- ⚠️ Gehe von möglichen Fehlern und von Änderungen mit Breaking-Changes aus.
-- ⚠️ **Nutze die App auf keinen Fall als einziges Speichermedium für deine Fotos und Videos.**
- ⚠️ Befolge immer die [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) Backup-Regel für deine wertvollen Fotos und Videos!
> [!NOTE]
@@ -62,7 +57,7 @@
## Demo
-Die Web-Demo kannst Du unter https://demo.immich.app finden. Für die Handy-App kannst Du `https://demo.immich.app` als `Server Endpoint URL` angeben.
+Die Web-Demo kannst Du unter https://demo.immich.app finden. Für die Smartphone-App kannst Du `https://demo.immich.app` als `Server Endpoint URL` angeben.
### Login Daten
@@ -93,7 +88,7 @@ Die Web-Demo kannst Du unter https://demo.immich.app finden. Für die Handy-App
| LivePhoto/MotionPhoto Sicherung und Wiedergabe | Ja | Ja |
| Unterstützung für 360-Grad-Bilder | Nein | Ja |
| Benutzerdefinierte Speicherstruktur | Ja | Ja |
-| Öffentliches Teilen | Nein | Ja |
+| Öffentliches Teilen | Ja | Ja |
| Archiv und Favoriten | Ja | Ja |
| Globale Karte | Ja | Ja |
| Partnerfreigabe (Teilen) | Ja | Ja |
@@ -103,7 +98,7 @@ Die Web-Demo kannst Du unter https://demo.immich.app finden. Für die Handy-App
| Schreibgeschützte Gallerie | Ja | Ja |
| Gestapelte Bilder | Ja | Ja |
| Tags | Nein | Ja |
-| Ordner-Ansicht | Nein | Ja |
+| Ordner-Ansicht | Ja | Ja |
## Übersetzungen
diff --git a/server/src/controllers/map.controller.ts b/server/src/controllers/map.controller.ts
index dbd1082561..ae3b56af28 100644
--- a/server/src/controllers/map.controller.ts
+++ b/server/src/controllers/map.controller.ts
@@ -8,7 +8,7 @@ import {
MapReverseGeocodeDto,
MapReverseGeocodeResponseDto,
} from 'src/dtos/map.dto';
-import { ApiTag } from 'src/enum';
+import { ApiTag, Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { MapService } from 'src/services/map.service';
@@ -18,7 +18,7 @@ export class MapController {
constructor(private service: MapService) {}
@Get('markers')
- @Authenticated()
+ @Authenticated({ permission: Permission.MapRead })
@Endpoint({
summary: 'Retrieve map markers',
description: 'Retrieve a list of latitude and longitude coordinates for every asset with location data.',
@@ -28,8 +28,8 @@ export class MapController {
return this.service.getMapMarkers(auth, options);
}
- @Authenticated()
@Get('reverse-geocode')
+ @Authenticated({ permission: Permission.MapSearch })
@HttpCode(HttpStatus.OK)
@Endpoint({
summary: 'Reverse geocode coordinates',
diff --git a/server/src/enum.ts b/server/src/enum.ts
index 179ac20b80..e017b6b36a 100644
--- a/server/src/enum.ts
+++ b/server/src/enum.ts
@@ -160,6 +160,9 @@ export enum Permission {
Maintenance = 'maintenance',
+ MapRead = 'map.read',
+ MapSearch = 'map.search',
+
MemoryCreate = 'memory.create',
MemoryRead = 'memory.read',
MemoryUpdate = 'memory.update',
diff --git a/server/src/services/tag.service.spec.ts b/server/src/services/tag.service.spec.ts
index ff706552a9..a80e6d508b 100644
--- a/server/src/services/tag.service.spec.ts
+++ b/server/src/services/tag.service.spec.ts
@@ -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']);
diff --git a/server/src/services/tag.service.ts b/server/src/services/tag.service.ts
index 3b3000f759..20303421c1 100644
--- a/server/src/services/tag.service.ts
+++ b/server/src/services/tag.service.ts
@@ -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' },
);
}
diff --git a/server/test/medium/specs/services/tag.service.spec.ts b/server/test/medium/specs/services/tag.service.spec.ts
index 2ec498e56d..989e4f535f 100644
--- a/server/test/medium/specs/services/tag.service.spec.ts
+++ b/server/test/medium/specs/services/tag.service.spec.ts
@@ -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;
@@ -14,8 +17,8 @@ let defaultDatabase: Kysely;
const setup = (db?: Kysely) => {
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();
diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.spec.ts b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.spec.ts
index 1c802b0dce..5ee6dbf93e 100644
--- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.spec.ts
+++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.spec.ts
@@ -16,7 +16,7 @@ describe('AssetViewerNavBar component', () => {
preAction: () => {},
onZoomImage: () => {},
onAction: () => {},
- onRunJob: () => {},
+ onEdit: () => {},
onPlaySlideshow: () => {},
onClose: () => {},
playOriginalVideo: false,
diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte
index cf8301d755..93d1a4acd1 100644
--- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte
+++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte
@@ -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 @@
- onRunJob(AssetJobName.RefreshFaces)}
- text={$getAssetJobName(AssetJobName.RefreshFaces)}
- />
- onRunJob(AssetJobName.RefreshMetadata)}
- text={$getAssetJobName(AssetJobName.RefreshMetadata)}
- />
- onRunJob(AssetJobName.RegenerateThumbnail)}
- text={$getAssetJobName(AssetJobName.RegenerateThumbnail)}
- />
- {#if asset.type === AssetTypeEnum.Video}
- onRunJob(AssetJobName.TranscodeVideo)}
- text={$getAssetJobName(AssetJobName.TranscodeVideo)}
- />
- {/if}
+
+
+
+
{/if}
{/if}
diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte
index ac9c07df94..91dc041754 100644
--- a/web/src/lib/components/asset-viewer/asset-viewer.svelte
+++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte
@@ -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
*/
@@ -443,6 +431,7 @@
const showOcrButton = $derived(
$slideshowState === SlideshowState.None &&
asset.type === AssetTypeEnum.Image &&
+ !(asset.exifInfo?.projectionType === 'EQUIRECTANGULAR') &&
!isShowEditor &&
ocrManager.hasOcrData,
);
@@ -473,7 +462,6 @@
onAction={handleAction}
{onUndoDelete}
onEdit={showEditor}
- onRunJob={handleRunJob}
onPlaySlideshow={() => ($slideshowState = SlideshowState.PlaySlideshow)}
onClose={onClose ? () => onClose(asset) : undefined}
{playOriginalVideo}
diff --git a/web/src/lib/components/faces-page/person-side-panel.svelte b/web/src/lib/components/faces-page/person-side-panel.svelte
index 6a0d467124..d9530e6114 100644
--- a/web/src/lib/components/faces-page/person-side-panel.svelte
+++ b/web/src/lib/components/faces-page/person-side-panel.svelte
@@ -1,9 +1,9 @@
+
+
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}
- handleRunJob(job)} />
+ handleRunJob(job)} />
{/if}
{/each}
diff --git a/web/src/lib/managers/event-manager.svelte.ts b/web/src/lib/managers/event-manager.svelte.ts
index 23a8e09306..47beb3a048 100644
--- a/web/src/lib/managers/event-manager.svelte.ts
+++ b/web/src/lib/managers/event-manager.svelte.ts
@@ -46,6 +46,7 @@ export type Events = {
AlbumUserDelete: [{ albumId: string; userId: string }];
PersonUpdate: [PersonResponseDto];
+ PersonThumbnailReady: [{ id: string }];
BackupDeleteStatus: [{ filename: string; isDeleting: boolean }];
BackupDeleted: [{ filename: string }];
diff --git a/web/src/lib/services/asset.service.ts b/web/src/lib/services/asset.service.ts
index 0feab709c0..d768e5590b 100644
--- a/web/src/lib/services/asset.service.ts
+++ b/web/src/lib/services/asset.service.ts
@@ -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'));
+ }
+};
diff --git a/web/src/lib/services/keyboard.service.ts b/web/src/lib/services/keyboard.service.ts
new file mode 100644
index 0000000000..f5c9e59b75
--- /dev/null
+++ b/web/src/lib/services/keyboard.service.ts
@@ -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 };
+};
diff --git a/web/src/lib/services/library.service.ts b/web/src/lib/services/library.service.ts
index b21d6ea050..81a140a924 100644
--- a/web/src/lib/services/library.service.ts
+++ b/web/src/lib/services/library.service.ts
@@ -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) => {
diff --git a/web/src/lib/services/user-admin.service.ts b/web/src/lib/services/user-admin.service.ts
index 71d20bd9ce..233eb88657 100644
--- a/web/src/lib/services/user-admin.service.ts
+++ b/web/src/lib/services/user-admin.service.ts
@@ -23,6 +23,7 @@ import {
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
import {
mdiDeleteRestore,
+ mdiInformationOutline,
mdiLockReset,
mdiLockSmart,
mdiPencilOutline,
@@ -46,6 +47,12 @@ export const getUserAdminsActions = ($t: MessageFormatter) => {
};
export const getUserAdminActions = ($t: MessageFormatter, user: UserAdminResponseDto) => {
+ const Detail: ActionItem = {
+ icon: mdiInformationOutline,
+ title: $t('details'),
+ onAction: () => goto(Route.viewUser(user)),
+ };
+
const Update: ActionItem = {
icon: mdiPencilOutline,
title: $t('edit'),
@@ -92,7 +99,7 @@ export const getUserAdminActions = ($t: MessageFormatter, user: UserAdminRespons
onAction: () => handleResetPinCodeUserAdmin(user),
};
- return { Update, Delete, Restore, ResetPassword, ResetPinCode };
+ return { Detail, Update, Delete, Restore, ResetPassword, ResetPinCode };
};
export const handleCreateUserAdmin = async (dto: UserAdminCreateDto) => {
diff --git a/web/src/lib/stores/websocket.ts b/web/src/lib/stores/websocket.ts
index 2b7d5ec80c..5e197fbb3f 100644
--- a/web/src/lib/stores/websocket.ts
+++ b/web/src/lib/stores/websocket.ts
@@ -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));
diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts
index d43ad67bd6..e3b5ae50c9 100644
--- a/web/src/lib/utils.ts
+++ b/web/src/lib/utils.ts
@@ -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 {
@@ -260,31 +260,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.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.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.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 = {
diff --git a/web/src/routes/(user)/explore/+page.svelte b/web/src/routes/(user)/explore/+page.svelte
index 580d84e888..513d211a56 100644
--- a/web/src/routes/(user)/explore/+page.svelte
+++ b/web/src/routes/(user)/explore/+page.svelte
@@ -1,15 +1,14 @@
+
+
{#if hasPeople}
diff --git a/web/src/routes/(user)/user-settings/+page.svelte b/web/src/routes/(user)/user-settings/+page.svelte
index 43c214fd07..bf4f5b00f1 100644
--- a/web/src/routes/(user)/user-settings/+page.svelte
+++ b/web/src/routes/(user)/user-settings/+page.svelte
@@ -1,30 +1,21 @@
-
- {#snippet buttons()}
- modalManager.show(ShortcutsModal, {})}
- />
- {/snippet}
+
diff --git a/web/src/routes/admin/library-management/(list)/+layout.svelte b/web/src/routes/admin/library-management/(list)/+layout.svelte
index 17a91b2a13..5002b92de4 100644
--- a/web/src/routes/admin/library-management/(list)/+layout.svelte
+++ b/web/src/routes/admin/library-management/(list)/+layout.svelte
@@ -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',
};
@@ -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]}
- {library.name}
- {owners[library.id].name}
+
+ {library.name}
+
+
+ {owner.name}
+
{photos.toLocaleString($locale)}
{videos.toLocaleString($locale)}
{diskUsage} {diskUsageUnit}
-
+
{/each}
diff --git a/web/src/routes/admin/users/(list)/+layout.svelte b/web/src/routes/admin/users/(list)/+layout.svelte
index 368ce5ed18..b895e72de5 100644
--- a/web/src/routes/admin/users/(list)/+layout.svelte
+++ b/web/src/routes/admin/users/(list)/+layout.svelte
@@ -1,15 +1,18 @@
@@ -68,16 +76,18 @@
- {$t('email')}
- {$t('name')}
+ {$t('name')}
+ {$t('email')}
{$t('has_quota')}
{#each users as user (user.id)}
- {user.email}
- {user.name}
+
+ {user.name}
+
+ {user.email}
{#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0}
@@ -88,7 +98,7 @@
-
+
{/each}
diff --git a/web/src/routes/admin/users/[id]/+layout.svelte b/web/src/routes/admin/users/[id]/+layout.svelte
index 92d29aa5e2..61fd184303 100644
--- a/web/src/routes/admin/users/[id]/+layout.svelte
+++ b/web/src/routes/admin/users/[id]/+layout.svelte
@@ -198,8 +198,8 @@
})}
>
{$t('storage')}
-
{/if}