Compare commits

...

11 Commits

Author SHA1 Message Date
CJPeckover
af605876ad Merge branch 'immich-main' into feat/multi-select-asset-viewer 2025-09-16 13:49:32 -04:00
CJPeckover
585199553f revert pnpm-lock 2025-09-16 13:38:34 -04:00
CJPeckover
cb321afada revert pnpm-lock 2025-09-16 13:35:16 -04:00
CJPeckover
22cbf39fcf - revert pnpm-lock 2025-09-16 13:12:21 -04:00
CJPeckover
097dc3d21e Merge branch 'immich-main' into feat/multi-select-asset-viewer 2025-09-16 13:10:37 -04:00
CJPeckover
7fae893558 - make view icon visible on mobile 2025-09-16 13:07:56 -04:00
CJPeckover
669a7a9a10 - ensure gallery viewer has view icon
- ensure view icon is above video thumbnail
2025-09-16 13:00:41 -04:00
CJPeckover
360b1fea2d format/check 2025-09-16 01:46:26 -04:00
CJPeckover
8497364cc7 make assetViewer.assetInteraction optional, it is not needed on utility pages, map, or individual-shared-viewer 2025-09-16 01:27:51 -04:00
CJPeckover
cf539cc033 Add initial select logic to asset viewer 2025-09-16 00:54:46 -04:00
CJPeckover
52903e510e - add initial viewer button UI 2025-09-16 00:19:10 -04:00
6 changed files with 214 additions and 136 deletions

View File

@@ -19,9 +19,12 @@
import ShareAction from '$lib/components/asset-viewer/actions/share-action.svelte';
import ShowDetailAction from '$lib/components/asset-viewer/actions/show-detail-action.svelte';
import UnstackAction from '$lib/components/asset-viewer/actions/unstack-action.svelte';
import Icon from '$lib/components/elements/icon.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 { AppRoute } from '$lib/constants';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { featureFlags } from '$lib/stores/server-config.store';
import { user } from '$lib/stores/user.store';
import { photoZoomState } from '$lib/stores/zoom-image.store';
@@ -41,6 +44,7 @@
import { IconButton } from '@immich/ui';
import {
mdiAlertOutline,
mdiCheckCircle,
mdiCogRefreshOutline,
mdiCompare,
mdiContentCopy,
@@ -59,12 +63,14 @@
interface Props {
asset: AssetResponseDto;
assetInteraction?: AssetInteraction | null;
album?: AlbumResponseDto | null;
person?: PersonResponseDto | null;
stack?: StackResponseDto | null;
showCloseButton?: boolean;
showDetailButton: boolean;
showSlideshow?: boolean;
onSelectAsset?: (asset: TimelineAsset) => void;
onZoomImage: () => void;
onCopyImage?: () => Promise<void>;
preAction: PreAction;
@@ -79,12 +85,14 @@
let {
asset,
assetInteraction,
album = null,
person = null,
stack = null,
showCloseButton = true,
showDetailButton,
showSlideshow = false,
onSelectAsset,
onZoomImage,
onCopyImage,
preAction,
@@ -102,6 +110,7 @@
let isLocked = $derived(asset.visibility === AssetVisibility.Locked);
let smartSearchEnabled = $derived($featureFlags.loaded && $featureFlags.smartSearch);
let selected = $derived(assetInteraction?.hasSelectedAsset(asset.id));
// $: showEditorButton =
// isOwner &&
// asset.type === AssetTypeEnum.Image &&
@@ -122,149 +131,182 @@
{/if}
</div>
<div class="flex gap-2 overflow-x-auto dark" data-testid="asset-viewer-navbar-actions">
<CastButton />
{#if !asset.isTrashed && $user && !isLocked}
<ShareAction {asset} />
{/if}
{#if asset.isOffline}
<IconButton
shape="round"
color="danger"
icon={mdiAlertOutline}
onclick={onShowDetail}
aria-label={$t('asset_offline')}
/>
{/if}
{#if asset.livePhotoVideoId}
{@render motionPhoto?.()}
{/if}
{#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}
<IconButton
color="secondary"
variant="ghost"
shape="round"
icon={mdiContentCopy}
aria-label={$t('copy_image')}
onclick={() => onCopyImage?.()}
/>
{/if}
{#if !isOwner && showDownloadButton}
<DownloadAction asset={toTimelineAsset(asset)} />
{/if}
{#if showDetailButton}
<ShowDetailAction {onShowDetail} />
{/if}
{#if isOwner}
<FavoriteAction {asset} {onAction} />
{/if}
{#if isOwner}
<DeleteAction {asset} {onAction} {preAction} />
<ButtonContextMenu direction="left" align="top-right" color="secondary" title={$t('more')} icon={mdiDotsVertical}>
{#if showSlideshow && !isLocked}
<MenuOption icon={mdiPresentationPlay} text={$t('slideshow')} onClick={onPlaySlideshow} />
{#if !!onSelectAsset && assetInteraction?.selectionActive}
<p class="text-lg text-immich-fg dark:text-immich-dark-fg">
{#if selected}
{$t('selected')}
{:else}
{$t('select')}
{/if}
{#if showDownloadButton}
<DownloadAction asset={toTimelineAsset(asset)} menuItem />
</p>
<button
type="button"
onclick={() => onSelectAsset(toTimelineAsset(asset))}
class={['focus:outline-none']}
role="checkbox"
tabindex={-1}
aria-checked={selected}
>
{#if selected}
<div class="rounded-full bg-[#D9DCEF] dark:bg-[#232932]">
<Icon path={mdiCheckCircle} size="24" class="text-primary" />
</div>
{:else}
<Icon path={mdiCheckCircle} size="24" class="text-white/80 hover:text-white" />
{/if}
</button>
{:else}
<CastButton />
{#if !isLocked}
{#if asset.isTrashed}
<RestoreAction {asset} {onAction} />
{:else}
<AddToAlbumAction {asset} {onAction} />
<AddToAlbumAction {asset} {onAction} shared />
{/if}
{/if}
{#if !asset.isTrashed && $user && !isLocked}
<ShareAction {asset} />
{/if}
{#if asset.isOffline}
<IconButton
shape="round"
color="danger"
icon={mdiAlertOutline}
onclick={onShowDetail}
aria-label={$t('asset_offline')}
/>
{/if}
{#if asset.livePhotoVideoId}
{@render motionPhoto?.()}
{/if}
{#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}
<IconButton
color="secondary"
variant="ghost"
shape="round"
icon={mdiContentCopy}
aria-label={$t('copy_image')}
onclick={() => onCopyImage?.()}
/>
{/if}
{#if isOwner}
{#if stack}
<UnstackAction {stack} {onAction} />
<KeepThisDeleteOthersAction {stack} {asset} {onAction} />
{#if stack?.primaryAssetId !== asset.id}
<SetStackPrimaryAsset {stack} {asset} {onAction} />
{#if stack?.assets?.length > 2}
<RemoveAssetFromStack {asset} {stack} {onAction} />
{/if}
{/if}
{#if !isOwner && showDownloadButton}
<DownloadAction asset={toTimelineAsset(asset)} />
{/if}
{#if showDetailButton}
<ShowDetailAction {onShowDetail} />
{/if}
{#if isOwner}
<FavoriteAction {asset} {onAction} />
{/if}
{#if isOwner}
<DeleteAction {asset} {onAction} {preAction} />
<ButtonContextMenu
direction="left"
align="top-right"
color="secondary"
title={$t('more')}
icon={mdiDotsVertical}
>
{#if showSlideshow && !isLocked}
<MenuOption icon={mdiPresentationPlay} text={$t('slideshow')} onClick={onPlaySlideshow} />
{/if}
{#if album}
<SetAlbumCoverAction {asset} {album} />
{/if}
{#if person}
<SetFeaturedPhotoAction {asset} {person} />
{/if}
{#if asset.type === AssetTypeEnum.Image && !isLocked}
<SetProfilePictureAction {asset} />
{#if showDownloadButton}
<DownloadAction asset={toTimelineAsset(asset)} menuItem />
{/if}
{#if !isLocked}
<ArchiveAction {asset} {onAction} {preAction} />
<MenuOption
icon={mdiUpload}
onClick={() => openFileUploadDialog({ multiple: false, assetId: asset.id })}
text={$t('replace_with_upload')}
/>
{#if !asset.isArchived && !asset.isTrashed}
<MenuOption
icon={mdiImageSearch}
onClick={() => goto(`${AppRoute.PHOTOS}?at=${stack?.primaryAssetId ?? asset.id}`)}
text={$t('view_in_timeline')}
/>
{/if}
{#if !asset.isArchived && !asset.isTrashed && smartSearchEnabled}
<MenuOption
icon={mdiCompare}
onClick={() => goto(`${AppRoute.SEARCH}?query={"queryAssetId":"${stack?.primaryAssetId ?? asset.id}"}`)}
text={$t('view_similar_photos')}
/>
{#if asset.isTrashed}
<RestoreAction {asset} {onAction} />
{:else}
<AddToAlbumAction {asset} {onAction} />
<AddToAlbumAction {asset} {onAction} shared />
{/if}
{/if}
{#if !asset.isTrashed}
<SetVisibilityAction asset={toTimelineAsset(asset)} {onAction} {preAction} />
{/if}
<hr />
<MenuOption
icon={mdiHeadSyncOutline}
onClick={() => onRunJob(AssetJobName.RefreshFaces)}
text={$getAssetJobName(AssetJobName.RefreshFaces)}
/>
<MenuOption
icon={mdiDatabaseRefreshOutline}
onClick={() => onRunJob(AssetJobName.RefreshMetadata)}
text={$getAssetJobName(AssetJobName.RefreshMetadata)}
/>
<MenuOption
icon={mdiImageRefreshOutline}
onClick={() => onRunJob(AssetJobName.RegenerateThumbnail)}
text={$getAssetJobName(AssetJobName.RegenerateThumbnail)}
/>
{#if asset.type === AssetTypeEnum.Video}
{#if isOwner}
{#if stack}
<UnstackAction {stack} {onAction} />
<KeepThisDeleteOthersAction {stack} {asset} {onAction} />
{#if stack?.primaryAssetId !== asset.id}
<SetStackPrimaryAsset {stack} {asset} {onAction} />
{#if stack?.assets?.length > 2}
<RemoveAssetFromStack {asset} {stack} {onAction} />
{/if}
{/if}
{/if}
{#if album}
<SetAlbumCoverAction {asset} {album} />
{/if}
{#if person}
<SetFeaturedPhotoAction {asset} {person} />
{/if}
{#if asset.type === AssetTypeEnum.Image && !isLocked}
<SetProfilePictureAction {asset} />
{/if}
{#if !isLocked}
<ArchiveAction {asset} {onAction} {preAction} />
<MenuOption
icon={mdiUpload}
onClick={() => openFileUploadDialog({ multiple: false, assetId: asset.id })}
text={$t('replace_with_upload')}
/>
{#if !asset.isArchived && !asset.isTrashed}
<MenuOption
icon={mdiImageSearch}
onClick={() => goto(`${AppRoute.PHOTOS}?at=${stack?.primaryAssetId ?? asset.id}`)}
text={$t('view_in_timeline')}
/>
{/if}
{#if !asset.isArchived && !asset.isTrashed && smartSearchEnabled}
<MenuOption
icon={mdiCompare}
onClick={() =>
goto(`${AppRoute.SEARCH}?query={"queryAssetId":"${stack?.primaryAssetId ?? asset.id}"}`)}
text={$t('view_similar_photos')}
/>
{/if}
{/if}
{#if !asset.isTrashed}
<SetVisibilityAction asset={toTimelineAsset(asset)} {onAction} {preAction} />
{/if}
<hr />
<MenuOption
icon={mdiCogRefreshOutline}
onClick={() => onRunJob(AssetJobName.TranscodeVideo)}
text={$getAssetJobName(AssetJobName.TranscodeVideo)}
icon={mdiHeadSyncOutline}
onClick={() => onRunJob(AssetJobName.RefreshFaces)}
text={$getAssetJobName(AssetJobName.RefreshFaces)}
/>
<MenuOption
icon={mdiDatabaseRefreshOutline}
onClick={() => onRunJob(AssetJobName.RefreshMetadata)}
text={$getAssetJobName(AssetJobName.RefreshMetadata)}
/>
<MenuOption
icon={mdiImageRefreshOutline}
onClick={() => onRunJob(AssetJobName.RegenerateThumbnail)}
text={$getAssetJobName(AssetJobName.RegenerateThumbnail)}
/>
{#if asset.type === AssetTypeEnum.Video}
<MenuOption
icon={mdiCogRefreshOutline}
onClick={() => onRunJob(AssetJobName.TranscodeVideo)}
text={$getAssetJobName(AssetJobName.TranscodeVideo)}
/>
{/if}
{/if}
{/if}
</ButtonContextMenu>
</ButtonContextMenu>
{/if}
{/if}
</div>
</div>

View File

@@ -10,6 +10,7 @@
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 { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { isShowDetail } from '$lib/stores/preferences.store';
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
@@ -49,6 +50,7 @@
interface Props {
asset: AssetResponseDto;
assetInteraction?: AssetInteraction;
preloadAssets?: TimelineAsset[];
showNavigation?: boolean;
withStacked?: boolean;
@@ -58,6 +60,7 @@
preAction?: PreAction | undefined;
onAction?: OnAction | undefined;
showCloseButton?: boolean;
onSelectAsset?: (asset: TimelineAsset) => void;
onClose: (asset: AssetResponseDto) => void;
onNext: () => Promise<HasAsset>;
onPrevious: () => Promise<HasAsset>;
@@ -67,6 +70,7 @@
let {
asset = $bindable(),
assetInteraction,
preloadAssets = $bindable([]),
showNavigation = true,
withStacked = false,
@@ -76,6 +80,7 @@
preAction = undefined,
onAction = undefined,
showCloseButton,
onSelectAsset,
onClose,
onNext,
onPrevious,
@@ -391,12 +396,14 @@
<div class="col-span-4 col-start-1 row-span-1 row-start-1 transition-transform">
<AssetViewerNavBar
{asset}
{assetInteraction}
{album}
{person}
{stack}
{showCloseButton}
showDetailButton={enableDetailPanel}
showSlideshow={true}
{onSelectAsset}
onZoomImage={zoomToggle}
onCopyImage={copyImage}
preAction={handlePreAction}
@@ -529,7 +536,7 @@
</div>
{/if}
{#if enableDetailPanel && $slideshowState === SlideshowState.None && $isShowDetail && !isShowEditor}
{#if enableDetailPanel && $slideshowState === SlideshowState.None && $isShowDetail && !isShowEditor && !assetInteraction?.selectionActive}
<div
transition:fly={{ duration: 150 }}
id="detail-panel"

View File

@@ -11,6 +11,7 @@
mdiCameraBurst,
mdiCheckCircle,
mdiHeart,
mdiMagnifyPlusOutline,
mdiMotionPauseOutline,
mdiMotionPlayOutline,
mdiRotate360,
@@ -37,6 +38,7 @@
thumbnailHeight?: number;
selected?: boolean;
selectionCandidate?: boolean;
selectionActive?: boolean;
disabled?: boolean;
disableLinkMouseOver?: boolean;
readonly?: boolean;
@@ -45,7 +47,7 @@
imageClass?: ClassValue;
brokenAssetClass?: ClassValue;
dimmed?: boolean;
onClick?: (asset: TimelineAsset) => void;
onClick?: (asset: TimelineAsset, forceView?: boolean) => void;
onSelect?: (asset: TimelineAsset) => void;
onMouseEvent?: (event: { isMouseOver: boolean; selectedGroupIndex: number }) => void;
}
@@ -58,6 +60,7 @@
thumbnailHeight = undefined,
selected = false,
selectionCandidate = false,
selectionActive = false,
disabled = false,
disableLinkMouseOver = false,
readonly = false,
@@ -92,6 +95,12 @@
}
};
const onViewerIconClickedHandler = (e?: MouseEvent) => {
e?.stopPropagation();
e?.preventDefault();
onClick?.($state.snapshot(asset), true);
};
const callClickHandlers = () => {
if (selected) {
onIconClickedHandler();
@@ -344,6 +353,19 @@
</div>
{/if}
<!-- View Asset while selecting -->
{#if selectionActive && (usingMobileDevice || mouseOver)}
<button
type="button"
onclick={onViewerIconClickedHandler}
class={['absolute focus:outline-none bottom-2 end-2', { 'cursor-not-allowed': disabled }]}
tabindex={-1}
{disabled}
>
<Icon path={mdiMagnifyPlusOutline} size="24" class="text-white/80 hover:text-white" />
</button>
{/if}
{#if (!loaded || thumbError) && asset.thumbhash}
<canvas
use:thumbhash={{ base64ThumbHash: asset.thumbhash }}

View File

@@ -75,8 +75,9 @@
assets: TimelineAsset[],
groupTitle: string,
asset: TimelineAsset,
forceView: boolean = false,
) => {
if (isSelectionMode || assetInteraction.selectionActive) {
if (!forceView && (isSelectionMode || assetInteraction.selectionActive)) {
assetSelectHandler(timelineManager, asset, assets, groupTitle);
return;
}
@@ -218,11 +219,11 @@
{showArchiveIcon}
{asset}
{groupIndex}
onClick={(asset) => {
onClick={(asset, forceView: boolean = false) => {
if (typeof onThumbnailClick === 'function') {
onThumbnailClick(asset, timelineManager, dayGroup, _onClick);
} else {
_onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset);
_onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset, forceView);
}
}}
onSelect={(asset) => assetSelectHandler(timelineManager, asset, dayGroup.getAssets(), dayGroup.groupTitle)}
@@ -230,6 +231,7 @@
selected={assetInteraction.hasSelectedAsset(asset.id) ||
dayGroup.monthGroup.timelineManager.albumAssets.has(asset.id)}
selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)}
selectionActive={assetInteraction.selectionActive}
disabled={dayGroup.monthGroup.timelineManager.albumAssets.has(asset.id)}
thumbnailWidth={position.width}
thumbnailHeight={position.height}

View File

@@ -494,8 +494,8 @@
>
<Thumbnail
readonly={disableAssetSelect}
onClick={() => {
if (assetInteraction.selectionActive) {
onClick={(asset, forceView: boolean = false) => {
if (assetInteraction.selectionActive && !forceView) {
handleSelectAssets(toTimelineAsset(currentAsset));
return;
}
@@ -507,6 +507,7 @@
asset={toTimelineAsset(currentAsset)}
selected={assetInteraction.hasSelectedAsset(currentAsset.id)}
selectionCandidate={assetInteraction.hasSelectionCandidate(currentAsset.id)}
selectionActive={assetInteraction.selectionActive}
thumbnailWidth={layout.width}
thumbnailHeight={layout.height}
/>
@@ -528,10 +529,12 @@
<Portal target="body">
<AssetViewer
asset={$viewingAsset}
{assetInteraction}
onAction={handleAction}
onPrevious={handlePrevious}
onNext={handleNext}
onRandom={handleRandom}
onSelectAsset={handleSelectAssets}
onClose={() => {
assetViewingStore.showAssetViewer(false);
handlePromiseError(navigate({ targetRoute: 'current', assetId: null }));

View File

@@ -991,6 +991,8 @@
onNext={handleNext}
onRandom={handleRandom}
onClose={handleClose}
onSelectAsset={handleSelectAssets}
{assetInteraction}
/>
{/await}
{/if}