Merge remote-tracking branch 'origin/main' into feat/integrity-checks-izzy

Signed-off-by: izzy <me@insrt.uk>
This commit is contained in:
izzy
2026-02-06 16:28:53 +00:00
958 changed files with 36912 additions and 7752 deletions
+4 -3
View File
@@ -1,6 +1,6 @@
{
"name": "immich-web",
"version": "2.4.1",
"version": "2.5.5",
"license": "GNU Affero General Public License version 3",
"type": "module",
"scripts": {
@@ -27,7 +27,7 @@
"@formatjs/icu-messageformat-parser": "^3.0.0",
"@immich/justified-layout-wasm": "^0.4.3",
"@immich/sdk": "file:../open-api/typescript-sdk",
"@immich/ui": "^0.59.0",
"@immich/ui": "^0.61.3",
"@mapbox/mapbox-gl-rtl-text": "0.2.3",
"@mdi/js": "^7.4.47",
"@photo-sphere-viewer/core": "^5.14.0",
@@ -61,6 +61,7 @@
"svelte-persisted-store": "^0.12.0",
"tabbable": "^6.2.0",
"thumbhash": "^0.1.1",
"transformation-matrix": "^3.1.0",
"uplot": "^1.6.32"
},
"devDependencies": {
@@ -97,7 +98,7 @@
"prettier-plugin-sort-json": "^4.1.1",
"prettier-plugin-svelte": "^3.3.3",
"rollup-plugin-visualizer": "^6.0.0",
"svelte": "5.46.4",
"svelte": "5.48.0",
"svelte-check": "^4.1.5",
"svelte-eslint-parser": "^1.3.3",
"tailwindcss": "^4.1.7",
@@ -0,0 +1,8 @@
import { vi } from 'vitest';
export const getResizeObserverMock = () =>
vi.fn(() => ({
disconnect: vi.fn(),
observe: vi.fn(),
unobserve: vi.fn(),
}));
+4 -11
View File
@@ -1,19 +1,12 @@
import { photoZoomState } from '$lib/stores/zoom-image.store';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { createZoomImageWheel } from '@zoom-image/core';
import { get } from 'svelte/store';
export const zoomImageAction = (node: HTMLElement, options?: { disabled?: boolean }) => {
const state = get(photoZoomState);
const zoomInstance = createZoomImageWheel(node, {
maxZoom: 10,
initialState: state,
});
const zoomInstance = createZoomImageWheel(node, { maxZoom: 10, initialState: assetViewerManager.zoomState });
const unsubscribes = [
photoZoomState.subscribe((state) => zoomInstance.setState(state)),
zoomInstance.subscribe(({ state }) => {
photoZoomState.set(state);
}),
assetViewerManager.on({ ZoomChange: (state) => zoomInstance.setState(state) }),
zoomInstance.subscribe(({ state }) => assetViewerManager.onZoomChange(state)),
];
const stopIfDisabled = (event: Event) => {
@@ -0,0 +1,24 @@
<script lang="ts">
import { assetViewerManager, type Events } from '$lib/managers/asset-viewer-manager.svelte';
import type { EventCallback, EventMap } from '$lib/utils/base-event-manager.svelte';
import { onMount } from 'svelte';
type Props = {
[K in keyof Events as `on${K}`]?: EventCallback<Events, K>;
};
const props: Props = $props();
onMount(() => {
const events: EventMap<Events> = {};
for (const [name, listener] of Object.entries(props)) {
if (listener) {
const event = name.slice(2) as keyof Events;
events[event] = listener as EventCallback<Events, typeof event>;
}
}
return assetViewerManager.on(events);
});
</script>
+10 -19
View File
@@ -1,33 +1,24 @@
<script lang="ts">
import { eventManager, type Events } from '$lib/managers/event-manager.svelte';
import type { EventCallback, EventMap } from '$lib/utils/base-event-manager.svelte';
import { onMount } from 'svelte';
type Props = Partial<{
[K in keyof Events as `on${K}`]: (...args: Events[K]) => void;
}>;
type Props = {
[K in keyof Events as `on${K}`]?: (...args: Events[K]) => void;
};
const props: Props = $props();
const unsubscribes: Array<() => void> = [];
onMount(() => {
for (const name of Object.keys(props)) {
const event = name.slice(2) as keyof Events;
const listener = props[name as keyof Props];
const events: EventMap<Events> = {};
if (!listener) {
continue;
for (const [name, listener] of Object.entries(props)) {
if (listener) {
const event = name.slice(2) as keyof Events;
events[event] = listener as EventCallback<Events, typeof event>;
}
const args = [event, listener as (...args: Events[typeof event]) => void] as const;
eventManager.on(...args);
unsubscribes.push(() => eventManager.off(...args));
}
return () => {
for (const unsubscribe of unsubscribes) {
unsubscribe();
}
};
return eventManager.on(events);
});
</script>
+7 -2
View File
@@ -60,7 +60,7 @@
const axisOptions: Axis = {
stroke: () => (isDark ? '#ccc' : 'black'),
ticks: {
show: true,
show: false,
stroke: () => (isDark ? '#444' : '#ddd'),
},
grid: {
@@ -116,6 +116,8 @@
axes: [
{
...axisOptions,
size: 40,
ticks: { show: true },
values: (plot, values) => {
return values.map((value) => {
if (!value) {
@@ -125,7 +127,10 @@
});
},
},
axisOptions,
{
...axisOptions,
size: 60,
},
],
};
@@ -0,0 +1,34 @@
import { render } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import SharedLinkFormFields from './SharedLinkFormFields.svelte';
describe('SharedLinkFormFields component', () => {
const isChecked = (element: Element) =>
element instanceof HTMLInputElement ? element.checked : element.getAttribute('aria-checked') === 'true';
it('turns downloads off when metadata is disabled', async () => {
const { container } = render(SharedLinkFormFields, {
props: {
slug: '',
password: '',
description: '',
allowDownload: true,
allowUpload: false,
showMetadata: true,
expiresAt: null,
},
});
const user = userEvent.setup();
const switches = Array.from(container.querySelectorAll('[role="switch"], input[type="checkbox"]'));
expect(switches).toHaveLength(3);
const [showMetadataSwitch, allowDownloadSwitch] = switches;
expect(isChecked(allowDownloadSwitch)).toBe(true);
await user.click(showMetadataSwitch);
expect(isChecked(showMetadataSwitch)).toBe(false);
expect(isChecked(allowDownloadSwitch)).toBe(false);
});
});
@@ -0,0 +1,65 @@
<script lang="ts">
import SharedLinkExpiration from '$lib/components/SharedLinkExpiration.svelte';
import { Field, Input, PasswordInput, Switch, Text } from '@immich/ui';
import { t } from 'svelte-i18n';
type Props = {
slug: string;
password: string;
description: string;
allowDownload: boolean;
allowUpload: boolean;
showMetadata: boolean;
expiresAt: string | null;
createdAt?: string;
};
let {
slug = $bindable(),
password = $bindable(),
description = $bindable(),
allowDownload = $bindable(),
allowUpload = $bindable(),
showMetadata = $bindable(),
expiresAt = $bindable(),
createdAt,
}: Props = $props();
$effect(() => {
if (!showMetadata && allowDownload) {
allowDownload = false;
}
});
</script>
<div class="flex flex-col gap-4 mt-4">
<div>
<Field label={$t('custom_url')} description={$t('shared_link_custom_url_description')}>
<Input bind:value={slug} autocomplete="off" />
</Field>
{#if slug}
<Text size="tiny" color="muted" class="pt-2 break-all">/s/{encodeURIComponent(slug)}</Text>
{/if}
</div>
<Field label={$t('password')} description={$t('shared_link_password_description')}>
<PasswordInput bind:value={password} autocomplete="new-password" />
</Field>
<Field label={$t('description')}>
<Input bind:value={description} autocomplete="off" />
</Field>
<SharedLinkExpiration {createdAt} bind:expiresAt />
<Field label={$t('show_metadata')}>
<Switch bind:checked={showMetadata} />
</Field>
<Field label={$t('allow_public_user_to_download')} disabled={!showMetadata}>
<Switch bind:checked={allowDownload} />
</Field>
<Field label={$t('allow_public_user_to_upload')}>
<Switch bind:checked={allowUpload} />
</Field>
</div>
@@ -0,0 +1,41 @@
<script lang="ts">
import OnEvents from '$lib/components/OnEvents.svelte';
import VersionAnnouncementModal from '$lib/modals/VersionAnnouncementModal.svelte';
import { user } from '$lib/stores/user.store';
import type { ReleaseEvent } from '$lib/types';
import { getReleaseType, semverToName } from '$lib/utils';
import { modalManager } from '@immich/ui';
let modal = $state<{
onClose: Promise<void>;
close: () => Promise<void>;
}>();
const onReleaseEvent = async (release: ReleaseEvent) => {
if (!release.isAvailable || !$user.isAdmin) {
return;
}
const releaseVersion = semverToName(release.releaseVersion);
const serverVersion = semverToName(release.serverVersion);
const type = getReleaseType(release.serverVersion, release.releaseVersion);
if (type === 'none' || type === 'patch' || localStorage.getItem('appVersion') === releaseVersion) {
return;
}
try {
await modal?.close();
modal = modalManager.open(VersionAnnouncementModal, { serverVersion, releaseVersion });
void modal.onClose.then(() => {
localStorage.setItem('appVersion', releaseVersion);
});
} catch (error) {
console.error('Error [VersionAnnouncementBox]:', error);
}
};
</script>
<OnEvents {onReleaseEvent} />
@@ -105,7 +105,7 @@
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="ISSUER_URL"
label="issuer_url"
bind:value={configToEdit.oauth.issuerUrl}
required={true}
disabled={disabled || !configToEdit.oauth.enabled}
@@ -114,7 +114,7 @@
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="CLIENT_ID"
label="client_id"
bind:value={configToEdit.oauth.clientId}
required={true}
disabled={disabled || !configToEdit.oauth.enabled}
@@ -123,7 +123,7 @@
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="CLIENT_SECRET"
label="client_secret"
description={$t('admin.oauth_client_secret_description')}
bind:value={configToEdit.oauth.clientSecret}
disabled={disabled || !configToEdit.oauth.enabled}
@@ -132,7 +132,7 @@
{#if configToEdit.oauth.clientSecret}
<SettingSelect
label="TOKEN_ENDPOINT_AUTH_METHOD"
label="token_endpoint_auth_method"
bind:value={configToEdit.oauth.tokenEndpointAuthMethod}
disabled={disabled || !configToEdit.oauth.enabled || !configToEdit.oauth.clientSecret}
isEdited={!(configToEdit.oauth.tokenEndpointAuthMethod === config.oauth.tokenEndpointAuthMethod)}
@@ -146,7 +146,7 @@
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="SCOPE"
label="scope"
bind:value={configToEdit.oauth.scope}
required={true}
disabled={disabled || !configToEdit.oauth.enabled}
@@ -155,7 +155,7 @@
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="ID_TOKEN_SIGNED_RESPONSE_ALG"
label="id_token_signed_response_alg"
bind:value={configToEdit.oauth.signingAlgorithm}
required={true}
disabled={disabled || !configToEdit.oauth.enabled}
@@ -164,7 +164,7 @@
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="USERINFO_SIGNED_RESPONSE_ALG"
label="userinfo_signed_response_alg"
bind:value={configToEdit.oauth.profileSigningAlgorithm}
required={true}
disabled={disabled || !configToEdit.oauth.enabled}
@@ -37,6 +37,11 @@
name="format"
isEdited={configToEdit.image.thumbnail.format !== config.image.thumbnail.format}
{disabled}
onSelect={(value) => {
if (value === ImageFormat.Webp) {
configToEdit.image.thumbnail.progressive = false;
}
}}
/>
<SettingSelect
@@ -64,6 +69,15 @@
isEdited={configToEdit.image.thumbnail.quality !== config.image.thumbnail.quality}
{disabled}
/>
<SettingSwitch
title={$t('admin.image_progressive')}
subtitle={$t('admin.image_progressive_description')}
checked={configToEdit.image.thumbnail.progressive}
onToggle={(isChecked) => (configToEdit.image.thumbnail.progressive = isChecked)}
isEdited={configToEdit.image.thumbnail.progressive !== config.image.thumbnail.progressive}
disabled={disabled || configToEdit.image.thumbnail.format === ImageFormat.Webp}
/>
</SettingAccordion>
<SettingAccordion
@@ -82,6 +96,11 @@
name="format"
isEdited={configToEdit.image.preview.format !== config.image.preview.format}
{disabled}
onSelect={(value) => {
if (value === ImageFormat.Webp) {
configToEdit.image.preview.progressive = false;
}
}}
/>
<SettingSelect
@@ -108,6 +127,15 @@
isEdited={configToEdit.image.preview.quality !== config.image.preview.quality}
{disabled}
/>
<SettingSwitch
title={$t('admin.image_progressive')}
subtitle={$t('admin.image_progressive_description')}
checked={configToEdit.image.preview.progressive}
onToggle={(isChecked) => (configToEdit.image.preview.progressive = isChecked)}
isEdited={configToEdit.image.preview.progressive !== config.image.preview.progressive}
disabled={disabled || configToEdit.image.preview.format === ImageFormat.Webp}
/>
</SettingAccordion>
<SettingAccordion
@@ -137,6 +165,11 @@
name="format"
isEdited={configToEdit.image.fullsize.format !== config.image.fullsize.format}
disabled={disabled || !configToEdit.image.fullsize.enabled}
onSelect={(value) => {
if (value === ImageFormat.Webp) {
configToEdit.image.fullsize.progressive = false;
}
}}
/>
<SettingInputField
@@ -147,6 +180,17 @@
isEdited={configToEdit.image.fullsize.quality !== config.image.fullsize.quality}
disabled={disabled || !configToEdit.image.fullsize.enabled}
/>
<SettingSwitch
title={$t('admin.image_progressive')}
subtitle={$t('admin.image_progressive_description')}
checked={configToEdit.image.fullsize.progressive}
onToggle={(isChecked) => (configToEdit.image.fullsize.progressive = isChecked)}
isEdited={configToEdit.image.fullsize.progressive !== config.image.fullsize.progressive}
disabled={disabled ||
!configToEdit.image.fullsize.enabled ||
configToEdit.image.fullsize.format === ImageFormat.Webp}
/>
</SettingAccordion>
<div class="mt-4">
@@ -1,5 +1,5 @@
import AlbumCover from '$lib/components/album-page/album-cover.svelte';
import { getAssetThumbnailUrl } from '$lib/utils';
import { getAssetMediaUrl } from '$lib/utils';
import { albumFactory } from '@test-data/factories/album-factory';
import { render } from '@testing-library/svelte';
@@ -7,7 +7,7 @@ vi.mock('$lib/utils');
describe('AlbumCover component', () => {
it('renders an image when the album has a thumbnail', () => {
vi.mocked(getAssetThumbnailUrl).mockReturnValue('/asdf');
vi.mocked(getAssetMediaUrl).mockReturnValue('/asdf');
const component = render(AlbumCover, {
album: albumFactory.build({
albumName: 'someName',
@@ -21,7 +21,7 @@ describe('AlbumCover component', () => {
expect(img.getAttribute('loading')).toBe('lazy');
expect(img.className).toBe('size-full rounded-xl object-cover aspect-square text');
expect(img.getAttribute('src')).toBe('/asdf');
expect(getAssetThumbnailUrl).toHaveBeenCalledWith({ id: '123' });
expect(getAssetMediaUrl).toHaveBeenCalledWith({ id: '123' });
});
it('renders an image when the album has no thumbnail', () => {
@@ -1,8 +1,8 @@
<script lang="ts">
import { getAssetThumbnailUrl } from '$lib/utils';
import { type AlbumResponseDto } from '@immich/sdk';
import NoCover from '$lib/components/sharedlinks-page/covers/no-cover.svelte';
import AssetCover from '$lib/components/sharedlinks-page/covers/asset-cover.svelte';
import NoCover from '$lib/components/sharedlinks-page/covers/no-cover.svelte';
import { getAssetMediaUrl } from '$lib/utils';
import { type AlbumResponseDto } from '@immich/sdk';
import { t } from 'svelte-i18n';
interface Props {
@@ -15,7 +15,7 @@
let alt = $derived(album.albumName || $t('unnamed_album'));
let thumbnailUrl = $derived(
album.albumThumbnailAssetId ? getAssetThumbnailUrl({ id: album.albumThumbnailAssetId }) : null,
album.albumThumbnailAssetId ? getAssetMediaUrl({ id: album.albumThumbnailAssetId }) : null,
);
</script>
@@ -13,7 +13,7 @@
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { mediaQueryManager } from '$lib/stores/media-query-manager.svelte';
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { handlePromiseError } from '$lib/utils';
import { cancelMultiselect } from '$lib/utils/asset-utils';
@@ -114,7 +114,7 @@
<ControlAppBar showBackButton={false}>
{#snippet leading()}
<a data-sveltekit-preload-data="hover" class="ms-4" href="/">
<Logo variant={mobileDevice.maxMd ? 'icon' : 'inline'} class="min-w-10" />
<Logo variant={mediaQueryManager.maxMd ? 'icon' : 'inline'} class="min-w-10" />
</a>
{/snippet}
@@ -1,20 +0,0 @@
<script lang="ts">
import { IconButton } from '@immich/ui';
import { mdiTune } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
onAction: () => void;
}
let { onAction }: Props = $props();
</script>
<IconButton
color="secondary"
shape="round"
variant="ghost"
icon={mdiTune}
aria-label={$t('editor')}
onclick={() => onAction()}
/>
@@ -7,7 +7,7 @@
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { Route } from '$lib/route';
import { locale } from '$lib/stores/preferences.store';
import { getAssetThumbnailUrl } from '$lib/utils';
import { getAssetMediaUrl } from '$lib/utils';
import { getAssetType } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { isTenMinutesApart } from '$lib/utils/timesince';
@@ -142,7 +142,7 @@
<a class="aspect-square w-19 h-19" href={Route.viewAlbumAsset({ albumId, assetId: reaction.assetId })}>
<img
class="rounded-lg w-19 h-19 object-cover"
src={getAssetThumbnailUrl(reaction.assetId)}
src={getAssetMediaUrl({ id: reaction.assetId })}
alt="Profile picture of {reaction.user.name}, who commented on this asset"
/>
</a>
@@ -195,7 +195,7 @@
>
<img
class="rounded-lg w-19 h-19 object-cover"
src={getAssetThumbnailUrl(reaction.assetId)}
src={getAssetMediaUrl({ id: reaction.assetId })}
alt="Profile picture of {reaction.user.name}, who liked this asset"
/>
</a>
@@ -1,7 +1,7 @@
<script lang="ts">
import { SCROLL_PROPERTIES } from '$lib/components/shared-components/album-selection/album-selection-utils';
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { getAssetThumbnailUrl } from '$lib/utils';
import { mediaQueryManager } from '$lib/stores/media-query-manager.svelte';
import { getAssetMediaUrl } from '$lib/utils';
import { normalizeSearchString } from '$lib/utils/string-utils.js';
import { type AlbumResponseDto } from '@immich/sdk';
import { Icon } from '@immich/ui';
@@ -54,7 +54,7 @@
onMultiSelect();
};
let usingMobileDevice = $derived(mobileDevice.pointerCoarse);
let usingMobileDevice = $derived(mediaQueryManager.pointerCoarse);
let mouseOver = $state(false);
const onMouseEnter = () => {
if (usingMobileDevice) {
@@ -134,7 +134,7 @@
<span class="h-16 w-16 shrink-0 rounded-xl bg-slate-300">
{#if album.albumThumbnailAssetId}
<img
src={getAssetThumbnailUrl(album.albumThumbnailAssetId)}
src={getAssetMediaUrl({ id: album.albumThumbnailAssetId })}
alt={album.albumName}
class={['h-full w-full rounded-xl object-cover transition-all duration-300 hover:shadow-lg']}
data-testid="album-image"
@@ -1,3 +1,4 @@
import { getResizeObserverMock } from '$lib/__mocks__/resize-observer.mock';
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';
@@ -8,15 +9,8 @@ import AssetViewerNavBar from './asset-viewer-nav-bar.svelte';
describe('AssetViewerNavBar component', () => {
const additionalProps = {
showCopyButton: false,
showZoomButton: false,
showDownloadButton: false,
showMotionPlayButton: false,
showShareButton: false,
preAction: () => {},
onZoomImage: () => {},
onAction: () => {},
onEdit: () => {},
onPlaySlideshow: () => {},
onClose: () => {},
playOriginalVideo: false,
@@ -27,10 +21,7 @@ describe('AssetViewerNavBar component', () => {
Element.prototype.animate = vi.fn().mockImplementation(() => ({
cancel: () => {},
}));
vi.stubGlobal(
'ResizeObserver',
vi.fn(() => ({ observe: vi.fn(), unobserve: vi.fn(), disconnect: vi.fn() })),
);
vi.stubGlobal('ResizeObserver', getResizeObserverMock());
vi.mock(import('$lib/managers/feature-flags-manager.svelte'), () => {
return {
featureFlagsManager: {
@@ -7,7 +7,6 @@
import AddToStackAction from '$lib/components/asset-viewer/actions/add-to-stack-action.svelte';
import ArchiveAction from '$lib/components/asset-viewer/actions/archive-action.svelte';
import DeleteAction from '$lib/components/asset-viewer/actions/delete-action.svelte';
import EditAction from '$lib/components/asset-viewer/actions/edit-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';
@@ -20,17 +19,13 @@
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 { ProjectionType } from '$lib/constants';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { Route } from '$lib/route';
import { getGlobalActions } from '$lib/services/app.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 { getSharedLink, withoutIcons } 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 {
AssetTypeEnum,
@@ -40,15 +35,12 @@
type PersonResponseDto,
type StackResponseDto,
} from '@immich/sdk';
import { CommandPaletteDefaultProvider, IconButton, type ActionItem } from '@immich/ui';
import { CommandPaletteDefaultProvider, type ActionItem } from '@immich/ui';
import {
mdiArrowLeft,
mdiCompare,
mdiContentCopy,
mdiDotsVertical,
mdiImageSearch,
mdiMagnifyMinusOutline,
mdiMagnifyPlusOutline,
mdiPresentationPlay,
mdiUpload,
mdiVideoOutline,
@@ -61,13 +53,10 @@
person?: PersonResponseDto | null;
stack?: StackResponseDto | null;
showSlideshow?: boolean;
onZoomImage: () => void;
onCopyImage?: () => Promise<void>;
preAction: PreAction;
onAction: OnAction;
onUndoDelete?: OnUndoDelete;
onPlaySlideshow: () => void;
onEdit: () => void;
onClose?: () => void;
playOriginalVideo: boolean;
setPlayOriginalVideo: (value: boolean) => void;
@@ -79,32 +68,29 @@
person = null,
stack = null,
showSlideshow = false,
onZoomImage,
onCopyImage,
preAction,
onAction,
onUndoDelete = undefined,
onPlaySlideshow,
onClose,
onEdit,
playOriginalVideo = false,
setPlayOriginalVideo,
}: Props = $props();
let isOwner = $derived($user && asset.ownerId === $user?.id);
let isLocked = $derived(asset.visibility === AssetVisibility.Locked);
let smartSearchEnabled = $derived(featureFlagsManager.value.smartSearch);
const isOwner = $derived($user && asset.ownerId === $user?.id);
const isLocked = $derived(asset.visibility === AssetVisibility.Locked);
const smartSearchEnabled = $derived(featureFlagsManager.value.smartSearch);
const Close: ActionItem = {
const { Cast } = $derived(getGlobalActions($t));
const Close: ActionItem = $derived({
title: $t('go_back'),
type: $t('assets'),
icon: mdiArrowLeft,
$if: () => !!onClose,
onAction: () => onClose?.(),
shortcuts: [{ key: 'Escape' }],
};
const { Cast } = $derived(getGlobalActions($t));
});
const {
Share,
@@ -116,23 +102,17 @@
Unfavorite,
PlayMotionPhoto,
StopMotionPhoto,
ZoomIn,
ZoomOut,
Copy,
Info,
Edit,
RefreshFacesJob,
RefreshMetadataJob,
RegenerateThumbnailJob,
TranscodeVideoJob,
} = $derived(getAssetActions($t, asset));
const sharedLink = getSharedLink();
const editorDisabled = $derived(
!isOwner ||
asset.type !== AssetTypeEnum.Image ||
asset.livePhotoVideoId ||
(asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR &&
asset.originalPath.toLowerCase().endsWith('.insp')) ||
asset.originalPath.toLowerCase().endsWith('.gif') ||
asset.originalPath.toLowerCase().endsWith('.svg'),
);
</script>
<CommandPaletteDefaultProvider
@@ -149,7 +129,11 @@
Unfavorite,
PlayMotionPhoto,
StopMotionPhoto,
ZoomIn,
ZoomOut,
Copy,
Info,
Edit,
RefreshFacesJob,
RefreshMetadataJob,
RegenerateThumbnailJob,
@@ -170,29 +154,9 @@
<ActionButton action={Offline} />
<ActionButton action={PlayMotionPhoto} />
<ActionButton action={StopMotionPhoto} />
{#if asset.type === AssetTypeEnum.Image}
<IconButton
class="hidden sm:flex"
color="secondary"
variant="ghost"
shape="round"
icon={$photoZoomState && $photoZoomState.currentZoom > 1 ? mdiMagnifyMinusOutline : mdiMagnifyPlusOutline}
aria-label={$t('zoom_image')}
onclick={onZoomImage}
/>
{/if}
{#if canCopyImageToClipboard() && asset.type === AssetTypeEnum.Image && $photoViewerImgElement}
<IconButton
color="secondary"
variant="ghost"
shape="round"
icon={mdiContentCopy}
aria-label={$t('copy_image')}
onclick={() => onCopyImage?.()}
/>
{/if}
<ActionButton action={ZoomIn} />
<ActionButton action={ZoomOut} />
<ActionButton action={Copy} />
<ActionButton action={SharedLinkDownload} />
<ActionButton action={Info} />
<ActionButton action={Favorite} />
@@ -202,9 +166,7 @@
<RatingAction {asset} {onAction} />
{/if}
{#if !editorDisabled}
<EditAction onAction={onEdit} />
{/if}
<ActionButton action={Edit} />
{#if isOwner}
<DeleteAction {asset} {onAction} {preAction} {onUndoDelete} />
@@ -12,20 +12,20 @@
import { authManager } from '$lib/managers/auth-manager.svelte';
import { editManager, EditToolType } from '$lib/managers/edit/edit-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { preloadManager } from '$lib/managers/PreloadManager.svelte';
import { imageManager } from '$lib/managers/ImageManager.svelte';
import { Route } from '$lib/route';
import { getAssetActions } from '$lib/services/asset.service';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { ocrManager } from '$lib/stores/ocr.svelte';
import { alwaysLoadOriginalVideo } from '$lib/stores/preferences.store';
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { user } from '$lib/stores/user.store';
import { getAssetUrl, getSharedLink, handlePromiseError } from '$lib/utils';
import { getSharedLink, handlePromiseError } from '$lib/utils';
import type { OnUndoDelete } from '$lib/utils/actions';
import { navigateToAsset } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { InvocationTracker } from '$lib/utils/invocationTracker';
import { SlideshowHistory } from '$lib/utils/slideshow-history';
import { preloadImageUrl } from '$lib/utils/sw-messaging';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import {
AssetTypeEnum,
@@ -37,6 +37,7 @@
type PersonResponseDto,
type StackResponseDto,
} from '@immich/sdk';
import { CommandPaletteDefaultProvider } from '@immich/ui';
import { onDestroy, onMount, untrack } from 'svelte';
import { t } from 'svelte-i18n';
import { fly } from 'svelte/transition';
@@ -71,7 +72,6 @@
onUndoDelete?: OnUndoDelete;
onClose?: (asset: AssetResponseDto) => void;
onRandom?: () => Promise<{ id: string } | undefined>;
copyImage?: () => Promise<void>;
}
let {
@@ -87,7 +87,6 @@
onUndoDelete,
onClose,
onRandom,
copyImage = $bindable(),
}: Props = $props();
const { setAssetId } = assetViewingStore;
@@ -97,6 +96,7 @@
slideshowNavigation,
slideshowState,
slideshowTransition,
slideshowRepeat,
} = slideshowStore;
const stackThumbnailSize = 60;
const stackSelectedThumbnailSize = 65;
@@ -107,13 +107,12 @@
let appearsInAlbums: AlbumResponseDto[] = $state([]);
let sharedLink = getSharedLink();
let previewStackedAsset: AssetResponseDto | undefined = $state();
let isShowEditor = $state(false);
let fullscreenElement = $state<Element>();
let unsubscribes: (() => void)[] = [];
let stack: StackResponseDto | null = $state(null);
let zoomToggle = $state(() => void 0);
let playOriginalVideo = $state($alwaysLoadOriginalVideo);
let slideshowStartAssetId = $state<string>();
const setPlayOriginalVideo = (value: boolean) => {
playOriginalVideo = value;
@@ -133,9 +132,7 @@
}
untrack(() => {
if (stack && stack?.assets.length > 1) {
preloadImageUrl(getAssetUrl({ asset: stack.assets[1] }));
}
imageManager.preload(stack?.assets[1]);
});
};
@@ -179,6 +176,7 @@
}
activityManager.reset();
assetViewerManager.closeEditor();
});
const handleGetAllAlbums = async () => {
@@ -199,13 +197,11 @@
const closeEditor = async () => {
if (editManager.hasAppliedEdits) {
console.log(asset);
const refreshedAsset = await getAssetInfo({ id: asset.id });
console.log(refreshedAsset);
onAssetChange?.(refreshedAsset);
assetViewingStore.setAsset(refreshedAsset);
}
isShowEditor = false;
assetViewerManager.closeEditor();
};
const tracker = new InvocationTracker();
@@ -220,7 +216,7 @@
}
e?.stopPropagation();
preloadManager.cancel(asset);
imageManager.cancel(asset);
if (tracker.isActive()) {
return;
}
@@ -245,6 +241,10 @@
if ($slideshowState === SlideshowState.PlaySlideshow) {
if (hasNext) {
$restartSlideshowProgress = true;
} else if ($slideshowRepeat && slideshowStartAssetId) {
// Loop back to starting asset
await setAssetId(slideshowStartAssetId);
$restartSlideshowProgress = true;
} else {
await handleStopSlideshow();
}
@@ -252,13 +252,6 @@
}, $t('error_while_navigating'));
};
const showEditor = () => {
if (assetViewerManager.isShowActivityPanel) {
assetViewerManager.isShowActivityPanel = false;
}
isShowEditor = !isShowEditor;
};
/**
* Slide show mode
*/
@@ -276,6 +269,7 @@
};
const handlePlaySlideshow = async () => {
slideshowStartAssetId = asset.id;
try {
await assetViewerHtmlElement?.requestFullscreen?.();
} catch (error) {
@@ -380,8 +374,8 @@
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
asset;
untrack(() => handlePromiseError(refresh()));
preloadManager.preload(cursor.nextAsset);
preloadManager.preload(cursor.previousAsset);
imageManager.preload(cursor.nextAsset);
imageManager.preload(cursor.previousAsset);
});
const onAssetReplace = async ({ oldAssetId, newAssetId }: { oldAssetId: string; newAssetId: string }) => {
@@ -415,7 +409,7 @@
) {
return 'ImagePanaramaViewer';
}
if (isShowEditor && editManager.selectedTool?.type === EditToolType.Transform) {
if (assetViewerManager.isShowEditor && editManager.selectedTool?.type === EditToolType.Transform) {
return 'CropArea';
}
return 'PhotoViewer';
@@ -432,11 +426,14 @@
$slideshowState === SlideshowState.None &&
asset.type === AssetTypeEnum.Image &&
!(asset.exifInfo?.projectionType === 'EQUIRECTANGULAR') &&
!isShowEditor &&
!assetViewerManager.isShowEditor &&
ocrManager.hasOcrData,
);
const { Tag } = $derived(getAssetActions($t, asset));
</script>
<CommandPaletteDefaultProvider name={$t('assets')} actions={[Tag]} />
<OnEvents {onAssetReplace} {onAssetUpdate} />
<svelte:document bind:fullscreenElement />
@@ -448,7 +445,7 @@
bind:this={assetViewerHtmlElement}
>
<!-- Top navigation bar -->
{#if $slideshowState === SlideshowState.None && !isShowEditor}
{#if $slideshowState === SlideshowState.None && !assetViewerManager.isShowEditor}
<div class="col-span-4 col-start-1 row-span-1 row-start-1 transition-transform">
<AssetViewerNavBar
{asset}
@@ -456,12 +453,9 @@
{person}
{stack}
showSlideshow={true}
onZoomImage={zoomToggle}
onCopyImage={copyImage}
preAction={handlePreAction}
onAction={handleAction}
{onUndoDelete}
onEdit={showEditor}
onPlaySlideshow={() => ($slideshowState = SlideshowState.PlaySlideshow)}
onClose={onClose ? () => onClose(asset) : undefined}
{playOriginalVideo}
@@ -483,7 +477,7 @@
</div>
{/if}
{#if $slideshowState === SlideshowState.None && showNavigation && !isShowEditor && previousAsset}
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && previousAsset}
<div class="my-auto col-span-1 col-start-1 row-span-full row-start-1 justify-self-start">
<PreviousAssetAction onPreviousAsset={() => navigateAsset('previous')} />
</div>
@@ -493,8 +487,6 @@
<div class="z-[-1] relative col-start-1 col-span-4 row-start-1 row-span-full">
{#if viewerKind === 'StackPhotoViewer'}
<PhotoViewer
bind:zoomToggle
bind:copyImage
cursor={{ ...cursor, current: previewStackedAsset! }}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
@@ -503,7 +495,7 @@
/>
{:else if viewerKind === 'StackVideoViewer'}
<VideoViewer
assetId={previewStackedAsset!.id}
asset={previewStackedAsset!}
cacheKey={previewStackedAsset!.thumbhash}
projectionType={previewStackedAsset!.exifInfo?.projectionType}
loopVideo={true}
@@ -516,6 +508,7 @@
/>
{:else if viewerKind === 'LiveVideoViewer'}
<VideoViewer
{asset}
assetId={asset.livePhotoVideoId!}
cacheKey={asset.thumbhash}
projectionType={asset.exifInfo?.projectionType}
@@ -526,13 +519,11 @@
{playOriginalVideo}
/>
{:else if viewerKind === 'ImagePanaramaViewer'}
<ImagePanoramaViewer bind:zoomToggle {asset} />
<ImagePanoramaViewer {asset} />
{:else if viewerKind === 'CropArea'}
<CropArea {asset} />
{:else if viewerKind === 'PhotoViewer'}
<PhotoViewer
bind:zoomToggle
bind:copyImage
{cursor}
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
@@ -541,7 +532,7 @@
/>
{:else if viewerKind === 'VideoViewer'}
<VideoViewer
assetId={asset.id}
{asset}
cacheKey={asset.thumbhash}
projectionType={asset.exifInfo?.projectionType}
loopVideo={$slideshowState !== SlideshowState.PlaySlideshow}
@@ -573,13 +564,13 @@
{/if}
</div>
{#if $slideshowState === SlideshowState.None && showNavigation && !isShowEditor && nextAsset}
{#if $slideshowState === SlideshowState.None && showNavigation && !assetViewerManager.isShowEditor && nextAsset}
<div class="my-auto col-span-1 col-start-4 row-span-full row-start-1 justify-self-end">
<NextAssetAction onNextAsset={() => navigateAsset('next')} />
</div>
{/if}
{#if asset.hasMetadata && $slideshowState === SlideshowState.None && assetViewerManager.isShowDetailPanel && !isShowEditor}
{#if asset.hasMetadata && $slideshowState === SlideshowState.None && assetViewerManager.isShowDetailPanel && !assetViewerManager.isShowEditor}
<div
transition:fly={{ duration: 150 }}
id="detail-panel"
@@ -590,7 +581,7 @@
</div>
{/if}
{#if isShowEditor}
{#if assetViewerManager.isShowEditor}
<div
transition:fly={{ duration: 150 }}
id="editor-panel"
@@ -601,7 +592,7 @@
</div>
{/if}
{#if stack && withStacked}
{#if stack && withStacked && !assetViewerManager.isShowEditor}
{@const stackedAssets = stack.assets}
<div id="stack-slideshow" class="absolute bottom-0 w-full col-span-4 col-start-1 pointer-events-none">
<div class="relative flex flex-row no-wrap overflow-x-auto overflow-y-hidden horizontal-scrollbar">
@@ -1,12 +1,13 @@
<script lang="ts">
import { shortcut } from '$lib/actions/shortcut';
import HeaderActionButton from '$lib/components/HeaderActionButton.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import AssetTagModal from '$lib/modals/AssetTagModal.svelte';
import { Route } from '$lib/route';
import { getAssetActions } from '$lib/services/asset.service';
import { removeTag } from '$lib/utils/asset-utils';
import { getAssetInfo, type AssetResponseDto } from '@immich/sdk';
import { Icon, modalManager, Text } from '@immich/ui';
import { mdiClose, mdiPlus } from '@mdi/js';
import { Badge, IconButton, Link, Text } from '@immich/ui';
import { mdiClose } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
@@ -18,22 +19,23 @@
let tags = $derived(asset.tags || []);
const handleAddTag = async () => {
const success = await modalManager.show(AssetTagModal, { assetIds: [asset.id] });
if (success) {
asset = await getAssetInfo({ id: asset.id });
}
};
const handleRemove = async (tagId: string) => {
const ids = await removeTag({ tagIds: [tagId], assetIds: [asset.id], showNotification: false });
if (ids) {
asset = await getAssetInfo({ id: asset.id });
}
};
const onAssetsTag = async (ids: string[]) => {
if (ids.includes(asset.id)) {
asset = await getAssetInfo({ id: asset.id });
}
};
const { Tag } = $derived(getAssetActions($t, asset));
</script>
<svelte:document use:shortcut={{ shortcut: { key: 't' }, onShortcut: handleAddTag }} />
<OnEvents {onAssetsTag} />
{#if isOwner && !authManager.isSharedLink}
<section class="px-4 mt-4">
@@ -42,36 +44,24 @@
</div>
<section class="flex flex-wrap pt-2 gap-1" data-testid="detail-panel-tags">
{#each tags as tag (tag.id)}
<div class="flex group transition-all">
<a
class="inline-block h-min whitespace-nowrap ps-3 pe-1 group-hover:ps-3 py-1 text-center align-baseline leading-none text-gray-100 dark:text-immich-dark-gray bg-primary rounded-s-full hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
<Badge size="small" class="items-center px-0" shape="round">
<Link
href={Route.tags({ path: tag.value })}
class="text-light no-underline rounded-full hover:bg-primary-400 px-2"
>
<p class="text-sm">
{tag.value}
</p>
</a>
<button
type="button"
class="text-gray-100 dark:text-immich-dark-gray bg-immich-primary/95 dark:bg-immich-dark-primary/95 rounded-e-full place-items-center place-content-center pe-2 ps-1 py-1 hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
title="Remove tag"
{tag.value}
</Link>
<IconButton
aria-label={$t('remove_tag')}
icon={mdiClose}
onclick={() => handleRemove(tag.id)}
>
<Icon icon={mdiClose} />
</button>
</div>
size="tiny"
class="hover:bg-primary-400"
shape="round"
/>
</Badge>
{/each}
<button
type="button"
class="rounded-full bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 hover:text-gray-700 dark:hover:text-gray-200 flex place-items-center place-content-center gap-1 px-2 py-1"
title={$t('add_tag')}
onclick={handleAddTag}
>
<span class="text-sm px-1 flex place-items-center place-content-center gap-1"
><Icon icon={mdiPlus} />{$t('add')}</span
>
</button>
<HeaderActionButton action={Tag} />
</section>
</section>
{/if}
@@ -14,7 +14,7 @@
import { boundingBoxesArray } from '$lib/stores/people.store';
import { locale } from '$lib/stores/preferences.store';
import { preferences, user } from '$lib/stores/user.store';
import { getAssetThumbnailUrl, getPeopleThumbnailUrl } from '$lib/utils';
import { getAssetMediaUrl, getPeopleThumbnailUrl } from '$lib/utils';
import { delay, getDimensions } from '$lib/utils/asset-utils';
import { getByteUnitString } from '$lib/utils/byte-units';
import { fromISODateTime, fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
@@ -515,7 +515,7 @@
alt={album.albumName}
class="h-12.5 w-12.5 rounded object-cover"
src={album.albumThumbnailAssetId &&
getAssetThumbnailUrl({ id: album.albumThumbnailAssetId, size: AssetMediaSize.Preview })}
getAssetMediaUrl({ id: album.albumThumbnailAssetId, size: AssetMediaSize.Preview })}
draggable="false"
/>
</div>
@@ -75,7 +75,7 @@
<Button
variant="outline"
onclick={() => editManager.resetAllChanges()}
disabled={!editManager.hasChanges}
disabled={!editManager.canReset}
class="self-start"
shape="round"
size="small"
@@ -0,0 +1,39 @@
import { getResizeObserverMock } from '$lib/__mocks__/resize-observer.mock';
import CropArea from '$lib/components/asset-viewer/editor/transform-tool/crop-area.svelte';
import { transformManager } from '$lib/managers/edit/transform-manager.svelte';
import { getAssetMediaUrl } from '$lib/utils';
import { assetFactory } from '@test-data/factories/asset-factory';
import { render } from '@testing-library/svelte';
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
vi.mock('$lib/utils');
describe('CropArea', () => {
beforeAll(() => {
vi.stubGlobal('ResizeObserver', getResizeObserverMock());
vi.mocked(getAssetMediaUrl).mockReturnValue('/mock-image.jpg');
});
afterEach(() => {
transformManager.reset();
});
it('clears cursor styles on reset', () => {
const asset = assetFactory.build();
const { getByRole } = render(CropArea, { asset });
const cropArea = getByRole('button', { name: 'Crop area' });
transformManager.region = { x: 100, y: 100, width: 200, height: 200 };
transformManager.cropImageSize = { width: 1000, height: 1000 };
transformManager.cropImageScale = 1;
transformManager.updateCursor(100, 150);
expect(document.body.style.cursor).toBe('ew-resize');
expect(cropArea.style.cursor).toBe('ew-resize');
transformManager.reset();
expect(document.body.style.cursor).toBe('');
expect(cropArea.style.cursor).toBe('');
});
});
@@ -1,6 +1,6 @@
<script lang="ts">
import { transformManager } from '$lib/managers/edit/transform-manager.svelte';
import { getAssetThumbnailUrl } from '$lib/utils';
import { getAssetMediaUrl } from '$lib/utils';
import { getAltText } from '$lib/utils/thumbnail-util';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { AssetMediaSize, type AssetResponseDto } from '@immich/sdk';
@@ -14,7 +14,7 @@
let canvasContainer = $state<HTMLElement | null>(null);
let imageSrc = $derived(
getAssetThumbnailUrl({ id: asset.id, cacheKey: asset.thumbhash, edited: false, size: AssetMediaSize.Preview }),
getAssetMediaUrl({ id: asset.id, cacheKey: asset.thumbhash, edited: false, size: AssetMediaSize.Preview }),
);
let imageTransform = $derived.by(() => {
@@ -1,7 +1,6 @@
<script lang="ts">
import { authManager } from '$lib/managers/auth-manager.svelte';
import { getAssetOriginalUrl, getAssetThumbnailUrl } from '$lib/utils';
import { isWebCompatibleImage } from '$lib/utils/asset-utils';
import { getAssetUrl } from '$lib/utils';
import { AssetMediaSize, viewAsset, type AssetResponseDto } from '@immich/sdk';
import { LoadingSpinner } from '@immich/ui';
import { t } from 'svelte-i18n';
@@ -9,10 +8,9 @@
type Props = {
asset: AssetResponseDto;
zoomToggle?: (() => void) | null;
};
let { asset, zoomToggle = $bindable() }: Props = $props();
let { asset }: Props = $props();
const loadAssetData = async (id: string) => {
const data = await viewAsset({ ...authManager.params, id, size: AssetMediaSize.Preview });
@@ -24,13 +22,7 @@
{#await Promise.all([loadAssetData(asset.id), import('./photo-sphere-viewer-adapter.svelte')])}
<LoadingSpinner />
{:then [data, { default: PhotoSphereViewer }]}
<PhotoSphereViewer
bind:zoomToggle
panorama={data}
originalPanorama={isWebCompatibleImage(asset)
? getAssetOriginalUrl(asset.id)
: getAssetThumbnailUrl({ id: asset.id, size: AssetMediaSize.Fullsize, cacheKey: asset.thumbhash })}
/>
<PhotoSphereViewer panorama={data} originalPanorama={getAssetUrl({ asset, forceOriginal: true })} />
{:catch}
{$t('errors.failed_to_load_asset')}
{/await}
@@ -1,8 +1,9 @@
<script lang="ts">
import { shortcuts } from '$lib/actions/shortcut';
import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
import { photoZoomState } from '$lib/stores/zoom-image.store';
import {
EquirectangularAdapter,
Viewer,
@@ -32,17 +33,9 @@
adapter?: AdapterConstructor | [AdapterConstructor, unknown];
plugins?: (PluginConstructor | [PluginConstructor, unknown])[];
navbar?: boolean;
zoomToggle?: (() => void) | null;
};
let {
panorama,
originalPanorama,
adapter = EquirectangularAdapter,
plugins = [],
navbar = false,
zoomToggle = $bindable(),
}: Props = $props();
let { panorama, originalPanorama, adapter = EquirectangularAdapter, plugins = [], navbar = false }: Props = $props();
let container: HTMLDivElement | undefined = $state();
let viewer: Viewer;
@@ -103,11 +96,8 @@
}
});
zoomToggle = () => {
if (!viewer) {
return;
}
viewer.animate({ zoom: $photoZoomState.currentZoom > 1 ? 50 : 83.3, speed: 250 });
const onZoom = () => {
viewer?.animate({ zoom: assetViewerManager.zoom > 1 ? 50 : 83.3, speed: 250 });
};
let hasChangedResolution: boolean = false;
@@ -156,11 +146,8 @@
});
const resolutionPlugin = viewer.getPlugin<ResolutionPlugin>(ResolutionPlugin);
const zoomHandler = ({ zoomLevel }: events.ZoomUpdatedEvent) => {
// zoomLevel range: [0, 100]
photoZoomState.set({
...$photoZoomState,
currentZoom: zoomLevel / 50,
});
// zoomLevel is 0-100
assetViewerManager.zoom = zoomLevel / 50;
if (Math.round(zoomLevel) >= 75 && !hasChangedResolution) {
// Replace the preview with the original
@@ -181,13 +168,11 @@
viewer.destroy();
}
boundingBoxesUnsubscribe();
// zoomHandler is not called on initial load. Viewer initial zoom is 1, but photoZoomState could be != 1.
photoZoomState.set({
...$photoZoomState,
currentZoom: 1,
});
assetViewerManager.zoom = 1;
});
</script>
<svelte:document use:shortcuts={[{ shortcut: { key: 'z' }, onShortcut: zoomToggle, preventDefault: true }]} />
<AssetViewerEvents {onZoom} />
<svelte:document use:shortcuts={[{ shortcut: { key: 'z' }, onShortcut: onZoom, preventDefault: true }]} />
<div class="h-full w-full mb-0" bind:this={container}></div>
@@ -4,15 +4,15 @@
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
import OcrBoundingBox from '$lib/components/asset-viewer/ocr-bounding-box.svelte';
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte';
import { assetViewerFadeDuration } from '$lib/constants';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { castManager } from '$lib/managers/cast-manager.svelte';
import { preloadManager } from '$lib/managers/PreloadManager.svelte';
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
import { imageManager } from '$lib/managers/ImageManager.svelte';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { ocrManager } from '$lib/stores/ocr.svelte';
import { boundingBoxesArray } from '$lib/stores/people.store';
import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store';
import { photoZoomState } from '$lib/stores/zoom-image.store';
import { getAssetUrl, targetImageSize as getTargetImageSize, handlePromiseError } from '$lib/utils';
import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
@@ -35,8 +35,6 @@
sharedLink?: SharedLinkResponseDto | undefined;
onPreviousAsset?: (() => void) | null;
onNextAsset?: (() => void) | null;
copyImage?: () => Promise<void>;
zoomToggle?: (() => void) | null;
}
let {
@@ -46,8 +44,6 @@
sharedLink = undefined,
onPreviousAsset = null,
onNextAsset = null,
copyImage = $bindable(),
zoomToggle = $bindable(),
}: Props = $props();
const { slideshowState, slideshowLook } = slideshowStore;
@@ -59,12 +55,9 @@
let loader = $state<HTMLImageElement>();
photoZoomState.set({
currentRotation: 0,
currentZoom: 1,
enable: true,
currentPositionX: 0,
currentPositionY: 0,
$effect.pre(() => {
void asset.id;
untrack(() => assetViewerManager.resetZoomState());
});
onDestroy(() => {
@@ -72,51 +65,50 @@
});
let ocrBoxes = $derived(
ocrManager.showOverlay && $photoViewerImgElement
? getOcrBoundingBoxes(ocrManager.data, $photoZoomState, $photoViewerImgElement)
ocrManager.showOverlay && assetViewerManager.imgRef
? getOcrBoundingBoxes(ocrManager.data, assetViewerManager.zoomState, assetViewerManager.imgRef)
: [],
);
let isOcrActive = $derived(ocrManager.showOverlay);
copyImage = async () => {
if (!canCopyImageToClipboard() || !$photoViewerImgElement) {
const onCopy = async () => {
if (!canCopyImageToClipboard() || !assetViewerManager.imgRef) {
return;
}
try {
await copyImageToClipboard($photoViewerImgElement);
await copyImageToClipboard(assetViewerManager.imgRef);
toastManager.info($t('copied_image_to_clipboard'));
} catch (error) {
handleError(error, $t('copy_error'));
}
};
zoomToggle = () => {
photoZoomState.set({
...$photoZoomState,
currentZoom: $photoZoomState.currentZoom > 1 ? 1 : 2,
});
const onZoom = () => {
assetViewerManager.zoom = assetViewerManager.zoom > 1 ? 1 : 2;
};
const onPlaySlideshow = () => ($slideshowState = SlideshowState.PlaySlideshow);
$effect(() => {
if (isFaceEditMode.value && $photoZoomState.currentZoom > 1) {
zoomToggle();
if (isFaceEditMode.value && assetViewerManager.zoom > 1) {
onZoom();
}
});
// TODO move to action + command palette
const onCopyShortcut = (event: KeyboardEvent) => {
if (globalThis.getSelection()?.type === 'Range') {
return;
}
event.preventDefault();
handlePromiseError(copyImage());
handlePromiseError(onCopy());
};
const onSwipe = (event: SwipeCustomEvent) => {
if ($photoZoomState.currentZoom > 1) {
if (assetViewerManager.zoom > 1) {
return;
}
@@ -133,7 +125,7 @@
}
};
const targetImageSize = $derived(getTargetImageSize(asset, originalImageLoaded || $photoZoomState.currentZoom > 1));
const targetImageSize = $derived(getTargetImageSize(asset, originalImageLoaded || assetViewerManager.zoom > 1));
$effect(() => {
if (imageLoaderUrl) {
@@ -164,10 +156,10 @@
imageError = imageLoaded = true;
};
onDestroy(() => preloadManager.cancelPreloadUrl(imageLoaderUrl));
onDestroy(() => imageManager.cancelPreloadUrl(imageLoaderUrl));
let imageLoaderUrl = $derived(
getAssetUrl({ asset, sharedLink, forceOriginal: originalImageLoaded || $photoZoomState.currentZoom > 1 }),
getAssetUrl({ asset, sharedLink, forceOriginal: originalImageLoaded || assetViewerManager.zoom > 1 }),
);
let containerWidth = $state(0);
@@ -187,13 +179,14 @@
});
</script>
<AssetViewerEvents {onCopy} {onZoom} />
<svelte:document
use:shortcuts={[
{ shortcut: { key: 'z' }, onShortcut: zoomToggle, preventDefault: true },
{ shortcut: { key: 'z' }, onShortcut: onZoom, preventDefault: true },
{ shortcut: { key: 's' }, onShortcut: onPlaySlideshow, preventDefault: true },
{ shortcut: { key: 'c', ctrl: true }, onShortcut: onCopyShortcut, preventDefault: false },
{ shortcut: { key: 'c', meta: true }, onShortcut: onCopyShortcut, preventDefault: false },
{ shortcut: { key: 'z' }, onShortcut: zoomToggle, preventDefault: false },
]}
/>
{#if imageError}
@@ -228,7 +221,7 @@
/>
{/if}
<img
bind:this={$photoViewerImgElement}
bind:this={assetViewerManager.imgRef}
src={imageLoaderUrl}
alt={$getAltText(toTimelineAsset(asset))}
class="h-full w-full {$slideshowState === SlideshowState.None
@@ -237,7 +230,7 @@
draggable="false"
/>
<!-- eslint-disable-next-line svelte/require-each-key -->
{#each getBoundingBox($boundingBoxesArray, $photoZoomState, $photoViewerImgElement) as boundingbox}
{#each getBoundingBox($boundingBoxesArray, assetViewerManager.zoomState, assetViewerManager.imgRef) as boundingbox}
<div
class="absolute border-solid border-white border-3 rounded-lg"
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
@@ -250,7 +243,7 @@
</div>
{#if isFaceEditMode.value}
<FaceEditor htmlElement={$photoViewerImgElement} {containerWidth} {containerHeight} assetId={asset.id} />
<FaceEditor htmlElement={assetViewerManager.imgRef} {containerWidth} {containerHeight} assetId={asset.id} />
{/if}
{/if}
</div>
@@ -10,7 +10,7 @@
videoViewerMuted,
videoViewerVolume,
} from '$lib/stores/preferences.store';
import { getAssetOriginalUrl, getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils';
import { getAssetMediaUrl, getAssetPlaybackUrl } from '$lib/utils';
import { AssetMediaSize } from '@immich/sdk';
import { LoadingSpinner } from '@immich/ui';
import { onDestroy, onMount } from 'svelte';
@@ -44,7 +44,9 @@
let videoPlayer: HTMLVideoElement | undefined = $state();
let isLoading = $state(true);
let assetFileUrl = $derived(
playOriginalVideo ? getAssetOriginalUrl({ id: assetId, cacheKey }) : getAssetPlaybackUrl({ id: assetId, cacheKey }),
playOriginalVideo
? getAssetMediaUrl({ id: assetId, size: AssetMediaSize.Original, cacheKey })
: getAssetPlaybackUrl({ id: assetId, cacheKey }),
);
let isScrubbing = $state(false);
let showVideo = $state(false);
@@ -127,7 +129,7 @@
{#if castManager.isCasting}
<div class="place-content-center h-full place-items-center">
<VideoRemoteViewer
poster={getAssetThumbnailUrl({ id: assetId, size: AssetMediaSize.Preview, cacheKey })}
poster={getAssetMediaUrl({ id: assetId, size: AssetMediaSize.Preview, cacheKey })}
{onVideoStarted}
{onVideoEnded}
{assetFileUrl}
@@ -154,7 +156,7 @@
onclose={() => onClose()}
muted={$videoViewerMuted}
bind:volume={$videoViewerVolume}
poster={getAssetThumbnailUrl({ id: assetId, size: AssetMediaSize.Preview, cacheKey })}
poster={getAssetMediaUrl({ id: assetId, size: AssetMediaSize.Preview, cacheKey })}
src={assetFileUrl}
>
</video>
@@ -1,14 +1,15 @@
<script lang="ts">
import { getAssetOriginalUrl, getAssetPlaybackUrl } from '$lib/utils';
import { getAssetPlaybackUrl, getAssetUrl } from '$lib/utils';
import type { AssetResponseDto } from '@immich/sdk';
import { LoadingSpinner } from '@immich/ui';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
interface Props {
assetId: string;
asset: AssetResponseDto;
}
const { assetId }: Props = $props();
const { asset }: Props = $props();
const modules = Promise.all([
import('./photo-sphere-viewer-adapter.svelte').then((module) => module.default),
@@ -23,8 +24,8 @@
<LoadingSpinner />
{:then [PhotoSphereViewer, adapter, videoPlugin]}
<PhotoSphereViewer
panorama={{ source: getAssetPlaybackUrl(assetId) }}
originalPanorama={{ source: getAssetOriginalUrl(assetId) }}
panorama={{ source: getAssetPlaybackUrl({ id: asset.id }) }}
originalPanorama={{ source: getAssetUrl({ asset, forceOriginal: true })! }}
plugins={[videoPlugin]}
{adapter}
navbar
@@ -2,9 +2,11 @@
import VideoNativeViewer from '$lib/components/asset-viewer/video-native-viewer.svelte';
import VideoPanoramaViewer from '$lib/components/asset-viewer/video-panorama-viewer.svelte';
import { ProjectionType } from '$lib/constants';
import type { AssetResponseDto } from '@immich/sdk';
interface Props {
assetId: string;
asset: AssetResponseDto;
assetId?: string;
projectionType: string | null | undefined;
cacheKey: string | null;
loopVideo: boolean;
@@ -17,6 +19,7 @@
}
let {
asset,
assetId,
projectionType,
cacheKey,
@@ -28,15 +31,17 @@
onVideoEnded,
onVideoStarted,
}: Props = $props();
const effectiveAssetId = $derived(assetId ?? asset.id);
</script>
{#if projectionType === ProjectionType.EQUIRECTANGULAR}
<VideoPanoramaViewer {assetId} />
<VideoPanoramaViewer {asset} />
{:else}
<VideoNativeViewer
{loopVideo}
{cacheKey}
{assetId}
assetId={effectiveAssetId}
{playOriginalVideo}
{onPreviousAsset}
{onNextAsset}
@@ -1,6 +1,6 @@
<script lang="ts">
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
import { preloadManager } from '$lib/managers/PreloadManager.svelte';
import { imageManager } from '$lib/managers/ImageManager.svelte';
import { Icon } from '@immich/ui';
import { mdiEyeOffOutline } from '@mdi/js';
import type { ActionReturn } from 'svelte/action';
@@ -60,7 +60,7 @@
onComplete?.(false);
}
return {
destroy: () => preloadManager.cancelPreloadUrl(url),
destroy: () => imageManager.cancelPreloadUrl(url),
};
}
@@ -1,10 +1,18 @@
<script lang="ts">
import { thumbhash } from '$lib/actions/thumbhash';
import { ProjectionType } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { mediaQueryManager } from '$lib/stores/media-query-manager.svelte';
import { locale, playVideoThumbnailOnHover } from '$lib/stores/preferences.store';
import { getAssetOriginalUrl, getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils';
import { getAssetMediaUrl, getAssetPlaybackUrl } from '$lib/utils';
import { timeToSeconds } from '$lib/utils/date-time';
import { moveFocus } from '$lib/utils/focus-util';
import { currentUrlReplaceAssetId } from '$lib/utils/navigation';
import { getAltText } from '$lib/utils/thumbnail-util';
import { TUNABLES } from '$lib/utils/tunables';
import { AssetMediaSize, AssetVisibility, type UserResponseDto } from '@immich/sdk';
import { Icon } from '@immich/ui';
import {
mdiArchiveArrowDownOutline,
mdiCameraBurst,
@@ -15,21 +23,11 @@
mdiMotionPlayOutline,
mdiRotate360,
} from '@mdi/js';
import { thumbhash } from '$lib/actions/thumbhash';
import { authManager } from '$lib/managers/auth-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { moveFocus } from '$lib/utils/focus-util';
import { currentUrlReplaceAssetId } from '$lib/utils/navigation';
import { TUNABLES } from '$lib/utils/tunables';
import { Icon } from '@immich/ui';
import { onMount } from 'svelte';
import type { ClassValue } from 'svelte/elements';
import { fade } from 'svelte/transition';
import ImageThumbnail from './image-thumbnail.svelte';
import VideoThumbnail from './video-thumbnail.svelte';
interface Props {
asset: TimelineAsset;
groupIndex?: number;
@@ -78,7 +76,7 @@
IMAGE_THUMBNAIL: { THUMBHASH_FADE_DURATION },
} = TUNABLES;
let usingMobileDevice = $derived(mobileDevice.pointerCoarse);
let usingMobileDevice = $derived(mediaQueryManager.pointerCoarse);
let element: HTMLElement | undefined = $state();
let mouseOver = $state(false);
let loaded = $state(false);
@@ -335,7 +333,7 @@
<ImageThumbnail
class={imageClass}
{brokenAssetClass}
url={getAssetThumbnailUrl({ id: asset.id, size: AssetMediaSize.Thumbnail, cacheKey: asset.thumbhash })}
url={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Thumbnail, cacheKey: asset.thumbhash })}
altText={$getAltText(asset)}
widthStyle="{width}px"
heightStyle="{height}px"
@@ -371,7 +369,7 @@
<ImageThumbnail
class={imageClass}
{brokenAssetClass}
url={getAssetOriginalUrl({ id: asset.id, cacheKey: asset.thumbhash })}
url={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Original, cacheKey: asset.thumbhash })}
altText={$getAltText(asset)}
widthStyle="{width}px"
heightStyle="{height}px"
@@ -1,7 +1,7 @@
<script lang="ts">
import SearchPeople from '$lib/components/faces-page/people-search.svelte';
import { timeBeforeShowLoadingSpinner } from '$lib/constants';
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { zoomImageToBase64 } from '$lib/utils/people-utils';
@@ -61,7 +61,7 @@
const handleCreatePerson = async () => {
const timeout = setTimeout(() => (isShowLoadingNewPerson = true), timeBeforeShowLoadingSpinner);
const newFeaturePhoto = await zoomImageToBase64(editedFace, assetId, assetType, $photoViewerImgElement);
const newFeaturePhoto = await zoomImageToBase64(editedFace, assetId, assetType, assetViewerManager.imgRef);
onCreatePerson(newFeaturePhoto);
@@ -0,0 +1,88 @@
import { getIntersectionObserverMock } from '$lib/__mocks__/intersection-observer.mock';
import { personFactory } from '@test-data/factories/person-factory';
import { render } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
import ManagePeopleVisibilityWrapper from './manage-people-visibility.test-wrapper.svelte';
describe('ManagePeopleVisibility component', () => {
beforeEach(() => {
vi.stubGlobal('IntersectionObserver', getIntersectionObserverMock());
});
it('keeps toggled hidden state when loading more people', async () => {
const onClose = vi.fn();
const onUpdate = vi.fn();
const loadNextPage = vi.fn();
const [personA, personB, personC] = [
personFactory.build({ id: 'a', isHidden: false }),
personFactory.build({ id: 'b', isHidden: false }),
personFactory.build({ id: 'c', isHidden: true }),
];
const { container, rerender } = render(ManagePeopleVisibilityWrapper, {
props: {
people: [personA, personB],
totalPeopleCount: 3,
onClose,
onUpdate,
loadNextPage,
},
});
const user = userEvent.setup();
let personButtons = container.querySelectorAll('button[aria-pressed]');
expect(personButtons).toHaveLength(2);
await user.click(personButtons[0]);
expect(personButtons[0].getAttribute('aria-pressed')).toBe('true');
await rerender({
people: [personA, personB, personC],
totalPeopleCount: 3,
onClose,
onUpdate,
loadNextPage,
});
personButtons = container.querySelectorAll('button[aria-pressed]');
expect(personButtons).toHaveLength(3);
expect(personButtons[0].getAttribute('aria-pressed')).toBe('true');
expect(personButtons[2].getAttribute('aria-pressed')).toBe('true');
});
it('shows newly loaded hidden people as hidden', async () => {
const onClose = vi.fn();
const onUpdate = vi.fn();
const loadNextPage = vi.fn();
const [personA, personB, personC] = [
personFactory.build({ id: 'a', isHidden: false }),
personFactory.build({ id: 'b', isHidden: false }),
personFactory.build({ id: 'c', isHidden: true }),
];
const { container, rerender } = render(ManagePeopleVisibilityWrapper, {
props: {
people: [personA, personB],
totalPeopleCount: 3,
onClose,
onUpdate,
loadNextPage,
},
});
await rerender({
people: [personA, personB, personC],
totalPeopleCount: 3,
onClose,
onUpdate,
loadNextPage,
});
const personButtons = container.querySelectorAll('button[aria-pressed]');
expect(personButtons).toHaveLength(3);
expect(personButtons[2].getAttribute('aria-pressed')).toBe('true');
});
});
@@ -10,27 +10,22 @@
import { Button, IconButton, toastManager } from '@immich/ui';
import { mdiClose, mdiEye, mdiEyeOff, mdiEyeSettings, mdiRestart } from '@mdi/js';
import { t } from 'svelte-i18n';
import { SvelteMap } from 'svelte/reactivity';
interface Props {
people: PersonResponseDto[];
totalPeopleCount: number;
titleId?: string | undefined;
onClose: () => void;
onUpdate: (people: PersonResponseDto[]) => void;
loadNextPage: () => void;
}
let { people = $bindable(), totalPeopleCount, titleId = undefined, onClose, loadNextPage }: Props = $props();
let { people, totalPeopleCount, titleId = undefined, onClose, onUpdate, loadNextPage }: Props = $props();
let toggleVisibility = $state(ToggleVisibility.SHOW_ALL);
let showLoadingSpinner = $state(false);
const getPersonIsHidden = (people: PersonResponseDto[]) => {
const personIsHidden: Record<string, boolean> = {};
for (const person of people) {
personIsHidden[person.id] = person.isHidden;
}
return personIsHidden;
};
const overrides = new SvelteMap<string, boolean>();
const getNextVisibility = (toggleVisibility: ToggleVisibility) => {
if (toggleVisibility === ToggleVisibility.SHOW_ALL) {
@@ -46,23 +41,23 @@
toggleVisibility = getNextVisibility(toggleVisibility);
for (const person of people) {
let isHidden = overrides.get(person.id) ?? person.isHidden;
if (toggleVisibility === ToggleVisibility.HIDE_ALL) {
personIsHidden[person.id] = true;
isHidden = true;
} else if (toggleVisibility === ToggleVisibility.SHOW_ALL) {
personIsHidden[person.id] = false;
isHidden = false;
} else if (toggleVisibility === ToggleVisibility.HIDE_UNNANEMD && !person.name) {
personIsHidden[person.id] = true;
isHidden = true;
}
setHiddenOverride(person, isHidden);
}
};
const handleResetVisibility = () => (personIsHidden = getPersonIsHidden(people));
const handleSaveVisibility = async () => {
showLoadingSpinner = true;
const changed = people
.filter((person) => person.isHidden !== personIsHidden[person.id])
.map((person) => ({ id: person.id, isHidden: personIsHidden[person.id] }));
const changed = Array.from(overrides, ([id, isHidden]) => ({ id, isHidden }));
try {
if (changed.length > 0) {
@@ -76,9 +71,14 @@
}
for (const person of people) {
person.isHidden = personIsHidden[person.id];
const isHidden = overrides.get(person.id);
if (isHidden !== undefined) {
person.isHidden = isHidden;
}
}
overrides.clear();
onUpdate(people);
onClose();
} catch (error) {
handleError(error, $t('errors.unable_to_change_visibility', { values: { count: changed.length } }));
@@ -87,7 +87,13 @@
}
};
let personIsHidden = $state(getPersonIsHidden(people));
const setHiddenOverride = (person: PersonResponseDto, isHidden: boolean) => {
if (isHidden === person.isHidden) {
overrides.delete(person.id);
return;
}
overrides.set(person.id, isHidden);
};
let toggleButtonOptions: Record<ToggleVisibility, { icon: string; label: string }> = $derived({
[ToggleVisibility.HIDE_ALL]: { icon: mdiEyeOff, label: $t('hide_all_people') },
@@ -124,7 +130,7 @@
variant="ghost"
aria-label={$t('reset_people_visibility')}
icon={mdiRestart}
onclick={handleResetVisibility}
onclick={() => overrides.clear()}
/>
<IconButton
shape="round"
@@ -142,11 +148,11 @@
<div class="flex flex-wrap gap-1 p-2 pb-8 md:px-8 mt-16">
<PeopleInfiniteScroll {people} hasNextPage={true} {loadNextPage}>
{#snippet children({ person })}
{@const hidden = personIsHidden[person.id]}
{@const hidden = overrides.get(person.id) ?? person.isHidden}
<button
type="button"
class="group relative w-full h-full"
onclick={() => (personIsHidden[person.id] = !hidden)}
onclick={() => setHiddenOverride(person, !hidden)}
aria-pressed={hidden}
aria-label={person.name ? $t('hide_named_person', { values: { name: person.name } }) : $t('hide_person')}
>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { PersonResponseDto } from '@immich/sdk';
import { TooltipProvider } from '@immich/ui';
import ManagePeopleVisibility from './manage-people-visibility.svelte';
interface Props {
people: PersonResponseDto[];
totalPeopleCount: number;
titleId?: string | undefined;
onClose: () => void;
onUpdate: (people: PersonResponseDto[]) => void;
loadNextPage: () => void;
}
let props: Props = $props();
</script>
<TooltipProvider>
<ManagePeopleVisibility {...props} />
</TooltipProvider>
@@ -2,7 +2,6 @@
import OnEvents from '$lib/components/OnEvents.svelte';
import { timeBeforeShowLoadingSpinner } from '$lib/constants';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
import { boundingBoxesArray } from '$lib/stores/people.store';
import { getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
@@ -25,6 +24,7 @@
import { fly } from 'svelte/transition';
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
import AssignFaceSidePanel from './assign-face-side-panel.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
interface Props {
assetId: string;
@@ -269,7 +269,7 @@
hidden={face.person.isHidden}
/>
{:else}
{#await zoomImageToBase64(face, assetId, assetType, $photoViewerImgElement)}
{#await zoomImageToBase64(face, assetId, assetType, assetViewerManager.imgRef)}
<ImageThumbnail
curve
shadow
@@ -1,7 +1,7 @@
<script lang="ts">
import { assetViewerFadeDuration } from '$lib/constants';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { getAssetThumbnailUrl } from '$lib/utils';
import { getAssetMediaUrl } from '$lib/utils';
import { getAltText } from '$lib/utils/thumbnail-util';
import { AssetMediaSize } from '@immich/sdk';
import { LoadingSpinner } from '@immich/ui';
@@ -35,7 +35,7 @@
};
});
const imageLoaderUrl = $derived(getAssetThumbnailUrl({ id: asset.id, size: AssetMediaSize.Preview }));
const imageLoaderUrl = $derived(getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Preview }));
</script>
{#if !imageLoaded}
@@ -2,7 +2,7 @@
import { assetViewerFadeDuration } from '$lib/constants';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { autoPlayVideo } from '$lib/stores/preferences.store';
import { getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils';
import { getAssetMediaUrl, getAssetPlaybackUrl } from '$lib/utils';
import { AssetMediaSize } from '@immich/sdk';
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
@@ -32,7 +32,7 @@
playsinline
class="h-full w-full rounded-2xl object-contain transition-all"
src={getAssetPlaybackUrl({ id: asset.id })}
poster={getAssetThumbnailUrl({ id: asset.id, size: AssetMediaSize.Preview })}
poster={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Preview })}
draggable="false"
muted={videoViewerMuted}
volume={videoViewerVolume}
@@ -30,7 +30,7 @@
import { memoryStore, type MemoryAsset } from '$lib/stores/memory.store.svelte';
import { locale, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store';
import { preferences } from '$lib/stores/user.store';
import { getAssetThumbnailUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils';
import { getAssetMediaUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils';
import { cancelMultiselect } from '$lib/utils/asset-utils';
import { fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
import { AssetMediaSize, AssetTypeEnum, getAssetInfo } from '@immich/sdk';
@@ -68,7 +68,11 @@
let currentMemoryAssetFull = $derived.by(async () =>
current?.asset ? await getAssetInfo({ ...authManager.params, id: current.asset.id }) : undefined,
);
let currentTimelineAssets = $derived(current?.memory.assets || []);
let currentTimelineAssets = $derived([
...(current?.previousMemory?.assets ?? []),
...(current?.memory.assets ?? []),
...(current?.nextMemory?.assets ?? []),
]);
let isSaved = $derived(current?.memory.isSaved);
let viewerHeight = $state(0);
@@ -449,7 +453,7 @@
{#if current.previousMemory && current.previousMemory.assets.length > 0}
<img
class="h-full w-full rounded-2xl object-cover"
src={getAssetThumbnailUrl({ id: current.previousMemory.assets[0].id, size: AssetMediaSize.Preview })}
src={getAssetMediaUrl({ id: current.previousMemory.assets[0].id, size: AssetMediaSize.Preview })}
alt={$t('previous_memory')}
draggable="false"
/>
@@ -598,7 +602,7 @@
{#if current.nextMemory && current.nextMemory.assets.length > 0}
<img
class="h-full w-full rounded-2xl object-cover"
src={getAssetThumbnailUrl({ id: current.nextMemory.assets[0].id, size: AssetMediaSize.Preview })}
src={getAssetMediaUrl({ id: current.nextMemory.assets[0].id, size: AssetMediaSize.Preview })}
alt={$t('next_memory')}
draggable="false"
/>
@@ -1,7 +1,7 @@
<script lang="ts">
import { Route } from '$lib/route';
import { placesViewSettings } from '$lib/stores/preferences.store';
import { getAssetThumbnailUrl } from '$lib/utils';
import { getAssetMediaUrl } from '$lib/utils';
import { type PlacesGroup, isPlacesGroupCollapsed, togglePlacesGroupCollapsing } from '$lib/utils/places-utils';
import { AssetMediaSize, type AssetResponseDto } from '@immich/sdk';
import { Icon } from '@immich/ui';
@@ -45,7 +45,7 @@
class="flex w-[calc((100vw-(72px+5rem))/2)] max-w-39 justify-center overflow-hidden rounded-xl brightness-75 filter"
>
<img
src={getAssetThumbnailUrl({ id: item.id, size: AssetMediaSize.Thumbnail })}
src={getAssetMediaUrl({ id: item.id, size: AssetMediaSize.Thumbnail })}
alt={city}
class="object-cover w-39 h-39"
loading="lazy"
@@ -10,7 +10,7 @@
import { Route } from '$lib/route';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { mediaQueryManager } from '$lib/stores/media-query-manager.svelte';
import { handlePromiseError } from '$lib/utils';
import { cancelMultiselect, downloadArchive } from '$lib/utils/asset-utils';
import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader';
@@ -110,7 +110,7 @@
<ControlAppBar onClose={() => goto(Route.photos())} backIcon={mdiArrowLeft} showBackButton={false}>
{#snippet leading()}
<a data-sveltekit-preload-data="hover" class="ms-4" href="/">
<Logo variant={mobileDevice.maxMd ? 'icon' : 'inline'} class="min-w-10" />
<Logo variant={mediaQueryManager.maxMd ? 'icon' : 'inline'} class="min-w-10" />
</a>
{/snippet}
@@ -302,6 +302,7 @@
case AssetAction.ARCHIVE:
case AssetAction.DELETE:
case AssetAction.TRASH: {
const nextAsset = assetCursor.nextAsset ?? assetCursor.previousAsset;
assets.splice(
assets.findIndex((currentAsset) => currentAsset.id === action.asset.id),
1,
@@ -309,10 +310,8 @@
if (assets.length === 0) {
return await goto(Route.photos());
}
if (assetCursor.nextAsset) {
await navigateToAsset(assetCursor.nextAsset);
} else if (assetCursor.previousAsset) {
await navigateToAsset(assetCursor.previousAsset);
if (nextAsset) {
await navigateToAsset(nextAsset);
}
break;
}
@@ -16,7 +16,7 @@
import { themeManager } from '$lib/managers/theme-manager.svelte';
import MapSettingsModal from '$lib/modals/MapSettingsModal.svelte';
import { mapSettings } from '$lib/stores/preferences.store';
import { getAssetThumbnailUrl, handlePromiseError } from '$lib/utils';
import { getAssetMediaUrl, handlePromiseError } from '$lib/utils';
import { getMapMarkers, type MapMarkerResponseDto } from '@immich/sdk';
import { Icon, modalManager } from '@immich/ui';
import { mdiCog, mdiMap, mdiMapMarker } from '@mdi/js';
@@ -388,7 +388,7 @@
<Icon icon={mdiMapMarker} size="50px" class="text-primary -translate-y-[50%]" />
{:else}
<img
src={getAssetThumbnailUrl(feature.properties?.id)}
src={getAssetMediaUrl({ id: feature.properties?.id })}
class="rounded-full w-15 h-15 border-2 border-immich-primary shadow-lg hover:border-immich-dark-primary transition-all duration-200 hover:scale-150 object-cover bg-immich-primary"
alt={feature.properties?.city && feature.properties.country
? $t('map_marker_for_images', {
@@ -13,7 +13,7 @@
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { Route } from '$lib/route';
import { getGlobalActions } from '$lib/services/app.service';
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { mediaQueryManager } from '$lib/stores/media-query-manager.svelte';
import { notificationManager } from '$lib/stores/notification-manager.svelte';
import { sidebarStore } from '$lib/stores/sidebar.svelte';
import { user } from '$lib/stores/user.store';
@@ -79,7 +79,7 @@
class="sidebar:hidden"
/>
<a data-sveltekit-preload-data="hover" href={Route.photos()}>
<Logo variant={mobileDevice.isFullSidebar ? 'inline' : 'icon'} class="max-md:h-12" />
<Logo variant={mediaQueryManager.isFullSidebar ? 'inline' : 'icon'} class="max-md:h-12" />
</a>
</div>
<div class="flex justify-between gap-4 lg:gap-8 pe-6">
@@ -0,0 +1,23 @@
import { render } from '@testing-library/svelte';
import SettingSwitch from './setting-switch.svelte';
describe('SettingSwitch component', () => {
it('links switch and subtitle ids on the switch', () => {
const { getByText } = render(SettingSwitch, {
props: {
title: 'Enable feature',
subtitle: 'Controls the feature state.',
},
});
const label = getByText('Enable feature') as HTMLLabelElement;
const subtitle = getByText('Controls the feature state.');
const subtitleId = subtitle.getAttribute('id');
const switchElement = document.querySelector(`#${label.htmlFor}`);
expect(subtitleId).not.toBeNull();
expect(label.htmlFor).not.toBe('');
expect(switchElement).not.toBeNull();
expect(switchElement?.getAttribute('aria-describedby')).toBe(subtitleId);
});
});
@@ -28,14 +28,14 @@
let id: string = generateId();
let sliderId = $derived(`${id}-slider`);
let switchId = $derived(`input-${id}`);
let subtitleId = $derived(subtitle ? `${id}-subtitle` : undefined);
</script>
<div class="flex place-items-center justify-between">
<div class="me-2">
<div class="flex h-6.5 place-items-center gap-1">
<label class="font-medium text-primary text-sm" for={sliderId}>
<label class="font-medium text-primary text-sm" for={switchId}>
{title}
</label>
{#if isEdited}
@@ -54,5 +54,5 @@
{@render children?.()}
</div>
<Switch id={sliderId} bind:checked {disabled} onCheckedChange={onToggle} aria-describedby={subtitleId} />
<Switch {id} bind:checked {disabled} onCheckedChange={onToggle} aria-describedby={subtitleId} />
</div>
@@ -1,7 +1,7 @@
<script lang="ts">
import { Route } from '$lib/route';
import { userInteraction } from '$lib/stores/user.svelte';
import { getAssetThumbnailUrl } from '$lib/utils';
import { getAssetMediaUrl } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { getAllAlbums, type AlbumResponseDto } from '@immich/sdk';
import { onMount } from 'svelte';
@@ -34,7 +34,7 @@
<div
class="h-6 w-6 bg-cover rounded bg-gray-200 dark:bg-gray-600"
style={album.albumThumbnailAssetId
? `background-image:url('${getAssetThumbnailUrl({ id: album.albumThumbnailAssetId })}')`
? `background-image:url('${getAssetMediaUrl({ id: album.albumThumbnailAssetId })}')`
: ''}
></div>
</div>
@@ -1,5 +1,5 @@
import ShareCover from '$lib/components/sharedlinks-page/covers/share-cover.svelte';
import { getAssetThumbnailUrl } from '$lib/utils';
import { getAssetMediaUrl } from '$lib/utils';
import { albumFactory } from '@test-data/factories/album-factory';
import { assetFactory } from '@test-data/factories/asset-factory';
import { sharedLinkFactory } from '@test-data/factories/shared-link-factory';
@@ -21,7 +21,7 @@ describe('ShareCover component', () => {
});
it('renders an image when the shared link is an individual share', () => {
vi.mocked(getAssetThumbnailUrl).mockReturnValue('/asdf');
vi.mocked(getAssetMediaUrl).mockReturnValue('/asdf');
const component = render(ShareCover, {
sharedLink: sharedLinkFactory.build({ assets: [assetFactory.build({ id: 'someId' })] }),
preload: false,
@@ -32,7 +32,7 @@ describe('ShareCover component', () => {
expect(img.getAttribute('loading')).toBe('lazy');
expect(img.className).toBe('size-full rounded-xl object-cover aspect-square text');
expect(img.getAttribute('src')).toBe('/asdf');
expect(getAssetThumbnailUrl).toHaveBeenCalledWith('someId');
expect(getAssetMediaUrl).toHaveBeenCalledWith({ id: 'someId' });
});
it('renders an image when the shared link has no album or assets', () => {
@@ -2,7 +2,7 @@
import AlbumCover from '$lib/components/album-page/album-cover.svelte';
import AssetCover from '$lib/components/sharedlinks-page/covers/asset-cover.svelte';
import NoCover from '$lib/components/sharedlinks-page/covers/no-cover.svelte';
import { getAssetThumbnailUrl } from '$lib/utils';
import { getAssetMediaUrl } from '$lib/utils';
import type { SharedLinkResponseDto } from '@immich/sdk';
import { t } from 'svelte-i18n';
@@ -23,7 +23,7 @@
alt={$t('individual_share')}
class={className}
{preload}
src={getAssetThumbnailUrl(sharedLink.assets[0].id)}
src={getAssetMediaUrl({ id: sharedLink.assets[0].id })}
/>
{:else}
<NoCover alt={$t('unnamed_share')} class={className} {preload} />
@@ -5,14 +5,14 @@ import { vi } from 'vitest';
const mocks = vi.hoisted(() => {
return {
mobileDevice: {
mediaQueryManager: {
isFullSidebar: false,
},
};
});
vi.mock('$lib/stores/mobile-device.svelte', () => ({
mobileDevice: mocks.mobileDevice,
vi.mock('$lib/stores/media-query-manager.svelte', () => ({
mediaQueryManager: mocks.mediaQueryManager,
}));
vi.mock('$lib/stores/sidebar.svelte', () => ({
@@ -25,7 +25,7 @@ vi.mock('$lib/stores/sidebar.svelte', () => ({
describe('Sidebar component', () => {
beforeEach(() => {
vi.resetAllMocks();
mocks.mobileDevice.isFullSidebar = false;
mocks.mediaQueryManager.isFullSidebar = false;
sidebarStore.isOpen = false;
});
@@ -39,7 +39,7 @@ describe('Sidebar component', () => {
'inert is $expectedInert when isFullSidebar=$isFullSidebar and isSidebarOpen=$isSidebarOpen',
({ isFullSidebar, isSidebarOpen, expectedInert }) => {
// setup
mocks.mobileDevice.isFullSidebar = isFullSidebar;
mocks.mediaQueryManager.isFullSidebar = isFullSidebar;
sidebarStore.isOpen = isSidebarOpen;
// when
@@ -53,7 +53,7 @@ describe('Sidebar component', () => {
it('should set width when sidebar is expanded', () => {
// setup
mocks.mobileDevice.isFullSidebar = false;
mocks.mediaQueryManager.isFullSidebar = false;
sidebarStore.isOpen = true;
// when
@@ -68,7 +68,7 @@ describe('Sidebar component', () => {
it('should close the sidebar if it is open on initial render', () => {
// setup
mocks.mobileDevice.isFullSidebar = false;
mocks.mediaQueryManager.isFullSidebar = false;
sidebarStore.isOpen = true;
// when
@@ -2,7 +2,7 @@
import { clickOutside } from '$lib/actions/click-outside';
import { focusTrap } from '$lib/actions/focus-trap';
import { menuButtonId } from '$lib/components/shared-components/navigation-bar/navigation-bar.svelte';
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { mediaQueryManager } from '$lib/stores/media-query-manager.svelte';
import { sidebarStore } from '$lib/stores/sidebar.svelte';
import { onMount, type Snippet } from 'svelte';
@@ -13,8 +13,8 @@
let { ariaLabel, children }: Props = $props();
const isHidden = $derived(!sidebarStore.isOpen && !mobileDevice.isFullSidebar);
const isExpanded = $derived(sidebarStore.isOpen && !mobileDevice.isFullSidebar);
const isHidden = $derived(!sidebarStore.isOpen && !mediaQueryManager.isFullSidebar);
const isExpanded = $derived(sidebarStore.isOpen && !mediaQueryManager.isFullSidebar);
onMount(() => {
closeSidebar();
@@ -1,7 +1,7 @@
<script lang="ts">
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { ScrubberMonth, ViewportTopMonth } from '$lib/managers/timeline-manager/types';
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { mediaQueryManager } from '$lib/stores/media-query-manager.svelte';
import { getTabbable } from '$lib/utils/focus-util';
import { type ScrubberListener } from '$lib/utils/timeline-util';
import { Icon } from '@immich/ui';
@@ -65,7 +65,7 @@
const toScrollY = (percent: number) => percent * (height - (PADDING_TOP + PADDING_BOTTOM));
const toTimelineY = (scrollY: number) => scrollY / (height - (PADDING_TOP + PADDING_BOTTOM));
const usingMobileDevice = $derived(mobileDevice.pointerCoarse);
const usingMobileDevice = $derived(mediaQueryManager.pointerCoarse);
const MOBILE_WIDTH = 20;
const DESKTOP_WIDTH = 60;
@@ -21,7 +21,7 @@
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { mediaQueryManager } from '$lib/stores/media-query-manager.svelte';
import { isAssetViewerRoute, navigate } from '$lib/utils/navigation';
import { getTimes, type ScrubberListener } from '$lib/utils/timeline-util';
import { type AlbumResponseDto, type PersonResponseDto, type UserResponseDto } from '@immich/sdk';
@@ -106,8 +106,8 @@
let scrubberWidth = $state(0);
const isEmpty = $derived(timelineManager.isInitialized && timelineManager.months.length === 0);
const maxMd = $derived(mobileDevice.maxMd);
const usingMobileDevice = $derived(mobileDevice.pointerCoarse);
const maxMd = $derived(mediaQueryManager.maxMd);
const usingMobileDevice = $derived(mediaQueryManager.pointerCoarse);
$effect(() => {
const layoutOptions = maxMd
@@ -97,7 +97,6 @@
};
const handleClose = async (asset: { id: string }) => {
assetViewingStore.showAssetViewer(false);
invisible = true;
$gridScrollTarget = { at: asset.id };
await navigate({ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget });
@@ -20,11 +20,8 @@
const handleTagAssets = async () => {
const assets = [...getOwnedAssets()];
const success = await modalManager.show(AssetTagModal, { assetIds: assets.map(({ id }) => id) });
if (success) {
clearSelect();
}
await modalManager.show(AssetTagModal, { assetIds: assets.map(({ id }) => id) });
clearSelect();
};
</script>
@@ -141,43 +141,41 @@
}
};
let shortcutList = $derived(
(() => {
if (searchStore.isSearchEnabled || $showAssetViewer) {
return [];
}
const shortcutList = $derived.by(() => {
if (searchStore.isSearchEnabled || $showAssetViewer) {
return [];
}
const shortcuts: ShortcutOptions[] = [
{ shortcut: { key: '?', shift: true }, onShortcut: handleOpenShortcutModal },
{ shortcut: { key: '/' }, onShortcut: () => goto(Route.explore()) },
{ shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets(timelineManager, assetInteraction) },
{ shortcut: { key: 'ArrowRight' }, onShortcut: () => setFocusTo('earlier', 'asset') },
{ shortcut: { key: 'ArrowLeft' }, onShortcut: () => setFocusTo('later', 'asset') },
{ shortcut: { key: 'D' }, onShortcut: () => setFocusTo('earlier', 'day') },
{ shortcut: { key: 'D', shift: true }, onShortcut: () => setFocusTo('later', 'day') },
{ shortcut: { key: 'M' }, onShortcut: () => setFocusTo('earlier', 'month') },
{ shortcut: { key: 'M', shift: true }, onShortcut: () => setFocusTo('later', 'month') },
{ shortcut: { key: 'Y' }, onShortcut: () => setFocusTo('earlier', 'year') },
{ shortcut: { key: 'Y', shift: true }, onShortcut: () => setFocusTo('later', 'year') },
{ shortcut: { key: 'G' }, onShortcut: handleOpenDateModal },
];
if (onEscape) {
shortcuts.push({ shortcut: { key: 'Escape' }, onShortcut: onEscape });
}
const shortcuts: ShortcutOptions[] = [
{ shortcut: { key: '?', shift: true }, onShortcut: handleOpenShortcutModal },
{ shortcut: { key: '/' }, onShortcut: () => goto(Route.explore()) },
{ shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets(timelineManager, assetInteraction) },
{ shortcut: { key: 'ArrowRight' }, onShortcut: () => setFocusTo('earlier', 'asset') },
{ shortcut: { key: 'ArrowLeft' }, onShortcut: () => setFocusTo('later', 'asset') },
{ shortcut: { key: 'D' }, onShortcut: () => setFocusTo('earlier', 'day') },
{ shortcut: { key: 'D', shift: true }, onShortcut: () => setFocusTo('later', 'day') },
{ shortcut: { key: 'M' }, onShortcut: () => setFocusTo('earlier', 'month') },
{ shortcut: { key: 'M', shift: true }, onShortcut: () => setFocusTo('later', 'month') },
{ shortcut: { key: 'Y' }, onShortcut: () => setFocusTo('earlier', 'year') },
{ shortcut: { key: 'Y', shift: true }, onShortcut: () => setFocusTo('later', 'year') },
{ shortcut: { key: 'G' }, onShortcut: handleOpenDateModal },
];
if (onEscape) {
shortcuts.push({ shortcut: { key: 'Escape' }, onShortcut: onEscape });
}
if (assetInteraction.selectionActive) {
shortcuts.push(
{ shortcut: { key: 'Delete' }, onShortcut: onDelete },
{ shortcut: { key: 'Delete', shift: true }, onShortcut: () => trashOrDelete(true) },
{ shortcut: { key: 'D', ctrl: true }, onShortcut: () => deselectAllAssets() },
{ shortcut: { key: 's' }, onShortcut: () => onStackAssets() },
{ shortcut: { key: 'a', shift: true }, onShortcut: toggleArchive },
);
}
if (assetInteraction.selectionActive) {
shortcuts.push(
{ shortcut: { key: 'Delete' }, onShortcut: onDelete },
{ shortcut: { key: 'Delete', shift: true }, onShortcut: () => trashOrDelete(true) },
{ shortcut: { key: 'D', ctrl: true }, onShortcut: () => deselectAllAssets() },
{ shortcut: { key: 's' }, onShortcut: () => onStackAssets() },
{ shortcut: { key: 'a', shift: true }, onShortcut: toggleArchive },
);
}
return shortcuts;
})(),
);
return shortcuts;
});
</script>
<svelte:document onkeydown={onKeyDown} onkeyup={onKeyUp} onselectstart={onSelectStart} use:shortcuts={shortcutList} />
@@ -5,7 +5,6 @@
import { changePinCode } from '@immich/sdk';
import { Button, Heading, modalManager, Text, toastManager } from '@immich/ui';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
let currentPinCode = $state('');
let newPinCode = $state('');
@@ -38,27 +37,23 @@
};
</script>
<section class="my-4">
<div in:fade={{ duration: 200 }}>
<form autocomplete="off" onsubmit={handleSubmit} class="mt-6">
<div class="flex flex-col gap-6 place-items-center place-content-center">
<Heading>{$t('change_pin_code')}</Heading>
<PinCodeInput label={$t('current_pin_code')} bind:value={currentPinCode} tabindexStart={1} pinLength={6} />
<PinCodeInput label={$t('new_pin_code')} bind:value={newPinCode} tabindexStart={7} pinLength={6} />
<PinCodeInput label={$t('confirm_new_pin_code')} bind:value={confirmPinCode} tabindexStart={13} pinLength={6} />
<button type="button" onclick={() => modalManager.show(PinCodeResetModal, {})}>
<Text color="muted" class="underline" size="small">{$t('forgot_pin_code_question')}</Text>
</button>
</div>
<div class="flex justify-end gap-2 mt-4">
<Button shape="round" color="secondary" type="button" size="small" onclick={resetForm}>
{$t('clear')}
</Button>
<Button shape="round" type="submit" size="small" loading={isLoading} disabled={!canSubmit}>
{$t('save')}
</Button>
</div>
</form>
<form autocomplete="off" onsubmit={handleSubmit}>
<div class="flex flex-col gap-6 place-items-center place-content-center">
<Heading>{$t('change_pin_code')}</Heading>
<PinCodeInput label={$t('current_pin_code')} bind:value={currentPinCode} tabindexStart={1} pinLength={6} />
<PinCodeInput label={$t('new_pin_code')} bind:value={newPinCode} tabindexStart={7} pinLength={6} />
<PinCodeInput label={$t('confirm_new_pin_code')} bind:value={confirmPinCode} tabindexStart={13} pinLength={6} />
<button type="button" onclick={() => modalManager.show(PinCodeResetModal, {})}>
<Text color="muted" class="underline" size="small">{$t('forgot_pin_code_question')}</Text>
</button>
</div>
</section>
<div class="flex justify-end gap-2 mt-4">
<Button shape="round" color="secondary" type="button" size="small" onclick={resetForm}>
{$t('clear')}
</Button>
<Button shape="round" type="submit" size="small" loading={isLoading} disabled={!canSubmit}>
{$t('save')}
</Button>
</div>
</form>
@@ -20,7 +20,7 @@
<OnEvents {onUserPinCodeReset} />
<section>
<section class="my-4 sm:ms-8">
{#if hasPinCode}
<div in:fade={{ duration: 200 }}>
<PinCodeChangeForm />
@@ -59,7 +59,7 @@
<section class="my-4">
<div in:fade={{ duration: 500 }}>
<div class="ms-8 mt-4 flex flex-col gap-6">
<div class="sm:ms-8 flex flex-col gap-6">
<Field label={$t('theme_selection')} description={$t('theme_selection_description')}>
<Switch checked={themeManager.theme.system} onCheckedChange={(checked) => themeManager.setSystem(checked)} />
</Field>
@@ -23,7 +23,7 @@
<section class="my-4">
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" {onsubmit}>
<div class="ms-4 mt-4 flex flex-col gap-4">
<div class="sm:ms-8 flex flex-col gap-4">
<Field label={$t('password')} required>
<PasswordInput bind:value={password} autocomplete="current-password" />
</Field>
@@ -3,6 +3,7 @@
import { deleteAllSessions, deleteSession, getSessions, type SessionResponseDto } from '@immich/sdk';
import { Button, modalManager, Text, toastManager } from '@immich/ui';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
import DeviceCard from './device-card.svelte';
interface Props {
@@ -50,33 +51,39 @@
</script>
<section class="my-4">
{#if currentSession}
<div class="mb-6">
<Text class="mb-2" fontWeight="medium" size="tiny" color="primary">
{$t('current_device')}
</Text>
<DeviceCard session={currentSession} />
</div>
{/if}
{#if otherSessions.length > 0}
<div class="mb-6">
<Text class="mb-2" fontWeight="medium" size="tiny" color="primary">
{$t('other_devices')}
</Text>
{#each otherSessions as session, index (session.id)}
<DeviceCard {session} onDelete={() => handleDelete(session)} />
{#if index !== otherSessions.length - 1}
<hr class="my-3" />
{/if}
{/each}
</div>
<div in:fade={{ duration: 500 }}>
<div class="sm:ms-8 flex flex-col gap-4">
{#if currentSession}
<div class="mb-6">
<Text class="mb-2" fontWeight="medium" size="tiny" color="primary">
{$t('current_device')}
</Text>
<DeviceCard session={currentSession} />
</div>
{/if}
{#if otherSessions.length > 0}
<div class="mb-6">
<Text class="mb-2" fontWeight="medium" size="tiny" color="primary">
{$t('other_devices')}
</Text>
{#each otherSessions as session, index (session.id)}
<DeviceCard {session} onDelete={() => handleDelete(session)} />
{#if index !== otherSessions.length - 1}
<hr class="my-3" />
{/if}
{/each}
</div>
<div class="my-3">
<hr />
</div>
<div class="my-3">
<hr />
</div>
<div class="flex justify-end">
<Button shape="round" color="danger" size="small" onclick={handleDeleteAll}>{$t('log_out_all_devices')}</Button>
<div class="flex justify-end">
<Button shape="round" color="danger" size="small" onclick={handleDeleteAll}
>{$t('log_out_all_devices')}</Button
>
</div>
{/if}
</div>
{/if}
</div>
</section>
@@ -39,7 +39,7 @@
<section class="my-4">
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" {onsubmit}>
<div class="ms-4 mt-4 flex flex-col gap-4">
<div class="sm:ms-8 flex flex-col gap-4">
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label={$t('archive_size')}
@@ -67,9 +67,9 @@
<section class="my-4">
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" {onsubmit}>
<div class="ms-4 mt-4 flex flex-col">
<div class="sm:ms-4 md:ms-8 flex flex-col">
<SettingAccordion key="albums" title={$t('albums')} subtitle={$t('albums_feature_description')}>
<div class="ms-4 mt-6 flex flex-col gap-4">
<div class="sm:ms-4 mt-4 flex flex-col gap-4">
<Field label={$t('albums_default_sort_order')} description={$t('albums_default_sort_order_description')}>
<Select
options={[
@@ -83,7 +83,7 @@
</SettingAccordion>
<SettingAccordion key="folders" title={$t('folders')} subtitle={$t('folders_feature_description')}>
<div class="ms-4 mt-6 flex flex-col gap-4">
<div class="sm:ms-4 mt-4 flex flex-col gap-4">
<Field label={$t('enable')}>
<Switch bind:checked={foldersEnabled} />
</Field>
@@ -97,7 +97,7 @@
</SettingAccordion>
<SettingAccordion key="memories" title={$t('time_based_memories')} subtitle={$t('photos_from_previous_years')}>
<div class="ms-4 mt-6 flex flex-col gap-4">
<div class="sm:ms-4 mt-4 flex flex-col gap-4">
<Field label={$t('enable')}>
<Switch bind:checked={memoriesEnabled} />
</Field>
@@ -109,7 +109,7 @@
</SettingAccordion>
<SettingAccordion key="people" title={$t('people')} subtitle={$t('people_feature_description')}>
<div class="ms-4 mt-6 flex flex-col gap-4">
<div class="sm:ms-4 mt-4 flex flex-col gap-4">
<Field label={$t('enable')}>
<Switch bind:checked={peopleEnabled} />
</Field>
@@ -123,7 +123,7 @@
</SettingAccordion>
<SettingAccordion key="rating" title={$t('rating')} subtitle={$t('rating_description')}>
<div class="ms-4 mt-6 flex flex-col gap-4">
<div class="sm:ms-4 mt-4 flex flex-col gap-4">
<Field label={$t('enable')}>
<Switch bind:checked={ratingsEnabled} />
</Field>
@@ -131,7 +131,7 @@
</SettingAccordion>
<SettingAccordion key="shared-links" title={$t('shared_links')} subtitle={$t('shared_links_description')}>
<div class="ms-4 mt-6 flex flex-col gap-4">
<div class="sm:ms-4 mt-4 flex flex-col gap-4">
<Field label={$t('enable')}>
<Switch bind:checked={sharedLinksEnabled} />
</Field>
@@ -145,7 +145,7 @@
</SettingAccordion>
<SettingAccordion key="tags" title={$t('tags')} subtitle={$t('tag_feature_description')}>
<div class="ms-4 mt-6 flex flex-col gap-4">
<div class="sm:ms-4 mt-4 flex flex-col gap-4">
<Field label={$t('enable')}>
<Switch bind:checked={tagsEnabled} />
</Field>
@@ -159,7 +159,7 @@
</SettingAccordion>
<SettingAccordion key="cast" title={$t('cast')} subtitle={$t('cast_description')}>
<div class="ms-4 mt-6 flex flex-col gap-4">
<div class="sm:ms-4 mt-4 flex flex-col gap-4">
<Field label={$t('gcast_enabled')} description={$t('gcast_enabled_description')}>
<Switch bind:checked={gCastEnabled} />
</Field>
@@ -42,7 +42,7 @@
<section class="my-4">
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" {onsubmit}>
<div class="ms-4 mt-4 flex flex-col gap-6">
<div class="sm:ms-8 flex flex-col gap-6">
<Field label={$t('enable')} description={$t('notification_toggle_setting_description')}>
<Switch bind:checked={emailNotificationsEnabled} />
</Field>
@@ -45,7 +45,7 @@
<section class="my-4">
<div in:fade={{ duration: 500 }}>
<div class="flex justify-end">
<div class="sm:ms-8 flex justify-end">
{#if loading}
<div class="flex place-content-center place-items-center">
<LoadingSpinner />
@@ -33,7 +33,7 @@
<OnEvents {onApiKeyCreate} {onApiKeyUpdate} {onApiKeyDelete} />
<section class="my-4">
<div class="flex flex-col gap-2" in:fade={{ duration: 500 }}>
<div class="sm:ms-8 flex flex-col gap-2" in:fade={{ duration: 500 }}>
<div class="mb-2 flex justify-end">
<Button leadingIcon={Create.icon} shape="round" size="small" onclick={() => Create.onAction(Create)}>
{Create.title}
@@ -33,7 +33,7 @@
<section class="my-4">
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" onsubmit={preventDefault(bubble('submit'))}>
<div class="ms-4 mt-4 flex flex-col gap-4">
<div class="sm:ms-8 flex flex-col gap-4">
<Field label={$t('user_id')} disabled>
<Input bind:value={editedUser.id} />
</Field>
@@ -105,7 +105,7 @@
</script>
<section class="my-4">
<div in:fade={{ duration: 500 }}>
<div class="sm:ms-8" in:fade={{ duration: 500 }}>
{#if $isPurchased}
<!-- BADGE TOGGLE -->
<div class="mb-4">
@@ -65,7 +65,7 @@
</TableRow>
{/snippet}
<section class="my-6 w-full">
<section class="my-4 w-full">
<Heading size="tiny">{$t('photos_and_videos')}</Heading>
<Table striped spacing="small" class="mt-4" size="small">
<TableHeader>
@@ -1,6 +1,6 @@
<script lang="ts">
import { locale } from '$lib/stores/preferences.store';
import { getAssetThumbnailUrl } from '$lib/utils';
import { getAssetMediaUrl } from '$lib/utils';
import { getAssetResolution, getFileSize } from '$lib/utils/asset-utils';
import { getAltText } from '$lib/utils/thumbnail-util';
import { fromISODateTime, fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
@@ -112,7 +112,7 @@
>
<!-- THUMBNAIL-->
<img
src={getAssetThumbnailUrl(asset.id)}
src={getAssetMediaUrl({ id: asset.id })}
alt={$getAltText(toTimelineAsset(asset))}
title={assetData}
class="h-60 object-cover w-full rounded-t-md"
@@ -171,7 +171,7 @@
<InfoRow
icon={mdiImageOutline}
highlight={hasDifferentValues.fileName}
title={$t('file_name', { values: { file_name: asset.originalFileName ?? '' } })}
title={$t('file_name_with_value', { values: { file_name: asset.originalFileName ?? '' } })}
>
{asset.originalFileName}
</InfoRow>
@@ -9,7 +9,6 @@
mdiCrosshairsGps,
mdiImageSizeSelectLarge,
mdiLinkEdit,
mdiStateMachine,
} from '@mdi/js';
import { t } from 'svelte-i18n';
@@ -17,7 +16,7 @@
{ href: Route.duplicatesUtility(), icon: mdiContentDuplicate, label: $t('review_duplicates') },
{ href: Route.largeFileUtility(), icon: mdiImageSizeSelectLarge, label: $t('review_large_files') },
{ href: Route.geolocationUtility(), icon: mdiCrosshairsGps, label: $t('manage_geolocation') },
{ href: Route.workflows(), icon: mdiStateMachine, label: $t('workflows') },
// { href: Route.workflows(), icon: mdiStateMachine, label: $t('workflows') },
];
</script>
@@ -1,5 +1,5 @@
<script lang="ts">
import { getAssetThumbnailUrl, getPeopleThumbnailUrl } from '$lib/utils';
import { getAssetMediaUrl, getPeopleThumbnailUrl } from '$lib/utils';
import type { AlbumResponseDto, PersonResponseDto } from '@immich/sdk';
import { Card, CardBody, IconButton, Text } from '@immich/ui';
import { mdiClose } from '@mdi/js';
@@ -20,7 +20,7 @@
{#if isAlbum && 'albumThumbnailAssetId' in item}
{#if item.albumThumbnailAssetId}
<img
src={getAssetThumbnailUrl(item.albumThumbnailAssetId)}
src={getAssetMediaUrl({ id: item.albumThumbnailAssetId })}
alt={item.albumName}
class="h-12 w-12 rounded-lg object-cover"
/>
@@ -1,3 +1,4 @@
import { eventManager } from '$lib/managers/event-manager.svelte';
import { getAssetInfo, getAssetOcr, type AssetOcrResponseDto, type AssetResponseDto } from '@immich/sdk';
const defaultSerializer = <K>(params: K) => JSON.stringify(params);
@@ -35,6 +36,15 @@ class AssetCacheManager {
#assetCache = new AsyncCache<AssetResponseDto>();
#ocrCache = new AsyncCache<AssetOcrResponseDto[]>();
constructor() {
eventManager.on({
AssetEditsApplied: () => {
this.#assetCache.clear();
this.#ocrCache.clear();
},
});
}
async getAsset(assetIdentifier: { key?: string; slug?: string; id: string }, updateCache = true) {
return this.#assetCache.getOrFetch(assetIdentifier, getAssetInfo, defaultSerializer, updateCache);
}
@@ -0,0 +1,43 @@
import { getAssetMediaUrl } from '$lib/utils';
import { cancelImageUrl } from '$lib/utils/sw-messaging';
import { AssetMediaSize, type AssetResponseDto } from '@immich/sdk';
type AllAssetMediaSize = AssetMediaSize | 'all';
class ImageManager {
preload(asset: AssetResponseDto | undefined, size: AssetMediaSize = AssetMediaSize.Preview) {
if (!asset) {
return;
}
const url = getAssetMediaUrl({ id: asset.id, size, cacheKey: asset.thumbhash });
if (!url) {
return;
}
const img = new Image();
img.src = url;
}
cancel(asset: AssetResponseDto | undefined, size: AllAssetMediaSize = AssetMediaSize.Preview) {
if (!asset) {
return;
}
const sizes = size === 'all' ? Object.values(AssetMediaSize) : [size];
for (const size of sizes) {
const url = getAssetMediaUrl({ id: asset.id, size, cacheKey: asset.thumbhash });
if (url) {
cancelImageUrl(url);
}
}
}
cancelPreloadUrl(url: string | undefined) {
if (url) {
cancelImageUrl(url);
}
}
}
export const imageManager = new ImageManager();
@@ -1,38 +0,0 @@
import { getAssetUrl } from '$lib/utils';
import { cancelImageUrl, preloadImageUrl } from '$lib/utils/sw-messaging';
import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk';
class PreloadManager {
preload(asset: AssetResponseDto | undefined) {
if (globalThis.isSecureContext) {
preloadImageUrl(getAssetUrl({ asset }));
return;
}
if (!asset || asset.type !== AssetTypeEnum.Image) {
return;
}
const img = new Image();
const url = getAssetUrl({ asset });
if (!url) {
return;
}
img.src = url;
}
cancel(asset: AssetResponseDto | undefined) {
if (!globalThis.isSecureContext || !asset) {
return;
}
const url = getAssetUrl({ asset });
cancelImageUrl(url);
}
cancelPreloadUrl(url: string | undefined) {
if (!globalThis.isSecureContext) {
return;
}
cancelImageUrl(url);
}
}
export const preloadManager = new PreloadManager();
@@ -1,19 +1,78 @@
import { canCopyImageToClipboard } from '$lib/utils/asset-utils';
import { BaseEventManager } from '$lib/utils/base-event-manager.svelte';
import { PersistedLocalStorage } from '$lib/utils/persisted';
import type { ZoomImageWheelState } from '@zoom-image/core';
const isShowDetailPanel = new PersistedLocalStorage<boolean>('asset-viewer-state', false);
export class AssetViewerManager {
const createDefaultZoomState = (): ZoomImageWheelState => ({
currentRotation: 0,
currentZoom: 1,
enable: true,
currentPositionX: 0,
currentPositionY: 0,
});
export type Events = {
Zoom: [];
ZoomChange: [ZoomImageWheelState];
Copy: [];
};
export class AssetViewerManager extends BaseEventManager<Events> {
#zoomState = $state(createDefaultZoomState());
imgRef = $state<HTMLImageElement | undefined>();
isShowActivityPanel = $state(false);
isPlayingMotionPhoto = $state(false);
isShowEditor = $state(false);
get isShowDetailPanel() {
return isShowDetailPanel.current;
}
get zoomState() {
return this.#zoomState;
}
set zoomState(state: ZoomImageWheelState) {
this.#zoomState = state;
this.emit('ZoomChange', state);
}
get zoom() {
return this.#zoomState.currentZoom;
}
set zoom(zoom: number) {
this.zoomState = { ...this.zoomState, currentZoom: zoom };
}
canZoomIn() {
return this.hasListeners('Zoom') && this.zoom <= 1;
}
canZoomOut() {
return this.hasListeners('Zoom') && this.zoom > 1;
}
canCopyImage() {
return canCopyImageToClipboard() && !!assetViewerManager.imgRef;
}
private set isShowDetailPanel(value: boolean) {
isShowDetailPanel.current = value;
}
onZoomChange(state: ZoomImageWheelState) {
// bypass event emitter to avoid loop
this.#zoomState = state;
}
resetZoomState() {
this.zoomState = createDefaultZoomState();
}
toggleActivityPanel() {
this.closeDetailPanel();
this.isShowActivityPanel = !this.isShowActivityPanel;
@@ -31,6 +90,15 @@ export class AssetViewerManager {
closeDetailPanel() {
this.isShowDetailPanel = false;
}
openEditor() {
this.closeActivityPanel();
this.isShowEditor = true;
}
closeEditor() {
this.isShowEditor = false;
}
}
export const assetViewerManager = new AssetViewerManager();
+3 -1
View File
@@ -59,7 +59,9 @@ class CastManager {
// Add other cast destinations here (ie FCast)
];
eventManager.on('AppInit', () => void this.initialize());
eventManager.on({
AppInit: () => void this.initialize(),
});
}
private async initialize() {
@@ -1,6 +1,8 @@
import TransformTool from '$lib/components/asset-viewer/editor/transform-tool/transform-tool.svelte';
import { transformManager } from '$lib/managers/edit/transform-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { waitForWebsocketEvent } from '$lib/stores/websocket';
import { getFormatter } from '$lib/utils/i18n';
import { editAsset, removeAssetEdits, type AssetEditsDto, type AssetResponseDto } from '@immich/sdk';
import { ConfirmModal, modalManager, toastManager } from '@immich/ui';
import { mdiCropRotate } from '@mdi/js';
@@ -14,6 +16,7 @@ export interface EditToolManager {
onDeactivate: () => void;
resetAllChanges: () => Promise<void>;
hasChanges: boolean;
canReset: boolean;
edits: EditAction[];
}
@@ -40,28 +43,33 @@ export class EditManager {
currentAsset = $state<AssetResponseDto | null>(null);
selectedTool = $state<EditTool | null>(null);
hasChanges = $derived(this.tools.some((t) => t.manager.hasChanges));
// used to disable multiple confirm dialogs and mouse events while one is open
isShowingConfirmDialog = $state(false);
isApplyingEdits = $state(false);
hasAppliedEdits = $state(false);
hasUnsavedChanges = $derived(this.tools.some((t) => t.manager.hasChanges) && !this.hasAppliedEdits);
canReset = $derived(this.tools.some((t) => t.manager.canReset));
async closeConfirm(): Promise<boolean> {
// Prevent multiple dialogs (usually happens with rapid escape key presses)
if (this.isShowingConfirmDialog) {
return false;
}
if (!this.hasChanges || this.hasAppliedEdits) {
if (!this.hasUnsavedChanges) {
return true;
}
this.isShowingConfirmDialog = true;
const t = await getFormatter();
const confirmed = await modalManager.show(ConfirmModal, {
title: 'Discard Edits?',
prompt: 'You have unsaved edits. Are you sure you want to discard them?',
confirmText: 'Discard Edits',
title: t('editor_discard_edits_title'),
prompt: t('editor_discard_edits_prompt'),
confirmText: t('editor_discard_edits_confirm'),
});
this.isShowingConfirmDialog = false;
@@ -110,31 +118,36 @@ export class EditManager {
this.isApplyingEdits = true;
const edits = this.tools.flatMap((tool) => tool.manager.edits);
if (!this.currentAsset) {
return false;
}
const assetId = this.currentAsset.id;
const t = await getFormatter();
try {
// Setup the websocket listener before sending the edit request
const editCompleted = waitForWebsocketEvent(
'AssetEditReadyV1',
(event) => event.asset.id === this.currentAsset!.id,
10_000,
);
const editCompleted = waitForWebsocketEvent('AssetEditReadyV1', (event) => event.asset.id === assetId, 10_000);
await (edits.length === 0
? removeAssetEdits({ id: this.currentAsset!.id })
? removeAssetEdits({ id: assetId })
: editAsset({
id: this.currentAsset!.id,
id: assetId,
assetEditActionListDto: {
edits,
},
}));
await editCompleted;
toastManager.success('Edits applied successfully');
eventManager.emit('AssetEditsApplied', assetId);
toastManager.success(t('editor_edits_applied_success'));
this.hasAppliedEdits = true;
return true;
} catch {
toastManager.danger('Failed to apply edits');
toastManager.danger(t('editor_edits_applied_error'));
return false;
} finally {
this.isApplyingEdits = false;
@@ -1,16 +1,9 @@
import { editManager, type EditActions, type EditToolManager } from '$lib/managers/edit/edit-manager.svelte';
import { getAssetThumbnailUrl } from '$lib/utils';
import { getAssetMediaUrl } from '$lib/utils';
import { getDimensions } from '$lib/utils/asset-utils';
import { normalizeTransformEdits } from '$lib/utils/editor';
import { handleError } from '$lib/utils/handle-error';
import {
AssetEditAction,
AssetMediaSize,
MirrorAxis,
type AssetResponseDto,
type CropParameters,
type MirrorParameters,
type RotateParameters,
} from '@immich/sdk';
import { AssetEditAction, AssetMediaSize, MirrorAxis, type AssetResponseDto, type CropParameters } from '@immich/sdk';
import { tick } from 'svelte';
export type CropAspectRatio =
@@ -45,7 +38,8 @@ type RegionConvertParams = {
};
class TransformManager implements EditToolManager {
hasChanges: boolean = $derived.by(() => this.checkEdits());
canReset: boolean = $derived.by(() => this.checkEdits());
hasChanges: boolean = $state(false);
darkenLevel = $state(0.65);
isInteracting = $state(false);
@@ -63,7 +57,7 @@ class TransformManager implements EditToolManager {
cropAspectRatio = $state('free');
originalImageSize = $state<ImageDimensions>({ width: 1000, height: 1000 });
region = $state({ x: 0, y: 0, width: 100, height: 100 });
preveiwImgSize = $derived({
previewImageSize = $derived({
width: this.cropImageSize.width * this.cropImageScale,
height: this.cropImageSize.height * this.cropImageScale,
});
@@ -80,6 +74,7 @@ class TransformManager implements EditToolManager {
edits = $derived.by(() => this.getEdits());
setAspectRatio(aspectRatio: string) {
this.hasChanges = true;
this.cropAspectRatio = aspectRatio;
if (!this.imgElement || !this.cropAreaEl) {
@@ -95,8 +90,8 @@ class TransformManager implements EditToolManager {
checkEdits() {
return (
Math.abs(this.preveiwImgSize.width - this.region.width) > 2 ||
Math.abs(this.preveiwImgSize.height - this.region.height) > 2 ||
Math.abs(this.previewImageSize.width - this.region.width) > 2 ||
Math.abs(this.previewImageSize.height - this.region.height) > 2 ||
this.mirrorHorizontal ||
this.mirrorVertical ||
this.normalizedRotation !== 0
@@ -105,8 +100,8 @@ class TransformManager implements EditToolManager {
checkCropEdits() {
return (
Math.abs(this.preveiwImgSize.width - this.region.width) > 2 ||
Math.abs(this.preveiwImgSize.height - this.region.height) > 2
Math.abs(this.previewImageSize.width - this.region.width) > 2 ||
Math.abs(this.previewImageSize.height - this.region.height) > 2
);
}
@@ -185,7 +180,7 @@ class TransformManager implements EditToolManager {
this.imgElement = new Image();
const imageURL = getAssetThumbnailUrl({
const imageURL = getAssetMediaUrl({
id: asset.id,
cacheKey: asset.thumbhash,
edited: false,
@@ -200,22 +195,14 @@ class TransformManager implements EditToolManager {
globalThis.addEventListener('mousemove', (e) => transformManager.handleMouseMove(e), { passive: true });
// set the rotation before loading the image
const rotateEdit = edits.find((e) => e.action === 'rotate');
if (rotateEdit) {
this.imageRotation = (rotateEdit.parameters as RotateParameters).angle;
}
const transformEdits = edits.filter((e) => e.action === 'rotate' || e.action === 'mirror');
// set mirror state from edits
const mirrorEdits = edits.filter((e) => e.action === 'mirror');
for (const mirrorEdit of mirrorEdits) {
const axis = (mirrorEdit.parameters as MirrorParameters).axis;
if (axis === MirrorAxis.Horizontal) {
this.mirrorHorizontal = true;
} else if (axis === MirrorAxis.Vertical) {
this.mirrorVertical = true;
}
}
// Normalize rotation and mirror edits to single rotation and mirror state
// This allows edits to be imported in any order and still produce correct state
const normalizedTransformation = normalizeTransformEdits(transformEdits);
this.imageRotation = normalizedTransformation.rotation;
this.mirrorHorizontal = normalizedTransformation.mirrorHorizontal;
this.mirrorVertical = normalizedTransformation.mirrorVertical;
await tick();
@@ -236,6 +223,10 @@ class TransformManager implements EditToolManager {
this.dragOffset = { x: 0, y: 0 };
this.resizeSide = '';
this.imgElement = null;
if (this.cropAreaEl) {
this.cropAreaEl.style.cursor = '';
}
document.body.style.cursor = '';
this.cropAreaEl = null;
this.isDragging = false;
this.overlayEl = null;
@@ -247,9 +238,12 @@ class TransformManager implements EditToolManager {
this.originalImageSize = { width: 1000, height: 1000 };
this.cropImageScale = 1;
this.cropAspectRatio = 'free';
this.hasChanges = false;
}
mirror(axis: 'horizontal' | 'vertical') {
this.hasChanges = true;
if (this.imageRotation % 180 !== 0) {
axis = axis === 'horizontal' ? 'vertical' : 'horizontal';
}
@@ -262,6 +256,8 @@ class TransformManager implements EditToolManager {
}
async rotate(angle: number) {
this.hasChanges = true;
this.imageRotation += angle;
await tick();
this.onImageLoad();
@@ -775,6 +771,7 @@ class TransformManager implements EditToolManager {
return;
}
this.hasChanges = true;
const newX = Math.max(0, Math.min(mouseX - this.dragOffset.x, cropArea.clientWidth - this.region.width));
const newY = Math.max(0, Math.min(mouseY - this.dragOffset.y, cropArea.clientHeight - this.region.height));
@@ -796,6 +793,7 @@ class TransformManager implements EditToolManager {
}
this.fadeOverlay(false);
this.hasChanges = true;
const { x, y, width, height } = crop;
const minSize = 50;
let newRegion = { ...crop };
+4 -51
View File
@@ -1,5 +1,6 @@
import type { ThemeSetting } from '$lib/managers/theme-manager.svelte';
import type { ReleaseEvent } from '$lib/types';
import { BaseEventManager } from '$lib/utils/base-event-manager.svelte';
import type { TreeNode } from '$lib/utils/tree-utils';
import type {
AlbumResponseDto,
@@ -37,6 +38,8 @@ export type Events = {
AssetReplace: [{ oldAssetId: string; newAssetId: string }];
AssetsArchive: [string[]];
AssetsDelete: [string[]];
AssetEditsApplied: [string];
AssetsTag: [string[]];
AlbumAddAssets: [];
AlbumUpdate: [AlbumResponseDto];
@@ -92,54 +95,4 @@ export type Events = {
ReleaseEvent: [ReleaseEvent];
};
type Listener<EventMap extends Record<string, unknown[]>, K extends keyof EventMap> = (...params: EventMap[K]) => void;
class EventManager<EventMap extends Record<string, unknown[]>> {
private listeners: {
[K in keyof EventMap]?: {
listener: Listener<EventMap, K>;
once?: boolean;
}[];
} = {};
on<T extends keyof EventMap>(key: T, listener: (...params: EventMap[T]) => void) {
return this.addListener(key, listener, false);
}
once<T extends keyof EventMap>(key: T, listener: (...params: EventMap[T]) => void) {
return this.addListener(key, listener, true);
}
off<K extends keyof EventMap>(key: K, listener: Listener<EventMap, K>) {
if (this.listeners[key]) {
this.listeners[key] = this.listeners[key].filter((item) => item.listener !== listener);
}
return this;
}
emit<T extends keyof EventMap>(key: T, ...params: EventMap[T]) {
if (!this.listeners[key]) {
return;
}
for (const { listener } of this.listeners[key]) {
listener(...params);
}
// remove one time listeners
this.listeners[key] = this.listeners[key].filter((item) => !item.once);
}
private addListener<T extends keyof EventMap>(key: T, listener: (...params: EventMap[T]) => void, once: boolean) {
if (!this.listeners[key]) {
this.listeners[key] = [];
}
this.listeners[key].push({ listener, once });
return this;
}
}
export const eventManager = new EventManager<Events>();
export const eventManager = new BaseEventManager<Events>();
@@ -5,7 +5,9 @@ class FeatureFlagsManager {
#value?: ServerFeaturesDto = $state();
constructor() {
eventManager.on('SystemConfigUpdate', () => void this.#loadFeatureFlags());
eventManager.on({
SystemConfigUpdate: () => void this.#loadFeatureFlags(),
});
}
async init() {
@@ -4,7 +4,9 @@ import { lang } from '$lib/stores/preferences.store';
class LanguageManager {
constructor() {
eventManager.on('AppInit', () => lang.subscribe((lang) => this.setLanguage(lang)));
eventManager.on({
AppInit: () => lang.subscribe((lang) => this.setLanguage(lang)),
});
}
rtl = $state(false);
+3 -1
View File
@@ -19,7 +19,9 @@ export class QueueManager {
}
constructor() {
eventManager.on('QueueUpdate', () => void this.refresh());
eventManager.on({
QueueUpdate: () => this.refresh(),
});
}
listen() {
@@ -5,7 +5,9 @@ class ReleaseManager {
value = $state<ReleaseEvent | undefined>();
constructor() {
eventManager.on('ReleaseEvent', (event) => (this.value = event));
eventManager.on({
ReleaseEvent: (event) => (this.value = event),
});
}
}
@@ -5,7 +5,9 @@ class ServerConfigManager {
#value?: ServerConfigDto = $state();
constructor() {
eventManager.on('SystemConfigUpdate', () => void this.loadServerConfig());
eventManager.on({
SystemConfigUpdate: () => this.loadServerConfig(),
});
}
async init() {
@@ -7,7 +7,9 @@ class SystemConfigManager {
#defaultValue?: SystemConfigDto = $state();
constructor() {
eventManager.on('SystemConfigUpdate', (config) => (this.#value = config));
eventManager.on({
SystemConfigUpdate: (config) => (this.#value = config),
});
}
async init() {
+13 -11
View File
@@ -2,7 +2,7 @@ import { browser } from '$app/environment';
import { Theme } from '$lib/constants';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { PersistedLocalStorage } from '$lib/utils/persisted';
import { theme as uiTheme, type Theme as UiTheme } from '@immich/ui';
import { onThemeChange as onUiThemeChange, theme as uiTheme, type Theme as UiTheme } from '@immich/ui';
export interface ThemeSetting {
value: Theme;
@@ -37,7 +37,9 @@ class ThemeManager {
isDark = $derived(this.value === Theme.DARK);
constructor() {
eventManager.on('AppInit', () => this.#onAppInit());
eventManager.on({
AppInit: () => this.#onAppInit(),
});
}
setSystem(system: boolean) {
@@ -53,15 +55,14 @@ class ThemeManager {
}
#onAppInit() {
globalThis.matchMedia('(prefers-color-scheme: dark)').addEventListener(
'change',
() => {
if (this.theme.system) {
this.#update('system');
}
},
{ passive: true },
);
const syncSystemTheme = () => {
this.#update(this.theme.system ? 'system' : this.theme.value);
};
syncSystemTheme();
globalThis.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', syncSystemTheme, {
passive: true,
});
}
#update(value: Theme | 'system') {
@@ -73,6 +74,7 @@ class ThemeManager {
this.#theme.current = theme;
uiTheme.value = theme.value as unknown as UiTheme;
onUiThemeChange();
eventManager.emit('ThemeChange', theme);
}
@@ -111,11 +111,11 @@ export class TimelineManager extends VirtualScrollManager {
constructor() {
super();
const onAssetUpdate = (asset: AssetResponseDto) => this.upsertAssets([toTimelineAsset(asset)]);
eventManager.on('AssetUpdate', onAssetUpdate);
this.#unsubscribes.push(() => eventManager.off('AssetUpdate', onAssetUpdate));
this.#unsubscribes.push(
eventManager.on({
AssetUpdate: (asset: AssetResponseDto) => this.upsertAssets([toTimelineAsset(asset)]),
}),
);
}
override get scrollTop(): number {
@@ -6,7 +6,10 @@ class UploadManager {
mediaTypes = $state<ServerMediaTypesResponseDto>({ image: [], sidecar: [], video: [] });
constructor() {
eventManager.on('AppInit', () => void this.#loadExtensions()).on('AuthLogout', () => void this.reset());
eventManager.on({
AppInit: () => this.#loadExtensions(),
AuthLogout: () => this.reset(),
});
}
reset() {
+22 -14
View File
@@ -2,7 +2,7 @@
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
import { handleAddUsersToAlbum } from '$lib/services/album.service';
import { searchUsers, type AlbumResponseDto, type UserResponseDto } from '@immich/sdk';
import { FormModal, ListButton, Stack, Text } from '@immich/ui';
import { FormModal, ListButton, LoadingSpinner, Stack, Text } from '@immich/ui';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import { SvelteMap } from 'svelte/reactivity';
@@ -18,6 +18,7 @@
const excludedUserIds = $derived([album.ownerId, ...album.albumUsers.map(({ user: { id } }) => id)]);
const filteredUsers = $derived(users.filter(({ id }) => !excludedUserIds.includes(id)));
const selectedUsers = new SvelteMap<string, UserResponseDto>();
let loading = $state(true);
const handleToggle = (user: UserResponseDto) => {
if (selectedUsers.has(user.id)) {
@@ -36,6 +37,7 @@
onMount(async () => {
users = await searchUsers();
loading = false;
});
</script>
@@ -47,17 +49,23 @@
disabled={selectedUsers.size === 0}
{onClose}
>
<Stack>
{#each filteredUsers as user (user.id)}
<ListButton selected={selectedUsers.has(user.id)} onclick={() => handleToggle(user)}>
<UserAvatar {user} size="md" />
<div class="text-start grow">
<Text fontWeight="medium">{user.name}</Text>
<Text size="tiny" color="muted">{user.email}</Text>
</div>
</ListButton>
{:else}
<Text class="py-6">{$t('album_share_no_users')}</Text>
{/each}
</Stack>
{#if loading}
<div class="w-full flex place-items-center place-content-center">
<LoadingSpinner />
</div>
{:else}
<Stack>
{#each filteredUsers as user (user.id)}
<ListButton selected={selectedUsers.has(user.id)} onclick={() => handleToggle(user)}>
<UserAvatar {user} size="md" />
<div class="text-start grow">
<Text fontWeight="medium">{user.name}</Text>
<Text size="tiny" color="muted">{user.email}</Text>
</div>
</ListButton>
{:else}
<Text class="py-6">{$t('album_share_no_users')}</Text>
{/each}
</Stack>
{/if}
</FormModal>
+4 -4
View File
@@ -3,13 +3,13 @@
import HeaderActionButton from '$lib/components/HeaderActionButton.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
import { AlbumPageViewMode } from '$lib/constants';
import {
getAlbumActions,
handleRemoveUserFromAlbum,
handleUpdateAlbum,
handleUpdateUserAlbumRole,
} from '$lib/services/album.service';
import { user } from '$lib/stores/user.store';
import {
AlbumUserRole,
AssetOrder,
@@ -56,7 +56,7 @@
sharedLinks = sharedLinks.filter(({ id }) => sharedLink.id !== id);
};
const { AddUsers, CreateSharedLink } = $derived(getAlbumActions($t, album));
const { AddUsers, CreateSharedLink } = $derived(getAlbumActions($t, album, AlbumPageViewMode.OPTIONS));
let sharedLinks: SharedLinkResponseDto[] = $state([]);
@@ -108,9 +108,9 @@
<div class="ps-2">
<div class="flex items-center gap-2 mb-2">
<div>
<UserAvatar user={$user} size="md" />
<UserAvatar user={album.owner} size="md" />
</div>
<Text class="w-full" size="small">{$user.name}</Text>
<Text class="w-full" size="small">{album.owner.name}</Text>
<Field disabled class="w-32 shrink-0">
<Select options={[{ label: $t('owner'), value: 'owner' }]} value="owner" />
</Field>
@@ -59,12 +59,7 @@
size="small"
>
<Label for="datetime" class="block mb-1">{$t('date_and_time')}</Label>
<DateInput
class="immich-form-input text-gray-700 w-full mb-2"
id="datetime"
type="datetime-local"
bind:value={selectedDate}
/>
<DateInput class="immich-form-input w-full mb-2" id="datetime" type="datetime-local" bind:value={selectedDate} />
{#if timezoneInput}
<div class="w-full">
<Combobox bind:selectedOption label={$t('timezone')} options={timezones} placeholder={$t('search_timezone')} />
@@ -77,11 +77,7 @@
</Field>
{#if showRelative}
<Label for="relativedatetime" class="block mb-1">{$t('offset')}</Label>
<DurationInput
class="immich-form-input w-full text-gray-700 mb-2"
id="relativedatetime"
bind:value={selectedDuration}
/>
<DurationInput class="immich-form-input w-full mb-2" id="relativedatetime" bind:value={selectedDuration} />
{:else}
<Label for="datetime" class="block mb-1">{$t('date_and_time')}</Label>
<DateInput class="immich-form-input w-full mb-2" id="datetime" type="datetime-local" bind:value={selectedDate} />
+6 -4
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { eventManager } from '$lib/managers/event-manager.svelte';
import { tagAssets } from '$lib/utils/asset-utils';
import { getAllTags, upsertTags, type TagResponseDto } from '@immich/sdk';
import { FormModal, Icon } from '@immich/ui';
@@ -9,7 +10,7 @@
import Combobox, { type ComboBoxOption } from '../components/shared-components/combobox.svelte';
interface Props {
onClose: (success?: true) => void;
onClose: () => void;
assetIds: string[];
}
@@ -30,8 +31,9 @@
return;
}
await tagAssets({ tagIds: [...selectedIds], assetIds, showNotification: false });
onClose(true);
const updatedIds = await tagAssets({ tagIds: [...selectedIds], assetIds, showNotification: false });
eventManager.emit('AssetsTag', updatedIds);
onClose();
};
const handleSelect = async (option?: ComboBoxOption) => {
@@ -80,7 +82,7 @@
{#if tag}
<div class="flex group transition-all">
<span
class="inline-block h-min whitespace-nowrap ps-3 pe-1 group-hover:ps-3 py-1 text-center align-baseline leading-none text-gray-100 dark:text-immich-dark-gray bg-primary roudned-s-full hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
class="inline-block h-min whitespace-nowrap ps-3 pe-1 group-hover:ps-3 py-1 text-center align-baseline leading-none text-gray-100 dark:text-immich-dark-gray bg-primary rounded-s-full hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
>
<p class="text-sm">
{tag.value}
+25 -36
View File
@@ -1,8 +1,7 @@
<script lang="ts">
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
import { getPartners, PartnerDirection, searchUsers, type UserResponseDto } from '@immich/sdk';
import { Button, Modal, ModalBody, ModalFooter } from '@immich/ui';
import { onMount } from 'svelte';
import { Button, ListButton, LoadingSpinner, Modal, ModalBody, ModalFooter, Text } from '@immich/ui';
import { t } from 'svelte-i18n';
interface Props {
@@ -15,7 +14,7 @@
let availableUsers: UserResponseDto[] = $state([]);
let selectedUsers: UserResponseDto[] = $state([]);
onMount(async () => {
const loadUsers = async () => {
let users = await searchUsers();
// remove current user
@@ -25,7 +24,7 @@
const partners = await getPartners({ direction: PartnerDirection.SharedBy });
const partnerIds = new Set(partners.map((partner) => partner.id));
availableUsers = users.filter((user) => !partnerIds.has(user.id));
});
};
const selectUser = (user: UserResponseDto) => {
selectedUsers = selectedUsers.includes(user)
@@ -36,44 +35,34 @@
<Modal title={$t('add_partner')} {onClose} size="small">
<ModalBody>
<div class="immich-scrollbar max-h-75 overflow-y-auto">
{#await loadUsers()}
<div class="w-full flex place-items-center place-content-center">
<LoadingSpinner />
</div>
{:then _}
{#if availableUsers.length > 0}
{#each availableUsers as user (user.id)}
<button
type="button"
onclick={() => selectUser(user)}
class="flex w-full place-items-center gap-4 px-5 py-4 transition-all hover:bg-gray-200 dark:hover:bg-gray-700 rounded-xl"
>
{#if selectedUsers.includes(user)}
<span
class="flex h-12 w-12 place-content-center place-items-center rounded-full border bg-immich-primary text-3xl text-white dark:border-immich-dark-gray dark:bg-immich-dark-primary dark:text-immich-dark-bg"
>✓</span
>
{:else}
<UserAvatar {user} size="lg" />
{/if}
<div class="immich-scrollbar max-h-75 overflow-y-auto gap-2 flex flex-col">
{#each availableUsers as user (user.id)}
<ListButton onclick={() => selectUser(user)} selected={selectedUsers.includes(user)}>
<UserAvatar {user} size="md" />
<div class="text-start grow">
<Text fontWeight="medium">{user.name}</Text>
<Text size="tiny" color="muted">{user.email}</Text>
</div>
</ListButton>
{/each}
<div class="text-start">
<p class="text-immich-fg dark:text-immich-dark-fg">
{user.name}
</p>
<p class="text-xs">
{user.email}
</p>
</div>
</button>
{/each}
<ModalFooter>
{#if selectedUsers.length > 0}
<Button shape="round" fullWidth onclick={() => onClose(selectedUsers)}>{$t('add')}</Button>
{/if}
</ModalFooter>
</div>
{:else}
<p class="py-5 text-sm">
{$t('photo_shared_all_users')}
</p>
{/if}
<ModalFooter>
{#if selectedUsers.length > 0}
<Button shape="round" fullWidth onclick={() => onClose(selectedUsers)}>{$t('add')}</Button>
{/if}
</ModalFooter>
</div>
{/await}
</ModalBody>
</Modal>
+11 -40
View File
@@ -1,8 +1,8 @@
<script lang="ts">
import SharedLinkExpiration from '$lib/components/SharedLinkExpiration.svelte';
import SharedLinkFormFields from '$lib/components/SharedLinkFormFields.svelte';
import { handleCreateSharedLink } from '$lib/services/shared-link.service';
import { SharedLinkType } from '@immich/sdk';
import { Field, FormModal, Input, PasswordInput, Switch, Text } from '@immich/ui';
import { FormModal } from '@immich/ui';
import { mdiLink } from '@mdi/js';
import { t } from 'svelte-i18n';
@@ -24,12 +24,6 @@
let type = $derived(albumId ? SharedLinkType.Album : SharedLinkType.Individual);
$effect(() => {
if (!showMetadata) {
allowDownload = false;
}
});
const onSubmit = async () => {
const success = await handleCreateSharedLink({
type,
@@ -65,36 +59,13 @@
<div>{$t('create_link_to_share_description')}</div>
{/if}
<div class="flex flex-col gap-4 mt-4">
<div>
<Field label={$t('custom_url')} description={$t('shared_link_custom_url_description')}>
<Input bind:value={slug} autocomplete="off" />
</Field>
{#if slug}
<Text size="tiny" color="muted" class="pt-2 break-all">/s/{encodeURIComponent(slug)}</Text>
{/if}
</div>
<Field label={$t('password')} description={$t('shared_link_password_description')}>
<PasswordInput bind:value={password} autocomplete="new-password" />
</Field>
<Field label={$t('description')}>
<Input bind:value={description} autocomplete="off" />
</Field>
<SharedLinkExpiration bind:expiresAt />
<Field label={$t('show_metadata')}>
<Switch bind:checked={showMetadata} />
</Field>
<Field label={$t('allow_public_user_to_download')} disabled={!showMetadata}>
<Switch bind:checked={allowDownload} />
</Field>
<Field label={$t('allow_public_user_to_upload')}>
<Switch bind:checked={allowUpload} />
</Field>
</div>
<SharedLinkFormFields
bind:slug
bind:password
bind:description
bind:allowDownload
bind:allowUpload
bind:showMetadata
bind:expiresAt
/>
</FormModal>
@@ -20,6 +20,7 @@
slideshowLook,
slideshowTransition,
slideshowAutoplay,
slideshowRepeat,
slideshowState,
} = slideshowStore;
@@ -36,6 +37,7 @@
let tempSlideshowLook = $state($slideshowLook);
let tempSlideshowTransition = $state($slideshowTransition);
let tempSlideshowAutoplay = $state($slideshowAutoplay);
let tempSlideshowRepeat = $state($slideshowRepeat);
const navigationOptions: Record<SlideshowNavigation, RenderedOption> = {
[SlideshowNavigation.Shuffle]: { icon: mdiShuffle, title: $t('shuffle') },
@@ -67,6 +69,7 @@
$slideshowLook = tempSlideshowLook;
$slideshowTransition = tempSlideshowTransition;
$slideshowAutoplay = tempSlideshowAutoplay;
$slideshowRepeat = tempSlideshowRepeat;
$slideshowState = SlideshowState.PlaySlideshow;
onClose();
};
@@ -104,6 +107,10 @@
<Switch bind:checked={tempSlideshowTransition} />
</Field>
<Field label={$t('slideshow_repeat')} description={$t('slideshow_repeat_description')}>
<Switch bind:checked={tempSlideshowRepeat} />
</Field>
<Field label={$t('duration')}>
<NumberInput min={1} bind:value={tempSlideshowDelay} />
<HelperText>{$t('admin.slideshow_duration_description')}</HelperText>
+13 -3
View File
@@ -1,5 +1,6 @@
import { goto } from '$app/navigation';
import ToastAction from '$lib/components/ToastAction.svelte';
import { AlbumPageViewMode } from '$lib/constants';
import { eventManager } from '$lib/managers/event-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import AlbumAddUsersModal from '$lib/modals/AlbumAddUsersModal.svelte';
@@ -25,7 +26,7 @@ import {
type UserResponseDto,
} from '@immich/sdk';
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
import { mdiLink, mdiPlus, mdiPlusBoxOutline, mdiShareVariantOutline, mdiUpload } from '@mdi/js';
import { mdiArrowLeft, mdiLink, mdiPlus, mdiPlusBoxOutline, mdiShareVariantOutline, mdiUpload } from '@mdi/js';
import { type MessageFormatter } from 'svelte-i18n';
import { get } from 'svelte/store';
@@ -39,7 +40,7 @@ export const getAlbumsActions = ($t: MessageFormatter) => {
return { Create };
};
export const getAlbumActions = ($t: MessageFormatter, album: AlbumResponseDto) => {
export const getAlbumActions = ($t: MessageFormatter, album: AlbumResponseDto, viewMode: AlbumPageViewMode) => {
const isOwned = get(user).id === album.ownerId;
const Share: ActionItem = {
@@ -66,7 +67,16 @@ export const getAlbumActions = ($t: MessageFormatter, album: AlbumResponseDto) =
onAction: () => modalManager.show(SharedLinkCreateModal, { albumId: album.id }),
};
return { Share, AddUsers, CreateSharedLink };
const Close: ActionItem = {
title: $t('go_back'),
type: $t('command'),
icon: mdiArrowLeft,
onAction: () => goto(Route.albums()),
$if: () => viewMode === AlbumPageViewMode.VIEW,
shortcuts: { key: 'Escape' },
};
return { Share, AddUsers, CreateSharedLink, Close };
};
export const getAlbumAssetsActions = ($t: MessageFormatter, album: AlbumResponseDto, assets: TimelineAsset[]) => {

Some files were not shown because too many files have changed in this diff Show More