mirror of
https://github.com/immich-app/immich
synced 2025-11-07 17:27:20 +00:00
feat: timeline performance (#16446)
* Squash - feature complete * remove need to init assetstore * More optimizations. No need to init. Fix tests * lint * add missing selector for e2e * e2e selectors again * Update: fully reactive store, some transitions, bugfixes * merge fallout * Test fallout * safari quirk * security * lint * lint * Bug fixes * lint/format * accidental commit * lock * null check, more throttle * revert long duration * Fix intersection bounds * Fix bugs in intersection calculation * lint, tweak scrubber ui a tiny bit * bugfix - deselecting asset doesnt work * fix not loading bucket, scroll off-by-1 error, jsdoc, naming
This commit is contained in:
parent
dd263b010c
commit
e96ffd43e7
48 changed files with 2318 additions and 2764 deletions
|
|
@ -69,7 +69,7 @@
|
|||
<div in:fly={{ y: 10, duration: 200 }} class="absolute top-0 w-full z-[100] bg-transparent">
|
||||
<div
|
||||
id="asset-selection-app-bar"
|
||||
class={`grid ${multiRow ? 'grid-cols-[100%] md:grid-cols-[25%_50%_25%]' : 'grid-cols-[10%_80%_10%] sm:grid-cols-[25%_50%_25%]'} justify-between lg:grid-cols-[25%_50%_25%] ${appBarBorder} mx-2 mt-2 place-items-center rounded-lg p-2 transition-all ${tailwindClasses} dark:bg-immich-dark-gray ${
|
||||
class={`grid ${multiRow ? 'grid-cols-[100%] md:grid-cols-[25%_50%_25%]' : 'grid-cols-[10%_80%_10%] sm:grid-cols-[25%_50%_25%]'} justify-between lg:grid-cols-[25%_50%_25%] ${appBarBorder} mx-2 my-2 place-items-center rounded-lg p-2 transition-all ${tailwindClasses} dark:bg-immich-dark-gray ${
|
||||
forceDark && 'bg-immich-dark-gray text-white'
|
||||
}`}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -8,13 +8,11 @@
|
|||
import type { Viewport } from '$lib/stores/assets-store.svelte';
|
||||
import { showDeleteModal } from '$lib/stores/preferences.store';
|
||||
import { deleteAssets } from '$lib/utils/actions';
|
||||
import { archiveAssets, cancelMultiselect, getAssetRatio } from '$lib/utils/asset-utils';
|
||||
import { archiveAssets, cancelMultiselect } from '$lib/utils/asset-utils';
|
||||
import { featureFlags } from '$lib/stores/server-config.store';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { navigate } from '$lib/utils/navigation';
|
||||
import { calculateWidth } from '$lib/utils/timeline-util';
|
||||
import { type AssetResponseDto } from '@immich/sdk';
|
||||
import justifiedLayout from 'justified-layout';
|
||||
import { t } from 'svelte-i18n';
|
||||
import AssetViewer from '../../asset-viewer/asset-viewer.svelte';
|
||||
import ShowShortcuts from '../show-shortcuts.svelte';
|
||||
|
|
@ -22,6 +20,8 @@
|
|||
import { handlePromiseError } from '$lib/utils';
|
||||
import DeleteAssetDialog from '../../photos-page/delete-asset-dialog.svelte';
|
||||
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
import { debounce } from 'lodash-es';
|
||||
import { getJustifiedLayoutFromAssets, type CommonJustifiedLayout } from '$lib/utils/layout-utils';
|
||||
|
||||
interface Props {
|
||||
assets: AssetResponseDto[];
|
||||
|
|
@ -53,11 +53,84 @@
|
|||
|
||||
let { isViewing: isViewerOpen, asset: viewingAsset, setAsset } = assetViewingStore;
|
||||
|
||||
let geometry: CommonJustifiedLayout | undefined = $state();
|
||||
|
||||
$effect(() => {
|
||||
const _assets = assets;
|
||||
updateSlidingWindow();
|
||||
|
||||
geometry = getJustifiedLayoutFromAssets(_assets, {
|
||||
spacing: 2,
|
||||
heightTolerance: 0.15,
|
||||
rowHeight: 235,
|
||||
rowWidth: Math.floor(viewport.width),
|
||||
});
|
||||
});
|
||||
|
||||
let assetLayouts = $derived.by(() => {
|
||||
const assetLayout = [];
|
||||
let containerHeight = 0;
|
||||
let containerWidth = 0;
|
||||
if (geometry) {
|
||||
containerHeight = geometry.containerHeight;
|
||||
containerWidth = geometry.containerWidth;
|
||||
for (const [i, asset] of assets.entries()) {
|
||||
const layout = {
|
||||
asset,
|
||||
top: geometry.getTop(i),
|
||||
left: geometry.getLeft(i),
|
||||
width: geometry.getWidth(i),
|
||||
height: geometry.getHeight(i),
|
||||
};
|
||||
// 54 is the content height of the asset-selection-app-bar
|
||||
const layoutTopWithOffset = layout.top + 54;
|
||||
const layoutBottom = layoutTopWithOffset + layout.height;
|
||||
|
||||
const display = layoutTopWithOffset < slidingWindow.bottom && layoutBottom > slidingWindow.top;
|
||||
assetLayout.push({ ...layout, display });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
assetLayout,
|
||||
containerHeight,
|
||||
containerWidth,
|
||||
};
|
||||
});
|
||||
|
||||
let showShortcuts = $state(false);
|
||||
let currentViewAssetIndex = 0;
|
||||
let shiftKeyIsDown = $state(false);
|
||||
let lastAssetMouseEvent: AssetResponseDto | null = $state(null);
|
||||
let slidingWindow = $state({ top: 0, bottom: 0 });
|
||||
|
||||
const updateSlidingWindow = () => {
|
||||
const v = $state.snapshot(viewport);
|
||||
const top = document.scrollingElement?.scrollTop || 0;
|
||||
const bottom = top + v.height;
|
||||
const w = {
|
||||
top,
|
||||
bottom,
|
||||
};
|
||||
slidingWindow = w;
|
||||
};
|
||||
const debouncedOnIntersected = debounce(() => onIntersected?.(), 750, { maxWait: 100, leading: true });
|
||||
|
||||
let lastIntersectedHeight = 0;
|
||||
$effect(() => {
|
||||
// notify we got to (near) the end of scroll
|
||||
const scrollPercentage =
|
||||
((slidingWindow.bottom - viewport.height) / (viewport.height - (document.scrollingElement?.clientHeight || 0))) *
|
||||
100;
|
||||
|
||||
if (scrollPercentage > 90) {
|
||||
const intersectedHeight = geometry?.containerHeight || 0;
|
||||
if (lastIntersectedHeight !== intersectedHeight) {
|
||||
debouncedOnIntersected();
|
||||
lastIntersectedHeight = intersectedHeight;
|
||||
}
|
||||
}
|
||||
});
|
||||
const viewAssetHandler = async (asset: AssetResponseDto) => {
|
||||
currentViewAssetIndex = assets.findIndex((a) => a.id == asset.id);
|
||||
setAsset(assets[currentViewAssetIndex]);
|
||||
|
|
@ -75,6 +148,7 @@
|
|||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Shift') {
|
||||
event.preventDefault();
|
||||
|
||||
shiftKeyIsDown = true;
|
||||
}
|
||||
};
|
||||
|
|
@ -90,7 +164,7 @@
|
|||
if (!asset) {
|
||||
return;
|
||||
}
|
||||
const deselect = assetInteraction.selectedAssets.has(asset);
|
||||
const deselect = assetInteraction.hasSelectedAsset(asset.id);
|
||||
|
||||
// Select/deselect already loaded assets
|
||||
if (deselect) {
|
||||
|
|
@ -173,7 +247,7 @@
|
|||
const toggleArchive = async () => {
|
||||
const ids = await archiveAssets(assetInteraction.selectedAssetsArray, !assetInteraction.isAllArchived);
|
||||
if (ids) {
|
||||
assets.filter((asset) => !ids.includes(asset.id));
|
||||
assets = assets.filter((asset) => !ids.includes(asset.id));
|
||||
deselectAllAssets();
|
||||
}
|
||||
};
|
||||
|
|
@ -248,7 +322,7 @@
|
|||
}
|
||||
};
|
||||
|
||||
const handleRandom = async (): Promise<AssetResponseDto | null> => {
|
||||
const handleRandom = async (): Promise<AssetResponseDto | undefined> => {
|
||||
try {
|
||||
let asset: AssetResponseDto | undefined;
|
||||
if (onRandom) {
|
||||
|
|
@ -261,14 +335,14 @@
|
|||
}
|
||||
|
||||
if (!asset) {
|
||||
return null;
|
||||
return;
|
||||
}
|
||||
|
||||
await navigateToAsset(asset);
|
||||
return asset;
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.cannot_navigate_next_asset'));
|
||||
return null;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -335,26 +409,6 @@
|
|||
let isTrashEnabled = $derived($featureFlags.loaded && $featureFlags.trash);
|
||||
let idsSelectedAssets = $derived(assetInteraction.selectedAssetsArray.map(({ id }) => id));
|
||||
|
||||
let geometry = $derived(
|
||||
(() => {
|
||||
const justifiedLayoutResult = justifiedLayout(
|
||||
assets.map((asset) => getAssetRatio(asset)),
|
||||
{
|
||||
boxSpacing: 2,
|
||||
containerWidth: Math.floor(viewport.width),
|
||||
containerPadding: 0,
|
||||
targetRowHeightTolerance: 0.15,
|
||||
targetRowHeight: 235,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
...justifiedLayoutResult,
|
||||
containerWidth: calculateWidth(justifiedLayoutResult.boxes),
|
||||
};
|
||||
})(),
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (!lastAssetMouseEvent) {
|
||||
assetInteraction.clearAssetSelectionCandidates();
|
||||
|
|
@ -374,7 +428,13 @@
|
|||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKeyDown} onkeyup={onKeyUp} onselectstart={onSelectStart} use:shortcuts={shortcutList} />
|
||||
<svelte:window
|
||||
onkeydown={onKeyDown}
|
||||
onkeyup={onKeyUp}
|
||||
onselectstart={onSelectStart}
|
||||
use:shortcuts={shortcutList}
|
||||
onscroll={() => updateSlidingWindow()}
|
||||
/>
|
||||
|
||||
{#if isShowDeleteConfirmation}
|
||||
<DeleteAssetDialog
|
||||
|
|
@ -389,43 +449,50 @@
|
|||
{/if}
|
||||
|
||||
{#if assets.length > 0}
|
||||
<div class="relative" style="height: {geometry.containerHeight}px;width: {geometry.containerWidth}px ">
|
||||
{#each assets as asset, i (i)}
|
||||
<div
|
||||
class="absolute"
|
||||
style="width: {geometry.boxes[i].width}px; height: {geometry.boxes[i].height}px; top: {geometry.boxes[i]
|
||||
.top}px; left: {geometry.boxes[i].left}px"
|
||||
title={showAssetName ? asset.originalFileName : ''}
|
||||
>
|
||||
<Thumbnail
|
||||
readonly={disableAssetSelect}
|
||||
onClick={(asset) => {
|
||||
if (assetInteraction.selectionActive) {
|
||||
handleSelectAssets(asset);
|
||||
return;
|
||||
}
|
||||
void viewAssetHandler(asset);
|
||||
}}
|
||||
onSelect={(asset) => handleSelectAssets(asset)}
|
||||
onMouseEvent={() => assetMouseEventHandler(asset)}
|
||||
handleFocus={() => assetOnFocusHandler(asset)}
|
||||
onIntersected={() => (i === Math.max(1, assets.length - 7) ? onIntersected?.() : void 0)}
|
||||
{showArchiveIcon}
|
||||
{asset}
|
||||
selected={assetInteraction.selectedAssets.has(asset)}
|
||||
focussed={assetInteraction.isFocussedAsset(asset)}
|
||||
selectionCandidate={assetInteraction.assetSelectionCandidates.has(asset)}
|
||||
thumbnailWidth={geometry.boxes[i].width}
|
||||
thumbnailHeight={geometry.boxes[i].height}
|
||||
/>
|
||||
{#if showAssetName}
|
||||
<div
|
||||
class="absolute text-center p-1 text-xs font-mono font-semibold w-full bottom-0 bg-gradient-to-t bg-slate-50/75 overflow-clip text-ellipsis whitespace-pre-wrap"
|
||||
>
|
||||
{asset.originalFileName}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
style:position="relative"
|
||||
style:height={assetLayouts.containerHeight + 'px'}
|
||||
style:width={assetLayouts.containerWidth - 1 + 'px'}
|
||||
>
|
||||
{#each assetLayouts.assetLayout as layout (layout.asset.id)}
|
||||
{@const asset = layout.asset}
|
||||
|
||||
{#if layout.display}
|
||||
<div
|
||||
class="absolute"
|
||||
style:overflow="clip"
|
||||
style="width: {layout.width}px; height: {layout.height}px; top: {layout.top}px; left: {layout.left}px"
|
||||
title={showAssetName ? asset.originalFileName : ''}
|
||||
>
|
||||
<Thumbnail
|
||||
readonly={disableAssetSelect}
|
||||
onClick={(asset) => {
|
||||
if (assetInteraction.selectionActive) {
|
||||
handleSelectAssets(asset);
|
||||
return;
|
||||
}
|
||||
void viewAssetHandler(asset);
|
||||
}}
|
||||
onSelect={(asset) => handleSelectAssets(asset)}
|
||||
onMouseEvent={() => assetMouseEventHandler(asset)}
|
||||
handleFocus={() => assetOnFocusHandler(asset)}
|
||||
{showArchiveIcon}
|
||||
{asset}
|
||||
selected={assetInteraction.hasSelectedAsset(asset.id)}
|
||||
selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)}
|
||||
focussed={assetInteraction.isFocussedAsset(asset)}
|
||||
thumbnailWidth={layout.width}
|
||||
thumbnailHeight={layout.height}
|
||||
/>
|
||||
{#if showAssetName}
|
||||
<div
|
||||
class="absolute text-center p-1 text-xs font-mono font-semibold w-full bottom-0 bg-gradient-to-t bg-slate-50/75 overflow-clip text-ellipsis whitespace-pre-wrap"
|
||||
>
|
||||
{asset.originalFileName}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
<script lang="ts">
|
||||
import type { AssetStore, AssetBucket, BucketListener } from '$lib/stores/assets-store.svelte';
|
||||
import { DateTime } from 'luxon';
|
||||
import type { AssetStore, LiteBucket } from '$lib/stores/assets-store.svelte';
|
||||
import { fromLocalDateTime, type ScrubberListener } from '$lib/utils/timeline-util';
|
||||
import { clamp } from 'lodash-es';
|
||||
import { onMount } from 'svelte';
|
||||
import { isTimelineScrolling } from '$lib/stores/timeline.store';
|
||||
import { DateTime } from 'luxon';
|
||||
import { fade, fly } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -15,11 +13,12 @@
|
|||
invisible?: boolean;
|
||||
scrubOverallPercent?: number;
|
||||
scrubBucketPercent?: number;
|
||||
scrubBucket?: { bucketDate: string | undefined } | undefined;
|
||||
scrubBucket?: { bucketDate: string | undefined };
|
||||
leadout?: boolean;
|
||||
onScrub?: ScrubberListener | undefined;
|
||||
startScrub?: ScrubberListener | undefined;
|
||||
stopScrub?: ScrubberListener | undefined;
|
||||
onScrub?: ScrubberListener;
|
||||
onScrubKeyDown?: (event: KeyboardEvent, element: HTMLElement) => void;
|
||||
startScrub?: ScrubberListener;
|
||||
stopScrub?: ScrubberListener;
|
||||
}
|
||||
|
||||
let {
|
||||
|
|
@ -27,25 +26,22 @@
|
|||
timelineBottomOffset = 0,
|
||||
height = 0,
|
||||
assetStore,
|
||||
invisible = false,
|
||||
scrubOverallPercent = 0,
|
||||
scrubBucketPercent = 0,
|
||||
scrubBucket = undefined,
|
||||
leadout = false,
|
||||
onScrub = undefined,
|
||||
onScrubKeyDown = undefined,
|
||||
startScrub = undefined,
|
||||
stopScrub = undefined,
|
||||
}: Props = $props();
|
||||
|
||||
let isHover = $state(false);
|
||||
let isDragging = $state(false);
|
||||
let hoverLabel: string | undefined = $state();
|
||||
let bucketDate: string | undefined;
|
||||
let hoverY = $state(0);
|
||||
let clientY = 0;
|
||||
let windowHeight = $state(0);
|
||||
let scrollBar: HTMLElement | undefined = $state();
|
||||
let segments: Segment[] = $state([]);
|
||||
|
||||
const toScrollY = (percent: number) => percent * (height - HOVER_DATE_HEIGHT * 2);
|
||||
const toTimelineY = (scrollY: number) => scrollY / (height - HOVER_DATE_HEIGHT * 2);
|
||||
|
|
@ -87,28 +83,11 @@
|
|||
return scrubOverallPercent * (height - HOVER_DATE_HEIGHT * 2) - 2;
|
||||
}
|
||||
};
|
||||
let scrollY = $state(0);
|
||||
$effect(() => {
|
||||
scrollY = toScrollFromBucketPercentage(scrubBucket, scrubBucketPercent, scrubOverallPercent);
|
||||
});
|
||||
|
||||
let timelineFullHeight = $derived(assetStore.timelineHeight + timelineTopOffset + timelineBottomOffset);
|
||||
let scrollY = $derived(toScrollFromBucketPercentage(scrubBucket, scrubBucketPercent, scrubOverallPercent));
|
||||
let timelineFullHeight = $derived(assetStore.scrubberTimelineHeight + timelineTopOffset + timelineBottomOffset);
|
||||
let relativeTopOffset = $derived(toScrollY(timelineTopOffset / timelineFullHeight));
|
||||
let relativeBottomOffset = $derived(toScrollY(timelineBottomOffset / timelineFullHeight));
|
||||
|
||||
const listener: BucketListener = (event) => {
|
||||
const { type } = event;
|
||||
if (type === 'viewport') {
|
||||
segments = calculateSegments(assetStore.buckets);
|
||||
scrollY = toScrollFromBucketPercentage(scrubBucket, scrubBucketPercent, scrubOverallPercent);
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
assetStore.addListener(listener);
|
||||
return () => assetStore.removeListener(listener);
|
||||
});
|
||||
|
||||
type Segment = {
|
||||
count: number;
|
||||
height: number;
|
||||
|
|
@ -119,7 +98,7 @@
|
|||
hasDot: boolean;
|
||||
};
|
||||
|
||||
const calculateSegments = (buckets: AssetBucket[]) => {
|
||||
const calculateSegments = (buckets: LiteBucket[]) => {
|
||||
let height = 0;
|
||||
let dotHeight = 0;
|
||||
|
||||
|
|
@ -127,11 +106,10 @@
|
|||
let previousLabeledSegment: Segment | undefined;
|
||||
|
||||
for (const [i, bucket] of buckets.entries()) {
|
||||
const scrollBarPercentage =
|
||||
bucket.bucketHeight / (assetStore.timelineHeight + timelineTopOffset + timelineBottomOffset);
|
||||
const scrollBarPercentage = bucket.bucketHeight / timelineFullHeight;
|
||||
|
||||
const segment = {
|
||||
count: bucket.assets.length,
|
||||
count: bucket.assetCount,
|
||||
height: toScrollY(scrollBarPercentage),
|
||||
bucketDate: bucket.bucketDate,
|
||||
date: fromLocalDateTime(bucket.bucketDate),
|
||||
|
|
@ -161,14 +139,23 @@
|
|||
segments.push(segment);
|
||||
}
|
||||
|
||||
hoverLabel = segments[0]?.dateFormatted;
|
||||
return segments;
|
||||
};
|
||||
|
||||
const updateLabel = (segment: HTMLElement) => {
|
||||
hoverLabel = segment.dataset.label;
|
||||
bucketDate = segment.dataset.timeSegmentBucketDate;
|
||||
};
|
||||
let activeSegment: HTMLElement | undefined = $state();
|
||||
const segments = $derived(calculateSegments(assetStore.scrubberBuckets));
|
||||
const hoverLabel = $derived(activeSegment?.dataset.label);
|
||||
const bucketDate = $derived(activeSegment?.dataset.timeSegmentBucketDate);
|
||||
const scrollHoverLabel = $derived.by(() => {
|
||||
const y = scrollY;
|
||||
let cur = 0;
|
||||
for (const segment of segments) {
|
||||
if (y <= cur + segment.height + relativeTopOffset) {
|
||||
return segment.dateFormatted;
|
||||
}
|
||||
cur += segment.height;
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
const handleMouseEvent = (event: { clientY: number; isDragging?: boolean }) => {
|
||||
const wasDragging = isDragging;
|
||||
|
|
@ -189,7 +176,8 @@
|
|||
const segment = elems.find(({ id }) => id === 'time-segment');
|
||||
let bucketPercentY = 0;
|
||||
if (segment) {
|
||||
updateLabel(segment as HTMLElement);
|
||||
activeSegment = segment as HTMLElement;
|
||||
|
||||
const sr = segment.getBoundingClientRect();
|
||||
const sy = sr.y;
|
||||
const relativeY = clientY - sy;
|
||||
|
|
@ -197,9 +185,9 @@
|
|||
} else {
|
||||
const leadin = elems.find(({ id }) => id === 'lead-in');
|
||||
if (leadin) {
|
||||
updateLabel(leadin as HTMLElement);
|
||||
activeSegment = leadin as HTMLElement;
|
||||
} else {
|
||||
bucketDate = undefined;
|
||||
activeSegment = undefined;
|
||||
bucketPercentY = 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -230,27 +218,34 @@
|
|||
onmouseup={({ clientY }) => handleMouseEvent({ clientY, isDragging: false })}
|
||||
/>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
|
||||
<div
|
||||
transition:fly={{ x: 50, duration: 250 }}
|
||||
tabindex="-1"
|
||||
role="scrollbar"
|
||||
aria-controls="time-label"
|
||||
aria-valuenow={scrollY + HOVER_DATE_HEIGHT}
|
||||
aria-valuemax={toScrollY(100)}
|
||||
aria-valuemin={toScrollY(0)}
|
||||
id="immich-scrubbable-scrollbar"
|
||||
class="absolute right-0 z-[1] select-none bg-immich-bg hover:cursor-row-resize"
|
||||
style:padding-top={HOVER_DATE_HEIGHT + 'px'}
|
||||
style:padding-bottom={HOVER_DATE_HEIGHT + 'px'}
|
||||
class:invisible
|
||||
style:width={isDragging ? '100vw' : '60px'}
|
||||
style:height={height + 'px'}
|
||||
style:background-color={isDragging ? 'transparent' : 'transparent'}
|
||||
draggable="false"
|
||||
bind:this={scrollBar}
|
||||
onmouseenter={() => (isHover = true)}
|
||||
onmouseleave={() => (isHover = false)}
|
||||
onkeydown={(event) => onScrubKeyDown?.(event, event.currentTarget)}
|
||||
>
|
||||
{#if hoverLabel && (isHover || isDragging)}
|
||||
<div
|
||||
id="time-label"
|
||||
class="truncate opacity-85 pointer-events-none absolute right-0 z-[100] min-w-20 max-w-64 w-fit rounded-tl-md border-b-2 border-immich-primary bg-immich-bg py-1 px-1 text-sm font-medium shadow-[0_0_8px_rgba(0,0,0,0.25)] dark:border-immich-dark-primary dark:bg-immich-dark-gray dark:text-immich-dark-fg"
|
||||
class={[
|
||||
{ 'border-b-2': isDragging },
|
||||
{ 'rounded-bl-md': !isDragging },
|
||||
'truncate opacity-85 pointer-events-none absolute right-0 z-[100] min-w-20 max-w-64 w-fit rounded-tl-md border-immich-primary bg-immich-bg py-1 px-1 text-sm font-medium shadow-[0_0_8px_rgba(0,0,0,0.25)] dark:border-immich-dark-primary dark:bg-immich-dark-gray dark:text-immich-dark-fg',
|
||||
]}
|
||||
style:top="{hoverY + 2}px"
|
||||
>
|
||||
{hoverLabel}
|
||||
|
|
@ -262,12 +257,12 @@
|
|||
class="absolute right-0 h-[2px] w-10 bg-immich-primary dark:bg-immich-dark-primary"
|
||||
style:top="{scrollY + HOVER_DATE_HEIGHT}px"
|
||||
>
|
||||
{#if $isTimelineScrolling && scrubBucket?.bucketDate}
|
||||
{#if assetStore.scrolling && scrollHoverLabel}
|
||||
<p
|
||||
transition:fade={{ duration: 200 }}
|
||||
class="truncate pointer-events-none absolute right-0 bottom-0 z-[100] min-w-20 max-w-64 w-fit rounded-tl-md border-b-2 border-immich-primary bg-immich-bg/80 py-1 px-1 text-sm font-medium shadow-[0_0_8px_rgba(0,0,0,0.25)] dark:border-immich-dark-primary dark:bg-immich-dark-gray/80 dark:text-immich-dark-fg"
|
||||
>
|
||||
{assetStore.getBucketByDate(scrubBucket.bucketDate)?.bucketDateFormattted}
|
||||
{scrollHoverLabel}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -121,15 +121,14 @@
|
|||
|
||||
<Portal target="body">
|
||||
{#if showMessage}
|
||||
<div
|
||||
<dialog
|
||||
open
|
||||
class="w-[500px] absolute bottom-[75px] left-[255px] bg-gray-50 dark:border-gray-800 border border-gray-200 dark:bg-immich-dark-gray dark:text-white text-black rounded-3xl z-10 shadow-2xl px-8 py-6"
|
||||
transition:fade={{ duration: 150 }}
|
||||
onmouseover={() => (hoverMessage = true)}
|
||||
onmouseleave={() => (hoverMessage = false)}
|
||||
onfocus={() => (hoverMessage = true)}
|
||||
onblur={() => (hoverMessage = false)}
|
||||
role="dialog"
|
||||
tabindex="0"
|
||||
>
|
||||
<div class="flex justify-between place-items-center">
|
||||
<div class="h-10 w-10">
|
||||
|
|
@ -178,6 +177,12 @@
|
|||
{$t('purchase_button_reminder')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
{/if}
|
||||
</Portal>
|
||||
|
||||
<style>
|
||||
dialog {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@
|
|||
onclick={() => {}}
|
||||
/>
|
||||
</li>
|
||||
{#each pathSegments as segment, index (segment)}
|
||||
{#each pathSegments as segment, index (index)}
|
||||
{@const isLastSegment = index === pathSegments.length - 1}
|
||||
<li
|
||||
class="flex gap-2 items-center font-mono text-sm text-nowrap text-immich-primary dark:text-immich-dark-primary"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue