Merge branch 'main' of github.com:immich-app/immich into workflow-ui

This commit is contained in:
Alex Tran
2025-11-24 16:29:45 +00:00
20 changed files with 654 additions and 309 deletions

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

@@ -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}