mirror of
https://github.com/immich-app/immich
synced 2025-10-17 18:19:27 +00:00
feat: Extract common StreamWithViewer component
- Create new StreamWithViewer component to handle asset viewer lifecycle and navigation - Move beforeNavigate/afterNavigate hooks from Timeline to StreamWithViewer - Extract asset viewer Portal rendering and close handler to wrapper component - Move timeline segment loading logic for viewed assets to StreamWithViewer - Simplify Timeline component by removing ~76 lines of navigation/viewer code - Remove showSkeleton state management from Timeline (now handled by PhotostreamWithScrubber) This separation of concerns makes the Timeline component more focused on rendering while StreamWithViewer handles all viewer-related navigation and state management.The new component can be reused by other photostream-like components that need asset viewer functionality.
This commit is contained in:
parent
79adb016e8
commit
398755e65e
2 changed files with 160 additions and 140 deletions
76
web/src/lib/components/timeline/StreamWithViewer.svelte
Normal file
76
web/src/lib/components/timeline/StreamWithViewer.svelte
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { afterNavigate, beforeNavigate } from '$app/navigation';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import Portal from '$lib/elements/Portal.svelte';
|
||||||
|
import type { PhotostreamManager } from '$lib/managers/photostream-manager/PhotostreamManager.svelte';
|
||||||
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
|
import { isAssetViewerRoute, navigate } from '$lib/utils/navigation';
|
||||||
|
import { getSegmentIdentifier, getTimes } from '$lib/utils/timeline-util';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
|
import { type Snippet } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
timelineManager: PhotostreamManager;
|
||||||
|
children?: Snippet;
|
||||||
|
assetViewer: Snippet<[{ onViewerClose: (asset: { id: string }) => Promise<void> }]>;
|
||||||
|
onAfterNavigateComplete: (args: { scrollToAssetQueryParam: boolean }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { timelineManager, children, assetViewer, onAfterNavigateComplete }: Props = $props();
|
||||||
|
|
||||||
|
let { isViewing: showAssetViewer, asset: viewingAsset, gridScrollTarget } = assetViewingStore;
|
||||||
|
|
||||||
|
// tri-state boolean
|
||||||
|
let initialLoadWasAssetViewer: boolean | null = null;
|
||||||
|
let hasNavigatedToOrFromAssetViewer: boolean = false;
|
||||||
|
let timelineScrollPositionInitialized = false;
|
||||||
|
|
||||||
|
beforeNavigate(({ from, to }) => {
|
||||||
|
timelineManager.suspendTransitions = true;
|
||||||
|
hasNavigatedToOrFromAssetViewer = isAssetViewerRoute(to) || isAssetViewerRoute(from);
|
||||||
|
});
|
||||||
|
|
||||||
|
const completeAfterNavigate = () => {
|
||||||
|
const assetViewerPage = !!(page.route.id?.endsWith('/[[assetId=id]]') && page.params.assetId);
|
||||||
|
let isInitial = false;
|
||||||
|
// Set initial load state only once
|
||||||
|
if (initialLoadWasAssetViewer === null) {
|
||||||
|
initialLoadWasAssetViewer = assetViewerPage && !hasNavigatedToOrFromAssetViewer;
|
||||||
|
isInitial = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let scrollToAssetQueryParam = false;
|
||||||
|
if (
|
||||||
|
!timelineScrollPositionInitialized &&
|
||||||
|
((isInitial && !assetViewerPage) || // Direct timeline load
|
||||||
|
(!isInitial && hasNavigatedToOrFromAssetViewer)) // Navigated from asset viewer
|
||||||
|
) {
|
||||||
|
scrollToAssetQueryParam = true;
|
||||||
|
timelineScrollPositionInitialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return onAfterNavigateComplete({ scrollToAssetQueryParam });
|
||||||
|
};
|
||||||
|
afterNavigate(({ complete }) => void complete.then(completeAfterNavigate, completeAfterNavigate));
|
||||||
|
|
||||||
|
const onViewerClose = async (asset: { id: string }) => {
|
||||||
|
assetViewingStore.showAssetViewer(false);
|
||||||
|
$gridScrollTarget = { at: asset.id };
|
||||||
|
await navigate({ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget });
|
||||||
|
};
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if ($showAssetViewer) {
|
||||||
|
const { localDateTime } = getTimes($viewingAsset.fileCreatedAt, DateTime.local().offset / 60);
|
||||||
|
void timelineManager.loadSegment(getSegmentIdentifier({ year: localDateTime.year, month: localDateTime.month }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{@render children?.()}
|
||||||
|
|
||||||
|
<Portal target="body">
|
||||||
|
{#if $showAssetViewer}
|
||||||
|
{@render assetViewer({ onViewerClose })}
|
||||||
|
{/if}
|
||||||
|
</Portal>
|
||||||
|
|
@ -1,25 +1,19 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { afterNavigate, beforeNavigate } from '$app/navigation';
|
|
||||||
import { page } from '$app/state';
|
|
||||||
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
|
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
|
||||||
import MonthSegment from '$lib/components/timeline/MonthSegment.svelte';
|
import MonthSegment from '$lib/components/timeline/MonthSegment.svelte';
|
||||||
import PhotostreamWithScrubber from '$lib/components/timeline/PhotostreamWithScrubber.svelte';
|
import PhotostreamWithScrubber from '$lib/components/timeline/PhotostreamWithScrubber.svelte';
|
||||||
import SelectableDay from '$lib/components/timeline/SelectableDay.svelte';
|
import SelectableDay from '$lib/components/timeline/SelectableDay.svelte';
|
||||||
import SelectableSegment from '$lib/components/timeline/SelectableSegment.svelte';
|
import SelectableSegment from '$lib/components/timeline/SelectableSegment.svelte';
|
||||||
|
import StreamWithViewer from '$lib/components/timeline/StreamWithViewer.svelte';
|
||||||
import TimelineAssetViewer from '$lib/components/timeline/TimelineAssetViewer.svelte';
|
import TimelineAssetViewer from '$lib/components/timeline/TimelineAssetViewer.svelte';
|
||||||
import TimelineKeyboardActions from '$lib/components/timeline/actions/TimelineKeyboardActions.svelte';
|
import TimelineKeyboardActions from '$lib/components/timeline/actions/TimelineKeyboardActions.svelte';
|
||||||
import { AssetAction } from '$lib/constants';
|
import { AssetAction } from '$lib/constants';
|
||||||
import Portal from '$lib/elements/Portal.svelte';
|
|
||||||
import Skeleton from '$lib/elements/Skeleton.svelte';
|
import Skeleton from '$lib/elements/Skeleton.svelte';
|
||||||
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
|
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
|
||||||
import { 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 type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||||
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
|
||||||
import { isAssetViewerRoute, navigate } from '$lib/utils/navigation';
|
|
||||||
import { getSegmentIdentifier, getTimes } from '$lib/utils/timeline-util';
|
|
||||||
import { type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
|
import { type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
|
||||||
import { DateTime } from 'luxon';
|
|
||||||
import { type Snippet } from 'svelte';
|
import { type Snippet } from 'svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -74,140 +68,90 @@
|
||||||
customThumbnailLayout,
|
customThumbnailLayout,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let { isViewing: showAssetViewer, asset: viewingAsset, gridScrollTarget } = assetViewingStore;
|
let viewer: PhotostreamWithScrubber | undefined = $state(undefined);
|
||||||
|
|
||||||
let viewer: PhotostreamWithScrubber | undefined = $state();
|
const onAfterNavigateComplete = ({ scrollToAssetQueryParam }: { scrollToAssetQueryParam: boolean }) =>
|
||||||
let showSkeleton: boolean = $state(true);
|
viewer?.completeAfterNavigate({ scrollToAssetQueryParam });
|
||||||
|
|
||||||
// tri-state boolean
|
|
||||||
let initialLoadWasAssetViewer: boolean | null = null;
|
|
||||||
let hasNavigatedToOrFromAssetViewer: boolean = false;
|
|
||||||
let timelineScrollPositionInitialized = false;
|
|
||||||
|
|
||||||
beforeNavigate(({ from, to }) => {
|
|
||||||
timelineManager.suspendTransitions = true;
|
|
||||||
hasNavigatedToOrFromAssetViewer = isAssetViewerRoute(to) || isAssetViewerRoute(from);
|
|
||||||
});
|
|
||||||
|
|
||||||
const completeAfterNavigate = () => {
|
|
||||||
const assetViewerPage = !!(page.route.id?.endsWith('/[[assetId=id]]') && page.params.assetId);
|
|
||||||
let isInitial = false;
|
|
||||||
// Set initial load state only once
|
|
||||||
if (initialLoadWasAssetViewer === null) {
|
|
||||||
initialLoadWasAssetViewer = assetViewerPage && !hasNavigatedToOrFromAssetViewer;
|
|
||||||
isInitial = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
let scrollToAssetQueryParam = false;
|
|
||||||
if (
|
|
||||||
!timelineScrollPositionInitialized &&
|
|
||||||
((isInitial && !assetViewerPage) || // Direct timeline load
|
|
||||||
(!isInitial && hasNavigatedToOrFromAssetViewer)) // Navigated from asset viewer
|
|
||||||
) {
|
|
||||||
scrollToAssetQueryParam = true;
|
|
||||||
timelineScrollPositionInitialized = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return viewer?.completeAfterNavigate({ scrollToAssetQueryParam });
|
|
||||||
};
|
|
||||||
afterNavigate(({ complete }) => void complete.then(completeAfterNavigate, completeAfterNavigate));
|
|
||||||
|
|
||||||
const onViewerClose = async (asset: { id: string }) => {
|
|
||||||
assetViewingStore.showAssetViewer(false);
|
|
||||||
showSkeleton = true;
|
|
||||||
$gridScrollTarget = { at: asset.id };
|
|
||||||
await navigate({ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget });
|
|
||||||
};
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if ($showAssetViewer) {
|
|
||||||
const { localDateTime } = getTimes($viewingAsset.fileCreatedAt, DateTime.local().offset / 60);
|
|
||||||
void timelineManager.loadSegment(getSegmentIdentifier({ year: localDateTime.year, month: localDateTime.month }));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<TimelineKeyboardActions
|
<StreamWithViewer {timelineManager} {onAfterNavigateComplete}>
|
||||||
scrollToAsset={async (asset) => (await viewer?.scrollToAsset(asset)) ?? Promise.resolve(false)}
|
{#snippet assetViewer({ onViewerClose })}
|
||||||
{timelineManager}
|
|
||||||
{assetInteraction}
|
|
||||||
bind:isShowDeleteConfirmation
|
|
||||||
{onEscape}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<PhotostreamWithScrubber
|
|
||||||
bind:this={viewer}
|
|
||||||
{enableRouting}
|
|
||||||
{timelineManager}
|
|
||||||
{isShowDeleteConfirmation}
|
|
||||||
{showSkeleton}
|
|
||||||
{children}
|
|
||||||
{empty}
|
|
||||||
>
|
|
||||||
{#snippet skeleton({ segment })}
|
|
||||||
<Skeleton
|
|
||||||
height={segment.height - segment.timelineManager.headerHeight}
|
|
||||||
title={(segment as MonthGroup).monthGroupTitle}
|
|
||||||
/>
|
|
||||||
{/snippet}
|
|
||||||
{#snippet segment({ segment, onScrollCompensationMonthInDOM })}
|
|
||||||
<SelectableSegment
|
|
||||||
{segment}
|
|
||||||
{onScrollCompensationMonthInDOM}
|
|
||||||
{timelineManager}
|
|
||||||
{assetInteraction}
|
|
||||||
{isSelectionMode}
|
|
||||||
{singleSelect}
|
|
||||||
{onAssetOpen}
|
|
||||||
{onAssetSelect}
|
|
||||||
>
|
|
||||||
{#snippet content({ onAssetOpen, onAssetSelect, onAssetHover })}
|
|
||||||
<SelectableDay {assetInteraction} {onAssetSelect}>
|
|
||||||
{#snippet content({ onDayGroupSelect, onDayGroupAssetSelect })}
|
|
||||||
<MonthSegment
|
|
||||||
{assetInteraction}
|
|
||||||
{customThumbnailLayout}
|
|
||||||
{singleSelect}
|
|
||||||
monthGroup={segment as MonthGroup}
|
|
||||||
{timelineManager}
|
|
||||||
{onDayGroupSelect}
|
|
||||||
>
|
|
||||||
{#snippet thumbnail({ asset, position, dayGroup, groupIndex })}
|
|
||||||
{@const isAssetSelectionCandidate = assetInteraction.hasSelectionCandidate(asset.id)}
|
|
||||||
{@const isAssetSelected =
|
|
||||||
assetInteraction.hasSelectedAsset(asset.id) || timelineManager.albumAssets.has(asset.id)}
|
|
||||||
{@const isAssetDisabled = timelineManager.albumAssets.has(asset.id)}
|
|
||||||
<Thumbnail
|
|
||||||
showStackedIcon={withStacked}
|
|
||||||
{showArchiveIcon}
|
|
||||||
{asset}
|
|
||||||
{groupIndex}
|
|
||||||
onClick={() => onAssetOpen(asset)}
|
|
||||||
onSelect={() => onDayGroupAssetSelect(dayGroup, asset)}
|
|
||||||
onMouseEvent={(isMouseOver) => {
|
|
||||||
if (isMouseOver) {
|
|
||||||
onAssetHover(asset);
|
|
||||||
} else {
|
|
||||||
onAssetHover(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
selected={isAssetSelected}
|
|
||||||
selectionCandidate={isAssetSelectionCandidate}
|
|
||||||
disabled={isAssetDisabled}
|
|
||||||
thumbnailWidth={position.width}
|
|
||||||
thumbnailHeight={position.height}
|
|
||||||
/>
|
|
||||||
{/snippet}
|
|
||||||
</MonthSegment>
|
|
||||||
{/snippet}
|
|
||||||
</SelectableDay>
|
|
||||||
{/snippet}
|
|
||||||
</SelectableSegment>
|
|
||||||
{/snippet}
|
|
||||||
</PhotostreamWithScrubber>
|
|
||||||
|
|
||||||
<Portal target="body">
|
|
||||||
{#if $showAssetViewer}
|
|
||||||
<TimelineAssetViewer {timelineManager} {removeAction} {withStacked} {isShared} {album} {person} {onViewerClose} />
|
<TimelineAssetViewer {timelineManager} {removeAction} {withStacked} {isShared} {album} {person} {onViewerClose} />
|
||||||
{/if}
|
{/snippet}
|
||||||
</Portal>
|
<TimelineKeyboardActions
|
||||||
|
scrollToAsset={async (asset) => (await viewer?.scrollToAsset(asset)) ?? Promise.resolve(false)}
|
||||||
|
{timelineManager}
|
||||||
|
{assetInteraction}
|
||||||
|
bind:isShowDeleteConfirmation
|
||||||
|
{onEscape}
|
||||||
|
/>
|
||||||
|
<PhotostreamWithScrubber
|
||||||
|
bind:this={viewer}
|
||||||
|
{enableRouting}
|
||||||
|
{timelineManager}
|
||||||
|
{isShowDeleteConfirmation}
|
||||||
|
{children}
|
||||||
|
{empty}
|
||||||
|
>
|
||||||
|
{#snippet skeleton({ segment })}
|
||||||
|
<Skeleton
|
||||||
|
height={segment.height - segment.timelineManager.headerHeight}
|
||||||
|
title={(segment as MonthGroup).monthGroupTitle}
|
||||||
|
/>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet segment({ segment, onScrollCompensationMonthInDOM })}
|
||||||
|
<SelectableSegment
|
||||||
|
{segment}
|
||||||
|
{onScrollCompensationMonthInDOM}
|
||||||
|
{timelineManager}
|
||||||
|
{assetInteraction}
|
||||||
|
{isSelectionMode}
|
||||||
|
{singleSelect}
|
||||||
|
{onAssetOpen}
|
||||||
|
{onAssetSelect}
|
||||||
|
>
|
||||||
|
{#snippet content({ onAssetOpen, onAssetSelect, onAssetHover })}
|
||||||
|
<SelectableDay {assetInteraction} {onAssetSelect}>
|
||||||
|
{#snippet content({ onDayGroupSelect, onDayGroupAssetSelect })}
|
||||||
|
<MonthSegment
|
||||||
|
{assetInteraction}
|
||||||
|
{customThumbnailLayout}
|
||||||
|
{singleSelect}
|
||||||
|
monthGroup={segment as MonthGroup}
|
||||||
|
{timelineManager}
|
||||||
|
{onDayGroupSelect}
|
||||||
|
>
|
||||||
|
{#snippet thumbnail({ asset, position, dayGroup, groupIndex })}
|
||||||
|
{@const isAssetSelectionCandidate = assetInteraction.hasSelectionCandidate(asset.id)}
|
||||||
|
{@const isAssetSelected =
|
||||||
|
assetInteraction.hasSelectedAsset(asset.id) || timelineManager.albumAssets.has(asset.id)}
|
||||||
|
{@const isAssetDisabled = timelineManager.albumAssets.has(asset.id)}
|
||||||
|
<Thumbnail
|
||||||
|
showStackedIcon={withStacked}
|
||||||
|
{showArchiveIcon}
|
||||||
|
{asset}
|
||||||
|
{groupIndex}
|
||||||
|
onClick={() => onAssetOpen(asset)}
|
||||||
|
onSelect={() => onDayGroupAssetSelect(dayGroup, asset)}
|
||||||
|
onMouseEvent={(isMouseOver) => {
|
||||||
|
if (isMouseOver) {
|
||||||
|
onAssetHover(asset);
|
||||||
|
} else {
|
||||||
|
onAssetHover(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
selected={isAssetSelected}
|
||||||
|
selectionCandidate={isAssetSelectionCandidate}
|
||||||
|
disabled={isAssetDisabled}
|
||||||
|
thumbnailWidth={position.width}
|
||||||
|
thumbnailHeight={position.height}
|
||||||
|
/>
|
||||||
|
{/snippet}
|
||||||
|
</MonthSegment>
|
||||||
|
{/snippet}
|
||||||
|
</SelectableDay>
|
||||||
|
{/snippet}
|
||||||
|
</SelectableSegment>
|
||||||
|
{/snippet}
|
||||||
|
</PhotostreamWithScrubber>
|
||||||
|
</StreamWithViewer>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue