Merge branch 'main' of github.com:immich-app/immich into web/automation-ui
This commit is contained in:
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM node:iron-alpine3.18@sha256:3fb85a68652064ab109ed9730f45a3ede11f064afdd3ad9f96ef7e8a3c55f47e
|
||||
FROM node:iron-alpine3.18@sha256:d328c7bc3305e1ab26491817936c8151a47a8861ad617c16c1eeaa9c8075c8f6
|
||||
|
||||
RUN apk add --no-cache tini
|
||||
USER node
|
||||
|
||||
Generated
+21
-3
@@ -1,17 +1,19 @@
|
||||
{
|
||||
"name": "immich-web",
|
||||
"version": "1.101.0",
|
||||
"version": "1.102.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "immich-web",
|
||||
"version": "1.101.0",
|
||||
"version": "1.102.3",
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"dependencies": {
|
||||
"@immich/sdk": "file:../open-api/typescript-sdk",
|
||||
"@mdi/js": "^7.4.47",
|
||||
"@photo-sphere-viewer/core": "^5.7.1",
|
||||
"@photo-sphere-viewer/equirectangular-video-adapter": "^5.7.2",
|
||||
"@photo-sphere-viewer/video-plugin": "^5.7.2",
|
||||
"@zoom-image/svelte": "^0.2.6",
|
||||
"buffer": "^6.0.3",
|
||||
"copy-image-clipboard": "^2.1.2",
|
||||
@@ -63,7 +65,7 @@
|
||||
},
|
||||
"../open-api/typescript-sdk": {
|
||||
"name": "@immich/sdk",
|
||||
"version": "1.101.0",
|
||||
"version": "1.102.3",
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"dependencies": {
|
||||
"@oazapfts/runtime": "^1.0.2"
|
||||
@@ -1590,6 +1592,22 @@
|
||||
"three": "^0.161.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@photo-sphere-viewer/equirectangular-video-adapter": {
|
||||
"version": "5.7.2",
|
||||
"resolved": "https://registry.npmjs.org/@photo-sphere-viewer/equirectangular-video-adapter/-/equirectangular-video-adapter-5.7.2.tgz",
|
||||
"integrity": "sha512-cAaot52nPqa2p77Xp1humRvuxRIa8cqbZ/XRhA8kBToFLT1Ugh9YBcDD7pM/358JtAjicUbLpT7Ioap9iEigxQ==",
|
||||
"peerDependencies": {
|
||||
"@photo-sphere-viewer/core": "5.7.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@photo-sphere-viewer/video-plugin": {
|
||||
"version": "5.7.2",
|
||||
"resolved": "https://registry.npmjs.org/@photo-sphere-viewer/video-plugin/-/video-plugin-5.7.2.tgz",
|
||||
"integrity": "sha512-vrPV9RCr4HsYiORkto1unDPeUkbN2kbyogvNUoLiQ78M4xkPOqoKxtfxCxTYoM+7gECwNL9VTF81+okck498qA==",
|
||||
"peerDependencies": {
|
||||
"@photo-sphere-viewer/core": "5.7.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@polka/url": {
|
||||
"version": "1.0.0-next.24",
|
||||
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.24.tgz",
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-web",
|
||||
"version": "1.101.0",
|
||||
"version": "1.102.3",
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"scripts": {
|
||||
"dev": "vite dev --host 0.0.0.0 --port 3000",
|
||||
@@ -61,6 +61,8 @@
|
||||
"@immich/sdk": "file:../open-api/typescript-sdk",
|
||||
"@mdi/js": "^7.4.47",
|
||||
"@photo-sphere-viewer/core": "^5.7.1",
|
||||
"@photo-sphere-viewer/equirectangular-video-adapter": "^5.7.2",
|
||||
"@photo-sphere-viewer/video-plugin": "^5.7.2",
|
||||
"@zoom-image/svelte": "^0.2.6",
|
||||
"buffer": "^6.0.3",
|
||||
"copy-image-clipboard": "^2.1.2",
|
||||
|
||||
@@ -101,6 +101,16 @@
|
||||
isEdited={config.image.colorspace !== savedConfig.image.colorspace}
|
||||
{disabled}
|
||||
/>
|
||||
|
||||
<SettingSwitch
|
||||
id="prefer-embedded"
|
||||
title="PREFER EMBEDDED PREVIEW"
|
||||
subtitle="Use embedded previews in RAW photos as the input to image processing when available. This can produce more accurate colors for some images, but the quality of the preview is camera-dependent and the image may have more compression artifacts."
|
||||
checked={config.image.extractEmbedded}
|
||||
on:toggle={() => (config.image.extractEmbedded = !config.image.extractEmbedded)}
|
||||
isEdited={config.image.extractEmbedded !== savedConfig.image.extractEmbedded}
|
||||
{disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="ml-4">
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
mdiFolderDownloadOutline,
|
||||
mdiHeart,
|
||||
mdiHeartOutline,
|
||||
mdiHistory,
|
||||
mdiImageAlbum,
|
||||
mdiImageMinusOutline,
|
||||
mdiImageOutline,
|
||||
@@ -52,6 +53,7 @@
|
||||
|
||||
type MenuItemEvent =
|
||||
| 'addToAlbum'
|
||||
| 'restoreAsset'
|
||||
| 'addToSharedAlbum'
|
||||
| 'asProfileImage'
|
||||
| 'setAsAlbumCover'
|
||||
@@ -70,6 +72,7 @@
|
||||
delete: void;
|
||||
toggleArchive: void;
|
||||
addToAlbum: void;
|
||||
restoreAsset: void;
|
||||
addToSharedAlbum: void;
|
||||
asProfileImage: void;
|
||||
setAsAlbumCover: void;
|
||||
@@ -208,12 +211,16 @@
|
||||
{#if showDownloadButton}
|
||||
<MenuOption icon={mdiFolderDownloadOutline} on:click={() => onMenuClick('download')} text="Download" />
|
||||
{/if}
|
||||
<MenuOption icon={mdiImageAlbum} on:click={() => onMenuClick('addToAlbum')} text="Add to album" />
|
||||
<MenuOption
|
||||
icon={mdiShareVariantOutline}
|
||||
on:click={() => onMenuClick('addToSharedAlbum')}
|
||||
text="Add to shared album"
|
||||
/>
|
||||
{#if asset.isTrashed}
|
||||
<MenuOption icon={mdiHistory} on:click={() => onMenuClick('restoreAsset')} text="Restore" />
|
||||
{:else}
|
||||
<MenuOption icon={mdiImageAlbum} on:click={() => onMenuClick('addToAlbum')} text="Add to album" />
|
||||
<MenuOption
|
||||
icon={mdiShareVariantOutline}
|
||||
on:click={() => onMenuClick('addToSharedAlbum')}
|
||||
text="Add to shared album"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if isOwner}
|
||||
{#if hasStackChildren}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
import { stackAssetsStore } from '$lib/stores/stacked-asset.store';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { getAssetJobMessage, getSharedLink, handlePromiseError, isSharedLink } from '$lib/utils';
|
||||
import { addAssetsToAlbum, addAssetsToNewAlbum, downloadFile } from '$lib/utils/asset-utils';
|
||||
import { addAssetsToAlbum, addAssetsToNewAlbum, downloadFile, unstackAssets } from '$lib/utils/asset-utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { shortcuts } from '$lib/utils/shortcut';
|
||||
import { SlideshowHistory } from '$lib/utils/slideshow-history';
|
||||
@@ -27,8 +27,8 @@
|
||||
getActivityStatistics,
|
||||
getAllAlbums,
|
||||
runAssetJobs,
|
||||
restoreAssets,
|
||||
updateAsset,
|
||||
updateAssets,
|
||||
updateAlbumInfo,
|
||||
type ActivityResponseDto,
|
||||
type AlbumResponseDto,
|
||||
@@ -50,7 +50,7 @@
|
||||
import PanoramaViewer from './panorama-viewer.svelte';
|
||||
import PhotoViewer from './photo-viewer.svelte';
|
||||
import SlideshowBar from './slideshow-bar.svelte';
|
||||
import VideoViewer from './video-viewer.svelte';
|
||||
import VideoViewer from './video-wrapper-viewer.svelte';
|
||||
|
||||
export let assetStore: AssetStore | null = null;
|
||||
export let asset: AssetResponseDto;
|
||||
@@ -404,6 +404,22 @@
|
||||
await handleGetAllAlbums();
|
||||
};
|
||||
|
||||
const handleRestoreAsset = async () => {
|
||||
try {
|
||||
await restoreAssets({ bulkIdsDto: { ids: [asset.id] } });
|
||||
asset.isTrashed = false;
|
||||
|
||||
dispatch('action', { type: AssetAction.RESTORE, asset });
|
||||
|
||||
notificationController.show({
|
||||
type: NotificationType.Info,
|
||||
message: `Restored asset`,
|
||||
});
|
||||
} catch (error) {
|
||||
handleError(error, 'Error restoring asset');
|
||||
}
|
||||
};
|
||||
|
||||
const toggleArchive = async () => {
|
||||
try {
|
||||
const data = await updateAsset({
|
||||
@@ -481,20 +497,15 @@
|
||||
};
|
||||
|
||||
const handleUnstack = async () => {
|
||||
try {
|
||||
const ids = $stackAssetsStore.map(({ id }) => id);
|
||||
await updateAssets({ assetBulkUpdateDto: { ids, removeParent: true } });
|
||||
for (const child of $stackAssetsStore) {
|
||||
child.stackParentId = null;
|
||||
child.stackCount = 0;
|
||||
child.stack = [];
|
||||
dispatch('action', { type: AssetAction.ADD, asset: child });
|
||||
const unstackedAssets = await unstackAssets($stackAssetsStore);
|
||||
if (unstackedAssets) {
|
||||
for (const asset of unstackedAssets) {
|
||||
dispatch('action', {
|
||||
type: AssetAction.ADD,
|
||||
asset,
|
||||
});
|
||||
}
|
||||
|
||||
dispatch('close');
|
||||
notificationController.show({ type: NotificationType.Info, message: 'Un-stacked', timeout: 1500 });
|
||||
} catch (error) {
|
||||
handleError(error, `Unable to unstack`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -562,6 +573,7 @@
|
||||
on:delete={() => trashOrDelete()}
|
||||
on:favorite={toggleFavorite}
|
||||
on:addToAlbum={() => openAlbumPicker(false)}
|
||||
on:restoreAsset={() => handleRestoreAsset()}
|
||||
on:addToSharedAlbum={() => openAlbumPicker(true)}
|
||||
on:playMotionPhoto={() => (shouldPlayMotionPhoto = true)}
|
||||
on:stopMotionPhoto={() => (shouldPlayMotionPhoto = false)}
|
||||
@@ -610,6 +622,7 @@
|
||||
{:else}
|
||||
<VideoViewer
|
||||
assetId={previewStackedAsset.id}
|
||||
projectionType={previewStackedAsset.exifInfo?.projectionType}
|
||||
on:close={closeViewer}
|
||||
on:onVideoEnded={() => navigateAsset()}
|
||||
on:onVideoStarted={handleVideoStarted}
|
||||
@@ -630,6 +643,7 @@
|
||||
{#if shouldPlayMotionPhoto && asset.livePhotoVideoId}
|
||||
<VideoViewer
|
||||
assetId={asset.livePhotoVideoId}
|
||||
projectionType={asset.exifInfo?.projectionType}
|
||||
on:close={closeViewer}
|
||||
on:onVideoEnded={() => (shouldPlayMotionPhoto = false)}
|
||||
/>
|
||||
@@ -643,6 +657,7 @@
|
||||
{:else}
|
||||
<VideoViewer
|
||||
assetId={asset.id}
|
||||
projectionType={asset.exifInfo?.projectionType}
|
||||
on:close={closeViewer}
|
||||
on:onVideoEnded={() => navigateAsset()}
|
||||
on:onVideoStarted={handleVideoStarted}
|
||||
|
||||
@@ -1,22 +1,39 @@
|
||||
<script lang="ts">
|
||||
import { serveFile, type AssetResponseDto } from '@immich/sdk';
|
||||
import { serveFile, type AssetResponseDto, AssetTypeEnum } from '@immich/sdk';
|
||||
import { fade } from 'svelte/transition';
|
||||
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
|
||||
import { getKey } from '$lib/utils';
|
||||
export let asset: AssetResponseDto;
|
||||
import type { AdapterConstructor, PluginConstructor } from '@photo-sphere-viewer/core';
|
||||
export let asset: Pick<AssetResponseDto, 'id' | 'type'>;
|
||||
|
||||
const photoSphereConfigs =
|
||||
asset.type === AssetTypeEnum.Video
|
||||
? ([
|
||||
import('@photo-sphere-viewer/equirectangular-video-adapter').then(
|
||||
({ EquirectangularVideoAdapter }) => EquirectangularVideoAdapter,
|
||||
),
|
||||
import('@photo-sphere-viewer/video-plugin').then(({ VideoPlugin }) => [VideoPlugin]),
|
||||
true,
|
||||
import('@photo-sphere-viewer/video-plugin/index.css'),
|
||||
] as [PromiseLike<AdapterConstructor>, Promise<PluginConstructor[]>, true, unknown])
|
||||
: ([undefined, [], false] as [undefined, [], false]);
|
||||
|
||||
const loadAssetData = async () => {
|
||||
const data = await serveFile({ id: asset.id, isWeb: false, isThumb: false, key: getKey() });
|
||||
return URL.createObjectURL(data);
|
||||
const url = URL.createObjectURL(data);
|
||||
if (asset.type === AssetTypeEnum.Video) {
|
||||
return { source: url };
|
||||
}
|
||||
return url;
|
||||
};
|
||||
</script>
|
||||
|
||||
<div transition:fade={{ duration: 150 }} class="flex h-full select-none place-content-center place-items-center">
|
||||
<!-- the photo sphere viewer is quite large, so lazy load it in parallel with loading the data -->
|
||||
{#await Promise.all([loadAssetData(), import('./photo-sphere-viewer-adapter.svelte')])}
|
||||
{#await Promise.all([loadAssetData(), import('./photo-sphere-viewer-adapter.svelte'), ...photoSphereConfigs])}
|
||||
<LoadingSpinner />
|
||||
{:then [data, module]}
|
||||
<svelte:component this={module.default} panorama={data} />
|
||||
{:then [data, module, adapter, plugins, navbar]}
|
||||
<svelte:component this={module.default} panorama={data} plugins={plugins ?? undefined} {navbar} {adapter} />
|
||||
{:catch}
|
||||
Failed to load asset
|
||||
{/await}
|
||||
|
||||
@@ -1,17 +1,32 @@
|
||||
<script lang="ts">
|
||||
import { Viewer } from '@photo-sphere-viewer/core';
|
||||
import {
|
||||
Viewer,
|
||||
EquirectangularAdapter,
|
||||
type PluginConstructor,
|
||||
type AdapterConstructor,
|
||||
} from '@photo-sphere-viewer/core';
|
||||
import '@photo-sphere-viewer/core/index.css';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
|
||||
export let panorama: string;
|
||||
export let panorama: string | { source: string };
|
||||
export let adapter: AdapterConstructor | [AdapterConstructor, unknown] = EquirectangularAdapter;
|
||||
export let plugins: (PluginConstructor | [PluginConstructor, unknown])[] = [];
|
||||
export let navbar = false;
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let viewer: Viewer;
|
||||
|
||||
onMount(() => {
|
||||
viewer = new Viewer({
|
||||
adapter,
|
||||
plugins,
|
||||
container,
|
||||
panorama,
|
||||
navbar: false,
|
||||
touchmoveTwoFingers: true,
|
||||
mousewheelCtrlKey: false,
|
||||
navbar,
|
||||
maxFov: 180,
|
||||
fisheye: true,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
|
||||
import { NotificationType, notificationController } from '../shared-components/notification/notification';
|
||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||
import { SlideshowLook, slideshowLookCssMapping, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
|
||||
|
||||
const { slideshowState, slideshowLook } = slideshowStore;
|
||||
|
||||
export let asset: AssetResponseDto;
|
||||
export let preloadAssets: AssetResponseDto[] | null = null;
|
||||
@@ -147,18 +150,29 @@
|
||||
<div
|
||||
bind:this={element}
|
||||
transition:fade={{ duration: haveFadeTransition ? 150 : 0 }}
|
||||
class="flex h-full select-none place-content-center place-items-center"
|
||||
class="relative h-full select-none"
|
||||
>
|
||||
{#if !imageLoaded}
|
||||
<LoadingSpinner />
|
||||
<div class="flex h-full items-center justify-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
{:else}
|
||||
<div bind:this={imgElement} class="h-full w-full">
|
||||
<div bind:this={imgElement} class="h-full w-full" transition:fade={{ duration: haveFadeTransition ? 150 : 0 }}>
|
||||
{#if $slideshowState !== SlideshowState.None && $slideshowLook === SlideshowLook.BlurredBackground}
|
||||
<img
|
||||
src={assetData}
|
||||
alt={getAltText(asset)}
|
||||
class="absolute top-0 left-0 -z-10 object-cover h-full w-full blur-lg"
|
||||
draggable="false"
|
||||
/>
|
||||
{/if}
|
||||
<img
|
||||
bind:this={$photoViewer}
|
||||
transition:fade={{ duration: haveFadeTransition ? 150 : 0 }}
|
||||
src={assetData}
|
||||
alt={getAltText(asset)}
|
||||
class="h-full w-full object-contain"
|
||||
class="h-full w-full {$slideshowState === SlideshowState.None
|
||||
? 'object-contain'
|
||||
: slideshowLookCssMapping[$slideshowLook]}"
|
||||
draggable="false"
|
||||
/>
|
||||
{#each getBoundingBox($boundingBoxesArray, $photoZoomState, $photoViewer) as boundingbox}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { AssetTypeEnum } from '@immich/sdk';
|
||||
import { ProjectionType } from '$lib/constants';
|
||||
import VideoNativeViewer from '$lib/components/asset-viewer/video-native-viewer.svelte';
|
||||
import PanoramaViewer from '$lib/components/asset-viewer/panorama-viewer.svelte';
|
||||
|
||||
export let assetId: string;
|
||||
export let projectionType: string | null | undefined;
|
||||
</script>
|
||||
|
||||
{#if projectionType === ProjectionType.EQUIRECTANGULAR}
|
||||
<PanoramaViewer asset={{ id: assetId, type: AssetTypeEnum.Video }} />
|
||||
{:else}
|
||||
<VideoNativeViewer {assetId} on:onVideoEnded on:onVideoStarted />
|
||||
{/if}
|
||||
@@ -28,9 +28,7 @@
|
||||
<Icon path={mdiMagnify} size="24" />
|
||||
</div>
|
||||
</button>
|
||||
<!-- svelte-ignore a11y-autofocus -->
|
||||
<input
|
||||
autofocus
|
||||
class="w-full gap-2 bg-gray-200 dark:bg-immich-dark-gray dark:text-white"
|
||||
type="text"
|
||||
{placeholder}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
export let peopleWithFaces: AssetFaceResponseDto[];
|
||||
export let allPeople: PersonResponseDto[];
|
||||
export let editedPersonIndex: number;
|
||||
export let editedPerson: PersonResponseDto;
|
||||
export let assetType: AssetTypeEnum;
|
||||
export let assetId: string;
|
||||
|
||||
@@ -106,7 +106,7 @@
|
||||
|
||||
const handleCreatePerson = async () => {
|
||||
const timeout = setTimeout(() => (isShowLoadingNewPerson = true), timeBeforeShowLoadingSpinner);
|
||||
const personToUpdate = peopleWithFaces.find((person) => person.id === peopleWithFaces[editedPersonIndex].id);
|
||||
const personToUpdate = peopleWithFaces.find((face) => face.person?.id === editedPerson.id);
|
||||
|
||||
const newFeaturePhoto = personToUpdate ? await zoomImageToBase64(personToUpdate) : null;
|
||||
|
||||
@@ -229,7 +229,7 @@
|
||||
<div class="immich-scrollbar mt-4 flex flex-wrap gap-2 overflow-y-auto">
|
||||
{#if searchName == ''}
|
||||
{#each allPeople as person (person.id)}
|
||||
{#if person.id !== peopleWithFaces[editedPersonIndex].person?.id}
|
||||
{#if person.id !== editedPerson.id}
|
||||
<div class="w-fit">
|
||||
<button class="w-[90px]" on:click={() => dispatch('reassign', person)}>
|
||||
<div class="relative">
|
||||
@@ -255,7 +255,7 @@
|
||||
{/each}
|
||||
{:else}
|
||||
{#each searchedPeople as person (person.id)}
|
||||
{#if person.id !== peopleWithFaces[editedPersonIndex].person?.id}
|
||||
{#if person.id !== editedPerson.id}
|
||||
<div class="w-fit">
|
||||
<button class="w-[90px]" on:click={() => dispatch('reassign', person)}>
|
||||
<div class="relative">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { type PersonResponseDto } from '@immich/sdk';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
|
||||
import Button from '../elements/buttons/button.svelte';
|
||||
|
||||
@@ -9,11 +9,17 @@
|
||||
export let suggestedPeople = false;
|
||||
export let thumbnailData: string;
|
||||
|
||||
let inputElement: HTMLInputElement;
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
change: string;
|
||||
cancel: void;
|
||||
input: void;
|
||||
}>();
|
||||
|
||||
onMount(() => {
|
||||
inputElement.focus();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -27,13 +33,12 @@
|
||||
autocomplete="off"
|
||||
on:submit|preventDefault={() => dispatch('change', name)}
|
||||
>
|
||||
<!-- svelte-ignore a11y-autofocus -->
|
||||
<input
|
||||
autofocus
|
||||
class="w-full gap-2 bg-gray-100 dark:bg-gray-700 dark:text-white"
|
||||
type="text"
|
||||
placeholder="New name or nickname"
|
||||
bind:value={name}
|
||||
bind:this={inputElement}
|
||||
on:input={() => dispatch('input')}
|
||||
/>
|
||||
<Button size="sm" type="submit">Done</Button>
|
||||
|
||||
@@ -28,14 +28,14 @@
|
||||
export let assetType: AssetTypeEnum;
|
||||
|
||||
// keep track of the changes
|
||||
let numberOfPersonToCreate: string[] = [];
|
||||
let numberOfAssetFaceGenerated: string[] = [];
|
||||
let peopleToCreate: string[] = [];
|
||||
let assetFaceGenerated: string[] = [];
|
||||
|
||||
// faces
|
||||
let peopleWithFaces: AssetFaceResponseDto[] = [];
|
||||
let selectedPersonToReassign: (PersonResponseDto | null)[];
|
||||
let selectedPersonToCreate: (string | null)[];
|
||||
let editedPersonIndex: number;
|
||||
let selectedPersonToReassign: Record<string, PersonResponseDto> = {};
|
||||
let selectedPersonToCreate: Record<string, string> = {};
|
||||
let editedPerson: PersonResponseDto;
|
||||
|
||||
// loading spinners
|
||||
let isShowLoadingDone = false;
|
||||
@@ -49,6 +49,8 @@
|
||||
let loaderLoadingDoneTimeout: ReturnType<typeof setTimeout>;
|
||||
let automaticRefreshTimeout: ReturnType<typeof setTimeout>;
|
||||
|
||||
const thumbnailWidth = '90px';
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
close: void;
|
||||
refresh: void;
|
||||
@@ -60,8 +62,6 @@
|
||||
const { people } = await getAllPeople({ withHidden: true });
|
||||
allPeople = people;
|
||||
peopleWithFaces = await getFaces({ id: assetId });
|
||||
selectedPersonToCreate = Array.from({ length: peopleWithFaces.length });
|
||||
selectedPersonToReassign = Array.from({ length: peopleWithFaces.length });
|
||||
} catch (error) {
|
||||
handleError(error, "Can't get faces");
|
||||
} finally {
|
||||
@@ -71,12 +71,12 @@
|
||||
}
|
||||
|
||||
const onPersonThumbnail = (personId: string) => {
|
||||
numberOfAssetFaceGenerated.push(personId);
|
||||
assetFaceGenerated.push(personId);
|
||||
if (
|
||||
isEqual(numberOfAssetFaceGenerated, numberOfPersonToCreate) &&
|
||||
isEqual(assetFaceGenerated, peopleToCreate) &&
|
||||
loaderLoadingDoneTimeout &&
|
||||
automaticRefreshTimeout &&
|
||||
selectedPersonToCreate.filter((person) => person !== null).length === numberOfPersonToCreate.length
|
||||
Object.keys(selectedPersonToCreate).length === peopleToCreate.length
|
||||
) {
|
||||
clearTimeout(loaderLoadingDoneTimeout);
|
||||
clearTimeout(automaticRefreshTimeout);
|
||||
@@ -97,36 +97,41 @@
|
||||
dispatch('close');
|
||||
};
|
||||
|
||||
const handleReset = (index: number) => {
|
||||
if (selectedPersonToReassign[index]) {
|
||||
selectedPersonToReassign[index] = null;
|
||||
const handleReset = (id: string) => {
|
||||
if (selectedPersonToReassign[id]) {
|
||||
delete selectedPersonToReassign[id];
|
||||
|
||||
// trigger reactivity
|
||||
selectedPersonToReassign = selectedPersonToReassign;
|
||||
}
|
||||
if (selectedPersonToCreate[index]) {
|
||||
selectedPersonToCreate[index] = null;
|
||||
if (selectedPersonToCreate[id]) {
|
||||
delete selectedPersonToCreate[id];
|
||||
|
||||
// trigger reactivity
|
||||
selectedPersonToCreate = selectedPersonToCreate;
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditFaces = async () => {
|
||||
loaderLoadingDoneTimeout = setTimeout(() => (isShowLoadingDone = true), timeBeforeShowLoadingSpinner);
|
||||
const numberOfChanges =
|
||||
selectedPersonToCreate.filter((person) => person !== null).length +
|
||||
selectedPersonToReassign.filter((person) => person !== null).length;
|
||||
const numberOfChanges = Object.keys(selectedPersonToCreate).length + Object.keys(selectedPersonToReassign).length;
|
||||
|
||||
if (numberOfChanges > 0) {
|
||||
try {
|
||||
for (const [index, peopleWithFace] of peopleWithFaces.entries()) {
|
||||
const personId = selectedPersonToReassign[index]?.id;
|
||||
for (const personWithFace of peopleWithFaces) {
|
||||
const personId = selectedPersonToReassign[personWithFace.id]?.id;
|
||||
|
||||
if (personId) {
|
||||
await reassignFacesById({
|
||||
id: personId,
|
||||
faceDto: { id: peopleWithFace.id },
|
||||
faceDto: { id: personWithFace.id },
|
||||
});
|
||||
} else if (selectedPersonToCreate[index]) {
|
||||
} else if (selectedPersonToCreate[personWithFace.id]) {
|
||||
const data = await createPerson({ personCreateDto: {} });
|
||||
numberOfPersonToCreate.push(data.id);
|
||||
peopleToCreate.push(data.id);
|
||||
await reassignFacesById({
|
||||
id: data.id,
|
||||
faceDto: { id: peopleWithFace.id },
|
||||
faceDto: { id: personWithFace.id },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -141,7 +146,7 @@
|
||||
}
|
||||
|
||||
isShowLoadingDone = false;
|
||||
if (numberOfPersonToCreate.length === 0) {
|
||||
if (peopleToCreate.length === 0) {
|
||||
clearTimeout(loaderLoadingDoneTimeout);
|
||||
dispatch('refresh');
|
||||
} else {
|
||||
@@ -150,23 +155,26 @@
|
||||
};
|
||||
|
||||
const handleCreatePerson = (newFeaturePhoto: string | null) => {
|
||||
const personToUpdate = peopleWithFaces.find((person) => person.id === peopleWithFaces[editedPersonIndex].id);
|
||||
const personToUpdate = peopleWithFaces.find((face) => face.person?.id === editedPerson.id);
|
||||
if (newFeaturePhoto && personToUpdate) {
|
||||
selectedPersonToCreate[peopleWithFaces.indexOf(personToUpdate)] = newFeaturePhoto;
|
||||
selectedPersonToCreate[personToUpdate.id] = newFeaturePhoto;
|
||||
}
|
||||
showSeletecFaces = false;
|
||||
};
|
||||
|
||||
const handleReassignFace = (person: PersonResponseDto | null) => {
|
||||
if (person) {
|
||||
selectedPersonToReassign[editedPersonIndex] = person;
|
||||
const personToUpdate = peopleWithFaces.find((face) => face.person?.id === editedPerson.id);
|
||||
if (person && personToUpdate) {
|
||||
selectedPersonToReassign[personToUpdate.id] = person;
|
||||
showSeletecFaces = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handlePersonPicker = (index: number) => {
|
||||
editedPersonIndex = index;
|
||||
showSeletecFaces = true;
|
||||
const handlePersonPicker = (person: PersonResponseDto | null) => {
|
||||
if (person) {
|
||||
editedPerson = person;
|
||||
showSeletecFaces = true;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -217,35 +225,48 @@
|
||||
on:mouseleave={() => ($boundingBoxesArray = [])}
|
||||
>
|
||||
<div class="relative">
|
||||
<ImageThumbnail
|
||||
curve
|
||||
shadow
|
||||
url={selectedPersonToCreate[index] ||
|
||||
getPeopleThumbnailUrl(selectedPersonToReassign[index]?.id || face.person.id)}
|
||||
altText={selectedPersonToReassign[index]
|
||||
? selectedPersonToReassign[index]?.name
|
||||
: selectedPersonToCreate[index]
|
||||
? 'New person'
|
||||
: getPersonNameWithHiddenValue(face.person?.name, face.person?.isHidden)}
|
||||
title={selectedPersonToReassign[index]
|
||||
? selectedPersonToReassign[index]?.name
|
||||
: selectedPersonToCreate[index]
|
||||
? 'New person'
|
||||
: getPersonNameWithHiddenValue(face.person?.name, face.person?.isHidden)}
|
||||
widthStyle="90px"
|
||||
heightStyle="90px"
|
||||
thumbhash={null}
|
||||
hidden={selectedPersonToReassign[index]
|
||||
? selectedPersonToReassign[index]?.isHidden
|
||||
: selectedPersonToCreate[index]
|
||||
? false
|
||||
: face.person?.isHidden}
|
||||
/>
|
||||
{#if selectedPersonToCreate[face.id]}
|
||||
<ImageThumbnail
|
||||
curve
|
||||
shadow
|
||||
url={selectedPersonToCreate[face.id]}
|
||||
altText={selectedPersonToCreate[face.id]}
|
||||
title={'New person'}
|
||||
widthStyle={thumbnailWidth}
|
||||
heightStyle={thumbnailWidth}
|
||||
/>
|
||||
{:else if selectedPersonToReassign[face.id]}
|
||||
<ImageThumbnail
|
||||
curve
|
||||
shadow
|
||||
url={getPeopleThumbnailUrl(selectedPersonToReassign[face.id].id)}
|
||||
altText={selectedPersonToReassign[face.id]?.name || selectedPersonToReassign[face.id].id}
|
||||
title={getPersonNameWithHiddenValue(
|
||||
selectedPersonToReassign[face.id].name,
|
||||
face.person?.isHidden,
|
||||
)}
|
||||
widthStyle={thumbnailWidth}
|
||||
heightStyle={thumbnailWidth}
|
||||
hidden={selectedPersonToReassign[face.id].isHidden}
|
||||
/>
|
||||
{:else}
|
||||
<ImageThumbnail
|
||||
curve
|
||||
shadow
|
||||
url={getPeopleThumbnailUrl(face.person.id)}
|
||||
altText={face.person.name || face.person.id}
|
||||
title={getPersonNameWithHiddenValue(face.person.name, face.person.isHidden)}
|
||||
widthStyle={thumbnailWidth}
|
||||
heightStyle={thumbnailWidth}
|
||||
hidden={face.person.isHidden}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{#if !selectedPersonToCreate[index]}
|
||||
|
||||
{#if !selectedPersonToCreate[face.id]}
|
||||
<p class="relative mt-1 truncate font-medium" title={face.person?.name}>
|
||||
{#if selectedPersonToReassign[index]?.id}
|
||||
{selectedPersonToReassign[index]?.name}
|
||||
{#if selectedPersonToReassign[face.id]?.id}
|
||||
{selectedPersonToReassign[face.id]?.name}
|
||||
{:else}
|
||||
{face.person?.name}
|
||||
{/if}
|
||||
@@ -253,8 +274,8 @@
|
||||
{/if}
|
||||
|
||||
<div class="absolute -right-[5px] -top-[5px] h-[20px] w-[20px] rounded-full bg-blue-700">
|
||||
{#if selectedPersonToCreate[index] || selectedPersonToReassign[index]}
|
||||
<button on:click={() => handleReset(index)} class="flex h-full w-full">
|
||||
{#if selectedPersonToCreate[face.id] || selectedPersonToReassign[face.id]}
|
||||
<button on:click={() => handleReset(face.id)} class="flex h-full w-full">
|
||||
<div class="absolute left-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%] transform">
|
||||
<div>
|
||||
<Icon path={mdiRestart} size={18} />
|
||||
@@ -262,7 +283,7 @@
|
||||
</div>
|
||||
</button>
|
||||
{:else}
|
||||
<button on:click={() => handlePersonPicker(index)} class="flex h-full w-full">
|
||||
<button on:click={() => handlePersonPicker(face.person)} class="flex h-full w-full">
|
||||
<div
|
||||
class="absolute left-1/2 top-1/2 h-[2px] w-[14px] translate-x-[-50%] translate-y-[-50%] transform bg-white"
|
||||
/>
|
||||
@@ -282,7 +303,7 @@
|
||||
<AssignFaceSidePanel
|
||||
{peopleWithFaces}
|
||||
{allPeople}
|
||||
{editedPersonIndex}
|
||||
{editedPerson}
|
||||
{assetType}
|
||||
{assetId}
|
||||
on:close={() => (showSeletecFaces = false)}
|
||||
|
||||
@@ -1,20 +1,45 @@
|
||||
<script lang="ts">
|
||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
|
||||
import type { OnStack } from '$lib/utils/actions';
|
||||
import { stackAssets } from '$lib/utils/asset-utils';
|
||||
import { mdiImageMultipleOutline } from '@mdi/js';
|
||||
import { getAssetControlContext } from '$lib/components/photos-page/asset-select-control-bar.svelte';
|
||||
import { mdiImageMinusOutline, mdiImageMultipleOutline } from '@mdi/js';
|
||||
import { stackAssets, unstackAssets } from '$lib/utils/asset-utils';
|
||||
import type { OnStack, OnUnstack } from '$lib/utils/actions';
|
||||
|
||||
export let unstack = false;
|
||||
export let onStack: OnStack | undefined;
|
||||
export let onUnstack: OnUnstack | undefined;
|
||||
|
||||
const { clearSelect, getOwnedAssets } = getAssetControlContext();
|
||||
|
||||
const handleStack = async () => {
|
||||
await stackAssets([...getOwnedAssets()], (ids) => {
|
||||
const selectedAssets = [...getOwnedAssets()];
|
||||
const ids = await stackAssets(selectedAssets);
|
||||
if (ids) {
|
||||
onStack?.(ids);
|
||||
clearSelect();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnstack = async () => {
|
||||
const selectedAssets = [...getOwnedAssets()];
|
||||
if (selectedAssets.length !== 1) {
|
||||
return;
|
||||
}
|
||||
const { stack } = selectedAssets[0];
|
||||
if (!stack) {
|
||||
return;
|
||||
}
|
||||
const assets = [selectedAssets[0], ...stack];
|
||||
const unstackedAssets = await unstackAssets(assets);
|
||||
if (unstackedAssets) {
|
||||
onUnstack?.(unstackedAssets);
|
||||
}
|
||||
clearSelect();
|
||||
};
|
||||
</script>
|
||||
|
||||
<MenuOption text="Stack" icon={mdiImageMultipleOutline} on:click={handleStack} />
|
||||
{#if unstack}
|
||||
<MenuOption text="Un-stack" icon={mdiImageMinusOutline} on:click={handleUnstack} />
|
||||
{:else}
|
||||
<MenuOption text="Stack" icon={mdiImageMultipleOutline} on:click={handleStack} />
|
||||
{/if}
|
||||
|
||||
@@ -89,11 +89,10 @@
|
||||
};
|
||||
|
||||
const onStackAssets = async () => {
|
||||
if ($selectedAssets.size > 1) {
|
||||
await stackAssets(Array.from($selectedAssets), (ids) => {
|
||||
assetStore.removeAssets(ids);
|
||||
dispatch('escape');
|
||||
});
|
||||
const ids = await stackAssets(Array.from($selectedAssets));
|
||||
if (ids) {
|
||||
assetStore.removeAssets(ids);
|
||||
dispatch('escape');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -107,6 +106,8 @@
|
||||
{ shortcut: { key: '?', shift: true }, onShortcut: () => (showShortcuts = !showShortcuts) },
|
||||
{ shortcut: { key: '/' }, onShortcut: () => goto(AppRoute.EXPLORE) },
|
||||
{ shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets(assetStore, assetInteractionStore) },
|
||||
{ shortcut: { key: 'PageUp' }, onShortcut: () => (element.scrollTop = 0) },
|
||||
{ shortcut: { key: 'PageDown' }, onShortcut: () => (element.scrollTop = viewport.height) },
|
||||
];
|
||||
|
||||
if ($isMultiSelectState) {
|
||||
@@ -168,6 +169,7 @@
|
||||
switch (action) {
|
||||
case removeAction:
|
||||
case AssetAction.TRASH:
|
||||
case AssetAction.RESTORE:
|
||||
case AssetAction.DELETE: {
|
||||
// find the next asset to show or close the viewer
|
||||
(await handleNext()) || (await handlePrevious()) || handleClose();
|
||||
|
||||
@@ -83,6 +83,7 @@
|
||||
hoverLabel = new Date(attr).toLocaleString($locale, {
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -43,21 +43,20 @@
|
||||
|
||||
const filterPeople = (list: PersonResponseDto[], name: string) => {
|
||||
const nameLower = name.toLowerCase();
|
||||
return name ? list.filter((p) => p.name.toLowerCase().startsWith(nameLower)) : list;
|
||||
return name ? list.filter((p) => p.name.toLowerCase().includes(nameLower)) : list;
|
||||
};
|
||||
</script>
|
||||
|
||||
{#await peoplePromise then people}
|
||||
{#if people && people.length > 0}
|
||||
{@const peopleList = showAllPeople ? filterPeople(people, name) : people.slice(0, numberOfPeople)}
|
||||
{@const peopleList = showAllPeople
|
||||
? filterPeople(people, name)
|
||||
: filterPeople(people, name).slice(0, numberOfPeople)}
|
||||
|
||||
<div id="people-selection" class="-mb-4">
|
||||
<div class="flex items-center w-full justify-between gap-6">
|
||||
<p class="immich-form-label py-3">PEOPLE</p>
|
||||
|
||||
{#if showAllPeople}
|
||||
<SearchBar bind:name placeholder="Filter people" isSearching={false} />
|
||||
{/if}
|
||||
<SearchBar bind:name placeholder="Filter people" isSearching={false} />
|
||||
</div>
|
||||
|
||||
<div class="flex -mx-1 max-h-64 gap-1 mt-2 flex-wrap overflow-y-auto immich-scrollbar">
|
||||
|
||||
@@ -4,27 +4,42 @@
|
||||
SettingInputFieldType,
|
||||
} from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||
import { mdiArrowDownThin, mdiArrowUpThin, mdiShuffle } from '@mdi/js';
|
||||
import { SlideshowNavigation, slideshowStore } from '../stores/slideshow.store';
|
||||
import {
|
||||
mdiArrowDownThin,
|
||||
mdiArrowUpThin,
|
||||
mdiFitToPageOutline,
|
||||
mdiFitToScreenOutline,
|
||||
mdiPanorama,
|
||||
mdiShuffle,
|
||||
} from '@mdi/js';
|
||||
import { SlideshowLook, SlideshowNavigation, slideshowStore } from '../stores/slideshow.store';
|
||||
import Button from './elements/buttons/button.svelte';
|
||||
import type { RenderedOption } from './elements/dropdown.svelte';
|
||||
import SettingDropdown from './shared-components/settings/setting-dropdown.svelte';
|
||||
|
||||
const { slideshowDelay, showProgressBar, slideshowNavigation } = slideshowStore;
|
||||
const { slideshowDelay, showProgressBar, slideshowNavigation, slideshowLook } = slideshowStore;
|
||||
|
||||
export let onClose = () => {};
|
||||
|
||||
const options: Record<SlideshowNavigation, RenderedOption> = {
|
||||
const navigationOptions: Record<SlideshowNavigation, RenderedOption> = {
|
||||
[SlideshowNavigation.Shuffle]: { icon: mdiShuffle, title: 'Shuffle' },
|
||||
[SlideshowNavigation.AscendingOrder]: { icon: mdiArrowUpThin, title: 'Backward' },
|
||||
[SlideshowNavigation.DescendingOrder]: { icon: mdiArrowDownThin, title: 'Forward' },
|
||||
};
|
||||
|
||||
const handleToggle = (selectedOption: RenderedOption) => {
|
||||
const lookOptions: Record<SlideshowLook, RenderedOption> = {
|
||||
[SlideshowLook.Contain]: { icon: mdiFitToScreenOutline, title: 'Contain' },
|
||||
[SlideshowLook.Cover]: { icon: mdiFitToPageOutline, title: 'Cover' },
|
||||
[SlideshowLook.BlurredBackground]: { icon: mdiPanorama, title: 'Blurred background' },
|
||||
};
|
||||
|
||||
const handleToggle = <Type extends SlideshowNavigation | SlideshowLook>(
|
||||
record: RenderedOption,
|
||||
options: Record<Type, RenderedOption>,
|
||||
): undefined | Type => {
|
||||
for (const [key, option] of Object.entries(options)) {
|
||||
if (option === selectedOption) {
|
||||
$slideshowNavigation = key as SlideshowNavigation;
|
||||
break;
|
||||
if (option === record) {
|
||||
return key as Type;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -34,9 +49,19 @@
|
||||
<div class="flex flex-col gap-4 text-immich-primary dark:text-immich-dark-primary">
|
||||
<SettingDropdown
|
||||
title="Direction"
|
||||
options={Object.values(options)}
|
||||
selectedOption={options[$slideshowNavigation]}
|
||||
onToggle={(option) => handleToggle(option)}
|
||||
options={Object.values(navigationOptions)}
|
||||
selectedOption={navigationOptions[$slideshowNavigation]}
|
||||
onToggle={(option) => {
|
||||
$slideshowNavigation = handleToggle(option, navigationOptions) || $slideshowNavigation;
|
||||
}}
|
||||
/>
|
||||
<SettingDropdown
|
||||
title="Look"
|
||||
options={Object.values(lookOptions)}
|
||||
selectedOption={lookOptions[$slideshowLook]}
|
||||
onToggle={(option) => {
|
||||
$slideshowLook = handleToggle(option, lookOptions) || $slideshowLook;
|
||||
}}
|
||||
/>
|
||||
<SettingSwitch id="show-progress-bar" title="Show Progress Bar" bind:checked={$showProgressBar} />
|
||||
<SettingInputField
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import type { AuthDeviceResponseDto } from '@immich/sdk';
|
||||
import type { SessionResponseDto } from '@immich/sdk';
|
||||
import {
|
||||
mdiAndroid,
|
||||
mdiApple,
|
||||
@@ -15,7 +15,7 @@
|
||||
import { DateTime, type ToRelativeCalendarOptions } from 'luxon';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
export let device: AuthDeviceResponseDto;
|
||||
export let device: SessionResponseDto;
|
||||
|
||||
const dispatcher = createEventDispatcher<{
|
||||
delete: void;
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { getAuthDevices, logoutAuthDevice, logoutAuthDevices, type AuthDeviceResponseDto } from '@immich/sdk';
|
||||
import { deleteAllSessions, deleteSession, getSessions, type SessionResponseDto } from '@immich/sdk';
|
||||
import { handleError } from '../../utils/handle-error';
|
||||
import Button from '../elements/buttons/button.svelte';
|
||||
import ConfirmDialogue from '../shared-components/confirm-dialogue.svelte';
|
||||
import { NotificationType, notificationController } from '../shared-components/notification/notification';
|
||||
import { notificationController, NotificationType } from '../shared-components/notification/notification';
|
||||
import DeviceCard from './device-card.svelte';
|
||||
|
||||
export let devices: AuthDeviceResponseDto[];
|
||||
let deleteDevice: AuthDeviceResponseDto | null = null;
|
||||
export let devices: SessionResponseDto[];
|
||||
let deleteDevice: SessionResponseDto | null = null;
|
||||
let deleteAll = false;
|
||||
|
||||
const refresh = () => getAuthDevices().then((_devices) => (devices = _devices));
|
||||
const refresh = () => getSessions().then((_devices) => (devices = _devices));
|
||||
|
||||
$: currentDevice = devices.find((device) => device.current);
|
||||
$: otherDevices = devices.filter((device) => !device.current);
|
||||
@@ -21,7 +21,7 @@
|
||||
}
|
||||
|
||||
try {
|
||||
await logoutAuthDevice({ id: deleteDevice.id });
|
||||
await deleteSession({ id: deleteDevice.id });
|
||||
notificationController.show({ message: `Logged out device`, type: NotificationType.Info });
|
||||
} catch (error) {
|
||||
handleError(error, 'Unable to log out device');
|
||||
@@ -33,7 +33,7 @@
|
||||
|
||||
const handleDeleteAll = async () => {
|
||||
try {
|
||||
await logoutAuthDevices();
|
||||
await deleteAllSessions();
|
||||
notificationController.show({
|
||||
message: `Logged out all devices`,
|
||||
type: NotificationType.Info,
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
import { featureFlags } from '$lib/stores/server-config.store';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { oauth } from '$lib/utils';
|
||||
import { type ApiKeyResponseDto, type AuthDeviceResponseDto } from '@immich/sdk';
|
||||
import { type ApiKeyResponseDto, type SessionResponseDto } from '@immich/sdk';
|
||||
import SettingAccordionState from '../shared-components/settings/setting-accordion-state.svelte';
|
||||
import SettingAccordion from '../shared-components/settings/setting-accordion.svelte';
|
||||
import AppSettings from './app-settings.svelte';
|
||||
import ChangePasswordSettings from './change-password-settings.svelte';
|
||||
@@ -14,10 +15,9 @@
|
||||
import PartnerSettings from './partner-settings.svelte';
|
||||
import UserAPIKeyList from './user-api-key-list.svelte';
|
||||
import UserProfileSettings from './user-profile-settings.svelte';
|
||||
import SettingAccordionState from '../shared-components/settings/setting-accordion-state.svelte';
|
||||
|
||||
export let keys: ApiKeyResponseDto[] = [];
|
||||
export let devices: AuthDeviceResponseDto[] = [];
|
||||
export let sessions: SessionResponseDto[] = [];
|
||||
|
||||
let oauthOpen =
|
||||
oauth.isCallback(window.location) ||
|
||||
@@ -38,7 +38,7 @@
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion key="authorized-devices" title="Authorized Devices" subtitle="Manage your logged-in devices">
|
||||
<DeviceList bind:devices />
|
||||
<DeviceList bind:devices={sessions} />
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion key="memories" title="Memories" subtitle="Manage what you see in your memories">
|
||||
|
||||
@@ -5,7 +5,7 @@ export enum AssetAction {
|
||||
UNFAVORITE = 'unfavorite',
|
||||
TRASH = 'trash',
|
||||
DELETE = 'delete',
|
||||
// RESTORE = 'restore',
|
||||
RESTORE = 'restore',
|
||||
ADD = 'add',
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,18 @@ export enum SlideshowNavigation {
|
||||
DescendingOrder = 'descending-order',
|
||||
}
|
||||
|
||||
export enum SlideshowLook {
|
||||
Contain = 'contain',
|
||||
Cover = 'cover',
|
||||
BlurredBackground = 'blurred-background',
|
||||
}
|
||||
|
||||
export const slideshowLookCssMapping: Record<SlideshowLook, string> = {
|
||||
[SlideshowLook.Contain]: 'object-contain',
|
||||
[SlideshowLook.Cover]: 'object-cover',
|
||||
[SlideshowLook.BlurredBackground]: 'object-contain',
|
||||
};
|
||||
|
||||
function createSlideshowStore() {
|
||||
const restartState = writable<boolean>(false);
|
||||
const stopState = writable<boolean>(false);
|
||||
@@ -21,6 +33,7 @@ function createSlideshowStore() {
|
||||
'slideshow-navigation',
|
||||
SlideshowNavigation.DescendingOrder,
|
||||
);
|
||||
const slideshowLook = persisted<SlideshowLook>('slideshow-look', SlideshowLook.Contain);
|
||||
const slideshowState = writable<SlideshowState>(SlideshowState.None);
|
||||
|
||||
const showProgressBar = persisted<boolean>('slideshow-show-progressbar', true);
|
||||
@@ -50,6 +63,7 @@ function createSlideshowStore() {
|
||||
},
|
||||
},
|
||||
slideshowNavigation,
|
||||
slideshowLook,
|
||||
slideshowState,
|
||||
slideshowDelay,
|
||||
showProgressBar,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { notificationController, NotificationType } from '$lib/components/shared-components/notification/notification';
|
||||
import { deleteAssets as deleteBulk } from '@immich/sdk';
|
||||
import { deleteAssets as deleteBulk, type AssetResponseDto } from '@immich/sdk';
|
||||
import { handleError } from './handle-error';
|
||||
|
||||
export type OnDelete = (assetIds: string[]) => void;
|
||||
@@ -7,6 +7,7 @@ export type OnRestore = (ids: string[]) => void;
|
||||
export type OnArchive = (ids: string[], isArchived: boolean) => void;
|
||||
export type OnFavorite = (ids: string[], favorite: boolean) => void;
|
||||
export type OnStack = (ids: string[]) => void;
|
||||
export type OnUnstack = (assets: AssetResponseDto[]) => void;
|
||||
|
||||
export const deleteAssets = async (force: boolean, onAssetDelete: OnDelete, ids: string[]) => {
|
||||
try {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { goto } from '$app/navigation';
|
||||
import { NotificationType, notificationController } from '$lib/components/shared-components/notification/notification';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { BucketPosition, isSelectingAllAssets, type AssetStore } from '$lib/stores/assets.store';
|
||||
import { downloadManager } from '$lib/stores/download';
|
||||
import { downloadRequest, getKey } from '$lib/utils';
|
||||
@@ -269,43 +270,81 @@ export const getSelectedAssets = (assets: Set<AssetResponseDto>, user: UserRespo
|
||||
return ids;
|
||||
};
|
||||
|
||||
export async function stackAssets(assets: Array<AssetResponseDto>, onStack: (ds: string[]) => void) {
|
||||
export const stackAssets = async (assets: AssetResponseDto[]) => {
|
||||
if (assets.length < 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parent = assets[0];
|
||||
const children = assets.slice(1);
|
||||
const ids = children.map(({ id }) => id);
|
||||
|
||||
try {
|
||||
const parent = assets.at(0);
|
||||
if (!parent) {
|
||||
return;
|
||||
}
|
||||
await updateAssets({
|
||||
assetBulkUpdateDto: {
|
||||
ids,
|
||||
stackParentId: parent.id,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
handleError(error, 'Failed to stack assets');
|
||||
return false;
|
||||
}
|
||||
|
||||
const children = assets.slice(1);
|
||||
const ids = children.map(({ id }) => id);
|
||||
|
||||
if (children.length > 0) {
|
||||
await updateAssets({ assetBulkUpdateDto: { ids, stackParentId: parent.id } });
|
||||
}
|
||||
|
||||
let childrenCount = parent.stackCount || 1;
|
||||
for (const asset of children) {
|
||||
asset.stackParentId = parent.id;
|
||||
// Add grand-children's count to new parent
|
||||
childrenCount += asset.stackCount || 1;
|
||||
let grandChildren: AssetResponseDto[] = [];
|
||||
for (const asset of children) {
|
||||
asset.stackParentId = parent.id;
|
||||
if (asset.stack) {
|
||||
// Add grand-children to new parent
|
||||
grandChildren = grandChildren.concat(asset.stack);
|
||||
// Reset children stack info
|
||||
asset.stackCount = null;
|
||||
asset.stack = [];
|
||||
}
|
||||
|
||||
parent.stackCount = childrenCount;
|
||||
|
||||
notificationController.show({
|
||||
message: `Stacked ${ids.length + 1} assets`,
|
||||
type: NotificationType.Info,
|
||||
timeout: 1500,
|
||||
});
|
||||
|
||||
onStack(ids);
|
||||
} catch (error) {
|
||||
handleError(error, `Unable to stack`);
|
||||
}
|
||||
}
|
||||
|
||||
parent.stack ??= [];
|
||||
parent.stack = parent.stack.concat(children, grandChildren);
|
||||
parent.stackCount = parent.stack.length + 1;
|
||||
|
||||
notificationController.show({
|
||||
message: `Stacked ${parent.stackCount} assets`,
|
||||
type: NotificationType.Info,
|
||||
button: {
|
||||
text: 'View Stack',
|
||||
onClick() {
|
||||
return assetViewingStore.setAssetId(parent.id);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return ids;
|
||||
};
|
||||
|
||||
export const unstackAssets = async (assets: AssetResponseDto[]) => {
|
||||
const ids = assets.map(({ id }) => id);
|
||||
try {
|
||||
await updateAssets({
|
||||
assetBulkUpdateDto: {
|
||||
ids,
|
||||
removeParent: true,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
handleError(error, 'Failed to un-stack assets');
|
||||
return;
|
||||
}
|
||||
for (const asset of assets) {
|
||||
asset.stackParentId = null;
|
||||
asset.stackCount = null;
|
||||
asset.stack = [];
|
||||
}
|
||||
notificationController.show({
|
||||
type: NotificationType.Info,
|
||||
message: `Un-stacked ${assets.length} assets`,
|
||||
});
|
||||
return assets;
|
||||
};
|
||||
|
||||
export const selectAllAssets = async (assetStore: AssetStore, assetInteractionStore: AssetInteractionStore) => {
|
||||
if (get(isSelectingAllAssets)) {
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
|
||||
const handleSearch = async (force: boolean) => {
|
||||
$page.url.searchParams.set(QueryParameter.SEARCHED_PEOPLE, searchName);
|
||||
await goto($page.url);
|
||||
await goto($page.url, { keepFocus: true });
|
||||
await handleSearchPeople(force);
|
||||
};
|
||||
|
||||
|
||||
@@ -30,7 +30,14 @@
|
||||
const assetInteractionStore = createAssetInteractionStore();
|
||||
const { isMultiSelectState, selectedAssets } = assetInteractionStore;
|
||||
|
||||
$: isAllFavorite = [...$selectedAssets].every((asset) => asset.isFavorite);
|
||||
let isAllFavorite: boolean;
|
||||
let isAssetStackSelected: boolean;
|
||||
|
||||
$: {
|
||||
const selection = [...$selectedAssets];
|
||||
isAllFavorite = selection.every((asset) => asset.isFavorite);
|
||||
isAssetStackSelected = selection.length === 1 && !!selection[0].stack;
|
||||
}
|
||||
|
||||
const handleEscape = () => {
|
||||
if ($showAssetViewer) {
|
||||
@@ -62,8 +69,12 @@
|
||||
<FavoriteAction removeFavorite={isAllFavorite} onFavorite={() => assetStore.triggerUpdate()} />
|
||||
<AssetSelectContextMenu icon={mdiDotsVertical} title="Menu">
|
||||
<DownloadAction menuItem />
|
||||
{#if $selectedAssets.size > 1}
|
||||
<StackAction onStack={(assetIds) => assetStore.removeAssets(assetIds)} />
|
||||
{#if $selectedAssets.size > 1 || isAssetStackSelected}
|
||||
<StackAction
|
||||
unstack={isAssetStackSelected}
|
||||
onStack={(assetIds) => assetStore.removeAssets(assetIds)}
|
||||
onUnstack={(assets) => assetStore.addAssets(assets)}
|
||||
/>
|
||||
{/if}
|
||||
<ChangeDate menuItem />
|
||||
<ChangeLocation menuItem />
|
||||
|
||||
@@ -39,8 +39,12 @@
|
||||
try {
|
||||
await emptyTrash();
|
||||
|
||||
const deletedAssetIds = assetStore.assets.map((a) => a.id);
|
||||
const numberOfAssets = deletedAssetIds.length;
|
||||
assetStore.removeAssets(deletedAssetIds);
|
||||
|
||||
notificationController.show({
|
||||
message: `Empty trash initiated. Refresh the page to see the changes`,
|
||||
message: `Permanently deleted ${numberOfAssets} ${numberOfAssets == 1 ? 'asset' : 'assets'}`,
|
||||
type: NotificationType.Info,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -52,8 +56,12 @@
|
||||
try {
|
||||
await restoreTrash();
|
||||
|
||||
const restoredAssetIds = assetStore.assets.map((a) => a.id);
|
||||
const numberOfAssets = restoredAssetIds.length;
|
||||
assetStore.removeAssets(restoredAssetIds);
|
||||
|
||||
notificationController.show({
|
||||
message: `Restore trash initiated. Refresh the page to see the changes`,
|
||||
message: `Restored ${numberOfAssets} ${numberOfAssets == 1 ? 'asset' : 'assets'}`,
|
||||
type: NotificationType.Info,
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
</svelte:fragment>
|
||||
<section class="mx-4 flex place-content-center">
|
||||
<div class="w-full max-w-3xl">
|
||||
<UserSettingsList keys={data.keys} devices={data.devices} />
|
||||
<UserSettingsList keys={data.keys} sessions={data.sessions} />
|
||||
</div>
|
||||
</section>
|
||||
</UserPageLayout>
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { authenticate } from '$lib/utils/auth';
|
||||
import { getApiKeys, getAuthDevices } from '@immich/sdk';
|
||||
import { getApiKeys, getSessions } from '@immich/sdk';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (async () => {
|
||||
await authenticate();
|
||||
|
||||
const keys = await getApiKeys();
|
||||
const devices = await getAuthDevices();
|
||||
const sessions = await getSessions();
|
||||
|
||||
return {
|
||||
keys,
|
||||
devices,
|
||||
sessions,
|
||||
meta: {
|
||||
title: 'Settings',
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import OnboadingStorageTemplate from '$lib/components/onboarding-page/onboarding-storage-template.svelte';
|
||||
import OnboardingTheme from '$lib/components/onboarding-page/onboarding-theme.svelte';
|
||||
import { AppRoute, QueryParameter } from '$lib/constants';
|
||||
import { setAdminOnboarding } from '@immich/sdk';
|
||||
import { updateAdminOnboarding } from '@immich/sdk';
|
||||
|
||||
let index = 0;
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
|
||||
const handleDoneClicked = async () => {
|
||||
if (index >= onboardingSteps.length - 1) {
|
||||
await setAdminOnboarding();
|
||||
await updateAdminOnboarding({ adminOnboardingUpdateDto: { isOnboarded: true } });
|
||||
await goto(AppRoute.PHOTOS);
|
||||
} else {
|
||||
index++;
|
||||
|
||||
Reference in New Issue
Block a user