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}