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

This commit is contained in:
izzy
2026-01-13 09:21:09 +00:00
271 changed files with 20486 additions and 3940 deletions
@@ -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}
@@ -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,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,47 +0,0 @@
<script lang="ts">
import FormatMessage from '$lib/elements/FormatMessage.svelte';
import { showDeleteModal } from '$lib/stores/preferences.store';
import { Checkbox, ConfirmModal, Label } from '@immich/ui';
import { mdiDeleteForeverOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
size: number;
onConfirm: () => void;
onCancel: () => void;
}
let { size, onConfirm, onCancel }: Props = $props();
let checked = $state(false);
const handleConfirm = () => {
if (checked) {
$showDeleteModal = false;
}
onConfirm();
};
</script>
<ConfirmModal
title={$t('permanently_delete_assets_count', { values: { count: size } })}
confirmText={$t('delete')}
icon={mdiDeleteForeverOutline}
onClose={(confirmed) => (confirmed ? handleConfirm() : onCancel())}
>
{#snippet promptSnippet()}
<p>
<FormatMessage key="permanently_delete_assets_prompt" values={{ count: size }}>
{#snippet children({ message })}
<b>{message}</b>
{/snippet}
</FormatMessage>
</p>
<p><b>{$t('cannot_undo_this_action')}</b></p>
<div class="pt-4 flex justify-center items-center gap-2">
<Checkbox id="confirm-deletion-input" bind:checked color="secondary" />
<Label label={$t('do_not_show_again')} for="confirm-deletion-input" />
</div>
{/snippet}
</ConfirmModal>
@@ -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>