merge main

This commit is contained in:
Alex Tran
2025-11-25 15:01:40 +00:00
94 changed files with 3510 additions and 1729 deletions

View File

@@ -52,7 +52,7 @@
let innerHeight: number = $state(0);
let activityHeight: number = $state(0);
let chatHeight: number = $state(0);
let divHeight: number = $state(0);
let divHeight = $derived(innerHeight - activityHeight);
let previousAssetId: string | undefined = $state(assetId);
let message = $state('');
let isSendingMessage = $state(false);
@@ -96,11 +96,7 @@
}
isSendingMessage = false;
};
$effect(() => {
if (innerHeight && activityHeight) {
divHeight = innerHeight - activityHeight;
}
});
$effect(() => {
if (assetId && previousAssetId != assetId) {
previousAssetId = assetId;

View File

@@ -35,15 +35,13 @@
});
};
let albumNameArray: string[] = $state(['', '', '']);
// This part of the code is responsible for splitting album name into 3 parts where part 2 is the search query
// It is used to highlight the search query in the album name
$effect(() => {
const albumNameArray: string[] = $derived.by(() => {
let { albumName } = album;
let findIndex = normalizeSearchString(albumName).indexOf(normalizeSearchString(searchQuery));
let findLength = searchQuery.length;
albumNameArray = [
return [
albumName.slice(0, findIndex),
albumName.slice(findIndex, findIndex + findLength),
albumName.slice(findIndex + findLength),

View File

@@ -395,12 +395,12 @@
}
});
let currentAssetId = $derived(asset.id);
// primarily, this is reactive on `asset`
$effect(() => {
if (currentAssetId) {
untrack(() => handlePromiseError(handleGetAllAlbums()));
ocrManager.clear();
handlePromiseError(ocrManager.getAssetOcr(currentAssetId));
handlePromiseError(handleGetAllAlbums());
ocrManager.clear();
if (!sharedLink) {
handlePromiseError(ocrManager.getAssetOcr(asset.id));
}
});
</script>

View File

@@ -23,6 +23,7 @@
import { Icon, IconButton, LoadingSpinner, modalManager } from '@immich/ui';
import {
mdiCalendar,
mdiCamera,
mdiCameraIris,
mdiClose,
mdiEye,
@@ -372,9 +373,9 @@
</div>
</div>
{#if asset.exifInfo?.make || asset.exifInfo?.model || asset.exifInfo?.fNumber}
{#if asset.exifInfo?.make || asset.exifInfo?.model || asset.exifInfo?.exposureTime || asset.exifInfo?.iso}
<div class="flex gap-4 py-4">
<div><Icon icon={mdiCameraIris} size="24" /></div>
<div><Icon icon={mdiCamera} size="24" /></div>
<div>
{#if asset.exifInfo?.make || asset.exifInfo?.model}
@@ -395,20 +396,34 @@
</p>
{/if}
<div class="flex gap-2 text-sm">
{#if asset.exifInfo.exposureTime}
<p>{`${asset.exifInfo.exposureTime} s`}</p>
{/if}
{#if asset.exifInfo.iso}
<p>{`ISO ${asset.exifInfo.iso}`}</p>
{/if}
</div>
</div>
</div>
{/if}
{#if asset.exifInfo?.lensModel || asset.exifInfo?.fNumber || asset.exifInfo?.focalLength}
<div class="flex gap-4 py-4">
<div><Icon icon={mdiCameraIris} size="24" /></div>
<div>
{#if asset.exifInfo?.lensModel}
<div class="flex gap-2 text-sm">
<p>
<a
href={resolve(
`${AppRoute.SEARCH}?${getMetadataSearchQuery({ lensModel: asset.exifInfo.lensModel })}`,
)}
title="{$t('search_for')} {asset.exifInfo.lensModel}"
class="hover:text-primary line-clamp-1"
>
{asset.exifInfo.lensModel}
</a>
</p>
</div>
<p>
<a
href={resolve(`${AppRoute.SEARCH}?${getMetadataSearchQuery({ lensModel: asset.exifInfo.lensModel })}`)}
title="{$t('search_for')} {asset.exifInfo.lensModel}"
class="hover:text-primary line-clamp-1"
>
{asset.exifInfo.lensModel}
</a>
</p>
{/if}
<div class="flex gap-2 text-sm">
@@ -416,19 +431,9 @@
<p>ƒ/{asset.exifInfo.fNumber.toLocaleString($locale)}</p>
{/if}
{#if asset.exifInfo.exposureTime}
<p>{`${asset.exifInfo.exposureTime} s`}</p>
{/if}
{#if asset.exifInfo.focalLength}
<p>{`${asset.exifInfo.focalLength.toLocaleString($locale)} mm`}</p>
{/if}
{#if asset.exifInfo.iso}
<p>
{`ISO ${asset.exifInfo.iso}`}
</p>
{/if}
</div>
</div>
</div>

View File

@@ -171,7 +171,6 @@
$effect(() => {
if (assetFileUrl) {
// this can't be in an async context with $effect
void cast(assetFileUrl);
}
});

View File

@@ -43,7 +43,9 @@
let videoPlayer: HTMLVideoElement | undefined = $state();
let isLoading = $state(true);
let assetFileUrl = $state('');
let assetFileUrl = $derived(
playOriginalVideo ? getAssetOriginalUrl({ id: assetId, cacheKey }) : getAssetPlaybackUrl({ id: assetId, cacheKey }),
);
let isScrubbing = $state(false);
let showVideo = $state(false);
@@ -53,11 +55,9 @@
});
$effect(() => {
assetFileUrl = playOriginalVideo
? getAssetOriginalUrl({ id: assetId, cacheKey })
: getAssetPlaybackUrl({ id: assetId, cacheKey });
if (videoPlayer) {
videoPlayer.load();
// reactive on `assetFileUrl` changes
if (assetFileUrl) {
videoPlayer?.load();
}
});

View File

@@ -35,7 +35,6 @@
$effect(() => {
if (assetFileUrl) {
// this can't be in an async context with $effect
void cast(assetFileUrl);
}
});

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import Badge from '$lib/elements/Badge.svelte';
import { locale } from '$lib/stores/preferences.store';
import { QueueCommand, type QueueCommandDto, type QueueStatisticsDto, type QueueStatusDto } from '@immich/sdk';
import { QueueCommand, type QueueCommandDto, type QueueStatisticsDto, type QueueStatusLegacyDto } from '@immich/sdk';
import { Icon, IconButton } from '@immich/ui';
import {
mdiAlertCircle,
@@ -23,7 +23,7 @@
subtitle: string | undefined;
description: Component | undefined;
statistics: QueueStatisticsDto;
queueStatus: QueueStatusDto;
queueStatus: QueueStatusLegacyDto;
icon: string;
disabled?: boolean;
allText: string | undefined;

View File

@@ -6,7 +6,7 @@
QueueCommand,
type QueueCommandDto,
QueueName,
type QueuesResponseDto,
type QueuesResponseLegacyDto,
runQueueCommandLegacy,
} from '@immich/sdk';
import { modalManager, toastManager } from '@immich/ui';
@@ -29,7 +29,7 @@
import StorageMigrationDescription from './StorageMigrationDescription.svelte';
interface Props {
jobs: QueuesResponseDto;
jobs: QueuesResponseLegacyDto;
}
let { jobs = $bindable() }: Props = $props();

View File

@@ -9,7 +9,6 @@
import { type PlacesGroup, getSelectedPlacesGroupOption } from '$lib/utils/places-utils';
import { Icon } from '@immich/ui';
import { t } from 'svelte-i18n';
import { run } from 'svelte/legacy';
interface Props {
places?: AssetResponseDto[];
@@ -70,39 +69,27 @@
},
};
let filteredPlaces: AssetResponseDto[] = $state([]);
let groupedPlaces: PlacesGroup[] = $state([]);
const filteredPlaces = $derived.by(() => {
const searchQueryNormalized = normalizeSearchString(searchQuery);
return searchQueryNormalized
? places.filter((place) => normalizeSearchString(place.exifInfo?.city ?? '').includes(searchQueryNormalized))
: places;
});
let placesGroupOption: string = $state(PlacesGroupBy.None);
let hasPlaces = $derived(places.length > 0);
// Step 1: Filter using the given search query.
run(() => {
if (searchQuery) {
const searchQueryNormalized = normalizeSearchString(searchQuery);
filteredPlaces = places.filter((place) => {
return normalizeSearchString(place.exifInfo?.city ?? '').includes(searchQueryNormalized);
});
} else {
filteredPlaces = places;
}
const placesGroupOption: string = $derived(getSelectedPlacesGroupOption(userSettings));
const groupingFunction = $derived(groupOptions[placesGroupOption] ?? groupOptions[PlacesGroupBy.None]);
const groupedPlaces: PlacesGroup[] = $derived(groupingFunction(filteredPlaces));
$effect(() => {
searchResultCount = filteredPlaces.length;
});
// Step 2: Group places.
run(() => {
placesGroupOption = getSelectedPlacesGroupOption(userSettings);
const groupFunc = groupOptions[placesGroupOption] ?? groupOptions[PlacesGroupBy.None];
groupedPlaces = groupFunc(filteredPlaces);
$effect(() => {
placesGroupIds = groupedPlaces.map(({ id }) => id);
});
</script>
{#if hasPlaces}
{#if places.length > 0}
<!-- Album Cards -->
{#if placesGroupOption === PlacesGroupBy.None}
<PlacesCardGroup places={groupedPlaces[0].places} />

View File

@@ -7,20 +7,11 @@
import { mdiCameraIris, mdiChartPie, mdiPlayCircle } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
stats?: ServerStatsResponseDto;
}
type Props = {
stats: ServerStatsResponseDto;
};
let {
stats = {
photos: 0,
videos: 0,
usage: 0,
usagePhotos: 0,
usageVideos: 0,
usageByUser: [],
},
}: Props = $props();
const { stats }: Props = $props();
const zeros = (value: number) => {
const maxLength = 13;

View File

@@ -27,7 +27,7 @@
let { asset = undefined, point: initialPoint, onClose }: Props = $props();
let places: PlacesResponseDto[] = $state([]);
let suggestedPlaces: PlacesResponseDto[] = $state([]);
let suggestedPlaces: PlacesResponseDto[] = $derived(places.slice(0, 5));
let searchWord: string = $state('');
let latestSearchTimeout: number;
let showLoadingSpinner = $state(false);
@@ -52,9 +52,6 @@
});
$effect(() => {
if (places) {
suggestedPlaces = places.slice(0, 5);
}
if (searchWord === '') {
suggestedPlaces = [];
}

View File

@@ -33,37 +33,36 @@
children,
}: Props = $props();
let left: number = $state(0);
let top: number = $state(0);
const swap = (direction: string) => (direction === 'left' ? 'right' : 'left');
const layoutDirection = $derived(languageManager.rtl ? swap(direction) : direction);
const position = $derived.by(() => {
if (!menuElement) {
return { left: 0, top: 0 };
}
const rect = menuElement.getBoundingClientRect();
const directionWidth = layoutDirection === 'left' ? rect.width : 0;
const menuHeight = Math.min(menuElement.clientHeight, height) || 0;
const left = Math.max(8, Math.min(window.innerWidth - rect.width, x - directionWidth));
const top = Math.max(8, Math.min(window.innerHeight - menuHeight, y));
return { left, top };
});
// We need to bind clientHeight since the bounding box may return a height
// of zero when starting the 'slide' animation.
let height: number = $state(0);
let isTransitioned = $state(false);
$effect(() => {
if (menuElement) {
let layoutDirection = direction;
if (languageManager.rtl) {
layoutDirection = direction === 'left' ? 'right' : 'left';
}
const rect = menuElement.getBoundingClientRect();
const directionWidth = layoutDirection === 'left' ? rect.width : 0;
const menuHeight = Math.min(menuElement.clientHeight, height) || 0;
left = Math.max(8, Math.min(window.innerWidth - rect.width, x - directionWidth));
top = Math.max(8, Math.min(window.innerHeight - menuHeight, y));
}
});
</script>
<div
bind:clientHeight={height}
class="fixed min-w-50 w-max max-w-75 overflow-hidden rounded-lg shadow-lg z-1"
style:left="{left}px"
style:top="{top}px"
style:left="{position.left}px"
style:top="{position.top}px"
transition:slide={{ duration: 250, easing: quintOut }}
use:clickOutside={{ onOutclick: onClose }}
onintroend={() => {

View File

@@ -64,10 +64,11 @@
}
}
let makeFilter = $derived(filters.make);
let modelFilter = $derived(filters.model);
let lensModelFilter = $derived(filters.lensModel);
const makeFilter = $derived(filters.make);
const modelFilter = $derived(filters.model);
const lensModelFilter = $derived(filters.lensModel);
// TODO replace by async $derived, at the latest when it's in stable https://svelte.dev/docs/svelte/await-expressions
$effect(() => {
handlePromiseError(updateMakes());
});

View File

@@ -7,11 +7,10 @@
</script>
<script lang="ts">
import { run } from 'svelte/legacy';
import Combobox, { asComboboxOptions, asSelectedOption } from '$lib/components/shared-components/combobox.svelte';
import { handlePromiseError } from '$lib/utils';
import { getSearchSuggestions, SearchSuggestionType } from '@immich/sdk';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
interface Props {
@@ -66,15 +65,12 @@
}
let countryFilter = $derived(filters.country);
let stateFilter = $derived(filters.state);
run(() => {
handlePromiseError(updateCountries());
});
run(() => {
handlePromiseError(updateStates(countryFilter));
});
run(() => {
handlePromiseError(updateCities(countryFilter, stateFilter));
});
// TODO replace by async $derived, at the latest when it's in stable https://svelte.dev/docs/svelte/await-expressions
$effect(() => handlePromiseError(updateStates(countryFilter)));
$effect(() => handlePromiseError(updateCities(countryFilter, stateFilter)));
onMount(() => updateCountries());
</script>
<div id="location-selection">

View File

@@ -0,0 +1,66 @@
<script lang="ts">
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import type { ViewerAsset } from '$lib/managers/timeline-manager/viewer-asset.svelte';
import type { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
import { uploadAssetsStore } from '$lib/stores/upload';
import type { CommonPosition } from '$lib/utils/layout-utils';
import type { Snippet } from 'svelte';
import { flip } from 'svelte/animate';
import { scale } from 'svelte/transition';
let { isUploading } = uploadAssetsStore;
type Props = {
viewerAssets: ViewerAsset[];
width: number;
height: number;
manager: VirtualScrollManager;
thumbnail: Snippet<
[
{
asset: TimelineAsset;
position: CommonPosition;
},
]
>;
customThumbnailLayout?: Snippet<[asset: TimelineAsset]>;
};
const { viewerAssets, width, height, manager, thumbnail, customThumbnailLayout }: Props = $props();
const transitionDuration = $derived(manager.suspendTransitions && !$isUploading ? 0 : 150);
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
const filterIntersecting = <T extends { intersecting: boolean }>(intersectables: T[]) => {
return intersectables.filter(({ intersecting }) => intersecting);
};
</script>
<!-- Image grid -->
<div data-image-grid class="relative overflow-clip" style:height={height + 'px'} style:width={width + 'px'}>
{#each filterIntersecting(viewerAssets) as viewerAsset (viewerAsset.id)}
{@const position = viewerAsset.position!}
{@const asset = viewerAsset.asset!}
<!-- note: don't remove data-asset-id - its used by web e2e tests -->
<div
data-asset-id={asset.id}
class="absolute"
style:top={position.top + 'px'}
style:left={position.left + 'px'}
style:width={position.width + 'px'}
style:height={position.height + 'px'}
out:scale|global={{ start: 0.1, duration: scaleDuration }}
animate:flip={{ duration: transitionDuration }}
>
{@render thumbnail({ asset, position })}
{@render customThumbnailLayout?.(asset)}
</div>
{/each}
</div>
<style>
[data-image-grid] {
user-select: none;
}
</style>

View File

@@ -0,0 +1,115 @@
<script lang="ts">
import AssetLayout from '$lib/components/timeline/AssetLayout.svelte';
import { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
import type { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { uploadAssetsStore } from '$lib/stores/upload';
import type { CommonPosition } from '$lib/utils/layout-utils';
import { fromTimelinePlainDate, getDateLocaleString } from '$lib/utils/timeline-util';
import { Icon } from '@immich/ui';
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
import type { Snippet } from 'svelte';
type Props = {
thumbnail: Snippet<[{ asset: TimelineAsset; position: CommonPosition; dayGroup: DayGroup; groupIndex: number }]>;
customThumbnailLayout?: Snippet<[TimelineAsset]>;
singleSelect: boolean;
assetInteraction: AssetInteraction;
monthGroup: MonthGroup;
manager: VirtualScrollManager;
onDayGroupSelect: (dayGroup: DayGroup, assets: TimelineAsset[]) => void;
};
let {
thumbnail: thumbnailWithGroup,
customThumbnailLayout,
singleSelect,
assetInteraction,
monthGroup,
manager,
onDayGroupSelect,
}: Props = $props();
let { isUploading } = uploadAssetsStore;
let hoveredDayGroup = $state<string | null>(null);
const isMouseOverGroup = $derived(hoveredDayGroup !== null);
const transitionDuration = $derived(monthGroup.timelineManager.suspendTransitions && !$isUploading ? 0 : 150);
const filterIntersecting = <T extends { intersecting: boolean }>(intersectables: T[]) => {
return intersectables.filter(({ intersecting }) => intersecting);
};
const getDayGroupFullDate = (dayGroup: DayGroup): string => {
const { month, year } = dayGroup.monthGroup.yearMonth;
const date = fromTimelinePlainDate({
year,
month,
day: dayGroup.day,
});
return getDateLocaleString(date);
};
</script>
{#each filterIntersecting(monthGroup.dayGroups) as dayGroup, groupIndex (dayGroup.day)}
{@const absoluteWidth = dayGroup.left}
{@const isDayGroupSelected = assetInteraction.selectedGroup.has(dayGroup.groupTitle)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<section
class={[
{ 'transition-all': !monthGroup.timelineManager.suspendTransitions },
!monthGroup.timelineManager.suspendTransitions && `delay-${transitionDuration}`,
]}
data-group
style:position="absolute"
style:transform={`translate3d(${absoluteWidth}px,${dayGroup.top}px,0)`}
onmouseenter={() => (hoveredDayGroup = dayGroup.groupTitle)}
onmouseleave={() => (hoveredDayGroup = null)}
>
<!-- Month title -->
<div
class="flex pt-7 pb-5 max-md:pt-5 max-md:pb-3 h-6 place-items-center text-xs font-medium text-immich-fg dark:text-immich-dark-fg md:text-sm"
style:width={dayGroup.width + 'px'}
>
{#if !singleSelect}
<div
class="hover:cursor-pointer transition-all duration-200 ease-out overflow-hidden w-0"
class:w-8={(hoveredDayGroup === dayGroup.groupTitle && isMouseOverGroup) ||
assetInteraction.selectedGroup.has(dayGroup.groupTitle)}
onclick={() => onDayGroupSelect(dayGroup, assetsSnapshot(dayGroup.getAssets()))}
onkeydown={() => onDayGroupSelect(dayGroup, assetsSnapshot(dayGroup.getAssets()))}
>
{#if isDayGroupSelected}
<Icon icon={mdiCheckCircle} size="24" class="text-primary" />
{:else}
<Icon icon={mdiCircleOutline} size="24" color="#757575" />
{/if}
</div>
{/if}
<span class="w-full truncate first-letter:capitalize" title={getDayGroupFullDate(dayGroup)}>
{dayGroup.groupTitle}
</span>
</div>
<AssetLayout
{manager}
viewerAssets={dayGroup.viewerAssets}
height={dayGroup.height}
width={dayGroup.width}
{customThumbnailLayout}
>
{#snippet thumbnail({ asset, position })}
{@render thumbnailWithGroup({ asset, position, dayGroup, groupIndex })}
{/snippet}
</AssetLayout>
</section>
{/each}
<style>
section {
contain: layout paint style;
}
</style>

View File

@@ -2,6 +2,8 @@
import { afterNavigate, beforeNavigate } from '$app/navigation';
import { page } from '$app/state';
import { resizeObserver, type OnResizeCallback } from '$lib/actions/resize-observer';
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import Month from '$lib/components/timeline/Month.svelte';
import Scrubber from '$lib/components/timeline/Scrubber.svelte';
import TimelineAssetViewer from '$lib/components/timeline/TimelineAssetViewer.svelte';
import TimelineKeyboardActions from '$lib/components/timeline/actions/TimelineKeyboardActions.svelte';
@@ -19,13 +21,12 @@
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { isAssetViewerRoute } from '$lib/utils/navigation';
import { isAssetViewerRoute, navigate } from '$lib/utils/navigation';
import { getTimes, type ScrubberListener } from '$lib/utils/timeline-util';
import { type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
import { DateTime } from 'luxon';
import { onDestroy, onMount, type Snippet } from 'svelte';
import type { UpdatePayload } from 'vite';
import TimelineDateGroup from './TimelineDateGroup.svelte';
interface Props {
isSelectionMode?: boolean;
@@ -54,7 +55,7 @@
onEscape?: () => void;
children?: Snippet;
empty?: Snippet;
customLayout?: Snippet<[TimelineAsset]>;
customThumbnailLayout?: Snippet<[TimelineAsset]>;
onThumbnailClick?: (
asset: TimelineAsset,
timelineManager: TimelineManager,
@@ -86,7 +87,7 @@
onEscape = () => {},
children,
empty,
customLayout,
customThumbnailLayout,
onThumbnailClick,
}: Props = $props();
@@ -398,7 +399,8 @@
lastAssetMouseEvent = asset;
};
const handleGroupSelect = (timelineManager: TimelineManager, group: string, assets: TimelineAsset[]) => {
const handleGroupSelect = (dayGroup: DayGroup, assets: TimelineAsset[]) => {
const group = dayGroup.groupTitle;
if (assetInteraction.selectedGroup.has(group)) {
assetInteraction.removeGroupFromMultiselectGroup(group);
for (const asset of assets) {
@@ -418,7 +420,7 @@
}
};
const handleSelectAssets = async (asset: TimelineAsset) => {
const onSelectAssets = async (asset: TimelineAsset) => {
if (!asset) {
return;
}
@@ -540,6 +542,40 @@
void timelineManager.loadMonthGroup({ year: localDateTime.year, month: localDateTime.month });
}
});
const assetSelectHandler = (
timelineManager: TimelineManager,
asset: TimelineAsset,
assetsInDayGroup: TimelineAsset[],
groupTitle: string,
) => {
void onSelectAssets(asset);
// Check if all assets are selected in a group to toggle the group selection's icon
let selectedAssetsInGroupCount = assetsInDayGroup.filter(({ id }) => assetInteraction.hasSelectedAsset(id)).length;
// if all assets are selected in a group, add the group to selected group
if (selectedAssetsInGroupCount === assetsInDayGroup.length) {
assetInteraction.addGroupToMultiselectGroup(groupTitle);
} else {
assetInteraction.removeGroupFromMultiselectGroup(groupTitle);
}
isSelectingAllAssets.set(timelineManager.assetCount === assetInteraction.selectedAssets.length);
};
const _onClick = (
timelineManager: TimelineManager,
assets: TimelineAsset[],
groupTitle: string,
asset: TimelineAsset,
) => {
if (isSelectionMode || assetInteraction.selectionActive) {
assetSelectHandler(timelineManager, asset, assets, groupTitle);
return;
}
void navigate({ targetRoute: 'current', assetId: asset.id });
};
</script>
<svelte:document onkeydown={onKeyDown} onkeyup={onKeyUp} />
@@ -649,20 +685,47 @@
style:transform={`translate3d(0,${absoluteHeight}px,0)`}
style:width="100%"
>
<TimelineDateGroup
{withStacked}
{showArchiveIcon}
<Month
{assetInteraction}
{timelineManager}
{isSelectionMode}
{customThumbnailLayout}
{singleSelect}
{monthGroup}
onSelect={({ title, assets }) => handleGroupSelect(timelineManager, title, assets)}
onSelectAssetCandidates={handleSelectAssetCandidates}
onSelectAssets={handleSelectAssets}
{customLayout}
{onThumbnailClick}
/>
manager={timelineManager}
onDayGroupSelect={handleGroupSelect}
>
{#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={(asset) => {
if (typeof onThumbnailClick === 'function') {
onThumbnailClick(asset, timelineManager, dayGroup, _onClick);
} else {
_onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset);
}
}}
onSelect={() => {
if (isSelectionMode || assetInteraction.selectionActive) {
assetSelectHandler(timelineManager, asset, dayGroup.getAssets(), dayGroup.groupTitle);
return;
}
void onSelectAssets(asset);
}}
onMouseEvent={() => handleSelectAssetCandidates(asset)}
selected={isAssetSelected}
selectionCandidate={isAssetSelectionCandidate}
disabled={isAssetDisabled}
thumbnailWidth={position.width}
thumbnailHeight={position.height}
/>
{/snippet}
</Month>
</div>
{/if}
{/each}

View File

@@ -1,246 +0,0 @@
<script lang="ts">
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.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 { assetSnapshot, assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
import { uploadAssetsStore } from '$lib/stores/upload';
import { navigate } from '$lib/utils/navigation';
import { mdiCheckCircle, mdiCircleOutline } from '@mdi/js';
import { fromTimelinePlainDate, getDateLocaleString } from '$lib/utils/timeline-util';
import { Icon } from '@immich/ui';
import { type Snippet } from 'svelte';
import { flip } from 'svelte/animate';
import { scale } from 'svelte/transition';
let { isUploading } = uploadAssetsStore;
interface Props {
isSelectionMode: boolean;
singleSelect: boolean;
withStacked: boolean;
showArchiveIcon: boolean;
monthGroup: MonthGroup;
timelineManager: TimelineManager;
assetInteraction: AssetInteraction;
customLayout?: Snippet<[TimelineAsset]>;
onSelect: ({ title, assets }: { title: string; assets: TimelineAsset[] }) => void;
onSelectAssets: (asset: TimelineAsset) => void;
onSelectAssetCandidates: (asset: TimelineAsset | null) => void;
onThumbnailClick?: (
asset: TimelineAsset,
timelineManager: TimelineManager,
dayGroup: DayGroup,
onClick: (
timelineManager: TimelineManager,
assets: TimelineAsset[],
groupTitle: string,
asset: TimelineAsset,
) => void,
) => void;
}
let {
isSelectionMode,
singleSelect,
withStacked,
showArchiveIcon,
monthGroup = $bindable(),
assetInteraction,
timelineManager,
customLayout,
onSelect,
onSelectAssets,
onSelectAssetCandidates,
onThumbnailClick,
}: Props = $props();
let isMouseOverGroup = $state(false);
let hoveredDayGroup = $state();
const transitionDuration = $derived.by(() =>
monthGroup.timelineManager.suspendTransitions && !$isUploading ? 0 : 150,
);
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
const _onClick = (
timelineManager: TimelineManager,
assets: TimelineAsset[],
groupTitle: string,
asset: TimelineAsset,
) => {
if (isSelectionMode || assetInteraction.selectionActive) {
assetSelectHandler(timelineManager, asset, assets, groupTitle);
return;
}
void navigate({ targetRoute: 'current', assetId: asset.id });
};
const handleSelectGroup = (title: string, assets: TimelineAsset[]) => onSelect({ title, assets });
const assetSelectHandler = (
timelineManager: TimelineManager,
asset: TimelineAsset,
assetsInDayGroup: TimelineAsset[],
groupTitle: string,
) => {
onSelectAssets(asset);
// Check if all assets are selected in a group to toggle the group selection's icon
let selectedAssetsInGroupCount = assetsInDayGroup.filter((asset) =>
assetInteraction.hasSelectedAsset(asset.id),
).length;
// if all assets are selected in a group, add the group to selected group
if (selectedAssetsInGroupCount == assetsInDayGroup.length) {
assetInteraction.addGroupToMultiselectGroup(groupTitle);
} else {
assetInteraction.removeGroupFromMultiselectGroup(groupTitle);
}
if (timelineManager.assetCount == assetInteraction.selectedAssets.length) {
isSelectingAllAssets.set(true);
} else {
isSelectingAllAssets.set(false);
}
};
const assetMouseEventHandler = (groupTitle: string, asset: TimelineAsset | null) => {
// Show multi select icon on hover on date group
hoveredDayGroup = groupTitle;
if (assetInteraction.selectionActive) {
onSelectAssetCandidates(asset);
}
};
function filterIntersecting<R extends { intersecting: boolean }>(intersectable: R[]) {
return intersectable.filter((int) => int.intersecting);
}
const getDayGroupFullDate = (dayGroup: DayGroup): string => {
const { month, year } = dayGroup.monthGroup.yearMonth;
const date = fromTimelinePlainDate({
year,
month,
day: dayGroup.day,
});
return getDateLocaleString(date);
};
</script>
{#each filterIntersecting(monthGroup.dayGroups) as dayGroup, groupIndex (dayGroup.day)}
{@const absoluteWidth = dayGroup.left}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<section
class={[
{ 'transition-all': !monthGroup.timelineManager.suspendTransitions },
!monthGroup.timelineManager.suspendTransitions && `delay-${transitionDuration}`,
]}
data-group
style:position="absolute"
style:transform={`translate3d(${absoluteWidth}px,${dayGroup.top}px,0)`}
onmouseenter={() => {
isMouseOverGroup = true;
assetMouseEventHandler(dayGroup.groupTitle, null);
}}
onmouseleave={() => {
isMouseOverGroup = false;
assetMouseEventHandler(dayGroup.groupTitle, null);
}}
>
<!-- Date group title -->
<div
class="flex pt-7 pb-5 max-md:pt-5 max-md:pb-3 h-6 place-items-center text-xs font-medium text-immich-fg dark:text-immich-dark-fg md:text-sm"
style:width={dayGroup.width + 'px'}
>
{#if !singleSelect}
<div
class="hover:cursor-pointer transition-all duration-200 ease-out overflow-hidden w-0"
class:w-8={(hoveredDayGroup === dayGroup.groupTitle && isMouseOverGroup) ||
assetInteraction.selectedGroup.has(dayGroup.groupTitle)}
onclick={() => handleSelectGroup(dayGroup.groupTitle, assetsSnapshot(dayGroup.getAssets()))}
onkeydown={() => handleSelectGroup(dayGroup.groupTitle, assetsSnapshot(dayGroup.getAssets()))}
>
{#if assetInteraction.selectedGroup.has(dayGroup.groupTitle)}
<Icon icon={mdiCheckCircle} size="24" class="text-primary" />
{:else}
<Icon icon={mdiCircleOutline} size="24" color="#757575" />
{/if}
</div>
{/if}
<span class="w-full truncate first-letter:capitalize" title={getDayGroupFullDate(dayGroup)}>
{dayGroup.groupTitle}
</span>
</div>
<!-- Image grid -->
<div
data-image-grid
class="relative overflow-clip"
style:height={dayGroup.height + 'px'}
style:width={dayGroup.width + 'px'}
>
{#each filterIntersecting(dayGroup.viewerAssets) as viewerAsset (viewerAsset.id)}
{@const position = viewerAsset.position!}
{@const asset = viewerAsset.asset!}
<!-- {#if viewerAsset.intersecting} -->
<!-- note: don't remove data-asset-id - its used by web e2e tests -->
<div
data-asset-id={asset.id}
class="absolute"
style:top={position.top + 'px'}
style:left={position.left + 'px'}
style:width={position.width + 'px'}
style:height={position.height + 'px'}
out:scale|global={{ start: 0.1, duration: scaleDuration }}
animate:flip={{ duration: transitionDuration }}
>
<Thumbnail
showStackedIcon={withStacked}
{showArchiveIcon}
{asset}
{groupIndex}
onClick={(asset) => {
if (typeof onThumbnailClick === 'function') {
onThumbnailClick(asset, timelineManager, dayGroup, _onClick);
} else {
_onClick(timelineManager, dayGroup.getAssets(), dayGroup.groupTitle, asset);
}
}}
onSelect={(asset) => assetSelectHandler(timelineManager, asset, dayGroup.getAssets(), dayGroup.groupTitle)}
onMouseEvent={() => assetMouseEventHandler(dayGroup.groupTitle, assetSnapshot(asset))}
selected={assetInteraction.hasSelectedAsset(asset.id) ||
dayGroup.monthGroup.timelineManager.albumAssets.has(asset.id)}
selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)}
disabled={dayGroup.monthGroup.timelineManager.albumAssets.has(asset.id)}
thumbnailWidth={position.width}
thumbnailHeight={position.height}
/>
{#if customLayout}
{@render customLayout(asset)}
{/if}
</div>
<!-- {/if} -->
{/each}
</div>
</section>
{/each}
<style>
section {
contain: layout paint style;
}
[data-image-grid] {
user-select: none;
}
</style>

View File

@@ -1,142 +0,0 @@
<script lang="ts">
import { type PluginResponseDto, PluginContext } from '@immich/sdk';
import { Button, Field, Icon, IconButton } from '@immich/ui';
import { mdiChevronDown, mdiChevronUp, mdiClose, mdiPlus } from '@mdi/js';
import { t } from 'svelte-i18n';
import SchemaFormFields from './schema-form/SchemaFormFields.svelte';
interface Props {
actions: Array<{ actionId: string; actionConfig?: object }>;
triggerType: 'AssetCreate' | 'PersonRecognized';
plugins: PluginResponseDto[];
}
let { actions = $bindable([]), triggerType, plugins }: Props = $props();
// Map trigger type to context
const getTriggerContext = (trigger: string): PluginContext => {
const contextMap: Record<string, PluginContext> = {
AssetCreate: PluginContext.Asset,
PersonRecognized: PluginContext.Person,
};
return contextMap[trigger] || PluginContext.Asset;
};
const triggerContext = $derived(getTriggerContext(triggerType));
// Get all available actions that match the trigger context
const availableActions = $derived(
plugins.flatMap((plugin) => plugin.actions.filter((action) => action.supportedContexts.includes(triggerContext))),
);
const addAction = () => {
if (availableActions.length > 0) {
actions = [...actions, { actionId: availableActions[0].id, actionConfig: {} }];
}
};
const removeAction = (index: number) => {
actions = actions.filter((_, i) => i !== index);
};
const moveUp = (index: number) => {
if (index > 0) {
const newActions = [...actions];
[newActions[index - 1], newActions[index]] = [newActions[index], newActions[index - 1]];
actions = newActions;
}
};
const moveDown = (index: number) => {
if (index < actions.length - 1) {
const newActions = [...actions];
[newActions[index], newActions[index + 1]] = [newActions[index + 1], newActions[index]];
actions = newActions;
}
};
const getActionById = (actionId: string) => {
for (const plugin of plugins) {
const action = plugin.actions.find((a) => a.id === actionId);
if (action) {
return action;
}
}
return null;
};
</script>
{#if actions.length === 0}
<div
class="rounded-lg border border-dashed border-gray-300 p-4 text-center text-sm text-gray-600 dark:border-gray-700 dark:text-gray-400"
>
{$t('no_actions_added')}
</div>
{:else}
<div class="flex flex-col gap-3">
{#each actions as action, index (index)}
{@const actionDef = getActionById(action.actionId)}
<div class="rounded-lg border border-gray-300 p-3 dark:border-gray-700">
<div class="mb-2 flex items-center justify-between">
<div class="flex-1">
<Field label={$t('action')}>
<select
bind:value={action.actionId}
class="immich-form-input w-full"
onchange={() => {
action.actionConfig = {};
}}
>
{#each availableActions as availAction (availAction.id)}
<option value={availAction.id}>{availAction.title}</option>
{/each}
</select>
</Field>
</div>
<div class="ml-2 flex gap-1">
<IconButton
shape="round"
color="secondary"
icon={mdiChevronUp}
aria-label={$t('move_up')}
onclick={() => moveUp(index)}
disabled={index === 0}
size="small"
/>
<IconButton
shape="round"
color="secondary"
icon={mdiChevronDown}
aria-label={$t('move_down')}
onclick={() => moveDown(index)}
disabled={index === actions.length - 1}
size="small"
/>
<IconButton
shape="round"
color="secondary"
icon={mdiClose}
aria-label={$t('remove')}
onclick={() => removeAction(index)}
size="small"
/>
</div>
</div>
{#if actionDef}
<div class="text-xs text-gray-600 dark:text-gray-400 mb-2">
{actionDef.description}
</div>
{#if actionDef.schema}
<SchemaFormFields schema={actionDef.schema} bind:config={action.actionConfig} />
{/if}
{/if}
</div>
{/each}
</div>
{/if}
<Button shape="round" size="small" onclick={addAction} disabled={availableActions.length === 0} class="mt-2">
<Icon icon={mdiPlus} size="18" />
{$t('add_action')}
</Button>

View File

@@ -1,142 +0,0 @@
<script lang="ts">
import { type PluginResponseDto, PluginContext } from '@immich/sdk';
import { Button, Field, Icon, IconButton } from '@immich/ui';
import { mdiChevronDown, mdiChevronUp, mdiClose, mdiPlus } from '@mdi/js';
import { t } from 'svelte-i18n';
import SchemaFormFields from './schema-form/SchemaFormFields.svelte';
interface Props {
filters: Array<{ filterId: string; filterConfig?: object }>;
triggerType: 'AssetCreate' | 'PersonRecognized';
plugins: PluginResponseDto[];
}
let { filters = $bindable([]), triggerType, plugins }: Props = $props();
// Map trigger type to context
const getTriggerContext = (trigger: string): PluginContext => {
const contextMap: Record<string, PluginContext> = {
AssetCreate: PluginContext.Asset,
PersonRecognized: PluginContext.Person,
};
return contextMap[trigger] || PluginContext.Asset;
};
const triggerContext = $derived(getTriggerContext(triggerType));
// Get all available filters that match the trigger context
const availableFilters = $derived(
plugins.flatMap((plugin) => plugin.filters.filter((filter) => filter.supportedContexts.includes(triggerContext))),
);
const addFilter = () => {
if (availableFilters.length > 0) {
filters = [...filters, { filterId: availableFilters[0].id, filterConfig: {} }];
}
};
const removeFilter = (index: number) => {
filters = filters.filter((_, i) => i !== index);
};
const moveUp = (index: number) => {
if (index > 0) {
const newFilters = [...filters];
[newFilters[index - 1], newFilters[index]] = [newFilters[index], newFilters[index - 1]];
filters = newFilters;
}
};
const moveDown = (index: number) => {
if (index < filters.length - 1) {
const newFilters = [...filters];
[newFilters[index], newFilters[index + 1]] = [newFilters[index + 1], newFilters[index]];
filters = newFilters;
}
};
const getFilterById = (filterId: string) => {
for (const plugin of plugins) {
const filter = plugin.filters.find((f) => f.id === filterId);
if (filter) {
return filter;
}
}
return null;
};
</script>
{#if filters.length === 0}
<div
class="rounded-lg border border-dashed border-gray-300 p-4 text-center text-sm text-gray-600 dark:border-gray-700 dark:text-gray-400"
>
{$t('no_filters_added')}
</div>
{:else}
<div class="flex flex-col gap-3">
{#each filters as filter, index (index)}
{@const filterDef = getFilterById(filter.filterId)}
<div class="rounded-lg border border-gray-300 p-3 dark:border-gray-700">
<div class="mb-2 flex items-center justify-between">
<div class="flex-1">
<Field label={$t('filter')}>
<select
bind:value={filter.filterId}
class="immich-form-input w-full"
onchange={() => {
filter.filterConfig = {};
}}
>
{#each availableFilters as availFilter (availFilter.id)}
<option value={availFilter.id}>{availFilter.title}</option>
{/each}
</select>
</Field>
</div>
<div class="ml-2 flex gap-1">
<IconButton
shape="round"
color="secondary"
icon={mdiChevronUp}
aria-label={$t('move_up')}
onclick={() => moveUp(index)}
disabled={index === 0}
size="small"
/>
<IconButton
shape="round"
color="secondary"
icon={mdiChevronDown}
aria-label={$t('move_down')}
onclick={() => moveDown(index)}
disabled={index === filters.length - 1}
size="small"
/>
<IconButton
shape="round"
color="secondary"
icon={mdiClose}
aria-label={$t('remove')}
onclick={() => removeFilter(index)}
size="small"
/>
</div>
</div>
{#if filterDef}
<div class="text-xs text-gray-600 dark:text-gray-400 mb-2">
{filterDef.description}
</div>
{#if filterDef.schema}
<SchemaFormFields schema={filterDef.schema} bind:config={filter.filterConfig} />
{/if}
{/if}
</div>
{/each}
</div>
{/if}
<Button shape="round" size="small" onclick={addFilter} disabled={availableFilters.length === 0} class="mt-2">
<Icon icon={mdiPlus} size="18" />
{$t('add_filter')}
</Button>

View File

@@ -0,0 +1,178 @@
<script lang="ts">
import type { PluginActionResponseDto, PluginFilterResponseDto, PluginTriggerResponseDto } from '@immich/sdk';
import { Icon, IconButton } from '@immich/ui';
import {
mdiClose,
mdiDrag,
mdiFilterOutline,
mdiFlashOutline,
mdiPlayCircleOutline,
mdiViewDashboardOutline,
} from '@mdi/js';
interface Props {
trigger: PluginTriggerResponseDto;
filters: PluginFilterResponseDto[];
actions: PluginActionResponseDto[];
}
let { trigger, filters, actions }: Props = $props();
let isOpen = $state(false);
let position = $state({ x: 0, y: 0 });
let isDragging = $state(false);
let dragOffset = $state({ x: 0, y: 0 });
let containerEl: HTMLDivElement | undefined = $state();
const handleMouseDown = (e: MouseEvent) => {
if (!containerEl) {
return;
}
isDragging = true;
const rect = containerEl.getBoundingClientRect();
dragOffset = {
x: e.clientX - rect.left,
y: e.clientY - rect.top,
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
};
const handleMouseMove = (e: MouseEvent) => {
if (!isDragging) {
return;
}
position = {
x: e.clientX - dragOffset.x,
y: e.clientY - dragOffset.y,
};
};
const handleMouseUp = () => {
isDragging = false;
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
$effect(() => {
// Initialize position to bottom-right on mount
if (globalThis.window && position.x === 0 && position.y === 0) {
position = {
x: globalThis.innerWidth - 280,
y: globalThis.innerHeight - 400,
};
}
});
</script>
{#if isOpen}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={containerEl}
class="hidden sm:block fixed w-64 z-50 hover:cursor-grab"
style="left: {position.x}px; top: {position.y}px;"
class:cursor-grabbing={isDragging}
onmousedown={handleMouseDown}
>
<div
class="rounded-xl border hover:shadow-xl hover:border-dashed bg-light-50 shadow-sm p-4 hover:border-primary-200 transition-all"
>
<div class="flex items-center justify-between mb-4 cursor-grab select-none">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300">Workflow Summary</h3>
<div class="flex items-center gap-1">
<Icon icon={mdiDrag} size="18" class="text-gray-400" />
<IconButton
icon={mdiClose}
size="small"
variant="ghost"
color="secondary"
title="Close summary"
aria-label="Close summary"
onclick={(e: MouseEvent) => {
e.stopPropagation();
isOpen = false;
}}
/>
</div>
</div>
<div class="space-y-2">
<!-- Trigger -->
<div class="rounded-lg bg-light-100 border p-3">
<div class="flex items-center gap-2 mb-1">
<Icon icon={mdiFlashOutline} size="14" class="text-primary" />
<span class="text-[10px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"
>Trigger</span
>
</div>
<p class="text-sm text-gray-900 dark:text-gray-100 truncate pl-5">{trigger.name}</p>
</div>
<!-- Connector -->
<div class="flex justify-center">
<div class="w-0.5 h-3 bg-gray-300 dark:bg-gray-600"></div>
</div>
<!-- Filters -->
{#if filters.length > 0}
<div class="rounded-lg bg-light-100 border p-3">
<div class="flex items-center gap-2 mb-2">
<Icon icon={mdiFilterOutline} size="14" class="text-warning" />
<span class="text-[10px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"
>Filters ({filters.length})</span
>
</div>
<div class="space-y-1 pl-5">
{#each filters as filter, index (filter.id)}
<div class="flex items-center gap-2">
<span
class="shrink-0 h-4 w-4 rounded-full bg-gray-200 dark:bg-gray-700 text-[10px] font-medium text-gray-600 dark:text-gray-300 flex items-center justify-center"
>{index + 1}</span
>
<p class="text-sm text-gray-700 dark:text-gray-300 truncate">{filter.title}</p>
</div>
{/each}
</div>
</div>
<!-- Connector -->
<div class="flex justify-center">
<div class="w-0.5 h-3 bg-gray-300 dark:bg-gray-600"></div>
</div>
{/if}
<!-- Actions -->
{#if actions.length > 0}
<div class="rounded-lg bg-light-100 border p-3">
<div class="flex items-center gap-2 mb-2">
<Icon icon={mdiPlayCircleOutline} size="14" class="text-success" />
<span class="text-[10px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"
>Actions ({actions.length})</span
>
</div>
<div class="space-y-1 pl-5">
{#each actions as action, index (action.id)}
<div class="flex items-center gap-2">
<span
class="shrink-0 h-4 w-4 rounded-full bg-gray-200 dark:bg-gray-700 text-[10px] font-medium text-gray-600 dark:text-gray-300 flex items-center justify-center"
>{index + 1}</span
>
<p class="text-sm text-gray-700 dark:text-gray-300 truncate">{action.title}</p>
</div>
{/each}
</div>
</div>
{/if}
</div>
</div>
</div>
{:else}
<button
type="button"
class="hidden sm:flex fixed right-6 bottom-6 z-50 h-14 w-14 items-center justify-center rounded-full bg-primary text-light shadow-lg hover:bg-primary/90 transition-colors"
title="Show workflow summary"
onclick={() => (isOpen = true)}
>
<Icon icon={mdiViewDashboardOutline} size="24" />
</button>
{/if}

View File

@@ -1,83 +0,0 @@
<script lang="ts">
import type { PluginActionResponseDto, PluginFilterResponseDto, PluginTriggerResponseDto } from '@immich/sdk';
import { Icon } from '@immich/ui';
import { mdiChevronRight, mdiFilterOutline, mdiFlashOutline, mdiPlayCircleOutline } from '@mdi/js';
interface Props {
trigger: PluginTriggerResponseDto;
filters: PluginFilterResponseDto[];
actions: PluginActionResponseDto[];
}
let { trigger, filters, actions }: Props = $props();
</script>
<div class="fixed right-20 top-1/2 -translate-y-1/2 z-10 w-64">
<div class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 shadow-lg p-4">
<h3 class="text-sm font-semibold mb-4 text-gray-700 dark:text-gray-300">Workflow Summary</h3>
<div class="space-y-3">
<!-- Trigger -->
<div class="flex items-start gap-2">
<div class="shrink-0 mt-0.5">
<div class="h-6 w-6 rounded-md bg-indigo-100 dark:bg-primary/20 flex items-center justify-center">
<Icon icon={mdiFlashOutline} size="16" class="text-primary" />
</div>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">{trigger.name}</p>
</div>
</div>
<!-- Arrow -->
{#if filters.length > 0}
<div class="flex justify-center">
<Icon icon={mdiChevronRight} size="20" class="text-gray-400 rotate-90" />
</div>
<!-- Filters -->
<div class="flex items-start gap-2">
<div class="shrink-0 mt-0.5">
<div class="h-6 w-6 rounded-md bg-amber-100 dark:bg-amber-950 flex items-center justify-center">
<Icon icon={mdiFilterOutline} size="16" class="text-warning" />
</div>
</div>
<div class="flex-1 min-w-0">
<div class="space-y-1">
{#each filters as filter, index (filter.id)}
<p class="text-xs text-gray-700 dark:text-gray-300 truncate">
{index + 1}. {filter.title}
</p>
{/each}
</div>
</div>
</div>
{/if}
<!-- Arrow -->
{#if actions.length > 0}
<div class="flex justify-center">
<Icon icon={mdiChevronRight} size="20" class="text-gray-400 rotate-90" />
</div>
<!-- Actions -->
<div class="flex items-start gap-2">
<div class="shrink-0 mt-0.5">
<div class="h-6 w-6 rounded-md bg-teal-100 dark:bg-teal-950 flex items-center justify-center">
<Icon icon={mdiPlayCircleOutline} size="16" class="text-success" />
</div>
</div>
<div class="flex-1 min-w-0">
<div class="space-y-1">
{#each actions as action, index (action.id)}
<p class="text-xs text-gray-700 dark:text-gray-300 truncate">
{index + 1}. {action.title}
</p>
{/each}
</div>
</div>
</div>
{/if}
</div>
</div>
</div>