mirror of
https://github.com/immich-app/immich.git
synced 2025-12-23 09:15:05 +03:00
merge main
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { MaintenanceAction, setMaintenanceMode } from '@immich/sdk';
|
||||
import { Button } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let { disabled = false }: Props = $props();
|
||||
|
||||
async function start() {
|
||||
try {
|
||||
await setMaintenanceMode({
|
||||
setMaintenanceModeDto: {
|
||||
action: MaintenanceAction.Start,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
handleError(error, $t('admin.maintenance_start_error'));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<div class="ms-4 mt-4 flex items-end gap-4">
|
||||
<Button shape="round" type="submit" {disabled} size="small" onclick={start}
|
||||
>{$t('admin.maintenance_start')}</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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
|
||||
|
||||
@@ -176,12 +176,24 @@
|
||||
};
|
||||
|
||||
const scrollAndLoadAsset = async (assetId: string) => {
|
||||
const monthGroup = await timelineManager.findMonthGroupForAsset(assetId);
|
||||
if (!monthGroup) {
|
||||
return false;
|
||||
try {
|
||||
// This flag prevents layout deferral to fix scroll positioning issues.
|
||||
// When layouts are deferred and we scroll to an asset at the end of the timeline,
|
||||
// we can calculate the asset's position, but the scrollableElement's scrollHeight
|
||||
// hasn't been updated yet to reflect the new layout. This creates a mismatch that
|
||||
// breaks scroll positioning. By disabling layout deferral in this case, we maintain
|
||||
// the performance benefits of deferred layouts while still supporting deep linking
|
||||
// to assets at the end of the timeline.
|
||||
timelineManager.isScrollingOnLoad = true;
|
||||
const monthGroup = await timelineManager.findMonthGroupForAsset(assetId);
|
||||
if (!monthGroup) {
|
||||
return false;
|
||||
}
|
||||
scrollToAssetPosition(assetId, monthGroup);
|
||||
return true;
|
||||
} finally {
|
||||
timelineManager.isScrollingOnLoad = false;
|
||||
}
|
||||
scrollToAssetPosition(assetId, monthGroup);
|
||||
return true;
|
||||
};
|
||||
|
||||
const scrollToAsset = (asset: TimelineAsset) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user