merge main

This commit is contained in:
Alex Tran
2025-11-20 20:12:30 +00:00
283 changed files with 10731 additions and 2599 deletions

View File

@@ -0,0 +1,35 @@
<script lang="ts">
import { handleError } from '$lib/utils/handle-error';
import { MaintenanceAction, setMaintenanceMode } from '@immich/sdk';
import { Button } from '@immich/ui';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
interface Props {
disabled?: boolean;
}
let { disabled = false }: Props = $props();
async function start() {
try {
await setMaintenanceMode({
setMaintenanceModeDto: {
action: MaintenanceAction.Start,
},
});
} catch (error) {
handleError(error, $t('admin.maintenance_start_error'));
}
}
</script>
<div>
<div in:fade={{ duration: 500 }}>
<div class="ms-4 mt-4 flex items-end gap-4">
<Button shape="round" type="submit" {disabled} size="small" onclick={start}
>{$t('admin.maintenance_start')}</Button
>
</div>
</div>
</div>

View File

@@ -25,12 +25,12 @@
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { AppRoute } from '$lib/constants';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { handleReplaceAsset } from '$lib/services/asset.service';
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
import { user } from '$lib/stores/user.store';
import { photoZoomState } from '$lib/stores/zoom-image.store';
import { getAssetJobName, getSharedLink } from '$lib/utils';
import { canCopyImageToClipboard } from '$lib/utils/asset-utils';
import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import {
AssetJobName,
@@ -227,7 +227,7 @@
<ArchiveAction {asset} {onAction} {preAction} />
<MenuOption
icon={mdiUpload}
onClick={() => openFileUploadDialog({ multiple: false, assetId: asset.id })}
onClick={() => handleReplaceAsset(asset.id)}
text={$t('replace_with_upload')}
/>
{#if !asset.isArchived && !asset.isTrashed}

View File

@@ -1,16 +1,19 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { focusTrap } from '$lib/actions/focus-trap';
import type { Action, OnAction, PreAction } from '$lib/components/asset-viewer/actions/action';
import MotionPhotoAction from '$lib/components/asset-viewer/actions/motion-photo-action.svelte';
import NextAssetAction from '$lib/components/asset-viewer/actions/next-asset-action.svelte';
import PreviousAssetAction from '$lib/components/asset-viewer/actions/previous-asset-action.svelte';
import AssetViewerNavBar from '$lib/components/asset-viewer/asset-viewer-nav-bar.svelte';
import { AssetAction, ProjectionType } from '$lib/constants';
import OnEvents from '$lib/components/OnEvents.svelte';
import { AppRoute, AssetAction, ProjectionType } from '$lib/constants';
import { activityManager } from '$lib/managers/activity-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
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, isShowDetail } from '$lib/stores/preferences.store';
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { user } from '$lib/stores/user.store';
@@ -42,6 +45,7 @@
import CropArea from './editor/crop-tool/crop-area.svelte';
import EditorPanel from './editor/editor-panel.svelte';
import ImagePanoramaViewer from './image-panorama-viewer.svelte';
import OcrButton from './ocr-button.svelte';
import PhotoViewer from './photo-viewer.svelte';
import SlideshowBar from './slideshow-bar.svelte';
import VideoViewer from './video-wrapper-viewer.svelte';
@@ -363,6 +367,15 @@
selectedEditType = type;
};
const handleAssetReplace = async ({ oldAssetId, newAssetId }: { oldAssetId: string; newAssetId: string }) => {
if (oldAssetId !== asset.id) {
return;
}
await new Promise((promise) => setTimeout(promise, 500));
await goto(`${AppRoute.PHOTOS}/${newAssetId}`);
};
let isFullScreen = $derived(fullscreenElement !== null);
$effect(() => {
@@ -381,13 +394,19 @@
handlePromiseError(activityManager.init(album.id, asset.id));
}
});
let currentAssetId = $derived(asset.id);
$effect(() => {
if (asset.id) {
handlePromiseError(handleGetAllAlbums());
if (currentAssetId) {
untrack(() => handlePromiseError(handleGetAllAlbums()));
ocrManager.clear();
handlePromiseError(ocrManager.getAssetOcr(currentAssetId));
}
});
</script>
<OnEvents onAssetReplace={handleAssetReplace} />
<svelte:document bind:fullscreenElement />
<section
@@ -522,6 +541,7 @@
{playOriginalVideo}
/>
{/if}
{#if $slideshowState === SlideshowState.None && isShared && ((album && album.isActivityEnabled) || activityManager.commentCount > 0) && !activityManager.isLoading}
<div class="absolute bottom-0 end-0 mb-20 me-8">
<ActivityStatus
@@ -534,6 +554,12 @@
/>
</div>
{/if}
{#if $slideshowState === SlideshowState.None && asset.type === AssetTypeEnum.Image && !isShowEditor && ocrManager.hasOcrData}
<div class="absolute bottom-0 end-0 mb-6 me-6">
<OcrButton />
</div>
{/if}
{/key}
{/if}
</div>

View File

@@ -503,7 +503,7 @@
{/if}
{#if albums.length > 0}
<section class="px-6 pt-6 dark:text-immich-dark-fg">
<section class="px-6 py-6 dark:text-immich-dark-fg">
<p class="uppercase pb-4 text-sm">{$t('appears_in')}</p>
{#each albums as album (album.id)}
<a href={resolve(`${AppRoute.ALBUMS}/${album.id}`)}>

View File

@@ -0,0 +1,36 @@
<script lang="ts">
import type { OcrBox } from '$lib/utils/ocr-utils';
import { calculateBoundingBoxDimensions } from '$lib/utils/ocr-utils';
type Props = {
ocrBox: OcrBox;
};
let { ocrBox }: Props = $props();
const dimensions = $derived(calculateBoundingBoxDimensions(ocrBox.points));
const transform = $derived(
`translate(${dimensions.minX}px, ${dimensions.minY}px) rotate(${dimensions.rotation}deg) skew(${dimensions.skewX}deg, ${dimensions.skewY}deg)`,
);
const transformOrigin = $derived(
`${dimensions.centerX - dimensions.minX}px ${dimensions.centerY - dimensions.minY}px`,
);
</script>
<div class="absolute group left-0 top-0 pointer-events-none">
<!-- Bounding box with CSS transforms -->
<div
class="absolute border-2 border-blue-500 bg-blue-500/10 cursor-pointer pointer-events-auto transition-all group-hover:bg-blue-500/30 group-hover:border-blue-600 group-hover:border-[3px]"
style="width: {dimensions.width}px; height: {dimensions.height}px; transform: {transform}; transform-origin: {transformOrigin};"
></div>
<!-- Text overlay - always rendered but invisible, allows text selection and copy -->
<div
class="absolute flex items-center justify-center text-transparent text-sm px-2 py-1 pointer-events-auto cursor-text whitespace-pre-wrap wrap-break-word select-text group-hover:text-white group-hover:bg-black/75 group-hover:z-10"
style="width: {dimensions.width}px; height: {dimensions.height}px; transform: {transform}; transform-origin: {transformOrigin};"
>
{ocrBox.text}
</div>
</div>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { ocrManager } from '$lib/stores/ocr.svelte';
import { IconButton } from '@immich/ui';
import { mdiTextRecognition } from '@mdi/js';
import { t } from 'svelte-i18n';
</script>
<IconButton
title={ocrManager.showOverlay ? $t('hide_text_recognition') : $t('show_text_recognition')}
icon={mdiTextRecognition}
class={"dark {ocrStore.showOverlay ? 'bg-immich-primary text-white dark' : 'dark'}"}
color="secondary"
variant="ghost"
shape="round"
aria-label={$t('text_recognition')}
onclick={() => ocrManager.toggleOcrBoundingBox()}
/>

View File

@@ -1,7 +1,7 @@
import { getAnimateMock } from '$lib/__mocks__/animate.mock';
import PhotoViewer from '$lib/components/asset-viewer/photo-viewer.svelte';
import * as utils from '$lib/utils';
import { AssetMediaSize } from '@immich/sdk';
import { AssetMediaSize, AssetTypeEnum } from '@immich/sdk';
import { assetFactory } from '@test-data/factories/asset-factory';
import { sharedLinkFactory } from '@test-data/factories/shared-link-factory';
import { render } from '@testing-library/svelte';
@@ -65,7 +65,11 @@ describe('PhotoViewer component', () => {
});
it('loads the thumbnail', () => {
const asset = assetFactory.build({ originalPath: 'image.jpg', originalMimeType: 'image/jpeg' });
const asset = assetFactory.build({
originalPath: 'image.jpg',
originalMimeType: 'image/jpeg',
type: AssetTypeEnum.Image,
});
render(PhotoViewer, { asset });
expect(getAssetThumbnailUrlSpy).toBeCalledWith({
@@ -76,16 +80,89 @@ describe('PhotoViewer component', () => {
expect(getAssetOriginalUrlSpy).not.toBeCalled();
});
it('loads the original image for gifs', () => {
const asset = assetFactory.build({ originalPath: 'image.gif', originalMimeType: 'image/gif' });
it('loads the thumbnail image for static gifs', () => {
const asset = assetFactory.build({
originalPath: 'image.gif',
originalMimeType: 'image/gif',
type: AssetTypeEnum.Image,
});
render(PhotoViewer, { asset });
expect(getAssetThumbnailUrlSpy).toBeCalledWith({
id: asset.id,
size: AssetMediaSize.Preview,
cacheKey: asset.thumbhash,
});
expect(getAssetOriginalUrlSpy).not.toBeCalled();
});
it('loads the thumbnail image for static webp images', () => {
const asset = assetFactory.build({
originalPath: 'image.webp',
originalMimeType: 'image/webp',
type: AssetTypeEnum.Image,
});
render(PhotoViewer, { asset });
expect(getAssetThumbnailUrlSpy).toBeCalledWith({
id: asset.id,
size: AssetMediaSize.Preview,
cacheKey: asset.thumbhash,
});
expect(getAssetOriginalUrlSpy).not.toBeCalled();
});
it('loads the original image for animated gifs', () => {
const asset = assetFactory.build({
originalPath: 'image.gif',
originalMimeType: 'image/gif',
type: AssetTypeEnum.Image,
duration: '2.0',
});
render(PhotoViewer, { asset });
expect(getAssetThumbnailUrlSpy).not.toBeCalled();
expect(getAssetOriginalUrlSpy).toBeCalledWith({ id: asset.id, cacheKey: asset.thumbhash });
});
it('loads original for shared link when download permission is true and showMetadata permission is true', () => {
const asset = assetFactory.build({ originalPath: 'image.gif', originalMimeType: 'image/gif' });
it('loads the original image for animated webp images', () => {
const asset = assetFactory.build({
originalPath: 'image.webp',
originalMimeType: 'image/webp',
type: AssetTypeEnum.Image,
duration: '2.0',
});
render(PhotoViewer, { asset });
expect(getAssetThumbnailUrlSpy).not.toBeCalled();
expect(getAssetOriginalUrlSpy).toBeCalledWith({ id: asset.id, cacheKey: asset.thumbhash });
});
it('not loads original static image in shared link even when download permission is true and showMetadata permission is true', () => {
const asset = assetFactory.build({
originalPath: 'image.gif',
originalMimeType: 'image/gif',
type: AssetTypeEnum.Image,
});
const sharedLink = sharedLinkFactory.build({ allowDownload: true, showMetadata: true, assets: [asset] });
render(PhotoViewer, { asset, sharedLink });
expect(getAssetThumbnailUrlSpy).toBeCalledWith({
id: asset.id,
size: AssetMediaSize.Preview,
cacheKey: asset.thumbhash,
});
expect(getAssetOriginalUrlSpy).not.toBeCalled();
});
it('loads original animated image in shared link when download permission is true and showMetadata permission is true', () => {
const asset = assetFactory.build({
originalPath: 'image.gif',
originalMimeType: 'image/gif',
type: AssetTypeEnum.Image,
duration: '2.0',
});
const sharedLink = sharedLinkFactory.build({ allowDownload: true, showMetadata: true, assets: [asset] });
render(PhotoViewer, { asset, sharedLink });
@@ -93,8 +170,13 @@ describe('PhotoViewer component', () => {
expect(getAssetOriginalUrlSpy).toBeCalledWith({ id: asset.id, cacheKey: asset.thumbhash });
});
it('not loads original image when shared link download permission is false', () => {
const asset = assetFactory.build({ originalPath: 'image.gif', originalMimeType: 'image/gif' });
it('not loads original animated image when shared link download permission is false', () => {
const asset = assetFactory.build({
originalPath: 'image.gif',
originalMimeType: 'image/gif',
type: AssetTypeEnum.Image,
duration: '2.0',
});
const sharedLink = sharedLinkFactory.build({ allowDownload: false, assets: [asset] });
render(PhotoViewer, { asset, sharedLink });
@@ -107,8 +189,13 @@ describe('PhotoViewer component', () => {
expect(getAssetOriginalUrlSpy).not.toBeCalled();
});
it('not loads original image when shared link showMetadata permission is false', () => {
const asset = assetFactory.build({ originalPath: 'image.gif', originalMimeType: 'image/gif' });
it('not loads original animated image when shared link showMetadata permission is false', () => {
const asset = assetFactory.build({
originalPath: 'image.gif',
originalMimeType: 'image/gif',
type: AssetTypeEnum.Image,
duration: '2.0',
});
const sharedLink = sharedLinkFactory.build({ showMetadata: false, assets: [asset] });
render(PhotoViewer, { asset, sharedLink });

View File

@@ -2,12 +2,14 @@
import { shortcuts } from '$lib/actions/shortcut';
import { zoomImageAction } from '$lib/actions/zoom-image';
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
import OcrBoundingBox from '$lib/components/asset-viewer/ocr-bounding-box.svelte';
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
import { assetViewerFadeDuration } from '$lib/constants';
import { castManager } from '$lib/managers/cast-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { ocrManager } from '$lib/stores/ocr.svelte';
import { boundingBoxesArray } from '$lib/stores/people.store';
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store';
@@ -15,11 +17,12 @@
import { getAssetOriginalUrl, getAssetThumbnailUrl, handlePromiseError } from '$lib/utils';
import { canCopyImageToClipboard, copyImageToClipboard, isWebCompatibleImage } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils';
import { getBoundingBox } from '$lib/utils/people-utils';
import { cancelImageUrl } from '$lib/utils/sw-messaging';
import { getAltText } from '$lib/utils/thumbnail-util';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { AssetMediaSize, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk';
import { AssetMediaSize, AssetTypeEnum, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk';
import { LoadingSpinner, toastManager } from '@immich/ui';
import { onDestroy, onMount } from 'svelte';
import { useSwipe, type SwipeCustomEvent } from 'svelte-gestures';
@@ -71,6 +74,14 @@
$boundingBoxesArray = [];
});
let ocrBoxes = $derived(
ocrManager.showOverlay && $photoViewerImgElement
? getOcrBoundingBoxes(ocrManager.data, $photoZoomState, $photoViewerImgElement)
: [],
);
let isOcrActive = $derived(ocrManager.showOverlay);
const preload = (targetSize: AssetMediaSize | 'original', preloadAssets?: TimelineAsset[]) => {
for (const preloadAsset of preloadAssets || []) {
if (preloadAsset.isImage) {
@@ -130,16 +141,25 @@
if ($photoZoomState.currentZoom > 1) {
return;
}
if (ocrManager.showOverlay) {
return;
}
if (onNextAsset && event.detail.direction === 'left') {
onNextAsset();
}
if (onPreviousAsset && event.detail.direction === 'right') {
onPreviousAsset();
}
};
// when true, will force loading of the original image
let forceUseOriginal: boolean = $derived(asset.originalMimeType === 'image/gif' || $photoZoomState.currentZoom > 1);
let forceUseOriginal: boolean = $derived(
(asset.type === AssetTypeEnum.Image && asset.duration && !asset.duration.includes('0:00:00.000')) ||
$photoZoomState.currentZoom > 1,
);
const targetImageSize = $derived.by(() => {
if ($alwaysLoadOriginalFile || forceUseOriginal || originalImageLoaded) {
@@ -232,7 +252,7 @@
</div>
{:else if !imageError}
<div
use:zoomImageAction
use:zoomImageAction={{ disabled: isOcrActive }}
{...useSwipe(onSwipe)}
class="h-full w-full"
transition:fade={{ duration: haveFadeTransition ? assetViewerFadeDuration : 0 }}
@@ -261,6 +281,10 @@
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
></div>
{/each}
{#each ocrBoxes as ocrBox (ocrBox.id)}
<OcrBoundingBox {ocrBox} />
{/each}
</div>
{#if isFaceEditMode.value}

View File

@@ -282,7 +282,7 @@
</div>
{/if}
{#if asset.isImage && asset.duration}
{#if asset.isImage && asset.duration && !asset.duration.includes('0:00:00.000')}
<div class="absolute end-0 top-0 flex place-items-center gap-1 text-xs font-medium text-white">
<span class="pe-2 pt-2">
<Icon icon={mdiFileGifBox} size="24" />
@@ -351,7 +351,7 @@
playbackOnIconHover={!$playVideoThumbnailOnHover}
/>
</div>
{:else if asset.isImage && asset.duration && mouseOver}
{:else if asset.isImage && asset.duration && !asset.duration.includes('0:00:00.000') && mouseOver}
<!-- GIF -->
<div class="absolute top-0 h-full w-full pointer-events-none">
<div class="absolute h-full w-full bg-linear-to-b from-black/25 via-[transparent_25%]"></div>

View File

@@ -1,207 +0,0 @@
<script lang="ts">
import LibraryImportPathModal from '$lib/modals/LibraryImportPathModal.svelte';
import { handleError } from '$lib/utils/handle-error';
import type { ValidateLibraryImportPathResponseDto } from '@immich/sdk';
import { validate, type LibraryResponseDto } from '@immich/sdk';
import { Button, Icon, IconButton, modalManager, toastManager } from '@immich/ui';
import { mdiAlertOutline, mdiCheckCircleOutline, mdiPencilOutline, mdiRefresh } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
interface Props {
library: LibraryResponseDto;
onCancel: () => void;
onSubmit: (library: LibraryResponseDto) => void;
}
let { library = $bindable(), onCancel, onSubmit }: Props = $props();
let validatedPaths: ValidateLibraryImportPathResponseDto[] = $state([]);
let importPaths = $derived(validatedPaths.map((validatedPath) => validatedPath.importPath));
onMount(async () => {
if (library.importPaths) {
await handleValidation();
} else {
library.importPaths = [];
}
});
const handleValidation = async () => {
if (library.importPaths) {
const validation = await validate({
id: library.id,
validateLibraryDto: { importPaths: library.importPaths },
});
validatedPaths = validation.importPaths ?? [];
}
};
const revalidate = async (notifyIfSuccessful = true) => {
await handleValidation();
let failedPaths = 0;
for (const validatedPath of validatedPaths) {
if (!validatedPath.isValid) {
failedPaths++;
}
}
if (failedPaths === 0) {
if (notifyIfSuccessful) {
toastManager.success($t('admin.paths_validated_successfully'));
}
} else {
toastManager.warning($t('errors.paths_validation_failed', { values: { paths: failedPaths } }));
}
};
const handleAddImportPath = async (importPathToAdd: string | null) => {
if (!importPathToAdd) {
return;
}
if (!library.importPaths) {
library.importPaths = [];
}
try {
// Check so that import path isn't duplicated
if (!library.importPaths.includes(importPathToAdd)) {
library.importPaths.push(importPathToAdd);
await revalidate(false);
}
} catch (error) {
handleError(error, $t('errors.unable_to_add_import_path'));
}
};
const handleEditImportPath = async (editedImportPath: string | null, pathIndexToEdit: number) => {
if (editedImportPath === null) {
return;
}
if (!library.importPaths) {
library.importPaths = [];
}
try {
// Check so that import path isn't duplicated
if (!library.importPaths.includes(editedImportPath)) {
// Update import path
library.importPaths[pathIndexToEdit] = editedImportPath;
await revalidate(false);
}
} catch (error) {
handleError(error, $t('errors.unable_to_edit_import_path'));
}
};
const handleDeleteImportPath = async (pathIndexToDelete?: number) => {
if (pathIndexToDelete === undefined) {
return;
}
try {
if (!library.importPaths) {
library.importPaths = [];
}
const pathToDelete = library.importPaths[pathIndexToDelete];
library.importPaths = library.importPaths.filter((path) => path != pathToDelete);
await handleValidation();
} catch (error) {
handleError(error, $t('errors.unable_to_delete_import_path'));
}
};
const onEditImportPath = async (pathIndexToEdit?: number) => {
const result = await modalManager.show(LibraryImportPathModal, {
title: pathIndexToEdit === undefined ? $t('add_import_path') : $t('edit_import_path'),
submitText: pathIndexToEdit === undefined ? $t('add') : $t('save'),
isEditing: pathIndexToEdit !== undefined,
importPath: pathIndexToEdit === undefined ? null : library.importPaths[pathIndexToEdit],
importPaths: library.importPaths,
});
if (!result) {
return;
}
switch (result.action) {
case 'submit': {
// eslint-disable-next-line unicorn/prefer-ternary
if (pathIndexToEdit === undefined) {
await handleAddImportPath(result.importPath);
} else {
await handleEditImportPath(result.importPath, pathIndexToEdit);
}
break;
}
case 'delete': {
await handleDeleteImportPath(pathIndexToEdit);
break;
}
}
};
const onsubmit = (event: Event) => {
event.preventDefault();
onSubmit({ ...library });
};
</script>
<form {onsubmit} autocomplete="off" class="m-4 flex flex-col gap-4">
<table class="text-start">
<tbody class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray">
{#each validatedPaths as validatedPath, listIndex (validatedPath.importPath)}
<tr
class="flex h-20 w-full place-items-center text-center dark:text-immich-dark-fg even:bg-subtle/20 odd:bg-subtle/80"
>
<td class="w-1/8 text-ellipsis ps-8 text-sm">
{#if validatedPath.isValid}
<Icon icon={mdiCheckCircleOutline} size="24" title={validatedPath.message} class="text-success" />
{:else}
<Icon icon={mdiAlertOutline} size="24" title={validatedPath.message} class="text-warning" />
{/if}
</td>
<td class="w-4/5 text-ellipsis px-4 text-sm">{validatedPath.importPath}</td>
<td class="w-1/5 text-ellipsis flex justify-center">
<IconButton
shape="round"
color="primary"
icon={mdiPencilOutline}
aria-label={$t('edit_import_path')}
onclick={() => onEditImportPath(listIndex)}
size="small"
/>
</td>
</tr>
{/each}
<tr
class="flex h-20 w-full place-items-center text-center dark:text-immich-dark-fg even:bg-subtle/20 odd:bg-subtle/80"
>
<td class="w-4/5 text-ellipsis px-4 text-sm">
{#if importPaths.length === 0}
{$t('admin.no_paths_added')}
{/if}</td
>
<td class="w-1/5 text-ellipsis px-4 text-sm">
<Button shape="round" size="small" onclick={() => onEditImportPath()}>{$t('add_path')}</Button>
</td>
</tr>
</tbody>
</table>
<div class="flex justify-between w-full">
<div class="justify-end gap-2">
<Button shape="round" leadingIcon={mdiRefresh} size="small" color="secondary" onclick={() => revalidate()}
>{$t('validate')}</Button
>
</div>
<div class="flex justify-end gap-2">
<Button shape="round" size="small" color="secondary" onclick={onCancel}>{$t('cancel')}</Button>
<Button shape="round" size="small" type="submit">{$t('save')}</Button>
</div>
</div>
</form>

View File

@@ -1,151 +0,0 @@
<script lang="ts">
import LibraryExclusionPatternModal from '$lib/modals/LibraryExclusionPatternModal.svelte';
import { handleError } from '$lib/utils/handle-error';
import { type LibraryResponseDto } from '@immich/sdk';
import { Button, IconButton, modalManager } from '@immich/ui';
import { mdiPencilOutline } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
interface Props {
library: Partial<LibraryResponseDto>;
onCancel: () => void;
onSubmit: (library: Partial<LibraryResponseDto>) => void;
}
let { library = $bindable(), onCancel, onSubmit }: Props = $props();
let exclusionPatterns: string[] = $state([]);
onMount(() => {
if (library.exclusionPatterns) {
exclusionPatterns = library.exclusionPatterns;
} else {
library.exclusionPatterns = [];
}
});
const handleAddExclusionPattern = (exclusionPatternToAdd: string) => {
if (!library.exclusionPatterns) {
library.exclusionPatterns = [];
}
try {
// Check so that exclusion pattern isn't duplicated
if (!library.exclusionPatterns.includes(exclusionPatternToAdd)) {
library.exclusionPatterns.push(exclusionPatternToAdd);
exclusionPatterns = library.exclusionPatterns;
}
} catch (error) {
handleError(error, $t('errors.unable_to_add_exclusion_pattern'));
}
};
const handleEditExclusionPattern = (editedExclusionPattern: string, patternIndex: number) => {
if (!library.exclusionPatterns) {
library.exclusionPatterns = [];
}
try {
library.exclusionPatterns[patternIndex] = editedExclusionPattern;
exclusionPatterns = library.exclusionPatterns;
} catch (error) {
handleError(error, $t('errors.unable_to_edit_exclusion_pattern'));
}
};
const handleDeleteExclusionPattern = (patternIndexToDelete?: number) => {
if (patternIndexToDelete === undefined) {
return;
}
try {
if (!library.exclusionPatterns) {
library.exclusionPatterns = [];
}
const patternToDelete = library.exclusionPatterns[patternIndexToDelete];
library.exclusionPatterns = library.exclusionPatterns.filter((path) => path != patternToDelete);
exclusionPatterns = library.exclusionPatterns;
} catch (error) {
handleError(error, $t('errors.unable_to_delete_exclusion_pattern'));
}
};
const onEditExclusionPattern = async (patternIndexToEdit?: number) => {
const result = await modalManager.show(LibraryExclusionPatternModal, {
submitText: patternIndexToEdit === undefined ? $t('add') : $t('save'),
isEditing: patternIndexToEdit !== undefined,
exclusionPattern: patternIndexToEdit === undefined ? '' : exclusionPatterns[patternIndexToEdit],
exclusionPatterns,
});
if (!result) {
return;
}
switch (result.action) {
case 'submit': {
if (patternIndexToEdit === undefined) {
handleAddExclusionPattern(result.exclusionPattern);
} else {
handleEditExclusionPattern(result.exclusionPattern, patternIndexToEdit);
}
break;
}
case 'delete': {
handleDeleteExclusionPattern(patternIndexToEdit);
break;
}
}
};
const onsubmit = (event: Event) => {
event.preventDefault();
onSubmit(library);
};
</script>
<form {onsubmit} autocomplete="off" class="m-4 flex flex-col gap-4">
<table class="w-full text-start">
<tbody class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray">
{#each exclusionPatterns as exclusionPattern, listIndex (exclusionPattern)}
<tr
class="flex h-20 w-full place-items-center text-center dark:text-immich-dark-fg even:bg-subtle/20 odd:bg-subtle/80"
>
<td class="w-3/4 text-ellipsis px-4 text-sm">{exclusionPattern}</td>
<td class="w-1/4 text-ellipsis flex justify-center">
<IconButton
shape="round"
color="primary"
icon={mdiPencilOutline}
title={$t('edit_exclusion_pattern')}
onclick={() => onEditExclusionPattern(listIndex)}
aria-label={$t('edit_exclusion_pattern')}
size="small"
/>
</td>
</tr>
{/each}
<tr
class="flex h-20 w-full place-items-center text-center dark:text-immich-dark-fg even:bg-subtle/20 odd:bg-subtle/80"
>
<td class="w-3/4 text-ellipsis px-4 text-sm">
{#if exclusionPatterns.length === 0}
{$t('admin.no_pattern_added')}
{/if}
</td>
<td class="w-1/4 text-ellipsis px-4 text-sm flex justify-center">
<Button size="small" shape="round" onclick={() => onEditExclusionPattern()}>
{$t('add_exclusion_pattern')}
</Button>
</td>
</tr>
</tbody>
</table>
<div class="flex w-full justify-end gap-2">
<Button size="small" shape="round" color="secondary" onclick={onCancel}>{$t('cancel')}</Button>
<Button size="small" shape="round" type="submit">{$t('save')}</Button>
</div>
</form>

View File

@@ -7,9 +7,10 @@
fullWidth?: boolean;
src?: string;
title?: string;
class?: string;
}
let { onClick = undefined, text, fullWidth = false, src = empty1Url, title }: Props = $props();
let { onClick = undefined, text, fullWidth = false, src = empty1Url, title, class: className }: Props = $props();
let width = $derived(fullWidth ? 'w-full' : 'w-1/2');
@@ -22,7 +23,7 @@
<svelte:element
this={onClick ? 'button' : 'div'}
onclick={onClick}
class="{width} m-auto mt-10 flex flex-col place-content-center place-items-center rounded-3xl bg-gray-50 p-5 dark:bg-immich-dark-gray {hoverClasses}"
class="{width} {className} flex flex-col place-content-center place-items-center rounded-3xl bg-gray-50 p-5 dark:bg-immich-dark-gray {hoverClasses}"
>
<img {src} alt="" width="500" draggable="false" />

View File

@@ -74,10 +74,10 @@
{#if $isPurchased && $preferences.purchase.showSupportBadge}
<button
onclick={() => goto(`${AppRoute.USER_SETTINGS}?isOpen=user-purchase-settings`)}
class="w-full"
class="w-full mt-2"
type="button"
>
<SupporterBadge />
<SupporterBadge size="small" effect="always" />
</button>
{:else if !$isPurchased && showBuyButton && getAccountAge() > 14}
<button

View File

@@ -1,8 +1,10 @@
<script lang="ts">
import { releaseManager } from '$lib/managers/release-manager.svelte';
import ServerAboutModal from '$lib/modals/ServerAboutModal.svelte';
import { user } from '$lib/stores/user.store';
import { userInteraction } from '$lib/stores/user.svelte';
import { websocketStore } from '$lib/stores/websocket';
import type { ReleaseEvent } from '$lib/types';
import { semverToName } from '$lib/utils';
import { requestServerInfo } from '$lib/utils/auth';
import {
@@ -16,7 +18,7 @@
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
const { serverVersion, connected, release } = websocketStore;
const { serverVersion, connected } = websocketStore;
let info: ServerAboutResponseDto | undefined = $state();
let versions: ServerVersionHistoryResponseDto[] = $state([]);
@@ -37,20 +39,22 @@
$serverVersion ? `v${$serverVersion.major}.${$serverVersion.minor}.${$serverVersion.patch}` : null,
);
const releaseInfo = $derived.by(() => {
if ($release == undefined || $release?.isAvailable || !$user.isAdmin) {
const getReleaseInfo = (release?: ReleaseEvent) => {
if (!release || !release?.isAvailable || !$user.isAdmin) {
return;
}
const availableVersion = semverToName($release.releaseVersion);
const serverVersion = semverToName($release.serverVersion);
const availableVersion = semverToName(release.releaseVersion);
const serverVersion = semverToName(release.serverVersion);
if (serverVersion === availableVersion) {
return;
}
return { availableVersion, releaseUrl: `https://github.com/immich-app/immich/releases/tag/${availableVersion}` };
});
};
const releaseInfo = $derived(getReleaseInfo(releaseManager.value));
</script>
<div

View File

@@ -176,12 +176,24 @@
};
const scrollAndLoadAsset = async (assetId: string) => {
const monthGroup = await timelineManager.findMonthGroupForAsset(assetId);
if (!monthGroup) {
return false;
try {
// This flag prevents layout deferral to fix scroll positioning issues.
// When layouts are deferred and we scroll to an asset at the end of the timeline,
// we can calculate the asset's position, but the scrollableElement's scrollHeight
// hasn't been updated yet to reflect the new layout. This creates a mismatch that
// breaks scroll positioning. By disabling layout deferral in this case, we maintain
// the performance benefits of deferred layouts while still supporting deep linking
// to assets at the end of the timeline.
timelineManager.isScrollingOnLoad = true;
const monthGroup = await timelineManager.findMonthGroupForAsset(assetId);
if (!monthGroup) {
return false;
}
scrollToAssetPosition(assetId, monthGroup);
return true;
} finally {
timelineManager.isScrollingOnLoad = false;
}
scrollToAssetPosition(assetId, monthGroup);
return true;
};
const scrollToAsset = (asset: TimelineAsset) => {

View File

@@ -110,13 +110,9 @@
case AssetAction.ARCHIVE:
case AssetAction.UNARCHIVE:
case AssetAction.FAVORITE:
case AssetAction.UNFAVORITE: {
timelineManager.updateAssets([action.asset]);
break;
}
case AssetAction.UNFAVORITE:
case AssetAction.ADD: {
timelineManager.addAssets([action.asset]);
timelineManager.upsertAssets([action.asset]);
break;
}
@@ -135,7 +131,7 @@
break;
}
case AssetAction.REMOVE_ASSET_FROM_STACK: {
timelineManager.addAssets([toTimelineAsset(action.asset)]);
timelineManager.upsertAssets([toTimelineAsset(action.asset)]);
if (action.stack) {
//Have to unstack then restack assets in timeline in order to update the stack count in the timeline.
updateUnstackedAssetInTimeline(

View File

@@ -2,7 +2,7 @@
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
import type { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { assetSnapshot, assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';

View File

@@ -24,12 +24,12 @@
const { clearSelect, getOwnedAssets } = getAssetControlContext();
const handleArchive = async () => {
const isArchived = unarchive ? AssetVisibility.Timeline : AssetVisibility.Archive;
const assets = [...getOwnedAssets()].filter((asset) => asset.visibility !== isArchived);
const visibility = unarchive ? AssetVisibility.Timeline : AssetVisibility.Archive;
const assets = [...getOwnedAssets()].filter((asset) => asset.visibility !== visibility);
loading = true;
const ids = await archiveAssets(assets, isArchived as AssetVisibility);
const ids = await archiveAssets(assets, visibility as AssetVisibility);
if (ids) {
onArchive?.(ids, isArchived ? AssetVisibility.Archive : AssetVisibility.Timeline);
onArchive?.(ids, visibility);
clearSelect();
}
loading = false;

View File

@@ -46,7 +46,7 @@
!(isTrashEnabled && !force),
(assetIds) => timelineManager.removeAssets(assetIds),
assetInteraction.selectedAssets,
!isTrashEnabled || force ? undefined : (assets) => timelineManager.addAssets(assets),
!isTrashEnabled || force ? undefined : (assets) => timelineManager.upsertAssets(assets),
);
assetInteraction.clearMultiselect();
};

View File

@@ -12,6 +12,7 @@
mdiClock,
mdiFile,
mdiFitToScreen,
mdiFolderOutline,
mdiHeart,
mdiImageMultipleOutline,
mdiImageOutline,
@@ -51,6 +52,7 @@
fileName: isDifferent((a) => a.originalFileName),
fileSize: isDifferent((a) => getFileSize(a)),
resolution: isDifferent((a) => getAssetResolution(a)),
originalPath: isDifferent((a) => a.originalPath ?? $t('unknown')),
date: isDifferent((a) => {
const tz = a.exifInfo?.timeZone;
const dt =
@@ -79,6 +81,24 @@
(a) => [a.exifInfo?.city, a.exifInfo?.state, a.exifInfo?.country].filter(Boolean).join(', ') || 'unknown',
),
});
const getBasePath = (fullpath: string, fileName: string): string => {
if (fileName && fullpath.endsWith(fileName)) {
return fullpath.slice(0, -(fileName.length + 1));
}
return fullpath;
};
function truncateMiddle(path: string, maxLength: number = 50): string {
if (path.length <= maxLength) {
return path;
}
const start = Math.floor(maxLength / 2) - 2;
const end = Math.floor(maxLength / 2) - 2;
return path.slice(0, Math.max(0, start)) + '...' + path.slice(Math.max(0, path.length - end));
}
</script>
<div class="min-w-60 transition-colors border rounded-lg">
@@ -152,6 +172,14 @@
{asset.originalFileName}
</InfoRow>
<InfoRow
icon={mdiFolderOutline}
highlight={hasDifferentValues.originalPath}
title={$t('full_path', { values: { path: asset.originalPath } })}
>
{truncateMiddle(getBasePath(asset.originalPath, asset.originalFileName)) || $t('unknown')}
</InfoRow>
<InfoRow icon={mdiFile} highlight={hasDifferentValues.fileSize} title={$t('file_size')}>
{getFileSize(asset)}
</InfoRow>