merge: remote-tracking branch 'origin/main' into feat/database-restores

This commit is contained in:
izzy
2025-12-01 10:18:25 +00:00
41 changed files with 293 additions and 247 deletions

View File

@@ -97,7 +97,7 @@
"prettier-plugin-sort-json": "^4.1.1",
"prettier-plugin-svelte": "^3.3.3",
"rollup-plugin-visualizer": "^6.0.0",
"svelte": "5.43.12",
"svelte": "5.45.2",
"svelte-check": "^4.1.5",
"svelte-eslint-parser": "^1.3.3",
"tailwindcss": "^4.1.7",

View File

@@ -512,7 +512,7 @@
{:else if asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || (asset.originalPath && asset.originalPath
.toLowerCase()
.endsWith('.insp'))}
<ImagePanoramaViewer {asset} />
<ImagePanoramaViewer bind:zoomToggle {asset} />
{:else if isShowEditor && selectedEditType === 'crop'}
<CropArea {asset} />
{:else}

View File

@@ -7,11 +7,12 @@
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
interface Props {
type Props = {
asset: AssetResponseDto;
}
zoomToggle?: (() => void) | null;
};
const { asset }: Props = $props();
let { asset, zoomToggle = $bindable() }: Props = $props();
const loadAssetData = async (id: string) => {
const data = await viewAsset({ ...authManager.params, id, size: AssetMediaSize.Preview });
@@ -24,6 +25,7 @@
<LoadingSpinner />
{:then [data, { default: PhotoSphereViewer }]}
<PhotoSphereViewer
bind:zoomToggle
panorama={data}
originalPanorama={isWebCompatibleImage(asset)
? getAssetOriginalUrl(asset.id)

View File

@@ -1,6 +1,8 @@
<script lang="ts">
import { shortcuts } from '$lib/actions/shortcut';
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,
@@ -24,15 +26,23 @@
strokeLinejoin: 'round',
};
interface Props {
type Props = {
panorama: string | { source: string };
originalPanorama?: string | { source: string };
adapter?: AdapterConstructor | [AdapterConstructor, unknown];
plugins?: (PluginConstructor | [PluginConstructor, unknown])[];
navbar?: boolean;
}
zoomToggle?: (() => void) | null;
};
let { panorama, originalPanorama, adapter = EquirectangularAdapter, plugins = [], navbar = false }: Props = $props();
let {
panorama,
originalPanorama,
adapter = EquirectangularAdapter,
plugins = [],
navbar = false,
zoomToggle = $bindable(),
}: Props = $props();
let container: HTMLDivElement | undefined = $state();
let viewer: Viewer;
@@ -93,6 +103,14 @@
}
});
zoomToggle = () => {
if (!viewer) {
return;
}
viewer.animate({ zoom: $photoZoomState.currentZoom > 1 ? 50 : 83.3, speed: 250 });
};
let hasChangedResolution: boolean = false;
onMount(() => {
if (!container) {
return;
@@ -139,10 +157,15 @@
const resolutionPlugin = viewer.getPlugin<ResolutionPlugin>(ResolutionPlugin);
const zoomHandler = ({ zoomLevel }: events.ZoomUpdatedEvent) => {
// zoomLevel range: [0, 100]
if (Math.round(zoomLevel) >= 75) {
photoZoomState.set({
...$photoZoomState,
currentZoom: zoomLevel / 50,
});
if (Math.round(zoomLevel) >= 75 && !hasChangedResolution) {
// Replace the preview with the original
void resolutionPlugin.setResolution('original');
viewer.removeEventListener(events.ZoomUpdatedEvent.type, zoomHandler);
hasChangedResolution = true;
}
};
@@ -158,7 +181,13 @@
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,
});
});
</script>
<svelte:document use:shortcuts={[{ shortcut: { key: 'z' }, onShortcut: zoomToggle, preventDefault: true }]} />
<div class="h-full w-full mb-0" bind:this={container}></div>

View File

@@ -4,7 +4,7 @@
import { getAssetOriginalUrl, getAssetPlaybackUrl, getAssetThumbnailUrl } from '$lib/utils';
import { timeToSeconds } from '$lib/utils/date-time';
import { getAltText } from '$lib/utils/thumbnail-util';
import { AssetMediaSize, AssetVisibility } from '@immich/sdk';
import { AssetMediaSize, AssetVisibility, type UserResponseDto } from '@immich/sdk';
import {
mdiArchiveArrowDownOutline,
mdiCameraBurst,
@@ -46,6 +46,7 @@
imageClass?: ClassValue;
brokenAssetClass?: ClassValue;
dimmed?: boolean;
albumUsers?: UserResponseDto[];
onClick?: (asset: TimelineAsset) => void;
onSelect?: (asset: TimelineAsset) => void;
onMouseEvent?: (event: { isMouseOver: boolean; selectedGroupIndex: number }) => void;
@@ -64,6 +65,7 @@
readonly = false,
showArchiveIcon = false,
showStackedIcon = true,
albumUsers = [],
onClick = undefined,
onSelect = undefined,
onMouseEvent = undefined,
@@ -85,6 +87,8 @@
let width = $derived(thumbnailSize || thumbnailWidth || 235);
let height = $derived(thumbnailSize || thumbnailHeight || 235);
let assetOwner = $derived(albumUsers?.find((user) => user.id === asset.ownerId) ?? null);
const onIconClickedHandler = (e?: MouseEvent) => {
e?.stopPropagation();
e?.preventDefault();
@@ -268,6 +272,14 @@
</div>
{/if}
{#if !!assetOwner}
<div class="absolute bottom-1 end-2 max-w-[50%]">
<p class="text-xs font-medium text-white drop-shadow-lg max-w-[100%] truncate">
{assetOwner.name}
</p>
</div>
{/if}
{#if !authManager.isSharedLink && showArchiveIcon && asset.visibility === AssetVisibility.Archive}
<div class={['absolute start-2', asset.isFavorite ? 'bottom-10' : 'bottom-2']}>
<Icon data-icon-archive icon={mdiArchiveArrowDownOutline} size="24" class="text-white" />

View File

@@ -23,7 +23,7 @@
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { isAssetViewerRoute, navigate } from '$lib/utils/navigation';
import { getTimes, type ScrubberListener } from '$lib/utils/timeline-util';
import { type AlbumResponseDto, type PersonResponseDto } from '@immich/sdk';
import { type AlbumResponseDto, type PersonResponseDto, type UserResponseDto } from '@immich/sdk';
import { DateTime } from 'luxon';
import { onDestroy, onMount, type Snippet } from 'svelte';
import type { UpdatePayload } from 'vite';
@@ -49,6 +49,7 @@
showArchiveIcon?: boolean;
isShared?: boolean;
album?: AlbumResponseDto | null;
albumUsers?: UserResponseDto[];
person?: PersonResponseDto | null;
isShowDeleteConfirmation?: boolean;
onSelect?: (asset: TimelineAsset) => void;
@@ -81,6 +82,7 @@
showArchiveIcon = false,
isShared = false,
album = null,
albumUsers = [],
person = null,
isShowDeleteConfirmation = $bindable(false),
onSelect = () => {},
@@ -702,6 +704,7 @@
showStackedIcon={withStacked}
{showArchiveIcon}
{asset}
{albumUsers}
{groupIndex}
onClick={(asset) => {
if (typeof onThumbnailClick === 'function') {

View File

@@ -14,7 +14,7 @@
const { onClose, baseTag }: Props = $props();
let tagValue = $state(baseTag?.value ? `${baseTag.value}/` : '');
let tagValue = $state(baseTag?.path ? `${baseTag.path}/` : '');
const createTag = async () => {
const [tag] = await upsertTags({ tagUpsertDto: { tags: [tagValue] } });

View File

@@ -66,6 +66,7 @@
} from '@immich/sdk';
import { Button, Icon, IconButton, modalManager, toastManager } from '@immich/ui';
import {
mdiAccountEyeOutline,
mdiArrowLeft,
mdiCogOutline,
mdiDeleteOutline,
@@ -100,6 +101,7 @@
let isCreatingSharedAlbum = $state(false);
let isShowActivity = $state(false);
let albumOrder: AssetOrder | undefined = $state(data.album.order);
let showAlbumUsers = $state(false);
const assetInteraction = new AssetInteraction();
const timelineInteraction = new AssetInteraction();
@@ -290,6 +292,11 @@
let album = $derived(data.album);
let albumId = $derived(album.id);
const containsEditors = $derived(album?.shared && album.albumUsers.some(({ role }) => role === AlbumUserRole.Editor));
const albumUsers = $derived(
showAlbumUsers && containsEditors ? [album.owner, ...album.albumUsers.map(({ user }) => user)] : [],
);
$effect(() => {
if (!album.isActivityEnabled && activityManager.commentCount === 0) {
isShowActivity = false;
@@ -418,6 +425,7 @@
<Timeline
enableRouting={viewMode === AlbumPageViewMode.SELECT_ASSETS ? false : true}
{album}
{albumUsers}
bind:timelineManager
{options}
assetInteraction={currentAssetIntersection}
@@ -597,6 +605,17 @@
{#snippet trailing()}
<CastButton />
{#if containsEditors}
<IconButton
variant="ghost"
shape="round"
color="secondary"
aria-label="view asset owners"
icon={mdiAccountEyeOutline}
onclick={() => (showAlbumUsers = !showAlbumUsers)}
/>
{/if}
{#if isEditor}
<IconButton
variant="ghost"