mirror of
https://github.com/immich-app/immich.git
synced 2025-12-23 01:11:36 +03:00
merge main
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -171,7 +171,6 @@
|
||||
|
||||
$effect(() => {
|
||||
if (assetFileUrl) {
|
||||
// this can't be in an async context with $effect
|
||||
void cast(assetFileUrl);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -35,7 +35,6 @@
|
||||
|
||||
$effect(() => {
|
||||
if (assetFileUrl) {
|
||||
// this can't be in an async context with $effect
|
||||
void cast(assetFileUrl);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = [];
|
||||
}
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
|
||||
66
web/src/lib/components/timeline/AssetLayout.svelte
Normal file
66
web/src/lib/components/timeline/AssetLayout.svelte
Normal 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>
|
||||
115
web/src/lib/components/timeline/Month.svelte
Normal file
115
web/src/lib/components/timeline/Month.svelte
Normal 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>
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
178
web/src/lib/components/workflows/WorkflowSummary.svelte
Normal file
178
web/src/lib/components/workflows/WorkflowSummary.svelte
Normal 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}
|
||||
@@ -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>
|
||||
16
web/src/lib/modals/WorkflowDeleteConfirmModal.svelte
Normal file
16
web/src/lib/modals/WorkflowDeleteConfirmModal.svelte
Normal 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))}
|
||||
/>
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user