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

@@ -1 +1 @@
24.11.0
24.11.1

View File

@@ -28,7 +28,7 @@
"@formatjs/icu-messageformat-parser": "^2.9.8",
"@immich/justified-layout-wasm": "^0.4.3",
"@immich/sdk": "file:../open-api/typescript-sdk",
"@immich/ui": "^0.46.0",
"@immich/ui": "^0.47.0",
"@mapbox/mapbox-gl-rtl-text": "0.2.3",
"@mdi/js": "^7.4.47",
"@photo-sphere-viewer/core": "^5.14.0",
@@ -108,6 +108,6 @@
"vitest": "^3.0.0"
},
"volta": {
"node": "24.11.0"
"node": "24.11.1"
}
}

View File

@@ -76,14 +76,6 @@
--immich-dark-gray: 33 33 33;
}
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: rgb(var(--immich-ui-default-border));
}
button:not(:disabled),
[role='button']:not(:disabled) {
cursor: pointer;

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>

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import { ConfirmModal } from '@immich/ui';
import { t } from 'svelte-i18n';
type Props = {
onClose: (confirmed: boolean) => void;
};
let { onClose }: Props = $props();
</script>
<ConfirmModal
confirmColor="danger"
prompt={$t('workflow_delete_prompt')}
onClose={(confirmed) => (confirmed ? onClose(true) : onClose(false))}
/>

View File

@@ -19,21 +19,23 @@ export type OcrBoundingBox = {
class OcrManager {
#data = $state<OcrBoundingBox[]>([]);
showOverlay = $state(false);
hasOcrData = $state(false);
#hasOcrData = $derived(this.#data.length > 0);
get data() {
return this.#data;
}
get hasOcrData() {
return this.#hasOcrData;
}
async getAssetOcr(id: string) {
this.#data = await getAssetOcr({ id });
this.hasOcrData = this.#data.length > 0;
}
clear() {
this.#data = [];
this.showOverlay = false;
this.hasOcrData = false;
}
toggleOcrBoundingBox() {

View File

@@ -1,8 +1,6 @@
<script lang="ts">
import { run } from 'svelte/legacy';
import { page } from '$app/state';
import UploadCover from '$lib/components/shared-components/drag-and-drop-upload-overlay.svelte';
import { page } from '$app/stores';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import type { Snippet } from 'svelte';
@@ -15,13 +13,13 @@
// $page.data.asset is loaded by route specific +page.ts loaders if that
// route contains the assetId path.
run(() => {
if ($page.data.asset) {
setAsset($page.data.asset);
$effect.pre(() => {
if (page.data.asset) {
setAsset(page.data.asset);
} else {
$showAssetViewer = false;
}
const asset = $page.url.searchParams.get('at');
const asset = page.url.searchParams.get('at');
$gridScrollTarget = { at: asset };
});
</script>

View File

@@ -44,7 +44,7 @@
} from '@immich/sdk';
import { Icon, IconButton, LoadingSpinner } from '@immich/ui';
import { mdiArrowLeft, mdiDotsVertical, mdiImageOffOutline, mdiPlus, mdiSelectAll } from '@mdi/js';
import { tick } from 'svelte';
import { tick, untrack } from 'svelte';
import { t } from 'svelte-i18n';
let { isViewing: showAssetViewer } = assetViewingStore;
@@ -71,11 +71,10 @@
let terms = $derived(searchQuery ? JSON.parse(searchQuery) : {});
$effect(() => {
// we want this to *only* be reactive on `terms`
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
terms;
setTimeout(() => {
handlePromiseError(onSearchQueryUpdate());
});
untrack(() => handlePromiseError(onSearchQueryUpdate()));
});
const onEscape = () => {

View File

@@ -196,7 +196,7 @@
withStacked
onThumbnailClick={handleThumbnailClick}
>
{#snippet customLayout(asset: TimelineAsset)}
{#snippet customThumbnailLayout(asset: TimelineAsset)}
{#if hasGps(asset)}
<div class="absolute bottom-1 end-3 px-4 py-1 rounded-xl text-xs transition-colors bg-success text-black">
{asset.city || $t('gps')}

View File

@@ -2,6 +2,7 @@
import { goto } from '$app/navigation';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import { AppRoute } from '$lib/constants';
import WorkflowDeleteConfirmModal from '$lib/modals/WorkflowDeleteConfirmModal.svelte';
import type { WorkflowPayload } from '$lib/services/workflow.service';
import { handleError } from '$lib/utils/handle-error';
import {
@@ -9,9 +10,7 @@
deleteWorkflow,
PluginTriggerType,
updateWorkflow,
type PluginActionResponseDto,
type PluginFilterResponseDto,
type PluginResponseDto,
type WorkflowResponseDto,
} from '@immich/sdk';
import {
@@ -27,10 +26,12 @@
IconButton,
MenuItemType,
menuManager,
modalManager,
Text,
toastManager,
VStack,
} from '@immich/ui';
import { mdiCodeJson, mdiDelete, mdiDotsVertical, mdiPause, mdiPencil, mdiPlay, mdiPlus } from '@mdi/js';
import { mdiClose, mdiCodeJson, mdiDelete, mdiDotsVertical, mdiPause, mdiPencil, mdiPlay, mdiPlus } from '@mdi/js';
import { t } from 'svelte-i18n';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import type { PageData } from './$types';
@@ -44,20 +45,20 @@
let workflows = $state<WorkflowResponseDto[]>(data.workflows);
const expandedWorkflows = new SvelteSet<string>();
const pluginFilterLookup = new SvelteMap<string, PluginFilterResponseDto & { pluginTitle: string }>();
const pluginActionLookup = new SvelteMap<string, PluginActionResponseDto & { pluginTitle: string }>();
const pluginFilterLookup = new SvelteMap<string, PluginFilterResponseDto>();
const pluginActionLookup = new SvelteMap<string, PluginFilterResponseDto>();
for (const plugin of data.plugins as PluginResponseDto[]) {
for (const plugin of data.plugins) {
for (const filter of plugin.filters ?? []) {
pluginFilterLookup.set(filter.id, { ...filter, pluginTitle: plugin.title });
pluginFilterLookup.set(filter.id, { ...filter });
}
for (const action of plugin.actions ?? []) {
pluginActionLookup.set(action.id, { ...action, pluginTitle: plugin.title });
pluginActionLookup.set(action.id, { ...action });
}
}
const toggleExpanded = (id: string) => {
const toggleShowingSchema = (id: string) => {
if (expandedWorkflows.has(id)) {
expandedWorkflows.delete(id);
} else {
@@ -65,7 +66,7 @@
}
};
const buildShareableWorkflow = (workflow: WorkflowResponseDto): WorkflowPayload => {
const constructPayload = (workflow: WorkflowResponseDto): WorkflowPayload => {
const orderedFilters = [...(workflow.filters ?? [])].sort((a, b) => a.order - b.order);
const orderedActions = [...(workflow.actions ?? [])].sort((a, b) => a.order - b.order);
@@ -74,24 +75,24 @@
description: workflow.description ?? '',
enabled: workflow.enabled,
triggerType: workflow.triggerType,
filters: orderedFilters.map((wfFilter) => {
const meta = pluginFilterLookup.get(wfFilter.filterId);
const key = meta?.methodName ?? wfFilter.filterId;
filters: orderedFilters.map((filter) => {
const meta = pluginFilterLookup.get(filter.filterId);
const key = meta?.methodName ?? filter.filterId;
return {
[key]: wfFilter.filterConfig ?? {},
[key]: filter.filterConfig ?? {},
};
}),
actions: orderedActions.map((wfAction) => {
const meta = pluginActionLookup.get(wfAction.actionId);
const key = meta?.methodName ?? wfAction.actionId;
actions: orderedActions.map((action) => {
const meta = pluginActionLookup.get(action.actionId);
const key = meta?.methodName ?? action.actionId;
return {
[key]: wfAction.actionConfig ?? {},
[key]: action.actionConfig ?? {},
};
}),
};
};
const getWorkflowJson = (workflow: WorkflowResponseDto) => JSON.stringify(buildShareableWorkflow(workflow), null, 2);
const getJson = (workflow: WorkflowResponseDto) => JSON.stringify(constructPayload(workflow), null, 2);
const handleToggleEnabled = async (workflow: WorkflowResponseDto) => {
try {
@@ -102,18 +103,22 @@
workflows = workflows.map((w) => (w.id === updated.id ? updated : w));
toastManager.success($t('workflow_updated'));
} catch (error) {
// @ts-expect-error - translation type issue
handleError(error, $t('errors.unable_to_update') as string);
handleError(error, $t('errors.unable_to_update_workflow'));
}
};
const handleDeleteWorkflow = async (workflow: WorkflowResponseDto) => {
try {
const confirmed = await modalManager.show(WorkflowDeleteConfirmModal);
if (!confirmed) {
return;
}
await deleteWorkflow({ id: workflow.id });
workflows = workflows.filter((w) => w.id !== workflow.id);
toastManager.success($t('workflow_deleted'));
} catch (error) {
handleError(error, $t('errors.unable_to_delete') as string);
handleError(error, $t('errors.unable_to_delete_workflow'));
}
};
@@ -135,36 +140,14 @@
await goto(`${AppRoute.WORKFLOWS_EDIT}/${workflow.id}?editMode=visual`);
};
type WorkflowChip = {
id: string;
title: string;
subtitle: string;
const getFilterLabel = (filterId: string) => {
const meta = pluginFilterLookup.get(filterId);
return meta?.title ?? $t('filter');
};
const getFilterChips = (workflow: WorkflowResponseDto): WorkflowChip[] => {
return [...(workflow.filters ?? [])]
.sort((a, b) => a.order - b.order)
.map((filter) => {
const meta = pluginFilterLookup.get(filter.filterId);
return {
id: filter.id,
title: meta?.title ?? $t('filter'),
subtitle: meta?.pluginTitle ?? $t('workflow'),
};
});
};
const getActionChips = (workflow: WorkflowResponseDto): WorkflowChip[] => {
return [...(workflow.actions ?? [])]
.sort((a, b) => a.order - b.order)
.map((action) => {
const meta = pluginActionLookup.get(action.actionId);
return {
id: action.id,
title: meta?.title ?? $t('action'),
subtitle: meta?.pluginTitle ?? $t('workflow'),
};
});
const getActionLabel = (actionId: string) => {
const meta = pluginActionLookup.get(actionId);
return meta?.title ?? $t('action');
};
const getTriggerLabel = (triggerType: string) => {
@@ -175,34 +158,19 @@
return labels[triggerType] || triggerType;
};
const dateFormatter = new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
});
const formatTimestamp = (iso?: string) => {
if (!iso) {
return '—';
}
return dateFormatter.format(new Date(iso));
};
type WorkflowWithMeta = {
workflow: WorkflowResponseDto;
filterChips: WorkflowChip[];
actionChips: WorkflowChip[];
workflowJson: string;
};
const getWorkflowsWithMeta = (): WorkflowWithMeta[] =>
workflows.map((workflow) => ({
workflow,
filterChips: getFilterChips(workflow),
actionChips: getActionChips(workflow),
workflowJson: getWorkflowJson(workflow),
}));
const formatTimestamp = (createdAt: string) =>
new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(createdAt));
</script>
{#snippet chipItem(title: string)}
<span class="rounded-xl border border-gray-200/80 px-3 py-1.5 text-sm dark:border-gray-600 bg-light">
<span class="font-medium text-dark">{title}</span>
</span>
{/snippet}
<UserPageLayout title={data.meta.title} scrollbar={false}>
{#snippet buttons()}
<HStack gap={1}>
@@ -229,23 +197,20 @@
</div>
{:else}
<div class="my-6 grid gap-6">
{#each getWorkflowsWithMeta() as { workflow, filterChips, actionChips, workflowJson } (workflow.id)}
{#each workflows as workflow (workflow.id)}
<Card class="border border-gray-200/70 shadow-xl shadow-gray-900/5 dark:border-gray-700/60">
<CardHeader
class={`flex flex-col px-8 py-6 gap-4 sm:flex-row sm:items-center sm:gap-6 ${workflow.enabled ? 'bg-linear-to-r from-green-50 to-white dark:from-green-950/40 dark:to-gray-900' : 'bg-neutral-50 dark:bg-neutral-900'}`}
class={`flex flex-col px-8 py-6 gap-4 sm:flex-row sm:items-center sm:gap-6 ${
workflow.enabled
? 'bg-linear-to-r from-green-50 to-white dark:from-green-800/50 dark:to-green-950/45'
: 'bg-neutral-50 dark:bg-neutral-900'
}`}
>
<div class="flex-1">
<div class="flex items-center gap-3">
<span class={workflow.enabled ? 'relative flex h-3 w-3' : 'flex h-3 w-3'}>
{#if workflow.enabled}
<span class="absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
{/if}
<span
class={workflow.enabled
? 'relative inline-flex h-3 w-3 rounded-full bg-green-500'
: 'relative inline-flex h-3 w-3 rounded-full bg-gray-400 dark:bg-gray-600'}
></span>
</span>
<span
class="rounded-full {workflow.enabled ? 'h-3 w-3 bg-success' : 'h-3 w-3 rounded-full bg-muted'}"
></span>
<CardTitle>{workflow.name}</CardTitle>
</div>
<CardDescription class="mt-1 text-sm">
@@ -273,7 +238,7 @@
items: [
{
title: workflow.enabled ? $t('disable') : $t('enable'),
color: workflow.enabled ? 'warning' : 'success',
color: workflow.enabled ? 'danger' : 'primary',
icon: workflow.enabled ? mdiPause : mdiPlay,
onSelect: () => void handleToggleEnabled(workflow),
},
@@ -284,9 +249,9 @@
},
{
title: expandedWorkflows.has(workflow.id) ? $t('hide_json') : $t('show_json'),
title: expandedWorkflows.has(workflow.id) ? $t('hide_schema') : $t('show_schema'),
icon: mdiCodeJson,
onSelect: () => toggleExpanded(workflow.id),
onSelect: () => toggleShowingSchema(workflow.id),
},
MenuItemType.Divider,
{
@@ -305,87 +270,64 @@
<CardBody class="space-y-6">
<div class="grid gap-4 md:grid-cols-3">
<!-- Trigger Section -->
<div
class="rounded-2xl border border-gray-100/80 bg-gray-50/90 p-4 dark:border-gray-700 dark:bg-gray-800"
>
<dt class="mb-3 text-xs font-semibold uppercase tracking-widest text-gray-500 dark:text-gray-400">
{$t('trigger')}
</dt>
<span
class="inline-block rounded-xl border border-gray-200/80 bg-white/70 px-3 py-1.5 text-sm font-medium shadow-sm dark:border-gray-600 dark:bg-gray-700/80 dark:text-white"
>
{getTriggerLabel(workflow.triggerType)}
</span>
<div class="rounded-2xl border p-4 bg-light-50 border-light-200">
<div class="mb-3">
<Text class="text-xs font-semibold uppercase tracking-widest" color="muted">{$t('trigger')}</Text>
</div>
{@render chipItem(getTriggerLabel(workflow.triggerType))}
</div>
<!-- Filters Section -->
<div
class="rounded-2xl border border-gray-100/80 bg-gray-50/90 p-4 dark:border-gray-700 dark:bg-gray-800"
>
<div class="mb-3 flex items-center justify-between">
<dt class="text-xs font-semibold uppercase tracking-widest text-gray-500 dark:text-gray-400">
{$t('filter')}
</dt>
<dd
class="rounded-full bg-gray-200 px-2.5 py-0.5 text-sm font-semibold text-gray-700 dark:bg-gray-700 dark:text-gray-200"
>
{workflow.filters.length}
</dd>
<div class="rounded-2xl border p-4 bg-light-50 border-light-200">
<div class="mb-3">
<Text class="text-xs font-semibold uppercase tracking-widest" color="muted">{$t('filters')}</Text>
</div>
<div class="flex flex-wrap gap-2">
{#if filterChips.length === 0}
{#if workflow.filters.length === 0}
<span class="text-sm text-gray-500 dark:text-gray-400">
{$t('no_filters_added')}
</span>
{:else}
{#each filterChips as chip (chip.id)}
<span
class="rounded-xl border border-gray-200/80 bg-white/70 px-3 py-1.5 text-sm shadow-sm dark:border-gray-600 dark:bg-gray-700/80"
>
<span class="font-medium text-gray-900 dark:text-white">{chip.title}</span>
</span>
{#each workflow.filters as workflowFilter (workflowFilter.id)}
{@render chipItem(getFilterLabel(workflowFilter.filterId))}
{/each}
{/if}
</div>
</div>
<!-- Actions Section -->
<div
class="rounded-2xl border border-gray-100/80 bg-gray-50/90 p-4 dark:border-gray-700 dark:bg-gray-800"
>
<div class="mb-3 flex items-center justify-between">
<dt class="text-xs font-semibold uppercase tracking-widest text-gray-500 dark:text-gray-400">
{$t('actions')}
</dt>
<dd
class="rounded-full bg-gray-200 px-2.5 py-0.5 text-sm font-semibold text-gray-700 dark:bg-gray-700 dark:text-gray-200"
>
{workflow.actions.length}
</dd>
<div class="rounded-2xl border p-4 bg-light-50 border-light-200">
<div class="mb-3">
<Text class="text-xs font-semibold uppercase tracking-widest" color="muted">{$t('actions')}</Text>
</div>
<div class="flex flex-wrap gap-2">
{#if actionChips.length === 0}
<div>
{#if workflow.actions.length === 0}
<span class="text-sm text-gray-500 dark:text-gray-400">
{$t('no_actions_added')}
</span>
{:else}
{#each actionChips as chip (chip.id)}
<span
class="rounded-xl border border-gray-200/80 bg-white/70 px-3 py-1.5 text-sm shadow-sm dark:border-gray-600 dark:bg-gray-700/80"
>
<span class="font-medium text-gray-900 dark:text-white">{chip.title}</span>
</span>
{/each}
<div class="flex flex-wrap gap-2">
{#each workflow.actions as workflowAction (workflowAction.id)}
{@render chipItem(getActionLabel(workflowAction.actionId))}
{/each}
</div>
{/if}
</div>
</div>
</div>
{#if expandedWorkflows.has(workflow.id)}
<div>
<p class="mb-3 text-sm font-semibold text-gray-700 dark:text-gray-200">Workflow JSON</p>
<CodeBlock code={workflowJson} />
</div>
<VStack gap={2} class="w-full rounded-2xl border bg-light-50 p-4 border-light-200 ">
<CodeBlock code={getJson(workflow)} lineNumbers />
<Button
leadingIcon={mdiClose}
fullWidth
variant="ghost"
color="secondary"
onclick={() => toggleShowingSchema(workflow.id)}>{$t('close')}</Button
>
</VStack>
{/if}
</CardBody>
</Card>

View File

@@ -1,11 +1,12 @@
<script lang="ts">
import { beforeNavigate, goto } from '$app/navigation';
import { dragAndDrop } from '$lib/actions/drag-and-drop';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import SchemaFormFields from '$lib/components/workflow/schema-form/SchemaFormFields.svelte';
import WorkflowCardConnector from '$lib/components/workflows/workflow-card-connector.svelte';
import WorkflowJsonEditor from '$lib/components/workflows/workflow-json-editor.svelte';
import WorkflowTriggerCard from '$lib/components/workflows/workflow-trigger-card.svelte';
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
import SchemaFormFields from '$lib/components/workflows/SchemaFormFields.svelte';
import WorkflowCardConnector from '$lib/components/workflows/WorkflowCardConnector.svelte';
import WorkflowJsonEditor from '$lib/components/workflows/WorkflowJsonEditor.svelte';
import WorkflowSummarySidebar from '$lib/components/workflows/WorkflowSummary.svelte';
import WorkflowTriggerCard from '$lib/components/workflows/WorkflowTriggerCard.svelte';
import AddWorkflowStepModal from '$lib/modals/AddWorkflowStepModal.svelte';
import WorkflowNavigationConfirmModal from '$lib/modals/WorkflowNavigationConfirmModal.svelte';
import WorkflowTriggerUpdateConfirmModal from '$lib/modals/WorkflowTriggerUpdateConfirmModal.svelte';
@@ -31,6 +32,7 @@
modalManager,
} from '@immich/ui';
import {
mdiArrowLeft,
mdiCodeJson,
mdiContentSave,
mdiFilterOutline,
@@ -281,9 +283,9 @@
};
const handleTriggerChange = async (newTrigger: PluginTriggerResponseDto) => {
const isConfirmed = await modalManager.show(WorkflowTriggerUpdateConfirmModal);
const confirmed = await modalManager.show(WorkflowTriggerUpdateConfirmModal);
if (!isConfirmed) {
if (!confirmed) {
return;
}
@@ -342,45 +344,12 @@
</button>
{/snippet}
<UserPageLayout title={data.meta.title} scrollbar={false}>
<!-- <WorkflowSummarySidebar trigger={selectedTrigger} filters={orderedFilters} actions={orderedActions} /> -->
<svelte:head>
<title>{data.meta.title} - Immich</title>
</svelte:head>
{#snippet buttons()}
<HStack gap={4} class="me-4">
<HStack gap={1} class="border rounded-lg p-1 dark:border-gray-600">
<Button
size="small"
variant={viewMode === 'visual' ? 'outline' : 'ghost'}
color={viewMode === 'visual' ? 'primary' : 'secondary'}
leadingIcon={mdiViewDashboard}
onclick={() => (viewMode = 'visual')}
>
Visual
</Button>
<Button
size="small"
variant={viewMode === 'json' ? 'outline' : 'ghost'}
color={viewMode === 'json' ? 'primary' : 'secondary'}
leadingIcon={mdiCodeJson}
onclick={() => {
viewMode = 'json';
jsonEditorContent = jsonContent;
}}
>
JSON
</Button>
</HStack>
<HStack gap={2}>
<Text class="text-sm">{editWorkflow.enabled ? 'ON' : 'OFF'}</Text>
<Switch bind:checked={editWorkflow.enabled} />
</HStack>
<Button leadingIcon={mdiContentSave} size="small" color="primary" onclick={updateWorkflow} disabled={!hasChanges}>
{$t('save')}
</Button>
</HStack>
{/snippet}
<main class="pt-24 immich-scrollbar">
<WorkflowSummarySidebar trigger={selectedTrigger} filters={orderedFilters} actions={orderedActions} />
<Container size="medium" class="p-4" center>
{#if viewMode === 'json'}
@@ -391,7 +360,7 @@
/>
{:else}
<VStack gap={0}>
<Card expandable expanded={false}>
<Card expandable>
<CardHeader>
<div class="flex place-items-start gap-3">
<Icon icon={mdiInformationOutline} size="20" class="mt-1" />
@@ -416,8 +385,8 @@
<div class="my-10 h-px w-[98%] bg-gray-200 dark:bg-gray-700"></div>
<Card expandable expanded={true}>
<CardHeader class="bg-indigo-50 dark:bg-primary-800">
<Card expandable>
<CardHeader class="bg-primary-50">
<div class="flex items-start gap-3">
<Icon icon={mdiFlashOutline} size="20" class="mt-1 text-primary" />
<div class="flex flex-col">
@@ -442,8 +411,8 @@
<WorkflowCardConnector />
<Card expandable expanded={true}>
<CardHeader class="bg-amber-50 dark:bg-[#5e4100]">
<Card expandable>
<CardHeader class="bg-warning-50">
<div class="flex items-start gap-3">
<Icon icon={mdiFilterOutline} size="20" class="mt-1 text-warning" />
<div class="flex flex-col">
@@ -473,7 +442,7 @@
isDragging: draggedFilterIndex === index,
isDragOver: dragOverFilterIndex === index,
}}
class="mb-4 cursor-move rounded-lg border-2 p-4 transition-all bg-gray-50 dark:bg-subtle border-dashed border-transparent hover:border-gray-300 dark:hover:border-gray-600"
class="mb-4 cursor-move rounded-lg border-2 p-4 transition-all bg-neutral-50 dark:bg-neutral-900/50 border-dashed border-transparent hover:border-gray-300 dark:hover:border-gray-600"
>
<div class="flex items-start gap-4">
{@render cardOrder(index)}
@@ -514,7 +483,7 @@
<WorkflowCardConnector />
<Card expandable expanded>
<CardHeader class="bg-success/10 dark:bg-teal-950">
<CardHeader class="bg-success-50">
<div class="flex items-start gap-3">
<Icon icon={mdiPlayCircleOutline} size="20" class="mt-1 text-success" />
<div class="flex flex-col">
@@ -544,7 +513,7 @@
isDragging: draggedActionIndex === index,
isDragOver: dragOverActionIndex === index,
}}
class="mb-4 cursor-move rounded-lg border-2 p-4 transition-all bg-gray-50 dark:bg-subtle border-dashed border-transparent hover:border-gray-300 dark:hover:border-gray-600"
class="mb-4 cursor-move rounded-lg border-2 p-4 transition-all bg-neutral-50 dark:bg-neutral-900/50 border-dashed border-transparent hover:border-gray-300 dark:hover:border-gray-600"
>
<div class="flex items-start gap-4">
{@render cardOrder(index)}
@@ -583,4 +552,51 @@
</VStack>
{/if}
</Container>
</UserPageLayout>
</main>
<ControlAppBar
onClose={() => goto('/utilities/workflows')}
backIcon={mdiArrowLeft}
tailwindClasses="fixed! top-0! w-full"
>
{#snippet leading()}
<p>{data.meta.title}</p>
{/snippet}
{#snippet trailing()}
<HStack gap={4}>
<HStack gap={1} class="border rounded-lg p-1 dark:border-gray-600">
<Button
size="small"
variant={viewMode === 'visual' ? 'outline' : 'ghost'}
color={viewMode === 'visual' ? 'primary' : 'secondary'}
leadingIcon={mdiViewDashboard}
onclick={() => (viewMode = 'visual')}
>
Visual
</Button>
<Button
size="small"
variant={viewMode === 'json' ? 'outline' : 'ghost'}
color={viewMode === 'json' ? 'primary' : 'secondary'}
leadingIcon={mdiCodeJson}
onclick={() => {
viewMode = 'json';
jsonEditorContent = jsonContent;
}}
>
JSON
</Button>
</HStack>
<HStack gap={2}>
<Text class="text-sm">{editWorkflow.enabled ? 'ON' : 'OFF'}</Text>
<Switch bind:checked={editWorkflow.enabled} />
</HStack>
<Button leadingIcon={mdiContentSave} size="small" color="primary" onclick={updateWorkflow} disabled={!hasChanges}>
{$t('save')}
</Button>
</HStack>
{/snippet}
</ControlAppBar>

View File

@@ -22,7 +22,6 @@
import { modalManager, setTranslations } from '@immich/ui';
import { onMount, type Snippet } from 'svelte';
import { t } from 'svelte-i18n';
import { run } from 'svelte/legacy';
import '../app.css';
interface Props {
@@ -69,7 +68,8 @@
afterNavigate(() => {
showNavigationLoadingBar = false;
});
run(() => {
$effect.pre(() => {
if ($user || page.url.pathname.startsWith(AppRoute.MAINTENANCE)) {
openWebsocketConnection();
} else {

View File

@@ -5,7 +5,13 @@
import JobCreateModal from '$lib/modals/JobCreateModal.svelte';
import { asyncTimeout } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { getQueuesLegacy, QueueCommand, QueueName, runQueueCommandLegacy, type QueuesResponseDto } from '@immich/sdk';
import {
getQueuesLegacy,
QueueCommand,
QueueName,
runQueueCommandLegacy,
type QueuesResponseLegacyDto,
} from '@immich/sdk';
import { Button, HStack, modalManager, Text } from '@immich/ui';
import { mdiCog, mdiPlay, mdiPlus } from '@mdi/js';
import { onDestroy, onMount } from 'svelte';
@@ -18,7 +24,7 @@
let { data }: Props = $props();
let jobs: QueuesResponseDto | undefined = $state();
let jobs: QueuesResponseLegacyDto | undefined = $state();
let running = true;

View File

@@ -1,35 +1,33 @@
<script lang="ts">
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
import ServerStatisticsPanel from '$lib/components/server-statistics/ServerStatisticsPanel.svelte';
import { asyncTimeout } from '$lib/utils';
import { getServerStatistics } from '@immich/sdk';
import { onDestroy, onMount } from 'svelte';
import { onMount } from 'svelte';
import type { PageData } from './$types';
interface Props {
type Props = {
data: PageData;
}
};
let { data = $bindable() }: Props = $props();
const { data }: Props = $props();
let running = true;
let stats = $state(data.stats);
onMount(async () => {
while (running) {
data.stats = await getServerStatistics();
await asyncTimeout(5000);
}
});
const updateStatistics = async () => {
stats = await getServerStatistics();
};
onDestroy(() => {
running = false;
onMount(() => {
const interval = setInterval(() => void updateStatistics(), 5000);
return () => clearInterval(interval);
});
</script>
<AdminPageLayout title={data.meta.title}>
<section id="setting-content" class="flex place-content-center sm:mx-4">
<section class="w-full pb-28 sm:w-5/6 md:w-212.5">
<ServerStatisticsPanel stats={data.stats} />
<ServerStatisticsPanel {stats} />
</section>
</section>
</AdminPageLayout>

View File

@@ -6,7 +6,7 @@
import { getUserAdminActions, getUserAdminsActions } from '$lib/services/user-admin.service';
import { locale } from '$lib/stores/preferences.store';
import { getByteUnitString } from '$lib/utils/byte-units';
import { searchUsersAdmin, type UserAdminResponseDto } from '@immich/sdk';
import { type UserAdminResponseDto } from '@immich/sdk';
import { HStack, Icon } from '@immich/ui';
import { mdiInfinity } from '@mdi/js';
import { t } from 'svelte-i18n';
@@ -18,24 +18,20 @@
let { data }: Props = $props();
let allUsers: UserAdminResponseDto[] = $derived(data.allUsers);
let allUsers: UserAdminResponseDto[] = $state(data.allUsers);
const refresh = async () => {
allUsers = await searchUsersAdmin({ withDeleted: true });
};
const onUserAdminDeleted = ({ id: userId }: { id: string }) => {
const user = allUsers.find(({ id }) => id === userId);
if (user) {
allUsers = allUsers.filter((user) => user.id !== userId);
const onUpdate = (user: UserAdminResponseDto) => {
const index = allUsers.findIndex(({ id }) => id === user.id);
if (index !== -1) {
allUsers[index] = user;
}
};
const UserAdminsActions = $derived(getUserAdminsActions($t));
const onUpdate = async () => {
await refresh();
const onUserAdminDeleted = ({ id: userId }: { id: string }) => {
allUsers = allUsers.filter(({ id }) => id !== userId);
};
const { Create } = $derived(getUserAdminsActions($t));
</script>
<OnEvents
@@ -49,7 +45,7 @@
<AdminPageLayout title={data.meta.title}>
{#snippet buttons()}
<HStack gap={1}>
<HeaderButton action={UserAdminsActions.Create} />
<HeaderButton action={Create} />
</HStack>
{/snippet}
<section id="setting-content" class="flex place-content-center sm:mx-4">
@@ -69,7 +65,7 @@
</thead>
<tbody class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray">
{#each allUsers as user (user.id)}
{@const UserAdminActions = getUserAdminActions($t, user)}
{@const { View, ContextMenu } = getUserAdminActions($t, user)}
<tr
class="flex h-20 overflow-hidden w-full place-items-center text-center dark:text-immich-dark-fg {user.deletedAt
? 'bg-red-300 dark:bg-red-900'
@@ -91,8 +87,8 @@
<td
class="flex flex-row flex-wrap justify-center gap-x-2 gap-y-1 w-4/12 lg:w-3/12 xl:w-2/12 text-ellipsis break-all text-sm"
>
<TableButton action={UserAdminActions.View} />
<TableButton action={UserAdminActions.ContextMenu} />
<TableButton action={View} />
<TableButton action={ContextMenu} />
</td>
</tr>
{/each}

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { page } from '$app/state';
import OnboardingBackup from '$lib/components/onboarding-page/onboarding-backup.svelte';
import OnboardingCard from '$lib/components/onboarding-page/onboarding-card.svelte';
import OnboardingHello from '$lib/components/onboarding-page/onboarding-hello.svelte';
@@ -95,7 +95,11 @@
},
]);
let index = $state(0);
const index = $derived.by(() => {
const stepState = page.url.searchParams.get('step');
const temporaryIndex = onboardingSteps.findIndex((step) => step.name === stepState);
return temporaryIndex === -1 ? 0 : temporaryIndex;
});
let userRole = $derived(
$user.isAdmin && !serverConfigManager.value.isOnboarded ? OnboardingRole.SERVER : OnboardingRole.USER,
);
@@ -114,12 +118,6 @@
);
};
$effect(() => {
const stepState = $page.url.searchParams.get('step');
const temporaryIndex = onboardingSteps.findIndex((step) => step.name === stepState);
index = temporaryIndex === -1 ? 0 : temporaryIndex;
});
const previousStepIndex = $derived(
onboardingSteps.findLastIndex((step, i) => shouldRunStep(step.role, userRole) && i < index),
);