mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
Merge remote-tracking branch 'origin/main' into feat/integrity-checks-izzy
Signed-off-by: izzy <me@insrt.uk>
This commit is contained in:
+4
-3
@@ -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(),
|
||||
}));
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user