Compare commits
3 Commits
fix/timeli
...
feat/timel
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d044889cb0 | ||
|
|
c144b1ad17 | ||
|
|
c424b2f0a1 |
@@ -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;
|
||||
|
||||
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">
|
||||
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>
|
||||
|
||||
@@ -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)"
|
||||
|
||||
Reference in New Issue
Block a user