mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
merge: remote-tracking branch 'origin/main' into feat/integrity-checks-izzy
This commit is contained in:
+1
-1
@@ -27,7 +27,7 @@
|
||||
"@formatjs/icu-messageformat-parser": "^3.0.0",
|
||||
"@immich/justified-layout-wasm": "^0.4.3",
|
||||
"@immich/sdk": "file:../open-api/typescript-sdk",
|
||||
"@immich/ui": "^0.53.3",
|
||||
"@immich/ui": "^0.56.1",
|
||||
"@mapbox/mapbox-gl-rtl-text": "0.2.3",
|
||||
"@mdi/js": "^7.4.47",
|
||||
"@photo-sphere-viewer/core": "^5.14.0",
|
||||
|
||||
@@ -32,8 +32,8 @@
|
||||
const allMethodsDisabled = !configToEdit.oauth.enabled && !configToEdit.passwordLogin.enabled;
|
||||
|
||||
if (allMethodsDisabled) {
|
||||
const isConfirmed = await modalManager.show(AuthDisableLoginConfirmModal);
|
||||
if (!isConfirmed) {
|
||||
const confirmed = await modalManager.show(AuthDisableLoginConfirmModal);
|
||||
if (!confirmed) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
import RightClickContextMenu from '$lib/components/shared-components/context-menu/right-click-context-menu.svelte';
|
||||
import AlbumEditModal from '$lib/modals/AlbumEditModal.svelte';
|
||||
import AlbumShareModal from '$lib/modals/AlbumShareModal.svelte';
|
||||
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
|
||||
import { handleDeleteAlbum, handleDownloadAlbum } from '$lib/services/album.service';
|
||||
import {
|
||||
AlbumFilter,
|
||||
@@ -21,14 +20,8 @@
|
||||
import { userInteraction } from '$lib/stores/user.svelte';
|
||||
import { getSelectedAlbumGroupOption, sortAlbums, stringToSortOrder, type AlbumGroup } from '$lib/utils/album-utils';
|
||||
import type { ContextMenuPosition } from '$lib/utils/context-menu';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { normalizeSearchString } from '$lib/utils/string-utils';
|
||||
import {
|
||||
addUsersToAlbum,
|
||||
type AlbumResponseDto,
|
||||
type AlbumUserAddDto,
|
||||
type SharedLinkResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { type AlbumResponseDto, type SharedLinkResponseDto } from '@immich/sdk';
|
||||
import { modalManager } from '@immich/ui';
|
||||
import { mdiDeleteOutline, mdiDownload, mdiRenameOutline, mdiShareVariantOutline } from '@mdi/js';
|
||||
import { groupBy } from 'lodash-es';
|
||||
@@ -205,18 +198,7 @@
|
||||
}
|
||||
|
||||
case 'share': {
|
||||
const result = await modalManager.show(AlbumShareModal, { album: selectedAlbum });
|
||||
switch (result?.action) {
|
||||
case 'sharedUsers': {
|
||||
await handleAddUsers(selectedAlbum, result.data);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'sharedLink': {
|
||||
await modalManager.show(SharedLinkCreateModal, { albumId: selectedAlbum.id });
|
||||
break;
|
||||
}
|
||||
}
|
||||
await modalManager.show(AlbumShareModal, { album: selectedAlbum });
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -251,20 +233,6 @@
|
||||
sharedAlbums = findAndUpdate(sharedAlbums, album);
|
||||
};
|
||||
|
||||
const handleAddUsers = async (album: AlbumResponseDto, albumUsers: AlbumUserAddDto[]) => {
|
||||
try {
|
||||
const updatedAlbum = await addUsersToAlbum({
|
||||
id: album.id,
|
||||
addUsersDto: {
|
||||
albumUsers,
|
||||
},
|
||||
});
|
||||
onUpdate(updatedAlbum);
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_add_album_users'));
|
||||
}
|
||||
};
|
||||
|
||||
const onAlbumUpdate = (album: AlbumResponseDto) => {
|
||||
onUpdate(album);
|
||||
userInteraction.recentAlbums = findAndUpdate(userInteraction.recentAlbums || [], album);
|
||||
|
||||
@@ -7,6 +7,13 @@ import DeleteAction from './delete-action.svelte';
|
||||
let asset: AssetResponseDto;
|
||||
|
||||
describe('DeleteAction component', () => {
|
||||
beforeEach(() => {
|
||||
vi.mock(import('$lib/managers/feature-flags-manager.svelte'), () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return { featureFlagsManager: { init: vi.fn(), loadFeatureFlags: vi.fn(), value: { trash: true } } as any };
|
||||
});
|
||||
});
|
||||
|
||||
describe('given an asset which is not trashed yet', () => {
|
||||
beforeEach(() => {
|
||||
asset = assetFactory.build({ isTrashed: false });
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { shortcuts } from '$lib/actions/shortcut';
|
||||
import DeleteAssetDialog from '$lib/components/photos-page/delete-asset-dialog.svelte';
|
||||
import { AssetAction } from '$lib/constants';
|
||||
import Portal from '$lib/elements/Portal.svelte';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import AssetDeleteConfirmModal from '$lib/modals/AssetDeleteConfirmModal.svelte';
|
||||
import { showDeleteModal } from '$lib/stores/preferences.store';
|
||||
import { deleteAssets as deleteAssetsUtil, type OnUndoDelete } from '$lib/utils/actions';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { deleteAssets, type AssetResponseDto } from '@immich/sdk';
|
||||
import { IconButton, toastManager } from '@immich/ui';
|
||||
import { IconButton, modalManager, toastManager } from '@immich/ui';
|
||||
import { mdiDeleteForeverOutline, mdiDeleteOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { OnAction, PreAction } from './action';
|
||||
@@ -23,24 +22,32 @@
|
||||
|
||||
let { asset, onAction, preAction, onUndoDelete = undefined }: Props = $props();
|
||||
|
||||
let showConfirmModal = $state(false);
|
||||
const forceDefault = $derived(asset.isTrashed || !featureFlagsManager.value.trash);
|
||||
|
||||
const trashOrDelete = async (force = false) => {
|
||||
if (force || !featureFlagsManager.value.trash) {
|
||||
const trashOrDelete = async (forceRequest?: boolean) => {
|
||||
const timelineAsset = toTimelineAsset(asset);
|
||||
const force = forceDefault || forceRequest;
|
||||
|
||||
if (force) {
|
||||
if ($showDeleteModal) {
|
||||
showConfirmModal = true;
|
||||
return;
|
||||
const confirmed = await modalManager.show(AssetDeleteConfirmModal, { size: 1 });
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await deleteAsset();
|
||||
|
||||
try {
|
||||
preAction({ type: AssetAction.DELETE, asset: timelineAsset });
|
||||
await deleteAssets({ assetBulkDeleteDto: { ids: [asset.id], force: true } });
|
||||
onAction({ type: AssetAction.DELETE, asset: timelineAsset });
|
||||
toastManager.success($t('permanently_deleted_asset'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_delete_asset'));
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await trashAsset();
|
||||
return;
|
||||
};
|
||||
|
||||
const trashAsset = async () => {
|
||||
const timelineAsset = toTimelineAsset(asset);
|
||||
preAction({ type: AssetAction.TRASH, asset: timelineAsset });
|
||||
await deleteAssetsUtil(
|
||||
false,
|
||||
@@ -49,24 +56,11 @@
|
||||
onUndoDelete,
|
||||
);
|
||||
};
|
||||
|
||||
const deleteAsset = async () => {
|
||||
try {
|
||||
preAction({ type: AssetAction.DELETE, asset: toTimelineAsset(asset) });
|
||||
await deleteAssets({ assetBulkDeleteDto: { ids: [asset.id], force: true } });
|
||||
onAction({ type: AssetAction.DELETE, asset: toTimelineAsset(asset) });
|
||||
toastManager.success($t('permanently_deleted_asset'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_delete_asset'));
|
||||
} finally {
|
||||
showConfirmModal = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:document
|
||||
use:shortcuts={[
|
||||
{ shortcut: { key: 'Delete' }, onShortcut: () => trashOrDelete(asset.isTrashed) },
|
||||
{ shortcut: { key: 'Delete' }, onShortcut: () => trashOrDelete() },
|
||||
{ shortcut: { key: 'Delete', shift: true }, onShortcut: () => trashOrDelete(true) },
|
||||
]}
|
||||
/>
|
||||
@@ -75,13 +69,7 @@
|
||||
color="secondary"
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
icon={asset.isTrashed ? mdiDeleteForeverOutline : mdiDeleteOutline}
|
||||
aria-label={asset.isTrashed ? $t('permanently_delete') : $t('delete')}
|
||||
onclick={() => trashOrDelete(asset.isTrashed)}
|
||||
icon={forceDefault ? mdiDeleteForeverOutline : mdiDeleteOutline}
|
||||
aria-label={forceDefault ? $t('permanently_delete') : $t('delete')}
|
||||
onclick={() => trashOrDelete()}
|
||||
/>
|
||||
|
||||
{#if showConfirmModal}
|
||||
<Portal target="body">
|
||||
<DeleteAssetDialog size={1} onCancel={() => (showConfirmModal = false)} onConfirm={deleteAsset} />
|
||||
</Portal>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { IconButton } from '@immich/ui';
|
||||
import { mdiTune } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
onAction: () => void;
|
||||
}
|
||||
|
||||
let { onAction }: Props = $props();
|
||||
</script>
|
||||
|
||||
<IconButton
|
||||
color="secondary"
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
icon={mdiTune}
|
||||
aria-label={$t('editor')}
|
||||
onclick={() => onAction()}
|
||||
/>
|
||||
@@ -32,8 +32,14 @@ describe('AssetViewerNavBar component', () => {
|
||||
vi.fn(() => ({ observe: vi.fn(), unobserve: vi.fn(), disconnect: vi.fn() })),
|
||||
);
|
||||
vi.mock(import('$lib/managers/feature-flags-manager.svelte'), () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return { featureFlagsManager: { init: vi.fn(), loadFeatureFlags: vi.fn(), value: { smartSearch: true } } as any };
|
||||
return {
|
||||
featureFlagsManager: {
|
||||
init: vi.fn(),
|
||||
loadFeatureFlags: vi.fn(),
|
||||
value: { trash: true, smartSearch: true },
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
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, withoutIcons } from '$lib/utils';
|
||||
import { getAssetJobName, 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';
|
||||
@@ -72,7 +72,7 @@
|
||||
onUndoDelete?: OnUndoDelete;
|
||||
onRunJob: (name: AssetJobName) => void;
|
||||
onPlaySlideshow: () => void;
|
||||
// export let showEditorHandler: () => void;
|
||||
// onEdit: () => void;
|
||||
onClose?: () => void;
|
||||
playOriginalVideo: boolean;
|
||||
setPlayOriginalVideo: (value: boolean) => void;
|
||||
@@ -92,6 +92,7 @@
|
||||
onRunJob,
|
||||
onPlaySlideshow,
|
||||
onClose,
|
||||
// onEdit,
|
||||
playOriginalVideo = false,
|
||||
setPlayOriginalVideo,
|
||||
}: Props = $props();
|
||||
@@ -113,16 +114,20 @@
|
||||
|
||||
const { Share, Download, SharedLinkDownload, Offline, Favorite, Unfavorite, PlayMotionPhoto, StopMotionPhoto, Info } =
|
||||
$derived(getAssetActions($t, asset));
|
||||
const sharedLink = getSharedLink();
|
||||
|
||||
// $: showEditorButton =
|
||||
// TODO: Enable when edits are ready for release
|
||||
// let showEditorButton = $derived(
|
||||
// isOwner &&
|
||||
// asset.type === AssetTypeEnum.Image &&
|
||||
// !(
|
||||
// asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR ||
|
||||
// (asset.originalPath && asset.originalPath.toLowerCase().endsWith('.insp'))
|
||||
// ) &&
|
||||
// !(asset.originalPath && asset.originalPath.toLowerCase().endsWith('.gif')) &&
|
||||
// !asset.livePhotoVideoId;
|
||||
// asset.type === AssetTypeEnum.Image &&
|
||||
// !(
|
||||
// asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR ||
|
||||
// (asset.originalPath && asset.originalPath.toLowerCase().endsWith('.insp'))
|
||||
// ) &&
|
||||
// !(asset.originalPath && asset.originalPath.toLowerCase().endsWith('.gif')) &&
|
||||
// !(asset.originalPath && asset.originalPath.toLowerCase().endsWith('.svg')) &&
|
||||
// !asset.livePhotoVideoId,
|
||||
// );
|
||||
</script>
|
||||
|
||||
<CommandPaletteDefaultProvider
|
||||
@@ -175,9 +180,15 @@
|
||||
<RatingAction {asset} {onAction} />
|
||||
{/if}
|
||||
|
||||
<!-- {#if showEditorButton}
|
||||
<EditAction onAction={onEdit} />
|
||||
{/if} -->
|
||||
|
||||
{#if isOwner}
|
||||
<DeleteAction {asset} {onAction} {preAction} {onUndoDelete} />
|
||||
{/if}
|
||||
|
||||
{#if !sharedLink}
|
||||
<ButtonContextMenu direction="left" align="top-right" color="secondary" title={$t('more')} icon={mdiDotsVertical}>
|
||||
{#if showSlideshow && !isLocked}
|
||||
<MenuOption icon={mdiPresentationPlay} text={$t('slideshow')} onClick={onPlaySlideshow} />
|
||||
@@ -206,17 +217,19 @@
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
{#if album}
|
||||
<SetAlbumCoverAction {asset} {album} />
|
||||
{/if}
|
||||
{#if person}
|
||||
<SetFeaturedPhotoAction {asset} {person} {onAction} />
|
||||
{/if}
|
||||
{#if asset.type === AssetTypeEnum.Image && !isLocked}
|
||||
<SetProfilePictureAction {asset} />
|
||||
{/if}
|
||||
{/if}
|
||||
{#if album}
|
||||
<SetAlbumCoverAction {asset} {album} />
|
||||
{/if}
|
||||
{#if person}
|
||||
<SetFeaturedPhotoAction {asset} {person} {onAction} />
|
||||
{/if}
|
||||
{#if asset.type === AssetTypeEnum.Image && !isLocked}
|
||||
<SetProfilePictureAction {asset} />
|
||||
{/if}
|
||||
|
||||
{#if !isLocked}
|
||||
{#if !isLocked}
|
||||
{#if isOwner}
|
||||
<ArchiveAction {asset} {onAction} {preAction} />
|
||||
<MenuOption
|
||||
icon={mdiUpload}
|
||||
@@ -230,28 +243,29 @@
|
||||
text={$t('view_in_timeline')}
|
||||
/>
|
||||
{/if}
|
||||
{#if !asset.isArchived && !asset.isTrashed && smartSearchEnabled}
|
||||
<MenuOption
|
||||
icon={mdiCompare}
|
||||
onClick={() =>
|
||||
goto(resolve(`${AppRoute.SEARCH}?query={"queryAssetId":"${stack?.primaryAssetId ?? asset.id}"}`))}
|
||||
text={$t('view_similar_photos')}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if !asset.isTrashed}
|
||||
<SetVisibilityAction asset={toTimelineAsset(asset)} {onAction} {preAction} />
|
||||
{/if}
|
||||
|
||||
{#if asset.type === AssetTypeEnum.Video}
|
||||
{#if !asset.isArchived && !asset.isTrashed && smartSearchEnabled}
|
||||
<MenuOption
|
||||
icon={mdiVideoOutline}
|
||||
onClick={() => setPlayOriginalVideo(!playOriginalVideo)}
|
||||
text={playOriginalVideo ? $t('play_transcoded_video') : $t('play_original_video')}
|
||||
icon={mdiCompare}
|
||||
onClick={() =>
|
||||
goto(resolve(`${AppRoute.SEARCH}?query={"queryAssetId":"${stack?.primaryAssetId ?? asset.id}"}`))}
|
||||
text={$t('view_similar_photos')}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if !asset.isTrashed && isOwner}
|
||||
<SetVisibilityAction asset={toTimelineAsset(asset)} {onAction} {preAction} />
|
||||
{/if}
|
||||
|
||||
{#if asset.type === AssetTypeEnum.Video}
|
||||
<MenuOption
|
||||
icon={mdiVideoOutline}
|
||||
onClick={() => setPlayOriginalVideo(!playOriginalVideo)}
|
||||
text={playOriginalVideo ? $t('play_transcoded_video') : $t('play_original_video')}
|
||||
/>
|
||||
{/if}
|
||||
{#if isOwner}
|
||||
<hr />
|
||||
<MenuOption
|
||||
icon={mdiHeadSyncOutline}
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
import { activityManager } from '$lib/managers/activity-manager.svelte';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { editManager, EditToolType } from '$lib/managers/edit/edit-manager.svelte';
|
||||
import { preloadManager } from '$lib/managers/PreloadManager.svelte';
|
||||
import { closeEditorCofirm } from '$lib/stores/asset-editor.store';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { ocrManager } from '$lib/stores/ocr.svelte';
|
||||
import { alwaysLoadOriginalVideo } from '$lib/stores/preferences.store';
|
||||
@@ -44,8 +44,8 @@
|
||||
import ActivityStatus from './activity-status.svelte';
|
||||
import ActivityViewer from './activity-viewer.svelte';
|
||||
import DetailPanel from './detail-panel.svelte';
|
||||
import CropArea from './editor/crop-tool/crop-area.svelte';
|
||||
import EditorPanel from './editor/editor-panel.svelte';
|
||||
import CropArea from './editor/transform-tool/crop-area.svelte';
|
||||
import ImagePanoramaViewer from './image-panorama-viewer.svelte';
|
||||
import OcrButton from './ocr-button.svelte';
|
||||
import PhotoViewer from './photo-viewer.svelte';
|
||||
@@ -67,6 +67,7 @@
|
||||
isShared?: boolean;
|
||||
album?: AlbumResponseDto;
|
||||
person?: PersonResponseDto;
|
||||
onAssetChange?: (asset: AssetResponseDto) => void;
|
||||
preAction?: PreAction;
|
||||
onAction?: OnAction;
|
||||
onUndoDelete?: OnUndoDelete;
|
||||
@@ -84,6 +85,7 @@
|
||||
isShared = false,
|
||||
album,
|
||||
person,
|
||||
onAssetChange,
|
||||
preAction,
|
||||
onAction,
|
||||
onUndoDelete,
|
||||
@@ -105,14 +107,13 @@
|
||||
const stackThumbnailSize = 60;
|
||||
const stackSelectedThumbnailSize = 65;
|
||||
|
||||
let asset = $derived(cursor.current);
|
||||
const asset = $derived(cursor.current);
|
||||
let appearsInAlbums: AlbumResponseDto[] = $state([]);
|
||||
let sharedLink = getSharedLink();
|
||||
let previewStackedAsset: AssetResponseDto | undefined = $state();
|
||||
let isShowEditor = $state(false);
|
||||
let fullscreenElement = $state<Element>();
|
||||
let unsubscribes: (() => void)[] = [];
|
||||
let selectedEditType: string = $state('');
|
||||
let stack: StackResponseDto | null = $state(null);
|
||||
|
||||
let zoomToggle = $state(() => void 0);
|
||||
@@ -200,10 +201,15 @@
|
||||
onClose?.(asset);
|
||||
};
|
||||
|
||||
const closeEditor = () => {
|
||||
closeEditorCofirm(() => {
|
||||
isShowEditor = false;
|
||||
});
|
||||
const closeEditor = async () => {
|
||||
if (editManager.hasAppliedEdits) {
|
||||
console.log(asset);
|
||||
const refreshedAsset = await getAssetInfo({ id: asset.id });
|
||||
console.log(refreshedAsset);
|
||||
onAssetChange?.(refreshedAsset);
|
||||
assetViewingStore.setAsset(refreshedAsset);
|
||||
}
|
||||
isShowEditor = false;
|
||||
};
|
||||
|
||||
const tracker = new InvocationTracker();
|
||||
@@ -249,6 +255,13 @@
|
||||
});
|
||||
};
|
||||
|
||||
// const showEditor = () => {
|
||||
// if (assetViewerManager.isShowActivityPanel) {
|
||||
// assetViewerManager.isShowActivityPanel = false;
|
||||
// }
|
||||
// isShowEditor = !isShowEditor;
|
||||
// };
|
||||
|
||||
const handleRunJob = async (name: AssetJobName) => {
|
||||
try {
|
||||
await runAssetJobs({ assetJobsDto: { assetIds: [asset.id], name } });
|
||||
@@ -312,7 +325,7 @@
|
||||
case AssetAction.REMOVE_ASSET_FROM_STACK: {
|
||||
stack = action.stack;
|
||||
if (stack) {
|
||||
asset = stack.assets[0];
|
||||
cursor.current = stack.assets[0];
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -323,11 +336,11 @@
|
||||
}
|
||||
case AssetAction.SET_PERSON_FEATURED_PHOTO: {
|
||||
const assetInfo = await getAssetInfo({ id: asset.id });
|
||||
asset = { ...asset, people: assetInfo.people };
|
||||
cursor.current = { ...asset, people: assetInfo.people };
|
||||
break;
|
||||
}
|
||||
case AssetAction.RATING: {
|
||||
asset = {
|
||||
cursor.current = {
|
||||
...asset,
|
||||
exifInfo: {
|
||||
...asset.exifInfo,
|
||||
@@ -346,10 +359,6 @@
|
||||
onAction?.(action);
|
||||
};
|
||||
|
||||
const handleUpdateSelectedEditType = (type: string) => {
|
||||
selectedEditType = type;
|
||||
};
|
||||
|
||||
let isFullScreen = $derived(fullscreenElement !== null);
|
||||
|
||||
$effect(() => {
|
||||
@@ -394,7 +403,7 @@
|
||||
|
||||
const onAssetUpdate = (update: AssetResponseDto) => {
|
||||
if (asset.id === update.id) {
|
||||
asset = update;
|
||||
cursor.current = update;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -498,7 +507,7 @@
|
||||
.toLowerCase()
|
||||
.endsWith('.insp'))}
|
||||
<ImagePanoramaViewer bind:zoomToggle {asset} />
|
||||
{:else if isShowEditor && selectedEditType === 'crop'}
|
||||
{:else if isShowEditor && editManager.selectedTool?.type === EditToolType.Transform}
|
||||
<CropArea {asset} />
|
||||
{:else}
|
||||
<PhotoViewer
|
||||
@@ -571,17 +580,17 @@
|
||||
class="row-start-1 row-span-4 w-[400px] overflow-y-auto transition-all dark:border-l dark:border-s-immich-dark-gray"
|
||||
translate="yes"
|
||||
>
|
||||
<EditorPanel {asset} onUpdateSelectedType={handleUpdateSelectedEditType} onClose={closeEditor} />
|
||||
<EditorPanel {asset} onClose={closeEditor} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if stack && withStacked}
|
||||
{@const stackedAssets = stack.assets}
|
||||
<div id="stack-slideshow" class="absolute bottom-0 w-full col-span-4 col-start-1">
|
||||
<div id="stack-slideshow" class="absolute bottom-0 w-full col-span-4 col-start-1 pointer-events-none">
|
||||
<div class="relative flex flex-row no-wrap overflow-x-auto overflow-y-hidden horizontal-scrollbar">
|
||||
{#each stackedAssets as stackedAsset (stackedAsset.id)}
|
||||
<div
|
||||
class={['inline-block px-1 relative transition-all pb-2']}
|
||||
class={['inline-block px-1 relative transition-all pb-2 pointer-events-auto']}
|
||||
style:bottom={stackedAsset.id === asset.id ? '0' : '-10px'}
|
||||
>
|
||||
<Thumbnail
|
||||
@@ -590,7 +599,7 @@
|
||||
dimmed={stackedAsset.id !== asset.id}
|
||||
asset={toTimelineAsset(stackedAsset)}
|
||||
onClick={() => {
|
||||
asset = stackedAsset;
|
||||
cursor.current = stackedAsset;
|
||||
previewStackedAsset = undefined;
|
||||
}}
|
||||
onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)}
|
||||
|
||||
@@ -13,17 +13,16 @@
|
||||
|
||||
let { asset, isOwner }: Props = $props();
|
||||
|
||||
let currentDescription = asset.exifInfo?.description ?? '';
|
||||
let draftDescription = $state(currentDescription);
|
||||
let currentDescription = $derived(asset.exifInfo?.description ?? '');
|
||||
let description = $derived(currentDescription);
|
||||
|
||||
const handleFocusOut = async () => {
|
||||
if (draftDescription === currentDescription) {
|
||||
if (description === currentDescription) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateAsset({ id: asset.id, updateAssetDto: { description: draftDescription } });
|
||||
await updateAsset({ id: asset.id, updateAssetDto: { description } });
|
||||
toastManager.success($t('asset_description_updated'));
|
||||
currentDescription = draftDescription;
|
||||
} catch (error) {
|
||||
handleError(error, $t('cannot_update_the_description'));
|
||||
}
|
||||
@@ -33,7 +32,7 @@
|
||||
{#if isOwner}
|
||||
<section class="px-4 mt-10">
|
||||
<Textarea
|
||||
bind:value={draftDescription}
|
||||
bind:value={description}
|
||||
class="max-h-40 outline-none border-b border-gray-500 bg-transparent ring-0 focus:ring-0 resize-none focus:border-b-2 focus:border-immich-primary dark:focus:border-immich-dark-primary dark:bg-transparent"
|
||||
rows={1}
|
||||
grow
|
||||
@@ -47,8 +46,8 @@
|
||||
}))}
|
||||
/>
|
||||
</section>
|
||||
{:else if draftDescription}
|
||||
{:else if description}
|
||||
<section class="px-4 mt-6">
|
||||
<p class="wrap-break-word whitespace-pre-line w-full text-black dark:text-white text-base">{draftDescription}</p>
|
||||
<p class="wrap-break-word whitespace-pre-line w-full text-black dark:text-white text-base">{description}</p>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
import type { CropAspectRatio, CropSettings } from '$lib/stores/asset-editor.store';
|
||||
import { get } from 'svelte/store';
|
||||
import { cropAreaEl } from './crop-store';
|
||||
import { checkEdits } from './mouse-handlers';
|
||||
|
||||
export function recalculateCrop(
|
||||
crop: CropSettings,
|
||||
canvas: HTMLElement,
|
||||
aspectRatio: CropAspectRatio,
|
||||
returnNewCrop = false,
|
||||
): CropSettings | null {
|
||||
const canvasW = canvas.clientWidth;
|
||||
const canvasH = canvas.clientHeight;
|
||||
|
||||
let newWidth = crop.width;
|
||||
let newHeight = crop.height;
|
||||
|
||||
const { newWidth: w, newHeight: h } = keepAspectRatio(newWidth, newHeight, aspectRatio);
|
||||
|
||||
if (w > canvasW) {
|
||||
newWidth = canvasW;
|
||||
newHeight = canvasW / (w / h);
|
||||
} else if (h > canvasH) {
|
||||
newHeight = canvasH;
|
||||
newWidth = canvasH * (w / h);
|
||||
} else {
|
||||
newWidth = w;
|
||||
newHeight = h;
|
||||
}
|
||||
|
||||
const newX = Math.max(0, Math.min(crop.x, canvasW - newWidth));
|
||||
const newY = Math.max(0, Math.min(crop.y, canvasH - newHeight));
|
||||
|
||||
const newCrop = {
|
||||
width: newWidth,
|
||||
height: newHeight,
|
||||
x: newX,
|
||||
y: newY,
|
||||
};
|
||||
|
||||
if (returnNewCrop) {
|
||||
setTimeout(() => {
|
||||
checkEdits();
|
||||
}, 1);
|
||||
return newCrop;
|
||||
} else {
|
||||
crop.width = newWidth;
|
||||
crop.height = newHeight;
|
||||
crop.x = newX;
|
||||
crop.y = newY;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function animateCropChange(crop: CropSettings, newCrop: CropSettings, draw: () => void, duration = 100) {
|
||||
const cropArea = get(cropAreaEl);
|
||||
if (!cropArea) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cropFrame = cropArea.querySelector('.crop-frame') as HTMLElement;
|
||||
if (!cropFrame) {
|
||||
return;
|
||||
}
|
||||
|
||||
const startTime = performance.now();
|
||||
const initialCrop = { ...crop };
|
||||
|
||||
const animate = (currentTime: number) => {
|
||||
const elapsedTime = currentTime - startTime;
|
||||
const progress = Math.min(elapsedTime / duration, 1);
|
||||
|
||||
crop.x = initialCrop.x + (newCrop.x - initialCrop.x) * progress;
|
||||
crop.y = initialCrop.y + (newCrop.y - initialCrop.y) * progress;
|
||||
crop.width = initialCrop.width + (newCrop.width - initialCrop.width) * progress;
|
||||
crop.height = initialCrop.height + (newCrop.height - initialCrop.height) * progress;
|
||||
|
||||
draw();
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
export function keepAspectRatio(newWidth: number, newHeight: number, aspectRatio: CropAspectRatio) {
|
||||
const [widthRatio, heightRatio] = aspectRatio.split(':').map(Number);
|
||||
|
||||
if (widthRatio && heightRatio) {
|
||||
const calculatedWidth = (newHeight * widthRatio) / heightRatio;
|
||||
return { newWidth: calculatedWidth, newHeight };
|
||||
}
|
||||
|
||||
return { newWidth, newHeight };
|
||||
}
|
||||
|
||||
export function adjustDimensions(
|
||||
newWidth: number,
|
||||
newHeight: number,
|
||||
aspectRatio: CropAspectRatio,
|
||||
xLimit: number,
|
||||
yLimit: number,
|
||||
minSize: number,
|
||||
) {
|
||||
let w = newWidth;
|
||||
let h = newHeight;
|
||||
|
||||
let aspectMultiplier: number;
|
||||
|
||||
if (aspectRatio === 'free') {
|
||||
aspectMultiplier = newWidth / newHeight;
|
||||
} else {
|
||||
const [widthRatio, heightRatio] = aspectRatio.split(':').map(Number);
|
||||
aspectMultiplier = widthRatio && heightRatio ? widthRatio / heightRatio : newWidth / newHeight;
|
||||
}
|
||||
|
||||
if (aspectRatio !== 'free') {
|
||||
h = w / aspectMultiplier;
|
||||
}
|
||||
|
||||
if (w > xLimit) {
|
||||
w = xLimit;
|
||||
if (aspectRatio !== 'free') {
|
||||
h = w / aspectMultiplier;
|
||||
}
|
||||
}
|
||||
if (h > yLimit) {
|
||||
h = yLimit;
|
||||
if (aspectRatio !== 'free') {
|
||||
w = h * aspectMultiplier;
|
||||
}
|
||||
}
|
||||
|
||||
if (w < minSize) {
|
||||
w = minSize;
|
||||
if (aspectRatio !== 'free') {
|
||||
h = w / aspectMultiplier;
|
||||
}
|
||||
}
|
||||
if (h < minSize) {
|
||||
h = minSize;
|
||||
if (aspectRatio !== 'free') {
|
||||
w = h * aspectMultiplier;
|
||||
}
|
||||
}
|
||||
|
||||
if (aspectRatio !== 'free' && w / h !== aspectMultiplier) {
|
||||
if (w < minSize) {
|
||||
h = w / aspectMultiplier;
|
||||
}
|
||||
if (h < minSize) {
|
||||
w = h * aspectMultiplier;
|
||||
}
|
||||
}
|
||||
|
||||
return { newWidth: w, newHeight: h };
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const darkenLevel = writable(0.65);
|
||||
export const isResizingOrDragging = writable(false);
|
||||
export const animationFrame = writable<ReturnType<typeof requestAnimationFrame> | null>(null);
|
||||
export const canvasCursor = writable('default');
|
||||
export const dragOffset = writable({ x: 0, y: 0 });
|
||||
export const resizeSide = writable('');
|
||||
export const imgElement = writable<HTMLImageElement | null>(null);
|
||||
export const cropAreaEl = writable<HTMLElement | null>(null);
|
||||
export const isDragging = writable<boolean>(false);
|
||||
|
||||
export const overlayEl = writable<HTMLElement | null>(null);
|
||||
export const cropFrame = writable<HTMLElement | null>(null);
|
||||
|
||||
export function resetCropStore() {
|
||||
darkenLevel.set(0.65);
|
||||
isResizingOrDragging.set(false);
|
||||
animationFrame.set(null);
|
||||
canvasCursor.set('default');
|
||||
dragOffset.set({ x: 0, y: 0 });
|
||||
resizeSide.set('');
|
||||
imgElement.set(null);
|
||||
cropAreaEl.set(null);
|
||||
isDragging.set(false);
|
||||
overlayEl.set(null);
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
cropAspectRatio,
|
||||
cropImageScale,
|
||||
cropImageSize,
|
||||
cropSettings,
|
||||
cropSettingsChanged,
|
||||
normaizedRorateDegrees,
|
||||
rotateDegrees,
|
||||
type CropAspectRatio,
|
||||
} from '$lib/stores/asset-editor.store';
|
||||
import { IconButton } from '@immich/ui';
|
||||
import { mdiBackupRestore, mdiCropFree, mdiRotateLeft, mdiRotateRight, mdiSquareOutline } from '@mdi/js';
|
||||
import { tick } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import CropPreset from './crop-preset.svelte';
|
||||
import { onImageLoad } from './image-loading';
|
||||
|
||||
let rotateHorizontal = $derived([90, 270].includes($normaizedRorateDegrees));
|
||||
const icon_16_9 = `M200-280q-33 0-56.5-23.5T120-360v-240q0-33 23.5-56.5T200-680h560q33 0 56.5 23.5T840-600v240q0 33-23.5 56.5T760-280H200Zm0-80h560v-240H200v240Zm0 0v-240 240Z`;
|
||||
const icon_4_3 = `M19 5H5c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 12H5V7h14v10z`;
|
||||
const icon_3_2 = `M200-240q-33 0-56.5-23.5T120-320v-320q0-33 23.5-56.5T200-720h560q33 0 56.5 23.5T840-640v320q0 33-23.5 56.5T760-240H200Zm0-80h560v-320H200v320Zm0 0v-320 320Z`;
|
||||
const icon_7_5 = `M200-200q-33 0-56.5-23.5T120-280v-400q0-33 23.5-56.5T200-760h560q33 0 56.5 23.5T840-680v400q0 33-23.5 56.5T760-200H200Zm0-80h560v-400H200v400Zm0 0v-400 400Z`;
|
||||
interface Size {
|
||||
icon: string;
|
||||
name: CropAspectRatio;
|
||||
viewBox: string;
|
||||
rotate?: boolean;
|
||||
}
|
||||
let sizes: Size[] = [
|
||||
{
|
||||
icon: mdiCropFree,
|
||||
name: 'free',
|
||||
viewBox: '0 0 24 24',
|
||||
rotate: false,
|
||||
},
|
||||
{
|
||||
name: '1:1',
|
||||
icon: mdiSquareOutline,
|
||||
viewBox: '0 0 24 24',
|
||||
rotate: false,
|
||||
},
|
||||
{
|
||||
name: '16:9',
|
||||
icon: icon_16_9,
|
||||
viewBox: '50 -700 840 400',
|
||||
},
|
||||
{
|
||||
name: '4:3',
|
||||
icon: icon_4_3,
|
||||
viewBox: '0 0 24 24',
|
||||
},
|
||||
{
|
||||
name: '3:2',
|
||||
icon: icon_3_2,
|
||||
viewBox: '50 -720 840 480',
|
||||
},
|
||||
{
|
||||
name: '7:5',
|
||||
icon: icon_7_5,
|
||||
viewBox: '50 -760 840 560',
|
||||
},
|
||||
{
|
||||
name: '9:16',
|
||||
icon: icon_16_9,
|
||||
viewBox: '50 -700 840 400',
|
||||
rotate: true,
|
||||
},
|
||||
{
|
||||
name: '3:4',
|
||||
icon: icon_4_3,
|
||||
viewBox: '0 0 24 24',
|
||||
rotate: true,
|
||||
},
|
||||
{
|
||||
name: '2:3',
|
||||
icon: icon_3_2,
|
||||
viewBox: '50 -720 840 480',
|
||||
rotate: true,
|
||||
},
|
||||
{
|
||||
name: '5:7',
|
||||
icon: icon_7_5,
|
||||
viewBox: '50 -760 840 560',
|
||||
rotate: true,
|
||||
},
|
||||
{
|
||||
name: 'reset',
|
||||
icon: mdiBackupRestore,
|
||||
viewBox: '0 0 24 24',
|
||||
rotate: false,
|
||||
},
|
||||
];
|
||||
|
||||
let selectedSize: CropAspectRatio = $state('free');
|
||||
|
||||
$effect(() => {
|
||||
$cropAspectRatio = selectedSize;
|
||||
});
|
||||
|
||||
let sizesRows = $derived([
|
||||
sizes.filter((s) => s.rotate === false),
|
||||
sizes.filter((s) => s.rotate === undefined),
|
||||
sizes.filter((s) => s.rotate === true),
|
||||
]);
|
||||
|
||||
async function rotate(clock: boolean) {
|
||||
rotateDegrees.update((v) => {
|
||||
return v + 90 * (clock ? 1 : -1);
|
||||
});
|
||||
|
||||
await tick();
|
||||
onImageLoad();
|
||||
}
|
||||
|
||||
function selectType(size: CropAspectRatio) {
|
||||
if (size === 'reset') {
|
||||
selectedSize = 'free';
|
||||
let cropImageSizeM = $cropImageSize;
|
||||
let cropImageScaleM = $cropImageScale;
|
||||
$cropSettings = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: cropImageSizeM[0] * cropImageScaleM - 1,
|
||||
height: cropImageSizeM[1] * cropImageScaleM - 1,
|
||||
};
|
||||
$cropAspectRatio = selectedSize;
|
||||
$cropSettingsChanged = false;
|
||||
return;
|
||||
}
|
||||
selectedSize = size;
|
||||
$cropAspectRatio = size;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mt-3 px-4 py-4">
|
||||
<div class="flex h-10 w-full items-center justify-between text-sm">
|
||||
<h2 class="uppercase">{$t('editor_crop_tool_h2_aspect_ratios')}</h2>
|
||||
</div>
|
||||
{#each sizesRows as sizesRow, index (index)}
|
||||
<ul class="flex-wrap flex-row flex gap-x-6 py-2 justify-evenly">
|
||||
{#each sizesRow as size (size.name)}
|
||||
<CropPreset {size} {selectedSize} {rotateHorizontal} {selectType} />
|
||||
{/each}
|
||||
</ul>
|
||||
{/each}
|
||||
<div class="flex h-10 w-full items-center justify-between text-sm">
|
||||
<h2 class="uppercase">{$t('editor_crop_tool_h2_rotation')}</h2>
|
||||
</div>
|
||||
<ul class="flex-wrap flex-row flex gap-x-6 gap-y-4 justify-center">
|
||||
<li>
|
||||
<IconButton
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
aria-label={$t('anti_clockwise')}
|
||||
onclick={() => rotate(false)}
|
||||
icon={mdiRotateLeft}
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<IconButton
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
aria-label={$t('clockwise')}
|
||||
onclick={() => rotate(true)}
|
||||
icon={mdiRotateRight}
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -1,40 +0,0 @@
|
||||
import type { CropSettings } from '$lib/stores/asset-editor.store';
|
||||
import { get } from 'svelte/store';
|
||||
import { cropFrame, overlayEl } from './crop-store';
|
||||
|
||||
export function draw(crop: CropSettings) {
|
||||
const mCropFrame = get(cropFrame);
|
||||
|
||||
if (!mCropFrame) {
|
||||
return;
|
||||
}
|
||||
|
||||
mCropFrame.style.left = `${crop.x}px`;
|
||||
mCropFrame.style.top = `${crop.y}px`;
|
||||
mCropFrame.style.width = `${crop.width}px`;
|
||||
mCropFrame.style.height = `${crop.height}px`;
|
||||
|
||||
drawOverlay(crop);
|
||||
}
|
||||
|
||||
export function drawOverlay(crop: CropSettings) {
|
||||
const overlay = get(overlayEl);
|
||||
if (!overlay) {
|
||||
return;
|
||||
}
|
||||
|
||||
overlay.style.clipPath = `
|
||||
polygon(
|
||||
0% 0%,
|
||||
0% 100%,
|
||||
100% 100%,
|
||||
100% 0%,
|
||||
0% 0%,
|
||||
${crop.x}px ${crop.y}px,
|
||||
${crop.x + crop.width}px ${crop.y}px,
|
||||
${crop.x + crop.width}px ${crop.y + crop.height}px,
|
||||
${crop.x}px ${crop.y + crop.height}px,
|
||||
${crop.x}px ${crop.y}px
|
||||
)
|
||||
`;
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
import { cropImageScale, cropImageSize, cropSettings, type CropSettings } from '$lib/stores/asset-editor.store';
|
||||
import { get } from 'svelte/store';
|
||||
import { cropAreaEl, cropFrame, imgElement } from './crop-store';
|
||||
import { draw } from './drawing';
|
||||
|
||||
export function onImageLoad(resetSize: boolean = false) {
|
||||
const img = get(imgElement);
|
||||
const cropArea = get(cropAreaEl);
|
||||
|
||||
if (!cropArea || !img) {
|
||||
return;
|
||||
}
|
||||
|
||||
const containerWidth = cropArea.clientWidth ?? 0;
|
||||
const containerHeight = cropArea.clientHeight ?? 0;
|
||||
|
||||
const scale = calculateScale(img, containerWidth, containerHeight);
|
||||
|
||||
cropImageSize.set([img.width, img.height]);
|
||||
|
||||
if (resetSize) {
|
||||
cropSettings.update((crop) => {
|
||||
crop.x = 0;
|
||||
crop.y = 0;
|
||||
crop.width = img.width * scale;
|
||||
crop.height = img.height * scale;
|
||||
return crop;
|
||||
});
|
||||
} else {
|
||||
const cropFrameEl = get(cropFrame);
|
||||
cropFrameEl?.classList.add('transition');
|
||||
cropSettings.update((crop) => normalizeCropArea(crop, img, scale));
|
||||
cropFrameEl?.classList.add('transition');
|
||||
cropFrameEl?.addEventListener('transitionend', () => cropFrameEl?.classList.remove('transition'), {
|
||||
passive: true,
|
||||
});
|
||||
}
|
||||
cropImageScale.set(scale);
|
||||
|
||||
img.style.width = `${img.width * scale}px`;
|
||||
img.style.height = `${img.height * scale}px`;
|
||||
|
||||
draw(get(cropSettings));
|
||||
}
|
||||
|
||||
export function calculateScale(img: HTMLImageElement, containerWidth: number, containerHeight: number): number {
|
||||
const imageAspectRatio = img.width / img.height;
|
||||
let scale: number;
|
||||
|
||||
if (imageAspectRatio > 1) {
|
||||
scale = containerWidth / img.width;
|
||||
if (img.height * scale > containerHeight) {
|
||||
scale = containerHeight / img.height;
|
||||
}
|
||||
} else {
|
||||
scale = containerHeight / img.height;
|
||||
if (img.width * scale > containerWidth) {
|
||||
scale = containerWidth / img.width;
|
||||
}
|
||||
}
|
||||
|
||||
return scale;
|
||||
}
|
||||
|
||||
export function normalizeCropArea(crop: CropSettings, img: HTMLImageElement, scale: number) {
|
||||
const prevScale = get(cropImageScale);
|
||||
const scaleRatio = scale / prevScale;
|
||||
|
||||
crop.x *= scaleRatio;
|
||||
crop.y *= scaleRatio;
|
||||
crop.width *= scaleRatio;
|
||||
crop.height *= scaleRatio;
|
||||
|
||||
crop.width = Math.min(crop.width, img.width * scale);
|
||||
crop.height = Math.min(crop.height, img.height * scale);
|
||||
crop.x = Math.max(0, Math.min(crop.x, img.width * scale - crop.width));
|
||||
crop.y = Math.max(0, Math.min(crop.y, img.height * scale - crop.height));
|
||||
|
||||
return crop;
|
||||
}
|
||||
|
||||
export function resizeCanvas() {
|
||||
const img = get(imgElement);
|
||||
const cropArea = get(cropAreaEl);
|
||||
|
||||
if (!cropArea || !img) {
|
||||
return;
|
||||
}
|
||||
|
||||
const containerWidth = cropArea?.clientWidth ?? 0;
|
||||
const containerHeight = cropArea?.clientHeight ?? 0;
|
||||
const imageAspectRatio = img.width / img.height;
|
||||
|
||||
let scale;
|
||||
if (imageAspectRatio > 1) {
|
||||
scale = containerWidth / img.width;
|
||||
if (img.height * scale > containerHeight) {
|
||||
scale = containerHeight / img.height;
|
||||
}
|
||||
} else {
|
||||
scale = containerHeight / img.height;
|
||||
if (img.width * scale > containerWidth) {
|
||||
scale = containerWidth / img.width;
|
||||
}
|
||||
}
|
||||
|
||||
img.style.width = `${img.width * scale}px`;
|
||||
img.style.height = `${img.height * scale}px`;
|
||||
|
||||
const cropFrame = cropArea.querySelector('.crop-frame') as HTMLElement;
|
||||
if (cropFrame) {
|
||||
cropFrame.style.width = `${img.width * scale}px`;
|
||||
cropFrame.style.height = `${img.height * scale}px`;
|
||||
}
|
||||
|
||||
draw(get(cropSettings));
|
||||
}
|
||||
@@ -1,536 +0,0 @@
|
||||
import {
|
||||
cropAspectRatio,
|
||||
cropImageScale,
|
||||
cropImageSize,
|
||||
cropSettings,
|
||||
cropSettingsChanged,
|
||||
normaizedRorateDegrees,
|
||||
rotateDegrees,
|
||||
showCancelConfirmDialog,
|
||||
type CropSettings,
|
||||
} from '$lib/stores/asset-editor.store';
|
||||
import { get } from 'svelte/store';
|
||||
import { adjustDimensions, keepAspectRatio } from './crop-settings';
|
||||
import {
|
||||
canvasCursor,
|
||||
cropAreaEl,
|
||||
dragOffset,
|
||||
isDragging,
|
||||
isResizingOrDragging,
|
||||
overlayEl,
|
||||
resizeSide,
|
||||
} from './crop-store';
|
||||
import { draw } from './drawing';
|
||||
|
||||
export function handleMouseDown(e: MouseEvent) {
|
||||
const canvas = get(cropAreaEl);
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
const crop = get(cropSettings);
|
||||
const { mouseX, mouseY } = getMousePosition(e);
|
||||
|
||||
const {
|
||||
onLeftBoundary,
|
||||
onRightBoundary,
|
||||
onTopBoundary,
|
||||
onBottomBoundary,
|
||||
onTopLeftCorner,
|
||||
onTopRightCorner,
|
||||
onBottomLeftCorner,
|
||||
onBottomRightCorner,
|
||||
} = isOnCropBoundary(mouseX, mouseY, crop);
|
||||
|
||||
if (
|
||||
onTopLeftCorner ||
|
||||
onTopRightCorner ||
|
||||
onBottomLeftCorner ||
|
||||
onBottomRightCorner ||
|
||||
onLeftBoundary ||
|
||||
onRightBoundary ||
|
||||
onTopBoundary ||
|
||||
onBottomBoundary
|
||||
) {
|
||||
setResizeSide(mouseX, mouseY);
|
||||
} else if (isInCropArea(mouseX, mouseY, crop)) {
|
||||
startDragging(mouseX, mouseY);
|
||||
}
|
||||
|
||||
document.body.style.userSelect = 'none';
|
||||
globalThis.addEventListener('mouseup', handleMouseUp, { passive: true });
|
||||
}
|
||||
|
||||
export function handleMouseMove(e: MouseEvent) {
|
||||
const canvas = get(cropAreaEl);
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resizeSideValue = get(resizeSide);
|
||||
const { mouseX, mouseY } = getMousePosition(e);
|
||||
|
||||
if (get(isDragging)) {
|
||||
moveCrop(mouseX, mouseY);
|
||||
} else if (resizeSideValue) {
|
||||
resizeCrop(mouseX, mouseY);
|
||||
} else {
|
||||
updateCursor(mouseX, mouseY);
|
||||
}
|
||||
}
|
||||
|
||||
export function handleMouseUp() {
|
||||
globalThis.removeEventListener('mouseup', handleMouseUp);
|
||||
document.body.style.userSelect = '';
|
||||
stopInteraction();
|
||||
}
|
||||
|
||||
function getMousePosition(e: MouseEvent) {
|
||||
let offsetX = e.clientX;
|
||||
let offsetY = e.clientY;
|
||||
const clienRect = getBoundingClientRectCached(get(cropAreaEl));
|
||||
const rotateDeg = get(normaizedRorateDegrees);
|
||||
|
||||
if (rotateDeg == 90) {
|
||||
offsetX = e.clientY - (clienRect?.top ?? 0);
|
||||
offsetY = window.innerWidth - e.clientX - (window.innerWidth - (clienRect?.right ?? 0));
|
||||
} else if (rotateDeg == 180) {
|
||||
offsetX = window.innerWidth - e.clientX - (window.innerWidth - (clienRect?.right ?? 0));
|
||||
offsetY = window.innerHeight - e.clientY - (window.innerHeight - (clienRect?.bottom ?? 0));
|
||||
} else if (rotateDeg == 270) {
|
||||
offsetX = window.innerHeight - e.clientY - (window.innerHeight - (clienRect?.bottom ?? 0));
|
||||
offsetY = e.clientX - (clienRect?.left ?? 0);
|
||||
} else if (rotateDeg == 0) {
|
||||
offsetX -= clienRect?.left ?? 0;
|
||||
offsetY -= clienRect?.top ?? 0;
|
||||
}
|
||||
return { mouseX: offsetX, mouseY: offsetY };
|
||||
}
|
||||
|
||||
type BoundingClientRect = ReturnType<HTMLElement['getBoundingClientRect']>;
|
||||
let getBoundingClientRectCache: { data: BoundingClientRect | null; time: number } = {
|
||||
data: null,
|
||||
time: 0,
|
||||
};
|
||||
rotateDegrees.subscribe(() => {
|
||||
getBoundingClientRectCache.time = 0;
|
||||
});
|
||||
function getBoundingClientRectCached(el: HTMLElement | null) {
|
||||
if (Date.now() - getBoundingClientRectCache.time > 5000 || getBoundingClientRectCache.data === null) {
|
||||
getBoundingClientRectCache = {
|
||||
time: Date.now(),
|
||||
data: el?.getBoundingClientRect() ?? null,
|
||||
};
|
||||
}
|
||||
return getBoundingClientRectCache.data;
|
||||
}
|
||||
|
||||
function isOnCropBoundary(mouseX: number, mouseY: number, crop: CropSettings) {
|
||||
const { x, y, width, height } = crop;
|
||||
const sensitivity = 10;
|
||||
const cornerSensitivity = 15;
|
||||
|
||||
const outOfBound = mouseX > get(cropImageSize)[0] || mouseY > get(cropImageSize)[1] || mouseX < 0 || mouseY < 0;
|
||||
if (outOfBound) {
|
||||
return {
|
||||
onLeftBoundary: false,
|
||||
onRightBoundary: false,
|
||||
onTopBoundary: false,
|
||||
onBottomBoundary: false,
|
||||
onTopLeftCorner: false,
|
||||
onTopRightCorner: false,
|
||||
onBottomLeftCorner: false,
|
||||
onBottomRightCorner: false,
|
||||
};
|
||||
}
|
||||
|
||||
const onLeftBoundary = mouseX >= x - sensitivity && mouseX <= x + sensitivity && mouseY >= y && mouseY <= y + height;
|
||||
const onRightBoundary =
|
||||
mouseX >= x + width - sensitivity && mouseX <= x + width + sensitivity && mouseY >= y && mouseY <= y + height;
|
||||
const onTopBoundary = mouseY >= y - sensitivity && mouseY <= y + sensitivity && mouseX >= x && mouseX <= x + width;
|
||||
const onBottomBoundary =
|
||||
mouseY >= y + height - sensitivity && mouseY <= y + height + sensitivity && mouseX >= x && mouseX <= x + width;
|
||||
|
||||
const onTopLeftCorner =
|
||||
mouseX >= x - cornerSensitivity &&
|
||||
mouseX <= x + cornerSensitivity &&
|
||||
mouseY >= y - cornerSensitivity &&
|
||||
mouseY <= y + cornerSensitivity;
|
||||
const onTopRightCorner =
|
||||
mouseX >= x + width - cornerSensitivity &&
|
||||
mouseX <= x + width + cornerSensitivity &&
|
||||
mouseY >= y - cornerSensitivity &&
|
||||
mouseY <= y + cornerSensitivity;
|
||||
const onBottomLeftCorner =
|
||||
mouseX >= x - cornerSensitivity &&
|
||||
mouseX <= x + cornerSensitivity &&
|
||||
mouseY >= y + height - cornerSensitivity &&
|
||||
mouseY <= y + height + cornerSensitivity;
|
||||
const onBottomRightCorner =
|
||||
mouseX >= x + width - cornerSensitivity &&
|
||||
mouseX <= x + width + cornerSensitivity &&
|
||||
mouseY >= y + height - cornerSensitivity &&
|
||||
mouseY <= y + height + cornerSensitivity;
|
||||
|
||||
return {
|
||||
onLeftBoundary,
|
||||
onRightBoundary,
|
||||
onTopBoundary,
|
||||
onBottomBoundary,
|
||||
onTopLeftCorner,
|
||||
onTopRightCorner,
|
||||
onBottomLeftCorner,
|
||||
onBottomRightCorner,
|
||||
};
|
||||
}
|
||||
|
||||
function isInCropArea(mouseX: number, mouseY: number, crop: CropSettings) {
|
||||
const { x, y, width, height } = crop;
|
||||
return mouseX >= x && mouseX <= x + width && mouseY >= y && mouseY <= y + height;
|
||||
}
|
||||
|
||||
function setResizeSide(mouseX: number, mouseY: number) {
|
||||
const crop = get(cropSettings);
|
||||
const {
|
||||
onLeftBoundary,
|
||||
onRightBoundary,
|
||||
onTopBoundary,
|
||||
onBottomBoundary,
|
||||
onTopLeftCorner,
|
||||
onTopRightCorner,
|
||||
onBottomLeftCorner,
|
||||
onBottomRightCorner,
|
||||
} = isOnCropBoundary(mouseX, mouseY, crop);
|
||||
|
||||
if (onTopLeftCorner) {
|
||||
resizeSide.set('top-left');
|
||||
} else if (onTopRightCorner) {
|
||||
resizeSide.set('top-right');
|
||||
} else if (onBottomLeftCorner) {
|
||||
resizeSide.set('bottom-left');
|
||||
} else if (onBottomRightCorner) {
|
||||
resizeSide.set('bottom-right');
|
||||
} else if (onLeftBoundary) {
|
||||
resizeSide.set('left');
|
||||
} else if (onRightBoundary) {
|
||||
resizeSide.set('right');
|
||||
} else if (onTopBoundary) {
|
||||
resizeSide.set('top');
|
||||
} else if (onBottomBoundary) {
|
||||
resizeSide.set('bottom');
|
||||
}
|
||||
}
|
||||
|
||||
function startDragging(mouseX: number, mouseY: number) {
|
||||
isDragging.set(true);
|
||||
const crop = get(cropSettings);
|
||||
isResizingOrDragging.set(true);
|
||||
dragOffset.set({ x: mouseX - crop.x, y: mouseY - crop.y });
|
||||
fadeOverlay(false);
|
||||
}
|
||||
|
||||
function moveCrop(mouseX: number, mouseY: number) {
|
||||
const cropArea = get(cropAreaEl);
|
||||
if (!cropArea) {
|
||||
return;
|
||||
}
|
||||
|
||||
const crop = get(cropSettings);
|
||||
const { x, y } = get(dragOffset);
|
||||
|
||||
let newX = mouseX - x;
|
||||
let newY = mouseY - y;
|
||||
|
||||
newX = Math.max(0, Math.min(cropArea.clientWidth - crop.width, newX));
|
||||
newY = Math.max(0, Math.min(cropArea.clientHeight - crop.height, newY));
|
||||
|
||||
cropSettings.update((crop) => {
|
||||
crop.x = newX;
|
||||
crop.y = newY;
|
||||
return crop;
|
||||
});
|
||||
|
||||
draw(crop);
|
||||
}
|
||||
|
||||
function resizeCrop(mouseX: number, mouseY: number) {
|
||||
const canvas = get(cropAreaEl);
|
||||
const crop = get(cropSettings);
|
||||
const resizeSideValue = get(resizeSide);
|
||||
if (!canvas || !resizeSideValue) {
|
||||
return;
|
||||
}
|
||||
fadeOverlay(false);
|
||||
|
||||
const { x, y, width, height } = crop;
|
||||
const minSize = 50;
|
||||
let newWidth = width;
|
||||
let newHeight = height;
|
||||
switch (resizeSideValue) {
|
||||
case 'left': {
|
||||
newWidth = width + x - mouseX;
|
||||
newHeight = height;
|
||||
if (newWidth >= minSize && mouseX >= 0) {
|
||||
const { newWidth: w, newHeight: h } = keepAspectRatio(newWidth, newHeight, get(cropAspectRatio));
|
||||
cropSettings.update((crop) => {
|
||||
crop.width = Math.max(minSize, Math.min(w, canvas.clientWidth));
|
||||
crop.height = Math.max(minSize, Math.min(h, canvas.clientHeight));
|
||||
crop.x = Math.max(0, x + width - crop.width);
|
||||
return crop;
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'right': {
|
||||
newWidth = mouseX - x;
|
||||
newHeight = height;
|
||||
if (newWidth >= minSize && mouseX <= canvas.clientWidth) {
|
||||
const { newWidth: w, newHeight: h } = keepAspectRatio(newWidth, newHeight, get(cropAspectRatio));
|
||||
cropSettings.update((crop) => {
|
||||
crop.width = Math.max(minSize, Math.min(w, canvas.clientWidth - x));
|
||||
crop.height = Math.max(minSize, Math.min(h, canvas.clientHeight));
|
||||
return crop;
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'top': {
|
||||
newHeight = height + y - mouseY;
|
||||
newWidth = width;
|
||||
if (newHeight >= minSize && mouseY >= 0) {
|
||||
const { newWidth: w, newHeight: h } = adjustDimensions(
|
||||
newWidth,
|
||||
newHeight,
|
||||
get(cropAspectRatio),
|
||||
canvas.clientWidth,
|
||||
canvas.clientHeight,
|
||||
minSize,
|
||||
);
|
||||
cropSettings.update((crop) => {
|
||||
crop.y = Math.max(0, y + height - h);
|
||||
crop.width = w;
|
||||
crop.height = h;
|
||||
return crop;
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'bottom': {
|
||||
newHeight = mouseY - y;
|
||||
newWidth = width;
|
||||
if (newHeight >= minSize && mouseY <= canvas.clientHeight) {
|
||||
const { newWidth: w, newHeight: h } = adjustDimensions(
|
||||
newWidth,
|
||||
newHeight,
|
||||
get(cropAspectRatio),
|
||||
canvas.clientWidth,
|
||||
canvas.clientHeight - y,
|
||||
minSize,
|
||||
);
|
||||
cropSettings.update((crop) => {
|
||||
crop.width = w;
|
||||
crop.height = h;
|
||||
return crop;
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'top-left': {
|
||||
newWidth = width + x - Math.max(mouseX, 0);
|
||||
newHeight = height + y - Math.max(mouseY, 0);
|
||||
const { newWidth: w, newHeight: h } = adjustDimensions(
|
||||
newWidth,
|
||||
newHeight,
|
||||
get(cropAspectRatio),
|
||||
canvas.clientWidth,
|
||||
canvas.clientHeight,
|
||||
minSize,
|
||||
);
|
||||
cropSettings.update((crop) => {
|
||||
crop.width = w;
|
||||
crop.height = h;
|
||||
crop.x = Math.max(0, x + width - crop.width);
|
||||
crop.y = Math.max(0, y + height - crop.height);
|
||||
return crop;
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'top-right': {
|
||||
newWidth = Math.max(mouseX, 0) - x;
|
||||
newHeight = height + y - Math.max(mouseY, 0);
|
||||
const { newWidth: w, newHeight: h } = adjustDimensions(
|
||||
newWidth,
|
||||
newHeight,
|
||||
get(cropAspectRatio),
|
||||
canvas.clientWidth - x,
|
||||
y + height,
|
||||
minSize,
|
||||
);
|
||||
cropSettings.update((crop) => {
|
||||
crop.width = w;
|
||||
crop.height = h;
|
||||
crop.y = Math.max(0, y + height - crop.height);
|
||||
return crop;
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'bottom-left': {
|
||||
newWidth = width + x - Math.max(mouseX, 0);
|
||||
newHeight = Math.max(mouseY, 0) - y;
|
||||
const { newWidth: w, newHeight: h } = adjustDimensions(
|
||||
newWidth,
|
||||
newHeight,
|
||||
get(cropAspectRatio),
|
||||
canvas.clientWidth,
|
||||
canvas.clientHeight - y,
|
||||
minSize,
|
||||
);
|
||||
cropSettings.update((crop) => {
|
||||
crop.width = w;
|
||||
crop.height = h;
|
||||
crop.x = Math.max(0, x + width - crop.width);
|
||||
return crop;
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'bottom-right': {
|
||||
newWidth = Math.max(mouseX, 0) - x;
|
||||
newHeight = Math.max(mouseY, 0) - y;
|
||||
const { newWidth: w, newHeight: h } = adjustDimensions(
|
||||
newWidth,
|
||||
newHeight,
|
||||
get(cropAspectRatio),
|
||||
canvas.clientWidth - x,
|
||||
canvas.clientHeight - y,
|
||||
minSize,
|
||||
);
|
||||
cropSettings.update((crop) => {
|
||||
crop.width = w;
|
||||
crop.height = h;
|
||||
return crop;
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
cropSettings.update((crop) => {
|
||||
crop.x = Math.max(0, Math.min(crop.x, canvas.clientWidth - crop.width));
|
||||
crop.y = Math.max(0, Math.min(crop.y, canvas.clientHeight - crop.height));
|
||||
return crop;
|
||||
});
|
||||
|
||||
draw(crop);
|
||||
}
|
||||
|
||||
function updateCursor(mouseX: number, mouseY: number) {
|
||||
const canvas = get(cropAreaEl);
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
const crop = get(cropSettings);
|
||||
const rotateDeg = get(normaizedRorateDegrees);
|
||||
|
||||
let {
|
||||
onLeftBoundary,
|
||||
onRightBoundary,
|
||||
onTopBoundary,
|
||||
onBottomBoundary,
|
||||
onTopLeftCorner,
|
||||
onTopRightCorner,
|
||||
onBottomLeftCorner,
|
||||
onBottomRightCorner,
|
||||
} = isOnCropBoundary(mouseX, mouseY, crop);
|
||||
|
||||
if (rotateDeg == 90) {
|
||||
[onTopBoundary, onRightBoundary, onBottomBoundary, onLeftBoundary] = [
|
||||
onLeftBoundary,
|
||||
onTopBoundary,
|
||||
onRightBoundary,
|
||||
onBottomBoundary,
|
||||
];
|
||||
|
||||
[onTopLeftCorner, onTopRightCorner, onBottomRightCorner, onBottomLeftCorner] = [
|
||||
onBottomLeftCorner,
|
||||
onTopLeftCorner,
|
||||
onTopRightCorner,
|
||||
onBottomRightCorner,
|
||||
];
|
||||
} else if (rotateDeg == 180) {
|
||||
[onTopBoundary, onBottomBoundary] = [onBottomBoundary, onTopBoundary];
|
||||
[onLeftBoundary, onRightBoundary] = [onRightBoundary, onLeftBoundary];
|
||||
|
||||
[onTopLeftCorner, onBottomRightCorner] = [onBottomRightCorner, onTopLeftCorner];
|
||||
[onTopRightCorner, onBottomLeftCorner] = [onBottomLeftCorner, onTopRightCorner];
|
||||
} else if (rotateDeg == 270) {
|
||||
[onTopBoundary, onRightBoundary, onBottomBoundary, onLeftBoundary] = [
|
||||
onRightBoundary,
|
||||
onBottomBoundary,
|
||||
onLeftBoundary,
|
||||
onTopBoundary,
|
||||
];
|
||||
|
||||
[onTopLeftCorner, onTopRightCorner, onBottomRightCorner, onBottomLeftCorner] = [
|
||||
onTopRightCorner,
|
||||
onBottomRightCorner,
|
||||
onBottomLeftCorner,
|
||||
onTopLeftCorner,
|
||||
];
|
||||
}
|
||||
if (onTopLeftCorner || onBottomRightCorner) {
|
||||
setCursor('nwse-resize');
|
||||
} else if (onTopRightCorner || onBottomLeftCorner) {
|
||||
setCursor('nesw-resize');
|
||||
} else if (onLeftBoundary || onRightBoundary) {
|
||||
setCursor('ew-resize');
|
||||
} else if (onTopBoundary || onBottomBoundary) {
|
||||
setCursor('ns-resize');
|
||||
} else if (isInCropArea(mouseX, mouseY, crop)) {
|
||||
setCursor('move');
|
||||
} else {
|
||||
setCursor('default');
|
||||
}
|
||||
|
||||
function setCursor(cursorName: string) {
|
||||
if (get(canvasCursor) != cursorName && canvas && !get(showCancelConfirmDialog)) {
|
||||
canvasCursor.set(cursorName);
|
||||
document.body.style.cursor = cursorName;
|
||||
canvas.style.cursor = cursorName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stopInteraction() {
|
||||
isResizingOrDragging.set(false);
|
||||
isDragging.set(false);
|
||||
resizeSide.set('');
|
||||
fadeOverlay(true); // Darken the background
|
||||
|
||||
setTimeout(() => {
|
||||
checkEdits();
|
||||
}, 1);
|
||||
}
|
||||
|
||||
export function checkEdits() {
|
||||
const cropImageSizeParams = get(cropSettings);
|
||||
const originalImgSize = get(cropImageSize).map((el) => el * get(cropImageScale));
|
||||
const changed =
|
||||
Math.abs(originalImgSize[0] - cropImageSizeParams.width) > 2 ||
|
||||
Math.abs(originalImgSize[1] - cropImageSizeParams.height) > 2;
|
||||
cropSettingsChanged.set(changed);
|
||||
}
|
||||
|
||||
function fadeOverlay(toDark: boolean) {
|
||||
const overlay = get(overlayEl);
|
||||
const cropFrame = document.querySelector('.crop-frame');
|
||||
|
||||
if (toDark) {
|
||||
overlay?.classList.remove('light');
|
||||
cropFrame?.classList.remove('resizing');
|
||||
} else {
|
||||
overlay?.classList.add('light');
|
||||
cropFrame?.classList.add('resizing');
|
||||
}
|
||||
|
||||
isResizingOrDragging.set(!toDark);
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { shortcut } from '$lib/actions/shortcut';
|
||||
import { editTypes, showCancelConfirmDialog } from '$lib/stores/asset-editor.store';
|
||||
import { editManager, EditToolType } from '$lib/managers/edit/edit-manager.svelte';
|
||||
import { websocketEvents } from '$lib/stores/websocket';
|
||||
import { type AssetResponseDto } from '@immich/sdk';
|
||||
import { ConfirmModal, IconButton } from '@immich/ui';
|
||||
import { getAssetEdits, type AssetResponseDto } from '@immich/sdk';
|
||||
import { Button, HStack, IconButton } from '@immich/ui';
|
||||
import { mdiClose } from '@mdi/js';
|
||||
import { onMount } from 'svelte';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
onMount(() => {
|
||||
@@ -18,67 +18,69 @@
|
||||
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
onUpdateSelectedType: (type: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { asset = $bindable(), onUpdateSelectedType, onClose }: Props = $props();
|
||||
onMount(async () => {
|
||||
const edits = await getAssetEdits({ id: asset.id });
|
||||
await editManager.activateTool(EditToolType.Transform, asset, edits);
|
||||
});
|
||||
|
||||
let selectedType: string = $state(editTypes[0].name);
|
||||
let selectedTypeObj = $derived(editTypes.find((t) => t.name === selectedType) || editTypes[0]);
|
||||
onDestroy(() => {
|
||||
editManager.cleanup();
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
onUpdateSelectedType(selectedType);
|
||||
}, 1);
|
||||
async function applyEdits() {
|
||||
const success = await editManager.applyEdits();
|
||||
|
||||
function selectType(name: string) {
|
||||
selectedType = name;
|
||||
onUpdateSelectedType(selectedType);
|
||||
if (success) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
const onConfirm = () => (typeof $showCancelConfirmDialog === 'boolean' ? null : $showCancelConfirmDialog());
|
||||
async function closeEditor() {
|
||||
if (await editManager.closeConfirm()) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
let { asset = $bindable(), onClose }: Props = $props();
|
||||
</script>
|
||||
|
||||
<svelte:document use:shortcut={{ shortcut: { key: 'Escape' }, onShortcut: onClose }} />
|
||||
|
||||
<section class="relative p-2 dark:bg-immich-dark-bg dark:text-immich-dark-fg">
|
||||
<div class="flex place-items-center gap-2">
|
||||
<IconButton
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
icon={mdiClose}
|
||||
aria-label={$t('close')}
|
||||
onclick={onClose}
|
||||
/>
|
||||
<p class="text-lg text-immich-fg dark:text-immich-dark-fg capitalize">{$t('editor')}</p>
|
||||
</div>
|
||||
<section class="px-4 py-4">
|
||||
<ul class="flex w-full justify-around">
|
||||
{#each editTypes as etype (etype.name)}
|
||||
<li>
|
||||
<IconButton
|
||||
shape="round"
|
||||
color={etype.name === selectedType ? 'primary' : 'secondary'}
|
||||
icon={etype.icon}
|
||||
aria-label={etype.name}
|
||||
onclick={() => selectType(etype.name)}
|
||||
/>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
<section class="relative flex flex-col h-full p-2 dark:bg-immich-dark-bg dark:text-immich-dark-fg dark pt-3">
|
||||
<HStack class="justify-between me-4">
|
||||
<HStack>
|
||||
<IconButton
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
icon={mdiClose}
|
||||
aria-label={$t('close')}
|
||||
onclick={closeEditor}
|
||||
/>
|
||||
<p class="text-lg text-immich-fg dark:text-immich-dark-fg capitalize">{$t('editor')}</p>
|
||||
</HStack>
|
||||
<Button shape="round" size="small" onclick={applyEdits}>{$t('save')}</Button>
|
||||
</HStack>
|
||||
|
||||
<section>
|
||||
<selectedTypeObj.component />
|
||||
{#if editManager.selectedTool}
|
||||
<editManager.selectedTool.component />
|
||||
{/if}
|
||||
</section>
|
||||
<div class="flex-1"></div>
|
||||
<section class="p-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => editManager.resetAllChanges()}
|
||||
disabled={!editManager.hasChanges}
|
||||
class="self-start"
|
||||
shape="round"
|
||||
size="small"
|
||||
>
|
||||
{$t('editor_reset_all_changes')}
|
||||
</Button>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
{#if $showCancelConfirmDialog}
|
||||
<ConfirmModal
|
||||
title={$t('editor_close_without_save_title')}
|
||||
prompt={$t('editor_close_without_save_prompt')}
|
||||
confirmColor="danger"
|
||||
confirmText={$t('close')}
|
||||
onClose={(confirmed) => (confirmed ? onConfirm() : ($showCancelConfirmDialog = false))}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
+51
-62
@@ -1,24 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { getAssetOriginalUrl } from '$lib/utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { transformManager } from '$lib/managers/edit/transform-manager.svelte';
|
||||
import { getAssetThumbnailUrl } from '$lib/utils';
|
||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||
import { onDestroy, onMount, tick } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
import {
|
||||
changedOriention,
|
||||
cropAspectRatio,
|
||||
cropSettings,
|
||||
resetGlobalCropStore,
|
||||
rotateDegrees,
|
||||
} from '$lib/stores/asset-editor.store';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import type { AssetResponseDto } from '@immich/sdk';
|
||||
import { animateCropChange, recalculateCrop } from './crop-settings';
|
||||
import { cropAreaEl, cropFrame, imgElement, isResizingOrDragging, overlayEl, resetCropStore } from './crop-store';
|
||||
import { draw } from './drawing';
|
||||
import { onImageLoad, resizeCanvas } from './image-loading';
|
||||
import { handleMouseDown, handleMouseMove, handleMouseUp } from './mouse-handlers';
|
||||
import { AssetMediaSize, type AssetResponseDto } from '@immich/sdk';
|
||||
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
@@ -26,69 +11,72 @@
|
||||
|
||||
let { asset }: Props = $props();
|
||||
|
||||
let img = $state<HTMLImageElement>();
|
||||
let canvasContainer = $state<HTMLElement | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (!img) {
|
||||
return;
|
||||
let imageSrc = $derived(
|
||||
getAssetThumbnailUrl({ id: asset.id, cacheKey: asset.thumbhash, edited: false, size: AssetMediaSize.Preview }),
|
||||
);
|
||||
|
||||
let imageTransform = $derived.by(() => {
|
||||
const transforms: string[] = [];
|
||||
|
||||
if (transformManager.mirrorHorizontal) {
|
||||
transforms.push('scaleX(-1)');
|
||||
}
|
||||
if (transformManager.mirrorVertical) {
|
||||
transforms.push('scaleY(-1)');
|
||||
}
|
||||
|
||||
imgElement.set(img);
|
||||
});
|
||||
|
||||
cropAspectRatio.subscribe((value) => {
|
||||
if (!img || !$cropAreaEl) {
|
||||
return;
|
||||
}
|
||||
const newCrop = recalculateCrop($cropSettings, $cropAreaEl, value, true);
|
||||
if (newCrop) {
|
||||
animateCropChange($cropSettings, newCrop, () => draw($cropSettings));
|
||||
}
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
resetGlobalCropStore();
|
||||
img = new Image();
|
||||
await tick();
|
||||
|
||||
img.src = getAssetOriginalUrl({ id: asset.id, cacheKey: asset.thumbhash });
|
||||
|
||||
img.addEventListener('load', () => onImageLoad(true), { passive: true });
|
||||
img.addEventListener('error', (error) => handleError(error, $t('error_loading_image')), { passive: true });
|
||||
|
||||
globalThis.addEventListener('mousemove', handleMouseMove, { passive: true });
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
globalThis.removeEventListener('mousemove', handleMouseMove);
|
||||
resetCropStore();
|
||||
resetGlobalCropStore();
|
||||
return transforms.join(' ');
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
resizeCanvas();
|
||||
if (!canvasContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
transformManager.resizeCanvas();
|
||||
});
|
||||
|
||||
resizeObserver.observe(canvasContainer);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="canvas-container">
|
||||
<div class="canvas-container" bind:this={canvasContainer}>
|
||||
<button
|
||||
class={`crop-area ${$changedOriention ? 'changedOriention' : ''}`}
|
||||
style={`rotate:${$rotateDegrees}deg`}
|
||||
bind:this={$cropAreaEl}
|
||||
onmousedown={handleMouseDown}
|
||||
onmouseup={handleMouseUp}
|
||||
class={`crop-area ${transformManager.orientationChanged ? 'changedOriention' : ''}`}
|
||||
style={`rotate:${transformManager.imageRotation}deg`}
|
||||
bind:this={transformManager.cropAreaEl}
|
||||
onmousedown={(e) => transformManager.handleMouseDown(e)}
|
||||
onmouseup={() => transformManager.handleMouseUp()}
|
||||
aria-label="Crop area"
|
||||
type="button"
|
||||
>
|
||||
<img draggable="false" src={img?.src} alt={$getAltText(toTimelineAsset(asset))} />
|
||||
<div class={`${$isResizingOrDragging ? 'resizing' : ''} crop-frame`} bind:this={$cropFrame}>
|
||||
<img
|
||||
draggable="false"
|
||||
src={imageSrc}
|
||||
alt={$getAltText(toTimelineAsset(asset))}
|
||||
style={imageTransform ? `transform: ${imageTransform}` : ''}
|
||||
/>
|
||||
<div
|
||||
class={`${transformManager.isInteracting ? 'resizing' : ''} crop-frame`}
|
||||
bind:this={transformManager.cropFrame}
|
||||
>
|
||||
<div class="grid"></div>
|
||||
<div class="corner top-left"></div>
|
||||
<div class="corner top-right"></div>
|
||||
<div class="corner bottom-left"></div>
|
||||
<div class="corner bottom-right"></div>
|
||||
</div>
|
||||
<div class={`${$isResizingOrDragging ? 'light' : ''} overlay`} bind:this={$overlayEl}></div>
|
||||
<div
|
||||
class={`${transformManager.isInteracting ? 'light' : ''} overlay`}
|
||||
bind:this={transformManager.overlayEl}
|
||||
></div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -161,6 +149,7 @@
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
user-select: none;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.crop-frame {
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import type { CropAspectRatio } from '$lib/stores/asset-editor.store';
|
||||
import type { CropAspectRatio } from '$lib/managers/edit/transform-manager.svelte';
|
||||
import { Button, Icon, type Color } from '@immich/ui';
|
||||
|
||||
interface Props {
|
||||
@@ -0,0 +1,150 @@
|
||||
<script lang="ts">
|
||||
import { transformManager } from '$lib/managers/edit/transform-manager.svelte';
|
||||
import { Button, HStack, IconButton } from '@immich/ui';
|
||||
import { mdiFlipHorizontal, mdiFlipVertical, mdiRotateLeft, mdiRotateRight } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface AspectRatioOption {
|
||||
label: string;
|
||||
value: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
isFree?: boolean;
|
||||
}
|
||||
|
||||
const aspectRatios: AspectRatioOption[] = [
|
||||
{ label: $t('crop_aspect_ratio_free'), value: 'free', isFree: true },
|
||||
{ label: $t('crop_aspect_ratio_original'), value: 'original', width: 24, height: 18 },
|
||||
{ label: '5:4', value: '5:4', width: 22, height: 18 },
|
||||
{ label: '4:5', value: '4:5', width: 18, height: 22 },
|
||||
{ label: '4:3', value: '4:3', width: 24, height: 18 },
|
||||
{ label: '3:4', value: '3:4', width: 18, height: 24 },
|
||||
{ label: '3:2', value: '3:2', width: 24, height: 16 },
|
||||
{ label: '2:3', value: '2:3', width: 16, height: 24 },
|
||||
{ label: '16:9', value: '16:9', width: 24, height: 14 },
|
||||
{ label: '9:16', value: '9:16', width: 14, height: 24 },
|
||||
{ label: 'Square', value: '1:1', width: 20, height: 20 },
|
||||
];
|
||||
|
||||
let isRotated = $derived(transformManager.normalizedRotation % 180 !== 0);
|
||||
|
||||
function rotatedRatio(ratio: AspectRatioOption): string {
|
||||
if (ratio.value === 'free') {
|
||||
return ratio.value;
|
||||
}
|
||||
|
||||
if (isRotated) {
|
||||
let [width, height] = ratio.value.split(':');
|
||||
return `${height}:${width}`;
|
||||
} else {
|
||||
return ratio.value;
|
||||
}
|
||||
}
|
||||
|
||||
function ratioSelected(ratio: AspectRatioOption): boolean {
|
||||
let currentRatioRotated;
|
||||
if (ratio.value === 'original') {
|
||||
const { width, height } = transformManager.cropImageSize;
|
||||
// Account for rotation when comparing to original
|
||||
if (isRotated) {
|
||||
currentRatioRotated = `${height}:${width}`;
|
||||
}
|
||||
currentRatioRotated = `${width}:${height}`;
|
||||
}
|
||||
currentRatioRotated = rotatedRatio(ratio);
|
||||
|
||||
return transformManager.cropAspectRatio === currentRatioRotated;
|
||||
}
|
||||
|
||||
function selectAspectRatio(ratio: AspectRatioOption) {
|
||||
let appliedRatio;
|
||||
if (ratio.value === 'original') {
|
||||
const { width, height } = transformManager.cropImageSize;
|
||||
appliedRatio = `${width}:${height}`;
|
||||
} else {
|
||||
appliedRatio = rotatedRatio(ratio);
|
||||
}
|
||||
|
||||
transformManager.setAspectRatio(appliedRatio);
|
||||
}
|
||||
|
||||
async function rotateImage(degrees: number) {
|
||||
await transformManager.rotate(degrees);
|
||||
}
|
||||
|
||||
function mirrorImage(axis: 'horizontal' | 'vertical') {
|
||||
transformManager.mirror(axis);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mt-3 px-4">
|
||||
<div class="flex h-10 w-full items-center justify-between text-sm mt-2">
|
||||
<h2>{$t('editor_orientation')}</h2>
|
||||
</div>
|
||||
<HStack>
|
||||
<IconButton
|
||||
class="w-full"
|
||||
size="small"
|
||||
aria-label={$t('editor_rotate_left')}
|
||||
icon={mdiRotateLeft}
|
||||
onclick={() => rotateImage(-90)}
|
||||
/>
|
||||
<IconButton
|
||||
class="w-full"
|
||||
size="small"
|
||||
aria-label={$t('editor_rotate_right')}
|
||||
icon={mdiRotateRight}
|
||||
onclick={() => rotateImage(90)}
|
||||
/>
|
||||
<IconButton
|
||||
class="w-full"
|
||||
size="small"
|
||||
aria-label={$t('editor_flip_horizontal')}
|
||||
icon={mdiFlipHorizontal}
|
||||
onclick={() => mirrorImage('horizontal')}
|
||||
/>
|
||||
<IconButton
|
||||
class="w-full"
|
||||
size="small"
|
||||
aria-label={$t('editor_flip_vertical')}
|
||||
icon={mdiFlipVertical}
|
||||
onclick={() => mirrorImage('vertical')}
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
<div class="flex h-10 w-full items-center justify-between text-sm mt-6">
|
||||
<h2>{$t('crop')}</h2>
|
||||
</div>
|
||||
|
||||
<!-- Aspect Ratio Grid -->
|
||||
<div class="grid grid-cols-2 mb-4">
|
||||
{#each aspectRatios as ratio (ratio.value)}
|
||||
<HStack>
|
||||
<Button
|
||||
class="w-14 h-14 m-2"
|
||||
shape="round"
|
||||
onclick={() => selectAspectRatio(ratio)}
|
||||
aria-label={ratio.label}
|
||||
color={ratioSelected(ratio) ? 'primary' : 'secondary'}
|
||||
variant={ratioSelected(ratio) ? 'filled' : 'outline'}
|
||||
>
|
||||
{#if ratio.isFree}
|
||||
<!-- Free crop icon with dashed border -->
|
||||
<div
|
||||
class="w-6 h-6 border-2 border-dashed rounded-xs flex-shrink-0 {ratioSelected(ratio)
|
||||
? 'border-black'
|
||||
: 'border-white'}"
|
||||
></div>
|
||||
{:else}
|
||||
<!-- Aspect ratio box -->
|
||||
<div
|
||||
class="border-2 rounded-xs flex-shrink-0 {ratioSelected(ratio) ? 'border-black' : 'border-white'}"
|
||||
style="width: {ratio.width}px; height: {ratio.height}px;"
|
||||
></div>
|
||||
{/if}
|
||||
</Button>
|
||||
<span class="text-sm text-white text-left">{ratio.label}</span>
|
||||
</HStack>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
@@ -85,7 +85,9 @@
|
||||
></video>
|
||||
{/if}
|
||||
|
||||
<div class="absolute end-0 top-0 flex place-items-center gap-1 text-xs font-medium text-white">
|
||||
<div
|
||||
class="absolute end-0 top-0 flex place-items-center gap-1 text-xs font-medium text-white text-shadow-[1px_1px_6px_rgb(0_0_0)]"
|
||||
>
|
||||
{#if showTime}
|
||||
<span class="pt-2">
|
||||
{#if remainingSeconds < 60}
|
||||
@@ -99,7 +101,7 @@
|
||||
{/if}
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<span class="pe-2 pt-2" onmouseenter={onMouseEnter} onmouseleave={onMouseLeave}>
|
||||
<span class="pe-2 pt-2 drop-shadow-[1px_1px_6px_rgb(0_0_0)]" onmouseenter={onMouseEnter} onmouseleave={onMouseLeave}>
|
||||
{#if enablePlayback}
|
||||
{#if loading}
|
||||
<LoadingSpinner />
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { focusOutside } from '$lib/actions/focus-outside';
|
||||
import ActionMenuItem from '$lib/components/ActionMenuItem.svelte';
|
||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
||||
import { AppRoute, QueryParameter } from '$lib/constants';
|
||||
import { getPersonActions } from '$lib/services/person.service';
|
||||
import { getPeopleThumbnailUrl } from '$lib/utils';
|
||||
import { type PersonResponseDto } from '@immich/sdk';
|
||||
import { Icon } from '@immich/ui';
|
||||
import {
|
||||
mdiAccountMultipleCheckOutline,
|
||||
mdiCalendarEditOutline,
|
||||
mdiDotsVertical,
|
||||
mdiEyeOffOutline,
|
||||
mdiHeart,
|
||||
@@ -18,17 +19,18 @@
|
||||
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
|
||||
import MenuOption from '../shared-components/context-menu/menu-option.svelte';
|
||||
|
||||
interface Props {
|
||||
type Props = {
|
||||
person: PersonResponseDto;
|
||||
onSetBirthDate: () => void;
|
||||
onMergePeople: () => void;
|
||||
onHidePerson: () => void;
|
||||
onToggleFavorite: () => void;
|
||||
}
|
||||
};
|
||||
|
||||
let { person, onSetBirthDate, onMergePeople, onHidePerson, onToggleFavorite }: Props = $props();
|
||||
let { person, onMergePeople, onHidePerson, onToggleFavorite }: Props = $props();
|
||||
|
||||
let showVerticalDots = $state(false);
|
||||
|
||||
const { SetDateOfBirth } = $derived(getPersonActions($t, person));
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -73,7 +75,7 @@
|
||||
title={$t('show_person_options')}
|
||||
>
|
||||
<MenuOption onClick={onHidePerson} icon={mdiEyeOffOutline} text={$t('hide_person')} />
|
||||
<MenuOption onClick={onSetBirthDate} icon={mdiCalendarEditOutline} text={$t('set_date_of_birth')} />
|
||||
<ActionMenuItem action={SetDateOfBirth} />
|
||||
<MenuOption onClick={onMergePeople} icon={mdiAccountMultipleCheckOutline} text={$t('merge_people')} />
|
||||
<MenuOption
|
||||
onClick={onToggleFavorite}
|
||||
|
||||
@@ -270,7 +270,7 @@
|
||||
};
|
||||
|
||||
afterNavigate(({ from, to }) => {
|
||||
memoryStore.initialize().then(
|
||||
memoryStore.ready().then(
|
||||
() => {
|
||||
let target = null;
|
||||
if (to?.params?.assetId) {
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { resizeObserver } from '$lib/actions/resize-observer';
|
||||
import { AppRoute, QueryParameter } from '$lib/constants';
|
||||
import { memoryStore } from '$lib/stores/memory.store.svelte';
|
||||
import { getAssetThumbnailUrl, memoryLaneTitle } from '$lib/utils';
|
||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { Icon } from '@immich/ui';
|
||||
import { mdiChevronLeft, mdiChevronRight } from '@mdi/js';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
let shouldRender = $derived(memoryStore.memories?.length > 0);
|
||||
|
||||
onMount(async () => {
|
||||
await memoryStore.initialize();
|
||||
});
|
||||
|
||||
let memoryLaneElement: HTMLElement | undefined = $state();
|
||||
let offsetWidth = $state(0);
|
||||
let innerWidth = $state(0);
|
||||
|
||||
let scrollLeftPosition = $state(0);
|
||||
|
||||
const onScroll = () => {
|
||||
scrollLeftPosition = memoryLaneElement?.scrollLeft ?? 0;
|
||||
};
|
||||
|
||||
let canScrollLeft = $derived(scrollLeftPosition > 0);
|
||||
let canScrollRight = $derived(Math.ceil(scrollLeftPosition) < Math.floor(innerWidth - offsetWidth));
|
||||
|
||||
const scrollBy = 400;
|
||||
const scrollLeft = () => memoryLaneElement?.scrollBy({ left: -scrollBy, behavior: 'smooth' });
|
||||
const scrollRight = () => memoryLaneElement?.scrollBy({ left: scrollBy, behavior: 'smooth' });
|
||||
</script>
|
||||
|
||||
{#if shouldRender}
|
||||
<section
|
||||
id="memory-lane"
|
||||
bind:this={memoryLaneElement}
|
||||
class="relative mt-3 overflow-x-scroll overflow-y-hidden whitespace-nowrap transition-all"
|
||||
style="scrollbar-width:none"
|
||||
use:resizeObserver={({ width }) => (offsetWidth = width)}
|
||||
onscroll={onScroll}
|
||||
>
|
||||
{#if canScrollLeft || canScrollRight}
|
||||
<div class="sticky start-0 z-1">
|
||||
{#if canScrollLeft}
|
||||
<div class="absolute start-4 max-md:top-19 top-27 -translate-y-1/2" transition:fade={{ duration: 200 }}>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full border border-gray-500 bg-gray-100 p-2 text-gray-500 opacity-50 hover:opacity-100"
|
||||
title={$t('previous')}
|
||||
aria-label={$t('previous')}
|
||||
onclick={scrollLeft}
|
||||
>
|
||||
<Icon icon={mdiChevronLeft} size="36" aria-label={$t('previous')} /></button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
{#if canScrollRight}
|
||||
<div class="absolute end-4 max-md:top-19 top-27 -translate-y-1/2 z-1" transition:fade={{ duration: 200 }}>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full border border-gray-500 bg-gray-100 p-2 text-gray-500 opacity-50 hover:opacity-100"
|
||||
title={$t('next')}
|
||||
aria-label={$t('next')}
|
||||
onclick={scrollRight}
|
||||
>
|
||||
<Icon icon={mdiChevronRight} size="36" aria-label={$t('next')} /></button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="inline-block" use:resizeObserver={({ width }) => (innerWidth = width)}>
|
||||
{#each memoryStore.memories as memory (memory.id)}
|
||||
<a
|
||||
class="memory-card relative me-2 md:me-4 last:me-0 inline-block aspect-3/4 md:aspect-4/3 max-md:h-37.5 xl:aspect-video h-54 rounded-xl"
|
||||
href="{AppRoute.MEMORY}?{QueryParameter.ID}={memory.assets[0].id}"
|
||||
>
|
||||
<img
|
||||
class="h-full w-full rounded-xl object-cover"
|
||||
src={getAssetThumbnailUrl(memory.assets[0].id)}
|
||||
alt={$t('memory_lane_title', { values: { title: $getAltText(toTimelineAsset(memory.assets[0])) } })}
|
||||
draggable="false"
|
||||
/>
|
||||
<div
|
||||
class="absolute start-0 top-0 h-full w-full rounded-xl bg-linear-to-t from-black/40 via-transparent to-transparent transition-all hover:bg-black/20"
|
||||
></div>
|
||||
<p class="absolute bottom-2 start-4 text-lg text-white max-md:text-sm">
|
||||
{$memoryLaneTitle(memory)}
|
||||
</p>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.memory-card {
|
||||
box-shadow:
|
||||
rgba(60, 64, 67, 0.3) 0px 1px 2px 0px,
|
||||
rgba(60, 64, 67, 0.15) 0px 1px 3px 1px;
|
||||
}
|
||||
</style>
|
||||
@@ -146,7 +146,7 @@
|
||||
size="medium"
|
||||
onClose={handleConfirm}
|
||||
>
|
||||
{#snippet promptSnippet()}
|
||||
{#snippet prompt()}
|
||||
<div class="flex flex-col w-full h-full gap-2">
|
||||
<div class="relative w-64 sm:w-96 z-1">
|
||||
{#if suggestionContainer}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import Portal from '$lib/elements/Portal.svelte';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
|
||||
import AssetDeleteConfirmModal from '$lib/modals/AssetDeleteConfirmModal.svelte';
|
||||
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
|
||||
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
@@ -23,9 +24,8 @@
|
||||
import { modalManager } from '@immich/ui';
|
||||
import { debounce } from 'lodash-es';
|
||||
import { t } from 'svelte-i18n';
|
||||
import DeleteAssetDialog from '../../photos-page/delete-asset-dialog.svelte';
|
||||
|
||||
interface Props {
|
||||
type Props = {
|
||||
initialAssetId?: string;
|
||||
assets: AssetResponseDto[];
|
||||
assetInteraction: AssetInteraction;
|
||||
@@ -34,7 +34,6 @@
|
||||
viewport: Viewport;
|
||||
onIntersected?: (() => void) | undefined;
|
||||
showAssetName?: boolean;
|
||||
isShowDeleteConfirmation?: boolean;
|
||||
onPrevious?: (() => Promise<{ id: string } | undefined>) | undefined;
|
||||
onNext?: (() => Promise<{ id: string } | undefined>) | undefined;
|
||||
onRandom?: (() => Promise<{ id: string } | undefined>) | undefined;
|
||||
@@ -42,7 +41,7 @@
|
||||
pageHeaderOffset?: number;
|
||||
slidingWindowOffset?: number;
|
||||
arrowNavigation?: boolean;
|
||||
}
|
||||
};
|
||||
|
||||
let {
|
||||
initialAssetId = undefined,
|
||||
@@ -53,7 +52,6 @@
|
||||
viewport,
|
||||
onIntersected = undefined,
|
||||
showAssetName = false,
|
||||
isShowDeleteConfirmation = $bindable(false),
|
||||
onPrevious = undefined,
|
||||
onNext = undefined,
|
||||
onRandom = undefined,
|
||||
@@ -106,6 +104,10 @@
|
||||
};
|
||||
});
|
||||
|
||||
const updateCurrentAsset = (asset: AssetResponseDto) => {
|
||||
assets[currentIndex] = asset;
|
||||
};
|
||||
|
||||
const updateSlidingWindow = () => (scrollTop = document.scrollingElement?.scrollTop ?? 0);
|
||||
|
||||
const debouncedOnIntersected = debounce(() => onIntersected?.(), 750, { maxWait: 100, leading: true });
|
||||
@@ -209,30 +211,27 @@
|
||||
|
||||
const onDelete = () => {
|
||||
const hasTrashedAsset = assetInteraction.selectedAssets.some((asset) => asset.isTrashed);
|
||||
|
||||
if ($showDeleteModal && (!isTrashEnabled || hasTrashedAsset)) {
|
||||
isShowDeleteConfirmation = true;
|
||||
return;
|
||||
}
|
||||
handlePromiseError(trashOrDelete(hasTrashedAsset));
|
||||
};
|
||||
|
||||
const onForceDelete = () => {
|
||||
if ($showDeleteModal) {
|
||||
isShowDeleteConfirmation = true;
|
||||
return;
|
||||
}
|
||||
handlePromiseError(trashOrDelete(true));
|
||||
};
|
||||
|
||||
const trashOrDelete = async (force: boolean = false) => {
|
||||
isShowDeleteConfirmation = false;
|
||||
const forceOrNoTrash = force || !featureFlagsManager.value.trash;
|
||||
const selectedAssets = assetInteraction.selectedAssets;
|
||||
|
||||
if ($showDeleteModal && forceOrNoTrash) {
|
||||
const confirmed = await modalManager.show(AssetDeleteConfirmModal, { size: selectedAssets.length });
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await deleteAssets(
|
||||
!(isTrashEnabled && !force),
|
||||
forceOrNoTrash,
|
||||
(assetIds) => (assets = assets.filter((asset) => !assetIds.includes(asset.id))),
|
||||
assetInteraction.selectedAssets,
|
||||
selectedAssets,
|
||||
onReload,
|
||||
);
|
||||
|
||||
assetInteraction.clearMultiselect();
|
||||
};
|
||||
|
||||
@@ -285,7 +284,7 @@
|
||||
shortcuts.push(
|
||||
{ shortcut: { key: 'Escape' }, onShortcut: deselectAllAssets },
|
||||
{ shortcut: { key: 'Delete' }, onShortcut: onDelete },
|
||||
{ shortcut: { key: 'Delete', shift: true }, onShortcut: onForceDelete },
|
||||
{ shortcut: { key: 'Delete', shift: true }, onShortcut: () => trashOrDelete(true) },
|
||||
{ shortcut: { key: 'D', ctrl: true }, onShortcut: () => deselectAllAssets() },
|
||||
{ shortcut: { key: 'a', shift: true }, onShortcut: toggleArchive },
|
||||
);
|
||||
@@ -405,8 +404,6 @@
|
||||
}
|
||||
};
|
||||
|
||||
let isTrashEnabled = $derived(featureFlagsManager.value.trash);
|
||||
|
||||
$effect(() => {
|
||||
if (!lastAssetMouseEvent) {
|
||||
assetInteraction.clearAssetSelectionCandidates();
|
||||
@@ -440,14 +437,6 @@
|
||||
onscroll={() => updateSlidingWindow()}
|
||||
/>
|
||||
|
||||
{#if isShowDeleteConfirmation}
|
||||
<DeleteAssetDialog
|
||||
size={assetInteraction.selectedAssets.length}
|
||||
onCancel={() => (isShowDeleteConfirmation = false)}
|
||||
onConfirm={() => handlePromiseError(trashOrDelete(true))}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if assets.length > 0}
|
||||
<div
|
||||
style:position="relative"
|
||||
@@ -499,6 +488,7 @@
|
||||
onPrevious={handlePrevious}
|
||||
onNext={handleNext}
|
||||
onRandom={handleRandom}
|
||||
onAssetChange={updateCurrentAsset}
|
||||
onClose={() => {
|
||||
assetViewingStore.showAssetViewer(false);
|
||||
handlePromiseError(navigate({ targetRoute: 'current', assetId: null }));
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import Combobox, { type ComboBoxOption } from '$lib/components/shared-components/combobox.svelte';
|
||||
import { Label, Text } from '@immich/ui';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { quintOut } from 'svelte/easing';
|
||||
@@ -28,25 +29,21 @@
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="grid grid-cols-2">
|
||||
<div>
|
||||
<div class="flex h-6.5 place-items-center gap-1">
|
||||
<label class="font-medium text-primary text-sm" for={title}>
|
||||
{title}
|
||||
</label>
|
||||
{#if isEdited}
|
||||
<div
|
||||
transition:fly={{ x: 10, duration: 200, easing: quintOut }}
|
||||
class="rounded-full bg-orange-100 px-2 text-[10px] text-orange-900"
|
||||
>
|
||||
{$t('unsaved_change')}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<p class="text-sm dark:text-immich-dark-fg">{subtitle}</p>
|
||||
<div>
|
||||
<div class="flex h-6.5 place-items-center gap-1">
|
||||
<Label>{title}</Label>
|
||||
{#if isEdited}
|
||||
<div
|
||||
transition:fly={{ x: 10, duration: 200, easing: quintOut }}
|
||||
class="rounded-full bg-orange-100 px-2 text-[10px] text-orange-900"
|
||||
>
|
||||
{$t('unsaved_change')}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
|
||||
<Text size="small" color="muted">{subtitle}</Text>
|
||||
<div class="flex items-center mt-2 max-w-[300px]">
|
||||
<Combobox label={title} hideLabel={true} {selectedOption} {options} placeholder={comboboxPlaceholder} {onSelect} />
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { defaultLang, langs } from '$lib/constants';
|
||||
import { lang } from '$lib/stores/preferences.store';
|
||||
import { getClosestAvailableLocale, langCodes } from '$lib/utils/i18n';
|
||||
import { Label, Text } from '@immich/ui';
|
||||
import { locale as i18nLocale, t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
@@ -34,16 +35,14 @@
|
||||
let closestLanguage = $derived(getClosestAvailableLocale([$lang], langCodes));
|
||||
</script>
|
||||
|
||||
<div class={showSettingDescription ? 'grid grid-cols-2' : ''}>
|
||||
<div class="max-w-[300px]">
|
||||
{#if showSettingDescription}
|
||||
<div>
|
||||
<div class="flex h-6.5 place-items-center gap-1">
|
||||
<label class="font-medium text-primary text-sm" for={$t('language')}>
|
||||
{$t('language')}
|
||||
</label>
|
||||
<Label>{$t('language')}</Label>
|
||||
</div>
|
||||
|
||||
<p class="text-sm dark:text-immich-dark-fg">{$t('language_setting_description')}</p>
|
||||
<Text size="small" color="muted">{$t('language_setting_description')}</Text>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -46,7 +46,6 @@
|
||||
album?: AlbumResponseDto;
|
||||
albumUsers?: UserResponseDto[];
|
||||
person?: PersonResponseDto;
|
||||
isShowDeleteConfirmation?: boolean;
|
||||
onSelect?: (asset: TimelineAsset) => void;
|
||||
onEscape?: () => void;
|
||||
children?: Snippet;
|
||||
@@ -79,7 +78,6 @@
|
||||
album,
|
||||
albumUsers = [],
|
||||
person,
|
||||
isShowDeleteConfirmation = $bindable(false),
|
||||
onSelect = () => {},
|
||||
onEscape = () => {},
|
||||
children,
|
||||
@@ -600,7 +598,6 @@
|
||||
scrollToAsset={(asset) => scrollToAsset(asset) ?? false}
|
||||
{timelineManager}
|
||||
{assetInteraction}
|
||||
bind:isShowDeleteConfirmation
|
||||
{onEscape}
|
||||
/>
|
||||
|
||||
|
||||
@@ -119,14 +119,15 @@
|
||||
case AssetAction.ARCHIVE:
|
||||
case AssetAction.SET_VISIBILITY_LOCKED:
|
||||
case AssetAction.SET_VISIBILITY_TIMELINE: {
|
||||
// must update manager before performing any navigation
|
||||
timelineManager.removeAssets([action.asset.id]);
|
||||
|
||||
// find the next asset to show or close the viewer
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
(await handleNavigateToAsset(assetCursor?.nextAsset)) ||
|
||||
(await handleNavigateToAsset(assetCursor?.previousAsset)) ||
|
||||
(await handleClose(action.asset));
|
||||
|
||||
// delete after find the next one
|
||||
timelineManager.removeAssets([action.asset.id]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -224,6 +225,9 @@
|
||||
{isShared}
|
||||
{album}
|
||||
{person}
|
||||
onAssetChange={(asset) => {
|
||||
timelineManager?.upsertAssets([toTimelineAsset(asset)]);
|
||||
}}
|
||||
preAction={handlePreAction}
|
||||
onAction={(action) => {
|
||||
handleAction(action);
|
||||
|
||||
@@ -1,55 +1,48 @@
|
||||
<script lang="ts">
|
||||
import DeleteAssetDialog from '$lib/components/photos-page/delete-asset-dialog.svelte';
|
||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import AssetDeleteConfirmModal from '$lib/modals/AssetDeleteConfirmModal.svelte';
|
||||
import { showDeleteModal } from '$lib/stores/preferences.store';
|
||||
import { type OnDelete, type OnUndoDelete, deleteAssets } from '$lib/utils/actions';
|
||||
import { IconButton } from '@immich/ui';
|
||||
import { IconButton, modalManager } from '@immich/ui';
|
||||
import { mdiDeleteForeverOutline, mdiDeleteOutline, mdiTimerSand } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
|
||||
|
||||
interface Props {
|
||||
type Props = {
|
||||
onAssetDelete: OnDelete;
|
||||
onUndoDelete?: OnUndoDelete | undefined;
|
||||
menuItem?: boolean;
|
||||
force?: boolean;
|
||||
}
|
||||
};
|
||||
|
||||
let {
|
||||
onAssetDelete,
|
||||
onUndoDelete = undefined,
|
||||
menuItem = false,
|
||||
force = !featureFlagsManager.value.trash,
|
||||
}: Props = $props();
|
||||
let { onAssetDelete, onUndoDelete = undefined, menuItem = false, force: forceRequested }: Props = $props();
|
||||
|
||||
const force = $derived(forceRequested || !featureFlagsManager.value.trash);
|
||||
let label = $derived(force ? $t('permanently_delete') : $t('delete'));
|
||||
let loading = $state(false);
|
||||
|
||||
const { clearSelect, getOwnedAssets } = getAssetControlContext();
|
||||
|
||||
let isShowConfirmation = $state(false);
|
||||
let loading = $state(false);
|
||||
const onAction = async () => {
|
||||
const assets = getOwnedAssets();
|
||||
|
||||
let label = $derived(force ? $t('permanently_delete') : $t('delete'));
|
||||
|
||||
const handleTrash = async () => {
|
||||
if (force) {
|
||||
isShowConfirmation = true;
|
||||
return;
|
||||
if (force && $showDeleteModal) {
|
||||
const confirmed = await modalManager.show(AssetDeleteConfirmModal, { size: assets.length });
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await handleDelete();
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
loading = true;
|
||||
const assets = [...getOwnedAssets()];
|
||||
await deleteAssets(force, onAssetDelete, assets, onUndoDelete);
|
||||
clearSelect();
|
||||
isShowConfirmation = false;
|
||||
loading = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if menuItem}
|
||||
<MenuOption text={label} icon={mdiDeleteOutline} onClick={handleTrash} />
|
||||
<MenuOption text={label} icon={mdiDeleteOutline} onClick={onAction} />
|
||||
{:else if loading}
|
||||
<IconButton
|
||||
shape="round"
|
||||
@@ -66,14 +59,6 @@
|
||||
variant="ghost"
|
||||
aria-label={label}
|
||||
icon={mdiDeleteForeverOutline}
|
||||
onclick={handleTrash}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if isShowConfirmation}
|
||||
<DeleteAssetDialog
|
||||
size={getOwnedAssets().length}
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => (isShowConfirmation = false)}
|
||||
onclick={onAction}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut';
|
||||
import DeleteAssetDialog from '$lib/components/photos-page/delete-asset-dialog.svelte';
|
||||
import {
|
||||
setFocusToAsset as setFocusAssetInit,
|
||||
setFocusTo as setFocusToInit,
|
||||
} from '$lib/components/timeline/actions/focus-actions';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import AssetDeleteConfirmModal from '$lib/modals/AssetDeleteConfirmModal.svelte';
|
||||
import NavigateToDateModal from '$lib/modals/NavigateToDateModal.svelte';
|
||||
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
|
||||
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
@@ -22,53 +23,45 @@
|
||||
import { AssetVisibility } from '@immich/sdk';
|
||||
import { modalManager } from '@immich/ui';
|
||||
|
||||
interface Props {
|
||||
type Props = {
|
||||
timelineManager: TimelineManager;
|
||||
assetInteraction: AssetInteraction;
|
||||
isShowDeleteConfirmation: boolean;
|
||||
onEscape?: () => void;
|
||||
scrollToAsset: (asset: TimelineAsset) => boolean;
|
||||
}
|
||||
};
|
||||
|
||||
let {
|
||||
timelineManager = $bindable(),
|
||||
assetInteraction,
|
||||
isShowDeleteConfirmation = $bindable(false),
|
||||
onEscape,
|
||||
scrollToAsset,
|
||||
}: Props = $props();
|
||||
let { timelineManager = $bindable(), assetInteraction, onEscape, scrollToAsset }: Props = $props();
|
||||
|
||||
const { isViewing: showAssetViewer } = assetViewingStore;
|
||||
|
||||
const trashOrDelete = async (force: boolean = false) => {
|
||||
isShowDeleteConfirmation = false;
|
||||
const trashOrDelete = async (forceRequested?: boolean) => {
|
||||
const force = forceRequested || !featureFlagsManager.value.trash;
|
||||
const selectedAssets = assetInteraction.selectedAssets;
|
||||
|
||||
if ($showDeleteModal && force) {
|
||||
const confirmed = await modalManager.show(AssetDeleteConfirmModal, { size: selectedAssets.length });
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await deleteAssets(
|
||||
!(isTrashEnabled && !force),
|
||||
(assetIds) => timelineManager.removeAssets(assetIds),
|
||||
assetInteraction.selectedAssets,
|
||||
!isTrashEnabled || force ? undefined : (assets) => timelineManager.upsertAssets(assets),
|
||||
force,
|
||||
(assetIds) => {
|
||||
timelineManager.removeAssets(assetIds);
|
||||
eventManager.emit('AssetsDelete', assetIds);
|
||||
},
|
||||
selectedAssets,
|
||||
force ? undefined : (assets) => timelineManager.upsertAssets(assets),
|
||||
);
|
||||
assetInteraction.clearMultiselect();
|
||||
};
|
||||
|
||||
const onDelete = () => {
|
||||
const hasTrashedAsset = assetInteraction.selectedAssets.some((asset) => asset.isTrashed);
|
||||
|
||||
if ($showDeleteModal && (!isTrashEnabled || hasTrashedAsset)) {
|
||||
isShowDeleteConfirmation = true;
|
||||
return;
|
||||
}
|
||||
handlePromiseError(trashOrDelete(hasTrashedAsset));
|
||||
};
|
||||
|
||||
const onForceDelete = () => {
|
||||
if ($showDeleteModal) {
|
||||
isShowDeleteConfirmation = true;
|
||||
return;
|
||||
}
|
||||
handlePromiseError(trashOrDelete(true));
|
||||
};
|
||||
|
||||
const onStackAssets = async () => {
|
||||
const result = await stackAssets(assetInteraction.selectedAssets);
|
||||
|
||||
@@ -81,6 +74,7 @@
|
||||
const visibility = assetInteraction.isAllArchived ? AssetVisibility.Timeline : AssetVisibility.Archive;
|
||||
const ids = await archiveAssets(assetInteraction.selectedAssets, visibility);
|
||||
timelineManager.update(ids, (asset) => (asset.visibility = visibility));
|
||||
eventManager.emit('AssetsArchive', ids);
|
||||
deselectAllAssets();
|
||||
};
|
||||
|
||||
@@ -118,9 +112,7 @@
|
||||
}
|
||||
};
|
||||
|
||||
const isTrashEnabled = $derived(featureFlagsManager.value.trash);
|
||||
const isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0);
|
||||
const idsSelectedAssets = $derived(assetInteraction.selectedAssets.map(({ id }) => id));
|
||||
let isShortcutModalOpen = false;
|
||||
|
||||
const handleOpenShortcutModal = async () => {
|
||||
@@ -176,7 +168,7 @@
|
||||
if (assetInteraction.selectionActive) {
|
||||
shortcuts.push(
|
||||
{ shortcut: { key: 'Delete' }, onShortcut: onDelete },
|
||||
{ shortcut: { key: 'Delete', shift: true }, onShortcut: onForceDelete },
|
||||
{ shortcut: { key: 'Delete', shift: true }, onShortcut: () => trashOrDelete(true) },
|
||||
{ shortcut: { key: 'D', ctrl: true }, onShortcut: () => deselectAllAssets() },
|
||||
{ shortcut: { key: 's' }, onShortcut: () => onStackAssets() },
|
||||
{ shortcut: { key: 'a', shift: true }, onShortcut: toggleArchive },
|
||||
@@ -189,11 +181,3 @@
|
||||
</script>
|
||||
|
||||
<svelte:document onkeydown={onKeyDown} onkeyup={onKeyUp} onselectstart={onSelectStart} use:shortcuts={shortcutList} />
|
||||
|
||||
{#if isShowDeleteConfirmation}
|
||||
<DeleteAssetDialog
|
||||
size={idsSelectedAssets.length}
|
||||
onCancel={() => (isShowDeleteConfirmation = false)}
|
||||
onConfirm={() => handlePromiseError(trashOrDelete(true))}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script lang="ts">
|
||||
import PinCodeInput from '$lib/components/user-settings-page/PinCodeInput.svelte';
|
||||
import PinCodeResetModal from '$lib/modals/PinCodeResetModal.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { changePinCode } from '@immich/sdk';
|
||||
import { Button, Heading, Text, toastManager } from '@immich/ui';
|
||||
import { Button, Heading, modalManager, Text, toastManager } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
@@ -12,12 +13,6 @@
|
||||
let isLoading = $state(false);
|
||||
let canSubmit = $derived(currentPinCode.length === 6 && confirmPinCode.length === 6 && newPinCode === confirmPinCode);
|
||||
|
||||
type Props = {
|
||||
onForgot: () => void;
|
||||
};
|
||||
|
||||
let { onForgot }: Props = $props();
|
||||
|
||||
const handleSubmit = async (event: Event) => {
|
||||
event.preventDefault();
|
||||
await handleChangePinCode();
|
||||
@@ -51,7 +46,7 @@
|
||||
<PinCodeInput label={$t('current_pin_code')} bind:value={currentPinCode} tabindexStart={1} pinLength={6} />
|
||||
<PinCodeInput label={$t('new_pin_code')} bind:value={newPinCode} tabindexStart={7} pinLength={6} />
|
||||
<PinCodeInput label={$t('confirm_new_pin_code')} bind:value={confirmPinCode} tabindexStart={13} pinLength={6} />
|
||||
<button type="button" onclick={onForgot}>
|
||||
<button type="button" onclick={() => modalManager.show(PinCodeResetModal, {})}>
|
||||
<Text color="muted" class="underline" size="small">{$t('forgot_pin_code_question')}</Text>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
<script lang="ts">
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import PinCodeChangeForm from '$lib/components/user-settings-page/PinCodeChangeForm.svelte';
|
||||
import PinCodeCreateForm from '$lib/components/user-settings-page/PinCodeCreateForm.svelte';
|
||||
import PinCodeResetModal from '$lib/modals/PinCodeResetModal.svelte';
|
||||
import { getAuthStatus } from '@immich/sdk';
|
||||
import { modalManager } from '@immich/ui';
|
||||
import { onMount } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
@@ -14,18 +13,17 @@
|
||||
hasPinCode = pinCode;
|
||||
});
|
||||
|
||||
const handleResetPINCode = async () => {
|
||||
const success = await modalManager.show(PinCodeResetModal, {});
|
||||
if (success) {
|
||||
hasPinCode = false;
|
||||
}
|
||||
const onUserPinCodeReset = () => {
|
||||
hasPinCode = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<OnEvents {onUserPinCodeReset} />
|
||||
|
||||
<section>
|
||||
{#if hasPinCode}
|
||||
<div in:fade={{ duration: 200 }}>
|
||||
<PinCodeChangeForm onForgot={handleResetPINCode} />
|
||||
<PinCodeChangeForm />
|
||||
</div>
|
||||
{:else}
|
||||
<div in:fade={{ duration: 200 }}>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { ComboBoxOption } from '$lib/components/shared-components/combobox.svelte';
|
||||
import SettingCombobox from '$lib/components/shared-components/settings/setting-combobox.svelte';
|
||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||
import SettingsLanguageSelector from '$lib/components/shared-components/settings/settings-language-selector.svelte';
|
||||
import { fallbackLocale, locales } from '$lib/constants';
|
||||
import { themeManager } from '$lib/managers/theme-manager.svelte';
|
||||
@@ -15,6 +14,7 @@
|
||||
showDeleteModal,
|
||||
} from '$lib/stores/preferences.store';
|
||||
import { createDateFormatter, findLocale } from '$lib/utils';
|
||||
import { Field, Switch, Text } from '@immich/ui';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
@@ -59,81 +59,55 @@
|
||||
|
||||
<section class="my-4">
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<div class="ms-4">
|
||||
<SettingSwitch
|
||||
title={$t('theme_selection')}
|
||||
subtitle={$t('theme_selection_description')}
|
||||
checked={themeManager.theme.system}
|
||||
onToggle={(isChecked) => themeManager.setSystem(isChecked)}
|
||||
/>
|
||||
</div>
|
||||
<div class="ms-8 mt-4 flex flex-col gap-4">
|
||||
<Field label={$t('theme_selection')} description={$t('theme_selection_description')}>
|
||||
<Switch checked={themeManager.theme.system} onCheckedChange={(checked) => themeManager.setSystem(checked)} />
|
||||
</Field>
|
||||
|
||||
<div class="ms-4">
|
||||
<SettingsLanguageSelector showSettingDescription />
|
||||
</div>
|
||||
<SettingsLanguageSelector showSettingDescription />
|
||||
|
||||
<Field label={$t('default_locale')} description={$t('default_locale_description')}>
|
||||
<Switch checked={$locale == 'default'} onCheckedChange={handleToggleLocaleBrowser} />
|
||||
<Text size="small" class="mt-2">{selectedDate}</Text>
|
||||
</Field>
|
||||
|
||||
<div class="ms-4">
|
||||
<SettingSwitch
|
||||
title={$t('default_locale')}
|
||||
subtitle={$t('default_locale_description')}
|
||||
checked={$locale == 'default'}
|
||||
onToggle={handleToggleLocaleBrowser}
|
||||
>
|
||||
<p class="mt-2 dark:text-gray-400">{selectedDate}</p>
|
||||
</SettingSwitch>
|
||||
</div>
|
||||
{#if $locale !== 'default'}
|
||||
<div class="ms-4">
|
||||
<SettingCombobox
|
||||
comboboxPlaceholder={$t('searching_locales')}
|
||||
{selectedOption}
|
||||
options={getAllLanguages()}
|
||||
title={$t('custom_locale')}
|
||||
subtitle={$t('custom_locale_description')}
|
||||
onSelect={(combobox) => handleLocaleChange(combobox?.value)}
|
||||
/>
|
||||
</div>
|
||||
<SettingCombobox
|
||||
comboboxPlaceholder={$t('searching_locales')}
|
||||
{selectedOption}
|
||||
options={getAllLanguages()}
|
||||
title={$t('custom_locale')}
|
||||
subtitle={$t('custom_locale_description')}
|
||||
onSelect={(combobox) => handleLocaleChange(combobox?.value)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div class="ms-4">
|
||||
<SettingSwitch
|
||||
title={$t('display_original_photos')}
|
||||
subtitle={$t('display_original_photos_setting_description')}
|
||||
bind:checked={$alwaysLoadOriginalFile}
|
||||
/>
|
||||
</div>
|
||||
<div class="ms-4">
|
||||
<SettingSwitch
|
||||
title={$t('video_hover_setting')}
|
||||
subtitle={$t('video_hover_setting_description')}
|
||||
bind:checked={$playVideoThumbnailOnHover}
|
||||
/>
|
||||
</div>
|
||||
<div class="ms-4">
|
||||
<SettingSwitch
|
||||
title={$t('setting_video_viewer_auto_play_title')}
|
||||
subtitle={$t('setting_video_viewer_auto_play_subtitle')}
|
||||
bind:checked={$autoPlayVideo}
|
||||
/>
|
||||
</div>
|
||||
<div class="ms-4">
|
||||
<SettingSwitch title={$t('loop_videos')} subtitle={$t('loop_videos_description')} bind:checked={$loopVideo} />
|
||||
</div>
|
||||
<div class="ms-4">
|
||||
<SettingSwitch
|
||||
title={$t('play_original_video')}
|
||||
subtitle={$t('play_original_video_setting_description')}
|
||||
bind:checked={$alwaysLoadOriginalVideo}
|
||||
/>
|
||||
</div>
|
||||
<div class="ms-4">
|
||||
<SettingSwitch
|
||||
title={$t('permanent_deletion_warning')}
|
||||
subtitle={$t('permanent_deletion_warning_setting_description')}
|
||||
bind:checked={$showDeleteModal}
|
||||
/>
|
||||
</div>
|
||||
<Field label={$t('display_original_photos')} description={$t('display_original_photos_setting_description')}>
|
||||
<Switch bind:checked={$alwaysLoadOriginalFile} />
|
||||
</Field>
|
||||
|
||||
<Field label={$t('video_hover_setting')} description={$t('video_hover_setting_description')}>
|
||||
<Switch bind:checked={$playVideoThumbnailOnHover} />
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={$t('setting_video_viewer_auto_play_title')}
|
||||
description={$t('setting_video_viewer_auto_play_subtitle')}
|
||||
>
|
||||
<Switch bind:checked={$autoPlayVideo} />
|
||||
</Field>
|
||||
|
||||
<Field label={$t('loop_videos')} description={$t('loop_videos_description')}>
|
||||
<Switch bind:checked={$loopVideo} />
|
||||
</Field>
|
||||
|
||||
<Field label={$t('play_original_video')} description={$t('play_original_video_setting_description')}>
|
||||
<Switch bind:checked={$alwaysLoadOriginalVideo} />
|
||||
</Field>
|
||||
|
||||
<Field label={$t('permanent_deletion_warning')} description={$t('permanent_deletion_warning_setting_description')}
|
||||
><Switch bind:checked={$showDeleteModal} />
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
<script lang="ts">
|
||||
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||
import { SettingInputFieldType } from '$lib/constants';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { changePassword } from '@immich/sdk';
|
||||
import { Button, toastManager } from '@immich/ui';
|
||||
import { handleChangePassword } from '$lib/services/user.service';
|
||||
import { Button, Field, PasswordInput, Switch } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
@@ -13,67 +9,43 @@
|
||||
let confirmPassword = $state('');
|
||||
let invalidateSessions = $state(false);
|
||||
|
||||
const handleChangePassword = async () => {
|
||||
try {
|
||||
await changePassword({ changePasswordDto: { password, newPassword, invalidateSessions } });
|
||||
|
||||
toastManager.success($t('updated_password'));
|
||||
|
||||
const onsubmit = async (event: Event) => {
|
||||
event.preventDefault();
|
||||
const success = await handleChangePassword({ password, newPassword, invalidateSessions });
|
||||
if (success) {
|
||||
password = '';
|
||||
newPassword = '';
|
||||
confirmPassword = '';
|
||||
} catch (error) {
|
||||
console.error('Error [user-profile] [changePassword]', error);
|
||||
handleError(error, $t('errors.unable_to_change_password'));
|
||||
}
|
||||
};
|
||||
|
||||
const onsubmit = (event: Event) => {
|
||||
event.preventDefault();
|
||||
};
|
||||
</script>
|
||||
|
||||
<section class="my-4">
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<form autocomplete="off" {onsubmit}>
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.PASSWORD}
|
||||
label={$t('password')}
|
||||
bind:value={password}
|
||||
required={true}
|
||||
passwordAutocomplete="current-password"
|
||||
/>
|
||||
<Field label={$t('password')} required>
|
||||
<PasswordInput bind:value={password} autocomplete="current-password" />
|
||||
</Field>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.PASSWORD}
|
||||
label={$t('new_password')}
|
||||
bind:value={newPassword}
|
||||
required={true}
|
||||
passwordAutocomplete="new-password"
|
||||
/>
|
||||
<Field label={$t('new_password')} required>
|
||||
<PasswordInput bind:value={newPassword} autocomplete="new-password" />
|
||||
</Field>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.PASSWORD}
|
||||
label={$t('confirm_password')}
|
||||
bind:value={confirmPassword}
|
||||
required={true}
|
||||
passwordAutocomplete="new-password"
|
||||
/>
|
||||
<Field label={$t('confirm_password')} required>
|
||||
<PasswordInput bind:value={confirmPassword} autocomplete="new-password" />
|
||||
</Field>
|
||||
|
||||
<SettingSwitch
|
||||
title={$t('log_out_all_devices')}
|
||||
subtitle={$t('change_password_form_log_out_description')}
|
||||
bind:checked={invalidateSessions}
|
||||
/>
|
||||
<Field label={$t('log_out_all_devices')} description={$t('change_password_form_log_out_description')} required>
|
||||
<Switch bind:checked={invalidateSessions} />
|
||||
</Field>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button
|
||||
shape="round"
|
||||
type="submit"
|
||||
size="small"
|
||||
disabled={!(password && newPassword && newPassword === confirmPassword)}
|
||||
onclick={() => handleChangePassword()}>{$t('save')}</Button
|
||||
disabled={!(password && newPassword && newPassword === confirmPassword)}>{$t('save')}</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
<script lang="ts">
|
||||
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
|
||||
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
|
||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||
import { SettingInputFieldType } from '$lib/constants';
|
||||
import { preferences } from '$lib/stores/user.store';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { AssetOrder, updateMyPreferences } from '@immich/sdk';
|
||||
import { Button, toastManager } from '@immich/ui';
|
||||
import { Button, Field, NumberInput, Switch, toastManager } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
@@ -73,7 +70,7 @@
|
||||
<form autocomplete="off" {onsubmit}>
|
||||
<div class="ms-4 mt-4 flex flex-col">
|
||||
<SettingAccordion key="albums" title={$t('albums')} subtitle={$t('albums_feature_description')}>
|
||||
<div class="ms-4 mt-6">
|
||||
<div class="ms-4 mt-6 flex flex-col gap-4">
|
||||
<SettingSelect
|
||||
label={$t('albums_default_sort_order')}
|
||||
desc={$t('albums_default_sort_order_description')}
|
||||
@@ -87,94 +84,86 @@
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion key="folders" title={$t('folders')} subtitle={$t('folders_feature_description')}>
|
||||
<div class="ms-4 mt-6">
|
||||
<SettingSwitch title={$t('enable')} bind:checked={foldersEnabled} />
|
||||
</div>
|
||||
<div class="ms-4 mt-6 flex flex-col gap-4">
|
||||
<Field label={$t('enable')}>
|
||||
<Switch bind:checked={foldersEnabled} />
|
||||
</Field>
|
||||
|
||||
{#if foldersEnabled}
|
||||
<div class="ms-4 mt-6">
|
||||
<SettingSwitch
|
||||
title={$t('sidebar')}
|
||||
subtitle={$t('sidebar_display_description')}
|
||||
bind:checked={foldersSidebar}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{#if foldersEnabled}
|
||||
<Field label={$t('sidebar')} description={$t('sidebar_display_description')}>
|
||||
<Switch bind:checked={foldersSidebar} />
|
||||
</Field>
|
||||
{/if}
|
||||
</div>
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion key="memories" title={$t('time_based_memories')} subtitle={$t('photos_from_previous_years')}>
|
||||
<div class="ms-4 mt-6">
|
||||
<SettingSwitch title={$t('enable')} bind:checked={memoriesEnabled} />
|
||||
</div>
|
||||
<div class="ms-4 mt-6">
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
label={$t('duration')}
|
||||
description={$t('time_based_memories_duration')}
|
||||
bind:value={memoriesDuration}
|
||||
/>
|
||||
<div class="ms-4 mt-6 flex flex-col gap-4">
|
||||
<Field label={$t('enable')}>
|
||||
<Switch bind:checked={memoriesEnabled} />
|
||||
</Field>
|
||||
|
||||
<Field label={$t('duration')} description={$t('time_based_memories_duration')}>
|
||||
<NumberInput bind:value={memoriesDuration} />
|
||||
</Field>
|
||||
</div>
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion key="people" title={$t('people')} subtitle={$t('people_feature_description')}>
|
||||
<div class="ms-4 mt-6">
|
||||
<SettingSwitch title={$t('enable')} bind:checked={peopleEnabled} />
|
||||
</div>
|
||||
<div class="ms-4 mt-6 flex flex-col gap-4">
|
||||
<Field label={$t('enable')}>
|
||||
<Switch bind:checked={peopleEnabled} />
|
||||
</Field>
|
||||
|
||||
{#if peopleEnabled}
|
||||
<div class="ms-4 mt-6">
|
||||
<SettingSwitch
|
||||
title={$t('sidebar')}
|
||||
subtitle={$t('sidebar_display_description')}
|
||||
bind:checked={peopleSidebar}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{#if peopleEnabled}
|
||||
<Field label={$t('sidebar')} description={$t('sidebar_display_description')}>
|
||||
<Switch bind:checked={peopleSidebar} />
|
||||
</Field>
|
||||
{/if}
|
||||
</div>
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion key="rating" title={$t('rating')} subtitle={$t('rating_description')}>
|
||||
<div class="ms-4 mt-6">
|
||||
<SettingSwitch title={$t('enable')} bind:checked={ratingsEnabled} />
|
||||
<div class="ms-4 mt-6 flex flex-col gap-4">
|
||||
<Field label={$t('enable')}>
|
||||
<Switch bind:checked={ratingsEnabled} />
|
||||
</Field>
|
||||
</div>
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion key="shared-links" title={$t('shared_links')} subtitle={$t('shared_links_description')}>
|
||||
<div class="ms-4 mt-6">
|
||||
<SettingSwitch title={$t('enable')} bind:checked={sharedLinksEnabled} />
|
||||
<div class="ms-4 mt-6 flex flex-col gap-4">
|
||||
<Field label={$t('enable')}>
|
||||
<Switch bind:checked={sharedLinksEnabled} />
|
||||
</Field>
|
||||
|
||||
{#if sharedLinksEnabled}
|
||||
<Field label={$t('sidebar')} description={$t('sidebar_display_description')}>
|
||||
<Switch bind:checked={sharedLinkSidebar} />
|
||||
</Field>
|
||||
{/if}
|
||||
</div>
|
||||
{#if sharedLinksEnabled}
|
||||
<div class="ms-4 mt-6">
|
||||
<SettingSwitch
|
||||
title={$t('sidebar')}
|
||||
subtitle={$t('sidebar_display_description')}
|
||||
bind:checked={sharedLinkSidebar}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion key="tags" title={$t('tags')} subtitle={$t('tag_feature_description')}>
|
||||
<div class="ms-4 mt-6">
|
||||
<SettingSwitch title={$t('enable')} bind:checked={tagsEnabled} />
|
||||
<div class="ms-4 mt-6 flex flex-col gap-4">
|
||||
<Field label={$t('enable')}>
|
||||
<Switch bind:checked={tagsEnabled} />
|
||||
</Field>
|
||||
|
||||
{#if tagsEnabled}
|
||||
<Field label={$t('sidebar')} description={$t('sidebar_display_description')}>
|
||||
<Switch bind:checked={tagsSidebar} />
|
||||
</Field>
|
||||
{/if}
|
||||
</div>
|
||||
{#if tagsEnabled}
|
||||
<div class="ms-4 mt-6">
|
||||
<SettingSwitch
|
||||
title={$t('sidebar')}
|
||||
subtitle={$t('sidebar_display_description')}
|
||||
bind:checked={tagsSidebar}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion key="cast" title={$t('cast')} subtitle={$t('cast_description')}>
|
||||
<div class="ms-4 mt-6">
|
||||
<SettingSwitch
|
||||
title={$t('gcast_enabled')}
|
||||
subtitle={$t('gcast_enabled_description')}
|
||||
bind:checked={gCastEnabled}
|
||||
/>
|
||||
<div class="ms-4 mt-6 flex flex-col gap-4">
|
||||
<Field label={$t('gcast_enabled')} description={$t('gcast_enabled_description')}>
|
||||
<Switch bind:checked={gCastEnabled} />
|
||||
</Field>
|
||||
</div>
|
||||
</SettingAccordion>
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
<script lang="ts">
|
||||
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
|
||||
import { preferences } from '$lib/stores/user.store';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { updateMyPreferences } from '@immich/sdk';
|
||||
import { Button, toastManager } from '@immich/ui';
|
||||
import { Button, Field, Switch, toastManager } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
@@ -36,38 +35,29 @@
|
||||
const onsubmit = (event: Event) => {
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
const disabled = $derived(!emailNotificationsEnabled);
|
||||
</script>
|
||||
|
||||
<section class="my-4">
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<form autocomplete="off" {onsubmit}>
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<div class="ms-4">
|
||||
<SettingSwitch
|
||||
title={$t('notification_toggle_setting_description')}
|
||||
bind:checked={emailNotificationsEnabled}
|
||||
/>
|
||||
</div>
|
||||
<div class="ms-4">
|
||||
<SettingSwitch
|
||||
title={$t('album_added')}
|
||||
subtitle={$t('album_added_notification_setting_description')}
|
||||
bind:checked={albumInviteNotificationEnabled}
|
||||
disabled={!emailNotificationsEnabled}
|
||||
/>
|
||||
</div>
|
||||
<div class="ms-4">
|
||||
<SettingSwitch
|
||||
title={$t('album_updated')}
|
||||
subtitle={$t('album_updated_setting_description')}
|
||||
bind:checked={albumUpdateNotificationEnabled}
|
||||
disabled={!emailNotificationsEnabled}
|
||||
/>
|
||||
</div>
|
||||
<Field label={$t('enable')} description={$t('notification_toggle_setting_description')}>
|
||||
<Switch bind:checked={emailNotificationsEnabled} />
|
||||
</Field>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button shape="round" type="submit" size="small" onclick={() => handleSave()}>{$t('save')}</Button>
|
||||
</div>
|
||||
<Field label={$t('album_added')} description={$t('album_added_notification_setting_description')} {disabled}>
|
||||
<Switch bind:checked={albumInviteNotificationEnabled} />
|
||||
</Field>
|
||||
|
||||
<Field label={$t('album_updated')} description={$t('album_updated_setting_description')} {disabled}>
|
||||
<Switch bind:checked={albumUpdateNotificationEnabled} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end mt-4">
|
||||
<Button shape="round" type="submit" size="small" onclick={() => handleSave()}>{$t('save')}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
<script lang="ts">
|
||||
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||
import { SettingInputFieldType } from '$lib/constants';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { updateMyUser } from '@immich/sdk';
|
||||
import { Button, toastManager } from '@immich/ui';
|
||||
import { Button, Field, Input, toastManager } from '@immich/ui';
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { createBubbler, preventDefault } from 'svelte/legacy';
|
||||
@@ -36,29 +34,21 @@
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<form autocomplete="off" onsubmit={preventDefault(bubble('submit'))}>
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
label={$t('user_id')}
|
||||
bind:value={editedUser.id}
|
||||
disabled={true}
|
||||
/>
|
||||
<Field label={$t('user_id')} disabled>
|
||||
<Input bind:value={editedUser.id} />
|
||||
</Field>
|
||||
|
||||
<SettingInputField inputType={SettingInputFieldType.EMAIL} label={$t('email')} bind:value={editedUser.email} />
|
||||
<Field label={$t('email')} required>
|
||||
<Input type="email" bind:value={editedUser.email} />
|
||||
</Field>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
label={$t('name')}
|
||||
bind:value={editedUser.name}
|
||||
required={true}
|
||||
/>
|
||||
<Field label={$t('name')} required>
|
||||
<Input bind:value={editedUser.name} />
|
||||
</Field>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
label={$t('storage_label')}
|
||||
disabled={true}
|
||||
value={editedUser.storageLabel || ''}
|
||||
required={false}
|
||||
/>
|
||||
<Field label={$t('storage_label')} disabled>
|
||||
<Input value={editedUser.storageLabel || ''} />
|
||||
</Field>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button shape="round" type="submit" size="small" onclick={() => handleSaveProfile()}>{$t('save')}</Button>
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import TransformTool from '$lib/components/asset-viewer/editor/transform-tool/transform-tool.svelte';
|
||||
import { transformManager } from '$lib/managers/edit/transform-manager.svelte';
|
||||
import { waitForWebsocketEvent } from '$lib/stores/websocket';
|
||||
import { editAsset, removeAssetEdits, type AssetEditsDto, type AssetResponseDto } from '@immich/sdk';
|
||||
import { ConfirmModal, modalManager, toastManager } from '@immich/ui';
|
||||
import { mdiCropRotate } from '@mdi/js';
|
||||
import type { Component } from 'svelte';
|
||||
|
||||
export type EditAction = AssetEditsDto['edits'][number];
|
||||
export type EditActions = EditAction[];
|
||||
|
||||
export interface EditToolManager {
|
||||
onActivate: (asset: AssetResponseDto, edits: EditActions) => Promise<void>;
|
||||
onDeactivate: () => void;
|
||||
resetAllChanges: () => Promise<void>;
|
||||
hasChanges: boolean;
|
||||
edits: EditAction[];
|
||||
}
|
||||
|
||||
export enum EditToolType {
|
||||
Transform = 'transform',
|
||||
}
|
||||
|
||||
export interface EditTool {
|
||||
type: EditToolType;
|
||||
icon: string;
|
||||
component: Component;
|
||||
manager: EditToolManager;
|
||||
}
|
||||
|
||||
export class EditManager {
|
||||
tools: EditTool[] = [
|
||||
{
|
||||
type: EditToolType.Transform,
|
||||
icon: mdiCropRotate,
|
||||
component: TransformTool,
|
||||
manager: transformManager,
|
||||
},
|
||||
];
|
||||
|
||||
currentAsset = $state<AssetResponseDto | null>(null);
|
||||
selectedTool = $state<EditTool | null>(null);
|
||||
hasChanges = $derived(this.tools.some((t) => t.manager.hasChanges));
|
||||
|
||||
// used to disable multiple confirm dialogs and mouse events while one is open
|
||||
isShowingConfirmDialog = $state(false);
|
||||
isApplyingEdits = $state(false);
|
||||
hasAppliedEdits = $state(false);
|
||||
|
||||
async closeConfirm(): Promise<boolean> {
|
||||
// Prevent multiple dialogs (usually happens with rapid escape key presses)
|
||||
if (this.isShowingConfirmDialog) {
|
||||
return false;
|
||||
}
|
||||
if (!this.hasChanges || this.hasAppliedEdits) {
|
||||
return true;
|
||||
}
|
||||
|
||||
this.isShowingConfirmDialog = true;
|
||||
|
||||
const confirmed = await modalManager.show(ConfirmModal, {
|
||||
title: 'Discard Edits?',
|
||||
prompt: 'You have unsaved edits. Are you sure you want to discard them?',
|
||||
confirmText: 'Discard Edits',
|
||||
});
|
||||
|
||||
this.isShowingConfirmDialog = false;
|
||||
|
||||
return confirmed;
|
||||
}
|
||||
|
||||
reset() {
|
||||
for (const tool of this.tools) {
|
||||
tool.manager.onDeactivate?.();
|
||||
}
|
||||
this.selectedTool = this.tools[0];
|
||||
}
|
||||
|
||||
async activateTool(toolType: EditToolType, asset: AssetResponseDto, edits: AssetEditsDto) {
|
||||
this.hasAppliedEdits = false;
|
||||
if (this.selectedTool?.type === toolType) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentAsset = asset;
|
||||
|
||||
this.selectedTool?.manager.onDeactivate?.();
|
||||
const newTool = this.tools.find((t) => t.type === toolType);
|
||||
if (newTool) {
|
||||
this.selectedTool = newTool;
|
||||
await newTool.manager.onActivate?.(asset, edits.edits);
|
||||
}
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
for (const tool of this.tools) {
|
||||
tool.manager.onDeactivate?.();
|
||||
}
|
||||
this.currentAsset = null;
|
||||
this.selectedTool = null;
|
||||
}
|
||||
|
||||
async resetAllChanges() {
|
||||
for (const tool of this.tools) {
|
||||
await tool.manager.resetAllChanges();
|
||||
}
|
||||
}
|
||||
|
||||
async applyEdits(): Promise<boolean> {
|
||||
this.isApplyingEdits = true;
|
||||
|
||||
const edits = this.tools.flatMap((tool) => tool.manager.edits);
|
||||
|
||||
try {
|
||||
// Setup the websocket listener before sending the edit request
|
||||
const editCompleted = waitForWebsocketEvent(
|
||||
'AssetEditReadyV1',
|
||||
(event) => event.assetId === this.currentAsset!.id,
|
||||
10_000,
|
||||
);
|
||||
|
||||
await (edits.length === 0
|
||||
? removeAssetEdits({ id: this.currentAsset!.id })
|
||||
: editAsset({
|
||||
id: this.currentAsset!.id,
|
||||
assetEditActionListDto: {
|
||||
edits,
|
||||
},
|
||||
}));
|
||||
|
||||
await editCompleted;
|
||||
toastManager.success('Edits applied successfully');
|
||||
this.hasAppliedEdits = true;
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
toastManager.danger('Failed to apply edits');
|
||||
return false;
|
||||
} finally {
|
||||
this.isApplyingEdits = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const editManager = new EditManager();
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,23 +1,28 @@
|
||||
import type { ThemeSetting } from '$lib/managers/theme-manager.svelte';
|
||||
import type { ReleaseEvent } from '$lib/types';
|
||||
import type { TreeNode } from '$lib/utils/tree-utils';
|
||||
import type {
|
||||
AlbumResponseDto,
|
||||
ApiKeyResponseDto,
|
||||
AssetResponseDto,
|
||||
LibraryResponseDto,
|
||||
LoginResponseDto,
|
||||
PersonResponseDto,
|
||||
QueueResponseDto,
|
||||
SharedLinkResponseDto,
|
||||
SystemConfigDto,
|
||||
TagResponseDto,
|
||||
UserAdminResponseDto,
|
||||
WorkflowResponseDto,
|
||||
} from '@immich/sdk';
|
||||
|
||||
export type Events = {
|
||||
AppInit: [];
|
||||
UserLogin: [];
|
||||
|
||||
AuthLogin: [LoginResponseDto];
|
||||
AuthLogout: [];
|
||||
AuthUserLoaded: [UserAdminResponseDto];
|
||||
|
||||
LanguageChange: [{ name: string; code: string; rtl?: boolean }];
|
||||
ThemeChange: [ThemeSetting];
|
||||
|
||||
@@ -27,9 +32,15 @@ export type Events = {
|
||||
|
||||
AssetUpdate: [AssetResponseDto];
|
||||
AssetReplace: [{ oldAssetId: string; newAssetId: string }];
|
||||
AssetsArchive: [string[]];
|
||||
AssetsDelete: [string[]];
|
||||
|
||||
AlbumAddAssets: [];
|
||||
AlbumUpdate: [AlbumResponseDto];
|
||||
AlbumDelete: [AlbumResponseDto];
|
||||
AlbumShare: [];
|
||||
|
||||
PersonUpdate: [PersonResponseDto];
|
||||
|
||||
QueueUpdate: [QueueResponseDto];
|
||||
|
||||
@@ -37,6 +48,12 @@ export type Events = {
|
||||
SharedLinkUpdate: [SharedLinkResponseDto];
|
||||
SharedLinkDelete: [SharedLinkResponseDto];
|
||||
|
||||
TagCreate: [TagResponseDto];
|
||||
TagUpdate: [TagResponseDto];
|
||||
TagDelete: [TreeNode];
|
||||
|
||||
UserPinCodeReset: [];
|
||||
|
||||
UserAdminCreate: [UserAdminResponseDto];
|
||||
UserAdminUpdate: [UserAdminResponseDto];
|
||||
UserAdminRestore: [UserAdminResponseDto];
|
||||
|
||||
@@ -2,16 +2,17 @@
|
||||
import AlbumSharedLink from '$lib/components/album-page/album-shared-link.svelte';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import Dropdown from '$lib/elements/Dropdown.svelte';
|
||||
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
|
||||
import { handleAddUsersToAlbum } from '$lib/services/album.service';
|
||||
import {
|
||||
AlbumUserRole,
|
||||
getAllSharedLinks,
|
||||
searchUsers,
|
||||
type AlbumResponseDto,
|
||||
type AlbumUserAddDto,
|
||||
type SharedLinkResponseDto,
|
||||
type UserResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { Button, Icon, Link, Modal, ModalBody, Stack, Text } from '@immich/ui';
|
||||
import { Button, Icon, Link, Modal, ModalBody, modalManager, Stack, Text } from '@immich/ui';
|
||||
import { mdiCheck, mdiEye, mdiLink, mdiPencil } from '@mdi/js';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
@@ -19,7 +20,7 @@
|
||||
|
||||
interface Props {
|
||||
album: AlbumResponseDto;
|
||||
onClose: (result?: { action: 'sharedLink' } | { action: 'sharedUsers'; data: AlbumUserAddDto[] }) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { album, onClose }: Props = $props();
|
||||
@@ -62,6 +63,21 @@
|
||||
selectedUsers[user.id].role = role;
|
||||
}
|
||||
};
|
||||
|
||||
const onShareUser = async () => {
|
||||
const success = await handleAddUsersToAlbum(
|
||||
album,
|
||||
Object.values(selectedUsers).map(({ user, ...rest }) => ({ userId: user.id, ...rest })),
|
||||
);
|
||||
if (success) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const onShareLink = () => {
|
||||
void modalManager.show(SharedLinkCreateModal, { albumId: album.id });
|
||||
onClose();
|
||||
};
|
||||
</script>
|
||||
|
||||
<Modal size="small" title={$t('share')} {onClose}>
|
||||
@@ -145,12 +161,10 @@
|
||||
fullWidth
|
||||
shape="round"
|
||||
disabled={Object.keys(selectedUsers).length === 0}
|
||||
onclick={() =>
|
||||
onClose({
|
||||
action: 'sharedUsers',
|
||||
data: Object.values(selectedUsers).map(({ user, ...rest }) => ({ userId: user.id, ...rest })),
|
||||
})}>{$t('add')}</Button
|
||||
onclick={onShareUser}
|
||||
>
|
||||
{$t('add')}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -170,13 +184,9 @@
|
||||
</Stack>
|
||||
{/if}
|
||||
|
||||
<Button
|
||||
leadingIcon={mdiLink}
|
||||
size="small"
|
||||
shape="round"
|
||||
fullWidth
|
||||
onclick={() => onClose({ action: 'sharedLink' })}>{$t('create_link')}</Button
|
||||
>
|
||||
<Button leadingIcon={mdiLink} size="small" shape="round" fullWidth onclick={onShareLink}>
|
||||
{$t('create_link')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</ModalBody>
|
||||
</Modal>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script lang="ts">
|
||||
import ApiKeyPermissionsPicker from '$lib/components/ApiKeyPermissionsPicker.svelte';
|
||||
import ApiKeySecretModal from '$lib/modals/ApiKeySecretModal.svelte';
|
||||
import { handleCreateApiKey } from '$lib/services/api-key.service';
|
||||
import { Permission } from '@immich/sdk';
|
||||
import { Field, FormModal, Input } from '@immich/ui';
|
||||
import { Field, FormModal, Input, modalManager } from '@immich/ui';
|
||||
import { mdiKeyVariant } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
@@ -15,11 +16,11 @@
|
||||
const isAllPermissions = $derived(selectedPermissions.length === Object.keys(Permission).length - 1);
|
||||
|
||||
const onSubmit = async () => {
|
||||
const success = await handleCreateApiKey({
|
||||
name,
|
||||
permissions: isAllPermissions ? [Permission.All] : selectedPermissions,
|
||||
});
|
||||
if (success) {
|
||||
const permissions = isAllPermissions ? [Permission.All] : selectedPermissions;
|
||||
const response = await handleCreateApiKey({ name, permissions });
|
||||
if (response) {
|
||||
// no nested modal
|
||||
void modalManager.show(ApiKeySecretModal, { secret: response.secret });
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
import { mdiKeyVariant } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
type Props = {
|
||||
secret?: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
};
|
||||
|
||||
let { secret = '', onClose }: Props = $props();
|
||||
</script>
|
||||
|
||||
+10
-10
@@ -5,21 +5,21 @@
|
||||
import { mdiDeleteForeverOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
type Props = {
|
||||
size: number;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
onClose: (confirmed?: boolean) => void;
|
||||
};
|
||||
|
||||
let { size, onConfirm, onCancel }: Props = $props();
|
||||
let { size, onClose: onCloseParent }: Props = $props();
|
||||
|
||||
let checked = $state(false);
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (checked) {
|
||||
const onClose = (confirmed: boolean) => {
|
||||
if (confirmed && checked) {
|
||||
$showDeleteModal = false;
|
||||
}
|
||||
onConfirm();
|
||||
|
||||
onCloseParent(confirmed);
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -27,9 +27,9 @@
|
||||
title={$t('permanently_delete_assets_count', { values: { count: size } })}
|
||||
confirmText={$t('delete')}
|
||||
icon={mdiDeleteForeverOutline}
|
||||
onClose={(confirmed) => (confirmed ? handleConfirm() : onCancel())}
|
||||
{onClose}
|
||||
>
|
||||
{#snippet promptSnippet()}
|
||||
{#snippet prompt()}
|
||||
<p>
|
||||
<FormatMessage key="permanently_delete_assets_prompt" values={{ count: size }}>
|
||||
{#snippet children({ message })}
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { ConfirmModal, Field, Textarea } from '@immich/ui';
|
||||
import { Field, FormModal, Textarea } from '@immich/ui';
|
||||
import { mdiText } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
@@ -11,16 +11,8 @@
|
||||
let description = $state('');
|
||||
</script>
|
||||
|
||||
<ConfirmModal
|
||||
confirmColor="primary"
|
||||
title={$t('edit_description')}
|
||||
icon={mdiText}
|
||||
prompt={$t('edit_description_prompt')}
|
||||
onClose={(confirmed) => (confirmed ? onClose(description) : onClose())}
|
||||
>
|
||||
{#snippet promptSnippet()}
|
||||
<Field label={$t('description')}>
|
||||
<Textarea bind:value={description} grow />
|
||||
</Field>
|
||||
{/snippet}
|
||||
</ConfirmModal>
|
||||
<FormModal title={$t('edit_description')} icon={mdiText} {onClose} onSubmit={() => onClose(description)}>
|
||||
<Field label={$t('description')}>
|
||||
<Textarea bind:value={description} grow />
|
||||
</Field>
|
||||
</FormModal>
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
<script lang="ts">
|
||||
import FormatMessage from '$lib/elements/FormatMessage.svelte';
|
||||
import { Button, HStack, Modal, ModalBody, ModalFooter } from '@immich/ui';
|
||||
import { ConfirmModal } from '@immich/ui';
|
||||
import { mdiCancel } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
type Props = {
|
||||
onClose: (confirmed?: boolean) => void;
|
||||
}
|
||||
};
|
||||
|
||||
let { onClose }: Props = $props();
|
||||
</script>
|
||||
|
||||
<Modal title={$t('admin.disable_login')} icon={mdiCancel} size="small" {onClose}>
|
||||
<ModalBody>
|
||||
<ConfirmModal title={$t('admin.disable_login')} icon={mdiCancel} size="small" {onClose}>
|
||||
{#snippet prompt()}
|
||||
<div class="flex flex-col gap-4 text-center">
|
||||
<p>{$t('admin.authentication_settings_disable_all')}</p>
|
||||
<p>
|
||||
@@ -30,15 +30,5 @@
|
||||
</FormatMessage>
|
||||
</p>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<HStack fullWidth>
|
||||
<Button shape="round" color="secondary" fullWidth onclick={() => onClose(false)}>
|
||||
{$t('cancel')}
|
||||
</Button>
|
||||
<Button shape="round" color="danger" fullWidth onclick={() => onClose(true)}>
|
||||
{$t('confirm')}
|
||||
</Button>
|
||||
</HStack>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
{/snippet}
|
||||
</ConfirmModal>
|
||||
|
||||
@@ -1,33 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { Button, HStack, Modal, ModalBody, ModalFooter } from '@immich/ui';
|
||||
import { ConfirmModal } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
location: { latitude: number | undefined; longitude: number | undefined };
|
||||
assetCount: number;
|
||||
onClose: (confirm?: true) => void;
|
||||
onClose: (confirm: boolean) => void;
|
||||
}
|
||||
|
||||
let { location, assetCount, onClose }: Props = $props();
|
||||
</script>
|
||||
|
||||
<Modal title={$t('confirm')} size="small" {onClose}>
|
||||
<ModalBody>
|
||||
<p>
|
||||
{$t('update_location_action_prompt', {
|
||||
values: {
|
||||
count: assetCount,
|
||||
},
|
||||
})}
|
||||
</p>
|
||||
|
||||
<ConfirmModal title={$t('confirm')} size="small" confirmColor="primary" {onClose}>
|
||||
{#snippet prompt()}
|
||||
<p>{$t('update_location_action_prompt', { values: { count: assetCount } })}</p>
|
||||
<p>- {$t('latitude')}: {location.latitude}</p>
|
||||
<p>- {$t('longitude')}: {location.longitude}</p>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<HStack fullWidth>
|
||||
<Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
|
||||
<Button shape="round" type="submit" fullWidth onclick={() => onClose(true)}>{$t('confirm')}</Button>
|
||||
</HStack>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
{/snippet}
|
||||
</ConfirmModal>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<script lang="ts">
|
||||
import Combobox, { type ComboBoxOption } from '$lib/components/shared-components/combobox.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { createJob, ManualJobName } from '@immich/sdk';
|
||||
import { ConfirmModal, toastManager } from '@immich/ui';
|
||||
import { handleCreateJob } from '$lib/services/job.service';
|
||||
import { ManualJobName } from '@immich/sdk';
|
||||
import { FormModal } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = { onClose: (confirmed: boolean) => void };
|
||||
type Props = { onClose: () => void };
|
||||
|
||||
let { onClose }: Props = $props();
|
||||
|
||||
@@ -44,42 +44,18 @@
|
||||
|
||||
let selectedJob: ComboBoxOption | undefined = $state(undefined);
|
||||
|
||||
const onsubmit = async (event: Event) => {
|
||||
event.preventDefault();
|
||||
await handleCreate();
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
const onSubmit = async () => {
|
||||
if (!selectedJob) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await createJob({ jobCreateDto: { name: selectedJob.value as ManualJobName } });
|
||||
toastManager.success($t('admin.job_created'));
|
||||
onClose(true);
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_submit_job'));
|
||||
const success = await handleCreateJob({ name: selectedJob.value as ManualJobName });
|
||||
if (success) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<ConfirmModal
|
||||
confirmColor="primary"
|
||||
title={$t('admin.create_job')}
|
||||
disabled={!selectedJob}
|
||||
onClose={(confirmed) => (confirmed ? handleCreate() : onClose(false))}
|
||||
>
|
||||
{#snippet promptSnippet()}
|
||||
<form {onsubmit} autocomplete="off" id="create-tag-form" class="w-full">
|
||||
<div class="flex flex-col gap-1 text-start">
|
||||
<Combobox
|
||||
bind:selectedOption={selectedJob}
|
||||
label={$t('jobs')}
|
||||
{options}
|
||||
placeholder={$t('admin.search_jobs')}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
{/snippet}
|
||||
</ConfirmModal>
|
||||
<FormModal title={$t('admin.create_job')} submitText={$t('create')} disabled={!selectedJob} {onClose} {onSubmit}>
|
||||
<Combobox bind:selectedOption={selectedJob} label={$t('jobs')} {options} placeholder={$t('admin.search_jobs')} />
|
||||
</FormModal>
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
<script lang="ts">
|
||||
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
|
||||
import { SettingInputFieldType } from '$lib/constants';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { createApiKey, Permission } from '@immich/sdk';
|
||||
import { Button, Modal, ModalBody, obtainiumBadge, Text } from '@immich/ui';
|
||||
import { handleCreateApiKey } from '$lib/services/api-key.service';
|
||||
import { Permission } from '@immich/sdk';
|
||||
import { Button, Field, Input, Modal, ModalBody, obtainiumBadge, Text } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
let inputUrl = $state(location.origin);
|
||||
let inputApiKey = $state('');
|
||||
@@ -12,72 +10,63 @@
|
||||
let obtainiumLink = $derived(
|
||||
`https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22app.alextran.immich%22%2C%22url%22%3A%22${inputUrl}%2Fapi%2Fserver%2Fapk-links%22%2C%22author%22%3A%22Immich%22%2C%22name%22%3A%22Immich%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%5C%22intermediateLink%5C%22%3A%5B%5D%2C%5C%22customLinkFilterRegex%5C%22%3A%5C%22%5C%22%2C%5C%22filterByLinkText%5C%22%3Afalse%2C%5C%22skipSort%5C%22%3Afalse%2C%5C%22reverseSort%5C%22%3Afalse%2C%5C%22sortByLastLinkSegment%5C%22%3Afalse%2C%5C%22versionExtractWholePage%5C%22%3Afalse%2C%5C%22requestHeader%5C%22%3A%5B%7B%5C%22requestHeader%5C%22%3A%5C%22User-Agent%3A%20Mozilla%2F5.0%20(Linux%3B%20Android%2010%3B%20K)%20AppleWebKit%2F537.36%20(KHTML%2C%20like%20Gecko)%20Chrome%2F114.0.0.0%20Mobile%20Safari%2F537.36%5C%22%7D%2C%7B%5C%22requestHeader%5C%22%3A%5C%22x-api-key%3A%20${inputApiKey}%5C%22%7D%5D%2C%5C%22defaultPseudoVersioningMethod%5C%22%3A%5C%22APKLinkHash%5C%22%2C%5C%22trackOnly%5C%22%3Afalse%2C%5C%22versionExtractionRegEx%5C%22%3A%5C%22%2Fv(%5C%5C%5C%5Cd%2B).(%5C%5C%5C%5Cd%2B).(%5C%5C%5C%5Cd%2B)%2F%5C%22%2C%5C%22matchGroupToUse%5C%22%3A%5C%22%241.%242.%243%5C%22%2C%5C%22versionDetection%5C%22%3Atrue%2C%5C%22useVersionCodeAsOSVersion%5C%22%3Afalse%2C%5C%22apkFilterRegEx%5C%22%3A%5C%22app-${archVariant}.apk%24%5C%22%2C%5C%22invertAPKFilter%5C%22%3Afalse%2C%5C%22autoApkFilterByArch%5C%22%3Atrue%2C%5C%22appName%5C%22%3A%5C%22%5C%22%2C%5C%22appAuthor%5C%22%3A%5C%22%5C%22%2C%5C%22shizukuPretendToBeGooglePlay%5C%22%3Afalse%2C%5C%22allowInsecure%5C%22%3Afalse%2C%5C%22exemptFromBackgroundUpdates%5C%22%3Afalse%2C%5C%22skipUpdateNotifications%5C%22%3Afalse%2C%5C%22about%5C%22%3A%5C%22%5C%22%2C%5C%22refreshBeforeDownload%5C%22%3Afalse%7D%22%2C%22overrideSource%22%3Anull%7D`,
|
||||
);
|
||||
|
||||
type Props = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
let { onClose }: Props = $props();
|
||||
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
const { secret } = await createApiKey({
|
||||
apiKeyCreateDto: {
|
||||
name: 'Obtainium',
|
||||
permissions: [Permission.ServerApkLinks],
|
||||
},
|
||||
});
|
||||
inputApiKey = secret;
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_create_api_key'));
|
||||
const response = await handleCreateApiKey({ name: 'Obtainium', permissions: [Permission.ServerApkLinks] });
|
||||
if (response) {
|
||||
inputApiKey = response.secret;
|
||||
}
|
||||
};
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
let { onClose }: Props = $props();
|
||||
</script>
|
||||
|
||||
<Modal title={$t('obtainium_configurator')} size="medium" {onClose}>
|
||||
<ModalBody>
|
||||
<div>
|
||||
<Text color="muted" size="small">
|
||||
{$t('obtainium_configurator_instructions')}
|
||||
</Text>
|
||||
<form class="mt-4">
|
||||
<div class="mt-2">
|
||||
<SettingInputField inputType={SettingInputFieldType.TEXT} label={$t('url')} bind:value={inputUrl} />
|
||||
</div>
|
||||
<Text color="muted" size="small">{$t('obtainium_configurator_instructions')}</Text>
|
||||
|
||||
<div class="mt-2 flex gap-2 place-items-center place-content-center">
|
||||
<SettingInputField inputType={SettingInputFieldType.TEXT} label={$t('api_key')} bind:value={inputApiKey} />
|
||||
<Field label={$t('url')} class="mt-4">
|
||||
<Input bind:value={inputUrl} />
|
||||
</Field>
|
||||
|
||||
<div class="translate-y-[3px]">
|
||||
<Button size="small" onclick={() => handleCreate()}>{$t('create_api_key')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Field label={$t('api_key')} class="mt-4">
|
||||
<Input bind:value={inputApiKey} />
|
||||
</Field>
|
||||
|
||||
<SettingSelect
|
||||
label={$t('app_architecture_variant')}
|
||||
bind:value={archVariant}
|
||||
options={[
|
||||
{ value: 'arm64-v8a-release', text: 'arm64-v8a' },
|
||||
{ value: 'armeabi-v7a-release', text: 'armeabi-v7a' },
|
||||
{ value: 'release', text: 'universal' },
|
||||
{ value: 'x86_64-release', text: 'x86_64' },
|
||||
]}
|
||||
/>
|
||||
</form>
|
||||
|
||||
{#if inputUrl && inputApiKey && archVariant}
|
||||
<div class="content-center">
|
||||
<hr />
|
||||
<div class="flex place-items-center place-content-center">
|
||||
<a
|
||||
href={obtainiumLink}
|
||||
class="underline text-sm immich-form-label"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
id="obtainium-link"
|
||||
>
|
||||
<img class="pt-2 pr-5 h-20" alt="Get it on Obtainium" src={obtainiumBadge} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex justify-end mt-2">
|
||||
<Button size="small" onclick={handleCreate}>{$t('create_api_key')}</Button>
|
||||
</div>
|
||||
|
||||
<SettingSelect
|
||||
label={$t('app_architecture_variant')}
|
||||
bind:value={archVariant}
|
||||
options={[
|
||||
{ value: 'arm64-v8a-release', text: 'arm64-v8a' },
|
||||
{ value: 'armeabi-v7a-release', text: 'armeabi-v7a' },
|
||||
{ value: 'release', text: 'universal' },
|
||||
{ value: 'x86_64-release', text: 'x86_64' },
|
||||
]}
|
||||
/>
|
||||
|
||||
{#if inputUrl && inputApiKey && archVariant}
|
||||
<div class="content-center">
|
||||
<hr />
|
||||
<div class="flex place-items-center place-content-center">
|
||||
<a
|
||||
href={obtainiumLink}
|
||||
class="underline text-sm immich-form-label"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
id="obtainium-link"
|
||||
>
|
||||
<img class="pt-2 h-20" alt="Get it on Obtainium" src={obtainiumBadge} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</ModalBody>
|
||||
</Modal>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { copyToClipboard } from '$lib/utils';
|
||||
import { Button, Code, HStack, IconButton, Modal, ModalBody, ModalFooter, Text } from '@immich/ui';
|
||||
import { BasicModal, Code, IconButton, Text } from '@immich/ui';
|
||||
import { mdiCheck, mdiContentCopy } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
@@ -12,33 +12,23 @@
|
||||
const { onClose, newPassword }: Props = $props();
|
||||
</script>
|
||||
|
||||
<Modal title={$t('password_reset_success')} icon={mdiCheck} onClose={() => onClose()} size="small">
|
||||
<ModalBody>
|
||||
<div class="flex flex-col gap-4">
|
||||
<Text>{$t('admin.user_password_has_been_reset')}</Text>
|
||||
<BasicModal title={$t('password_reset_success')} icon={mdiCheck} {onClose} size="small" closeText={$t('done')}>
|
||||
<div class="flex flex-col gap-4">
|
||||
<Text>{$t('admin.user_password_has_been_reset')}</Text>
|
||||
|
||||
<div class="flex justify-center gap-2 items-center">
|
||||
<Code color="primary">{newPassword}</Code>
|
||||
<IconButton
|
||||
icon={mdiContentCopy}
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
onclick={() => copyToClipboard(newPassword)}
|
||||
title={$t('copy_password')}
|
||||
aria-label={$t('copy_password')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Text>{$t('admin.user_password_reset_description')}</Text>
|
||||
<div class="flex justify-center gap-2 items-center">
|
||||
<Code color="primary">{newPassword}</Code>
|
||||
<IconButton
|
||||
icon={mdiContentCopy}
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
onclick={() => copyToClipboard(newPassword)}
|
||||
title={$t('copy_password')}
|
||||
aria-label={$t('copy_password')}
|
||||
/>
|
||||
</div>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<HStack fullWidth>
|
||||
<Button shape="round" color="primary" fullWidth onclick={() => onClose()}>
|
||||
{$t('done')}
|
||||
</Button>
|
||||
</HStack>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
<Text>{$t('admin.user_password_reset_description')}</Text>
|
||||
</div>
|
||||
</BasicModal>
|
||||
|
||||
@@ -1,73 +1,46 @@
|
||||
<script lang="ts">
|
||||
import DateInput from '$lib/elements/DateInput.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { updatePerson, type PersonResponseDto } from '@immich/sdk';
|
||||
import { Button, HStack, Modal, ModalBody, ModalFooter, toastManager } from '@immich/ui';
|
||||
import { handleUpdatePersonBirthDate } from '$lib/services/person.service';
|
||||
import { type PersonResponseDto } from '@immich/sdk';
|
||||
import { Button, FormModal, Text } from '@immich/ui';
|
||||
import { mdiCake } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
type Props = {
|
||||
person: PersonResponseDto;
|
||||
onClose: (updatedPerson?: PersonResponseDto) => void;
|
||||
}
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
let { person, onClose }: Props = $props();
|
||||
let birthDate = $state(person.birthDate ?? '');
|
||||
let birthDate = $derived(person.birthDate ?? '');
|
||||
|
||||
const todayFormatted = new Date().toISOString().split('T')[0];
|
||||
|
||||
const handleUpdateBirthDate = async () => {
|
||||
try {
|
||||
const updatedPerson = await updatePerson({
|
||||
id: person.id,
|
||||
personUpdateDto: { birthDate },
|
||||
});
|
||||
|
||||
toastManager.success($t('date_of_birth_saved'));
|
||||
onClose(updatedPerson);
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_save_date_of_birth'));
|
||||
const onSubmit = async () => {
|
||||
const success = await handleUpdatePersonBirthDate(person, birthDate);
|
||||
if (success) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const todayFormatted = new Date().toISOString().split('T')[0];
|
||||
</script>
|
||||
|
||||
<Modal title={$t('set_date_of_birth')} icon={mdiCake} {onClose} size="small">
|
||||
<ModalBody>
|
||||
<div class="text-primary">
|
||||
<p class="text-sm dark:text-immich-dark-fg">
|
||||
{$t('birthdate_set_description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onsubmit={() => handleUpdateBirthDate()} autocomplete="off" id="set-birth-date-form">
|
||||
<div class="my-4 flex flex-col gap-2">
|
||||
<DateInput
|
||||
class="immich-form-input"
|
||||
id="birthDate"
|
||||
name="birthDate"
|
||||
type="date"
|
||||
bind:value={birthDate}
|
||||
max={todayFormatted}
|
||||
/>
|
||||
{#if person.birthDate}
|
||||
<div class="flex justify-end">
|
||||
<Button shape="round" color="secondary" size="small" onclick={() => (birthDate = '')}>
|
||||
{$t('clear')}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
<FormModal title={$t('set_date_of_birth')} size="small" icon={mdiCake} {onClose} {onSubmit}>
|
||||
<Text size="small">{$t('birthdate_set_description')}</Text>
|
||||
<div class="my-4 flex flex-col gap-2">
|
||||
<DateInput
|
||||
class="immich-form-input"
|
||||
id="birthDate"
|
||||
name="birthDate"
|
||||
type="date"
|
||||
bind:value={birthDate}
|
||||
max={todayFormatted}
|
||||
/>
|
||||
{#if person.birthDate}
|
||||
<div class="flex justify-end">
|
||||
<Button shape="round" color="secondary" size="small" onclick={() => (birthDate = '')}>
|
||||
{$t('clear')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<HStack fullWidth>
|
||||
<Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>
|
||||
{$t('cancel')}
|
||||
</Button>
|
||||
<Button type="submit" shape="round" color="primary" fullWidth form="set-birth-date-form">
|
||||
{$t('save')}
|
||||
</Button>
|
||||
</HStack>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
{/if}
|
||||
</div>
|
||||
</FormModal>
|
||||
|
||||
@@ -2,18 +2,18 @@
|
||||
import { getPeopleThumbnailUrl } from '$lib/utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { mergePerson, type PersonResponseDto } from '@immich/sdk';
|
||||
import { Button, HStack, Icon, IconButton, Modal, ModalBody, ModalFooter, toastManager } from '@immich/ui';
|
||||
import { FormModal, Icon, IconButton, toastManager } from '@immich/ui';
|
||||
import { mdiArrowLeft, mdiCallMerge, mdiSwapHorizontal } from '@mdi/js';
|
||||
import { onMount, tick } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import ImageThumbnail from '../components/assets/thumbnail/image-thumbnail.svelte';
|
||||
|
||||
interface Props {
|
||||
type Props = {
|
||||
personToMerge: PersonResponseDto;
|
||||
personToBeMergedInto: PersonResponseDto;
|
||||
potentialMergePeople: PersonResponseDto[];
|
||||
onClose: (people?: [PersonResponseDto, PersonResponseDto]) => void;
|
||||
}
|
||||
};
|
||||
|
||||
let {
|
||||
personToMerge = $bindable(),
|
||||
@@ -32,7 +32,7 @@
|
||||
choosePersonToMerge = false;
|
||||
};
|
||||
|
||||
const handleMergePerson = async () => {
|
||||
const onSubmit = async () => {
|
||||
try {
|
||||
await mergePerson({
|
||||
id: personToBeMergedInto.id,
|
||||
@@ -51,99 +51,95 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<Modal title="{$t('merge_people')} - {title}" {onClose}>
|
||||
<ModalBody>
|
||||
<div class="flex items-center justify-center gap-2 py-4 md:h-36">
|
||||
{#if !choosePersonToMerge}
|
||||
<div class="flex h-20 w-20 items-center px-1 md:h-24 md:w-24 md:px-2">
|
||||
<ImageThumbnail
|
||||
circle
|
||||
shadow
|
||||
url={getPeopleThumbnailUrl(personToMerge)}
|
||||
altText={personToMerge.name}
|
||||
widthStyle="100%"
|
||||
<FormModal
|
||||
title="{$t('merge_people')} - {title}"
|
||||
submitColor="primary"
|
||||
submitText={$t('yes')}
|
||||
cancelText={$t('no')}
|
||||
{onClose}
|
||||
{onSubmit}
|
||||
>
|
||||
<div class="flex items-center justify-center gap-2 py-4 md:h-36">
|
||||
{#if !choosePersonToMerge}
|
||||
<div class="flex h-20 w-20 items-center px-1 md:h-24 md:w-24 md:px-2">
|
||||
<ImageThumbnail
|
||||
circle
|
||||
shadow
|
||||
url={getPeopleThumbnailUrl(personToMerge)}
|
||||
altText={personToMerge.name}
|
||||
widthStyle="100%"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-rows-3">
|
||||
<div></div>
|
||||
<div class="flex flex-col h-full items-center justify-center">
|
||||
<div class="flex h-full items-center justify-center">
|
||||
<Icon icon={mdiCallMerge} size="48" class="rotate-90 dark:text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<IconButton
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
aria-label={$t('swap_merge_direction')}
|
||||
icon={mdiSwapHorizontal}
|
||||
onclick={() => ([personToMerge, personToBeMergedInto] = [personToBeMergedInto, personToMerge])}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-rows-3">
|
||||
<div></div>
|
||||
<div class="flex flex-col h-full items-center justify-center">
|
||||
<div class="flex h-full items-center justify-center">
|
||||
<Icon icon={mdiCallMerge} size="48" class="rotate-90 dark:text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<IconButton
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
aria-label={$t('swap_merge_direction')}
|
||||
icon={mdiSwapHorizontal}
|
||||
onclick={() => ([personToMerge, personToBeMergedInto] = [personToBeMergedInto, personToMerge])}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={potentialMergePeople.length === 0}
|
||||
class="flex h-28 w-28 items-center rounded-full border-2 border-immich-primary px-1 dark:border-immich-dark-primary md:h-32 md:w-32 md:px-2"
|
||||
onclick={() => {
|
||||
if (potentialMergePeople.length > 0) {
|
||||
choosePersonToMerge = !choosePersonToMerge;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ImageThumbnail
|
||||
border={potentialMergePeople.length > 0}
|
||||
circle
|
||||
shadow
|
||||
url={getPeopleThumbnailUrl(personToBeMergedInto)}
|
||||
altText={personToBeMergedInto.name}
|
||||
widthStyle="100%"
|
||||
/>
|
||||
</button>
|
||||
{:else}
|
||||
<div class="grid w-full grid-cols-1 gap-2">
|
||||
<div class="px-2">
|
||||
<button type="button" onclick={() => (choosePersonToMerge = false)}> <Icon icon={mdiArrowLeft} /></button>
|
||||
</div>
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="flex flex-wrap justify-center md:grid md:grid-cols-{potentialMergePeople.length}">
|
||||
{#each potentialMergePeople as person (person.id)}
|
||||
<div class="h-24 w-24 md:h-28 md:w-28">
|
||||
<button type="button" class="p-2 w-full" onclick={() => changePersonToMerge(person)}>
|
||||
<ImageThumbnail
|
||||
border={true}
|
||||
circle
|
||||
shadow
|
||||
url={getPeopleThumbnailUrl(person)}
|
||||
altText={person.name}
|
||||
widthStyle="100%"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={potentialMergePeople.length === 0}
|
||||
class="flex h-28 w-28 items-center rounded-full border-2 border-immich-primary px-1 dark:border-immich-dark-primary md:h-32 md:w-32 md:px-2"
|
||||
onclick={() => {
|
||||
if (potentialMergePeople.length > 0) {
|
||||
choosePersonToMerge = !choosePersonToMerge;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ImageThumbnail
|
||||
border={potentialMergePeople.length > 0}
|
||||
circle
|
||||
shadow
|
||||
url={getPeopleThumbnailUrl(personToBeMergedInto)}
|
||||
altText={personToBeMergedInto.name}
|
||||
widthStyle="100%"
|
||||
/>
|
||||
</button>
|
||||
{:else}
|
||||
<div class="grid w-full grid-cols-1 gap-2">
|
||||
<div class="px-2">
|
||||
<button type="button" onclick={() => (choosePersonToMerge = false)}> <Icon icon={mdiArrowLeft} /></button>
|
||||
</div>
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="flex flex-wrap justify-center md:grid md:grid-cols-{potentialMergePeople.length}">
|
||||
{#each potentialMergePeople as person (person.id)}
|
||||
<div class="h-24 w-24 md:h-28 md:w-28">
|
||||
<button type="button" class="p-2 w-full" onclick={() => changePersonToMerge(person)}>
|
||||
<ImageThumbnail
|
||||
border={true}
|
||||
circle
|
||||
shadow
|
||||
url={getPeopleThumbnailUrl(person)}
|
||||
altText={person.name}
|
||||
widthStyle="100%"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex px-4 md:pt-4">
|
||||
<h1 class="text-xl text-gray-500 dark:text-gray-300">{$t('are_these_the_same_person')}</h1>
|
||||
</div>
|
||||
<div class="flex px-4 pt-2">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-300">{$t('they_will_be_merged_together')}</p>
|
||||
</div>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<HStack fullWidth>
|
||||
<Button fullWidth shape="round" color="secondary" onclick={() => onClose()}>{$t('no')}</Button>
|
||||
<Button id="merge-confirm-button" fullWidth shape="round" onclick={handleMergePerson}>
|
||||
{$t('yes')}
|
||||
</Button>
|
||||
</HStack>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
<div class="flex px-4 md:pt-4">
|
||||
<h1 class="text-xl text-gray-500 dark:text-gray-300">{$t('are_these_the_same_person')}</h1>
|
||||
</div>
|
||||
<div class="flex px-4 pt-2">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-300">{$t('they_will_be_merged_together')}</p>
|
||||
</div>
|
||||
</FormModal>
|
||||
|
||||
@@ -1,20 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { resetPinCode } from '@immich/sdk';
|
||||
import {
|
||||
Button,
|
||||
Field,
|
||||
HelperText,
|
||||
HStack,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
PasswordInput,
|
||||
Stack,
|
||||
Text,
|
||||
toastManager,
|
||||
} from '@immich/ui';
|
||||
import { handleResetPinCode } from '$lib/services/user.service';
|
||||
import { Field, FormModal, HelperText, Modal, ModalBody, PasswordInput, Stack, type ModalSize } from '@immich/ui';
|
||||
import { mdiLockReset } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
@@ -24,55 +11,35 @@
|
||||
|
||||
let { onClose }: Props = $props();
|
||||
|
||||
let passwordLoginEnabled = $derived(featureFlagsManager.value.passwordLogin);
|
||||
let password = $state('');
|
||||
|
||||
const handleReset = async () => {
|
||||
try {
|
||||
await resetPinCode({ pinCodeResetDto: { password } });
|
||||
toastManager.success($t('pin_code_reset_successfully'));
|
||||
onClose(true);
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.failed_to_reset_pin_code'));
|
||||
const onSubmit = async () => {
|
||||
const success = await handleResetPinCode({ password });
|
||||
if (success) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const onsubmit = async (event: Event) => {
|
||||
event.preventDefault();
|
||||
await handleReset();
|
||||
};
|
||||
const common = $derived({ title: $t('reset'), size: 'small' as ModalSize, icon: mdiLockReset, onClose });
|
||||
</script>
|
||||
|
||||
<Modal title={$t('reset_pin_code')} icon={mdiLockReset} size="small" {onClose}>
|
||||
<ModalBody>
|
||||
<form {onsubmit} autocomplete="off" id="reset-pin-form">
|
||||
<Stack gap={4}>
|
||||
<div>{$t('reset_pin_code_description')}</div>
|
||||
{#if passwordLoginEnabled}
|
||||
<hr class="my-2 h-px w-full border-0 bg-gray-200 dark:bg-gray-600" />
|
||||
<section>
|
||||
<Field label={$t('confirm_password')} required>
|
||||
<PasswordInput bind:value={password} autocomplete="current-password" />
|
||||
<HelperText>
|
||||
<Text color="muted">{$t('reset_pin_code_with_password')}</Text>
|
||||
</HelperText>
|
||||
</Field>
|
||||
</section>
|
||||
{/if}
|
||||
</Stack>
|
||||
</form>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
{#if passwordLoginEnabled}
|
||||
<HStack fullWidth>
|
||||
<Button fullWidth shape="round" color="secondary" onclick={() => onClose()}>{$t('cancel')}</Button>
|
||||
<Button type="submit" form="reset-pin-form" fullWidth shape="round" color="danger" disabled={!password}>
|
||||
{$t('reset')}
|
||||
</Button>
|
||||
</HStack>
|
||||
{:else}
|
||||
<Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>{$t('close')}</Button>
|
||||
{/if}
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
{#if featureFlagsManager.value.passwordLogin === false}
|
||||
<FormModal {...common} submitColor="danger" submitText={$t('reset')} disabled={!password} {onSubmit}>
|
||||
<Stack gap={4}>
|
||||
<div>{$t('reset_pin_code_description')}</div>
|
||||
<hr class="my-2 h-px w-full border-0 bg-gray-200 dark:bg-gray-600" />
|
||||
<section>
|
||||
<Field label={$t('confirm_password')} required>
|
||||
<PasswordInput bind:value={password} autocomplete="current-password" />
|
||||
<HelperText color="muted">{$t('reset_pin_code_with_password')}</HelperText>
|
||||
</Field>
|
||||
</section>
|
||||
</Stack>
|
||||
</FormModal>
|
||||
{:else}
|
||||
<Modal {...common} closeOnBackdropClick>
|
||||
<ModalBody>
|
||||
<div>{$t('reset_pin_code_description')}</div>
|
||||
</ModalBody>
|
||||
</Modal>
|
||||
{/if}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { createProfileImage, type AssetResponseDto } from '@immich/sdk';
|
||||
import { Button, Modal, ModalBody, ModalFooter, toastManager } from '@immich/ui';
|
||||
import { FormModal, toastManager } from '@immich/ui';
|
||||
import domtoimage from 'dom-to-image';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
@@ -50,7 +50,7 @@
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleSetProfilePicture = async () => {
|
||||
const onSubmit = async () => {
|
||||
if (!imgElement) {
|
||||
return;
|
||||
}
|
||||
@@ -72,24 +72,20 @@
|
||||
toastManager.success($t('profile_picture_set'));
|
||||
$user.profileImagePath = profileImagePath;
|
||||
$user.profileChangedAt = profileChangedAt;
|
||||
|
||||
onClose();
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_set_profile_picture'));
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
</script>
|
||||
|
||||
<Modal size="small" title={$t('set_profile_picture')} {onClose}>
|
||||
<ModalBody>
|
||||
<div class="flex place-items-center items-center justify-center">
|
||||
<div
|
||||
class="relative flex aspect-square w-62.5 overflow-hidden rounded-full border-4 border-immich-primary bg-immich-dark-primary dark:border-immich-dark-primary dark:bg-immich-primary"
|
||||
>
|
||||
<PhotoViewer bind:element={imgElement} cursor={{ current: asset }} />
|
||||
</div>
|
||||
<FormModal size="small" title={$t('set_profile_picture')} {onClose} {onSubmit}>
|
||||
<div class="flex place-items-center items-center justify-center">
|
||||
<div
|
||||
class="relative flex aspect-square w-62.5 overflow-hidden rounded-full border-4 border-immich-primary bg-immich-dark-primary dark:border-immich-dark-primary dark:bg-immich-primary"
|
||||
>
|
||||
<PhotoViewer bind:element={imgElement} cursor={{ current: asset }} />
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button fullWidth shape="round" onclick={handleSetProfilePicture}>{$t('set_as_profile_picture')}</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
</div>
|
||||
</FormModal>
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
<script lang="ts">
|
||||
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||
import { SettingInputFieldType } from '$lib/constants';
|
||||
import { handleCreateTag } from '$lib/services/tag.service';
|
||||
import type { TreeNode } from '$lib/utils/tree-utils';
|
||||
import { upsertTags, type TagResponseDto } from '@immich/sdk';
|
||||
import { Button, HStack, Modal, ModalBody, ModalFooter, toastManager } from '@immich/ui';
|
||||
import { Field, FormModal, Input, Text } from '@immich/ui';
|
||||
import { mdiTag } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
onClose: (tag?: TagResponseDto) => void;
|
||||
onClose: () => void;
|
||||
baseTag?: TreeNode;
|
||||
};
|
||||
|
||||
@@ -16,44 +14,17 @@
|
||||
|
||||
let tagValue = $state(baseTag?.path ? `${baseTag.path}/` : '');
|
||||
|
||||
const createTag = async () => {
|
||||
const [tag] = await upsertTags({ tagUpsertDto: { tags: [tagValue] } });
|
||||
|
||||
if (!tag) {
|
||||
return;
|
||||
const onSubmit = async () => {
|
||||
const success = await handleCreateTag(tagValue);
|
||||
if (success) {
|
||||
onClose();
|
||||
}
|
||||
|
||||
toastManager.success($t('tag_created', { values: { tag: tag.value } }));
|
||||
|
||||
onClose(tag);
|
||||
};
|
||||
</script>
|
||||
|
||||
<Modal size="small" title={$t('create_tag')} icon={mdiTag} {onClose}>
|
||||
<ModalBody>
|
||||
<div class="text-primary">
|
||||
<p class="text-sm dark:text-immich-dark-fg">
|
||||
{$t('create_tag_description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onsubmit={createTag} autocomplete="off" id="create-tag-form">
|
||||
<div class="my-4 flex flex-col gap-2">
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
label={$t('tag')}
|
||||
bind:value={tagValue}
|
||||
required={true}
|
||||
autofocus={true}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<HStack fullWidth>
|
||||
<Button color="secondary" fullWidth shape="round" onclick={() => onClose()}>{$t('cancel')}</Button>
|
||||
<Button type="submit" fullWidth shape="round" form="create-tag-form">{$t('create')}</Button>
|
||||
</HStack>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
<FormModal size="small" title={$t('create_tag')} submitText={$t('create')} icon={mdiTag} {onClose} {onSubmit}>
|
||||
<Text size="small">{$t('create_tag_description')}</Text>
|
||||
<Field label={$t('tag')} required>
|
||||
<Input autofocus bind:value={tagValue} />
|
||||
</Field>
|
||||
</FormModal>
|
||||
|
||||
@@ -1,47 +1,29 @@
|
||||
<script lang="ts">
|
||||
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
|
||||
import { SettingInputFieldType } from '$lib/constants';
|
||||
import { handleUpdateTag } from '$lib/services/tag.service';
|
||||
import type { TreeNode } from '$lib/utils/tree-utils';
|
||||
import { updateTag, type TagResponseDto } from '@immich/sdk';
|
||||
import { Button, HStack, Modal, ModalBody, ModalFooter, toastManager } from '@immich/ui';
|
||||
import { FormModal } from '@immich/ui';
|
||||
import { mdiTag } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
tag: TreeNode;
|
||||
onClose: (updatedTag?: TagResponseDto) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const { tag, onClose }: Props = $props();
|
||||
|
||||
let tagColor = $state(tag.color ?? '');
|
||||
|
||||
const handleEdit = async () => {
|
||||
if (!tag.id) {
|
||||
return;
|
||||
const onSubmit = async () => {
|
||||
const success = await handleUpdateTag(tag, { color: tagColor });
|
||||
if (success) {
|
||||
onClose();
|
||||
}
|
||||
|
||||
const updatedTag = await updateTag({ id: tag.id, tagUpdateDto: { color: tagColor } });
|
||||
|
||||
toastManager.success($t('tag_updated', { values: { tag: tag.value } }));
|
||||
|
||||
onClose(updatedTag);
|
||||
};
|
||||
</script>
|
||||
|
||||
<Modal title={$t('edit_tag')} icon={mdiTag} {onClose}>
|
||||
<ModalBody>
|
||||
<form onsubmit={handleEdit} autocomplete="off" id="edit-tag-form">
|
||||
<div class="my-4 flex flex-col gap-2">
|
||||
<SettingInputField inputType={SettingInputFieldType.COLOR} label={$t('color')} bind:value={tagColor} />
|
||||
</div>
|
||||
</form>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<HStack fullWidth>
|
||||
<Button color="secondary" fullWidth shape="round" onclick={() => onClose()}>{$t('cancel')}</Button>
|
||||
<Button type="submit" fullWidth shape="round" form="edit-tag-form">{$t('save')}</Button>
|
||||
</HStack>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
<FormModal title={$t('edit_tag')} size="small" icon={mdiTag} {onClose} {onSubmit}>
|
||||
<SettingInputField inputType={SettingInputFieldType.COLOR} label={$t('color')} bind:value={tagColor} />
|
||||
</FormModal>
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
onClose={handleClose}
|
||||
{disabled}
|
||||
>
|
||||
{#snippet promptSnippet()}
|
||||
{#snippet prompt()}
|
||||
<div class="flex flex-col gap-4">
|
||||
<Text>
|
||||
{#if force}
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
size="small"
|
||||
onClose={handleClose}
|
||||
>
|
||||
{#snippet promptSnippet()}
|
||||
{#snippet prompt()}
|
||||
<p>
|
||||
<FormatMessage key="admin.user_restore_description" values={{ user: user.name }}>
|
||||
{#snippet children({ message })}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import FormatMessage from '$lib/elements/FormatMessage.svelte';
|
||||
import { Button, Modal, ModalBody, ModalFooter } from '@immich/ui';
|
||||
import { BasicModal } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
@@ -12,33 +12,33 @@
|
||||
const { serverVersion, releaseVersion, onClose }: Props = $props();
|
||||
</script>
|
||||
|
||||
<Modal size="small" title="🎉 {$t('new_version_available')}" {onClose} icon={false}>
|
||||
<ModalBody>
|
||||
<div>
|
||||
<FormatMessage key="version_announcement_message">
|
||||
{#snippet children({ tag, message })}
|
||||
{#if tag === 'link'}
|
||||
<span class="font-medium underline">
|
||||
<a href="https://github.com/immich-app/immich/releases/latest" target="_blank" rel="noopener noreferrer">
|
||||
{message}
|
||||
</a>
|
||||
</span>
|
||||
{:else if tag === 'code'}
|
||||
<code>{message}</code>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</FormatMessage>
|
||||
</div>
|
||||
<BasicModal
|
||||
size="small"
|
||||
title="🎉 {$t('new_version_available')}"
|
||||
closeText={$t('acknowledge')}
|
||||
closeColor="primary"
|
||||
{onClose}
|
||||
icon={false}
|
||||
>
|
||||
<FormatMessage key="version_announcement_message">
|
||||
{#snippet children({ tag, message })}
|
||||
{#if tag === 'link'}
|
||||
<span class="font-medium underline">
|
||||
<a href="https://github.com/immich-app/immich/releases/latest" target="_blank" rel="noopener noreferrer">
|
||||
{message}
|
||||
</a>
|
||||
</span>
|
||||
{:else if tag === 'code'}
|
||||
<code>{message}</code>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</FormatMessage>
|
||||
|
||||
<div class="mt-4 font-medium">{$t('version_announcement_closing')}</div>
|
||||
<div class="mt-4 font-medium">{$t('version_announcement_closing')}</div>
|
||||
|
||||
<div class="font-sm mt-8">
|
||||
<code>{$t('server_version')}: {serverVersion}</code>
|
||||
<br />
|
||||
<code>{$t('latest_version')}: {releaseVersion}</code>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button fullWidth shape="round" onclick={onClose}>{$t('acknowledge')}</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
<div class="font-sm mt-8">
|
||||
<code>{$t('server_version')}: {serverVersion}</code>
|
||||
<br />
|
||||
<code>{$t('latest_version')}: {releaseVersion}</code>
|
||||
</div>
|
||||
</BasicModal>
|
||||
|
||||
@@ -2,11 +2,89 @@ import { goto } from '$app/navigation';
|
||||
import ToastAction from '$lib/components/ToastAction.svelte';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import AlbumShareModal from '$lib/modals/AlbumShareModal.svelte';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { downloadArchive } 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 { deleteAlbum, updateAlbumInfo, type AlbumResponseDto, type UpdateAlbumDto } from '@immich/sdk';
|
||||
import { modalManager, toastManager } from '@immich/ui';
|
||||
import {
|
||||
addAssetsToAlbum,
|
||||
addUsersToAlbum,
|
||||
deleteAlbum,
|
||||
updateAlbumInfo,
|
||||
type AlbumResponseDto,
|
||||
type AlbumUserAddDto,
|
||||
type UpdateAlbumDto,
|
||||
} from '@immich/sdk';
|
||||
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
|
||||
import { mdiPlusBoxOutline, mdiShareVariantOutline, mdiUpload } from '@mdi/js';
|
||||
import { type MessageFormatter } from 'svelte-i18n';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
export const getAlbumActions = ($t: MessageFormatter, album: AlbumResponseDto) => {
|
||||
const isOwned = get(user).id === album.ownerId;
|
||||
|
||||
const Share: ActionItem = {
|
||||
title: $t('share'),
|
||||
type: $t('command'),
|
||||
icon: mdiShareVariantOutline,
|
||||
$if: () => isOwned,
|
||||
onAction: () => modalManager.show(AlbumShareModal, { album }),
|
||||
};
|
||||
|
||||
return { Share };
|
||||
};
|
||||
|
||||
export const getAlbumAssetsActions = ($t: MessageFormatter, album: AlbumResponseDto, assets: TimelineAsset[]) => {
|
||||
const AddAssets: ActionItem = {
|
||||
title: $t('add_assets'),
|
||||
type: $t('command'),
|
||||
icon: mdiPlusBoxOutline,
|
||||
$if: () => assets.length > 0,
|
||||
onAction: () => addAssets(album, assets),
|
||||
};
|
||||
|
||||
const Upload: ActionItem = {
|
||||
title: $t('select_from_computer'),
|
||||
description: $t('album_upload_assets'),
|
||||
type: $t('command'),
|
||||
icon: mdiUpload,
|
||||
onAction: () => void openFileUploadDialog({ albumId: album.id }),
|
||||
};
|
||||
|
||||
return { AddAssets, Upload };
|
||||
};
|
||||
|
||||
const addAssets = async (album: AlbumResponseDto, assets: TimelineAsset[]) => {
|
||||
const $t = await getFormatter();
|
||||
const assetIds = assets.map(({ id }) => id);
|
||||
|
||||
try {
|
||||
const results = await addAssetsToAlbum({ id: album.id, bulkIdsDto: { ids: assetIds } });
|
||||
|
||||
const count = results.filter(({ success }) => success).length;
|
||||
toastManager.success($t('assets_added_count', { values: { count } }));
|
||||
eventManager.emit('AlbumAddAssets');
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.error_adding_assets_to_album'));
|
||||
}
|
||||
};
|
||||
|
||||
export const handleAddUsersToAlbum = async (album: AlbumResponseDto, albumUsers: AlbumUserAddDto[]) => {
|
||||
const $t = await getFormatter();
|
||||
|
||||
try {
|
||||
await addUsersToAlbum({ id: album.id, addUsersDto: { albumUsers } });
|
||||
eventManager.emit('AlbumShare');
|
||||
return true;
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.error_adding_users_to_album'));
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const handleUpdateAlbum = async ({ id }: { id: string }, dto: UpdateAlbumDto) => {
|
||||
const $t = await getFormatter();
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import ApiKeyCreateModal from '$lib/modals/ApiKeyCreateModal.svelte';
|
||||
import ApiKeySecretModal from '$lib/modals/ApiKeySecretModal.svelte';
|
||||
import ApiKeyUpdateModal from '$lib/modals/ApiKeyUpdateModal.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
@@ -56,14 +55,10 @@ export const handleCreateApiKey = async (dto: ApiKeyCreateDto) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const { apiKey, secret } = await createApiKey({ apiKeyCreateDto: dto });
|
||||
const response = await createApiKey({ apiKeyCreateDto: dto });
|
||||
eventManager.emit('ApiKeyCreate', response.apiKey);
|
||||
|
||||
eventManager.emit('ApiKeyCreate', apiKey);
|
||||
|
||||
// no nested modal
|
||||
void modalManager.show(ApiKeySecretModal, { secret });
|
||||
|
||||
return true;
|
||||
return response;
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_create_api_key'));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import { createJob, type JobCreateDto } from '@immich/sdk';
|
||||
import { toastManager } from '@immich/ui';
|
||||
|
||||
export const handleCreateJob = async (dto: JobCreateDto) => {
|
||||
const $t = await getFormatter();
|
||||
|
||||
try {
|
||||
await createJob({ jobCreateDto: dto });
|
||||
toastManager.success($t('admin.job_created'));
|
||||
return true;
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_submit_job'));
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import PersonEditBirthDateModal from '$lib/modals/PersonEditBirthDateModal.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import { updatePerson, type PersonResponseDto } from '@immich/sdk';
|
||||
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
|
||||
import { mdiCalendarEditOutline } from '@mdi/js';
|
||||
import type { MessageFormatter } from 'svelte-i18n';
|
||||
|
||||
export const getPersonActions = ($t: MessageFormatter, person: PersonResponseDto) => {
|
||||
const SetDateOfBirth: ActionItem = {
|
||||
title: $t('set_date_of_birth'),
|
||||
icon: mdiCalendarEditOutline,
|
||||
onAction: () => modalManager.show(PersonEditBirthDateModal, { person }),
|
||||
};
|
||||
|
||||
return { SetDateOfBirth };
|
||||
};
|
||||
|
||||
export const handleUpdatePersonBirthDate = async (person: PersonResponseDto, birthDate: string) => {
|
||||
const $t = await getFormatter();
|
||||
|
||||
try {
|
||||
const response = await updatePerson({ id: person.id, personUpdateDto: { birthDate } });
|
||||
toastManager.success($t('date_of_birth_saved'));
|
||||
eventManager.emit('PersonUpdate', response);
|
||||
return true;
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_save_date_of_birth'));
|
||||
}
|
||||
};
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
mdiLibraryShelves,
|
||||
mdiOcr,
|
||||
mdiPause,
|
||||
mdiPencil,
|
||||
mdiPlay,
|
||||
mdiPlus,
|
||||
mdiStateMachine,
|
||||
@@ -64,9 +65,7 @@ export const getQueuesActions = ($t: MessageFormatter, queues: QueueResponseDto[
|
||||
title: $t('admin.create_job'),
|
||||
type: $t('command'),
|
||||
shortcuts: { shift: true, key: 'n' },
|
||||
onAction: async () => {
|
||||
await modalManager.show(JobCreateModal, {});
|
||||
},
|
||||
onAction: () => modalManager.show(JobCreateModal, {}),
|
||||
};
|
||||
|
||||
const ManageConcurrency: ActionItem = {
|
||||
@@ -247,6 +246,10 @@ export const asQueueItem = ($t: MessageFormatter, queue: { name: QueueName }): Q
|
||||
icon: '',
|
||||
title: $t('integrity_checks'),
|
||||
},
|
||||
[QueueName.Editor]: {
|
||||
icon: mdiPencil,
|
||||
title: $t('editor'),
|
||||
},
|
||||
};
|
||||
|
||||
return items[queue.name];
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import TagCreateModal from '$lib/modals/TagCreateModal.svelte';
|
||||
import TagEditModal from '$lib/modals/TagEditModal.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import type { TreeNode } from '$lib/utils/tree-utils';
|
||||
import { deleteTag, updateTag, upsertTags, type TagUpdateDto } from '@immich/sdk';
|
||||
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
|
||||
import { mdiPencil, mdiPlus, mdiTrashCanOutline } from '@mdi/js';
|
||||
import { type MessageFormatter } from 'svelte-i18n';
|
||||
|
||||
export const getTagActions = ($t: MessageFormatter, tag: TreeNode) => {
|
||||
const Create: ActionItem = {
|
||||
title: $t('create_tag'),
|
||||
icon: mdiPlus,
|
||||
onAction: () => modalManager.show(TagCreateModal, { baseTag: tag }),
|
||||
};
|
||||
|
||||
const Update: ActionItem = {
|
||||
title: $t('edit_tag'),
|
||||
icon: mdiPencil,
|
||||
$if: () => tag.path.length > 0,
|
||||
onAction: () => modalManager.show(TagEditModal, { tag }),
|
||||
};
|
||||
|
||||
const Delete: ActionItem = {
|
||||
title: $t('delete_tag'),
|
||||
icon: mdiTrashCanOutline,
|
||||
$if: () => tag.path.length > 0,
|
||||
onAction: () => handleDeleteTag(tag),
|
||||
};
|
||||
|
||||
return { Create, Update, Delete };
|
||||
};
|
||||
|
||||
export const handleCreateTag = async (tagValue: string) => {
|
||||
const $t = await getFormatter();
|
||||
|
||||
try {
|
||||
const [tag] = await upsertTags({ tagUpsertDto: { tags: [tagValue] } });
|
||||
if (!tag) {
|
||||
return;
|
||||
}
|
||||
|
||||
toastManager.success($t('tag_created', { values: { tag: tag.value } }));
|
||||
eventManager.emit('TagCreate', tag);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.something_went_wrong'));
|
||||
}
|
||||
};
|
||||
|
||||
export const handleUpdateTag = async (tag: TreeNode, dto: TagUpdateDto) => {
|
||||
const $t = await getFormatter();
|
||||
|
||||
if (!tag.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await updateTag({ id: tag.id, tagUpdateDto: dto });
|
||||
|
||||
toastManager.success($t('tag_updated', { values: { tag: tag.value } }));
|
||||
eventManager.emit('TagUpdate', response);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.something_went_wrong'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteTag = async (tag: TreeNode) => {
|
||||
const $t = await getFormatter();
|
||||
|
||||
const tagId = tag.id;
|
||||
if (!tagId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = await modalManager.showDialog({
|
||||
title: $t('delete_tag'),
|
||||
prompt: $t('delete_tag_confirmation_prompt', { values: { tagName: tag.value } }),
|
||||
confirmText: $t('delete'),
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteTag({ id: tagId });
|
||||
eventManager.emit('TagDelete', tag);
|
||||
toastManager.success();
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.something_went_wrong'));
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import { changePassword, resetPinCode, type ChangePasswordDto, type PinCodeResetDto } from '@immich/sdk';
|
||||
import { toastManager } from '@immich/ui';
|
||||
|
||||
export const handleResetPinCode = async (dto: PinCodeResetDto) => {
|
||||
const $t = await getFormatter();
|
||||
|
||||
try {
|
||||
await resetPinCode({ pinCodeResetDto: dto });
|
||||
toastManager.success($t('pin_code_reset_successfully'));
|
||||
eventManager.emit('UserPinCodeReset');
|
||||
return true;
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.failed_to_reset_pin_code'));
|
||||
}
|
||||
};
|
||||
|
||||
export const handleChangePassword = async (dto: ChangePasswordDto) => {
|
||||
const $t = await getFormatter();
|
||||
|
||||
try {
|
||||
await changePassword({ changePasswordDto: dto });
|
||||
toastManager.success($t('updated_password'));
|
||||
return true;
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_change_password'));
|
||||
}
|
||||
};
|
||||
@@ -1,74 +1,4 @@
|
||||
import CropTool from '$lib/components/asset-viewer/editor/crop-tool/crop-tool.svelte';
|
||||
import { mdiCropRotate } from '@mdi/js';
|
||||
import { derived, get, writable } from 'svelte/store';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
//---------crop
|
||||
export const cropSettings = writable<CropSettings>({ x: 0, y: 0, width: 100, height: 100 });
|
||||
export const cropImageSize = writable([1000, 1000]);
|
||||
export const cropImageScale = writable(1);
|
||||
export const cropAspectRatio = writable<CropAspectRatio>('free');
|
||||
export const cropSettingsChanged = writable<boolean>(false);
|
||||
//---------rotate
|
||||
export const rotateDegrees = writable<number>(0);
|
||||
export const normaizedRorateDegrees = derived(rotateDegrees, (v) => {
|
||||
const newAngle = v % 360;
|
||||
return newAngle < 0 ? newAngle + 360 : newAngle;
|
||||
});
|
||||
export const changedOriention = derived(normaizedRorateDegrees, () => get(normaizedRorateDegrees) % 180 > 0);
|
||||
//-----other
|
||||
export const showCancelConfirmDialog = writable<boolean | CallableFunction>(false);
|
||||
export const lastChosenLocation = writable<{ lng: number; lat: number } | null>(null);
|
||||
|
||||
export const editTypes = [
|
||||
{
|
||||
name: 'crop',
|
||||
icon: mdiCropRotate,
|
||||
component: CropTool,
|
||||
changesFlag: cropSettingsChanged,
|
||||
},
|
||||
];
|
||||
|
||||
export function closeEditorCofirm(closeCallback: CallableFunction) {
|
||||
if (get(hasChanges)) {
|
||||
showCancelConfirmDialog.set(closeCallback);
|
||||
} else {
|
||||
closeCallback();
|
||||
}
|
||||
}
|
||||
|
||||
export const hasChanges = derived(
|
||||
editTypes.map((t) => t.changesFlag),
|
||||
($flags) => {
|
||||
return $flags.some(Boolean);
|
||||
},
|
||||
);
|
||||
|
||||
export function resetGlobalCropStore() {
|
||||
cropSettings.set({ x: 0, y: 0, width: 100, height: 100 });
|
||||
cropImageSize.set([1000, 1000]);
|
||||
cropImageScale.set(1);
|
||||
cropAspectRatio.set('free');
|
||||
cropSettingsChanged.set(false);
|
||||
showCancelConfirmDialog.set(false);
|
||||
rotateDegrees.set(0);
|
||||
}
|
||||
|
||||
export type CropAspectRatio =
|
||||
| '1:1'
|
||||
| '16:9'
|
||||
| '4:3'
|
||||
| '3:2'
|
||||
| '7:5'
|
||||
| '9:16'
|
||||
| '3:4'
|
||||
| '2:3'
|
||||
| '5:7'
|
||||
| 'free'
|
||||
| 'reset';
|
||||
|
||||
export type CropSettings = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
@@ -20,12 +20,18 @@ export type MemoryAsset = MemoryIndex & {
|
||||
};
|
||||
|
||||
class MemoryStoreSvelte {
|
||||
#loading: Promise<void> | undefined;
|
||||
|
||||
constructor() {
|
||||
eventManager.on('AuthLogout', () => this.clearCache());
|
||||
eventManager.on('AuthUserLoaded', () => void this.initialize());
|
||||
}
|
||||
|
||||
ready() {
|
||||
return this.initialize();
|
||||
}
|
||||
|
||||
memories = $state<MemoryResponseDto[]>([]);
|
||||
private initialized = false;
|
||||
private memoryAssets = $derived.by(() => {
|
||||
const memoryAssets: MemoryAsset[] = [];
|
||||
let previous: MemoryAsset | undefined;
|
||||
@@ -101,21 +107,20 @@ class MemoryStoreSvelte {
|
||||
}
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
this.initialized = true;
|
||||
|
||||
await this.loadAllMemories();
|
||||
}
|
||||
|
||||
clearCache() {
|
||||
this.initialized = false;
|
||||
private clearCache() {
|
||||
this.#loading = undefined;
|
||||
this.memories = [];
|
||||
}
|
||||
|
||||
private async loadAllMemories() {
|
||||
private initialize() {
|
||||
if (!this.#loading) {
|
||||
this.#loading = this.load();
|
||||
}
|
||||
|
||||
return this.#loading;
|
||||
}
|
||||
|
||||
private async load() {
|
||||
const memories = await searchMemories({ $for: asLocalTimeISO(DateTime.now()) });
|
||||
this.memories = memories.filter((memory) => memory.assets.length > 0);
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ export interface Events {
|
||||
on_notification: (notification: NotificationDto) => void;
|
||||
|
||||
AppRestartV1: (event: AppRestartEvent) => void;
|
||||
AssetEditReadyV1: (data: { assetId: string }) => void;
|
||||
}
|
||||
|
||||
const websocket: Socket<Events> = io({
|
||||
@@ -73,3 +74,25 @@ export const openWebsocketConnection = () => {
|
||||
export const closeWebsocketConnection = () => {
|
||||
websocket.disconnect();
|
||||
};
|
||||
|
||||
export const waitForWebsocketEvent = <T extends keyof Events>(
|
||||
event: T,
|
||||
predicate?: (...args: Parameters<Events[T]>) => boolean,
|
||||
timeout: number = 10_000,
|
||||
): Promise<Parameters<Events[T]>> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
// @ts-expect-error: The typings are weird on this?
|
||||
const cleanup = websocketEvents.on(event, (...args: Parameters<Events[T]>) => {
|
||||
if (!predicate || predicate(...args)) {
|
||||
cleanup();
|
||||
clearTimeout(timer);
|
||||
resolve(args);
|
||||
}
|
||||
});
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error(`Timeout waiting for event: ${String(event)}`));
|
||||
}, timeout);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -167,6 +167,7 @@ export const getQueueName = derived(t, ($t) => {
|
||||
[QueueName.Ocr]: $t('admin.machine_learning_ocr'),
|
||||
[QueueName.Workflow]: $t('workflows'),
|
||||
[QueueName.IntegrityCheck]: $t('integrity_checks'),
|
||||
[QueueName.Editor]: $t('editor'),
|
||||
};
|
||||
|
||||
return names[name];
|
||||
@@ -193,7 +194,7 @@ const createUrl = (path: string, parameters?: Record<string, unknown>) => {
|
||||
return getBaseUrl() + url.pathname + url.search + url.hash;
|
||||
};
|
||||
|
||||
type AssetUrlOptions = { id: string; cacheKey?: string | null };
|
||||
type AssetUrlOptions = { id: string; cacheKey?: string | null; edited?: boolean };
|
||||
|
||||
export const getAssetUrl = ({
|
||||
asset,
|
||||
@@ -233,16 +234,16 @@ export const getAssetOriginalUrl = (options: string | AssetUrlOptions) => {
|
||||
if (typeof options === 'string') {
|
||||
options = { id: options };
|
||||
}
|
||||
const { id, cacheKey } = options;
|
||||
return createUrl(getAssetOriginalPath(id), { ...authManager.params, c: cacheKey });
|
||||
const { id, cacheKey, edited = true } = options;
|
||||
return createUrl(getAssetOriginalPath(id), { ...authManager.params, c: cacheKey, edited });
|
||||
};
|
||||
|
||||
export const getAssetThumbnailUrl = (options: string | (AssetUrlOptions & { size?: AssetMediaSize })) => {
|
||||
if (typeof options === 'string') {
|
||||
options = { id: options };
|
||||
}
|
||||
const { id, size, cacheKey } = options;
|
||||
return createUrl(getAssetThumbnailPath(id), { ...authManager.params, size, c: cacheKey });
|
||||
const { id, size, cacheKey, edited = true } = options;
|
||||
return createUrl(getAssetThumbnailPath(id), { ...authManager.params, size, c: cacheKey, edited });
|
||||
};
|
||||
|
||||
export const getAssetPlaybackUrl = (options: string | AssetUrlOptions) => {
|
||||
|
||||
@@ -277,25 +277,18 @@ export function getFileSize(asset: AssetResponseDto, maxPrecision = 4): string {
|
||||
}
|
||||
|
||||
export function getAssetResolution(asset: AssetResponseDto): string {
|
||||
const { width, height } = getAssetRatio(asset);
|
||||
|
||||
if (width === 235 && height === 235) {
|
||||
if (!asset.width || !asset.height) {
|
||||
return 'Invalid Data';
|
||||
}
|
||||
|
||||
return `${width} x ${height}`;
|
||||
return `${asset.width} x ${asset.height}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns aspect ratio for the asset
|
||||
*/
|
||||
export function getAssetRatio(asset: AssetResponseDto) {
|
||||
let height = asset.exifInfo?.exifImageHeight || 235;
|
||||
let width = asset.exifInfo?.exifImageWidth || 235;
|
||||
if (isFlipped(asset.exifInfo?.orientation)) {
|
||||
[width, height] = [height, width];
|
||||
}
|
||||
return { width, height };
|
||||
return asset.width && asset.height ? asset.width / asset.height : null;
|
||||
}
|
||||
|
||||
// list of supported image extensions from https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Image_types excluding svg
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { purchaseStore } from '$lib/stores/purchase.store';
|
||||
import { preferences as preferences$, user as user$ } from '$lib/stores/user.store';
|
||||
import { userInteraction } from '$lib/stores/user.svelte';
|
||||
@@ -24,6 +25,8 @@ export const loadUser = async () => {
|
||||
user$.set(user);
|
||||
preferences$.set(preferences);
|
||||
|
||||
eventManager.emit('AuthUserLoaded', user);
|
||||
|
||||
// Check for license status
|
||||
if (serverInfo.licensed || user.license?.activatedAt) {
|
||||
purchaseStore.setPurchaseStatus(true);
|
||||
|
||||
@@ -49,8 +49,7 @@ function wasmLayoutFromTimeline(assets: TimelineAsset[], options: LayoutOptions)
|
||||
function wasmLayoutFromDto(assets: AssetResponseDto[], options: LayoutOptions) {
|
||||
const aspectRatios = new Float32Array(assets.length);
|
||||
for (let i = 0; i < assets.length; i++) {
|
||||
const { width, height } = getAssetRatio(assets[i]);
|
||||
aspectRatios[i] = width / height;
|
||||
aspectRatios[i] = getAssetRatio(assets[i]) ?? 1;
|
||||
}
|
||||
return new JustifiedLayout(aspectRatios, options);
|
||||
}
|
||||
@@ -111,7 +110,7 @@ export function justifiedLayout(assets: (TimelineAsset | AssetResponseDto)[], op
|
||||
};
|
||||
|
||||
const result = createJustifiedLayout(
|
||||
assets.map((asset) => (isTimelineAsset(asset) ? asset.ratio : getAssetRatio(asset))),
|
||||
assets.map((asset) => (isTimelineAsset(asset) ? asset.ratio : (getAssetRatio(asset) ?? 1))),
|
||||
adapter,
|
||||
);
|
||||
return new Adapter(result);
|
||||
|
||||
@@ -159,8 +159,7 @@ export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset):
|
||||
return unknownAsset;
|
||||
}
|
||||
const assetResponse = unknownAsset;
|
||||
const { width, height } = getAssetRatio(assetResponse);
|
||||
const ratio = width / height;
|
||||
const ratio = getAssetRatio(assetResponse) ?? 1;
|
||||
const city = assetResponse.exifInfo?.city;
|
||||
const country = assetResponse.exifInfo?.country;
|
||||
const people = assetResponse.people?.map((person) => person.name) || [];
|
||||
|
||||
+30
-107
@@ -8,6 +8,7 @@
|
||||
import AlbumTitle from '$lib/components/album-page/album-title.svelte';
|
||||
import ActivityStatus from '$lib/components/asset-viewer/activity-status.svelte';
|
||||
import ActivityViewer from '$lib/components/asset-viewer/activity-viewer.svelte';
|
||||
import HeaderActionButton from '$lib/components/HeaderActionButton.svelte';
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||
@@ -38,7 +39,12 @@
|
||||
import AlbumShareModal from '$lib/modals/AlbumShareModal.svelte';
|
||||
import AlbumUsersModal from '$lib/modals/AlbumUsersModal.svelte';
|
||||
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
|
||||
import { handleDeleteAlbum, handleDownloadAlbum } from '$lib/services/album.service';
|
||||
import {
|
||||
getAlbumActions,
|
||||
getAlbumAssetsActions,
|
||||
handleDeleteAlbum,
|
||||
handleDownloadAlbum,
|
||||
} from '$lib/services/album.service';
|
||||
import { getGlobalActions } from '$lib/services/app.service';
|
||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
@@ -46,7 +52,6 @@
|
||||
import { preferences, user } from '$lib/stores/user.store';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import { cancelMultiselect } from '$lib/utils/asset-utils';
|
||||
import { openFileUploadDialog } from '$lib/utils/file-uploader';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import {
|
||||
isAlbumsRoute,
|
||||
@@ -59,14 +64,11 @@
|
||||
AlbumUserRole,
|
||||
AssetOrder,
|
||||
AssetVisibility,
|
||||
addAssetsToAlbum,
|
||||
addUsersToAlbum,
|
||||
getAlbumInfo,
|
||||
updateAlbumInfo,
|
||||
type AlbumResponseDto,
|
||||
type AlbumUserAddDto,
|
||||
} from '@immich/sdk';
|
||||
import { Button, Icon, IconButton, modalManager, toastManager } from '@immich/ui';
|
||||
import { CommandPaletteDefaultProvider, Icon, IconButton, modalManager, toastManager } from '@immich/ui';
|
||||
import {
|
||||
mdiAccountEye,
|
||||
mdiAccountEyeOutline,
|
||||
@@ -80,8 +82,6 @@
|
||||
mdiLink,
|
||||
mdiPlus,
|
||||
mdiPresentationPlay,
|
||||
mdiShareVariantOutline,
|
||||
mdiUpload,
|
||||
} from '@mdi/js';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
@@ -101,7 +101,6 @@
|
||||
|
||||
let backUrl: string = $state(AppRoute.ALBUMS);
|
||||
let viewMode: AlbumPageViewMode = $state(AlbumPageViewMode.VIEW);
|
||||
let isCreatingSharedAlbum = $state(false);
|
||||
let albumOrder: AssetOrder | undefined = $state(data.album.order);
|
||||
|
||||
let timelineManager = $state<TimelineManager>() as TimelineManager;
|
||||
@@ -124,9 +123,7 @@
|
||||
|
||||
backUrl = url || AppRoute.ALBUMS;
|
||||
|
||||
if (backUrl === AppRoute.SHARING && album.albumUsers.length === 0 && !album.hasSharedLink) {
|
||||
isCreatingSharedAlbum = true;
|
||||
} else if (backUrl === AppRoute.SHARED_LINKS) {
|
||||
if (backUrl === AppRoute.SHARED_LINKS) {
|
||||
backUrl = history.state?.backUrl || AppRoute.ALBUMS;
|
||||
}
|
||||
});
|
||||
@@ -177,26 +174,6 @@
|
||||
const refreshAlbum = async () => {
|
||||
album = await getAlbumInfo({ id: album.id, withoutAssets: true });
|
||||
};
|
||||
const handleAddAssets = async () => {
|
||||
const assetIds = timelineInteraction.selectedAssets.map((asset) => asset.id);
|
||||
|
||||
try {
|
||||
const results = await addAssetsToAlbum({
|
||||
id: album.id,
|
||||
bulkIdsDto: { ids: assetIds },
|
||||
});
|
||||
|
||||
const count = results.filter(({ success }) => success).length;
|
||||
toastManager.success($t('assets_added_count', { values: { count } }));
|
||||
|
||||
await refreshAlbum();
|
||||
|
||||
timelineInteraction.clearMultiselect();
|
||||
await setModeToView();
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.error_adding_assets_to_album'));
|
||||
}
|
||||
};
|
||||
|
||||
const setModeToView = async () => {
|
||||
timelineManager.suspendTransitions = true;
|
||||
@@ -213,28 +190,6 @@
|
||||
await setModeToView();
|
||||
};
|
||||
|
||||
const handleSelectFromComputer = async () => {
|
||||
await openFileUploadDialog({ albumId: album.id });
|
||||
timelineInteraction.clearMultiselect();
|
||||
await setModeToView();
|
||||
};
|
||||
|
||||
const handleAddUsers = async (albumUsers: AlbumUserAddDto[]) => {
|
||||
try {
|
||||
await addUsersToAlbum({
|
||||
id: album.id,
|
||||
addUsersDto: {
|
||||
albumUsers,
|
||||
},
|
||||
});
|
||||
await refreshAlbum();
|
||||
|
||||
viewMode = AlbumPageViewMode.VIEW;
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.error_adding_users_to_album'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetVisibility = (assetIds: string[]) => {
|
||||
timelineManager.removeAssets(assetIds);
|
||||
assetInteraction.clearMultiselect();
|
||||
@@ -353,22 +308,6 @@
|
||||
viewMode === AlbumPageViewMode.SELECT_ASSETS ? timelineInteraction : assetInteraction,
|
||||
);
|
||||
|
||||
const handleShare = async () => {
|
||||
const result = await modalManager.show(AlbumShareModal, { album });
|
||||
|
||||
switch (result?.action) {
|
||||
case 'sharedLink': {
|
||||
await handleShareLink();
|
||||
return;
|
||||
}
|
||||
|
||||
case 'sharedUsers': {
|
||||
await handleAddUsers(result.data);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onSharedLinkCreate = async () => {
|
||||
await refreshAlbum();
|
||||
};
|
||||
@@ -380,10 +319,6 @@
|
||||
}
|
||||
};
|
||||
|
||||
const handleShareLink = async () => {
|
||||
await modalManager.show(SharedLinkCreateModal, { albumId: album.id });
|
||||
};
|
||||
|
||||
const handleEditUsers = async () => {
|
||||
const changed = await modalManager.show(AlbumUsersModal, { album });
|
||||
|
||||
@@ -405,7 +340,7 @@
|
||||
break;
|
||||
}
|
||||
case 'shareUser': {
|
||||
await handleShare();
|
||||
await modalManager.show(AlbumShareModal, { album });
|
||||
break;
|
||||
}
|
||||
case 'refreshAlbum': {
|
||||
@@ -415,10 +350,24 @@
|
||||
}
|
||||
};
|
||||
|
||||
const onAlbumAddAssets = async () => {
|
||||
await refreshAlbum();
|
||||
timelineInteraction.clearMultiselect();
|
||||
await setModeToView();
|
||||
};
|
||||
|
||||
const onAlbumShare = async () => {
|
||||
await refreshAlbum();
|
||||
await setModeToView();
|
||||
};
|
||||
|
||||
const { Cast } = $derived(getGlobalActions($t));
|
||||
const { Share } = $derived(getAlbumActions($t, album));
|
||||
const { AddAssets, Upload } = $derived(getAlbumAssetsActions($t, album, timelineInteraction.selectedAssets));
|
||||
</script>
|
||||
|
||||
<OnEvents {onSharedLinkCreate} {onAlbumDelete} />
|
||||
<OnEvents {onSharedLinkCreate} {onAlbumDelete} {onAlbumAddAssets} {onAlbumShare} />
|
||||
<CommandPaletteDefaultProvider name={$t('album')} actions={[AddAssets, Upload]} />
|
||||
|
||||
<div class="flex overflow-hidden" use:scrollMemoryClearer={{ routeStartsWith: AppRoute.ALBUMS }}>
|
||||
<div class="relative w-full shrink">
|
||||
@@ -463,7 +412,7 @@
|
||||
size="medium"
|
||||
shape="round"
|
||||
icon={mdiLink}
|
||||
onclick={handleShareLink}
|
||||
onclick={() => modalManager.show(SharedLinkCreateModal, { albumId: album.id })}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -491,16 +440,7 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if isOwned}
|
||||
<IconButton
|
||||
shape="round"
|
||||
color="secondary"
|
||||
size="medium"
|
||||
icon={mdiPlus}
|
||||
onclick={handleShare}
|
||||
aria-label={$t('add_more_users')}
|
||||
/>
|
||||
{/if}
|
||||
<ActionButton action={Share} />
|
||||
</div>
|
||||
{/if}
|
||||
<!-- ALBUM DESCRIPTION -->
|
||||
@@ -616,16 +556,7 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if isOwned}
|
||||
<IconButton
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
aria-label={$t('share')}
|
||||
onclick={handleShare}
|
||||
icon={mdiShareVariantOutline}
|
||||
/>
|
||||
{/if}
|
||||
<ActionButton action={Share} />
|
||||
|
||||
{#if featureFlagsManager.value.map}
|
||||
<AlbumMap {album} />
|
||||
@@ -682,12 +613,6 @@
|
||||
{/if}
|
||||
</ButtonContextMenu>
|
||||
{/if}
|
||||
|
||||
{#if isCreatingSharedAlbum && album.albumUsers.length === 0}
|
||||
<Button size="small" disabled={album.assetCount === 0} onclick={handleShare}>
|
||||
{$t('share')}
|
||||
</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</ControlAppBar>
|
||||
{/if}
|
||||
@@ -705,10 +630,8 @@
|
||||
{/snippet}
|
||||
|
||||
{#snippet trailing()}
|
||||
<Button variant="ghost" leadingIcon={mdiUpload} onclick={handleSelectFromComputer}
|
||||
>{$t('select_from_computer')}</Button
|
||||
>
|
||||
<Button disabled={!timelineInteraction.selectionActive} onclick={handleAddAssets}>{$t('done')}</Button>
|
||||
<HeaderActionButton action={Upload} />
|
||||
<HeaderActionButton action={AddAssets} />
|
||||
{/snippet}
|
||||
</ControlAppBar>
|
||||
{/if}
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
import PeopleInfiniteScroll from '$lib/components/faces-page/people-infinite-scroll.svelte';
|
||||
import SearchPeople from '$lib/components/faces-page/people-search.svelte';
|
||||
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import { ActionQueryParameterValue, AppRoute, QueryParameter, SessionStorageKey } from '$lib/constants';
|
||||
import PersonEditBirthDateModal from '$lib/modals/PersonEditBirthDateModal.svelte';
|
||||
import PersonMergeSuggestionModal from '$lib/modals/PersonMergeSuggestionModal.svelte';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { websocketEvents } from '$lib/stores/websocket';
|
||||
@@ -210,21 +210,6 @@
|
||||
);
|
||||
};
|
||||
|
||||
const handleChangeBirthDate = async (person: PersonResponseDto) => {
|
||||
const updatedPerson = await modalManager.show(PersonEditBirthDateModal, { person });
|
||||
|
||||
if (!updatedPerson) {
|
||||
return;
|
||||
}
|
||||
|
||||
people = people.map((person: PersonResponseDto) => {
|
||||
if (person.id === updatedPerson.id) {
|
||||
return updatedPerson;
|
||||
}
|
||||
return person;
|
||||
});
|
||||
};
|
||||
|
||||
const onResetSearchBar = async () => {
|
||||
await clearQueryParam(QueryParameter.SEARCHED_PEOPLE, $page.url);
|
||||
};
|
||||
@@ -293,10 +278,21 @@
|
||||
(person) => person.name.toLowerCase() === name.toLowerCase() && person.id !== personId && person.name,
|
||||
);
|
||||
};
|
||||
|
||||
const onPersonUpdate = (response: PersonResponseDto) => {
|
||||
people = people.map((person: PersonResponseDto) => {
|
||||
if (person.id === response.id) {
|
||||
return response;
|
||||
}
|
||||
return person;
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:window bind:innerHeight />
|
||||
|
||||
<OnEvents {onPersonUpdate} />
|
||||
|
||||
<UserPageLayout
|
||||
title={$t('people')}
|
||||
description={countVisiblePeople === 0 && !searchName ? undefined : `(${countVisiblePeople.toLocaleString($locale)})`}
|
||||
@@ -353,7 +349,6 @@
|
||||
>
|
||||
<PeopleCard
|
||||
{person}
|
||||
onSetBirthDate={() => handleChangeBirthDate(person)}
|
||||
onMergePeople={() => handleMergePeople(person)}
|
||||
onHidePerson={() => handleHidePerson(person)}
|
||||
onToggleFavorite={() => handleToggleFavorite(person)}
|
||||
|
||||
+18
-36
@@ -4,10 +4,12 @@
|
||||
import { clickOutside } from '$lib/actions/click-outside';
|
||||
import { listNavigation } from '$lib/actions/list-navigation';
|
||||
import { scrollMemoryClearer } from '$lib/actions/scroll-memory';
|
||||
import ActionMenuItem from '$lib/components/ActionMenuItem.svelte';
|
||||
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
|
||||
import EditNameInput from '$lib/components/faces-page/edit-name-input.svelte';
|
||||
import MergeFaceSelector from '$lib/components/faces-page/merge-face-selector.svelte';
|
||||
import UnMergeFaceSelector from '$lib/components/faces-page/unmerge-face-selector.svelte';
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
|
||||
@@ -28,8 +30,8 @@
|
||||
import { AppRoute, PersonPageViewMode, QueryParameter, SessionStorageKey } from '$lib/constants';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import PersonEditBirthDateModal from '$lib/modals/PersonEditBirthDateModal.svelte';
|
||||
import PersonMergeSuggestionModal from '$lib/modals/PersonMergeSuggestionModal.svelte';
|
||||
import { getPersonActions } from '$lib/services/person.service';
|
||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
@@ -38,19 +40,12 @@
|
||||
import { getPeopleThumbnailUrl } from '$lib/utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { isExternalUrl } from '$lib/utils/navigation';
|
||||
import {
|
||||
AssetVisibility,
|
||||
getPersonStatistics,
|
||||
searchPerson,
|
||||
updatePerson,
|
||||
type PersonResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { AssetVisibility, searchPerson, updatePerson, type PersonResponseDto } from '@immich/sdk';
|
||||
import { LoadingSpinner, modalManager, toastManager } from '@immich/ui';
|
||||
import {
|
||||
mdiAccountBoxOutline,
|
||||
mdiAccountMultipleCheckOutline,
|
||||
mdiArrowLeft,
|
||||
mdiCalendarEditOutline,
|
||||
mdiDotsVertical,
|
||||
mdiEyeOffOutline,
|
||||
mdiEyeOutline,
|
||||
@@ -69,7 +64,7 @@
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
let numberOfAssets = $state(data.statistics.assets);
|
||||
let numberOfAssets = $derived(data.statistics.assets);
|
||||
let { isViewing: showAssetViewer } = assetViewingStore;
|
||||
|
||||
let timelineManager = $state<TimelineManager>() as TimelineManager;
|
||||
@@ -79,7 +74,6 @@
|
||||
let viewMode: PersonPageViewMode = $state(PersonPageViewMode.VIEW_ASSETS);
|
||||
let isEditingName = $state(false);
|
||||
let previousRoute: string = $state(AppRoute.EXPLORE);
|
||||
let people: PersonResponseDto[] = [];
|
||||
let personMerge1: PersonResponseDto | undefined = $state();
|
||||
let personMerge2: PersonResponseDto | undefined = $state();
|
||||
let potentialMergePeople: PersonResponseDto[] = $state([]);
|
||||
@@ -129,12 +123,7 @@
|
||||
};
|
||||
|
||||
const updateAssetCount = async () => {
|
||||
try {
|
||||
const { assets } = await getPersonStatistics({ id: person.id });
|
||||
numberOfAssets = assets;
|
||||
} catch (error) {
|
||||
handleError(error, "Can't update the asset count");
|
||||
}
|
||||
await invalidateAll();
|
||||
};
|
||||
|
||||
afterNavigate(({ from }) => {
|
||||
@@ -223,9 +212,8 @@
|
||||
return { merged: false };
|
||||
}
|
||||
|
||||
const [personToMerge, personToBeMergedInto] = result;
|
||||
const [, personToBeMergedInto] = result;
|
||||
|
||||
people = people.filter((person: PersonResponseDto) => person.id !== personToMerge.id);
|
||||
if (personToBeMergedInto.name != personName && person.id === personToBeMergedInto.id) {
|
||||
await updateAssetCount();
|
||||
return { merged: true };
|
||||
@@ -309,22 +297,6 @@
|
||||
await changeName();
|
||||
};
|
||||
|
||||
const handleSetBirthDate = async () => {
|
||||
const updatedPerson = await modalManager.show(PersonEditBirthDateModal, { person });
|
||||
|
||||
if (!updatedPerson) {
|
||||
return;
|
||||
}
|
||||
|
||||
person = updatedPerson;
|
||||
people = people.map((person: PersonResponseDto) => {
|
||||
if (person.id === updatedPerson.id) {
|
||||
return updatedPerson;
|
||||
}
|
||||
return person;
|
||||
});
|
||||
};
|
||||
|
||||
const handleGoBack = async () => {
|
||||
viewMode = PersonPageViewMode.VIEW_ASSETS;
|
||||
if ($page.url.searchParams.has(QueryParameter.ACTION)) {
|
||||
@@ -351,8 +323,18 @@
|
||||
timelineManager.removeAssets(assetIds);
|
||||
assetInteraction.clearMultiselect();
|
||||
};
|
||||
|
||||
const onPersonUpdate = (response: PersonResponseDto) => {
|
||||
if (person.id === response.id) {
|
||||
return (person = response);
|
||||
}
|
||||
};
|
||||
|
||||
const { SetDateOfBirth } = $derived(getPersonActions($t, person));
|
||||
</script>
|
||||
|
||||
<OnEvents {onPersonUpdate} onAssetsDelete={updateAssetCount} onAssetsArchive={updateAssetCount} />
|
||||
|
||||
<main
|
||||
class="relative z-0 h-dvh overflow-hidden px-2 md:px-6 md:pt-(--navbar-height-md) pt-(--navbar-height)"
|
||||
use:scrollMemoryClearer={{
|
||||
@@ -535,7 +517,7 @@
|
||||
icon={person.isHidden ? mdiEyeOutline : mdiEyeOffOutline}
|
||||
onClick={() => toggleHidePerson()}
|
||||
/>
|
||||
<MenuOption text={$t('set_date_of_birth')} icon={mdiCalendarEditOutline} onClick={handleSetBirthDate} />
|
||||
<ActionMenuItem action={SetDateOfBirth} />
|
||||
<MenuOption
|
||||
text={$t('merge_people')}
|
||||
icon={mdiAccountMultipleCheckOutline}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { beforeNavigate } from '$app/navigation';
|
||||
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
|
||||
import MemoryLane from '$lib/components/photos-page/memory-lane.svelte';
|
||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
||||
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
|
||||
import AddToAlbum from '$lib/components/timeline/actions/AddToAlbumAction.svelte';
|
||||
@@ -21,12 +20,14 @@
|
||||
import TagAction from '$lib/components/timeline/actions/TagAction.svelte';
|
||||
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
|
||||
import Timeline from '$lib/components/timeline/Timeline.svelte';
|
||||
import { AssetAction } from '$lib/constants';
|
||||
import { AppRoute, AssetAction, QueryParameter } from '$lib/constants';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||
import { memoryStore } from '$lib/stores/memory.store.svelte';
|
||||
import { preferences, user } from '$lib/stores/user.store';
|
||||
import { getAssetThumbnailUrl, memoryLaneTitle } from '$lib/utils';
|
||||
import {
|
||||
updateStackedAssetInTimeline,
|
||||
updateUnstackedAssetInTimeline,
|
||||
@@ -34,10 +35,11 @@
|
||||
type OnUnlink,
|
||||
} from '$lib/utils/actions';
|
||||
import { openFileUploadDialog } from '$lib/utils/file-uploader';
|
||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { AssetVisibility } from '@immich/sdk';
|
||||
|
||||
import { ImageCarousel } from '@immich/ui';
|
||||
import { mdiDotsVertical, mdiPlus } from '@mdi/js';
|
||||
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
let { isViewing: showAssetViewer } = assetViewingStore;
|
||||
@@ -57,6 +59,9 @@
|
||||
|
||||
return assetInteraction.isAllUserOwned && (isLivePhoto || isLivePhotoCandidate);
|
||||
});
|
||||
|
||||
const isAllUserOwned = $derived($user && selectedAssets.every((asset) => asset.ownerId === $user.id));
|
||||
|
||||
const handleEscape = () => {
|
||||
if ($showAssetViewer) {
|
||||
return;
|
||||
@@ -85,6 +90,16 @@
|
||||
beforeNavigate(() => {
|
||||
isFaceEditMode.value = false;
|
||||
});
|
||||
|
||||
const items = $derived(
|
||||
memoryStore.memories.map((memory) => ({
|
||||
id: memory.id,
|
||||
title: $memoryLaneTitle(memory),
|
||||
href: `${AppRoute.MEMORY}?${QueryParameter.ID}=${memory.assets[0].id}`,
|
||||
alt: $t('memory_lane_title', { values: { title: $getAltText(toTimelineAsset(memory.assets[0])) } }),
|
||||
src: getAssetThumbnailUrl(memory.assets[0].id),
|
||||
})),
|
||||
);
|
||||
</script>
|
||||
|
||||
<UserPageLayout hideNavbar={assetInteraction.selectionActive} showUploadButton scrollbar={false}>
|
||||
@@ -98,7 +113,7 @@
|
||||
withStacked
|
||||
>
|
||||
{#if $preferences.memories.enabled}
|
||||
<MemoryLane />
|
||||
<ImageCarousel {items} />
|
||||
{/if}
|
||||
{#snippet empty()}
|
||||
<EmptyPlaceholder text={$t('no_assets_message')} onClick={() => openFileUploadDialog()} class="mt-10 mx-auto" />
|
||||
@@ -118,45 +133,51 @@
|
||||
<AddToAlbum />
|
||||
<AddToAlbum shared />
|
||||
</ButtonContextMenu>
|
||||
<FavoriteAction
|
||||
removeFavorite={assetInteraction.isAllFavorite}
|
||||
onFavorite={(ids, isFavorite) => timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))}
|
||||
></FavoriteAction>
|
||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
||||
<DownloadAction menuItem />
|
||||
{#if assetInteraction.selectedAssets.length > 1 || isAssetStackSelected}
|
||||
<StackAction
|
||||
unstack={isAssetStackSelected}
|
||||
onStack={(result) => updateStackedAssetInTimeline(timelineManager, result)}
|
||||
onUnstack={(assets) => updateUnstackedAssetInTimeline(timelineManager, assets)}
|
||||
/>
|
||||
{/if}
|
||||
{#if isLinkActionAvailable}
|
||||
<LinkLivePhotoAction
|
||||
|
||||
{#if isAllUserOwned}
|
||||
<FavoriteAction
|
||||
removeFavorite={assetInteraction.isAllFavorite}
|
||||
onFavorite={(ids, isFavorite) => timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))}
|
||||
/>
|
||||
|
||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
||||
<DownloadAction menuItem />
|
||||
{#if assetInteraction.selectedAssets.length > 1 || isAssetStackSelected}
|
||||
<StackAction
|
||||
unstack={isAssetStackSelected}
|
||||
onStack={(result) => updateStackedAssetInTimeline(timelineManager, result)}
|
||||
onUnstack={(assets) => updateUnstackedAssetInTimeline(timelineManager, assets)}
|
||||
/>
|
||||
{/if}
|
||||
{#if isLinkActionAvailable}
|
||||
<LinkLivePhotoAction
|
||||
menuItem
|
||||
unlink={assetInteraction.selectedAssets.length === 1}
|
||||
onLink={handleLink}
|
||||
onUnlink={handleUnlink}
|
||||
/>
|
||||
{/if}
|
||||
<ChangeDate menuItem />
|
||||
<ChangeDescription menuItem />
|
||||
<ChangeLocation menuItem />
|
||||
<ArchiveAction
|
||||
menuItem
|
||||
unlink={assetInteraction.selectedAssets.length === 1}
|
||||
onLink={handleLink}
|
||||
onUnlink={handleUnlink}
|
||||
onArchive={(ids, visibility) => timelineManager.update(ids, (asset) => (asset.visibility = visibility))}
|
||||
/>
|
||||
{/if}
|
||||
<ChangeDate menuItem />
|
||||
<ChangeDescription menuItem />
|
||||
<ChangeLocation menuItem />
|
||||
<ArchiveAction
|
||||
menuItem
|
||||
onArchive={(ids, visibility) => timelineManager.update(ids, (asset) => (asset.visibility = visibility))}
|
||||
/>
|
||||
{#if $preferences.tags.enabled}
|
||||
<TagAction menuItem />
|
||||
{/if}
|
||||
<DeleteAssets
|
||||
menuItem
|
||||
onAssetDelete={(assetIds) => timelineManager.removeAssets(assetIds)}
|
||||
onUndoDelete={(assets) => timelineManager.upsertAssets(assets)}
|
||||
/>
|
||||
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
|
||||
<hr />
|
||||
<AssetJobActions />
|
||||
</ButtonContextMenu>
|
||||
{#if $preferences.tags.enabled}
|
||||
<TagAction menuItem />
|
||||
{/if}
|
||||
<DeleteAssets
|
||||
menuItem
|
||||
onAssetDelete={(assetIds) => timelineManager.removeAssets(assetIds)}
|
||||
onUndoDelete={(assets) => timelineManager.upsertAssets(assets)}
|
||||
/>
|
||||
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
|
||||
<hr />
|
||||
<AssetJobActions />
|
||||
</ButtonContextMenu>
|
||||
{:else}
|
||||
<DownloadAction />
|
||||
{/if}
|
||||
</AssetSelectControlBar>
|
||||
{/if}
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { lang, locale } from '$lib/stores/preferences.store';
|
||||
import { preferences } from '$lib/stores/user.store';
|
||||
import { preferences, user } from '$lib/stores/user.store';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import { cancelMultiselect } from '$lib/utils/asset-utils';
|
||||
import { parseUtcDate } from '$lib/utils/date-time';
|
||||
@@ -71,6 +71,10 @@
|
||||
let smartSearchEnabled = $derived(featureFlagsManager.value.smartSearch);
|
||||
let terms = $derived(searchQuery ? JSON.parse(searchQuery) : {});
|
||||
|
||||
const isAllUserOwned = $derived(
|
||||
$user && assetInteraction.selectedAssets.every((asset) => asset.ownerId === $user.id),
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
// we want this to *only* be reactive on `terms`
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
@@ -258,64 +262,6 @@
|
||||
<svelte:window bind:scrollY />
|
||||
<svelte:document use:shortcut={{ shortcut: { key: 'Escape' }, onShortcut: onEscape }} />
|
||||
|
||||
<section>
|
||||
{#if assetInteraction.selectionActive}
|
||||
<div class="fixed top-0 start-0 w-full">
|
||||
<AssetSelectControlBar
|
||||
assets={assetInteraction.selectedAssets}
|
||||
clearSelect={() => cancelMultiselect(assetInteraction)}
|
||||
>
|
||||
<CreateSharedLink />
|
||||
<IconButton
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
aria-label={$t('select_all')}
|
||||
icon={mdiSelectAll}
|
||||
onclick={handleSelectAll}
|
||||
/>
|
||||
<ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
|
||||
<AddToAlbum {onAddToAlbum} />
|
||||
<AddToAlbum shared {onAddToAlbum} />
|
||||
</ButtonContextMenu>
|
||||
<FavoriteAction
|
||||
removeFavorite={assetInteraction.isAllFavorite}
|
||||
onFavorite={(assetIds, isFavorite) => {
|
||||
for (const assetId of assetIds) {
|
||||
const asset = searchResultAssets.find((searchAsset) => searchAsset.id === assetId);
|
||||
if (asset) {
|
||||
asset.isFavorite = isFavorite;
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
||||
<DownloadAction menuItem />
|
||||
<ChangeDate menuItem />
|
||||
<ChangeLocation menuItem />
|
||||
<ArchiveAction menuItem unarchive={assetInteraction.isAllArchived} />
|
||||
{#if $preferences.tags.enabled && assetInteraction.isAllUserOwned}
|
||||
<TagAction menuItem />
|
||||
{/if}
|
||||
<DeleteAssets menuItem {onAssetDelete} onUndoDelete={onSearchQueryUpdate} />
|
||||
<hr />
|
||||
<AssetJobActions />
|
||||
</ButtonContextMenu>
|
||||
</AssetSelectControlBar>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="fixed top-0 start-0 w-full">
|
||||
<ControlAppBar onClose={() => goto(previousRoute)} backIcon={mdiArrowLeft}>
|
||||
<div class="absolute bg-light"></div>
|
||||
<div class="w-full flex-1 ps-4">
|
||||
<SearchBar grayTheme={false} value={terms?.query ?? ''} searchQuery={terms} />
|
||||
</div>
|
||||
</ControlAppBar>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if terms}
|
||||
<section
|
||||
id="search-chips"
|
||||
@@ -419,34 +365,38 @@
|
||||
<AddToAlbum {onAddToAlbum} />
|
||||
<AddToAlbum shared {onAddToAlbum} />
|
||||
</ButtonContextMenu>
|
||||
<FavoriteAction
|
||||
removeFavorite={assetInteraction.isAllFavorite}
|
||||
onFavorite={(ids, isFavorite) => {
|
||||
for (const id of ids) {
|
||||
const asset = searchResultAssets.find((asset) => asset.id === id);
|
||||
if (asset) {
|
||||
asset.isFavorite = isFavorite;
|
||||
{#if isAllUserOwned}
|
||||
<FavoriteAction
|
||||
removeFavorite={assetInteraction.isAllFavorite}
|
||||
onFavorite={(ids, isFavorite) => {
|
||||
for (const id of ids) {
|
||||
const asset = searchResultAssets.find((asset) => asset.id === id);
|
||||
if (asset) {
|
||||
asset.isFavorite = isFavorite;
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
}}
|
||||
/>
|
||||
|
||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
||||
<DownloadAction menuItem />
|
||||
<ChangeDate menuItem />
|
||||
<ChangeDescription menuItem />
|
||||
<ChangeLocation menuItem />
|
||||
<ArchiveAction menuItem unarchive={assetInteraction.isAllArchived} />
|
||||
{#if assetInteraction.isAllUserOwned}
|
||||
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
|
||||
{/if}
|
||||
{#if $preferences.tags.enabled && assetInteraction.isAllUserOwned}
|
||||
<TagAction menuItem />
|
||||
{/if}
|
||||
<DeleteAssets menuItem {onAssetDelete} onUndoDelete={onSearchQueryUpdate} />
|
||||
<hr />
|
||||
<AssetJobActions />
|
||||
</ButtonContextMenu>
|
||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
||||
<DownloadAction menuItem />
|
||||
<ChangeDate menuItem />
|
||||
<ChangeDescription menuItem />
|
||||
<ChangeLocation menuItem />
|
||||
<ArchiveAction menuItem unarchive={assetInteraction.isAllArchived} />
|
||||
{#if assetInteraction.isAllUserOwned}
|
||||
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
|
||||
{/if}
|
||||
{#if $preferences.tags.enabled && assetInteraction.isAllUserOwned}
|
||||
<TagAction menuItem />
|
||||
{/if}
|
||||
<DeleteAssets menuItem {onAssetDelete} onUndoDelete={onSearchQueryUpdate} />
|
||||
<hr />
|
||||
<AssetJobActions />
|
||||
</ButtonContextMenu>
|
||||
{:else}
|
||||
<DownloadAction />
|
||||
{/if}
|
||||
</AssetSelectControlBar>
|
||||
</div>
|
||||
{:else}
|
||||
|
||||
@@ -1,24 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import UserPageLayout, { headerId } from '$lib/components/layouts/user-page-layout.svelte';
|
||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
||||
import Breadcrumbs from '$lib/components/shared-components/tree/breadcrumbs.svelte';
|
||||
import TreeItemThumbnails from '$lib/components/shared-components/tree/tree-item-thumbnails.svelte';
|
||||
import TreeItems from '$lib/components/shared-components/tree/tree-items.svelte';
|
||||
import Sidebar from '$lib/components/sidebar/sidebar.svelte';
|
||||
import Timeline from '$lib/components/timeline/Timeline.svelte';
|
||||
import { AppRoute, AssetAction, QueryParameter } from '$lib/constants';
|
||||
import SkipLink from '$lib/elements/SkipLink.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import TagCreateModal from '$lib/modals/TagCreateModal.svelte';
|
||||
import TagEditModal from '$lib/modals/TagEditModal.svelte';
|
||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
import { joinPaths, TreeNode } from '$lib/utils/tree-utils';
|
||||
import { deleteTag, getAllTags, type TagResponseDto } from '@immich/sdk';
|
||||
import { Button, HStack, modalManager, Text } from '@immich/ui';
|
||||
import { mdiDotsVertical, mdiPencil, mdiPlus, mdiTag, mdiTagMultiple, mdiTrashCanOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { PageData } from './$types';
|
||||
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
|
||||
import Timeline from '$lib/components/timeline/Timeline.svelte';
|
||||
import AddToAlbum from '$lib/components/timeline/actions/AddToAlbumAction.svelte';
|
||||
import ArchiveAction from '$lib/components/timeline/actions/ArchiveAction.svelte';
|
||||
import ChangeDate from '$lib/components/timeline/actions/ChangeDateAction.svelte';
|
||||
@@ -31,8 +21,17 @@
|
||||
import SelectAllAssets from '$lib/components/timeline/actions/SelectAllAction.svelte';
|
||||
import SetVisibilityAction from '$lib/components/timeline/actions/SetVisibilityAction.svelte';
|
||||
import TagAction from '$lib/components/timeline/actions/TagAction.svelte';
|
||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
||||
import { AppRoute, AssetAction, QueryParameter } from '$lib/constants';
|
||||
import SkipLink from '$lib/elements/SkipLink.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import { getTagActions } from '$lib/services/tag.service';
|
||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
import { preferences, user } from '$lib/stores/user.store';
|
||||
import { joinPaths, TreeNode } from '$lib/utils/tree-utils';
|
||||
import { getAllTags, type TagResponseDto } from '@immich/sdk';
|
||||
import { mdiDotsVertical, mdiPlus, mdiTag, mdiTagMultiple } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
interface Props {
|
||||
data: PageData;
|
||||
@@ -59,49 +58,29 @@
|
||||
|
||||
const navigateToView = (path: string) => goto(getLink(path));
|
||||
|
||||
const handleCreate = async () => {
|
||||
await modalManager.show(TagCreateModal, { baseTag: tag });
|
||||
tags = await getAllTags();
|
||||
};
|
||||
|
||||
const handleEdit = async () => {
|
||||
if (!tag) {
|
||||
return;
|
||||
}
|
||||
|
||||
await modalManager.show(TagEditModal, { tag });
|
||||
tags = await getAllTags();
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!tag) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isConfirm = await modalManager.showDialog({
|
||||
title: $t('delete_tag'),
|
||||
prompt: $t('delete_tag_confirmation_prompt', { values: { tagName: tag.value } }),
|
||||
confirmText: $t('delete'),
|
||||
});
|
||||
|
||||
if (!isConfirm) {
|
||||
return;
|
||||
}
|
||||
|
||||
await deleteTag({ id: tag.id! });
|
||||
tags = await getAllTags();
|
||||
|
||||
// navigate to parent
|
||||
await navigateToView(tag.parent ? tag.parent.path : '');
|
||||
};
|
||||
|
||||
const handleSetVisibility = (assetIds: string[]) => {
|
||||
timelineManager.removeAssets(assetIds);
|
||||
assetInteraction.clearMultiselect();
|
||||
};
|
||||
|
||||
const onRefresh = async () => {
|
||||
tags = await getAllTags();
|
||||
};
|
||||
|
||||
const onTagDelete = async (response: TreeNode) => {
|
||||
if (response.path === tag.path) {
|
||||
await navigateToView(tag.parent ? tag.parent.path : '');
|
||||
}
|
||||
|
||||
await onRefresh();
|
||||
};
|
||||
|
||||
const { Create, Update, Delete } = $derived(getTagActions($t, tag));
|
||||
</script>
|
||||
|
||||
<UserPageLayout title={data.meta.title}>
|
||||
<OnEvents onTagCreate={onRefresh} onTagUpdate={onRefresh} {onTagDelete} />
|
||||
|
||||
<UserPageLayout title={data.meta.title} actions={[Create, Update, Delete]}>
|
||||
{#snippet sidebar()}
|
||||
<Sidebar>
|
||||
<SkipLink target={`#${headerId}`} text={$t('skip_to_tags')} breakpoint="md" />
|
||||
@@ -114,23 +93,6 @@
|
||||
</Sidebar>
|
||||
{/snippet}
|
||||
|
||||
{#snippet buttons()}
|
||||
<HStack>
|
||||
<Button leadingIcon={mdiPlus} onclick={handleCreate} size="small" variant="ghost" color="secondary">
|
||||
<Text class="hidden md:block">{$t('create_tag')}</Text>
|
||||
</Button>
|
||||
|
||||
{#if tag.path.length > 0}
|
||||
<Button leadingIcon={mdiPencil} onclick={handleEdit} size="small" variant="ghost" color="secondary">
|
||||
<Text class="hidden md:block">{$t('edit_tag')}</Text>
|
||||
</Button>
|
||||
<Button leadingIcon={mdiTrashCanOutline} onclick={handleDelete} size="small" variant="ghost" color="secondary">
|
||||
<Text class="hidden md:block">{$t('delete_tag')}</Text>
|
||||
</Button>
|
||||
{/if}
|
||||
</HStack>
|
||||
{/snippet}
|
||||
|
||||
<Breadcrumbs node={tag} icon={mdiTagMultiple} title={$t('tags')} {getLink} />
|
||||
|
||||
<section class="mt-2 h-[calc(100%-(--spacing(20)))] overflow-auto immich-scrollbar">
|
||||
|
||||
@@ -49,6 +49,8 @@
|
||||
toast_info_title: $t('info'),
|
||||
toast_warning_title: $t('warning'),
|
||||
toast_danger_title: $t('error'),
|
||||
navigate_next: $t('next'),
|
||||
navigate_previous: $t('previous'),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -5,19 +5,7 @@
|
||||
import { handleCreateUserAdmin } from '$lib/services/user-admin.service';
|
||||
import { userInteraction } from '$lib/stores/user.svelte';
|
||||
import { ByteUnit, convertToBytes } from '$lib/utils/byte-units';
|
||||
import {
|
||||
Button,
|
||||
Field,
|
||||
HelperText,
|
||||
HStack,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
PasswordInput,
|
||||
Stack,
|
||||
Switch,
|
||||
} from '@immich/ui';
|
||||
import { Field, FormModal, HelperText, Input, PasswordInput, Stack, Switch } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
let success = $state(false);
|
||||
@@ -73,61 +61,48 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
<Modal title={$t('create_new_user')} {onClose} size="small">
|
||||
<ModalBody>
|
||||
<form onsubmit={onSubmit} autocomplete="off" id="create-new-user-form">
|
||||
{#if success}
|
||||
<p class="text-sm text-immich-primary">{$t('new_user_created')}</p>
|
||||
<FormModal title={$t('create_new_user')} size="small" disabled={!valid} submitText={$t('create')} {onClose} {onSubmit}>
|
||||
{#if success}
|
||||
<p class="text-sm text-immich-primary">{$t('new_user_created')}</p>
|
||||
{/if}
|
||||
|
||||
<Stack gap={4}>
|
||||
<Field label={$t('email')} required>
|
||||
<Input bind:value={email} type="email" />
|
||||
</Field>
|
||||
|
||||
{#if featureFlagsManager.value.email}
|
||||
<Field label={$t('admin.send_welcome_email')}>
|
||||
<Switch id="send-welcome-email" bind:checked={notify} class="text-sm" />
|
||||
</Field>
|
||||
{/if}
|
||||
|
||||
<Field label={$t('password')} required={!featureFlagsManager.value.oauth}>
|
||||
<PasswordInput id="password" bind:value={password} autocomplete="new-password" />
|
||||
</Field>
|
||||
|
||||
<Field label={$t('confirm_password')} required={!featureFlagsManager.value.oauth}>
|
||||
<PasswordInput id="confirmPassword" bind:value={passwordConfirm} autocomplete="new-password" />
|
||||
<HelperText color="danger">{passwordMismatchMessage}</HelperText>
|
||||
</Field>
|
||||
|
||||
<Field label={$t('admin.require_password_change_on_login')}>
|
||||
<Switch id="require-password-change" bind:checked={shouldChangePassword} class="text-sm text-start" />
|
||||
</Field>
|
||||
|
||||
<Field label={$t('name')} required>
|
||||
<Input bind:value={name} />
|
||||
</Field>
|
||||
|
||||
<Field label={$t('admin.quota_size_gib')}>
|
||||
<Input bind:value={quotaSize} type="number" placeholder={$t('unlimited')} min="0" step="1" />
|
||||
{#if quotaSizeWarning}
|
||||
<HelperText color="danger">{$t('errors.quota_higher_than_disk_size')}</HelperText>
|
||||
{/if}
|
||||
</Field>
|
||||
|
||||
<Stack gap={4}>
|
||||
<Field label={$t('email')} required>
|
||||
<Input bind:value={email} type="email" />
|
||||
</Field>
|
||||
|
||||
{#if featureFlagsManager.value.email}
|
||||
<Field label={$t('admin.send_welcome_email')}>
|
||||
<Switch id="send-welcome-email" bind:checked={notify} class="text-sm" />
|
||||
</Field>
|
||||
{/if}
|
||||
|
||||
<Field label={$t('password')} required={!featureFlagsManager.value.oauth}>
|
||||
<PasswordInput id="password" bind:value={password} autocomplete="new-password" />
|
||||
</Field>
|
||||
|
||||
<Field label={$t('confirm_password')} required={!featureFlagsManager.value.oauth}>
|
||||
<PasswordInput id="confirmPassword" bind:value={passwordConfirm} autocomplete="new-password" />
|
||||
<HelperText color="danger">{passwordMismatchMessage}</HelperText>
|
||||
</Field>
|
||||
|
||||
<Field label={$t('admin.require_password_change_on_login')}>
|
||||
<Switch id="require-password-change" bind:checked={shouldChangePassword} class="text-sm text-start" />
|
||||
</Field>
|
||||
|
||||
<Field label={$t('name')} required>
|
||||
<Input bind:value={name} />
|
||||
</Field>
|
||||
|
||||
<Field label={$t('admin.quota_size_gib')}>
|
||||
<Input bind:value={quotaSize} type="number" placeholder={$t('unlimited')} min="0" step="1" />
|
||||
{#if quotaSizeWarning}
|
||||
<HelperText color="danger">{$t('errors.quota_higher_than_disk_size')}</HelperText>
|
||||
{/if}
|
||||
</Field>
|
||||
|
||||
<Field label={$t('admin.admin_user')}>
|
||||
<Switch bind:checked={isAdmin} />
|
||||
</Field>
|
||||
</Stack>
|
||||
</form>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<HStack fullWidth>
|
||||
<Button color="secondary" fullWidth onclick={() => onClose()} shape="round">{$t('cancel')}</Button>
|
||||
<Button type="submit" disabled={!valid} fullWidth shape="round" form="create-new-user-form"
|
||||
>{$t('create')}
|
||||
</Button>
|
||||
</HStack>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
<Field label={$t('admin.admin_user')}>
|
||||
<Switch bind:checked={isAdmin} />
|
||||
</Field>
|
||||
</Stack>
|
||||
</FormModal>
|
||||
|
||||
@@ -5,19 +5,7 @@
|
||||
import { user as authUser } from '$lib/stores/user.store';
|
||||
import { userInteraction } from '$lib/stores/user.svelte';
|
||||
import { ByteUnit, convertFromBytes, convertToBytes } from '$lib/utils/byte-units';
|
||||
import {
|
||||
Button,
|
||||
Field,
|
||||
HStack,
|
||||
Input,
|
||||
Link,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
NumberInput,
|
||||
Switch,
|
||||
Text,
|
||||
} from '@immich/ui';
|
||||
import { Field, FormModal, Input, Link, NumberInput, Switch, Text } from '@immich/ui';
|
||||
import { mdiAccountEditOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { PageData } from './$types';
|
||||
@@ -69,49 +57,36 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
<Modal title={$t('edit_user')} size="small" icon={mdiAccountEditOutline} {onClose}>
|
||||
<ModalBody>
|
||||
<form onsubmit={onSubmit} autocomplete="off" id="edit-user-form">
|
||||
<Field label={$t('email')} required>
|
||||
<Input type="email" bind:value={email} />
|
||||
</Field>
|
||||
<FormModal title={$t('edit_user')} size="small" icon={mdiAccountEditOutline} {onClose} {onSubmit}>
|
||||
<Field label={$t('email')} required>
|
||||
<Input type="email" bind:value={email} />
|
||||
</Field>
|
||||
|
||||
<Field label={$t('name')} required class="mt-4">
|
||||
<Input bind:value={name} />
|
||||
</Field>
|
||||
<Field label={$t('name')} required class="mt-4">
|
||||
<Input bind:value={name} />
|
||||
</Field>
|
||||
|
||||
<Field label={$t('admin.quota_size_gib')} class="mt-4">
|
||||
<NumberInput bind:value={quotaSize} min="0" step="1" placeholder={$t('unlimited')} />
|
||||
{#if quotaSizeWarning}
|
||||
<Text size="small" color="danger">{$t('errors.quota_higher_than_disk_size')}</Text>
|
||||
{/if}
|
||||
</Field>
|
||||
<Field label={$t('admin.quota_size_gib')} class="mt-4">
|
||||
<NumberInput bind:value={quotaSize} min="0" step="1" placeholder={$t('unlimited')} />
|
||||
{#if quotaSizeWarning}
|
||||
<Text size="small" color="danger">{$t('errors.quota_higher_than_disk_size')}</Text>
|
||||
{/if}
|
||||
</Field>
|
||||
|
||||
<Field label={$t('storage_label')} class="mt-4">
|
||||
<Input bind:value={storageLabel} />
|
||||
</Field>
|
||||
<Field label={$t('storage_label')} class="mt-4">
|
||||
<Input bind:value={storageLabel} />
|
||||
</Field>
|
||||
|
||||
<Text size="small" class="mt-2" color="muted">
|
||||
{$t('admin.note_apply_storage_label_previous_assets')}
|
||||
<Link href={AppRoute.ADMIN_QUEUES}>
|
||||
{$t('admin.storage_template_migration_job')}
|
||||
</Link>
|
||||
</Text>
|
||||
<Text size="small" class="mt-2" color="muted">
|
||||
{$t('admin.note_apply_storage_label_previous_assets')}
|
||||
<Link href={AppRoute.ADMIN_QUEUES}>
|
||||
{$t('admin.storage_template_migration_job')}
|
||||
</Link>
|
||||
</Text>
|
||||
|
||||
{#if user.id !== $authUser.id}
|
||||
<Field label={$t('admin.admin_user')}>
|
||||
<Switch bind:checked={isAdmin} class="mt-4" />
|
||||
</Field>
|
||||
{/if}
|
||||
</form>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<HStack fullWidth>
|
||||
<Button shape="round" color="secondary" fullWidth form="edit-user-form" onclick={() => onClose()}
|
||||
>{$t('cancel')}</Button
|
||||
>
|
||||
<Button type="submit" shape="round" fullWidth form="edit-user-form">{$t('confirm')}</Button>
|
||||
</HStack>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
{#if user.id !== $authUser.id}
|
||||
<Field label={$t('admin.admin_user')}>
|
||||
<Switch bind:checked={isAdmin} class="mt-4" />
|
||||
</Field>
|
||||
{/if}
|
||||
</FormModal>
|
||||
|
||||
@@ -28,6 +28,8 @@ export const assetFactory = Sync.makeFactory<AssetResponseDto>({
|
||||
isOffline: Sync.each(() => faker.datatype.boolean()),
|
||||
hasMetadata: Sync.each(() => faker.datatype.boolean()),
|
||||
visibility: AssetVisibility.Timeline,
|
||||
width: faker.number.int({ min: 100, max: 1000 }),
|
||||
height: faker.number.int({ min: 100, max: 1000 }),
|
||||
});
|
||||
|
||||
export const timelineAssetFactory = Sync.makeFactory<TimelineAsset>({
|
||||
|
||||
Reference in New Issue
Block a user