refactor: asset navbar (#25476)

This commit is contained in:
Jason Rasmussen
2026-01-23 14:06:19 -05:00
committed by GitHub
parent d942e7212a
commit af51a11b1b
5 changed files with 70 additions and 77 deletions

View File

@@ -1,20 +0,0 @@
<script lang="ts">
import { IconButton } from '@immich/ui';
import { mdiTune } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
onAction: () => void;
}
let { onAction }: Props = $props();
</script>
<IconButton
color="secondary"
shape="round"
variant="ghost"
icon={mdiTune}
aria-label={$t('editor')}
onclick={() => onAction()}
/>

View File

@@ -7,7 +7,6 @@
import AddToStackAction from '$lib/components/asset-viewer/actions/add-to-stack-action.svelte'; import AddToStackAction from '$lib/components/asset-viewer/actions/add-to-stack-action.svelte';
import ArchiveAction from '$lib/components/asset-viewer/actions/archive-action.svelte'; import ArchiveAction from '$lib/components/asset-viewer/actions/archive-action.svelte';
import DeleteAction from '$lib/components/asset-viewer/actions/delete-action.svelte'; import DeleteAction from '$lib/components/asset-viewer/actions/delete-action.svelte';
import EditAction from '$lib/components/asset-viewer/actions/edit-action.svelte';
import KeepThisDeleteOthersAction from '$lib/components/asset-viewer/actions/keep-this-delete-others.svelte'; import KeepThisDeleteOthersAction from '$lib/components/asset-viewer/actions/keep-this-delete-others.svelte';
import RatingAction from '$lib/components/asset-viewer/actions/rating-action.svelte'; import RatingAction from '$lib/components/asset-viewer/actions/rating-action.svelte';
import RemoveAssetFromStack from '$lib/components/asset-viewer/actions/remove-asset-from-stack.svelte'; import RemoveAssetFromStack from '$lib/components/asset-viewer/actions/remove-asset-from-stack.svelte';
@@ -20,7 +19,6 @@
import UnstackAction from '$lib/components/asset-viewer/actions/unstack-action.svelte'; import UnstackAction from '$lib/components/asset-viewer/actions/unstack-action.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { ProjectionType } from '$lib/constants';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { Route } from '$lib/route'; import { Route } from '$lib/route';
import { getGlobalActions } from '$lib/services/app.service'; import { getGlobalActions } from '$lib/services/app.service';
@@ -67,7 +65,6 @@
onAction: OnAction; onAction: OnAction;
onUndoDelete?: OnUndoDelete; onUndoDelete?: OnUndoDelete;
onPlaySlideshow: () => void; onPlaySlideshow: () => void;
onEdit: () => void;
onClose?: () => void; onClose?: () => void;
playOriginalVideo: boolean; playOriginalVideo: boolean;
setPlayOriginalVideo: (value: boolean) => void; setPlayOriginalVideo: (value: boolean) => void;
@@ -86,25 +83,41 @@
onUndoDelete = undefined, onUndoDelete = undefined,
onPlaySlideshow, onPlaySlideshow,
onClose, onClose,
onEdit,
playOriginalVideo = false, playOriginalVideo = false,
setPlayOriginalVideo, setPlayOriginalVideo,
}: Props = $props(); }: Props = $props();
let isOwner = $derived($user && asset.ownerId === $user?.id); const isOwner = $derived($user && asset.ownerId === $user?.id);
let isLocked = $derived(asset.visibility === AssetVisibility.Locked); const isLocked = $derived(asset.visibility === AssetVisibility.Locked);
let smartSearchEnabled = $derived(featureFlagsManager.value.smartSearch); const isImage = $derived(asset.type === AssetTypeEnum.Image);
const smartSearchEnabled = $derived(featureFlagsManager.value.smartSearch);
const Close: ActionItem = { const { Cast } = $derived(getGlobalActions($t));
const { Close, ZoomIn, ZoomOut } = $derived({
Close: {
title: $t('go_back'), title: $t('go_back'),
type: $t('assets'), type: $t('assets'),
icon: mdiArrowLeft, icon: mdiArrowLeft,
$if: () => !!onClose, $if: () => !!onClose,
onAction: () => onClose?.(), onAction: () => onClose?.(),
shortcuts: [{ key: 'Escape' }], shortcuts: [{ key: 'Escape' }],
}; },
const { Cast } = $derived(getGlobalActions($t)); ZoomIn: {
title: $t('zoom_image'),
icon: mdiMagnifyPlusOutline,
$if: () => isImage && $photoZoomState && $photoZoomState.currentZoom <= 1,
onAction: () => onZoomImage(),
},
ZoomOut: {
title: $t('zoom_image'),
icon: mdiMagnifyMinusOutline,
$if: () => $photoZoomState && $photoZoomState.currentZoom > 1,
onAction: () => onZoomImage(),
},
} satisfies Record<string, ActionItem>);
const { const {
Share, Share,
@@ -117,22 +130,13 @@
PlayMotionPhoto, PlayMotionPhoto,
StopMotionPhoto, StopMotionPhoto,
Info, Info,
Edit,
RefreshFacesJob, RefreshFacesJob,
RefreshMetadataJob, RefreshMetadataJob,
RegenerateThumbnailJob, RegenerateThumbnailJob,
TranscodeVideoJob, TranscodeVideoJob,
} = $derived(getAssetActions($t, asset)); } = $derived(getAssetActions($t, asset));
const sharedLink = getSharedLink(); const sharedLink = getSharedLink();
const editorDisabled = $derived(
!isOwner ||
asset.type !== AssetTypeEnum.Image ||
asset.livePhotoVideoId ||
(asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR &&
asset.originalPath.toLowerCase().endsWith('.insp')) ||
asset.originalPath.toLowerCase().endsWith('.gif') ||
asset.originalPath.toLowerCase().endsWith('.svg'),
);
</script> </script>
<CommandPaletteDefaultProvider <CommandPaletteDefaultProvider
@@ -150,6 +154,7 @@
PlayMotionPhoto, PlayMotionPhoto,
StopMotionPhoto, StopMotionPhoto,
Info, Info,
Edit,
RefreshFacesJob, RefreshFacesJob,
RefreshMetadataJob, RefreshMetadataJob,
RegenerateThumbnailJob, RegenerateThumbnailJob,
@@ -170,18 +175,9 @@
<ActionButton action={Offline} /> <ActionButton action={Offline} />
<ActionButton action={PlayMotionPhoto} /> <ActionButton action={PlayMotionPhoto} />
<ActionButton action={StopMotionPhoto} /> <ActionButton action={StopMotionPhoto} />
<ActionButton action={ZoomIn} />
<ActionButton action={ZoomOut} />
{#if asset.type === AssetTypeEnum.Image}
<IconButton
class="hidden sm:flex"
color="secondary"
variant="ghost"
shape="round"
icon={$photoZoomState && $photoZoomState.currentZoom > 1 ? mdiMagnifyMinusOutline : mdiMagnifyPlusOutline}
aria-label={$t('zoom_image')}
onclick={onZoomImage}
/>
{/if}
{#if canCopyImageToClipboard() && asset.type === AssetTypeEnum.Image && $photoViewerImgElement} {#if canCopyImageToClipboard() && asset.type === AssetTypeEnum.Image && $photoViewerImgElement}
<IconButton <IconButton
color="secondary" color="secondary"
@@ -202,9 +198,7 @@
<RatingAction {asset} {onAction} /> <RatingAction {asset} {onAction} />
{/if} {/if}
{#if !editorDisabled} <ActionButton action={Edit} />
<EditAction onAction={onEdit} />
{/if}
{#if isOwner} {#if isOwner}
<DeleteAction {asset} {onAction} {preAction} {onUndoDelete} /> <DeleteAction {asset} {onAction} {preAction} {onUndoDelete} />

View File

@@ -106,7 +106,6 @@
let appearsInAlbums: AlbumResponseDto[] = $state([]); let appearsInAlbums: AlbumResponseDto[] = $state([]);
let sharedLink = getSharedLink(); let sharedLink = getSharedLink();
let previewStackedAsset: AssetResponseDto | undefined = $state(); let previewStackedAsset: AssetResponseDto | undefined = $state();
let isShowEditor = $state(false);
let fullscreenElement = $state<Element>(); let fullscreenElement = $state<Element>();
let unsubscribes: (() => void)[] = []; let unsubscribes: (() => void)[] = [];
let stack: StackResponseDto | null = $state(null); let stack: StackResponseDto | null = $state(null);
@@ -202,7 +201,7 @@
onAssetChange?.(refreshedAsset); onAssetChange?.(refreshedAsset);
assetViewingStore.setAsset(refreshedAsset); assetViewingStore.setAsset(refreshedAsset);
} }
isShowEditor = false; assetViewerManager.closeEditor();
}; };
const tracker = new InvocationTracker(); const tracker = new InvocationTracker();
@@ -249,13 +248,6 @@
}, $t('error_while_navigating')); }, $t('error_while_navigating'));
}; };
const showEditor = () => {
if (assetViewerManager.isShowActivityPanel) {
assetViewerManager.isShowActivityPanel = false;
}
isShowEditor = !isShowEditor;
};
/** /**
* Slide show mode * Slide show mode
*/ */
@@ -412,7 +404,7 @@
) { ) {
return 'ImagePanaramaViewer'; return 'ImagePanaramaViewer';
} }
if (isShowEditor && editManager.selectedTool?.type === EditToolType.Transform) { if (assetViewerManager.isShowEditor && editManager.selectedTool?.type === EditToolType.Transform) {
return 'CropArea'; return 'CropArea';
} }
return 'PhotoViewer'; return 'PhotoViewer';
@@ -429,7 +421,7 @@
$slideshowState === SlideshowState.None && $slideshowState === SlideshowState.None &&
asset.type === AssetTypeEnum.Image && asset.type === AssetTypeEnum.Image &&
!(asset.exifInfo?.projectionType === 'EQUIRECTANGULAR') && !(asset.exifInfo?.projectionType === 'EQUIRECTANGULAR') &&
!isShowEditor && !assetViewerManager.isShowEditor &&
ocrManager.hasOcrData, ocrManager.hasOcrData,
); );
</script> </script>
@@ -445,7 +437,7 @@
bind:this={assetViewerHtmlElement} bind:this={assetViewerHtmlElement}
> >
<!-- Top navigation bar --> <!-- Top navigation bar -->
{#if $slideshowState === SlideshowState.None && !isShowEditor} {#if $slideshowState === SlideshowState.None && !assetViewerManager.isShowEditor}
<div class="col-span-4 col-start-1 row-span-1 row-start-1 transition-transform"> <div class="col-span-4 col-start-1 row-span-1 row-start-1 transition-transform">
<AssetViewerNavBar <AssetViewerNavBar
{asset} {asset}
@@ -458,7 +450,6 @@
preAction={handlePreAction} preAction={handlePreAction}
onAction={handleAction} onAction={handleAction}
{onUndoDelete} {onUndoDelete}
onEdit={showEditor}
onPlaySlideshow={() => ($slideshowState = SlideshowState.PlaySlideshow)} onPlaySlideshow={() => ($slideshowState = SlideshowState.PlaySlideshow)}
onClose={onClose ? () => onClose(asset) : undefined} onClose={onClose ? () => onClose(asset) : undefined}
{playOriginalVideo} {playOriginalVideo}
@@ -480,7 +471,7 @@
</div> </div>
{/if} {/if}
{#if $slideshowState === SlideshowState.None && showNavigation && !isShowEditor && previousAsset} {#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && previousAsset}
<div class="my-auto col-span-1 col-start-1 row-span-full row-start-1 justify-self-start"> <div class="my-auto col-span-1 col-start-1 row-span-full row-start-1 justify-self-start">
<PreviousAssetAction onPreviousAsset={() => navigateAsset('previous')} /> <PreviousAssetAction onPreviousAsset={() => navigateAsset('previous')} />
</div> </div>
@@ -571,13 +562,13 @@
{/if} {/if}
</div> </div>
{#if $slideshowState === SlideshowState.None && showNavigation && !isShowEditor && nextAsset} {#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && nextAsset}
<div class="my-auto col-span-1 col-start-4 row-span-full row-start-1 justify-self-end"> <div class="my-auto col-span-1 col-start-4 row-span-full row-start-1 justify-self-end">
<NextAssetAction onNextAsset={() => navigateAsset('next')} /> <NextAssetAction onNextAsset={() => navigateAsset('next')} />
</div> </div>
{/if} {/if}
{#if asset.hasMetadata && $slideshowState === SlideshowState.None && assetViewerManager.isShowDetailPanel && !isShowEditor} {#if asset.hasMetadata && $slideshowState === SlideshowState.None && assetViewerManager.isShowDetailPanel && !assetViewerManager.isShowEditor}
<div <div
transition:fly={{ duration: 150 }} transition:fly={{ duration: 150 }}
id="detail-panel" id="detail-panel"
@@ -588,7 +579,7 @@
</div> </div>
{/if} {/if}
{#if isShowEditor} {#if assetViewerManager.isShowEditor}
<div <div
transition:fly={{ duration: 150 }} transition:fly={{ duration: 150 }}
id="editor-panel" id="editor-panel"

View File

@@ -5,6 +5,7 @@ const isShowDetailPanel = new PersistedLocalStorage<boolean>('asset-viewer-state
export class AssetViewerManager { export class AssetViewerManager {
isShowActivityPanel = $state(false); isShowActivityPanel = $state(false);
isPlayingMotionPhoto = $state(false); isPlayingMotionPhoto = $state(false);
isShowEditor = $state(false);
get isShowDetailPanel() { get isShowDetailPanel() {
return isShowDetailPanel.current; return isShowDetailPanel.current;
@@ -31,6 +32,15 @@ export class AssetViewerManager {
closeDetailPanel() { closeDetailPanel() {
this.isShowDetailPanel = false; this.isShowDetailPanel = false;
} }
openEditor() {
this.closeActivityPanel();
this.isShowEditor = true;
}
closeEditor() {
this.isShowEditor = false;
}
} }
export const assetViewerManager = new AssetViewerManager(); export const assetViewerManager = new AssetViewerManager();

View File

@@ -1,3 +1,4 @@
import { ProjectionType } from '$lib/constants';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte';
@@ -37,6 +38,7 @@ import {
mdiMotionPauseOutline, mdiMotionPauseOutline,
mdiMotionPlayOutline, mdiMotionPlayOutline,
mdiShareVariantOutline, mdiShareVariantOutline,
mdiTune,
} from '@mdi/js'; } from '@mdi/js';
import type { MessageFormatter } from 'svelte-i18n'; import type { MessageFormatter } from 'svelte-i18n';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
@@ -132,6 +134,21 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
shortcuts: [{ key: 'i' }], shortcuts: [{ key: 'i' }],
}; };
const Edit: ActionItem = {
title: $t('editor'),
icon: mdiTune,
$if: () =>
!sharedLink &&
isOwner &&
asset.type === AssetTypeEnum.Image &&
!asset.livePhotoVideoId &&
asset.exifInfo?.projectionType !== ProjectionType.EQUIRECTANGULAR &&
!asset.originalPath.toLowerCase().endsWith('.insp') &&
!asset.originalPath.toLowerCase().endsWith('.gif') &&
!asset.originalPath.toLowerCase().endsWith('.svg'),
onAction: () => assetViewerManager.openEditor(),
};
const RefreshFacesJob: ActionItem = { const RefreshFacesJob: ActionItem = {
title: getAssetJobName($t, AssetJobName.RefreshFaces), title: getAssetJobName($t, AssetJobName.RefreshFaces),
icon: mdiHeadSyncOutline, icon: mdiHeadSyncOutline,
@@ -168,6 +185,7 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
Unfavorite, Unfavorite,
PlayMotionPhoto, PlayMotionPhoto,
StopMotionPhoto, StopMotionPhoto,
Edit,
RefreshFacesJob, RefreshFacesJob,
RefreshMetadataJob, RefreshMetadataJob,
RegenerateThumbnailJob, RegenerateThumbnailJob,