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

This commit is contained in:
izzy
2025-11-24 16:52:41 +00:00
31 changed files with 795 additions and 355 deletions

View File

@@ -39,13 +39,17 @@ export const shortcutLabel = (shortcut: Shortcut) => {
/** Determines whether an event should be ignored. The event will be ignored if:
* - The element dispatching the event is not the same as the element which the event listener is attached to
* - The element dispatching the event is an input field
* - The element dispatching the event is a map canvas
*/
export const shouldIgnoreEvent = (event: KeyboardEvent | ClipboardEvent): boolean => {
if (event.target === event.currentTarget) {
return false;
}
const type = (event.target as HTMLInputElement).type;
return ['textarea', 'text', 'date', 'datetime-local', 'email', 'password'].includes(type);
return (
['textarea', 'text', 'date', 'datetime-local', 'email', 'password'].includes(type) ||
(event.target instanceof HTMLCanvasElement && event.target.classList.contains('maplibregl-canvas'))
);
};
export const matchesShortcut = (event: KeyboardEvent, shortcut: Shortcut) => {

View File

@@ -33,7 +33,6 @@
import { groupBy } from 'lodash-es';
import { onMount, type Snippet } from 'svelte';
import { t } from 'svelte-i18n';
import { run } from 'svelte/legacy';
interface Props {
ownedAlbums?: AlbumResponseDto[];
@@ -128,65 +127,45 @@
},
};
let albums: AlbumResponseDto[] = $state([]);
let filteredAlbums: AlbumResponseDto[] = $state([]);
let groupedAlbums: AlbumGroup[] = $state([]);
let albums = $derived.by(() => {
switch (userSettings.filter) {
case AlbumFilter.Owned: {
return ownedAlbums;
}
case AlbumFilter.Shared: {
return sharedAlbums;
}
default: {
const nonOwnedAlbums = sharedAlbums.filter((album) => album.ownerId !== $user.id);
return nonOwnedAlbums.length > 0 ? ownedAlbums.concat(nonOwnedAlbums) : ownedAlbums;
}
}
});
const normalizedSearchQuery = $derived(normalizeSearchString(searchQuery));
let filteredAlbums = $derived(
normalizedSearchQuery
? albums.filter(({ albumName }) => normalizeSearchString(albumName).includes(normalizedSearchQuery))
: albums,
);
let albumGroupOption: string = $state(AlbumGroupBy.None);
let albumGroupOption = $derived(getSelectedAlbumGroupOption(userSettings));
let groupedAlbums = $derived.by(() => {
const groupFunc = groupOptions[albumGroupOption] ?? groupOptions[AlbumGroupBy.None];
const groupedAlbums = groupFunc(stringToSortOrder(userSettings.groupOrder), filteredAlbums);
let albumToShare: AlbumResponseDto | null = $state(null);
return groupedAlbums.map((group) => ({
id: group.id,
name: group.name,
albums: sortAlbums(group.albums, { sortBy: userSettings.sortBy, orderBy: userSettings.sortOrder }),
}));
});
let contextMenuPosition: ContextMenuPosition = $state({ x: 0, y: 0 });
let selectedAlbum: AlbumResponseDto | undefined = $state();
let isOpen = $state(false);
// Step 1: Filter between Owned and Shared albums, or both.
run(() => {
switch (userSettings.filter) {
case AlbumFilter.Owned: {
albums = ownedAlbums;
break;
}
case AlbumFilter.Shared: {
albums = sharedAlbums;
break;
}
default: {
const userId = $user.id;
const nonOwnedAlbums = sharedAlbums.filter((album) => album.ownerId !== userId);
albums = nonOwnedAlbums.length > 0 ? ownedAlbums.concat(nonOwnedAlbums) : ownedAlbums;
}
}
});
// Step 2: Filter using the given search query.
run(() => {
if (searchQuery) {
const searchAlbumNormalized = normalizeSearchString(searchQuery);
filteredAlbums = albums.filter((album) => {
return normalizeSearchString(album.albumName).includes(searchAlbumNormalized);
});
} else {
filteredAlbums = albums;
}
});
// Step 3: Group albums.
run(() => {
albumGroupOption = getSelectedAlbumGroupOption(userSettings);
const groupFunc = groupOptions[albumGroupOption] ?? groupOptions[AlbumGroupBy.None];
groupedAlbums = groupFunc(stringToSortOrder(userSettings.groupOrder), filteredAlbums);
});
// Step 4: Sort albums amongst each group.
run(() => {
groupedAlbums = groupedAlbums.map((group) => ({
id: group.id,
name: group.name,
albums: sortAlbums(group.albums, { sortBy: userSettings.sortBy, orderBy: userSettings.sortOrder }),
}));
// TODO get rid of this
$effect(() => {
albumGroupIds = groupedAlbums.map(({ id }) => id);
});
@@ -231,7 +210,7 @@
const result = await modalManager.show(AlbumShareModal, { album: selectedAlbum });
switch (result?.action) {
case 'sharedUsers': {
await handleAddUsers(result.data);
await handleAddUsers(selectedAlbum, result.data);
break;
}
@@ -300,22 +279,17 @@
updateRecentAlbumInfo(album);
};
const handleAddUsers = async (albumUsers: AlbumUserAddDto[]) => {
if (!albumToShare) {
return;
}
const handleAddUsers = async (album: AlbumResponseDto, albumUsers: AlbumUserAddDto[]) => {
try {
const album = await addUsersToAlbum({
id: albumToShare.id,
const updatedAlbum = await addUsersToAlbum({
id: album.id,
addUsersDto: {
albumUsers,
},
});
updateAlbumInfo(album);
updateAlbumInfo(updatedAlbum);
} catch (error) {
handleError(error, $t('errors.unable_to_add_album_users'));
} finally {
albumToShare = null;
}
};

View File

@@ -23,6 +23,7 @@
import { Icon, IconButton, LoadingSpinner, modalManager } from '@immich/ui';
import {
mdiCalendar,
mdiCamera,
mdiCameraIris,
mdiClose,
mdiEye,
@@ -372,9 +373,9 @@
</div>
</div>
{#if asset.exifInfo?.make || asset.exifInfo?.model || asset.exifInfo?.fNumber}
{#if asset.exifInfo?.make || asset.exifInfo?.model || asset.exifInfo?.exposureTime || asset.exifInfo?.iso}
<div class="flex gap-4 py-4">
<div><Icon icon={mdiCameraIris} size="24" /></div>
<div><Icon icon={mdiCamera} size="24" /></div>
<div>
{#if asset.exifInfo?.make || asset.exifInfo?.model}
@@ -395,20 +396,34 @@
</p>
{/if}
<div class="flex gap-2 text-sm">
{#if asset.exifInfo.exposureTime}
<p>{`${asset.exifInfo.exposureTime} s`}</p>
{/if}
{#if asset.exifInfo.iso}
<p>{`ISO ${asset.exifInfo.iso}`}</p>
{/if}
</div>
</div>
</div>
{/if}
{#if asset.exifInfo?.lensModel || asset.exifInfo?.fNumber || asset.exifInfo?.focalLength}
<div class="flex gap-4 py-4">
<div><Icon icon={mdiCameraIris} size="24" /></div>
<div>
{#if asset.exifInfo?.lensModel}
<div class="flex gap-2 text-sm">
<p>
<a
href={resolve(
`${AppRoute.SEARCH}?${getMetadataSearchQuery({ lensModel: asset.exifInfo.lensModel })}`,
)}
title="{$t('search_for')} {asset.exifInfo.lensModel}"
class="hover:text-primary line-clamp-1"
>
{asset.exifInfo.lensModel}
</a>
</p>
</div>
<p>
<a
href={resolve(`${AppRoute.SEARCH}?${getMetadataSearchQuery({ lensModel: asset.exifInfo.lensModel })}`)}
title="{$t('search_for')} {asset.exifInfo.lensModel}"
class="hover:text-primary line-clamp-1"
>
{asset.exifInfo.lensModel}
</a>
</p>
{/if}
<div class="flex gap-2 text-sm">
@@ -416,19 +431,9 @@
<p>ƒ/{asset.exifInfo.fNumber.toLocaleString($locale)}</p>
{/if}
{#if asset.exifInfo.exposureTime}
<p>{`${asset.exifInfo.exposureTime} s`}</p>
{/if}
{#if asset.exifInfo.focalLength}
<p>{`${asset.exifInfo.focalLength.toLocaleString($locale)} mm`}</p>
{/if}
{#if asset.exifInfo.iso}
<p>
{`ISO ${asset.exifInfo.iso}`}
</p>
{/if}
</div>
</div>
</div>

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
import {
EquirectangularAdapter,
@@ -8,11 +9,21 @@
type PluginConstructor,
} from '@photo-sphere-viewer/core';
import '@photo-sphere-viewer/core/index.css';
import { MarkersPlugin } from '@photo-sphere-viewer/markers-plugin';
import '@photo-sphere-viewer/markers-plugin/index.css';
import { ResolutionPlugin } from '@photo-sphere-viewer/resolution-plugin';
import { SettingsPlugin } from '@photo-sphere-viewer/settings-plugin';
import '@photo-sphere-viewer/settings-plugin/index.css';
import { onDestroy, onMount } from 'svelte';
// Adapted as well as possible from classlist 'border-solid border-white border-3 rounded-lg'
const FACE_BOX_SVG_STYLE = {
fill: 'rgba(0, 0, 0, 0)',
stroke: '#ffffff',
strokeWidth: '3px',
strokeLinejoin: 'round',
};
interface Props {
panorama: string | { source: string };
originalPanorama?: string | { source: string };
@@ -26,6 +37,62 @@
let container: HTMLDivElement | undefined = $state();
let viewer: Viewer;
let animationInProgress: { cancel: () => void } | undefined;
let previousFaces: Faces[] = [];
const boundingBoxesUnsubscribe = boundingBoxesArray.subscribe((faces: Faces[]) => {
// Debounce; don't do anything when the data didn't actually change.
if (faces === previousFaces) {
return;
}
previousFaces = faces;
if (animationInProgress) {
animationInProgress.cancel();
animationInProgress = undefined;
}
if (!viewer || !viewer.state.textureData || !viewer.getPlugin(MarkersPlugin)) {
return;
}
const markersPlugin = viewer.getPlugin<MarkersPlugin>(MarkersPlugin);
// croppedWidth is the size of the texture, which might be cropped to be less than 360/180 degrees.
// This is what we want because the facial recognition is done on the image, not the sphere.
const currentTextureWidth = viewer.state.textureData.panoData.croppedWidth;
markersPlugin.clearMarkers();
for (const [index, face] of faces.entries()) {
const { boundingBoxX1: x1, boundingBoxY1: y1, boundingBoxX2: x2, boundingBoxY2: y2 } = face;
const ratio = currentTextureWidth / face.imageWidth;
// Pixel values are translated to spherical coordinates and only then added to the panorama;
// no need to recalculate when the texture image changes to the original size.
markersPlugin.addMarker({
id: `face_${index}`,
polygonPixels: [
[x1 * ratio, y1 * ratio],
[x2 * ratio, y1 * ratio],
[x2 * ratio, y2 * ratio],
[x1 * ratio, y2 * ratio],
],
svgStyle: FACE_BOX_SVG_STYLE,
});
}
// Smoothly pan to the highlighted (hovered-over) face.
if (faces.length === 1) {
const { boundingBoxX1: x1, boundingBoxY1: y1, boundingBoxX2: x2, boundingBoxY2: y2, imageWidth: w } = faces[0];
const ratio = currentTextureWidth / w;
const x = ((x1 + x2) * ratio) / 2;
const y = ((y1 + y2) * ratio) / 2;
animationInProgress = viewer.animate({
textureX: x,
textureY: y,
zoom: Math.min(viewer.getZoomLevel(), 75),
speed: 500, // duration in ms
});
}
});
onMount(() => {
if (!container) {
return;
@@ -34,6 +101,7 @@
viewer = new Viewer({
adapter,
plugins: [
MarkersPlugin,
SettingsPlugin,
[
ResolutionPlugin,
@@ -68,7 +136,7 @@
zoomSpeed: 0.5,
fisheye: false,
});
const resolutionPlugin = viewer.getPlugin(ResolutionPlugin) as ResolutionPlugin;
const resolutionPlugin = viewer.getPlugin<ResolutionPlugin>(ResolutionPlugin);
const zoomHandler = ({ zoomLevel }: events.ZoomUpdatedEvent) => {
// zoomLevel range: [0, 100]
if (Math.round(zoomLevel) >= 75) {
@@ -89,6 +157,7 @@
if (viewer) {
viewer.destroy();
}
boundingBoxesUnsubscribe();
});
</script>

View File

@@ -1,6 +1,5 @@
import { setDifference, type TimelineDate } from '$lib/utils/timeline-util';
import { AssetOrder } from '@immich/sdk';
import { SvelteSet } from 'svelte/reactivity';
import type { DayGroup } from './day-group.svelte';
import type { MonthGroup } from './month-group.svelte';
import type { TimelineAsset } from './types';
@@ -10,8 +9,10 @@ export class GroupInsertionCache {
[year: number]: { [month: number]: { [day: number]: DayGroup } };
} = {};
unprocessedAssets: TimelineAsset[] = [];
changedDayGroups = new SvelteSet<DayGroup>();
newDayGroups = new SvelteSet<DayGroup>();
// eslint-disable-next-line svelte/prefer-svelte-reactivity
changedDayGroups = new Set<DayGroup>();
// eslint-disable-next-line svelte/prefer-svelte-reactivity
newDayGroups = new Set<DayGroup>();
getDayGroup({ year, month, day }: TimelineDate): DayGroup | undefined {
return this.#lookupCache[year]?.[month]?.[day];
@@ -32,7 +33,8 @@ export class GroupInsertionCache {
}
get updatedBuckets() {
const updated = new SvelteSet<MonthGroup>();
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const updated = new Set<MonthGroup>();
for (const group of this.changedDayGroups) {
updated.add(group.monthGroup);
}
@@ -40,7 +42,8 @@ export class GroupInsertionCache {
}
get bucketsWithNewDayGroups() {
const updated = new SvelteSet<MonthGroup>();
// eslint-disable-next-line svelte/prefer-svelte-reactivity
const updated = new Set<MonthGroup>();
for (const group of this.newDayGroups) {
updated.add(group.monthGroup);
}

View File

@@ -46,7 +46,7 @@ export async function loadFromTimeBuckets(
}
}
const unprocessedAssets = monthGroup.addAssets(bucketResponse);
const unprocessedAssets = monthGroup.addAssets(bucketResponse, true);
if (unprocessedAssets.length > 0) {
console.error(
`Warning: getTimeBucket API returning assets not in requested month: ${monthGroup.yearMonth.month}, ${JSON.stringify(

View File

@@ -153,7 +153,7 @@ export class MonthGroup {
};
}
addAssets(bucketAssets: TimeBucketAssetResponseDto) {
addAssets(bucketAssets: TimeBucketAssetResponseDto, preSorted: boolean) {
const addContext = new GroupInsertionCache();
for (let i = 0; i < bucketAssets.id.length; i++) {
const { localDateTime, fileCreatedAt } = getTimes(
@@ -194,6 +194,9 @@ export class MonthGroup {
}
this.addTimelineAsset(timelineAsset, addContext);
}
if (preSorted) {
return addContext.unprocessedAssets;
}
for (const group of addContext.existingDayGroups) {
group.sortAssets(this.#sortOrder);

View File

@@ -2,7 +2,7 @@
import { type ServerAboutResponseDto } from '@immich/sdk';
import { Icon, Modal, ModalBody } from '@immich/ui';
import { mdiBugOutline, mdiFaceAgent, mdiGit, mdiGithub, mdiInformationOutline } from '@mdi/js';
import { siDiscord } from 'simple-icons';
import { type SimpleIcon, siDiscord } from 'simple-icons';
import { t } from 'svelte-i18n';
interface Props {
@@ -13,94 +13,57 @@
let { onClose, info }: Props = $props();
</script>
{#snippet link(url: string, icon: string | SimpleIcon, text: string)}
<div>
<a href={url} target="_blank" rel="noreferrer">
<Icon {icon} size="1.5em" class="inline-block" />
<p class="font-medium text-primary text-sm underline inline-block">
{text}
</p>
</a>
</div>
{/snippet}
<Modal title={$t('support_and_feedback')} {onClose} size="small">
<ModalBody>
<p>{$t('official_immich_resources')}</p>
<div class="flex flex-col sm:grid sm:grid-cols-2 gap-2 mt-5">
<div>
<a href="https://docs.{info.version}.archive.immich.app/overview/introduction" target="_blank" rel="noreferrer">
<Icon icon={mdiInformationOutline} size="1.5em" class="inline-block" />
<p class="font-medium text-primary text-sm underline inline-block" id="documentation-label">
{$t('documentation')}
</p>
</a>
</div>
<div class="flex flex-col gap-2 mt-5">
{@render link(
`https://docs.${info.version}.archive.immich.app/overview/introduction`,
mdiInformationOutline,
$t('documentation'),
)}
<div>
<a href="https://github.com/immich-app/immich/" target="_blank" rel="noreferrer">
<Icon icon={mdiGithub} size="1.5em" class="inline-block" />
<p class="font-medium text-primary text-sm underline inline-block" id="github-label">
{$t('source')}
</p>
</a>
</div>
{@render link('https://github.com/immich-app/immich/', mdiGithub, $t('source'))}
<div>
<a href="https://discord.immich.app" target="_blank" rel="noreferrer">
<Icon icon={siDiscord} class="inline-block" size="1.5em" />
<p class="font-medium text-primary text-sm underline inline-block" id="github-label">
{$t('discord')}
</p>
</a>
</div>
{@render link('https://discord.immich.app', siDiscord, $t('discord'))}
<div>
<a href="https://github.com/immich-app/immich/issues/new/choose" target="_blank" rel="noreferrer">
<Icon icon={mdiBugOutline} size="1.5em" class="inline-block" />
<p class="font-medium text-primary text-sm underline inline-block" id="github-label">
{$t('bugs_and_feature_requests')}
</p>
</a>
</div>
{@render link(
'https://github.com/immich-app/immich/issues/new/choose',
mdiBugOutline,
$t('bugs_and_feature_requests'),
)}
</div>
{#if info.thirdPartyBugFeatureUrl || info.thirdPartySourceUrl || info.thirdPartyDocumentationUrl || info.thirdPartySupportUrl}
<p class="mt-5">{$t('third_party_resources')}</p>
<p class="text-sm mt-1">
{$t('support_third_party_description')}
</p>
<div class="flex flex-col sm:grid sm:grid-cols-2 gap-2 mt-5">
<div class="flex flex-col gap-2 mt-5">
{#if info.thirdPartyDocumentationUrl}
<div>
<a href={info.thirdPartyDocumentationUrl} target="_blank" rel="noreferrer">
<Icon icon={mdiInformationOutline} size="1.5em" class="inline-block" />
<p class="font-medium text-primary text-sm underline inline-block" id="documentation-label">
{$t('documentation')}
</p>
</a>
</div>
{@render link(info.thirdPartyDocumentationUrl, mdiInformationOutline, $t('documentation'))}
{/if}
{#if info.thirdPartySourceUrl}
<div>
<a href={info.thirdPartySourceUrl} target="_blank" rel="noreferrer">
<Icon icon={mdiGit} size="1.5em" class="inline-block" />
<p class="font-medium text-primary text-sm underline inline-block" id="github-label">
{$t('source')}
</p>
</a>
</div>
{@render link(info.thirdPartySourceUrl, mdiGit, $t('source'))}
{/if}
{#if info.thirdPartySupportUrl}
<div>
<a href={info.thirdPartySupportUrl} target="_blank" rel="noreferrer">
<Icon icon={mdiFaceAgent} class="inline-block" size="1.5em" />
<p class="font-medium text-primary text-sm underline inline-block" id="github-label">
{$t('support')}
</p>
</a>
</div>
{@render link(info.thirdPartySupportUrl, mdiFaceAgent, $t('support'))}
{/if}
{#if info.thirdPartyBugFeatureUrl}
<div>
<a href={info.thirdPartyBugFeatureUrl} target="_blank" rel="noreferrer">
<Icon icon={mdiBugOutline} size="1.5em" class="inline-block" />
<p class="font-medium text-primary text-sm underline inline-block" id="github-label">
{$t('bugs_and_feature_requests')}
</p>
</a>
</div>
{@render link(info.thirdPartyBugFeatureUrl, mdiBugOutline, $t('bugs_and_feature_requests'))}
{/if}
</div>
{/if}