Merge branch 'main' into feature/readonly-sharing

# Conflicts:
#	mobile/openapi/.openapi-generator/FILES
#	mobile/openapi/README.md
#	mobile/openapi/lib/api.dart
#	mobile/openapi/lib/api_client.dart
This commit is contained in:
mgabor
2024-04-19 16:24:54 +02:00
81 changed files with 1377 additions and 1062 deletions

View File

@@ -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';
@@ -28,7 +28,6 @@
getAllAlbums,
runAssetJobs,
updateAsset,
updateAssets,
updateAlbumInfo,
type ActivityResponseDto,
type AlbumResponseDto,
@@ -481,20 +480,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`);
}
};

View File

@@ -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 { slideshowLookCssMapping, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
const { slideshowState, slideshowLook } = slideshowStore;
export let asset: AssetResponseDto;
export let preloadAssets: AssetResponseDto[] | null = null;
@@ -158,7 +161,9 @@
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}

View File

@@ -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">

View File

@@ -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)}

View File

@@ -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}

View File

@@ -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) {

View File

@@ -4,27 +4,34 @@
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, 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' },
};
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 +41,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

View File

@@ -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;

View File

@@ -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,

View File

@@ -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">

View File

@@ -13,6 +13,16 @@ export enum SlideshowNavigation {
DescendingOrder = 'descending-order',
}
export enum SlideshowLook {
Contain = 'contain',
Cover = 'cover',
}
export const slideshowLookCssMapping: Record<SlideshowLook, string> = {
[SlideshowLook.Contain]: 'object-contain',
[SlideshowLook.Cover]: 'object-cover',
};
function createSlideshowStore() {
const restartState = writable<boolean>(false);
const stopState = writable<boolean>(false);
@@ -21,6 +31,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 +61,7 @@ function createSlideshowStore() {
},
},
slideshowNavigation,
slideshowLook,
slideshowState,
slideshowDelay,
showProgressBar,

View File

@@ -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 {

View File

@@ -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)) {

View File

@@ -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 />

View File

@@ -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>

View File

@@ -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',
},