mirror of
https://github.com/immich-app/immich.git
synced 2025-12-27 09:14:55 +03:00
merge: remote-tracking branch 'origin/main' into feat/database-restores
This commit is contained in:
@@ -2,7 +2,7 @@ import { photoZoomState } from '$lib/stores/zoom-image.store';
|
||||
import { useZoomImageWheel } from '@zoom-image/svelte';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
export const zoomImageAction = (node: HTMLElement) => {
|
||||
export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolean }) => {
|
||||
const { createZoomImage, zoomImageState, setZoomImageState } = useZoomImageWheel();
|
||||
|
||||
createZoomImage(node, {
|
||||
@@ -14,9 +14,32 @@ export const zoomImageAction = (node: HTMLElement) => {
|
||||
setZoomImageState(state);
|
||||
}
|
||||
|
||||
// Store original event handlers so we can prevent them when disabled
|
||||
const wheelHandler = (event: WheelEvent) => {
|
||||
if (options?.disabled) {
|
||||
event.stopImmediatePropagation();
|
||||
}
|
||||
};
|
||||
|
||||
const pointerDownHandler = (event: PointerEvent) => {
|
||||
if (options?.disabled) {
|
||||
event.stopImmediatePropagation();
|
||||
}
|
||||
};
|
||||
|
||||
// Add handlers at capture phase with higher priority
|
||||
node.addEventListener('wheel', wheelHandler, { capture: true });
|
||||
node.addEventListener('pointerdown', pointerDownHandler, { capture: true });
|
||||
|
||||
const unsubscribes = [photoZoomState.subscribe(setZoomImageState), zoomImageState.subscribe(photoZoomState.set)];
|
||||
|
||||
return {
|
||||
update(newOptions?: { disabled?: boolean }) {
|
||||
options = newOptions;
|
||||
},
|
||||
destroy() {
|
||||
node.removeEventListener('wheel', wheelHandler, { capture: true });
|
||||
node.removeEventListener('pointerdown', pointerDownHandler, { capture: true });
|
||||
for (const unsubscribe of unsubscribes) {
|
||||
unsubscribe();
|
||||
}
|
||||
|
||||
1
web/src/lib/assets/empty-folders.svg
Normal file
1
web/src/lib/assets/empty-folders.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="900" height="600" viewBox="0 0 900 600" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill="transparent" d="M0 0h900v600H0z"/><path d="M718.697 359.789c2.347 69.208-149.828 213.346-331.607 165.169-84.544-22.409-76.298-62.83-139.698-114.488-37.789-30.789-92.638-53.5-106.885-99.138-12.309-39.393-3.044-82.222 20.77-110.466 53.556-63.52 159.542-108.522 260.374-12.465 100.832 96.056 290.968-7.105 297.046 171.388z" fill="url(#a)"/><path fill-rule="evenodd" clip-rule="evenodd" d="M629.602 207.307v-51.154c0-28.251-22.902-51.153-51.154-51.153H322.681c-28.251 0-51.153 22.902-51.153 51.153v127.884" fill="#fff"/><path d="M629.602 207.307v-51.154c0-28.251-22.902-51.153-51.154-51.153H322.681c-28.251 0-51.153 22.902-51.153 51.153v127.884" stroke="#E1E4E5" stroke-width="4.13"/><path fill-rule="evenodd" clip-rule="evenodd" d="M271.528 216.252h165.353a25.578 25.578 0 0 0 21.28-11.382l35.884-53.941a25.575 25.575 0 0 1 21.357-11.407h114.2c28.251 0 51.154 22.902 51.154 51.153v255.767c0 28.252-22.903 51.154-51.154 51.154H271.528c-28.251 0-51.154-22.902-51.154-51.154V267.405c0-28.251 22.903-51.153 51.154-51.153z" fill="#fff" stroke="#E1E4E5" stroke-width="4.13"/><path fill-rule="evenodd" clip-rule="evenodd" d="M320.022 432.016v3.968h3.964A3.028 3.028 0 0 1 327 439a3.028 3.028 0 0 1-3.014 3.016h-3.964v3.968a3.028 3.028 0 0 1-3.014 3.016 3.029 3.029 0 0 1-3.014-3.016v-3.951h-3.98a3.029 3.029 0 0 1-3.014-3.017 3.029 3.029 0 0 1 3.014-3.016h3.964v-3.984a3.031 3.031 0 0 1 3.03-3.016 3.028 3.028 0 0 1 3.014 3.016zm-33.14-27.793v5.554h5.748c2.399 0 4.37 1.905 4.37 4.223 0 2.318-1.971 4.223-4.37 4.223h-5.748v5.554c0 2.318-1.971 4.223-4.37 4.223s-4.37-1.905-4.37-4.223v-5.531h-5.772c-2.399 0-4.37-1.905-4.37-4.223 0-2.318 1.971-4.223 4.37-4.223h5.748v-5.577c0-2.318 1.971-4.223 4.394-4.223 2.399 0 4.37 1.905 4.37 4.223z" fill="#E1E4E5"/><circle cx="451.101" cy="358.294" r="98.899" fill="#aaa"/><rect x="444.142" y="322.427" width="13.918" height="71.734" rx="6.959" fill="#fff"/><rect x="486.968" y="351.335" width="13.918" height="71.734" rx="6.959" transform="rotate(90 486.968 351.335)" fill="#fff"/><ellipse rx="13.917" ry="13.254" transform="matrix(-1 0 0 1 718.227 479.149)" fill="#E1E4E5"/><circle r="4.639" transform="matrix(-1 0 0 1 292.465 519.783)" fill="#E1E4E5"/><circle r="6.627" transform="matrix(-1 0 0 1 566.399 205.929)" fill="#E1E4E5"/><circle r="6.476" transform="scale(1 -1) rotate(-75 -180.786 -314.12)" fill="#E1E4E5"/><circle r="8.615" transform="matrix(-1 0 0 1 217.158 114.719)" fill="#E1E4E5"/><ellipse rx="6.627" ry="5.302" transform="matrix(-1 0 0 1 704.513 233.511)" fill="#E1E4E5"/><path d="M186.177 456.259h.174c1.026 14.545 11.844 14.769 11.844 14.769s-11.929.233-11.929 17.04c0-16.807-11.929-17.04-11.929-17.04s10.814-.224 11.84-14.769zm574.334-165.951h.18c1.067 15.36 12.309 15.596 12.309 15.596s-12.397.246-12.397 17.994c0-17.748-12.396-17.994-12.396-17.994s11.237-.236 12.304-15.596z" fill="#E1E4E5"/><defs><linearGradient id="a" x1="530.485" y1="779.032" x2="277.414" y2="-357.319" gradientUnits="userSpaceOnUse"><stop stop-color="#fff"/><stop offset="1" stop-color="#EEE"/></linearGradient></defs></svg>
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`)}>
|
||||
|
||||
36
web/src/lib/components/asset-viewer/ocr-bounding-box.svelte
Normal file
36
web/src/lib/components/asset-viewer/ocr-bounding-box.svelte
Normal 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>
|
||||
17
web/src/lib/components/asset-viewer/ocr-button.svelte
Normal file
17
web/src/lib/components/asset-viewer/ocr-button.svelte
Normal 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()}
|
||||
/>
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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" />
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -301,6 +301,7 @@ export const langs: Lang[] = [
|
||||
{ name: 'Bulgarian', code: 'bg', loader: () => import('$i18n/bg.json') },
|
||||
{ name: 'Bislama', code: 'bi', loader: () => import('$i18n/bi.json') },
|
||||
{ name: 'Bengali', code: 'bn', loader: () => import('$i18n/bn.json') },
|
||||
{ name: 'Breton', code: 'br', loader: () => import('$i18n/br.json') },
|
||||
{ name: 'Catalan', code: 'ca', loader: () => import('$i18n/ca.json') },
|
||||
{ name: 'Czech', code: 'cs', loader: () => import('$i18n/cs.json') },
|
||||
{ name: 'Chuvash', code: 'cv', loader: () => import('$i18n/cv.json') },
|
||||
@@ -308,6 +309,7 @@ export const langs: Lang[] = [
|
||||
{ name: 'German', code: 'de', loader: () => import('$i18n/de.json') },
|
||||
defaultLang,
|
||||
{ name: 'Greek', code: 'el', loader: () => import('$i18n/el.json') },
|
||||
{ name: 'Esperanto', code: 'eo', loader: () => import('$i18n/eo.json') },
|
||||
{ name: 'Spanish', code: 'es', loader: () => import('$i18n/es.json') },
|
||||
{ name: 'Estonian', code: 'et', loader: () => import('$i18n/et.json') },
|
||||
{ name: 'Basque', code: 'eu', loader: () => import('$i18n/eu.json') },
|
||||
@@ -315,17 +317,22 @@ export const langs: Lang[] = [
|
||||
{ name: 'Finnish', code: 'fi', loader: () => import('$i18n/fi.json') },
|
||||
{ name: 'Filipino', code: 'fil', loader: () => import('$i18n/fil.json') },
|
||||
{ name: 'French', code: 'fr', loader: () => import('$i18n/fr.json') },
|
||||
{ name: 'Irish', code: 'ga', loader: () => import('$i18n/ga.json') },
|
||||
{ name: 'Galician', code: 'gl', loader: () => import('$i18n/gl.json') },
|
||||
{ name: 'Alemannic', code: 'gsw', loader: () => import('$i18n/gsw.json') },
|
||||
{ name: 'Gujarati', code: 'gu', loader: () => import('$i18n/gu.json') },
|
||||
{ name: 'Hebrew', code: 'he', loader: () => import('$i18n/he.json'), rtl: true },
|
||||
{ name: 'Hindi', code: 'hi', loader: () => import('$i18n/hi.json') },
|
||||
{ name: 'Croatian', code: 'hr', loader: () => import('$i18n/hr.json') },
|
||||
{ name: 'Hungarian', code: 'hu', loader: () => import('$i18n/hu.json') },
|
||||
{ name: 'Armenian', code: 'hy', loader: () => import('$i18n/hy.json') },
|
||||
{ name: 'Indonesian', code: 'id', loader: () => import('$i18n/id.json') },
|
||||
{ name: 'Icelandic', code: 'is', loader: () => import('$i18n/is.json') },
|
||||
{ name: 'Italian', code: 'it', loader: () => import('$i18n/it.json') },
|
||||
{ name: 'Japanese', code: 'ja', loader: () => import('$i18n/ja.json') },
|
||||
{ name: 'Georgian', code: 'ka', loader: () => import('$i18n/ka.json') },
|
||||
{ name: 'Kazakh', code: 'kk', loader: () => import('$i18n/kk.json') },
|
||||
{ name: 'Khmer (Central)', code: 'km', loader: () => import('$i18n/km.json') },
|
||||
{ name: 'Kurdish (Northern)', code: 'kmr', loader: () => import('$i18n/kmr.json'), rtl: true },
|
||||
{ name: 'Kannada', code: 'kn', loader: () => import('$i18n/kn.json') },
|
||||
{ name: 'Korean', code: 'ko', loader: () => import('$i18n/ko.json') },
|
||||
@@ -347,6 +354,7 @@ export const langs: Lang[] = [
|
||||
{ name: 'Portuguese (Brazil) ', code: 'pt-BR', weblateCode: 'pt_BR', loader: () => import('$i18n/pt_BR.json') },
|
||||
{ name: 'Romanian', code: 'ro', loader: () => import('$i18n/ro.json') },
|
||||
{ name: 'Russian', code: 'ru', loader: () => import('$i18n/ru.json') },
|
||||
{ name: 'Sinhala', code: 'si', loader: () => import('$i18n/si.json') },
|
||||
{ name: 'Slovak', code: 'sk', loader: () => import('$i18n/sk.json') },
|
||||
{ name: 'Slovenian', code: 'sl', loader: () => import('$i18n/sl.json') },
|
||||
{ name: 'Albanian', code: 'sq', loader: () => import('$i18n/sq.json') },
|
||||
@@ -364,7 +372,9 @@ export const langs: Lang[] = [
|
||||
{ name: 'Turkish', code: 'tr', loader: () => import('$i18n/tr.json') },
|
||||
{ name: 'Ukrainian', code: 'uk', loader: () => import('$i18n/uk.json') },
|
||||
{ name: 'Urdu', code: 'ur', loader: () => import('$i18n/ur.json'), rtl: true },
|
||||
{ name: 'Uzbek', code: 'uz', loader: () => import('$i18n/uz.json') },
|
||||
{ name: 'Vietnamese', code: 'vi', loader: () => import('$i18n/vi.json') },
|
||||
{ name: 'Cantonese (Traditional Han script)', code: 'yue_Hant', loader: () => import('$i18n/yue_Hant.json') },
|
||||
{
|
||||
name: 'Chinese (Traditional)',
|
||||
code: 'zh-TW',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { ThemeSetting } from '$lib/managers/theme-manager.svelte';
|
||||
import type { ReleaseEvent } from '$lib/types';
|
||||
import type {
|
||||
AlbumResponseDto,
|
||||
LibraryResponseDto,
|
||||
LoginResponseDto,
|
||||
SharedLinkResponseDto,
|
||||
SystemConfigDto,
|
||||
@@ -15,6 +17,8 @@ export type Events = {
|
||||
LanguageChange: [{ name: string; code: string; rtl?: boolean }];
|
||||
ThemeChange: [ThemeSetting];
|
||||
|
||||
AssetReplace: [{ oldAssetId: string; newAssetId: string }];
|
||||
|
||||
AlbumDelete: [AlbumResponseDto];
|
||||
|
||||
SharedLinkCreate: [SharedLinkResponseDto];
|
||||
@@ -23,10 +27,19 @@ export type Events = {
|
||||
|
||||
UserAdminCreate: [UserAdminResponseDto];
|
||||
UserAdminUpdate: [UserAdminResponseDto];
|
||||
UserAdminDelete: [UserAdminResponseDto];
|
||||
UserAdminRestore: [UserAdminResponseDto];
|
||||
// soft deleted
|
||||
UserAdminDelete: [UserAdminResponseDto];
|
||||
// confirmed permanently deleted from server
|
||||
UserAdminDeleted: [{ id: string }];
|
||||
|
||||
SystemConfigUpdate: [SystemConfigDto];
|
||||
|
||||
LibraryCreate: [LibraryResponseDto];
|
||||
LibraryUpdate: [LibraryResponseDto];
|
||||
LibraryDelete: [{ id: string }];
|
||||
|
||||
ReleaseEvent: [ReleaseEvent];
|
||||
};
|
||||
|
||||
type Listener<EventMap extends Record<string, unknown[]>, K extends keyof EventMap> = (...params: EventMap[K]) => void;
|
||||
|
||||
12
web/src/lib/managers/release-manager.svelte.ts
Normal file
12
web/src/lib/managers/release-manager.svelte.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { type ReleaseEvent } from '$lib/types';
|
||||
|
||||
class ReleaseManager {
|
||||
value = $state<ReleaseEvent | undefined>();
|
||||
|
||||
constructor() {
|
||||
eventManager.on('ReleaseEvent', (event) => (this.value = event));
|
||||
}
|
||||
}
|
||||
|
||||
export const releaseManager = new ReleaseManager();
|
||||
@@ -1,6 +1,6 @@
|
||||
import { TUNABLES } from '$lib/utils/tunables';
|
||||
import type { MonthGroup } from '../month-group.svelte';
|
||||
import type { TimelineManager } from '../timeline-manager.svelte';
|
||||
import { TimelineManager } from '../timeline-manager.svelte';
|
||||
|
||||
const {
|
||||
TIMELINE: { INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM },
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { MonthGroup } from '../month-group.svelte';
|
||||
import type { TimelineManager } from '../timeline-manager.svelte';
|
||||
import { TimelineManager } from '../timeline-manager.svelte';
|
||||
import type { UpdateGeometryOptions } from '../types';
|
||||
|
||||
export function updateGeometry(timelineManager: TimelineManager, month: MonthGroup, options: UpdateGeometryOptions) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { toISOYearMonthUTC } from '$lib/utils/timeline-util';
|
||||
import { getTimeBucket } from '@immich/sdk';
|
||||
import type { MonthGroup } from '../month-group.svelte';
|
||||
import type { TimelineManager } from '../timeline-manager.svelte';
|
||||
import { TimelineManager } from '../timeline-manager.svelte';
|
||||
import type { TimelineManagerOptions } from '../types';
|
||||
|
||||
export async function loadFromTimeBuckets(
|
||||
|
||||
@@ -2,7 +2,7 @@ import { plainDateTimeCompare, type TimelineYearMonth } from '$lib/utils/timelin
|
||||
import { AssetOrder } from '@immich/sdk';
|
||||
import { DateTime } from 'luxon';
|
||||
import type { MonthGroup } from '../month-group.svelte';
|
||||
import type { TimelineManager } from '../timeline-manager.svelte';
|
||||
import { TimelineManager } from '../timeline-manager.svelte';
|
||||
import type { AssetDescriptor, Direction, TimelineAsset } from '../types';
|
||||
|
||||
export async function getAssetWithOffset(
|
||||
|
||||
@@ -13,10 +13,10 @@ export class WebsocketSupport {
|
||||
#processPendingChanges = throttle(() => {
|
||||
const { add, update, remove } = this.#getPendingChangeBatches();
|
||||
if (add.length > 0) {
|
||||
this.#timelineManager.addAssets(add);
|
||||
this.#timelineManager.upsertAssets(add);
|
||||
}
|
||||
if (update.length > 0) {
|
||||
this.#timelineManager.updateAssets(update);
|
||||
this.#timelineManager.upsertAssets(update);
|
||||
}
|
||||
if (remove.length > 0) {
|
||||
this.#timelineManager.removeAssets(remove);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { sdkMock } from '$lib/__mocks__/sdk.mock';
|
||||
import { getMonthGroupByDate } from '$lib/managers/timeline-manager/internal/search-support.svelte';
|
||||
import { AbortError } from '$lib/utils';
|
||||
import { fromISODateTimeUTCToObject } from '$lib/utils/timeline-util';
|
||||
import { type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk';
|
||||
import { AssetVisibility, type AssetResponseDto, type TimeBucketAssetResponseDto } from '@immich/sdk';
|
||||
import { timelineAssetFactory, toResponseDto } from '@test-data/factories/asset-factory';
|
||||
import { tick } from 'svelte';
|
||||
import { TimelineManager } from './timeline-manager.svelte';
|
||||
@@ -175,7 +175,7 @@ describe('TimelineManager', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('addAssets', () => {
|
||||
describe('upsertAssets', () => {
|
||||
let timelineManager: TimelineManager;
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -196,7 +196,7 @@ describe('TimelineManager', () => {
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
timelineManager.addAssets([asset]);
|
||||
timelineManager.upsertAssets([asset]);
|
||||
|
||||
expect(timelineManager.months.length).toEqual(1);
|
||||
expect(timelineManager.assetCount).toEqual(1);
|
||||
@@ -212,8 +212,8 @@ describe('TimelineManager', () => {
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||
})
|
||||
.map((asset) => deriveLocalDateTimeFromFileCreatedAt(asset));
|
||||
timelineManager.addAssets([assetOne]);
|
||||
timelineManager.addAssets([assetTwo]);
|
||||
timelineManager.upsertAssets([assetOne]);
|
||||
timelineManager.upsertAssets([assetTwo]);
|
||||
|
||||
expect(timelineManager.months.length).toEqual(1);
|
||||
expect(timelineManager.assetCount).toEqual(2);
|
||||
@@ -238,7 +238,7 @@ describe('TimelineManager', () => {
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-16T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
timelineManager.addAssets([assetOne, assetTwo, assetThree]);
|
||||
timelineManager.upsertAssets([assetOne, assetTwo, assetThree]);
|
||||
|
||||
const month = getMonthGroupByDate(timelineManager, { year: 2024, month: 1 });
|
||||
expect(month).not.toBeNull();
|
||||
@@ -264,7 +264,7 @@ describe('TimelineManager', () => {
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2023-01-20T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
timelineManager.addAssets([assetOne, assetTwo, assetThree]);
|
||||
timelineManager.upsertAssets([assetOne, assetTwo, assetThree]);
|
||||
|
||||
expect(timelineManager.months.length).toEqual(3);
|
||||
expect(timelineManager.months[0].yearMonth.year).toEqual(2024);
|
||||
@@ -278,12 +278,10 @@ describe('TimelineManager', () => {
|
||||
});
|
||||
|
||||
it('updates existing asset', () => {
|
||||
const updateAssetsSpy = vi.spyOn(timelineManager, 'updateAssets');
|
||||
const asset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build());
|
||||
timelineManager.addAssets([asset]);
|
||||
timelineManager.upsertAssets([asset]);
|
||||
|
||||
timelineManager.addAssets([asset]);
|
||||
expect(updateAssetsSpy).toBeCalledWith([asset]);
|
||||
timelineManager.upsertAssets([asset]);
|
||||
expect(timelineManager.assetCount).toEqual(1);
|
||||
});
|
||||
|
||||
@@ -294,12 +292,12 @@ describe('TimelineManager', () => {
|
||||
|
||||
const timelineManager = new TimelineManager();
|
||||
await timelineManager.updateOptions({ isTrashed: true });
|
||||
timelineManager.addAssets([asset, trashedAsset]);
|
||||
timelineManager.upsertAssets([asset, trashedAsset]);
|
||||
expect(await getAssets(timelineManager)).toEqual([trashedAsset]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateAssets', () => {
|
||||
describe('upsertAssets - updating existing', () => {
|
||||
let timelineManager: TimelineManager;
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -309,22 +307,15 @@ describe('TimelineManager', () => {
|
||||
await timelineManager.updateViewport({ width: 1588, height: 1000 });
|
||||
});
|
||||
|
||||
it('ignores non-existing assets', () => {
|
||||
timelineManager.updateAssets([deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build())]);
|
||||
|
||||
expect(timelineManager.months.length).toEqual(0);
|
||||
expect(timelineManager.assetCount).toEqual(0);
|
||||
});
|
||||
|
||||
it('updates an asset', () => {
|
||||
const asset = deriveLocalDateTimeFromFileCreatedAt(timelineAssetFactory.build({ isFavorite: false }));
|
||||
const updatedAsset = { ...asset, isFavorite: true };
|
||||
|
||||
timelineManager.addAssets([asset]);
|
||||
timelineManager.upsertAssets([asset]);
|
||||
expect(timelineManager.assetCount).toEqual(1);
|
||||
expect(timelineManager.months[0].getFirstAsset().isFavorite).toEqual(false);
|
||||
|
||||
timelineManager.updateAssets([updatedAsset]);
|
||||
timelineManager.upsertAssets([updatedAsset]);
|
||||
expect(timelineManager.assetCount).toEqual(1);
|
||||
expect(timelineManager.months[0].getFirstAsset().isFavorite).toEqual(true);
|
||||
});
|
||||
@@ -340,18 +331,80 @@ describe('TimelineManager', () => {
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-03-20T12:00:00.000Z'),
|
||||
});
|
||||
|
||||
timelineManager.addAssets([asset]);
|
||||
timelineManager.upsertAssets([asset]);
|
||||
expect(timelineManager.months.length).toEqual(1);
|
||||
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })).not.toBeUndefined();
|
||||
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(1);
|
||||
|
||||
timelineManager.updateAssets([updatedAsset]);
|
||||
timelineManager.upsertAssets([updatedAsset]);
|
||||
expect(timelineManager.months.length).toEqual(2);
|
||||
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })).not.toBeUndefined();
|
||||
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 1 })?.getAssets().length).toEqual(0);
|
||||
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 3 })).not.toBeUndefined();
|
||||
expect(getMonthGroupByDate(timelineManager, { year: 2024, month: 3 })?.getAssets().length).toEqual(1);
|
||||
});
|
||||
it('asset is removed during upsert when TimelineManager if visibility changes', async () => {
|
||||
await timelineManager.updateOptions({
|
||||
visibility: AssetVisibility.Archive,
|
||||
});
|
||||
const fixture = deriveLocalDateTimeFromFileCreatedAt(
|
||||
timelineAssetFactory.build({
|
||||
visibility: AssetVisibility.Archive,
|
||||
}),
|
||||
);
|
||||
|
||||
timelineManager.upsertAssets([fixture]);
|
||||
expect(timelineManager.assetCount).toEqual(1);
|
||||
|
||||
const updated = Object.freeze({ ...fixture, visibility: AssetVisibility.Timeline });
|
||||
timelineManager.upsertAssets([updated]);
|
||||
expect(timelineManager.assetCount).toEqual(0);
|
||||
|
||||
timelineManager.upsertAssets([{ ...fixture, visibility: AssetVisibility.Archive }]);
|
||||
expect(timelineManager.assetCount).toEqual(1);
|
||||
});
|
||||
|
||||
it('asset is removed during upsert when TimelineManager if isFavorite changes', async () => {
|
||||
await timelineManager.updateOptions({
|
||||
isFavorite: true,
|
||||
});
|
||||
const fixture = deriveLocalDateTimeFromFileCreatedAt(
|
||||
timelineAssetFactory.build({
|
||||
isFavorite: true,
|
||||
}),
|
||||
);
|
||||
|
||||
timelineManager.upsertAssets([fixture]);
|
||||
expect(timelineManager.assetCount).toEqual(1);
|
||||
|
||||
const updated = Object.freeze({ ...fixture, isFavorite: false });
|
||||
timelineManager.upsertAssets([updated]);
|
||||
expect(timelineManager.assetCount).toEqual(0);
|
||||
|
||||
timelineManager.upsertAssets([{ ...fixture, isFavorite: true }]);
|
||||
expect(timelineManager.assetCount).toEqual(1);
|
||||
});
|
||||
|
||||
it('asset is removed during upsert when TimelineManager if isTrashed changes', async () => {
|
||||
await timelineManager.updateOptions({
|
||||
isTrashed: true,
|
||||
});
|
||||
const fixture = deriveLocalDateTimeFromFileCreatedAt(
|
||||
timelineAssetFactory.build({
|
||||
isTrashed: true,
|
||||
}),
|
||||
);
|
||||
|
||||
timelineManager.upsertAssets([fixture]);
|
||||
expect(timelineManager.assetCount).toEqual(1);
|
||||
|
||||
const updated = Object.freeze({ ...fixture, isTrashed: false });
|
||||
timelineManager.upsertAssets([updated]);
|
||||
expect(timelineManager.assetCount).toEqual(0);
|
||||
|
||||
timelineManager.upsertAssets([{ ...fixture, isTrashed: true }]);
|
||||
expect(timelineManager.assetCount).toEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeAssets', () => {
|
||||
@@ -365,7 +418,7 @@ describe('TimelineManager', () => {
|
||||
});
|
||||
|
||||
it('ignores invalid IDs', () => {
|
||||
timelineManager.addAssets(
|
||||
timelineManager.upsertAssets(
|
||||
timelineAssetFactory
|
||||
.buildList(2, {
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||
@@ -385,7 +438,7 @@ describe('TimelineManager', () => {
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||
})
|
||||
.map((asset) => deriveLocalDateTimeFromFileCreatedAt(asset));
|
||||
timelineManager.addAssets([assetOne, assetTwo]);
|
||||
timelineManager.upsertAssets([assetOne, assetTwo]);
|
||||
timelineManager.removeAssets([assetOne.id]);
|
||||
|
||||
expect(timelineManager.assetCount).toEqual(1);
|
||||
@@ -399,7 +452,7 @@ describe('TimelineManager', () => {
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
|
||||
})
|
||||
.map((asset) => deriveLocalDateTimeFromFileCreatedAt(asset));
|
||||
timelineManager.addAssets(assets);
|
||||
timelineManager.upsertAssets(assets);
|
||||
timelineManager.removeAssets(assets.map((asset) => asset.id));
|
||||
|
||||
expect(timelineManager.assetCount).toEqual(0);
|
||||
@@ -431,7 +484,7 @@ describe('TimelineManager', () => {
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-15T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
timelineManager.addAssets([assetOne, assetTwo]);
|
||||
timelineManager.upsertAssets([assetOne, assetTwo]);
|
||||
expect(timelineManager.getFirstAsset()).toEqual(assetOne);
|
||||
});
|
||||
});
|
||||
@@ -556,7 +609,7 @@ describe('TimelineManager', () => {
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-02-15T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
timelineManager.addAssets([assetOne, assetTwo]);
|
||||
timelineManager.upsertAssets([assetOne, assetTwo]);
|
||||
|
||||
expect(timelineManager.getMonthGroupByAssetId(assetTwo.id)?.yearMonth.year).toEqual(2024);
|
||||
expect(timelineManager.getMonthGroupByAssetId(assetTwo.id)?.yearMonth.month).toEqual(2);
|
||||
@@ -575,7 +628,7 @@ describe('TimelineManager', () => {
|
||||
fileCreatedAt: fromISODateTimeUTCToObject('2024-02-15T12:00:00.000Z'),
|
||||
}),
|
||||
);
|
||||
timelineManager.addAssets([assetOne, assetTwo]);
|
||||
timelineManager.upsertAssets([assetOne, assetTwo]);
|
||||
|
||||
timelineManager.removeAssets([assetTwo.id]);
|
||||
expect(timelineManager.getMonthGroupByAssetId(assetOne.id)?.yearMonth.year).toEqual(2024);
|
||||
|
||||
@@ -320,10 +320,10 @@ export class TimelineManager extends VirtualScrollManager {
|
||||
}
|
||||
}
|
||||
|
||||
addAssets(assets: TimelineAsset[]) {
|
||||
const assetsToUpdate = assets.filter((asset) => !this.isExcluded(asset));
|
||||
const notUpdated = this.updateAssets(assetsToUpdate);
|
||||
addAssetsToMonthGroups(this, [...notUpdated], { order: this.#options.order ?? AssetOrder.Desc });
|
||||
upsertAssets(assets: TimelineAsset[]) {
|
||||
const notUpdated = this.#updateAssets(assets);
|
||||
const notExcluded = notUpdated.filter((asset) => !this.isExcluded(asset));
|
||||
addAssetsToMonthGroups(this, [...notExcluded], { order: this.#options.order ?? AssetOrder.Desc });
|
||||
}
|
||||
|
||||
async findMonthGroupForAsset(id: string) {
|
||||
@@ -404,7 +404,7 @@ export class TimelineManager extends VirtualScrollManager {
|
||||
runAssetOperation(this, new SvelteSet(ids), operation, { order: this.#options.order ?? AssetOrder.Desc });
|
||||
}
|
||||
|
||||
updateAssets(assets: TimelineAsset[]) {
|
||||
#updateAssets(assets: TimelineAsset[]) {
|
||||
const lookup = new SvelteMap<string, TimelineAsset>(assets.map((asset) => [asset.id, asset]));
|
||||
const { unprocessedIds } = runAssetOperation(
|
||||
this,
|
||||
|
||||
43
web/src/lib/modals/LibraryExclusionPatternAddModal.svelte
Normal file
43
web/src/lib/modals/LibraryExclusionPatternAddModal.svelte
Normal file
@@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
import { handleAddLibraryExclusionPattern } from '$lib/services/library.service';
|
||||
import type { LibraryResponseDto } from '@immich/sdk';
|
||||
import { Button, Field, HStack, Input, Modal, ModalBody, ModalFooter, Text } from '@immich/ui';
|
||||
import { mdiFolderSync } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
library: LibraryResponseDto;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const { library, onClose }: Props = $props();
|
||||
let exclusionPattern = $state('');
|
||||
|
||||
const onsubmit = async () => {
|
||||
const success = await handleAddLibraryExclusionPattern(library, exclusionPattern);
|
||||
if (success) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<Modal title={$t('add_exclusion_pattern')} icon={mdiFolderSync} {onClose} size="small">
|
||||
<ModalBody>
|
||||
<form {onsubmit} autocomplete="off" id="library-exclusion-pattern-form">
|
||||
<Text size="small" class="mb-4">{$t('admin.exclusion_pattern_description')}</Text>
|
||||
|
||||
<Field label={$t('pattern')}>
|
||||
<Input bind:value={exclusionPattern} />
|
||||
</Field>
|
||||
</form>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<HStack fullWidth>
|
||||
<Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
|
||||
<Button shape="round" type="submit" fullWidth form="library-exclusion-pattern-form">
|
||||
{$t('add')}
|
||||
</Button>
|
||||
</HStack>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
45
web/src/lib/modals/LibraryExclusionPatternEditModal.svelte
Normal file
45
web/src/lib/modals/LibraryExclusionPatternEditModal.svelte
Normal file
@@ -0,0 +1,45 @@
|
||||
<script lang="ts">
|
||||
import { handleEditExclusionPattern } from '$lib/services/library.service';
|
||||
import type { LibraryResponseDto } from '@immich/sdk';
|
||||
import { Button, Field, HStack, Input, Modal, ModalBody, ModalFooter, Text } from '@immich/ui';
|
||||
import { mdiFolderSync } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
library: LibraryResponseDto;
|
||||
exclusionPattern: string;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const { library, exclusionPattern, onClose }: Props = $props();
|
||||
|
||||
let newExclusionPattern = $state(exclusionPattern);
|
||||
|
||||
const onsubmit = async () => {
|
||||
const success = await handleEditExclusionPattern(library, exclusionPattern, newExclusionPattern);
|
||||
if (success) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<Modal title={$t('edit_exclusion_pattern')} icon={mdiFolderSync} {onClose} size="small">
|
||||
<ModalBody>
|
||||
<form {onsubmit} autocomplete="off" id="library-exclusion-pattern-form">
|
||||
<Text size="small" class="mb-4">{$t('admin.exclusion_pattern_description')}</Text>
|
||||
|
||||
<Field label={$t('pattern')}>
|
||||
<Input bind:value={newExclusionPattern} />
|
||||
</Field>
|
||||
</form>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<HStack fullWidth>
|
||||
<Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
|
||||
<Button shape="round" type="submit" fullWidth form="library-exclusion-pattern-form">
|
||||
{$t('save')}
|
||||
</Button>
|
||||
</HStack>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
@@ -1,78 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Button, HStack, Modal, ModalBody, ModalFooter } from '@immich/ui';
|
||||
import { mdiFolderRemove } from '@mdi/js';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
exclusionPattern: string;
|
||||
exclusionPatterns?: string[];
|
||||
isEditing?: boolean;
|
||||
submitText?: string;
|
||||
onClose: (data?: { action: 'delete' } | { action: 'submit'; exclusionPattern: string }) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
exclusionPattern = $bindable(),
|
||||
exclusionPatterns = $bindable([]),
|
||||
isEditing = false,
|
||||
submitText = $t('submit'),
|
||||
onClose,
|
||||
}: Props = $props();
|
||||
|
||||
onMount(() => {
|
||||
if (isEditing) {
|
||||
exclusionPatterns = exclusionPatterns.filter((pattern) => pattern !== exclusionPattern);
|
||||
}
|
||||
});
|
||||
|
||||
let isDuplicate = $derived(exclusionPattern !== null && exclusionPatterns.includes(exclusionPattern));
|
||||
let canSubmit = $derived(exclusionPattern && !exclusionPatterns.includes(exclusionPattern));
|
||||
|
||||
const onsubmit = (event: Event) => {
|
||||
event.preventDefault();
|
||||
if (canSubmit) {
|
||||
onClose({ action: 'submit', exclusionPattern });
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<Modal size="small" title={$t('add_exclusion_pattern')} icon={mdiFolderRemove} {onClose}>
|
||||
<ModalBody>
|
||||
<form {onsubmit} autocomplete="off" id="add-exclusion-pattern-form">
|
||||
<p class="py-5 text-sm">
|
||||
{$t('admin.exclusion_pattern_description')}
|
||||
<br /><br />
|
||||
{$t('admin.add_exclusion_pattern_description')}
|
||||
</p>
|
||||
<div class="my-4 flex flex-col gap-2">
|
||||
<label class="immich-form-label" for="exclusionPattern">{$t('pattern')}</label>
|
||||
<input
|
||||
class="immich-form-input"
|
||||
id="exclusionPattern"
|
||||
name="exclusionPattern"
|
||||
type="text"
|
||||
bind:value={exclusionPattern}
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-8 flex w-full gap-4">
|
||||
{#if isDuplicate}
|
||||
<p class="text-red-500 text-sm">{$t('errors.exclusion_pattern_already_exists')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</form>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<HStack fullWidth>
|
||||
<Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
|
||||
{#if isEditing}
|
||||
<Button shape="round" color="danger" fullWidth onclick={() => onClose({ action: 'delete' })}
|
||||
>{$t('delete')}</Button
|
||||
>
|
||||
{/if}
|
||||
<Button shape="round" type="submit" disabled={!canSubmit} fullWidth form="add-exclusion-pattern-form">
|
||||
{submitText}
|
||||
</Button>
|
||||
</HStack>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
44
web/src/lib/modals/LibraryFolderAddModal.svelte
Normal file
44
web/src/lib/modals/LibraryFolderAddModal.svelte
Normal file
@@ -0,0 +1,44 @@
|
||||
<script lang="ts">
|
||||
import { handleAddLibraryFolder } from '$lib/services/library.service';
|
||||
import type { LibraryResponseDto } from '@immich/sdk';
|
||||
import { Button, Field, HStack, Input, Modal, ModalBody, ModalFooter, Text } from '@immich/ui';
|
||||
import { mdiFolderSync } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
library: LibraryResponseDto;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const { library, onClose }: Props = $props();
|
||||
let folder = $state('');
|
||||
|
||||
const onsubmit = async () => {
|
||||
const success = await handleAddLibraryFolder(library, folder);
|
||||
|
||||
if (success) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<Modal title={$t('library_add_folder')} icon={mdiFolderSync} {onClose} size="small">
|
||||
<ModalBody>
|
||||
<form {onsubmit} autocomplete="off" id="library-import-path-form">
|
||||
<Text size="small" class="mb-4">{$t('admin.library_folder_description')}</Text>
|
||||
|
||||
<Field label={$t('path')}>
|
||||
<Input bind:value={folder} />
|
||||
</Field>
|
||||
</form>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<HStack fullWidth>
|
||||
<Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
|
||||
<Button shape="round" type="submit" fullWidth form="library-import-path-form">
|
||||
{$t('add')}
|
||||
</Button>
|
||||
</HStack>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
45
web/src/lib/modals/LibraryFolderEditModal.svelte
Normal file
45
web/src/lib/modals/LibraryFolderEditModal.svelte
Normal file
@@ -0,0 +1,45 @@
|
||||
<script lang="ts">
|
||||
import { handleEditLibraryFolder } from '$lib/services/library.service';
|
||||
import type { LibraryResponseDto } from '@immich/sdk';
|
||||
import { Button, Field, HStack, Input, Modal, ModalBody, ModalFooter, Text } from '@immich/ui';
|
||||
import { mdiFolderSync } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
library: LibraryResponseDto;
|
||||
folder: string;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const { library, folder, onClose }: Props = $props();
|
||||
|
||||
let newFolder = $state(folder);
|
||||
|
||||
const onsubmit = async () => {
|
||||
const success = await handleEditLibraryFolder(library, folder, newFolder);
|
||||
if (success) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<Modal title={$t('library_edit_folder')} icon={mdiFolderSync} {onClose} size="small">
|
||||
<ModalBody>
|
||||
<form {onsubmit} autocomplete="off" id="library-import-path-form">
|
||||
<Text size="small" class="mb-4">{$t('admin.library_folder_description')}</Text>
|
||||
|
||||
<Field label={$t('path')}>
|
||||
<Input bind:value={newFolder} />
|
||||
</Field>
|
||||
</form>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<HStack fullWidth>
|
||||
<Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
|
||||
<Button shape="round" type="submit" fullWidth form="library-import-path-form">
|
||||
{$t('save')}
|
||||
</Button>
|
||||
</HStack>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
@@ -1,75 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Button, HStack, Modal, ModalBody, ModalFooter } from '@immich/ui';
|
||||
import { mdiFolderSync } from '@mdi/js';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
importPath: string | null;
|
||||
importPaths?: string[];
|
||||
title?: string;
|
||||
cancelText?: string;
|
||||
submitText?: string;
|
||||
isEditing?: boolean;
|
||||
onClose: (data?: { action: 'delete' } | { action: 'submit'; importPath: string | null }) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
importPath = $bindable(),
|
||||
importPaths = $bindable([]),
|
||||
title = $t('import_path'),
|
||||
cancelText = $t('cancel'),
|
||||
submitText = $t('save'),
|
||||
isEditing = false,
|
||||
onClose,
|
||||
}: Props = $props();
|
||||
|
||||
onMount(() => {
|
||||
if (isEditing) {
|
||||
importPaths = importPaths.filter((path) => path !== importPath);
|
||||
}
|
||||
});
|
||||
|
||||
let isDuplicate = $derived(importPath !== null && importPaths.includes(importPath));
|
||||
let canSubmit = $derived(importPath !== '' && importPath !== null && !importPaths.includes(importPath));
|
||||
|
||||
const onsubmit = (event: Event) => {
|
||||
event.preventDefault();
|
||||
if (canSubmit) {
|
||||
onClose({ action: 'submit', importPath });
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<Modal {title} icon={mdiFolderSync} {onClose} size="small">
|
||||
<ModalBody>
|
||||
<form {onsubmit} autocomplete="off" id="library-import-path-form">
|
||||
<p class="py-5 text-sm">{$t('admin.library_import_path_description')}</p>
|
||||
|
||||
<div class="my-4 flex flex-col gap-2">
|
||||
<label class="immich-form-label" for="path">{$t('path')}</label>
|
||||
<input class="immich-form-input" id="path" name="path" type="text" bind:value={importPath} />
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex w-full gap-4">
|
||||
{#if isDuplicate}
|
||||
<p class="text-red-500 text-sm">{$t('errors.import_path_already_exists')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</form>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<HStack fullWidth>
|
||||
<Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>{cancelText}</Button>
|
||||
{#if isEditing}
|
||||
<Button shape="round" color="danger" fullWidth onclick={() => onClose({ action: 'delete' })}>
|
||||
{$t('delete')}
|
||||
</Button>
|
||||
{/if}
|
||||
<Button shape="round" type="submit" disabled={!canSubmit} fullWidth form="library-import-path-form">
|
||||
{submitText}
|
||||
</Button>
|
||||
</HStack>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
@@ -1,21 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { handleRenameLibrary } from '$lib/services/library.service';
|
||||
import type { LibraryResponseDto } from '@immich/sdk';
|
||||
import { Button, Field, HStack, Input, Modal, ModalBody, ModalFooter } from '@immich/ui';
|
||||
import { mdiRenameOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
library: Partial<LibraryResponseDto>;
|
||||
onClose: (library?: Partial<LibraryResponseDto>) => void;
|
||||
}
|
||||
type Props = {
|
||||
library: LibraryResponseDto;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
let { library, onClose }: Props = $props();
|
||||
|
||||
let newName = $state(library.name);
|
||||
|
||||
const onsubmit = (event: Event) => {
|
||||
event.preventDefault();
|
||||
onClose({ ...library, name: newName });
|
||||
const onsubmit = async () => {
|
||||
const success = await handleRenameLibrary(library, newName);
|
||||
|
||||
if (success) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import DateInput from '$lib/elements/DateInput.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 { getPreferredTimeZone, getTimezones, toDatetime, type ZoneOption } from '$lib/modals/timezone-utils';
|
||||
import { Button, HStack, Modal, ModalBody, ModalFooter, VStack } from '@immich/ui';
|
||||
|
||||
11
web/src/lib/services/asset.service.ts
Normal file
11
web/src/lib/services/asset.service.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { openFileUploadDialog } from '$lib/utils/file-uploader';
|
||||
import { copyAsset, deleteAssets } from '@immich/sdk';
|
||||
|
||||
export const handleReplaceAsset = async (oldAssetId: string) => {
|
||||
const [newAssetId] = await openFileUploadDialog({ multiple: false });
|
||||
await copyAsset({ assetCopyDto: { sourceId: oldAssetId, targetId: newAssetId } });
|
||||
await deleteAssets({ assetBulkDeleteDto: { ids: [oldAssetId], force: true } });
|
||||
|
||||
eventManager.emit('AssetReplace', { oldAssetId, newAssetId });
|
||||
};
|
||||
352
web/src/lib/services/library.service.ts
Normal file
352
web/src/lib/services/library.service.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import LibraryExclusionPatternAddModal from '$lib/modals/LibraryExclusionPatternAddModal.svelte';
|
||||
import LibraryExclusionPatternEditModal from '$lib/modals/LibraryExclusionPatternEditModal.svelte';
|
||||
import LibraryFolderAddModal from '$lib/modals/LibraryFolderAddModal.svelte';
|
||||
import LibraryFolderEditModal from '$lib/modals/LibraryFolderEditModal.svelte';
|
||||
import LibraryRenameModal from '$lib/modals/LibraryRenameModal.svelte';
|
||||
import LibraryUserPickerModal from '$lib/modals/LibraryUserPickerModal.svelte';
|
||||
import type { ActionItem } from '$lib/types';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import {
|
||||
createLibrary,
|
||||
deleteLibrary,
|
||||
QueueCommand,
|
||||
QueueName,
|
||||
runQueueCommandLegacy,
|
||||
scanLibrary,
|
||||
updateLibrary,
|
||||
type LibraryResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { modalManager, toastManager } from '@immich/ui';
|
||||
import { mdiPencilOutline, mdiPlusBoxOutline, mdiSync, mdiTrashCanOutline } from '@mdi/js';
|
||||
import type { MessageFormatter } from 'svelte-i18n';
|
||||
|
||||
export const getLibrariesActions = ($t: MessageFormatter) => {
|
||||
const ScanAll: ActionItem = {
|
||||
title: $t('scan_all_libraries'),
|
||||
icon: mdiSync,
|
||||
onSelect: () => void handleScanAllLibraries(),
|
||||
};
|
||||
|
||||
const Create: ActionItem = {
|
||||
title: $t('create_library'),
|
||||
icon: mdiPlusBoxOutline,
|
||||
onSelect: () => void handleCreateLibrary(),
|
||||
};
|
||||
|
||||
return { ScanAll, Create };
|
||||
};
|
||||
|
||||
export const getLibraryActions = ($t: MessageFormatter, library: LibraryResponseDto) => {
|
||||
const Rename: ActionItem = {
|
||||
icon: mdiPencilOutline,
|
||||
title: $t('rename'),
|
||||
onSelect: () => void modalManager.show(LibraryRenameModal, { library }),
|
||||
};
|
||||
|
||||
const Delete: ActionItem = {
|
||||
icon: mdiTrashCanOutline,
|
||||
title: $t('delete'),
|
||||
color: 'danger',
|
||||
onSelect: () => void handleDeleteLibrary(library),
|
||||
};
|
||||
|
||||
const AddFolder: ActionItem = {
|
||||
icon: mdiPlusBoxOutline,
|
||||
title: $t('add'),
|
||||
onSelect: () => void modalManager.show(LibraryFolderAddModal, { library }),
|
||||
};
|
||||
|
||||
const AddExclusionPattern: ActionItem = {
|
||||
icon: mdiPlusBoxOutline,
|
||||
title: $t('add'),
|
||||
onSelect: () => void modalManager.show(LibraryExclusionPatternAddModal, { library }),
|
||||
};
|
||||
|
||||
const Scan: ActionItem = {
|
||||
icon: mdiSync,
|
||||
title: $t('scan_library'),
|
||||
onSelect: () => void handleScanLibrary(library),
|
||||
};
|
||||
|
||||
return { Rename, Delete, AddFolder, AddExclusionPattern, Scan };
|
||||
};
|
||||
|
||||
export const getLibraryFolderActions = ($t: MessageFormatter, library: LibraryResponseDto, folder: string) => {
|
||||
const Edit: ActionItem = {
|
||||
icon: mdiPencilOutline,
|
||||
title: $t('edit'),
|
||||
onSelect: () => void modalManager.show(LibraryFolderEditModal, { folder, library }),
|
||||
};
|
||||
|
||||
const Delete: ActionItem = {
|
||||
icon: mdiTrashCanOutline,
|
||||
title: $t('delete'),
|
||||
onSelect: () => void handleDeleteLibraryFolder(library, folder),
|
||||
};
|
||||
|
||||
return { Edit, Delete };
|
||||
};
|
||||
|
||||
export const getLibraryExclusionPatternActions = (
|
||||
$t: MessageFormatter,
|
||||
library: LibraryResponseDto,
|
||||
exclusionPattern: string,
|
||||
) => {
|
||||
const Edit: ActionItem = {
|
||||
icon: mdiPencilOutline,
|
||||
title: $t('edit'),
|
||||
onSelect: () => void modalManager.show(LibraryExclusionPatternEditModal, { exclusionPattern, library }),
|
||||
};
|
||||
|
||||
const Delete: ActionItem = {
|
||||
icon: mdiTrashCanOutline,
|
||||
title: $t('delete'),
|
||||
onSelect: () => void handleDeleteExclusionPattern(library, exclusionPattern),
|
||||
};
|
||||
|
||||
return { Edit, Delete };
|
||||
};
|
||||
|
||||
const handleScanAllLibraries = async () => {
|
||||
const $t = await getFormatter();
|
||||
|
||||
try {
|
||||
await runQueueCommandLegacy({ name: QueueName.Library, queueCommandDto: { command: QueueCommand.Start } });
|
||||
toastManager.info($t('admin.refreshing_all_libraries'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_scan_libraries'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleScanLibrary = async (library: LibraryResponseDto) => {
|
||||
const $t = await getFormatter();
|
||||
try {
|
||||
await scanLibrary({ id: library.id });
|
||||
toastManager.info($t('admin.scanning_library'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_scan_library'));
|
||||
}
|
||||
};
|
||||
|
||||
export const handleViewLibrary = async (library: LibraryResponseDto) => {
|
||||
await goto(`${AppRoute.ADMIN_LIBRARY_MANAGEMENT}/${library.id}`);
|
||||
};
|
||||
|
||||
export const handleCreateLibrary = async () => {
|
||||
const $t = await getFormatter();
|
||||
|
||||
const ownerId = await modalManager.show(LibraryUserPickerModal, {});
|
||||
if (!ownerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const createdLibrary = await createLibrary({ createLibraryDto: { ownerId } });
|
||||
eventManager.emit('LibraryCreate', createdLibrary);
|
||||
toastManager.success($t('admin.library_created', { values: { library: createdLibrary.name } }));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_create_library'));
|
||||
}
|
||||
};
|
||||
|
||||
export const handleRenameLibrary = async (library: { id: string }, name?: string) => {
|
||||
const $t = await getFormatter();
|
||||
|
||||
if (!name) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const updatedLibrary = await updateLibrary({
|
||||
id: library.id,
|
||||
updateLibraryDto: { name },
|
||||
});
|
||||
eventManager.emit('LibraryUpdate', updatedLibrary);
|
||||
toastManager.success($t('admin.library_updated'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_update_library'));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleDeleteLibrary = async (library: LibraryResponseDto) => {
|
||||
const $t = await getFormatter();
|
||||
|
||||
const confirmed = await modalManager.showDialog({
|
||||
prompt: $t('admin.confirm_delete_library', { values: { library: library.name } }),
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (library.assetCount > 0) {
|
||||
const isConfirmed = await modalManager.showDialog({
|
||||
prompt: $t('admin.confirm_delete_library_assets', { values: { count: library.assetCount } }),
|
||||
});
|
||||
if (!isConfirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteLibrary({ id: library.id });
|
||||
eventManager.emit('LibraryDelete', { id: library.id });
|
||||
toastManager.success($t('admin.library_deleted'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_remove_library'));
|
||||
}
|
||||
};
|
||||
|
||||
export const handleAddLibraryFolder = async (library: LibraryResponseDto, folder: string) => {
|
||||
const $t = await getFormatter();
|
||||
|
||||
if (library.importPaths.includes(folder)) {
|
||||
toastManager.danger($t('errors.library_folder_already_exists'));
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const updatedLibrary = await updateLibrary({
|
||||
id: library.id,
|
||||
updateLibraryDto: { importPaths: [...library.importPaths, folder] },
|
||||
});
|
||||
eventManager.emit('LibraryUpdate', updatedLibrary);
|
||||
toastManager.success($t('admin.library_updated'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_update_library'));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const handleEditLibraryFolder = async (library: LibraryResponseDto, oldFolder: string, newFolder: string) => {
|
||||
const $t = await getFormatter();
|
||||
|
||||
if (oldFolder === newFolder) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const importPaths = library.importPaths.map((path) => (path === oldFolder ? newFolder : path));
|
||||
|
||||
try {
|
||||
const updatedLibrary = await updateLibrary({ id: library.id, updateLibraryDto: { importPaths } });
|
||||
eventManager.emit('LibraryUpdate', updatedLibrary);
|
||||
toastManager.success($t('admin.library_updated'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_update_library'));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleDeleteLibraryFolder = async (library: LibraryResponseDto, folder: string) => {
|
||||
const $t = await getFormatter();
|
||||
|
||||
const confirmed = await modalManager.showDialog({
|
||||
prompt: $t('admin.library_remove_folder_prompt'),
|
||||
confirmText: $t('remove'),
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const updatedLibrary = await updateLibrary({
|
||||
id: library.id,
|
||||
updateLibraryDto: { importPaths: library.importPaths.filter((path) => path !== folder) },
|
||||
});
|
||||
eventManager.emit('LibraryUpdate', updatedLibrary);
|
||||
toastManager.success($t('admin.library_updated'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_update_library'));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const handleAddLibraryExclusionPattern = async (library: LibraryResponseDto, exclusionPattern: string) => {
|
||||
const $t = await getFormatter();
|
||||
|
||||
if (library.exclusionPatterns.includes(exclusionPattern)) {
|
||||
toastManager.danger($t('errors.exclusion_pattern_already_exists'));
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const updatedLibrary = await updateLibrary({
|
||||
id: library.id,
|
||||
updateLibraryDto: { exclusionPatterns: [...library.exclusionPatterns, exclusionPattern] },
|
||||
});
|
||||
eventManager.emit('LibraryUpdate', updatedLibrary);
|
||||
toastManager.success($t('admin.library_updated'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_update_library'));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const handleEditExclusionPattern = async (
|
||||
library: LibraryResponseDto,
|
||||
oldExclusionPattern: string,
|
||||
newExclusionPattern: string,
|
||||
) => {
|
||||
const $t = await getFormatter();
|
||||
|
||||
if (oldExclusionPattern === newExclusionPattern) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const exclusionPatterns = library.exclusionPatterns.map((pattern) =>
|
||||
pattern === oldExclusionPattern ? newExclusionPattern : pattern,
|
||||
);
|
||||
|
||||
try {
|
||||
const updatedLibrary = await updateLibrary({ id: library.id, updateLibraryDto: { exclusionPatterns } });
|
||||
eventManager.emit('LibraryUpdate', updatedLibrary);
|
||||
toastManager.success($t('admin.library_updated'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_update_library'));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleDeleteExclusionPattern = async (library: LibraryResponseDto, exclusionPattern: string) => {
|
||||
const $t = await getFormatter();
|
||||
|
||||
const confirmed = await modalManager.showDialog({ prompt: $t('admin.library_remove_exclusion_pattern_prompt') });
|
||||
|
||||
if (!confirmed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const updatedLibrary = await updateLibrary({
|
||||
id: library.id,
|
||||
updateLibraryDto: {
|
||||
exclusionPatterns: library.exclusionPatterns.filter((pattern) => pattern !== exclusionPattern),
|
||||
},
|
||||
});
|
||||
eventManager.emit('LibraryUpdate', updatedLibrary);
|
||||
toastManager.success($t('admin.library_updated'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_update_library'));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
44
web/src/lib/stores/ocr.svelte.ts
Normal file
44
web/src/lib/stores/ocr.svelte.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { getAssetOcr } from '@immich/sdk';
|
||||
|
||||
export type OcrBoundingBox = {
|
||||
id: string;
|
||||
assetId: string;
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
x3: number;
|
||||
y3: number;
|
||||
x4: number;
|
||||
y4: number;
|
||||
boxScore: number;
|
||||
textScore: number;
|
||||
text: string;
|
||||
};
|
||||
|
||||
class OcrManager {
|
||||
#data = $state<OcrBoundingBox[]>([]);
|
||||
showOverlay = $state(false);
|
||||
hasOcrData = $state(false);
|
||||
|
||||
get data() {
|
||||
return this.#data;
|
||||
}
|
||||
|
||||
async getAssetOcr(id: string) {
|
||||
this.#data = await getAssetOcr({ id });
|
||||
this.hasOcrData = this.#data.length > 0;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.#data = [];
|
||||
this.showOverlay = false;
|
||||
this.hasOcrData = false;
|
||||
}
|
||||
|
||||
toggleOcrBoundingBox() {
|
||||
this.showOverlay = !this.showOverlay;
|
||||
}
|
||||
}
|
||||
|
||||
export const ocrManager = new OcrManager();
|
||||
@@ -1,8 +1,10 @@
|
||||
import { page } from '$app/state';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { maintenanceStore } from '$lib/stores/maintenance.store';
|
||||
import { notificationManager } from '$lib/stores/notification-manager.svelte';
|
||||
import type { ReleaseEvent } from '$lib/types';
|
||||
import { createEventEmitter } from '$lib/utils/eventemitter';
|
||||
import {
|
||||
MaintenanceAction,
|
||||
@@ -15,14 +17,6 @@ import { io, type Socket } from 'socket.io-client';
|
||||
import { get, writable } from 'svelte/store';
|
||||
import { user } from './user.store';
|
||||
|
||||
export interface ReleaseEvent {
|
||||
isAvailable: boolean;
|
||||
/** ISO8601 */
|
||||
checkedAt: string;
|
||||
serverVersion: ServerVersionResponseDto;
|
||||
releaseVersion: ServerVersionResponseDto;
|
||||
}
|
||||
|
||||
interface AppRestartEvent {
|
||||
isMaintenanceMode: boolean;
|
||||
}
|
||||
@@ -39,7 +33,7 @@ export interface Events {
|
||||
on_person_thumbnail: (personId: string) => void;
|
||||
on_server_version: (serverVersion: ServerVersionResponseDto) => void;
|
||||
on_config_update: () => void;
|
||||
on_new_release: (newRelease: ReleaseEvent) => void;
|
||||
on_new_release: (event: ReleaseEvent) => void;
|
||||
on_session_delete: (sessionId: string) => void;
|
||||
on_notification: (notification: NotificationDto) => void;
|
||||
|
||||
@@ -59,7 +53,6 @@ const websocket: Socket<Events> = io({
|
||||
export const websocketStore = {
|
||||
connected: writable<boolean>(false),
|
||||
serverVersion: writable<ServerVersionResponseDto>(),
|
||||
release: writable<ReleaseEvent>(),
|
||||
serverRestarting: writable<undefined | AppRestartEvent>(),
|
||||
};
|
||||
|
||||
@@ -79,8 +72,9 @@ websocket
|
||||
});
|
||||
}
|
||||
})
|
||||
.on('on_new_release', (releaseVersion) => websocketStore.release.set(releaseVersion))
|
||||
.on('on_new_release', (event) => eventManager.emit('ReleaseEvent', event))
|
||||
.on('on_session_delete', () => authManager.logout())
|
||||
.on('on_user_delete', (id) => eventManager.emit('UserAdminDeleted', { id }))
|
||||
.on('on_notification', () => notificationManager.refresh())
|
||||
.on('connect_error', (e) => console.log('Websocket Connect Error', e));
|
||||
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
import type { ServerVersionResponseDto } from '@immich/sdk';
|
||||
import type { MenuItem } from '@immich/ui';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
export type ActionItem = MenuItem & { props?: Omit<HTMLAttributes<HTMLElement>, 'color'> };
|
||||
|
||||
export interface ReleaseEvent {
|
||||
isAvailable: boolean;
|
||||
/** ISO8601 */
|
||||
checkedAt: string;
|
||||
serverVersion: ServerVersionResponseDto;
|
||||
releaseVersion: ServerVersionResponseDto;
|
||||
}
|
||||
|
||||
@@ -109,5 +109,5 @@ export function updateUnstackedAssetInTimeline(timelineManager: TimelineManager,
|
||||
},
|
||||
);
|
||||
|
||||
timelineManager.addAssets(assets);
|
||||
timelineManager.upsertAssets(assets);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import ToastAction from '$lib/components/ToastAction.svelte';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { downloadManager } from '$lib/managers/download-manager.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 { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
|
||||
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
AssetMediaStatus,
|
||||
AssetVisibility,
|
||||
checkBulkUpload,
|
||||
getAssetOriginalPath,
|
||||
getBaseUrl,
|
||||
type AssetMediaResponseDto,
|
||||
} from '@immich/sdk';
|
||||
@@ -44,12 +43,10 @@ export const addDummyItems = () => {
|
||||
|
||||
export const uploadExecutionQueue = new ExecutorQueue({ concurrency: 2 });
|
||||
|
||||
type FileUploadParam = { multiple?: boolean } & (
|
||||
| { albumId?: string; assetId?: never }
|
||||
| { albumId?: never; assetId?: string }
|
||||
);
|
||||
type FileUploadParam = { multiple?: boolean; albumId?: string };
|
||||
|
||||
export const openFileUploadDialog = async (options: FileUploadParam = {}) => {
|
||||
const { albumId, multiple = true, assetId } = options;
|
||||
const { albumId, multiple = true } = options;
|
||||
const extensions = uploadManager.getExtensions();
|
||||
|
||||
return new Promise<string[]>((resolve, reject) => {
|
||||
@@ -68,7 +65,7 @@ export const openFileUploadDialog = async (options: FileUploadParam = {}) => {
|
||||
}
|
||||
const files = Array.from(target.files);
|
||||
|
||||
resolve(fileUploadHandler({ files, albumId, replaceAssetId: assetId }));
|
||||
resolve(fileUploadHandler({ files, albumId }));
|
||||
},
|
||||
{ passive: true },
|
||||
);
|
||||
@@ -88,7 +85,6 @@ type FileUploadHandlerParams = Omit<FileUploaderParams, 'deviceAssetId' | 'asset
|
||||
export const fileUploadHandler = async ({
|
||||
files,
|
||||
albumId,
|
||||
replaceAssetId,
|
||||
isLockedAssets = false,
|
||||
}: FileUploadHandlerParams): Promise<string[]> => {
|
||||
const extensions = uploadManager.getExtensions();
|
||||
@@ -99,9 +95,7 @@ export const fileUploadHandler = async ({
|
||||
const deviceAssetId = getDeviceAssetId(file);
|
||||
uploadAssetsStore.addItem({ id: deviceAssetId, file, albumId });
|
||||
promises.push(
|
||||
uploadExecutionQueue.addTask(() =>
|
||||
fileUploader({ assetFile: file, deviceAssetId, albumId, replaceAssetId, isLockedAssets }),
|
||||
),
|
||||
uploadExecutionQueue.addTask(() => fileUploader({ assetFile: file, deviceAssetId, albumId, isLockedAssets })),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -127,7 +121,6 @@ async function fileUploader({
|
||||
assetFile,
|
||||
deviceAssetId,
|
||||
albumId,
|
||||
replaceAssetId,
|
||||
isLockedAssets = false,
|
||||
}: FileUploaderParams): Promise<string | undefined> {
|
||||
const fileCreatedAt = new Date(assetFile.lastModified).toISOString();
|
||||
@@ -183,27 +176,17 @@ async function fileUploader({
|
||||
const queryParams = asQueryString(authManager.params);
|
||||
|
||||
uploadAssetsStore.updateItem(deviceAssetId, { message: $t('asset_uploading') });
|
||||
if (replaceAssetId) {
|
||||
const response = await uploadRequest<AssetMediaResponseDto>({
|
||||
url: getBaseUrl() + getAssetOriginalPath(replaceAssetId) + (queryParams ? `?${queryParams}` : ''),
|
||||
method: 'PUT',
|
||||
data: formData,
|
||||
onUploadProgress: (event) => uploadAssetsStore.updateProgress(deviceAssetId, event.loaded, event.total),
|
||||
});
|
||||
responseData = response.data;
|
||||
} else {
|
||||
const response = await uploadRequest<AssetMediaResponseDto>({
|
||||
url: getBaseUrl() + '/assets' + (queryParams ? `?${queryParams}` : ''),
|
||||
data: formData,
|
||||
onUploadProgress: (event) => uploadAssetsStore.updateProgress(deviceAssetId, event.loaded, event.total),
|
||||
});
|
||||
const response = await uploadRequest<AssetMediaResponseDto>({
|
||||
url: getBaseUrl() + '/assets' + (queryParams ? `?${queryParams}` : ''),
|
||||
data: formData,
|
||||
onUploadProgress: (event) => uploadAssetsStore.updateProgress(deviceAssetId, event.loaded, event.total),
|
||||
});
|
||||
|
||||
if (![200, 201].includes(response.status)) {
|
||||
throw new Error($t('errors.unable_to_upload_file'));
|
||||
}
|
||||
|
||||
responseData = response.data;
|
||||
if (![200, 201].includes(response.status)) {
|
||||
throw new Error($t('errors.unable_to_upload_file'));
|
||||
}
|
||||
|
||||
responseData = response.data;
|
||||
}
|
||||
|
||||
if (responseData.status === AssetMediaStatus.Duplicate) {
|
||||
|
||||
131
web/src/lib/utils/ocr-utils.ts
Normal file
131
web/src/lib/utils/ocr-utils.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import type { OcrBoundingBox } from '$lib/stores/ocr.svelte';
|
||||
import type { ZoomImageWheelState } from '@zoom-image/core';
|
||||
|
||||
const getContainedSize = (img: HTMLImageElement): { width: number; height: number } => {
|
||||
const ratio = img.naturalWidth / img.naturalHeight;
|
||||
let width = img.height * ratio;
|
||||
let height = img.height;
|
||||
if (width > img.width) {
|
||||
width = img.width;
|
||||
height = img.width / ratio;
|
||||
}
|
||||
return { width, height };
|
||||
};
|
||||
|
||||
export interface OcrBox {
|
||||
id: string;
|
||||
points: { x: number; y: number }[];
|
||||
text: string;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
export interface BoundingBoxDimensions {
|
||||
minX: number;
|
||||
maxX: number;
|
||||
minY: number;
|
||||
maxY: number;
|
||||
width: number;
|
||||
height: number;
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
rotation: number;
|
||||
skewX: number;
|
||||
skewY: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate bounding box dimensions and properties from OCR points
|
||||
* @param points - Array of 4 corner points of the bounding box
|
||||
* @returns Dimensions, rotation, and skew values for the bounding box
|
||||
*/
|
||||
export const calculateBoundingBoxDimensions = (points: { x: number; y: number }[]): BoundingBoxDimensions => {
|
||||
const [topLeft, topRight, bottomRight, bottomLeft] = points;
|
||||
const minX = Math.min(...points.map(({ x }) => x));
|
||||
const maxX = Math.max(...points.map(({ x }) => x));
|
||||
const minY = Math.min(...points.map(({ y }) => y));
|
||||
const maxY = Math.max(...points.map(({ y }) => y));
|
||||
const width = maxX - minX;
|
||||
const height = maxY - minY;
|
||||
const centerX = (minX + maxX) / 2;
|
||||
const centerY = (minY + maxY) / 2;
|
||||
|
||||
// Calculate rotation angle from the bottom edge (bottomLeft to bottomRight)
|
||||
const rotation = Math.atan2(bottomRight.y - bottomLeft.y, bottomRight.x - bottomLeft.x) * (180 / Math.PI);
|
||||
|
||||
// Calculate skew angles to handle perspective distortion
|
||||
// SkewX: compare left and right edges
|
||||
const leftEdgeAngle = Math.atan2(bottomLeft.y - topLeft.y, bottomLeft.x - topLeft.x);
|
||||
const rightEdgeAngle = Math.atan2(bottomRight.y - topRight.y, bottomRight.x - topRight.x);
|
||||
const skewX = (rightEdgeAngle - leftEdgeAngle) * (180 / Math.PI);
|
||||
|
||||
// SkewY: compare top and bottom edges
|
||||
const topEdgeAngle = Math.atan2(topRight.y - topLeft.y, topRight.x - topLeft.x);
|
||||
const bottomEdgeAngle = Math.atan2(bottomRight.y - bottomLeft.y, bottomRight.x - bottomLeft.x);
|
||||
const skewY = (bottomEdgeAngle - topEdgeAngle) * (180 / Math.PI);
|
||||
|
||||
return {
|
||||
minX,
|
||||
maxX,
|
||||
minY,
|
||||
maxY,
|
||||
width,
|
||||
height,
|
||||
centerX,
|
||||
centerY,
|
||||
rotation,
|
||||
skewX,
|
||||
skewY,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert normalized OCR coordinates to screen coordinates
|
||||
* OCR coordinates are normalized (0-1) and represent the 4 corners of a rotated rectangle
|
||||
*/
|
||||
export const getOcrBoundingBoxes = (
|
||||
ocrData: OcrBoundingBox[],
|
||||
zoom: ZoomImageWheelState,
|
||||
photoViewer: HTMLImageElement | null,
|
||||
): OcrBox[] => {
|
||||
const boxes: OcrBox[] = [];
|
||||
|
||||
if (photoViewer === null || !photoViewer.naturalWidth || !photoViewer.naturalHeight) {
|
||||
return boxes;
|
||||
}
|
||||
|
||||
const clientHeight = photoViewer.clientHeight;
|
||||
const clientWidth = photoViewer.clientWidth;
|
||||
const { width, height } = getContainedSize(photoViewer);
|
||||
|
||||
const imageWidth = photoViewer.naturalWidth;
|
||||
const imageHeight = photoViewer.naturalHeight;
|
||||
|
||||
for (const ocr of ocrData) {
|
||||
// Convert normalized coordinates (0-1) to actual pixel positions
|
||||
// OCR provides 4 corners of a potentially rotated rectangle
|
||||
const points = [
|
||||
{ x: ocr.x1, y: ocr.y1 },
|
||||
{ x: ocr.x2, y: ocr.y2 },
|
||||
{ x: ocr.x3, y: ocr.y3 },
|
||||
{ x: ocr.x4, y: ocr.y4 },
|
||||
].map((point) => ({
|
||||
x:
|
||||
(width / imageWidth) * zoom.currentZoom * point.x * imageWidth +
|
||||
((clientWidth - width) / 2) * zoom.currentZoom +
|
||||
zoom.currentPositionX,
|
||||
y:
|
||||
(height / imageHeight) * zoom.currentZoom * point.y * imageHeight +
|
||||
((clientHeight - height) / 2) * zoom.currentZoom +
|
||||
zoom.currentPositionY,
|
||||
}));
|
||||
|
||||
boxes.push({
|
||||
id: ocr.id,
|
||||
points,
|
||||
text: ocr.text,
|
||||
confidence: ocr.textScore,
|
||||
});
|
||||
}
|
||||
|
||||
return boxes;
|
||||
};
|
||||
Reference in New Issue
Block a user