mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
merge: remote-tracking branch 'immich/main' into feat/integrity-checks-izzy
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
import HeaderActionButton from '$lib/components/HeaderActionButton.svelte';
|
||||
import { Card, CardBody, CardHeader, CardTitle, Icon, type ActionItem, type IconLike } from '@immich/ui';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
type Props = {
|
||||
icon: IconLike;
|
||||
title: string;
|
||||
headerAction?: ActionItem;
|
||||
children?: Snippet;
|
||||
};
|
||||
|
||||
const { icon, title, headerAction, children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<Card color="secondary">
|
||||
<CardHeader>
|
||||
<div class="flex w-full justify-between items-center px-4 py-2">
|
||||
<div class="flex gap-2 text-primary">
|
||||
<Icon {icon} size="1.5rem" />
|
||||
<CardTitle>{title}</CardTitle>
|
||||
</div>
|
||||
{#if headerAction}
|
||||
<HeaderActionButton action={headerAction} />
|
||||
{/if}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div class="px-4 pb-7">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import BottomInfo from '$lib/components/shared-components/side-bar/bottom-info.svelte';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { NavbarItem } from '@immich/ui';
|
||||
import { mdiAccountMultipleOutline, mdiBookshelf, mdiCog, mdiServer, mdiTrayFull, mdiWrench } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
</script>
|
||||
|
||||
<div class="h-full flex flex-col justify-between gap-2">
|
||||
<div class="flex flex-col pt-8 pe-4 gap-1">
|
||||
<NavbarItem title={$t('users')} href={AppRoute.ADMIN_USERS} icon={mdiAccountMultipleOutline} />
|
||||
<NavbarItem title={$t('external_libraries')} href={AppRoute.ADMIN_LIBRARIES} icon={mdiBookshelf} />
|
||||
<NavbarItem title={$t('admin.queues')} href={AppRoute.ADMIN_QUEUES} icon={mdiTrayFull} />
|
||||
<NavbarItem title={$t('settings')} href={AppRoute.ADMIN_SETTINGS} icon={mdiCog} />
|
||||
<NavbarItem title={$t('admin.maintenance_settings')} href={AppRoute.ADMIN_MAINTENANCE_SETTINGS} icon={mdiWrench} />
|
||||
<NavbarItem title={$t('server_stats')} href={AppRoute.ADMIN_STATS} icon={mdiServer} />
|
||||
</div>
|
||||
|
||||
<div class="mb-2 me-4">
|
||||
<BottomInfo />
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { minBy } from 'lodash-es';
|
||||
import { minBy, uniqBy } from 'lodash-es';
|
||||
import { DateTime, Duration } from 'luxon';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
@@ -68,9 +68,8 @@
|
||||
<SettingSelect
|
||||
bind:value={expirationOption}
|
||||
{onSelect}
|
||||
options={[...new Set([...expiredDateOptions, getExpirationOption(createdAt, expiresAt)])]}
|
||||
options={uniqBy([...expiredDateOptions, getExpirationOption(createdAt, expiresAt)], 'value')}
|
||||
label={$t('expire_after')}
|
||||
disabled={expiresAt !== null && DateTime.fromISO(expiresAt) < DateTime.now()}
|
||||
number={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { IconButton, type ActionItem } from '@immich/ui';
|
||||
import { IconButton, type ActionItem, type Size } from '@immich/ui';
|
||||
|
||||
type Props = {
|
||||
action: ActionItem;
|
||||
size?: Size;
|
||||
};
|
||||
|
||||
const { action }: Props = $props();
|
||||
const { action, size }: Props = $props();
|
||||
const { title, icon, onAction } = $derived(action);
|
||||
</script>
|
||||
|
||||
{#if action.$if?.() ?? true}
|
||||
<IconButton shape="round" color="primary" {icon} aria-label={title} onclick={() => onAction(action)} />
|
||||
<IconButton {size} shape="round" color="primary" {icon} aria-label={title} onclick={() => onAction(action)} />
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<script lang="ts" generics="T extends Record<string, unknown>">
|
||||
import { TooltipProvider } from '@immich/ui';
|
||||
import type { Component } from 'svelte';
|
||||
|
||||
type Props = {
|
||||
component: Component<T>;
|
||||
componentProps: T;
|
||||
};
|
||||
|
||||
const { component: Test, componentProps }: Props = $props();
|
||||
</script>
|
||||
|
||||
<TooltipProvider>
|
||||
<Test {...componentProps} />
|
||||
</TooltipProvider>
|
||||
@@ -60,6 +60,9 @@
|
||||
assetId: 'a8312960-e277-447d-b4ea-56717ccba856',
|
||||
assetIdShort: '56717ccba856',
|
||||
album: $t('album_name'),
|
||||
make: 'FUJIFILM',
|
||||
model: 'X-T50',
|
||||
lensModel: 'XF27mm F2.8 R WR',
|
||||
};
|
||||
|
||||
const dt = luxon.DateTime.fromISO(new Date('2022-02-03T04:56:05.250').toISOString());
|
||||
|
||||
@@ -24,10 +24,8 @@
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<p class="uppercase font-medium text-primary">{$t('other')}</p>
|
||||
<p class="uppercase font-medium text-primary">{$t('album')}</p>
|
||||
<ul>
|
||||
<li>{`{{assetId}}`} - Asset ID</li>
|
||||
<li>{`{{assetIdShort}}`} - Asset ID (last 12 characters)</li>
|
||||
<li>{`{{album}}`} - Album Name</li>
|
||||
<li>
|
||||
{`{{album-startDate-x}}`} - Album Start Date and Time (e.g. album-startDate-yy).
|
||||
@@ -39,5 +37,20 @@
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<p class="uppercase font-medium text-primary">{$t('camera')}</p>
|
||||
<ul>
|
||||
<li>{`{{make}}`} - FUJIFILM</li>
|
||||
<li>{`{{model}}`} - X-T50</li>
|
||||
<li>{`{{lensModel}}`} - XF27mm F2.8 R WR</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<p class="uppercase font-medium text-primary">{$t('other')}</p>
|
||||
<ul>
|
||||
<li>{`{{assetId}}`} - Asset ID</li>
|
||||
<li>{`{{assetIdShort}}`} - Asset ID (last 12 characters)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { sdkMock } from '$lib/__mocks__/sdk.mock';
|
||||
import { renderWithTooltips } from '$tests/helpers';
|
||||
import { albumFactory } from '@test-data/factories/album-factory';
|
||||
import '@testing-library/jest-dom';
|
||||
import { render, waitFor, type RenderResult } from '@testing-library/svelte';
|
||||
@@ -88,7 +89,7 @@ describe('AlbumCard component', () => {
|
||||
const album = Object.freeze(albumFactory.build({ albumThumbnailAssetId: null }));
|
||||
|
||||
beforeEach(async () => {
|
||||
sut = render(AlbumCard, { album, onShowContextMenu });
|
||||
sut = renderWithTooltips(AlbumCard, { album, onShowContextMenu });
|
||||
|
||||
const albumImgElement = sut.getByTestId('album-image');
|
||||
await waitFor(() => expect(albumImgElement).toHaveAttribute('src'));
|
||||
|
||||
@@ -65,7 +65,6 @@
|
||||
<div class="grid grid-auto-fill-56 gap-y-4" transition:slide={{ duration: 300 }}>
|
||||
{#each albums as album, index (album.id)}
|
||||
<a
|
||||
data-sveltekit-preload-data="hover"
|
||||
href={resolve(`${AppRoute.ALBUMS}/${album.id}`)}
|
||||
animate:flip={{ duration: 400 }}
|
||||
oncontextmenu={(event) => oncontextmenu(event, album)}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { updateAlbumInfo } from '@immich/sdk';
|
||||
import { shortcut } from '$lib/actions/shortcut';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import AutogrowTextarea from '$lib/components/shared-components/autogrow-textarea.svelte';
|
||||
import { updateAlbumInfo } from '@immich/sdk';
|
||||
import { Textarea } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fromAction } from 'svelte/attachments';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
@@ -12,27 +14,34 @@
|
||||
|
||||
let { id, description = $bindable(), isOwned }: Props = $props();
|
||||
|
||||
const handleUpdateDescription = async (newDescription: string) => {
|
||||
const handleFocusOut = async () => {
|
||||
try {
|
||||
await updateAlbumInfo({
|
||||
id,
|
||||
updateAlbumDto: {
|
||||
description: newDescription,
|
||||
description,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_save_album'));
|
||||
}
|
||||
description = newDescription;
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if isOwned}
|
||||
<AutogrowTextarea
|
||||
content={description}
|
||||
class="w-full mt-2 text-black dark:text-white border-b-2 border-transparent border-gray-500 bg-transparent text-base outline-none transition-all focus:border-b-2 focus:border-immich-primary disabled:border-none dark:focus:border-immich-dark-primary hover:border-gray-400"
|
||||
onContentUpdate={handleUpdateDescription}
|
||||
<Textarea
|
||||
bind:value={description}
|
||||
class="outline-none border-b border-gray-500 bg-transparent ring-0 focus:ring-0 resize-none focus:border-b-2 focus:border-immich-primary dark:focus:border-immich-dark-primary dark:bg-transparent"
|
||||
rows={1}
|
||||
grow
|
||||
shape="rectangle"
|
||||
onfocusout={handleFocusOut}
|
||||
placeholder={$t('add_a_description')}
|
||||
data-testid="autogrow-textarea"
|
||||
{@attach fromAction(shortcut, () => ({
|
||||
shortcut: { key: 'Enter', ctrl: true },
|
||||
onShortcut: (e) => e.currentTarget.blur(),
|
||||
}))}
|
||||
/>
|
||||
{:else if description}
|
||||
<p class="break-words whitespace-pre-line w-full text-black dark:text-white text-base">
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import AlbumCardGroup from '$lib/components/album-page/album-card-group.svelte';
|
||||
import AlbumsTable from '$lib/components/album-page/albums-table.svelte';
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||
import RightClickContextMenu from '$lib/components/shared-components/context-menu/right-click-context-menu.svelte';
|
||||
import ToastAction from '$lib/components/ToastAction.svelte';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import AlbumEditModal from '$lib/modals/AlbumEditModal.svelte';
|
||||
import AlbumShareModal from '$lib/modals/AlbumShareModal.svelte';
|
||||
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
|
||||
@@ -17,8 +13,8 @@
|
||||
AlbumGroupBy,
|
||||
AlbumSortBy,
|
||||
AlbumViewMode,
|
||||
SortOrder,
|
||||
locale,
|
||||
SortOrder,
|
||||
type AlbumViewSettings,
|
||||
} from '$lib/stores/preferences.store';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
@@ -27,8 +23,13 @@
|
||||
import type { ContextMenuPosition } from '$lib/utils/context-menu';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { normalizeSearchString } from '$lib/utils/string-utils';
|
||||
import { addUsersToAlbum, type AlbumResponseDto, type AlbumUserAddDto } from '@immich/sdk';
|
||||
import { modalManager, toastManager } from '@immich/ui';
|
||||
import {
|
||||
addUsersToAlbum,
|
||||
type AlbumResponseDto,
|
||||
type AlbumUserAddDto,
|
||||
type SharedLinkResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { modalManager } from '@immich/ui';
|
||||
import { mdiDeleteOutline, mdiDownload, mdiRenameOutline, mdiShareVariantOutline } from '@mdi/js';
|
||||
import { groupBy } from 'lodash-es';
|
||||
import { onMount, type Snippet } from 'svelte';
|
||||
@@ -199,10 +200,7 @@
|
||||
|
||||
switch (action) {
|
||||
case 'edit': {
|
||||
const editedAlbum = await modalManager.show(AlbumEditModal, { album: selectedAlbum });
|
||||
if (editedAlbum) {
|
||||
successEditAlbumInfo(editedAlbum);
|
||||
}
|
||||
await modalManager.show(AlbumEditModal, { album: selectedAlbum });
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -215,12 +213,7 @@
|
||||
}
|
||||
|
||||
case 'sharedLink': {
|
||||
const success = await modalManager.show(SharedLinkCreateModal, { albumId: selectedAlbum.id });
|
||||
if (success) {
|
||||
selectedAlbum.shared = true;
|
||||
selectedAlbum.hasSharedLink = true;
|
||||
updateAlbumInfo(selectedAlbum);
|
||||
}
|
||||
await modalManager.show(SharedLinkCreateModal, { albumId: selectedAlbum.id });
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -244,39 +237,18 @@
|
||||
await Promise.allSettled(albumsToRemove.map((album) => handleDeleteAlbum(album, { prompt: false, notify: false })));
|
||||
};
|
||||
|
||||
const updateAlbumInfo = (album: AlbumResponseDto) => {
|
||||
ownedAlbums[ownedAlbums.findIndex(({ id }) => id === album.id)] = album;
|
||||
sharedAlbums[sharedAlbums.findIndex(({ id }) => id === album.id)] = album;
|
||||
};
|
||||
|
||||
const updateRecentAlbumInfo = (album: AlbumResponseDto) => {
|
||||
for (const cachedAlbum of userInteraction.recentAlbums || []) {
|
||||
if (cachedAlbum.id === album.id) {
|
||||
Object.assign(cachedAlbum, { ...cachedAlbum, ...album });
|
||||
break;
|
||||
}
|
||||
const findAndUpdate = (albums: AlbumResponseDto[], album: AlbumResponseDto) => {
|
||||
const target = albums.find(({ id }) => id === album.id);
|
||||
if (target) {
|
||||
Object.assign(target, album);
|
||||
}
|
||||
|
||||
return albums;
|
||||
};
|
||||
|
||||
const successEditAlbumInfo = (album: AlbumResponseDto) => {
|
||||
toastManager.custom({
|
||||
component: ToastAction,
|
||||
props: {
|
||||
color: 'primary',
|
||||
title: $t('success'),
|
||||
description: $t('album_info_updated'),
|
||||
button: {
|
||||
text: $t('view_album'),
|
||||
color: 'primary',
|
||||
onClick() {
|
||||
return goto(resolve(`${AppRoute.ALBUMS}/${album.id}`));
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
updateAlbumInfo(album);
|
||||
updateRecentAlbumInfo(album);
|
||||
const onUpdate = (album: AlbumResponseDto) => {
|
||||
ownedAlbums = findAndUpdate(ownedAlbums, album);
|
||||
sharedAlbums = findAndUpdate(sharedAlbums, album);
|
||||
};
|
||||
|
||||
const handleAddUsers = async (album: AlbumResponseDto, albumUsers: AlbumUserAddDto[]) => {
|
||||
@@ -287,19 +259,30 @@
|
||||
albumUsers,
|
||||
},
|
||||
});
|
||||
updateAlbumInfo(updatedAlbum);
|
||||
onUpdate(updatedAlbum);
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_add_album_users'));
|
||||
}
|
||||
};
|
||||
|
||||
const onAlbumUpdate = (album: AlbumResponseDto) => {
|
||||
onUpdate(album);
|
||||
userInteraction.recentAlbums = findAndUpdate(userInteraction.recentAlbums || [], album);
|
||||
};
|
||||
|
||||
const onAlbumDelete = (album: AlbumResponseDto) => {
|
||||
ownedAlbums = ownedAlbums.filter(({ id }) => id !== album.id);
|
||||
sharedAlbums = sharedAlbums.filter(({ id }) => id !== album.id);
|
||||
};
|
||||
|
||||
const onSharedLinkCreate = (sharedLink: SharedLinkResponseDto) => {
|
||||
if (sharedLink.album) {
|
||||
onUpdate(sharedLink.album);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<OnEvents {onAlbumDelete} />
|
||||
<OnEvents {onAlbumUpdate} {onAlbumDelete} {onSharedLinkCreate} />
|
||||
|
||||
{#if albums.length > 0}
|
||||
{#if userSettings.view === AlbumViewMode.Cover}
|
||||
|
||||
@@ -20,6 +20,7 @@ type ActionMap = {
|
||||
[AssetAction.SET_VISIBILITY_LOCKED]: { asset: TimelineAsset };
|
||||
[AssetAction.SET_VISIBILITY_TIMELINE]: { asset: TimelineAsset };
|
||||
[AssetAction.SET_PERSON_FEATURED_PHOTO]: { asset: AssetResponseDto; person: PersonResponseDto };
|
||||
[AssetAction.RATING]: { asset: TimelineAsset; rating: number | null };
|
||||
};
|
||||
|
||||
export type Action = {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { renderWithTooltips } from '$tests/helpers';
|
||||
import type { AssetResponseDto } from '@immich/sdk';
|
||||
import { assetFactory } from '@test-data/factories/asset-factory';
|
||||
import '@testing-library/jest-dom';
|
||||
import { render } from '@testing-library/svelte';
|
||||
import DeleteAction from './delete-action.svelte';
|
||||
|
||||
let asset: AssetResponseDto;
|
||||
@@ -13,8 +13,12 @@ describe('DeleteAction component', () => {
|
||||
});
|
||||
|
||||
it('displays a button to move the asset to the trash bin', () => {
|
||||
const { getByTitle, queryByTitle } = render(DeleteAction, { asset, onAction: vi.fn() });
|
||||
expect(getByTitle('delete')).toBeInTheDocument();
|
||||
const { getByLabelText, queryByTitle } = renderWithTooltips(DeleteAction, {
|
||||
asset,
|
||||
onAction: vi.fn(),
|
||||
preAction: vi.fn(),
|
||||
});
|
||||
expect(getByLabelText('delete')).toBeInTheDocument();
|
||||
expect(queryByTitle('deletePermanently')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -25,8 +29,12 @@ describe('DeleteAction component', () => {
|
||||
});
|
||||
|
||||
it('displays a button to permanently delete the asset', () => {
|
||||
const { getByTitle, queryByTitle } = render(DeleteAction, { asset, onAction: vi.fn() });
|
||||
expect(getByTitle('permanently_delete')).toBeInTheDocument();
|
||||
const { getByLabelText, queryByTitle } = renderWithTooltips(DeleteAction, {
|
||||
asset,
|
||||
onAction: vi.fn(),
|
||||
preAction: vi.fn(),
|
||||
});
|
||||
expect(getByLabelText('permanently_delete')).toBeInTheDocument();
|
||||
expect(queryByTitle('delete')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import Portal from '$lib/elements/Portal.svelte';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { showDeleteModal } from '$lib/stores/preferences.store';
|
||||
import { deleteAssets as deleteAssetsUtil, type OnUndoDelete } from '$lib/utils/actions';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { deleteAssets, type AssetResponseDto } from '@immich/sdk';
|
||||
@@ -17,9 +18,10 @@
|
||||
asset: AssetResponseDto;
|
||||
onAction: OnAction;
|
||||
preAction: PreAction;
|
||||
onUndoDelete?: OnUndoDelete;
|
||||
}
|
||||
|
||||
let { asset, onAction, preAction }: Props = $props();
|
||||
let { asset, onAction, preAction, onUndoDelete = undefined }: Props = $props();
|
||||
|
||||
let showConfirmModal = $state(false);
|
||||
|
||||
@@ -38,14 +40,14 @@
|
||||
};
|
||||
|
||||
const trashAsset = async () => {
|
||||
try {
|
||||
preAction({ type: AssetAction.TRASH, asset: toTimelineAsset(asset) });
|
||||
await deleteAssets({ assetBulkDeleteDto: { ids: [asset.id] } });
|
||||
onAction({ type: AssetAction.TRASH, asset: toTimelineAsset(asset) });
|
||||
toastManager.success($t('moved_to_trash'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_trash_asset'));
|
||||
}
|
||||
const timelineAsset = toTimelineAsset(asset);
|
||||
preAction({ type: AssetAction.TRASH, asset: timelineAsset });
|
||||
await deleteAssetsUtil(
|
||||
false,
|
||||
() => onAction({ type: AssetAction.TRASH, asset: timelineAsset }),
|
||||
[timelineAsset],
|
||||
onUndoDelete,
|
||||
);
|
||||
};
|
||||
|
||||
const deleteAsset = async () => {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
import { shortcuts } from '$lib/actions/shortcut';
|
||||
import type { OnAction } from '$lib/components/asset-viewer/actions/action';
|
||||
import { AssetAction } from '$lib/constants';
|
||||
import { preferences } from '$lib/stores/user.store';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { updateAsset, type AssetResponseDto } from '@immich/sdk';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
asset: AssetResponseDto;
|
||||
onAction: OnAction;
|
||||
};
|
||||
|
||||
let { asset, onAction }: Props = $props();
|
||||
|
||||
const rateAsset = async (rating: number | null) => {
|
||||
try {
|
||||
const updateAssetDto = rating === null ? {} : { rating };
|
||||
await updateAsset({
|
||||
id: asset.id,
|
||||
updateAssetDto,
|
||||
});
|
||||
|
||||
asset = {
|
||||
...asset,
|
||||
exifInfo: {
|
||||
...asset.exifInfo,
|
||||
rating,
|
||||
},
|
||||
};
|
||||
|
||||
onAction({
|
||||
type: AssetAction.RATING,
|
||||
asset: toTimelineAsset(asset),
|
||||
rating,
|
||||
});
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_set_rating'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:document
|
||||
use:shortcuts={$preferences?.ratings.enabled
|
||||
? [
|
||||
{ shortcut: { key: '0' }, onShortcut: () => rateAsset(null) },
|
||||
...[1, 2, 3, 4, 5].map((rating) => ({
|
||||
shortcut: { key: String(rating) },
|
||||
onShortcut: () => rateAsset(rating),
|
||||
})),
|
||||
]
|
||||
: []}
|
||||
/>
|
||||
@@ -1,26 +0,0 @@
|
||||
<script lang="ts">
|
||||
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
|
||||
import type { AssetResponseDto } from '@immich/sdk';
|
||||
import { IconButton, modalManager } from '@immich/ui';
|
||||
import { mdiShareVariantOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
}
|
||||
|
||||
let { asset }: Props = $props();
|
||||
|
||||
const handleClick = async () => {
|
||||
await modalManager.show(SharedLinkCreateModal, { assetIds: [asset.id] });
|
||||
};
|
||||
</script>
|
||||
|
||||
<IconButton
|
||||
color="secondary"
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
icon={mdiShareVariantOutline}
|
||||
onclick={handleClick}
|
||||
aria-label={$t('share')}
|
||||
/>
|
||||
@@ -1,23 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { shortcut } from '$lib/actions/shortcut';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { IconButton } from '@immich/ui';
|
||||
import { mdiInformationOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
onShowDetail: () => void;
|
||||
}
|
||||
|
||||
let { onShowDetail }: Props = $props();
|
||||
const onAction = () => {
|
||||
assetViewerManager.toggleDetailPanel();
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:document use:shortcut={{ shortcut: { key: 'i' }, onShortcut: onShowDetail }} />
|
||||
<svelte:document use:shortcut={{ shortcut: { key: 'i' }, onShortcut: onAction }} />
|
||||
|
||||
<IconButton
|
||||
color="secondary"
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
icon={mdiInformationOutline}
|
||||
onclick={onShowDetail}
|
||||
onclick={onAction}
|
||||
aria-label={$t('info')}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import type { ActivityResponseDto } from '@immich/sdk';
|
||||
import { Icon } from '@immich/ui';
|
||||
@@ -9,11 +10,10 @@
|
||||
numberOfComments: number | undefined;
|
||||
numberOfLikes: number | undefined;
|
||||
disabled: boolean;
|
||||
onOpenActivityTab: () => void;
|
||||
onFavorite: () => void;
|
||||
}
|
||||
|
||||
let { isLiked, numberOfComments, numberOfLikes, disabled, onOpenActivityTab, onFavorite }: Props = $props();
|
||||
let { isLiked, numberOfComments, numberOfLikes, disabled, onFavorite }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="w-full flex p-4 items-center justify-center rounded-full gap-5 bg-subtle border bg-opacity-60">
|
||||
@@ -25,7 +25,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
<button type="button" onclick={onOpenActivityTab}>
|
||||
<button type="button" onclick={() => assetViewerManager.toggleActivityPanel()}>
|
||||
<div class="flex gap-2 items-center justify-center">
|
||||
<Icon icon={mdiCommentOutline} class="scale-x-[-1]" size="24" />
|
||||
{#if numberOfComments}
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import { autoGrowHeight } from '$lib/actions/autogrow';
|
||||
import { shortcut } from '$lib/actions/shortcut';
|
||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||
import { AppRoute, timeBeforeShowLoadingSpinner } from '$lib/constants';
|
||||
import { activityManager } from '$lib/managers/activity-manager.svelte';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { getAssetThumbnailUrl } from '$lib/utils';
|
||||
import { getAssetType } from '$lib/utils/asset-utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { isTenMinutesApart } from '$lib/utils/timesince';
|
||||
import { ReactionType, type ActivityResponseDto, type AssetTypeEnum, type UserResponseDto } from '@immich/sdk';
|
||||
import { Icon, IconButton, LoadingSpinner, toastManager } from '@immich/ui';
|
||||
import { Icon, IconButton, LoadingSpinner, Textarea, toastManager } from '@immich/ui';
|
||||
import { mdiClose, mdiDeleteOutline, mdiDotsVertical, mdiSend, mdiThumbUp } from '@mdi/js';
|
||||
import * as luxon from 'luxon';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fromAction } from 'svelte/attachments';
|
||||
import UserAvatar from '../shared-components/user-avatar.svelte';
|
||||
|
||||
const units: Intl.RelativeTimeFormatUnit[] = ['year', 'month', 'week', 'day', 'hour', 'minute', 'second'];
|
||||
@@ -44,10 +45,9 @@
|
||||
assetType?: AssetTypeEnum | undefined;
|
||||
albumOwnerId: string;
|
||||
disabled: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { user, assetId = undefined, albumId, assetType = undefined, albumOwnerId, disabled, onClose }: Props = $props();
|
||||
let { user, assetId = undefined, albumId, assetType = undefined, albumOwnerId, disabled }: Props = $props();
|
||||
|
||||
let innerHeight: number = $state(0);
|
||||
let activityHeight: number = $state(0);
|
||||
@@ -117,7 +117,7 @@
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
onclick={onClose}
|
||||
onclick={() => assetViewerManager.closeActivityPanel()}
|
||||
icon={mdiClose}
|
||||
aria-label={$t('close')}
|
||||
/>
|
||||
@@ -243,37 +243,34 @@
|
||||
<div>
|
||||
<UserAvatar {user} size="md" noTitle />
|
||||
</div>
|
||||
<form class="flex w-full max-h-56 gap-1" {onsubmit}>
|
||||
<div class="flex w-full items-center gap-4">
|
||||
<textarea
|
||||
{disabled}
|
||||
bind:value={message}
|
||||
use:autoGrowHeight={{ height: '5px', value: message }}
|
||||
placeholder={disabled ? $t('comments_are_disabled') : $t('say_something')}
|
||||
use:shortcut={{
|
||||
shortcut: { key: 'Enter' },
|
||||
onShortcut: () => handleSendComment(),
|
||||
}}
|
||||
class="h-4.5 {disabled
|
||||
? 'cursor-not-allowed'
|
||||
: ''} w-full max-h-56 pe-2 items-center overflow-y-auto leading-4 outline-none resize-none bg-gray-200"
|
||||
></textarea>
|
||||
</div>
|
||||
<form class="flex w-full items-center max-h-56 gap-1" {onsubmit}>
|
||||
<Textarea
|
||||
{disabled}
|
||||
bind:value={message}
|
||||
rows={1}
|
||||
grow
|
||||
placeholder={disabled ? $t('comments_are_disabled') : $t('say_something')}
|
||||
{@attach fromAction(shortcut, () => ({
|
||||
shortcut: { key: 'Enter' },
|
||||
onShortcut: () => handleSendComment(),
|
||||
}))}
|
||||
class="{disabled
|
||||
? 'cursor-not-allowed'
|
||||
: ''} ring-0! w-full max-h-56 pe-2 items-center overflow-y-auto leading-4 outline-none resize-none bg-gray-200 dark:bg-gray-200"
|
||||
/>
|
||||
{#if isSendingMessage}
|
||||
<div class="flex items-end place-items-center pb-2 ms-0">
|
||||
<div class="flex place-items-center pb-2 ms-0">
|
||||
<div class="flex w-full place-items-center">
|
||||
<LoadingSpinner />
|
||||
<LoadingSpinner size="large" />
|
||||
</div>
|
||||
</div>
|
||||
{:else if message}
|
||||
<div class="flex items-end w-fit ms-0">
|
||||
<div class="flex items-center w-fit ms-0 light">
|
||||
<IconButton
|
||||
shape="round"
|
||||
aria-label={$t('send_message')}
|
||||
size="small"
|
||||
variant="ghost"
|
||||
icon={mdiSend}
|
||||
class="dark:text-immich-dark-gray"
|
||||
onclick={() => handleSendComment()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,27 +1,26 @@
|
||||
import { preferences as preferencesStore, resetSavedUser, user as userStore } from '$lib/stores/user.store';
|
||||
import { renderWithTooltips } from '$tests/helpers';
|
||||
import { assetFactory } from '@test-data/factories/asset-factory';
|
||||
import { preferencesFactory } from '@test-data/factories/preferences-factory';
|
||||
import { userAdminFactory } from '@test-data/factories/user-factory';
|
||||
import '@testing-library/jest-dom';
|
||||
import { render } from '@testing-library/svelte';
|
||||
import AssetViewerNavBar from './asset-viewer-nav-bar.svelte';
|
||||
|
||||
describe('AssetViewerNavBar component', () => {
|
||||
const additionalProps = {
|
||||
showCopyButton: false,
|
||||
showZoomButton: false,
|
||||
showDetailButton: false,
|
||||
showDownloadButton: false,
|
||||
showMotionPlayButton: false,
|
||||
showShareButton: false,
|
||||
preAction: () => {},
|
||||
onZoomImage: () => {},
|
||||
onCopyImage: () => {},
|
||||
onAction: () => {},
|
||||
onRunJob: () => {},
|
||||
onPlaySlideshow: () => {},
|
||||
onShowDetail: () => {},
|
||||
onClose: () => {},
|
||||
playOriginalVideo: false,
|
||||
setPlayOriginalVideo: () => Promise.resolve(),
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -51,8 +50,8 @@ describe('AssetViewerNavBar component', () => {
|
||||
preferencesStore.set(prefs);
|
||||
|
||||
const asset = assetFactory.build({ isTrashed: false });
|
||||
const { getByTitle } = render(AssetViewerNavBar, { asset, ...additionalProps });
|
||||
expect(getByTitle('go_back')).toBeInTheDocument();
|
||||
const { getByLabelText } = renderWithTooltips(AssetViewerNavBar, { asset, ...additionalProps });
|
||||
expect(getByLabelText('go_back')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('if the current user owns the asset', () => {
|
||||
@@ -65,8 +64,8 @@ describe('AssetViewerNavBar component', () => {
|
||||
const prefs = preferencesFactory.build({ cast: { gCastEnabled: false } });
|
||||
preferencesStore.set(prefs);
|
||||
|
||||
const { getByTitle } = render(AssetViewerNavBar, { asset, ...additionalProps });
|
||||
expect(getByTitle('delete')).toBeInTheDocument();
|
||||
const { getByLabelText } = renderWithTooltips(AssetViewerNavBar, { asset, ...additionalProps });
|
||||
expect(getByLabelText('delete')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import CastButton from '$lib/cast/cast-button.svelte';
|
||||
import ActionButton from '$lib/components/ActionButton.svelte';
|
||||
import type { OnAction, PreAction } from '$lib/components/asset-viewer/actions/action';
|
||||
import AddToAlbumAction from '$lib/components/asset-viewer/actions/add-to-album-action.svelte';
|
||||
import AddToStackAction from '$lib/components/asset-viewer/actions/add-to-stack-action.svelte';
|
||||
@@ -11,6 +12,7 @@
|
||||
import DownloadAction from '$lib/components/asset-viewer/actions/download-action.svelte';
|
||||
import FavoriteAction from '$lib/components/asset-viewer/actions/favorite-action.svelte';
|
||||
import KeepThisDeleteOthersAction from '$lib/components/asset-viewer/actions/keep-this-delete-others.svelte';
|
||||
import RatingAction from '$lib/components/asset-viewer/actions/rating-action.svelte';
|
||||
import RemoveAssetFromStack from '$lib/components/asset-viewer/actions/remove-asset-from-stack.svelte';
|
||||
import RestoreAction from '$lib/components/asset-viewer/actions/restore-action.svelte';
|
||||
import SetAlbumCoverAction from '$lib/components/asset-viewer/actions/set-album-cover-action.svelte';
|
||||
@@ -18,18 +20,19 @@
|
||||
import SetProfilePictureAction from '$lib/components/asset-viewer/actions/set-profile-picture-action.svelte';
|
||||
import SetStackPrimaryAsset from '$lib/components/asset-viewer/actions/set-stack-primary-asset.svelte';
|
||||
import SetVisibilityAction from '$lib/components/asset-viewer/actions/set-visibility-action.svelte';
|
||||
import ShareAction from '$lib/components/asset-viewer/actions/share-action.svelte';
|
||||
import ShowDetailAction from '$lib/components/asset-viewer/actions/show-detail-action.svelte';
|
||||
import UnstackAction from '$lib/components/asset-viewer/actions/unstack-action.svelte';
|
||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { handleReplaceAsset } from '$lib/services/asset.service';
|
||||
import { getAssetActions, handleReplaceAsset } from '$lib/services/asset.service';
|
||||
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { photoZoomState } from '$lib/stores/zoom-image.store';
|
||||
import { getAssetJobName, getSharedLink } from '$lib/utils';
|
||||
import type { OnUndoDelete } from '$lib/utils/actions';
|
||||
import { canCopyImageToClipboard } from '$lib/utils/asset-utils';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import {
|
||||
@@ -67,15 +70,14 @@
|
||||
person?: PersonResponseDto | null;
|
||||
stack?: StackResponseDto | null;
|
||||
showCloseButton?: boolean;
|
||||
showDetailButton: boolean;
|
||||
showSlideshow?: boolean;
|
||||
onZoomImage: () => void;
|
||||
onCopyImage?: () => Promise<void>;
|
||||
preAction: PreAction;
|
||||
onAction: OnAction;
|
||||
onUndoDelete?: OnUndoDelete;
|
||||
onRunJob: (name: AssetJobName) => void;
|
||||
onPlaySlideshow: () => void;
|
||||
onShowDetail: () => void;
|
||||
// export let showEditorHandler: () => void;
|
||||
onClose: () => void;
|
||||
motionPhoto?: Snippet;
|
||||
@@ -89,15 +91,14 @@
|
||||
person = null,
|
||||
stack = null,
|
||||
showCloseButton = true,
|
||||
showDetailButton,
|
||||
showSlideshow = false,
|
||||
onZoomImage,
|
||||
onCopyImage,
|
||||
preAction,
|
||||
onAction,
|
||||
onUndoDelete = undefined,
|
||||
onRunJob,
|
||||
onPlaySlideshow,
|
||||
onShowDetail,
|
||||
onClose,
|
||||
motionPhoto,
|
||||
playOriginalVideo = false,
|
||||
@@ -110,6 +111,8 @@
|
||||
let isLocked = $derived(asset.visibility === AssetVisibility.Locked);
|
||||
let smartSearchEnabled = $derived(featureFlagsManager.value.smartSearch);
|
||||
|
||||
const { Share } = $derived(getAssetActions($t, asset));
|
||||
|
||||
// $: showEditorButton =
|
||||
// isOwner &&
|
||||
// asset.type === AssetTypeEnum.Image &&
|
||||
@@ -132,15 +135,13 @@
|
||||
<div class="flex gap-2 overflow-x-auto dark" data-testid="asset-viewer-navbar-actions">
|
||||
<CastButton />
|
||||
|
||||
{#if !asset.isTrashed && $user && !isLocked}
|
||||
<ShareAction {asset} />
|
||||
{/if}
|
||||
<ActionButton action={Share} />
|
||||
{#if asset.isOffline}
|
||||
<IconButton
|
||||
shape="round"
|
||||
color="danger"
|
||||
icon={mdiAlertOutline}
|
||||
onclick={onShowDetail}
|
||||
onclick={() => assetViewerManager.toggleDetailPanel()}
|
||||
aria-label={$t('asset_offline')}
|
||||
/>
|
||||
{/if}
|
||||
@@ -173,16 +174,17 @@
|
||||
<DownloadAction asset={toTimelineAsset(asset)} />
|
||||
{/if}
|
||||
|
||||
{#if showDetailButton}
|
||||
<ShowDetailAction {onShowDetail} />
|
||||
{#if asset.hasMetadata}
|
||||
<ShowDetailAction />
|
||||
{/if}
|
||||
|
||||
{#if isOwner}
|
||||
<FavoriteAction {asset} {onAction} />
|
||||
<RatingAction {asset} {onAction} />
|
||||
{/if}
|
||||
|
||||
{#if isOwner}
|
||||
<DeleteAction {asset} {onAction} {preAction} />
|
||||
<DeleteAction {asset} {onAction} {preAction} {onUndoDelete} />
|
||||
|
||||
<ButtonContextMenu direction="left" align="top-right" color="secondary" title={$t('more')} icon={mdiDotsVertical}>
|
||||
{#if showSlideshow && !isLocked}
|
||||
|
||||
@@ -9,16 +9,18 @@
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import { AppRoute, AssetAction, ProjectionType } from '$lib/constants';
|
||||
import { activityManager } from '$lib/managers/activity-manager.svelte';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { closeEditorCofirm } from '$lib/stores/asset-editor.store';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { ocrManager } from '$lib/stores/ocr.svelte';
|
||||
import { alwaysLoadOriginalVideo, isShowDetail } from '$lib/stores/preferences.store';
|
||||
import { alwaysLoadOriginalVideo } from '$lib/stores/preferences.store';
|
||||
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { websocketEvents } from '$lib/stores/websocket';
|
||||
import { getAssetJobMessage, getSharedLink, handlePromiseError } from '$lib/utils';
|
||||
import type { OnUndoDelete } from '$lib/utils/actions';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { SlideshowHistory } from '$lib/utils/slideshow-history';
|
||||
import { toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
@@ -62,6 +64,7 @@
|
||||
person?: PersonResponseDto | null;
|
||||
preAction?: PreAction | undefined;
|
||||
onAction?: OnAction | undefined;
|
||||
onUndoDelete?: OnUndoDelete | undefined;
|
||||
showCloseButton?: boolean;
|
||||
onClose: (asset: AssetResponseDto) => void;
|
||||
onNext: () => Promise<HasAsset>;
|
||||
@@ -80,6 +83,7 @@
|
||||
person = null,
|
||||
preAction = undefined,
|
||||
onAction = undefined,
|
||||
onUndoDelete = undefined,
|
||||
showCloseButton,
|
||||
onClose,
|
||||
onNext,
|
||||
@@ -102,11 +106,7 @@
|
||||
let appearsInAlbums: AlbumResponseDto[] = $state([]);
|
||||
let shouldPlayMotionPhoto = $state(false);
|
||||
let sharedLink = getSharedLink();
|
||||
let enableDetailPanel = asset.hasMetadata;
|
||||
let slideshowStateUnsubscribe: () => void;
|
||||
let shuffleSlideshowUnsubscribe: () => void;
|
||||
let previewStackedAsset: AssetResponseDto | undefined = $state();
|
||||
let isShowActivity = $state(false);
|
||||
let isShowEditor = $state(false);
|
||||
let fullscreenElement = $state<Element>();
|
||||
let unsubscribes: (() => void)[] = [];
|
||||
@@ -160,39 +160,29 @@
|
||||
unsubscribes.push(
|
||||
websocketEvents.on('on_upload_success', (asset) => onAssetUpdate({ event: 'upload', asset })),
|
||||
websocketEvents.on('on_asset_update', (asset) => onAssetUpdate({ event: 'update', asset })),
|
||||
slideshowState.subscribe((value) => {
|
||||
if (value === SlideshowState.PlaySlideshow) {
|
||||
slideshowHistory.reset();
|
||||
slideshowHistory.queue(toTimelineAsset(asset));
|
||||
handlePromiseError(handlePlaySlideshow());
|
||||
} else if (value === SlideshowState.StopSlideshow) {
|
||||
handlePromiseError(handleStopSlideshow());
|
||||
}
|
||||
}),
|
||||
slideshowNavigation.subscribe((value) => {
|
||||
if (value === SlideshowNavigation.Shuffle) {
|
||||
slideshowHistory.reset();
|
||||
slideshowHistory.queue(toTimelineAsset(asset));
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
slideshowStateUnsubscribe = slideshowState.subscribe((value) => {
|
||||
if (value === SlideshowState.PlaySlideshow) {
|
||||
slideshowHistory.reset();
|
||||
slideshowHistory.queue(toTimelineAsset(asset));
|
||||
handlePromiseError(handlePlaySlideshow());
|
||||
} else if (value === SlideshowState.StopSlideshow) {
|
||||
handlePromiseError(handleStopSlideshow());
|
||||
}
|
||||
});
|
||||
|
||||
shuffleSlideshowUnsubscribe = slideshowNavigation.subscribe((value) => {
|
||||
if (value === SlideshowNavigation.Shuffle) {
|
||||
slideshowHistory.reset();
|
||||
slideshowHistory.queue(toTimelineAsset(asset));
|
||||
}
|
||||
});
|
||||
|
||||
if (!sharedLink) {
|
||||
await handleGetAllAlbums();
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (slideshowStateUnsubscribe) {
|
||||
slideshowStateUnsubscribe();
|
||||
}
|
||||
|
||||
if (shuffleSlideshowUnsubscribe) {
|
||||
shuffleSlideshowUnsubscribe();
|
||||
}
|
||||
|
||||
for (const unsubscribe of unsubscribes) {
|
||||
unsubscribe();
|
||||
}
|
||||
@@ -212,18 +202,6 @@
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenActivity = () => {
|
||||
if ($isShowDetail) {
|
||||
$isShowDetail = false;
|
||||
}
|
||||
isShowActivity = !isShowActivity;
|
||||
};
|
||||
|
||||
const toggleDetailPanel = () => {
|
||||
isShowActivity = false;
|
||||
$isShowDetail = !$isShowDetail;
|
||||
};
|
||||
|
||||
const closeViewer = () => {
|
||||
onClose(asset);
|
||||
};
|
||||
@@ -353,6 +331,16 @@
|
||||
asset = { ...asset, people: assetInfo.people };
|
||||
break;
|
||||
}
|
||||
case AssetAction.RATING: {
|
||||
asset = {
|
||||
...asset,
|
||||
exifInfo: {
|
||||
...asset.exifInfo,
|
||||
rating: action.rating,
|
||||
},
|
||||
};
|
||||
break;
|
||||
}
|
||||
case AssetAction.KEEP_THIS_DELETE_OTHERS:
|
||||
case AssetAction.UNSTACK: {
|
||||
closeViewer();
|
||||
@@ -386,7 +374,7 @@
|
||||
});
|
||||
$effect(() => {
|
||||
if (album && !album.isActivityEnabled && activityManager.commentCount === 0) {
|
||||
isShowActivity = false;
|
||||
assetViewerManager.closeActivityPanel();
|
||||
}
|
||||
});
|
||||
$effect(() => {
|
||||
@@ -424,15 +412,14 @@
|
||||
{person}
|
||||
{stack}
|
||||
{showCloseButton}
|
||||
showDetailButton={enableDetailPanel}
|
||||
showSlideshow={true}
|
||||
onZoomImage={zoomToggle}
|
||||
onCopyImage={copyImage}
|
||||
preAction={handlePreAction}
|
||||
onAction={handleAction}
|
||||
{onUndoDelete}
|
||||
onRunJob={handleRunJob}
|
||||
onPlaySlideshow={() => ($slideshowState = SlideshowState.PlaySlideshow)}
|
||||
onShowDetail={toggleDetailPanel}
|
||||
onClose={closeViewer}
|
||||
{playOriginalVideo}
|
||||
{setPlayOriginalVideo}
|
||||
@@ -451,6 +438,7 @@
|
||||
<div class="absolute w-full flex">
|
||||
<SlideshowBar
|
||||
{isFullScreen}
|
||||
assetType={previewStackedAsset?.type ?? asset.type}
|
||||
onSetToFullScreen={() => assetViewerHtmlElement?.requestFullscreen?.()}
|
||||
onPrevious={() => navigateAsset('previous')}
|
||||
onNext={() => navigateAsset('next')}
|
||||
@@ -550,7 +538,6 @@
|
||||
numberOfComments={activityManager.commentCount}
|
||||
numberOfLikes={activityManager.likeCount}
|
||||
onFavorite={handleFavorite}
|
||||
onOpenActivityTab={handleOpenActivity}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -570,14 +557,14 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if enableDetailPanel && $slideshowState === SlideshowState.None && $isShowDetail && !isShowEditor}
|
||||
{#if asset.hasMetadata && $slideshowState === SlideshowState.None && assetViewerManager.isShowDetailPanel && !isShowEditor}
|
||||
<div
|
||||
transition:fly={{ duration: 150 }}
|
||||
id="detail-panel"
|
||||
class="row-start-1 row-span-4 w-[360px] overflow-y-auto transition-all dark:border-l dark:border-s-immich-dark-gray bg-light"
|
||||
translate="yes"
|
||||
>
|
||||
<DetailPanel {asset} currentAlbum={album} albums={appearsInAlbums} onClose={() => ($isShowDetail = false)} />
|
||||
<DetailPanel {asset} currentAlbum={album} albums={appearsInAlbums} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -628,7 +615,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isShared && album && isShowActivity && $user}
|
||||
{#if isShared && album && assetViewerManager.isShowActivityPanel && $user}
|
||||
<div
|
||||
transition:fly={{ duration: 150 }}
|
||||
id="activity-panel"
|
||||
@@ -642,7 +629,6 @@
|
||||
albumOwnerId={album.ownerId}
|
||||
albumId={album.id}
|
||||
assetId={asset.id}
|
||||
onClose={() => (isShowActivity = false)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script lang="ts">
|
||||
import AutogrowTextarea from '$lib/components/shared-components/autogrow-textarea.svelte';
|
||||
import { shortcut } from '$lib/actions/shortcut';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { updateAsset, type AssetResponseDto } from '@immich/sdk';
|
||||
import { toastManager } from '@immich/ui';
|
||||
import { Textarea, toastManager } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fromAction } from 'svelte/attachments';
|
||||
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
@@ -12,15 +13,17 @@
|
||||
|
||||
let { asset, isOwner }: Props = $props();
|
||||
|
||||
let description = $derived(asset.exifInfo?.description || '');
|
||||
let currentDescription = asset.exifInfo?.description ?? '';
|
||||
let draftDescription = $state(currentDescription);
|
||||
|
||||
const handleFocusOut = async (newDescription: string) => {
|
||||
const handleFocusOut = async () => {
|
||||
if (draftDescription === currentDescription) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateAsset({ id: asset.id, updateAssetDto: { description: newDescription } });
|
||||
|
||||
asset.exifInfo = { ...asset.exifInfo, description: newDescription };
|
||||
|
||||
await updateAsset({ id: asset.id, updateAssetDto: { description: draftDescription } });
|
||||
toastManager.success($t('asset_description_updated'));
|
||||
currentDescription = draftDescription;
|
||||
} catch (error) {
|
||||
handleError(error, $t('cannot_update_the_description'));
|
||||
}
|
||||
@@ -29,15 +32,23 @@
|
||||
|
||||
{#if isOwner}
|
||||
<section class="px-4 mt-10">
|
||||
<AutogrowTextarea
|
||||
content={description}
|
||||
class="max-h-125 w-full border-b border-gray-500 bg-transparent text-base text-black outline-none transition-all focus:border-b-2 focus:border-immich-primary disabled:border-none dark:text-white dark:focus:border-immich-dark-primary immich-scrollbar"
|
||||
onContentUpdate={handleFocusOut}
|
||||
<Textarea
|
||||
bind:value={draftDescription}
|
||||
class="max-h-40 outline-none border-b border-gray-500 bg-transparent ring-0 focus:ring-0 resize-none focus:border-b-2 focus:border-immich-primary dark:focus:border-immich-dark-primary dark:bg-transparent"
|
||||
rows={1}
|
||||
grow
|
||||
shape="rectangle"
|
||||
onfocusout={handleFocusOut}
|
||||
placeholder={$t('add_a_description')}
|
||||
data-testid="autogrow-textarea"
|
||||
{@attach fromAction(shortcut, () => ({
|
||||
shortcut: { key: 'Enter', ctrl: true },
|
||||
onShortcut: (e) => e.currentTarget.blur(),
|
||||
}))}
|
||||
/>
|
||||
</section>
|
||||
{:else if description}
|
||||
{:else if draftDescription}
|
||||
<section class="px-4 mt-6">
|
||||
<p class="wrap-break-word whitespace-pre-line w-full text-black dark:text-white text-base">{description}</p>
|
||||
<p class="wrap-break-word whitespace-pre-line w-full text-black dark:text-white text-base">{draftDescription}</p>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import DetailPanelRating from '$lib/components/asset-viewer/detail-panel-star-rating.svelte';
|
||||
import DetailPanelTags from '$lib/components/asset-viewer/detail-panel-tags.svelte';
|
||||
import { AppRoute, QueryParameter, timeToLoadTheMap } from '$lib/constants';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import AssetChangeDateModal from '$lib/modals/AssetChangeDateModal.svelte';
|
||||
@@ -45,10 +46,9 @@
|
||||
asset: AssetResponseDto;
|
||||
albums?: AlbumResponseDto[];
|
||||
currentAlbum?: AlbumResponseDto | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { asset, albums = [], currentAlbum = null, onClose }: Props = $props();
|
||||
let { asset, albums = [], currentAlbum = null }: Props = $props();
|
||||
|
||||
let showAssetPath = $state(false);
|
||||
let showEditFaces = $state(false);
|
||||
@@ -127,7 +127,7 @@
|
||||
<IconButton
|
||||
icon={mdiClose}
|
||||
aria-label={$t('close')}
|
||||
onclick={onClose}
|
||||
onclick={() => assetViewerManager.closeDetailPanel()}
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { shortcuts } from '$lib/actions/shortcut';
|
||||
import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut';
|
||||
import ProgressBar from '$lib/components/shared-components/progress-bar/progress-bar.svelte';
|
||||
import { ProgressBarStatus } from '$lib/constants';
|
||||
import SlideshowSettingsModal from '$lib/modals/SlideshowSettingsModal.svelte';
|
||||
import { SlideshowNavigation, slideshowStore } from '$lib/stores/slideshow.store';
|
||||
import { AssetTypeEnum } from '@immich/sdk';
|
||||
import { IconButton, modalManager } from '@immich/ui';
|
||||
import { mdiChevronLeft, mdiChevronRight, mdiClose, mdiCog, mdiFullscreen, mdiPause, mdiPlay } from '@mdi/js';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
@@ -13,6 +14,7 @@
|
||||
|
||||
interface Props {
|
||||
isFullScreen: boolean;
|
||||
assetType: AssetTypeEnum;
|
||||
onNext?: () => void;
|
||||
onPrevious?: () => void;
|
||||
onClose?: () => void;
|
||||
@@ -21,6 +23,7 @@
|
||||
|
||||
let {
|
||||
isFullScreen,
|
||||
assetType,
|
||||
onNext = () => {},
|
||||
onPrevious = () => {},
|
||||
onClose = () => {},
|
||||
@@ -35,6 +38,7 @@
|
||||
let showControls = $state(true);
|
||||
let timer: NodeJS.Timeout;
|
||||
let isOverControls = $state(false);
|
||||
const isVideoSlide = $derived(assetType === AssetTypeEnum.Video);
|
||||
|
||||
let unsubscribeRestart: () => void;
|
||||
let unsubscribeStop: () => void;
|
||||
@@ -132,27 +136,34 @@
|
||||
{ onswipedown: showControlBar },
|
||||
true,
|
||||
);
|
||||
|
||||
const shortcutBindings = $derived.by((): ShortcutOptions[] => {
|
||||
const bindings: ShortcutOptions[] = [
|
||||
{ shortcut: { key: 'Escape' }, onShortcut: onClose },
|
||||
{ shortcut: { key: 'ArrowLeft' }, onShortcut: onPrevious },
|
||||
{ shortcut: { key: 'ArrowRight' }, onShortcut: onNext },
|
||||
];
|
||||
|
||||
// For videos, allow the native HTML5 element to handle space for play/pause
|
||||
if (!isVideoSlide) {
|
||||
bindings.push({
|
||||
shortcut: { key: ' ' },
|
||||
onShortcut: () => {
|
||||
if (progressBarStatus === ProgressBarStatus.Paused) {
|
||||
progressBar?.play();
|
||||
} else {
|
||||
progressBar?.pause();
|
||||
}
|
||||
},
|
||||
preventDefault: true,
|
||||
});
|
||||
}
|
||||
|
||||
return bindings;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:document
|
||||
onmousemove={showControlBar}
|
||||
use:shortcuts={[
|
||||
{ shortcut: { key: 'Escape' }, onShortcut: onClose },
|
||||
{ shortcut: { key: 'ArrowLeft' }, onShortcut: onPrevious },
|
||||
{ shortcut: { key: 'ArrowRight' }, onShortcut: onNext },
|
||||
{
|
||||
shortcut: { key: ' ' },
|
||||
onShortcut: () => {
|
||||
if (progressBarStatus === ProgressBarStatus.Paused) {
|
||||
progressBar?.play();
|
||||
} else {
|
||||
progressBar?.pause();
|
||||
}
|
||||
},
|
||||
preventDefault: true,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<svelte:document onmousemove={showControlBar} use:shortcuts={shortcutBindings} />
|
||||
|
||||
{/* @ts-expect-error https://github.com/Rezi/svelte-gestures/issues/38#issuecomment-3315953573 */ null}
|
||||
<svelte:body {@attach swipe} {onswipe} {onswipedown} />
|
||||
@@ -174,14 +185,16 @@
|
||||
aria-label={$t('exit_slideshow')}
|
||||
/>
|
||||
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
shape="round"
|
||||
color="secondary"
|
||||
icon={progressBarStatus === ProgressBarStatus.Paused ? mdiPlay : mdiPause}
|
||||
onclick={() => (progressBarStatus === ProgressBarStatus.Paused ? progressBar?.play() : progressBar?.pause())}
|
||||
aria-label={progressBarStatus === ProgressBarStatus.Paused ? $t('play') : $t('pause')}
|
||||
/>
|
||||
{#if !isVideoSlide}
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
shape="round"
|
||||
color="secondary"
|
||||
icon={progressBarStatus === ProgressBarStatus.Paused ? mdiPlay : mdiPause}
|
||||
onclick={() => (progressBarStatus === ProgressBarStatus.Paused ? progressBar?.play() : progressBar?.pause())}
|
||||
aria-label={progressBarStatus === ProgressBarStatus.Paused ? $t('play') : $t('pause')}
|
||||
/>
|
||||
{/if}
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
shape="round"
|
||||
@@ -219,11 +232,13 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<ProgressBar
|
||||
autoplay={$slideshowAutoplay}
|
||||
hidden={!$showProgressBar}
|
||||
duration={$slideshowDelay}
|
||||
bind:this={progressBar}
|
||||
bind:status={progressBarStatus}
|
||||
onDone={handleDone}
|
||||
/>
|
||||
{#if !isVideoSlide}
|
||||
<ProgressBar
|
||||
autoplay={$slideshowAutoplay}
|
||||
hidden={!$showProgressBar}
|
||||
duration={$slideshowDelay}
|
||||
bind:this={progressBar}
|
||||
bind:status={progressBarStatus}
|
||||
onDone={handleDone}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
import { getIntersectionObserverMock } from '$lib/__mocks__/intersection-observer.mock';
|
||||
import { sdkMock } from '$lib/__mocks__/sdk.mock';
|
||||
import ManagePeopleVisibility from '$lib/components/faces-page/manage-people-visibility.svelte';
|
||||
import type { PersonResponseDto } from '@immich/sdk';
|
||||
import { personFactory } from '@test-data/factories/person-factory';
|
||||
import { render } from '@testing-library/svelte';
|
||||
import { tick } from 'svelte';
|
||||
|
||||
describe('ManagePeopleVisibility Component', () => {
|
||||
let personVisible: PersonResponseDto;
|
||||
let personHidden: PersonResponseDto;
|
||||
let personWithoutName: PersonResponseDto;
|
||||
|
||||
beforeAll(() => {
|
||||
// Prevents errors from `img.decode()` in ImageThumbnail
|
||||
Object.defineProperty(HTMLImageElement.prototype, 'decode', {
|
||||
value: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('IntersectionObserver', getIntersectionObserverMock());
|
||||
personVisible = personFactory.build({ isHidden: false });
|
||||
personHidden = personFactory.build({ isHidden: true });
|
||||
personWithoutName = personFactory.build({ isHidden: false, name: undefined });
|
||||
sdkMock.updatePeople.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it('does not update people when no changes are made', () => {
|
||||
const { getByText } = render(ManagePeopleVisibility, {
|
||||
props: {
|
||||
people: [personVisible, personHidden, personWithoutName],
|
||||
totalPeopleCount: 3,
|
||||
onClose: vi.fn(),
|
||||
loadNextPage: vi.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
const saveButton = getByText('done');
|
||||
saveButton.click();
|
||||
expect(sdkMock.updatePeople).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// svelte animations require a real browser
|
||||
it.skip('hides unnamed people on first button press', () => {
|
||||
const { getByText, getByTitle } = render(ManagePeopleVisibility, {
|
||||
props: {
|
||||
people: [personVisible, personHidden, personWithoutName],
|
||||
totalPeopleCount: 3,
|
||||
onClose: vi.fn(),
|
||||
loadNextPage: vi.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
getByTitle('hide_unnamed_people').click();
|
||||
getByText('done').click();
|
||||
|
||||
expect(sdkMock.updatePeople).toHaveBeenCalledWith({
|
||||
peopleUpdateDto: {
|
||||
people: [{ id: personWithoutName.id, isHidden: true }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// svelte animations require a real browser
|
||||
it.skip('hides all people on second button press', async () => {
|
||||
const { getByText, getByTitle } = render(ManagePeopleVisibility, {
|
||||
props: {
|
||||
people: [personVisible, personHidden, personWithoutName],
|
||||
totalPeopleCount: 3,
|
||||
onClose: vi.fn(),
|
||||
loadNextPage: vi.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
getByTitle('hide_unnamed_people').click();
|
||||
await tick();
|
||||
getByTitle('hide_all_people').click();
|
||||
getByText('done').click();
|
||||
|
||||
expect(sdkMock.updatePeople).toHaveBeenCalledWith({
|
||||
peopleUpdateDto: {
|
||||
people: expect.arrayContaining([
|
||||
{ id: personVisible.id, isHidden: true },
|
||||
{ id: personWithoutName.id, isHidden: true },
|
||||
]),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// svelte animations require a real browser
|
||||
it.skip('shows all people on third button press', async () => {
|
||||
const { getByText, getByTitle } = render(ManagePeopleVisibility, {
|
||||
props: {
|
||||
people: [personVisible, personHidden, personWithoutName],
|
||||
totalPeopleCount: 3,
|
||||
onClose: vi.fn(),
|
||||
loadNextPage: vi.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
getByTitle('hide_unnamed_people').click();
|
||||
await tick();
|
||||
getByTitle('hide_all_people').click();
|
||||
await tick();
|
||||
getByTitle('show_all_people').click();
|
||||
getByText('done').click();
|
||||
|
||||
expect(sdkMock.updatePeople).toHaveBeenCalledWith({
|
||||
peopleUpdateDto: {
|
||||
people: [{ id: personHidden.id, isHidden: false }],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import AdminSidebar from '$lib/components/AdminSidebar.svelte';
|
||||
import PageContent from '$lib/components/layouts/PageContent.svelte';
|
||||
import NavigationBar from '$lib/components/shared-components/navigation-bar/navigation-bar.svelte';
|
||||
import AdminSidebar from '$lib/sidebars/AdminSidebar.svelte';
|
||||
import { sidebarStore } from '$lib/stores/sidebar.svelte';
|
||||
import type { HeaderButtonActionItem } from '$lib/types';
|
||||
import {
|
||||
@@ -28,6 +28,12 @@
|
||||
};
|
||||
|
||||
let { breadcrumbs, actions = [], children }: Props = $props();
|
||||
|
||||
const enabledActions = $derived(
|
||||
actions
|
||||
.filter((action): action is HeaderButtonActionItem => !isMenuItemType(action))
|
||||
.filter((action) => action.$if?.() ?? true),
|
||||
);
|
||||
</script>
|
||||
|
||||
<AppShell>
|
||||
@@ -42,22 +48,20 @@
|
||||
<div class="flex h-16 w-full justify-between items-center border-b py-2 px-4 md:px-2">
|
||||
<Breadcrumbs items={breadcrumbs} separator={mdiSlashForward} />
|
||||
|
||||
{#if actions.length > 0}
|
||||
{#if enabledActions.length > 0}
|
||||
<div class="hidden md:block">
|
||||
<HStack gap={0}>
|
||||
{#each actions as action, i (i)}
|
||||
{#if !isMenuItemType(action) && (action.$if?.() ?? true)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
color={action.color ?? 'secondary'}
|
||||
leadingIcon={action.icon}
|
||||
onclick={() => action.onAction(action)}
|
||||
title={action.data?.title}
|
||||
>
|
||||
{action.title}
|
||||
</Button>
|
||||
{/if}
|
||||
{#each enabledActions as action, i (i)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
color={action.color ?? 'secondary'}
|
||||
leadingIcon={action.icon}
|
||||
onclick={() => action.onAction(action)}
|
||||
title={action.data?.title}
|
||||
>
|
||||
{action.title}
|
||||
</Button>
|
||||
{/each}
|
||||
</HStack>
|
||||
</div>
|
||||
|
||||
@@ -6,8 +6,11 @@
|
||||
import { useActions, type ActionArray } from '$lib/actions/use-actions';
|
||||
import NavigationBar from '$lib/components/shared-components/navigation-bar/navigation-bar.svelte';
|
||||
import UserSidebar from '$lib/components/shared-components/side-bar/user-sidebar.svelte';
|
||||
import type { HeaderButtonActionItem } from '$lib/types';
|
||||
import { openFileUploadDialog } from '$lib/utils/file-uploader';
|
||||
import { Button, ContextMenuButton, HStack, isMenuItemType, type MenuItemType } from '@immich/ui';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
hideNavbar?: boolean;
|
||||
@@ -16,6 +19,7 @@
|
||||
description?: string | undefined;
|
||||
scrollbar?: boolean;
|
||||
use?: ActionArray;
|
||||
actions?: Array<HeaderButtonActionItem | MenuItemType>;
|
||||
header?: Snippet;
|
||||
sidebar?: Snippet;
|
||||
buttons?: Snippet;
|
||||
@@ -29,12 +33,19 @@
|
||||
description = undefined,
|
||||
scrollbar = true,
|
||||
use = [],
|
||||
actions = [],
|
||||
header,
|
||||
sidebar,
|
||||
buttons,
|
||||
children,
|
||||
}: Props = $props();
|
||||
|
||||
const enabledActions = $derived(
|
||||
actions
|
||||
.filter((action): action is HeaderButtonActionItem => !isMenuItemType(action))
|
||||
.filter((action) => action.$if?.() ?? true),
|
||||
);
|
||||
|
||||
let scrollbarClass = $derived(scrollbar ? 'immich-scrollbar' : 'scrollbar-hidden');
|
||||
let hasTitleClass = $derived(title ? 'top-16 h-[calc(100%-(--spacing(16)))]' : 'top-0 h-full');
|
||||
</script>
|
||||
@@ -74,7 +85,29 @@
|
||||
<p class="text-sm text-gray-400 dark:text-gray-600">{description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{@render buttons?.()}
|
||||
|
||||
{#if enabledActions.length > 0}
|
||||
<div class="hidden md:block">
|
||||
<HStack gap={0}>
|
||||
{#each enabledActions as action, i (i)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
color={action.color ?? 'secondary'}
|
||||
leadingIcon={action.icon}
|
||||
onclick={() => action.onAction(action)}
|
||||
title={action.data?.title}
|
||||
>
|
||||
{action.title}
|
||||
</Button>
|
||||
{/each}
|
||||
</HStack>
|
||||
</div>
|
||||
|
||||
<ContextMenuButton aria-label={$t('open')} items={actions} class="md:hidden" />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
|
||||
import { OnboardingRole } from '$lib/models/onboarding-role';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { OnboardingRole } from '$lib/types';
|
||||
import { Logo } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { getByteUnitString, getBytesWithUnit } from '$lib/utils/byte-units';
|
||||
import type { ServerStatsResponseDto } from '@immich/sdk';
|
||||
import { Icon } from '@immich/ui';
|
||||
import { Code, Heading, Icon, Text } from '@immich/ui';
|
||||
import { mdiCameraIris, mdiChartPie, mdiPlayCircle } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
@@ -25,52 +25,53 @@
|
||||
let [statsUsage, statsUsageUnit] = $derived(getBytesWithUnit(stats.usage, stats.usage > TiB ? 2 : 0));
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-5">
|
||||
<div class="flex flex-col gap-5 my-4">
|
||||
<div>
|
||||
<p class="text-sm dark:text-immich-dark-fg uppercase">{$t('total_usage')}</p>
|
||||
<Heading size="tiny" class="mb-2">{$t('total_usage')}</Heading>
|
||||
|
||||
<div class="mt-5 hidden justify-between lg:flex gap-4">
|
||||
<div class="hidden justify-between lg:flex gap-4">
|
||||
<StatsCard icon={mdiCameraIris} title={$t('photos')} value={stats.photos} />
|
||||
<StatsCard icon={mdiPlayCircle} title={$t('videos')} value={stats.videos} />
|
||||
<StatsCard icon={mdiChartPie} title={$t('storage')} value={statsUsage} unit={statsUsageUnit} />
|
||||
</div>
|
||||
|
||||
<div class="mt-5 flex lg:hidden">
|
||||
<div class="flex flex-col justify-between rounded-3xl bg-subtle p-5 dark:bg-immich-dark-gray">
|
||||
<div class="flex flex-wrap gap-x-12">
|
||||
<div class="flex place-items-center gap-4 text-primary">
|
||||
<div class="flex flex-1 place-items-center gap-4 text-primary">
|
||||
<Icon icon={mdiCameraIris} size="25" />
|
||||
<p class="uppercase">{$t('photos')}</p>
|
||||
<Text fontWeight="bold" class="uppercase">{$t('photos')}</Text>
|
||||
</div>
|
||||
|
||||
<div class="relative text-center font-mono text-2xl font-semibold">
|
||||
<span class="text-[#DCDADA] dark:text-[#525252]">{zeros(stats.photos)}</span><span class="text-primary"
|
||||
<span class="text-gray-400 dark:text-gray-600">{zeros(stats.photos)}</span><span class="text-primary"
|
||||
>{stats.photos}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-x-12">
|
||||
<div class="flex place-items-center gap-4 text-primary">
|
||||
<div class="flex flex-1 place-items-center gap-4 text-primary">
|
||||
<Icon icon={mdiPlayCircle} size="25" />
|
||||
<p class="uppercase">{$t('videos')}</p>
|
||||
<Text fontWeight="bold" class="uppercase">{$t('videos')}</Text>
|
||||
</div>
|
||||
|
||||
<div class="relative text-center font-mono text-2xl font-semibold">
|
||||
<span class="text-[#DCDADA] dark:text-[#525252]">{zeros(stats.videos)}</span><span class="text-primary"
|
||||
<span class="text-gray-400 dark:text-gray-600">{zeros(stats.videos)}</span><span class="text-primary"
|
||||
>{stats.videos}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-x-7">
|
||||
<div class="flex place-items-center gap-4 text-primary">
|
||||
<div class="flex flex-wrap gap-x-5">
|
||||
<div class="flex flex-1 flex-nowrap place-items-center gap-4 text-primary">
|
||||
<Icon icon={mdiChartPie} size="25" />
|
||||
<p class="uppercase">{$t('storage')}</p>
|
||||
<Text fontWeight="bold" class="uppercase">{$t('storage')}</Text>
|
||||
</div>
|
||||
|
||||
<div class="relative flex text-center font-mono text-2xl font-semibold">
|
||||
<span class="text-[#DCDADA] dark:text-[#525252]">{zeros(statsUsage)}</span><span class="text-primary"
|
||||
<span class="text-gray-400 dark:text-gray-600">{zeros(statsUsage)}</span><span class="text-primary"
|
||||
>{statsUsage}</span
|
||||
>
|
||||
<span class="my-auto ms-2 text-center text-base font-light text-gray-400">{statsUsageUnit}</span>
|
||||
<Code color="muted" class="font-light">{statsUsageUnit}</Code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -78,7 +79,7 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-sm dark:text-immich-dark-fg uppercase">{$t('user_usage_detail')}</p>
|
||||
<Heading size="tiny" class="mb-2">{$t('user_usage_detail')}</Heading>
|
||||
<table class="mt-5 w-full text-start">
|
||||
<thead
|
||||
class="mb-4 flex h-12 w-full rounded-md border bg-gray-50 text-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray"
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { getIntersectionObserverMock } from '$lib/__mocks__/intersection-observer.mock';
|
||||
import { getVisualViewportMock } from '$lib/__mocks__/visual-viewport.mock';
|
||||
import Combobox from '$lib/components/shared-components/combobox.svelte';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
|
||||
describe('Combobox component', () => {
|
||||
beforeAll(() => {
|
||||
vi.stubGlobal('IntersectionObserver', getIntersectionObserverMock());
|
||||
vi.stubGlobal('visualViewport', getVisualViewportMock());
|
||||
});
|
||||
|
||||
it('shows selected option', () => {
|
||||
render(Combobox, {
|
||||
label: 'test',
|
||||
selectedOption: { label: 'option-1', value: 'option-1' },
|
||||
});
|
||||
|
||||
expect(screen.getByRole('combobox')).toHaveValue('option-1');
|
||||
});
|
||||
|
||||
it('clears the selected option when set to undefined', async () => {
|
||||
const { rerender } = render(Combobox, {
|
||||
label: 'test',
|
||||
selectedOption: { label: 'option-1', value: 'option-1' },
|
||||
});
|
||||
|
||||
await rerender({ selectedOption: undefined });
|
||||
expect(screen.getByRole('combobox')).toHaveValue('');
|
||||
});
|
||||
});
|
||||
@@ -1,60 +0,0 @@
|
||||
import AutogrowTextarea from '$lib/components/shared-components/autogrow-textarea.svelte';
|
||||
import { render, screen, waitFor } from '@testing-library/svelte';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
describe('AutogrowTextarea component', () => {
|
||||
const getTextarea = () => screen.getByTestId('autogrow-textarea') as HTMLTextAreaElement;
|
||||
|
||||
it('should render correctly', () => {
|
||||
render(AutogrowTextarea);
|
||||
const textarea = getTextarea();
|
||||
expect(textarea).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show the content passed to the component', () => {
|
||||
render(AutogrowTextarea, { content: 'stuff' });
|
||||
const textarea = getTextarea();
|
||||
expect(textarea.value).toBe('stuff');
|
||||
});
|
||||
|
||||
it('should show the placeholder passed to the component', () => {
|
||||
render(AutogrowTextarea, { placeholder: 'asdf' });
|
||||
const textarea = getTextarea();
|
||||
expect(textarea.placeholder).toBe('asdf');
|
||||
});
|
||||
|
||||
it('should execute the passed callback on blur', async () => {
|
||||
const user = userEvent.setup();
|
||||
const update = vi.fn();
|
||||
render(AutogrowTextarea, { content: 'existing', onContentUpdate: update });
|
||||
const textarea = getTextarea();
|
||||
await user.click(textarea);
|
||||
await user.keyboard('extra');
|
||||
textarea.blur();
|
||||
await waitFor(() => expect(update).toHaveBeenCalledWith('existingextra'));
|
||||
});
|
||||
|
||||
it('should execute the passed callback when pressing ctrl+enter in the textarea', async () => {
|
||||
const user = userEvent.setup();
|
||||
const update = vi.fn();
|
||||
render(AutogrowTextarea, { onContentUpdate: update });
|
||||
const textarea = getTextarea();
|
||||
await user.click(textarea);
|
||||
const string = 'content';
|
||||
await user.keyboard(string);
|
||||
await user.keyboard('{Control>}{Enter}{/Control}');
|
||||
await waitFor(() => expect(update).toHaveBeenCalledWith(string));
|
||||
});
|
||||
|
||||
it('should not execute the passed callback if the text has not changed', async () => {
|
||||
const user = userEvent.setup();
|
||||
const update = vi.fn();
|
||||
render(AutogrowTextarea, { content: 'initial', onContentUpdate: update });
|
||||
const textarea = getTextarea();
|
||||
await user.click(textarea);
|
||||
await user.clear(textarea);
|
||||
await user.keyboard('initial');
|
||||
await user.keyboard('{Control>}{Enter}{/Control}');
|
||||
await waitFor(() => expect(update).not.toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
@@ -1,35 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { autoGrowHeight } from '$lib/actions/autogrow';
|
||||
import { shortcut } from '$lib/actions/shortcut';
|
||||
|
||||
interface Props {
|
||||
content?: string;
|
||||
class?: string;
|
||||
onContentUpdate?: (newContent: string) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
let { content = '', class: className = '', onContentUpdate = () => null, placeholder = '' }: Props = $props();
|
||||
|
||||
let newContent = $derived(content);
|
||||
|
||||
const updateContent = () => {
|
||||
if (content === newContent) {
|
||||
return;
|
||||
}
|
||||
onContentUpdate(newContent);
|
||||
};
|
||||
</script>
|
||||
|
||||
<textarea
|
||||
bind:value={newContent}
|
||||
class="resize-none {className}"
|
||||
onfocusout={updateContent}
|
||||
{placeholder}
|
||||
use:shortcut={{
|
||||
shortcut: { key: 'Enter', ctrl: true },
|
||||
onShortcut: (e) => e.currentTarget.blur(),
|
||||
}}
|
||||
use:autoGrowHeight={{ value: newContent }}
|
||||
data-testid="autogrow-textarea">{content}</textarea
|
||||
>
|
||||
@@ -86,6 +86,27 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to parse coordinate pair from search input in the format `LATITUDE, LONGITUDE` as floats
|
||||
const coordinateParts = searchWord.split(',').map((part) => part.trim());
|
||||
if (coordinateParts.length === 2) {
|
||||
const coordinateLat = Number.parseFloat(coordinateParts[0]);
|
||||
const coordinateLng = Number.parseFloat(coordinateParts[1]);
|
||||
|
||||
if (
|
||||
!Number.isNaN(coordinateLat) &&
|
||||
!Number.isNaN(coordinateLng) &&
|
||||
coordinateLat >= -90 &&
|
||||
coordinateLat <= 90 &&
|
||||
coordinateLng >= -180 &&
|
||||
coordinateLng <= 180
|
||||
) {
|
||||
places = [];
|
||||
showLoadingSpinner = false;
|
||||
handleUseSuggested(coordinateLat, coordinateLng);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
searchPlaces({ name: searchWord })
|
||||
.then((searchResult) => {
|
||||
// skip result when a newer search is happening
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
</Button>
|
||||
{#if $user.isAdmin}
|
||||
<Button
|
||||
href={AppRoute.ADMIN_USERS}
|
||||
href={AppRoute.ADMIN_SETTINGS}
|
||||
onclick={onClose}
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
|
||||
@@ -111,6 +111,7 @@
|
||||
if (close) {
|
||||
await close();
|
||||
close = undefined;
|
||||
searchStore.isSearchEnabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -120,6 +121,7 @@
|
||||
|
||||
const searchResult = await result.onClose;
|
||||
close = undefined;
|
||||
searchStore.isSearchEnabled = false;
|
||||
|
||||
// Refresh search type after modal closes
|
||||
getSearchType();
|
||||
@@ -308,18 +310,6 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="absolute inset-y-0 {showClearIcon ? 'end-14' : 'end-2'} flex items-center ps-6 transition-all">
|
||||
<IconButton
|
||||
aria-label={$t('show_search_options')}
|
||||
shape="round"
|
||||
icon={mdiTune}
|
||||
onclick={onFilterClick}
|
||||
size="medium"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if searchStore.isSearchEnabled}
|
||||
<div
|
||||
id={searchTypeId}
|
||||
@@ -327,7 +317,7 @@
|
||||
class:max-md:hidden={value}
|
||||
class:end-28={value.length > 0}
|
||||
>
|
||||
<div class="relative">
|
||||
<div class="relative" use:focusOutside={{ onFocusOut: closeSearchTypeDropdown }}>
|
||||
<Button
|
||||
class="bg-immich-primary text-white dark:bg-immich-dark-primary/90 dark:text-black/75 rounded-full px-3 py-1 text-xs hover:opacity-80 transition-opacity cursor-pointer"
|
||||
onclick={toggleSearchTypeDropdown}
|
||||
@@ -340,11 +330,11 @@
|
||||
{#if showSearchTypeDropdown}
|
||||
<div
|
||||
class="absolute top-full right-0 mt-1 bg-white dark:bg-immich-dark-gray border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg py-1 min-w-32 z-9999"
|
||||
use:focusOutside={{ onFocusOut: closeSearchTypeDropdown }}
|
||||
>
|
||||
{#each searchTypes as searchType (searchType.value)}
|
||||
<button
|
||||
type="button"
|
||||
tabindex="0"
|
||||
class="w-full text-left px-3 py-2 text-xs hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors
|
||||
{currentSearchType === searchType.value ? 'bg-gray-100 dark:bg-gray-700' : ''}"
|
||||
onclick={() => selectSearchType(searchType.value)}
|
||||
@@ -384,4 +374,16 @@
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="absolute inset-y-0 {showClearIcon ? 'end-14' : 'end-2'} flex items-center ps-6 transition-all">
|
||||
<IconButton
|
||||
aria-label={$t('show_search_options')}
|
||||
shape="round"
|
||||
icon={mdiTune}
|
||||
onclick={onFilterClick}
|
||||
size="medium"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import type { UploadAsset } from '$lib/models/upload-asset';
|
||||
import { UploadState } from '$lib/models/upload-asset';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { uploadAssetsStore } from '$lib/stores/upload';
|
||||
import type { UploadAsset } from '$lib/types';
|
||||
import { UploadState } from '$lib/types';
|
||||
import { getByteUnitString } from '$lib/utils/byte-units';
|
||||
import { fileUploadHandler } from '$lib/utils/file-uploader';
|
||||
import { Icon } from '@immich/ui';
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import Skeleton from '$lib/elements/Skeleton.svelte';
|
||||
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
|
||||
import { isIntersecting } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
|
||||
import { focusAsset } from '$lib/components/timeline/actions/focus-actions';
|
||||
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import type { TimelineAsset, TimelineManagerOptions, ViewportTopMonth } from '$lib/managers/timeline-manager/types';
|
||||
@@ -25,7 +26,7 @@
|
||||
import { getTimes, type ScrubberListener } from '$lib/utils/timeline-util';
|
||||
import { type AlbumResponseDto, type PersonResponseDto, type UserResponseDto } from '@immich/sdk';
|
||||
import { DateTime } from 'luxon';
|
||||
import { onDestroy, onMount, type Snippet } from 'svelte';
|
||||
import { onDestroy, onMount, tick, type Snippet } from 'svelte';
|
||||
import type { UpdatePayload } from 'vite';
|
||||
|
||||
interface Props {
|
||||
@@ -226,6 +227,9 @@
|
||||
if (!scrolled) {
|
||||
// if the asset is not found, scroll to the top
|
||||
timelineManager.scrollTo(0);
|
||||
} else if (scrollTarget) {
|
||||
await tick();
|
||||
focusAsset(scrollTarget);
|
||||
}
|
||||
invisible = false;
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { AssetAction } from '$lib/constants';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions';
|
||||
import { navigate } from '$lib/utils/navigation';
|
||||
@@ -163,6 +164,15 @@
|
||||
}
|
||||
}
|
||||
};
|
||||
const handleUndoDelete = async (assets: TimelineAsset[]) => {
|
||||
timelineManager.upsertAssets(assets);
|
||||
if (assets.length > 0) {
|
||||
const restoredAsset = assets[0];
|
||||
const asset = await getAssetInfo({ ...authManager.params, id: restoredAsset.id });
|
||||
assetViewingStore.setAsset(asset);
|
||||
await navigate({ targetRoute: 'current', assetId: restoredAsset.id });
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
|
||||
@@ -175,6 +185,7 @@
|
||||
{person}
|
||||
preAction={handlePreAction}
|
||||
onAction={handleAction}
|
||||
onUndoDelete={handleUndoDelete}
|
||||
onPrevious={handlePrevious}
|
||||
onNext={handleNext}
|
||||
onRandom={handleRandom}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||
import { getAssetControlContext } from '$lib/components/timeline/AssetSelectControlBar.svelte';
|
||||
import AssetSelectionChangeDateModal from '$lib/modals/AssetSelectionChangeDateModal.svelte';
|
||||
import { fromTimelinePlainDateTime } from '$lib/utils/timeline-util';
|
||||
import { modalManager } from '@immich/ui';
|
||||
import { mdiCalendarEditOutline } from '@mdi/js';
|
||||
import { DateTime } from 'luxon';
|
||||
@@ -14,9 +15,11 @@
|
||||
const { clearSelect, getOwnedAssets } = getAssetControlContext();
|
||||
|
||||
const handleChangeDate = async () => {
|
||||
const assets = getOwnedAssets();
|
||||
const initialDate = assets.length === 1 ? fromTimelinePlainDateTime(assets[0].localDateTime) : DateTime.now();
|
||||
const success = await modalManager.show(AssetSelectionChangeDateModal, {
|
||||
initialDate: DateTime.now(),
|
||||
assets: getOwnedAssets(),
|
||||
initialDate,
|
||||
assets,
|
||||
});
|
||||
if (success) {
|
||||
clearSelect();
|
||||
|
||||
@@ -21,11 +21,15 @@ export const focusPreviousAsset = () =>
|
||||
|
||||
const queryHTMLElement = (query: string) => document.querySelector(query) as HTMLElement;
|
||||
|
||||
export const focusAsset = (assetId: string) => {
|
||||
const element = queryHTMLElement(`[data-thumbnail-focus-container][data-asset="${assetId}"]`);
|
||||
element?.focus();
|
||||
};
|
||||
|
||||
export const setFocusToAsset = (scrollToAsset: (asset: TimelineAsset) => boolean, asset: TimelineAsset) => {
|
||||
const scrolled = scrollToAsset(asset);
|
||||
if (scrolled) {
|
||||
const element = queryHTMLElement(`[data-thumbnail-focus-container][data-asset="${asset.id}"]`);
|
||||
element?.focus();
|
||||
focusAsset(asset.id);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -71,8 +75,7 @@ export const setFocusTo = async (
|
||||
if (!invocation.isStillValid()) {
|
||||
return;
|
||||
}
|
||||
const element = queryHTMLElement(`[data-thumbnail-focus-container][data-asset="${asset.id}"]`);
|
||||
element?.focus();
|
||||
focusAsset(asset.id);
|
||||
}
|
||||
|
||||
invocation.endInvocation();
|
||||
|
||||
@@ -1,68 +1,47 @@
|
||||
<script lang="ts">
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import TableButton from '$lib/components/TableButton.svelte';
|
||||
import { dateFormats } from '$lib/constants';
|
||||
import ApiKeyCreateModal from '$lib/modals/ApiKeyCreateModal.svelte';
|
||||
import ApiKeySecretModal from '$lib/modals/ApiKeySecretModal.svelte';
|
||||
import ApiKeyUpdateModal from '$lib/modals/ApiKeyUpdateModal.svelte';
|
||||
import { getApiKeyActions, getApiKeysActions } from '$lib/services/api-key.service';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { deleteApiKey, getApiKeys, type ApiKeyResponseDto } from '@immich/sdk';
|
||||
import { Button, IconButton, modalManager, toastManager } from '@immich/ui';
|
||||
import { mdiPencilOutline, mdiTrashCanOutline } from '@mdi/js';
|
||||
import { getApiKeys, type ApiKeyResponseDto } from '@immich/sdk';
|
||||
import { Button } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
type Props = {
|
||||
keys: ApiKeyResponseDto[];
|
||||
}
|
||||
};
|
||||
|
||||
let { keys = $bindable() }: Props = $props();
|
||||
|
||||
async function refreshKeys() {
|
||||
const onApiKeyCreate = async () => {
|
||||
keys = await getApiKeys();
|
||||
}
|
||||
|
||||
const handleCreate = async () => {
|
||||
const secret = await modalManager.show(ApiKeyCreateModal);
|
||||
|
||||
if (!secret) {
|
||||
return;
|
||||
}
|
||||
|
||||
await modalManager.show(ApiKeySecretModal, { secret });
|
||||
await refreshKeys();
|
||||
};
|
||||
|
||||
const handleUpdate = async (key: ApiKeyResponseDto) => {
|
||||
const success = await modalManager.show(ApiKeyUpdateModal, {
|
||||
apiKey: key,
|
||||
});
|
||||
|
||||
if (success) {
|
||||
await refreshKeys();
|
||||
const onApiKeyUpdate = (update: ApiKeyResponseDto) => {
|
||||
for (const key of keys) {
|
||||
if (key.id === update.id) {
|
||||
Object.assign(key, update);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (key: ApiKeyResponseDto) => {
|
||||
const isConfirmed = await modalManager.showDialog({ prompt: $t('delete_api_key_prompt') });
|
||||
if (!isConfirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteApiKey({ id: key.id });
|
||||
toastManager.success($t('removed_api_key', { values: { name: key.name } }));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_remove_api_key'));
|
||||
} finally {
|
||||
await refreshKeys();
|
||||
}
|
||||
const onApiKeyDelete = ({ id }: ApiKeyResponseDto) => {
|
||||
keys = keys.filter((apiKey) => apiKey.id !== id);
|
||||
};
|
||||
|
||||
const { Create } = $derived(getApiKeysActions($t));
|
||||
</script>
|
||||
|
||||
<OnEvents {onApiKeyCreate} {onApiKeyUpdate} {onApiKeyDelete} />
|
||||
|
||||
<section class="my-4">
|
||||
<div class="flex flex-col gap-2" in:fade={{ duration: 500 }}>
|
||||
<div class="mb-2 flex justify-end">
|
||||
<Button shape="round" size="small" onclick={() => handleCreate()}>{$t('new_api_key')}</Button>
|
||||
<Button leadingIcon={Create.icon} shape="round" size="small" onclick={() => Create.onAction(Create)}
|
||||
>{Create.title}</Button
|
||||
>
|
||||
</div>
|
||||
|
||||
{#if keys.length > 0}
|
||||
@@ -79,6 +58,7 @@
|
||||
</thead>
|
||||
<tbody class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray">
|
||||
{#each keys as key (key.id)}
|
||||
{@const { Update, Delete } = getApiKeyActions($t, key)}
|
||||
<tr
|
||||
class="flex h-20 w-full place-items-center text-center dark:text-immich-dark-fg even:bg-subtle/20 odd:bg-subtle/80"
|
||||
>
|
||||
@@ -91,22 +71,8 @@
|
||||
>{new Date(key.createdAt).toLocaleDateString($locale, dateFormats.settings)}
|
||||
</td>
|
||||
<td class="flex flex-row flex-wrap justify-center gap-x-2 gap-y-1 w-1/4">
|
||||
<IconButton
|
||||
shape="round"
|
||||
color="primary"
|
||||
icon={mdiPencilOutline}
|
||||
aria-label={$t('edit_key')}
|
||||
size="small"
|
||||
onclick={() => handleUpdate(key)}
|
||||
/>
|
||||
<IconButton
|
||||
shape="round"
|
||||
color="primary"
|
||||
icon={mdiTrashCanOutline}
|
||||
aria-label={$t('delete_key')}
|
||||
size="small"
|
||||
onclick={() => handleDelete(key)}
|
||||
/>
|
||||
<TableButton action={Update} size="small" />
|
||||
<TableButton action={Delete} size="small" />
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="min-w-60 transition-colors border rounded-lg">
|
||||
<div class="min-w-60 transition-colors border rounded-lg flex-1">
|
||||
<div class="relative w-full">
|
||||
<button
|
||||
type="button"
|
||||
@@ -168,7 +168,11 @@
|
||||
? 'bg-success/15 dark:bg-[#001a06]'
|
||||
: 'bg-transparent'}"
|
||||
>
|
||||
<InfoRow icon={mdiImageOutline} highlight={hasDifferentValues.fileName} title={$t('file_name')}>
|
||||
<InfoRow
|
||||
icon={mdiImageOutline}
|
||||
highlight={hasDifferentValues.fileName}
|
||||
title={$t('file_name', { values: { file_name: asset.originalFileName ?? '' } })}
|
||||
>
|
||||
{asset.originalFileName}
|
||||
</InfoRow>
|
||||
|
||||
|
||||
@@ -170,7 +170,7 @@
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto p-2">
|
||||
<div class="flex flex-nowrap gap-1 place-items-center justify-center min-w-full w-fit mx-auto">
|
||||
<div class="flex flex-nowrap gap-1 place-items-start justify-center min-w-full w-fit mx-auto">
|
||||
{#each assets as asset (asset.id)}
|
||||
<DuplicateAsset {assets} {asset} {onSelectAsset} isSelected={selectedAssetIds.has(asset.id)} {onViewAsset} />
|
||||
{/each}
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
|
||||
<div class="grid grid-cols-[25px_1fr] w-full px-1 py-0.5" class:border-b={borderBottom} {title}>
|
||||
<Icon {icon} size="18" class="text-dark/25 {highlight ? 'text-primary/75' : ''}" />
|
||||
<div class="justify-self-end text-end rounded px-1 transition-colors">
|
||||
<Text size="tiny" class={highlight ? 'font-semibold text-primary' : ''}>
|
||||
<div class="justify-self-end text-end rounded px-1 transition-colors w-full overflow-hidden">
|
||||
<Text size="tiny" class={`${highlight ? 'font-semibold text-primary' : ''} text-ellipsis w-full overflow-hidden`}>
|
||||
{@render children?.()}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
mdiCrosshairsGps,
|
||||
mdiImageSizeSelectLarge,
|
||||
mdiLinkEdit,
|
||||
mdiStateMachine,
|
||||
} from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
@@ -16,6 +17,7 @@
|
||||
{ href: AppRoute.DUPLICATES, icon: mdiContentDuplicate, label: $t('review_duplicates') },
|
||||
{ href: AppRoute.LARGE_FILES, icon: mdiImageSizeSelectLarge, label: $t('review_large_files') },
|
||||
{ href: AppRoute.GEOLOCATION, icon: mdiCrosshairsGps, label: $t('manage_geolocation') },
|
||||
{ href: AppRoute.WORKFLOWS, icon: mdiStateMachine, label: $t('workflows') },
|
||||
];
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
<script lang="ts">
|
||||
import { getComponentDefaultValue, getComponentFromSchema } from '$lib/utils/workflow';
|
||||
import { Field, Input, MultiSelect, Select, Switch, Text } from '@immich/ui';
|
||||
import WorkflowPickerField from './WorkflowPickerField.svelte';
|
||||
|
||||
type Props = {
|
||||
schema: object | null;
|
||||
config: Record<string, unknown>;
|
||||
configKey?: string;
|
||||
};
|
||||
|
||||
let { schema = null, config = $bindable({}), configKey }: Props = $props();
|
||||
|
||||
const components = $derived(getComponentFromSchema(schema));
|
||||
|
||||
// Get the actual config object to work with
|
||||
const actualConfig = $derived(configKey ? (config[configKey] as Record<string, unknown>) || {} : config);
|
||||
|
||||
// Update function that handles nested config
|
||||
const updateConfig = (key: string, value: unknown) => {
|
||||
config = configKey ? { ...config, [configKey]: { ...actualConfig, [key]: value } } : { ...config, [key]: value };
|
||||
};
|
||||
|
||||
const updateConfigBatch = (updates: Record<string, unknown>) => {
|
||||
config = configKey ? { ...config, [configKey]: { ...actualConfig, ...updates } } : { ...config, ...updates };
|
||||
};
|
||||
|
||||
// Derive which keys need initialization (missing from actualConfig)
|
||||
const uninitializedKeys = $derived.by(() => {
|
||||
if (!components) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.entries(components)
|
||||
.filter(([key]) => actualConfig[key] === undefined)
|
||||
.map(([key, component]) => ({ key, component, defaultValue: getComponentDefaultValue(component) }));
|
||||
});
|
||||
|
||||
// Derive the batch updates needed
|
||||
const pendingUpdates = $derived.by(() => {
|
||||
const updates: Record<string, unknown> = {};
|
||||
for (const { key, defaultValue } of uninitializedKeys) {
|
||||
updates[key] = defaultValue;
|
||||
}
|
||||
return updates;
|
||||
});
|
||||
|
||||
// Initialize config namespace if needed
|
||||
$effect(() => {
|
||||
if (configKey && !config[configKey]) {
|
||||
config = { ...config, [configKey]: {} };
|
||||
}
|
||||
});
|
||||
|
||||
// Apply pending config updates
|
||||
$effect(() => {
|
||||
if (Object.keys(pendingUpdates).length > 0) {
|
||||
updateConfigBatch(pendingUpdates);
|
||||
}
|
||||
});
|
||||
|
||||
const isPickerField = (subType: string | undefined) => subType === 'album-picker' || subType === 'people-picker';
|
||||
</script>
|
||||
|
||||
{#if components}
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each Object.entries(components) as [key, component] (key)}
|
||||
{@const label = component.title || component.label || key}
|
||||
|
||||
<div class="flex flex-col gap-1 border bg-light p-4 rounded-xl">
|
||||
<!-- Select component -->
|
||||
{#if component.type === 'select'}
|
||||
{#if isPickerField(component.subType)}
|
||||
<WorkflowPickerField
|
||||
{component}
|
||||
configKey={key}
|
||||
value={actualConfig[key] as string | string[]}
|
||||
onchange={(value) => updateConfig(key, value)}
|
||||
/>
|
||||
{:else}
|
||||
{@const options = component.options?.map((opt) => {
|
||||
return { label: opt.label, value: String(opt.value) };
|
||||
}) || [{ label: 'N/A', value: '' }]}
|
||||
{@const currentValue = actualConfig[key]}
|
||||
{@const selectedItem = options.find((opt) => opt.value === String(currentValue)) ?? options[0]}
|
||||
|
||||
<Field
|
||||
{label}
|
||||
required={component.required}
|
||||
description={component.description}
|
||||
requiredIndicator={component.required}
|
||||
>
|
||||
<Select data={options} onChange={(opt) => updateConfig(key, opt.value)} value={selectedItem} />
|
||||
</Field>
|
||||
{/if}
|
||||
|
||||
<!-- MultiSelect component -->
|
||||
{:else if component.type === 'multiselect'}
|
||||
{#if isPickerField(component.subType)}
|
||||
<WorkflowPickerField
|
||||
{component}
|
||||
configKey={key}
|
||||
value={actualConfig[key] as string | string[]}
|
||||
onchange={(value) => updateConfig(key, value)}
|
||||
/>
|
||||
{:else}
|
||||
{@const options = component.options?.map((opt) => {
|
||||
return { label: opt.label, value: String(opt.value) };
|
||||
}) || [{ label: 'N/A', value: '' }]}
|
||||
{@const currentValues = (actualConfig[key] as string[]) ?? []}
|
||||
{@const selectedItems = options.filter((opt) => currentValues.includes(opt.value))}
|
||||
|
||||
<Field
|
||||
{label}
|
||||
required={component.required}
|
||||
description={component.description}
|
||||
requiredIndicator={component.required}
|
||||
>
|
||||
<MultiSelect
|
||||
data={options}
|
||||
onChange={(opt) =>
|
||||
updateConfig(
|
||||
key,
|
||||
opt.map((o) => o.value),
|
||||
)}
|
||||
values={selectedItems}
|
||||
/>
|
||||
</Field>
|
||||
{/if}
|
||||
|
||||
<!-- Switch component -->
|
||||
{:else if component.type === 'switch'}
|
||||
{@const checked = Boolean(actualConfig[key])}
|
||||
<Field
|
||||
{label}
|
||||
description={component.description}
|
||||
requiredIndicator={component.required}
|
||||
required={component.required}
|
||||
>
|
||||
<Switch {checked} onCheckedChange={(check) => updateConfig(key, check)} />
|
||||
</Field>
|
||||
|
||||
<!-- Text input -->
|
||||
{:else if isPickerField(component.subType)}
|
||||
<WorkflowPickerField
|
||||
{component}
|
||||
configKey={key}
|
||||
value={actualConfig[key] as string | string[]}
|
||||
onchange={(value) => updateConfig(key, value)}
|
||||
/>
|
||||
{:else}
|
||||
<Field
|
||||
{label}
|
||||
description={component.description}
|
||||
requiredIndicator={component.required}
|
||||
required={component.required}
|
||||
>
|
||||
<Input
|
||||
id={key}
|
||||
value={actualConfig[key] as string}
|
||||
oninput={(e) => updateConfig(key, e.currentTarget.value)}
|
||||
required={component.required}
|
||||
/>
|
||||
</Field>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<Text size="small" color="muted">No configuration required</Text>
|
||||
{/if}
|
||||
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
type Props = {
|
||||
animated?: boolean;
|
||||
};
|
||||
|
||||
let { animated = true }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex justify-center py-2">
|
||||
<div class="relative h-12 w-0.5">
|
||||
<div class="absolute inset-0 bg-linear-to-b from-primary/30 via-primary/50 to-primary/30"></div>
|
||||
{#if animated}
|
||||
<div class="absolute inset-0 bg-linear-to-b from-transparent via-primary to-transparent flow-pulse"></div>
|
||||
{/if}
|
||||
<div class="absolute left-1/2 top-0 -translate-x-1/2 -translate-y-1/2">
|
||||
<div class="h-2 w-2 rounded-full bg-primary shadow-sm shadow-primary/50"></div>
|
||||
</div>
|
||||
<div class="absolute left-1/2 bottom-0 -translate-x-1/2 translate-y-1/2">
|
||||
<div class="h-2 w-2 rounded-full bg-primary shadow-sm shadow-primary/50"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes flow {
|
||||
0% {
|
||||
transform: translateY(-25%);
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(25%);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.flow-pulse {
|
||||
animation: flow 2s ease-in-out infinite;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,69 @@
|
||||
<script lang="ts">
|
||||
import { themeManager } from '$lib/managers/theme-manager.svelte';
|
||||
import type { WorkflowPayload } from '$lib/services/workflow.service';
|
||||
import { Button, Card, CardBody, CardDescription, CardHeader, CardTitle, Icon, VStack } from '@immich/ui';
|
||||
import { mdiCodeJson } from '@mdi/js';
|
||||
import { JSONEditor, Mode, type Content, type OnChangeStatus } from 'svelte-jsoneditor';
|
||||
|
||||
type Props = {
|
||||
jsonContent: WorkflowPayload;
|
||||
onApply: () => void;
|
||||
onContentChange: (content: WorkflowPayload) => void;
|
||||
};
|
||||
|
||||
let { jsonContent, onApply, onContentChange }: Props = $props();
|
||||
|
||||
let content: Content = $derived({ json: jsonContent });
|
||||
let canApply = $state(false);
|
||||
let editorClass = $derived(themeManager.isDark ? 'jse-theme-dark' : '');
|
||||
|
||||
const handleChange = (updated: Content, _: Content, status: OnChangeStatus) => {
|
||||
if (status.contentErrors) {
|
||||
return;
|
||||
}
|
||||
|
||||
canApply = true;
|
||||
|
||||
if ('text' in updated && updated.text !== undefined) {
|
||||
try {
|
||||
const parsed = JSON.parse(updated.text);
|
||||
onContentChange(parsed);
|
||||
} catch (error_) {
|
||||
console.error('Invalid JSON in text mode:', error_);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleApply = () => {
|
||||
onApply();
|
||||
canApply = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<VStack gap={4}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex items-start gap-3">
|
||||
<Icon icon={mdiCodeJson} size="20" class="mt-1" />
|
||||
<div class="flex flex-col">
|
||||
<CardTitle>Workflow JSON</CardTitle>
|
||||
<CardDescription>Edit the workflow configuration directly in JSON format</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="small" color="primary" onclick={handleApply} disabled={!canApply}>Apply Changes</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<VStack gap={2}>
|
||||
<div class="w-full h-[600px] rounded-lg overflow-hidden border {editorClass}">
|
||||
<JSONEditor {content} onChange={handleChange} mainMenuBar={false} mode={Mode.text} />
|
||||
</div>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</VStack>
|
||||
|
||||
<style>
|
||||
@import 'svelte-jsoneditor/themes/jse-theme-dark.css';
|
||||
</style>
|
||||
@@ -0,0 +1,104 @@
|
||||
<script lang="ts">
|
||||
import WorkflowPickerItemCard from '$lib/components/workflows/WorkflowPickerItemCard.svelte';
|
||||
import AlbumPickerModal from '$lib/modals/AlbumPickerModal.svelte';
|
||||
import PeoplePickerModal from '$lib/modals/PeoplePickerModal.svelte';
|
||||
import { fetchPickerMetadata, type PickerMetadata } from '$lib/services/workflow.service';
|
||||
import type { ComponentConfig } from '$lib/utils/workflow';
|
||||
import type { AlbumResponseDto, PersonResponseDto } from '@immich/sdk';
|
||||
import { Button, Field, modalManager } from '@immich/ui';
|
||||
import { mdiPlus } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
component: ComponentConfig;
|
||||
configKey: string;
|
||||
value: string | string[] | undefined;
|
||||
onchange: (value: string | string[]) => void;
|
||||
};
|
||||
|
||||
let { component, configKey, value = $bindable(), onchange }: Props = $props();
|
||||
|
||||
const label = $derived(component.title || component.label || configKey);
|
||||
const subType = $derived(component.subType as 'album-picker' | 'people-picker');
|
||||
const isAlbum = $derived(subType === 'album-picker');
|
||||
const multiple = $derived(component.type === 'multiselect' || Array.isArray(value));
|
||||
|
||||
let pickerMetadata = $state<PickerMetadata | undefined>();
|
||||
|
||||
$effect(() => {
|
||||
if (!value) {
|
||||
pickerMetadata = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pickerMetadata) {
|
||||
void loadMetadata();
|
||||
}
|
||||
});
|
||||
|
||||
const loadMetadata = async () => {
|
||||
pickerMetadata = await fetchPickerMetadata(value, subType);
|
||||
};
|
||||
|
||||
const handlePicker = async () => {
|
||||
if (isAlbum) {
|
||||
const albums = await modalManager.show(AlbumPickerModal, { shared: false });
|
||||
if (albums && albums.length > 0) {
|
||||
const newValue = multiple ? albums.map((album) => album.id) : albums[0].id;
|
||||
onchange(newValue);
|
||||
pickerMetadata = multiple ? albums : albums[0];
|
||||
}
|
||||
} else {
|
||||
const currentIds = (Array.isArray(value) ? value : []) as string[];
|
||||
const excludedIds = multiple ? currentIds : [];
|
||||
const people = await modalManager.show(PeoplePickerModal, { multiple, excludedIds });
|
||||
if (people && people.length > 0) {
|
||||
const newValue = multiple ? people.map((person) => person.id) : people[0].id;
|
||||
onchange(newValue);
|
||||
pickerMetadata = multiple ? people : people[0];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const removeSelection = () => {
|
||||
onchange(multiple ? [] : '');
|
||||
pickerMetadata = undefined;
|
||||
};
|
||||
|
||||
const removeItemFromSelection = (itemId: string) => {
|
||||
if (!Array.isArray(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newValue = value.filter((id) => id !== itemId);
|
||||
onchange(newValue);
|
||||
|
||||
if (Array.isArray(pickerMetadata)) {
|
||||
pickerMetadata = pickerMetadata.filter((item) => item.id !== itemId) as AlbumResponseDto[] | PersonResponseDto[];
|
||||
}
|
||||
};
|
||||
|
||||
const getButtonText = () => {
|
||||
if (isAlbum) {
|
||||
return multiple ? $t('select_albums') : $t('select_album');
|
||||
}
|
||||
return multiple ? $t('select_people') : $t('select_person');
|
||||
};
|
||||
</script>
|
||||
|
||||
<Field {label} required={component.required} description={component.description} requiredIndicator={component.required}>
|
||||
<div class="flex flex-col gap-3">
|
||||
{#if pickerMetadata && !Array.isArray(pickerMetadata)}
|
||||
<WorkflowPickerItemCard item={pickerMetadata} {isAlbum} onRemove={removeSelection} />
|
||||
{:else if pickerMetadata && Array.isArray(pickerMetadata) && pickerMetadata.length > 0}
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each pickerMetadata as item (item.id)}
|
||||
<WorkflowPickerItemCard {item} {isAlbum} onRemove={() => removeItemFromSelection(item.id)} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<Button size="small" variant="outline" leadingIcon={mdiPlus} onclick={handlePicker}>
|
||||
{getButtonText()}
|
||||
</Button>
|
||||
</div>
|
||||
</Field>
|
||||
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
import { getAssetThumbnailUrl, getPeopleThumbnailUrl } from '$lib/utils';
|
||||
import type { AlbumResponseDto, PersonResponseDto } from '@immich/sdk';
|
||||
import { Card, CardBody, IconButton, Text } from '@immich/ui';
|
||||
import { mdiClose } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
item: AlbumResponseDto | PersonResponseDto;
|
||||
isAlbum: boolean;
|
||||
onRemove: () => void;
|
||||
};
|
||||
|
||||
let { item, isAlbum, onRemove }: Props = $props();
|
||||
</script>
|
||||
|
||||
<Card color="secondary">
|
||||
<CardBody class="flex items-center gap-3">
|
||||
<div class="shrink-0">
|
||||
{#if isAlbum && 'albumThumbnailAssetId' in item}
|
||||
{#if item.albumThumbnailAssetId}
|
||||
<img
|
||||
src={getAssetThumbnailUrl(item.albumThumbnailAssetId)}
|
||||
alt={item.albumName}
|
||||
class="h-12 w-12 rounded-lg object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<div class="h-12 w-12 rounded-lg"></div>
|
||||
{/if}
|
||||
{:else if !isAlbum && 'name' in item}
|
||||
<img src={getPeopleThumbnailUrl(item)} alt={item.name} class="h-12 w-12 rounded-full object-cover" />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<Text class="font-semibold truncate">
|
||||
{isAlbum && 'albumName' in item ? item.albumName : 'name' in item ? item.name : ''}
|
||||
</Text>
|
||||
{#if isAlbum && 'assetCount' in item}
|
||||
<Text size="small" color="muted">
|
||||
{$t('items_count', { values: { count: item.assetCount } })}
|
||||
</Text>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<IconButton
|
||||
type="button"
|
||||
onclick={onRemove}
|
||||
class="shrink-0"
|
||||
shape="round"
|
||||
aria-label={$t('remove')}
|
||||
icon={mdiClose}
|
||||
size="small"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
@@ -0,0 +1,184 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
PluginTriggerType,
|
||||
type PluginActionResponseDto,
|
||||
type PluginFilterResponseDto,
|
||||
type PluginTriggerResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { Icon, IconButton, Text } from '@immich/ui';
|
||||
import { mdiClose, mdiFilterOutline, mdiFlashOutline, mdiPlayCircleOutline, mdiViewDashboardOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
trigger: PluginTriggerResponseDto;
|
||||
filters: PluginFilterResponseDto[];
|
||||
actions: PluginActionResponseDto[];
|
||||
};
|
||||
|
||||
let { trigger, filters, actions }: Props = $props();
|
||||
|
||||
const getTriggerName = (triggerType: PluginTriggerType) => {
|
||||
switch (triggerType) {
|
||||
case PluginTriggerType.AssetCreate: {
|
||||
return $t('trigger_asset_uploaded');
|
||||
}
|
||||
case PluginTriggerType.PersonRecognized: {
|
||||
return $t('trigger_person_recognized');
|
||||
}
|
||||
default: {
|
||||
return triggerType;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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 hover:cursor-grab select-none"
|
||||
style="left: {position.x}px; top: {position.y}px;"
|
||||
class:cursor-grabbing={isDragging}
|
||||
onmousedown={handleMouseDown}
|
||||
>
|
||||
<div
|
||||
class="rounded-xl border-transparent border-2 hover:shadow-xl hover:border-dashed bg-light-50 shadow-sm p-4 hover:border-light-300 transition-all"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-4 cursor-grab select-none">
|
||||
<Text size="small" class="font-semibold">{$t('workflow_summary')}</Text>
|
||||
<div class="flex items-center gap-1">
|
||||
<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="18" class="text-primary" />
|
||||
<span class="text-[10px] font-semibold uppercase tracking-wide">{$t('trigger')}</span>
|
||||
</div>
|
||||
<p class="text-sm truncate pl-5">{getTriggerName(trigger.type)}</p>
|
||||
</div>
|
||||
|
||||
<!-- Connector -->
|
||||
<div class="flex justify-center">
|
||||
<div class="w-0.5 h-3 bg-light-400"></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="18" class="text-warning" />
|
||||
<span class="text-[10px] font-semibold uppercase tracking-wide">{$t('filters')}</span>
|
||||
</div>
|
||||
<div class="space-y-1 pl-5">
|
||||
{#each filters as filter, index (index)}
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="shrink-0 h-4 w-4 rounded-full bg-light-200 text-[10px] font-medium flex items-center justify-center"
|
||||
>{index + 1}</span
|
||||
>
|
||||
<p class="text-sm truncate">{filter.title}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Connector -->
|
||||
<div class="flex justify-center">
|
||||
<div class="w-0.5 h-3 bg-light-400"></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="18" class="text-success" />
|
||||
<span class="text-[10px] font-semibold uppercase tracking-wide">{$t('actions')}</span>
|
||||
</div>
|
||||
<div class="space-y-1 pl-5">
|
||||
{#each actions as action, index (index)}
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="shrink-0 h-4 w-4 rounded-full bg-light-200 text-[10px] font-medium flex items-center justify-center"
|
||||
>{index + 1}</span
|
||||
>
|
||||
<p class="text-sm 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 h-14 w-14 items-center justify-center rounded-full bg-primary text-light shadow-lg hover:bg-primary/90 transition-colors"
|
||||
title={$t('workflow_summary')}
|
||||
onclick={() => (isOpen = true)}
|
||||
>
|
||||
<Icon icon={mdiViewDashboardOutline} size="24" />
|
||||
</button>
|
||||
{/if}
|
||||
@@ -0,0 +1,80 @@
|
||||
<script lang="ts">
|
||||
import { PluginTriggerType, type PluginTriggerResponseDto } from '@immich/sdk';
|
||||
import { Icon, Text } from '@immich/ui';
|
||||
import { mdiFaceRecognition, mdiFileUploadOutline, mdiLightningBolt } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
trigger: PluginTriggerResponseDto;
|
||||
selected: boolean;
|
||||
onclick: () => void;
|
||||
};
|
||||
|
||||
let { trigger, selected, onclick }: Props = $props();
|
||||
|
||||
const getTriggerIcon = (triggerType: PluginTriggerType) => {
|
||||
switch (triggerType) {
|
||||
case PluginTriggerType.AssetCreate: {
|
||||
return mdiFileUploadOutline;
|
||||
}
|
||||
case PluginTriggerType.PersonRecognized: {
|
||||
return mdiFaceRecognition;
|
||||
}
|
||||
default: {
|
||||
return mdiLightningBolt;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getTriggerName = (triggerType: PluginTriggerType) => {
|
||||
switch (triggerType) {
|
||||
case PluginTriggerType.AssetCreate: {
|
||||
return $t('trigger_asset_uploaded');
|
||||
}
|
||||
case PluginTriggerType.PersonRecognized: {
|
||||
return $t('trigger_person_recognized');
|
||||
}
|
||||
default: {
|
||||
return triggerType;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getTriggerDescription = (triggerType: PluginTriggerType) => {
|
||||
switch (triggerType) {
|
||||
case PluginTriggerType.AssetCreate: {
|
||||
return $t('trigger_asset_uploaded_description');
|
||||
}
|
||||
case PluginTriggerType.PersonRecognized: {
|
||||
return $t('trigger_person_recognized_description');
|
||||
}
|
||||
default: {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
{onclick}
|
||||
class="group rounded-xl p-4 w-full text-left cursor-pointer border-2 {selected
|
||||
? 'border-primary text-primary'
|
||||
: 'border-light-100 hover:border-light-200 text-light-400 hover:text-light-700'}"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="rounded-xl p-2 {selected
|
||||
? 'bg-primary text-light'
|
||||
: 'text-light-100 bg-light-300 group-hover:bg-light-500'}"
|
||||
>
|
||||
<Icon icon={getTriggerIcon(trigger.type)} size="24" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<Text class="font-semibold mb-1">{getTriggerName(trigger.type)}</Text>
|
||||
{#if getTriggerDescription(trigger.type)}
|
||||
<Text size="small">{getTriggerDescription(trigger.type)}</Text>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
Reference in New Issue
Block a user