Compare commits

...

3 Commits

Author SHA1 Message Date
midzelis
d044889cb0 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.
2025-09-28 20:39:31 +00:00
midzelis
c144b1ad17 feat: photostream can have scrollbar, style options, standardize small/large layout sizes
- Add configurable header height props for responsive layouts
  (smallHeaderHeight/largeHeaderHeight)
  - Add style customization props: styleMarginContentHorizontal,
  styleMarginTop, alwaysShowScrollbar
  - Replace hardcoded layout values with configurable props
  - Change root element from <section> to custom <photostream> element for
  better semantic structure
  - Move viewport width binding to inner timeline element for more accurate
  measurements
  - Simplify HMR handler by removing file-specific checks
  - Add segment loading check to prevent rendering unloaded segments
  - Add spacing margin between month groups using layout options
  - Change scrollbar-width from 'auto' to 'thin' for consistency
  - Remove unused UpdatePayload type import
2025-09-28 20:39:31 +00:00
midzelis
c424b2f0a1 feat: skeleton title is optional
feat: skeleton title optional
2025-09-28 20:39:05 +00:00
4 changed files with 218 additions and 171 deletions

View File

@@ -8,7 +8,6 @@
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { onMount, type Snippet } from 'svelte';
import type { UpdatePayload } from 'vite';
interface Props {
segment: Snippet<
@@ -35,6 +34,7 @@
enableRouting: boolean;
timelineManager: PhotostreamManager;
alwaysShowScrollbar?: boolean;
showSkeleton?: boolean;
isShowDeleteConfirmation?: boolean;
styleMarginRightOverride?: string;
@@ -43,6 +43,18 @@
children?: Snippet;
empty?: Snippet;
handleTimelineScroll?: () => void;
smallHeaderHeight?: {
rowHeight: number;
headerHeight: number;
};
largeHeaderHeight?: {
rowHeight: number;
headerHeight: number;
};
styleMarginContentHorizontal?: string;
styleMarginTop?: string;
}
let {
@@ -51,14 +63,27 @@
enableRouting,
timelineManager = $bindable(),
showSkeleton = $bindable(true),
styleMarginRightOverride,
isShowDeleteConfirmation = $bindable(false),
showScrollbar,
styleMarginRightOverride,
styleMarginContentHorizontal = '0px',
styleMarginTop = '0px',
alwaysShowScrollbar,
isShowDeleteConfirmation = $bindable(false),
children,
skeleton,
empty,
header,
handleTimelineScroll = () => {},
smallHeaderHeight = {
rowHeight: 100,
headerHeight: 32,
},
largeHeaderHeight = {
rowHeight: 235,
headerHeight: 48,
},
}: Props = $props();
let { gridScrollTarget } = assetViewingStore;
@@ -70,15 +95,7 @@
const isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0);
$effect(() => {
const layoutOptions = maxMd
? {
rowHeight: 100,
headerHeight: 32,
}
: {
rowHeight: 235,
headerHeight: 48,
};
const layoutOptions = maxMd ? smallHeaderHeight : largeHeaderHeight;
timelineManager.setLayoutOptions(layoutOptions);
});
@@ -173,7 +190,7 @@
</script>
<HotModuleReload
onAfterUpdate={(payload: UpdatePayload) => {
onAfterUpdate={() => {
// when hmr happens, skeleton is initialized to true by default
// normally, loading asset-grid is part of a navigation event, and the completion of
// that event triggers a scroll-to-asset, if necessary, when then clears the skeleton.
@@ -186,38 +203,41 @@
}
void completeAfterNavigate({ scrollToAssetQueryParam: true });
};
const assetGridUpdate = payload.updates.some((update) => update.path.endsWith('Photostream.svelte'));
if (assetGridUpdate) {
// wait 500ms for the update to be fully swapped in
setTimeout(finishHmr, 500);
}
// wait 500ms for the update to be fully swapped in
setTimeout(finishHmr, 500);
}}
/>
{@render header?.(scrollTo)}
<!-- Right margin MUST be equal to the width of scrubber -->
<section
<photostream
id="asset-grid"
class={[
'h-full overflow-y-auto outline-none',
'overflow-y-auto outline-none',
{ 'scrollbar-hidden': !showScrollbar },
{ 'overflow-y-scroll': alwaysShowScrollbar },
{ 'm-0': isEmpty },
{ 'ms-0': !isEmpty },
]}
style:height={`calc(100% - ${styleMarginTop})`}
style:margin-top={styleMarginTop}
style:margin-right={styleMarginRightOverride}
style:scrollbar-width={showScrollbar ? 'auto' : 'none'}
style:scrollbar-width={showScrollbar ? 'thin' : 'none'}
tabindex="-1"
bind:clientHeight={timelineManager.viewportHeight}
bind:clientWidth={null, (v: number) => ((timelineManager.viewportWidth = v), updateSlidingWindow())}
bind:this={element}
onscroll={() => (handleTimelineScroll(), updateSlidingWindow(), updateIsScrolling())}
>
<section
bind:this={timelineElement}
id="virtual-timeline"
style:margin-left={styleMarginContentHorizontal}
style:margin-right={styleMarginContentHorizontal}
class:invisible={showSkeleton}
style:height={timelineManager.timelineHeight + 'px'}
bind:clientWidth={null, (v: number) => ((timelineManager.viewportWidth = v), updateSlidingWindow())}
>
<section
use:resizeObserver={topSectionResizeObserver}
@@ -234,13 +254,15 @@
</section>
{#each timelineManager.months as monthGroup (monthGroup.id)}
{@const shouldDisplay = monthGroup.intersecting}
{@const shouldDisplay = monthGroup.intersecting && monthGroup.isLoaded}
{@const absoluteHeight = monthGroup.top}
<div
class="month-group"
style:height={monthGroup.height + 'px'}
style:margin-bottom={timelineManager.createLayoutOptions().spacing + 'px'}
style:position="absolute"
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
style:height={`${monthGroup.height}px`}
style:width="100%"
>
{#if !shouldDisplay}
@@ -263,9 +285,12 @@
style:transform={`translate3d(0,${timelineManager.timelineHeight}px,0)`}
></div>
</section>
</section>
</photostream>
<style>
photostream {
display: block;
}
#asset-grid {
contain: strict;
scrollbar-width: none;

View 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>

View File

@@ -1,25 +1,19 @@
<script lang="ts">
import { afterNavigate, beforeNavigate } from '$app/navigation';
import { page } from '$app/state';
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import MonthSegment from '$lib/components/timeline/MonthSegment.svelte';
import PhotostreamWithScrubber from '$lib/components/timeline/PhotostreamWithScrubber.svelte';
import SelectableDay from '$lib/components/timeline/SelectableDay.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 TimelineKeyboardActions from '$lib/components/timeline/actions/TimelineKeyboardActions.svelte';
import { AssetAction } from '$lib/constants';
import Portal from '$lib/elements/Portal.svelte';
import Skeleton from '$lib/elements/Skeleton.svelte';
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
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 { DateTime } from 'luxon';
import { type Snippet } from 'svelte';
interface Props {
@@ -74,140 +68,90 @@
customThumbnailLayout,
}: Props = $props();
let { isViewing: showAssetViewer, asset: viewingAsset, gridScrollTarget } = assetViewingStore;
let viewer: PhotostreamWithScrubber | undefined = $state(undefined);
let viewer: PhotostreamWithScrubber | undefined = $state();
let showSkeleton: boolean = $state(true);
// 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 }));
}
});
const onAfterNavigateComplete = ({ scrollToAssetQueryParam }: { scrollToAssetQueryParam: boolean }) =>
viewer?.completeAfterNavigate({ scrollToAssetQueryParam });
</script>
<TimelineKeyboardActions
scrollToAsset={async (asset) => (await viewer?.scrollToAsset(asset)) ?? Promise.resolve(false)}
{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}
<StreamWithViewer {timelineManager} {onAfterNavigateComplete}>
{#snippet assetViewer({ onViewerClose })}
<TimelineAssetViewer {timelineManager} {removeAction} {withStacked} {isShared} {album} {person} {onViewerClose} />
{/if}
</Portal>
{/snippet}
<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>

View File

@@ -1,18 +1,20 @@
<script lang="ts">
interface Props {
height: number;
title: string;
title?: string;
}
let { height = 0, title }: Props = $props();
</script>
<div class="overflow-clip" style:height={height + 'px'}>
<div
class="flex pt-7 pb-5 h-6 place-items-center text-xs font-medium text-immich-fg bg-light dark:text-immich-dark-fg md:text-sm"
>
{title}
</div>
{#if title}
<div
class="flex pt-7 pb-5 h-6 place-items-center text-xs font-medium text-immich-fg bg-light dark:text-immich-dark-fg md:text-sm"
>
{title}
</div>
{/if}
<div
class="animate-pulse absolute h-full ms-[10px] me-[10px]"
style:width="calc(100% - 20px)"