merge: remote-tracking branch 'origin/main' into feat/integrity-checks-izzy

This commit is contained in:
izzy
2026-01-22 16:44:23 +00:00
28 changed files with 251 additions and 151 deletions
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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:
+6
View File
@@ -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;
+4
View File
@@ -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",
@@ -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",
+3 -8
View File
@@ -38,11 +38,6 @@
<a href="README_th_TH.md">ภาษาไทย</a>
</p>
## 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
+3 -3
View File
@@ -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',
+3
View File
@@ -160,6 +160,9 @@ export enum Permission {
Maintenance = 'maintenance',
MapRead = 'map.read',
MapSearch = 'map.search',
MemoryCreate = 'memory.create',
MemoryRead = 'memory.read',
MemoryUpdate = 'memory.update',
+5 -5
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']);
+2 -1
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' },
);
}
@@ -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();
@@ -16,7 +16,7 @@ describe('AssetViewerNavBar component', () => {
preAction: () => {},
onZoomImage: () => {},
onAction: () => {},
onRunJob: () => {},
onEdit: () => {},
onPlaySlideshow: () => {},
onClose: () => {},
playOriginalVideo: false,
@@ -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}
@@ -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}
@@ -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"
@@ -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}
@@ -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 }];
+49 -1
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'));
}
};
+14
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 };
};
+9 -2
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) => {
+8 -1
View File
@@ -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) => {
+1
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));
+9 -24
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> {
@@ -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, 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> = {
+10 -11
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">
@@ -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>
@@ -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}
@@ -1,15 +1,18 @@
<script lang="ts">
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import { getUserAdminsActions, handleNavigateUserAdmin } from '$lib/services/user-admin.service';
import { Route } from '$lib/route';
import { getUserAdminActions, getUserAdminsActions } from '$lib/services/user-admin.service';
import { locale } from '$lib/stores/preferences.store';
import { getByteUnitString } from '$lib/utils/byte-units';
import { searchUsersAdmin, type UserAdminResponseDto } from '@immich/sdk';
import {
Button,
CommandPaletteDefaultProvider,
Container,
ContextMenuButton,
Icon,
Link,
MenuItemType,
Table,
TableBody,
TableCell,
@@ -46,11 +49,16 @@
const { Create } = $derived(getUserAdminsActions($t));
const getActionsForUser = (user: UserAdminResponseDto) => {
const { Detail, Update, Delete, ResetPassword, ResetPinCode } = getUserAdminActions($t, user);
return [Detail, Update, ResetPassword, ResetPinCode, MenuItemType.Divider, Delete];
};
const classes = {
column1: 'w-8/12 sm:w-5/12 lg:w-6/12 xl:w-4/12 2xl:w-5/12',
column2: 'hidden sm:block w-3/12',
column3: 'hidden xl:block w-3/12 2xl:w-2/12',
column4: 'w-4/12 lg:w-3/12 xl:w-2/12',
column1: 'w-8/12 md:w-5/12 lg:w-4/12',
column2: 'hidden md:block md:w-5/12 lg:w-4/12',
column3: 'hidden lg:block lg:w-2/12',
column4: 'w-4/12 md:w-2/12 flex justify-end',
};
</script>
@@ -68,16 +76,18 @@
<Container center size="large">
<Table class="mt-4" striped spacing="small" size="small">
<TableHeader>
<TableHeading class={classes.column1}>{$t('email')}</TableHeading>
<TableHeading class={classes.column2}>{$t('name')}</TableHeading>
<TableHeading class={classes.column1}>{$t('name')}</TableHeading>
<TableHeading class={classes.column2}>{$t('email')}</TableHeading>
<TableHeading class={classes.column3}>{$t('has_quota')}</TableHeading>
</TableHeader>
<TableBody>
{#each users as user (user.id)}
<TableRow color={user.deletedAt ? 'danger' : undefined}>
<TableCell class={classes.column1}>{user.email}</TableCell>
<TableCell class={classes.column2}>{user.name}</TableCell>
<TableCell class={classes.column1}>
<Link href={Route.viewUser(user)}>{user.name}</Link>
</TableCell>
<TableCell class={classes.column2}>{user.email}</TableCell>
<TableCell class={classes.column3}>
<div class="container mx-auto flex flex-wrap justify-center">
{#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0}
@@ -88,7 +98,7 @@
</div>
</TableCell>
<TableCell class={classes.column4}>
<Button onclick={() => handleNavigateUserAdmin(user)}>{$t('view')}</Button>
<ContextMenuButton color="primary" aria-label={$t('open')} items={getActionsForUser(user)} />
</TableCell>
</TableRow>
{/each}
@@ -198,8 +198,8 @@
})}
>
<p class="font-medium text-immich-dark-gray dark:text-white mb-2">{$t('storage')}</p>
<div class="mt-4 h-[7px] w-full rounded-full bg-gray-200 dark:bg-gray-700">
<div class="h-[7px] rounded-full {getUsageClass()}" style="width: {usedPercentage}%"></div>
<div class="mt-4 h-1.75 w-full rounded-full bg-gray-200 dark:bg-gray-700">
<div class="h-1.75 rounded-full {getUsageClass()}" style="width: {usedPercentage}%"></div>
</div>
</div>
{/if}