mirror of
https://github.com/immich-app/immich
synced 2025-11-14 17:36:12 +00:00
feat(web): lighter timeline buckets (#17719)
* feat(web): lighter timeline buckets * GalleryViewer * weird ssr * Remove generics from AssetInteraction * ensure keys on getAssetInfo, alt-text * empty - trigger ci * re-add alt-text * test fix * update tests * tests * missing import * fix: flappy e2e test * lint * revert settings * unneeded cast * fix after merge * missing import * lint * review * lint * avoid abbreviations * review comment - type safety in test * merge conflicts * lint * lint/abbreviations * fix: left-over migration --------- Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
parent
a65c905621
commit
0bbe70e6a3
53 changed files with 725 additions and 471 deletions
|
|
@ -1,20 +1,20 @@
|
|||
import { notificationController, NotificationType } from '$lib/components/shared-components/notification/notification';
|
||||
import type { AssetStore } from '$lib/stores/assets-store.svelte';
|
||||
import type { AssetStore, TimelineAsset } from '$lib/stores/assets-store.svelte';
|
||||
import type { StackResponse } from '$lib/utils/asset-utils';
|
||||
import { deleteAssets as deleteBulk, type AssetResponseDto } from '@immich/sdk';
|
||||
import { deleteAssets as deleteBulk, Visibility } from '@immich/sdk';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { get } from 'svelte/store';
|
||||
import { handleError } from './handle-error';
|
||||
|
||||
export type OnDelete = (assetIds: string[]) => void;
|
||||
export type OnRestore = (ids: string[]) => void;
|
||||
export type OnLink = (assets: { still: AssetResponseDto; motion: AssetResponseDto }) => void;
|
||||
export type OnUnlink = (assets: { still: AssetResponseDto; motion: AssetResponseDto }) => void;
|
||||
export type OnLink = (assets: { still: TimelineAsset; motion: TimelineAsset }) => void;
|
||||
export type OnUnlink = (assets: { still: TimelineAsset; motion: TimelineAsset }) => void;
|
||||
export type OnAddToAlbum = (ids: string[], albumId: string) => void;
|
||||
export type OnArchive = (ids: string[], isArchived: boolean) => void;
|
||||
export type OnArchive = (ids: string[], visibility: Visibility) => void;
|
||||
export type OnFavorite = (ids: string[], favorite: boolean) => void;
|
||||
export type OnStack = (result: StackResponse) => void;
|
||||
export type OnUnstack = (assets: AssetResponseDto[]) => void;
|
||||
export type OnUnstack = (assets: TimelineAsset[]) => void;
|
||||
export type OnSetVisibility = (ids: string[]) => void;
|
||||
|
||||
export const deleteAssets = async (force: boolean, onAssetDelete: OnDelete, ids: string[]) => {
|
||||
|
|
@ -65,11 +65,11 @@ export function updateStackedAssetInTimeline(assetStore: AssetStore, { stack, to
|
|||
* @param assetStore - The asset store to update.
|
||||
* @param assets - The array of asset response DTOs to update in the asset store.
|
||||
*/
|
||||
export function updateUnstackedAssetInTimeline(assetStore: AssetStore, assets: AssetResponseDto[]) {
|
||||
export function updateUnstackedAssetInTimeline(assetStore: AssetStore, assets: TimelineAsset[]) {
|
||||
assetStore.updateAssetOperation(
|
||||
assets.map((asset) => asset.id),
|
||||
(asset) => {
|
||||
asset.stack = undefined;
|
||||
asset.stack = null;
|
||||
return { remove: false };
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -6,7 +6,12 @@ import { AppRoute } from '$lib/constants';
|
|||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { downloadManager } from '$lib/managers/download-manager.svelte';
|
||||
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
import { assetsSnapshot, isSelectingAllAssets, type AssetStore } from '$lib/stores/assets-store.svelte';
|
||||
import {
|
||||
assetsSnapshot,
|
||||
isSelectingAllAssets,
|
||||
type AssetStore,
|
||||
type TimelineAsset,
|
||||
} from '$lib/stores/assets-store.svelte';
|
||||
import { preferences } from '$lib/stores/user.store';
|
||||
import { downloadRequest, withError } from '$lib/utils';
|
||||
import { createAlbum } from '$lib/utils/album-utils';
|
||||
|
|
@ -366,7 +371,7 @@ export const getAssetType = (type: AssetTypeEnum) => {
|
|||
}
|
||||
};
|
||||
|
||||
export const getSelectedAssets = (assets: AssetResponseDto[], user: UserResponseDto | null): string[] => {
|
||||
export const getSelectedAssets = (assets: TimelineAsset[], user: UserResponseDto | null): string[] => {
|
||||
const ids = [...assets].filter((a) => user && a.ownerId === user.id).map((a) => a.id);
|
||||
|
||||
const numberOfIssues = [...assets].filter((a) => user && a.ownerId !== user.id).length;
|
||||
|
|
@ -385,7 +390,7 @@ export type StackResponse = {
|
|||
toDeleteIds: string[];
|
||||
};
|
||||
|
||||
export const stackAssets = async (assets: AssetResponseDto[], showNotification = true): Promise<StackResponse> => {
|
||||
export const stackAssets = async (assets: { id: string }[], showNotification = true): Promise<StackResponse> => {
|
||||
if (assets.length < 2) {
|
||||
return { stack: undefined, toDeleteIds: [] };
|
||||
}
|
||||
|
|
@ -405,10 +410,6 @@ export const stackAssets = async (assets: AssetResponseDto[], showNotification =
|
|||
});
|
||||
}
|
||||
|
||||
for (const [index, asset] of assets.entries()) {
|
||||
asset.stack = index === 0 ? { id: stack.id, assetCount: stack.assets.length, primaryAssetId: asset.id } : null;
|
||||
}
|
||||
|
||||
return {
|
||||
stack,
|
||||
toDeleteIds: assets.slice(1).map((asset) => asset.id),
|
||||
|
|
@ -525,30 +526,29 @@ export const toggleArchive = async (asset: AssetResponseDto) => {
|
|||
return asset;
|
||||
};
|
||||
|
||||
export const archiveAssets = async (assets: AssetResponseDto[], archive: boolean) => {
|
||||
const isArchived = archive;
|
||||
export const archiveAssets = async (assets: { id: string }[], visibility: AssetVisibility) => {
|
||||
const ids = assets.map(({ id }) => id);
|
||||
const $t = get(t);
|
||||
|
||||
try {
|
||||
if (ids.length > 0) {
|
||||
await updateAssets({
|
||||
assetBulkUpdateDto: { ids, visibility: isArchived ? AssetVisibility.Archive : AssetVisibility.Timeline },
|
||||
assetBulkUpdateDto: { ids, visibility },
|
||||
});
|
||||
}
|
||||
|
||||
for (const asset of assets) {
|
||||
asset.isArchived = isArchived;
|
||||
}
|
||||
|
||||
notificationController.show({
|
||||
message: isArchived
|
||||
? $t('archived_count', { values: { count: ids.length } })
|
||||
: $t('unarchived_count', { values: { count: ids.length } }),
|
||||
message:
|
||||
visibility === AssetVisibility.Archive
|
||||
? $t('archived_count', { values: { count: ids.length } })
|
||||
: $t('unarchived_count', { values: { count: ids.length } }),
|
||||
type: NotificationType.Info,
|
||||
});
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_archive_unarchive', { values: { archived: isArchived } }));
|
||||
handleError(
|
||||
error,
|
||||
$t('errors.unable_to_archive_unarchive', { values: { archived: visibility === AssetVisibility.Archive } }),
|
||||
);
|
||||
}
|
||||
|
||||
return ids;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import { getAssetRatio } from '$lib/utils/asset-utils';
|
||||
// import { TUNABLES } from '$lib/utils/tunables';
|
||||
// note: it's important that this is not imported in more than one file due to https://github.com/sveltejs/kit/issues/7805
|
||||
// import { JustifiedLayout, type LayoutOptions } from '@immich/justified-layout-wasm';
|
||||
|
||||
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
|
||||
import { getAssetRatio } from '$lib/utils/asset-utils';
|
||||
import { isTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import type { AssetResponseDto } from '@immich/sdk';
|
||||
import createJustifiedLayout from 'justified-layout';
|
||||
|
||||
|
|
@ -26,7 +29,7 @@ export type CommonLayoutOptions = {
|
|||
};
|
||||
|
||||
export function getJustifiedLayoutFromAssets(
|
||||
assets: AssetResponseDto[],
|
||||
assets: (TimelineAsset | AssetResponseDto)[],
|
||||
options: CommonLayoutOptions,
|
||||
): CommonJustifiedLayout {
|
||||
// if (useWasm) {
|
||||
|
|
@ -87,7 +90,7 @@ class Adapter {
|
|||
}
|
||||
}
|
||||
|
||||
export function justifiedLayout(assets: AssetResponseDto[], options: CommonLayoutOptions) {
|
||||
export function justifiedLayout(assets: (TimelineAsset | AssetResponseDto)[], options: CommonLayoutOptions) {
|
||||
const adapter = {
|
||||
targetRowHeight: options.rowHeight,
|
||||
containerWidth: options.rowWidth,
|
||||
|
|
@ -96,7 +99,7 @@ export function justifiedLayout(assets: AssetResponseDto[], options: CommonLayou
|
|||
};
|
||||
|
||||
const result = createJustifiedLayout(
|
||||
assets.map((g) => getAssetRatio(g)),
|
||||
assets.map((asset) => (isTimelineAsset(asset) ? asset.ratio : getAssetRatio(asset))),
|
||||
adapter,
|
||||
);
|
||||
return new Adapter(result);
|
||||
|
|
|
|||
|
|
@ -1,17 +1,15 @@
|
|||
import type { AssetResponseDto } from '@immich/sdk';
|
||||
|
||||
export class SlideshowHistory {
|
||||
private history: AssetResponseDto[] = [];
|
||||
private history: { id: string }[] = [];
|
||||
private index = 0;
|
||||
|
||||
constructor(private onChange: (asset: AssetResponseDto) => void) {}
|
||||
constructor(private onChange: (asset: { id: string }) => void) {}
|
||||
|
||||
reset() {
|
||||
this.history = [];
|
||||
this.index = 0;
|
||||
}
|
||||
|
||||
queue(asset: AssetResponseDto) {
|
||||
queue(asset: { id: string }) {
|
||||
this.history.push(asset);
|
||||
|
||||
// If we were at the end of the slideshow history, move the index to the new end
|
||||
|
|
|
|||
|
|
@ -1,11 +1,16 @@
|
|||
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
|
||||
import { getAltText } from '$lib/utils/thumbnail-util';
|
||||
import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk';
|
||||
import { Visibility } from '@immich/sdk';
|
||||
import { init, register, waitLocale } from 'svelte-i18n';
|
||||
|
||||
const onePerson = [{ name: 'person' }];
|
||||
const twoPeople = [{ name: 'person1' }, { name: 'person2' }];
|
||||
const threePeople = [{ name: 'person1' }, { name: 'person2' }, { name: 'person3' }];
|
||||
const fourPeople = [{ name: 'person1' }, { name: 'person2' }, { name: 'person3' }, { name: 'person4' }];
|
||||
interface Person {
|
||||
name: string;
|
||||
}
|
||||
|
||||
const onePerson: Person[] = [{ name: 'person' }];
|
||||
const twoPeople: Person[] = [{ name: 'person1' }, { name: 'person2' }];
|
||||
const threePeople: Person[] = [{ name: 'person1' }, { name: 'person2' }, { name: 'person3' }];
|
||||
const fourPeople: Person[] = [{ name: 'person1' }, { name: 'person2' }, { name: 'person3' }, { name: 'person4' }];
|
||||
|
||||
describe('getAltText', () => {
|
||||
beforeAll(async () => {
|
||||
|
|
@ -38,27 +43,44 @@ describe('getAltText', () => {
|
|||
${true} | ${'city'} | ${'country'} | ${fourPeople} | ${'Video taken in city, country with person1, person2, and 2 others on January 1, 2024'}
|
||||
`(
|
||||
'generates correctly formatted alt text when isVideo=$isVideo, city=$city, country=$country, people=$people.length',
|
||||
({ isVideo, city, country, people, expected }) => {
|
||||
const asset = {
|
||||
exifInfo: { city, country },
|
||||
({
|
||||
isVideo,
|
||||
city,
|
||||
country,
|
||||
people,
|
||||
expected,
|
||||
}: {
|
||||
isVideo: boolean;
|
||||
city?: string;
|
||||
country?: string;
|
||||
people?: Person[];
|
||||
expected: string;
|
||||
}) => {
|
||||
const asset: TimelineAsset = {
|
||||
id: 'test-id',
|
||||
ownerId: 'test-owner',
|
||||
ratio: 1,
|
||||
thumbhash: null,
|
||||
localDateTime: '2024-01-01T12:00:00.000Z',
|
||||
people,
|
||||
type: isVideo ? AssetTypeEnum.Video : AssetTypeEnum.Image,
|
||||
} as AssetResponseDto;
|
||||
visibility: Visibility.Timeline,
|
||||
isFavorite: false,
|
||||
isTrashed: false,
|
||||
isVideo,
|
||||
isImage: !isVideo,
|
||||
stack: null,
|
||||
duration: null,
|
||||
projectionType: null,
|
||||
livePhotoVideoId: null,
|
||||
text: {
|
||||
city: city ?? null,
|
||||
country: country ?? null,
|
||||
people: people?.map((person: Person) => person.name) ?? [],
|
||||
},
|
||||
};
|
||||
|
||||
getAltText.subscribe((fn) => {
|
||||
expect(fn(asset)).toEqual(expected);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('defaults to the description, if available', () => {
|
||||
const asset = {
|
||||
exifInfo: { description: 'description' },
|
||||
} as AssetResponseDto;
|
||||
|
||||
getAltText.subscribe((fn) => {
|
||||
expect(fn(asset)).toEqual('description');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { derived, get } from 'svelte/store';
|
||||
import { fromLocalDateTime } from './timeline-util';
|
||||
|
|
@ -39,21 +39,18 @@ export function getThumbnailSize(assetCount: number, viewWidth: number): number
|
|||
}
|
||||
|
||||
export const getAltText = derived(t, ($t) => {
|
||||
return (asset: AssetResponseDto) => {
|
||||
if (asset.exifInfo?.description) {
|
||||
return asset.exifInfo.description;
|
||||
}
|
||||
|
||||
return (asset: TimelineAsset) => {
|
||||
const date = fromLocalDateTime(asset.localDateTime).toLocaleString({ dateStyle: 'long' }, { locale: get(locale) });
|
||||
const hasPlace = !!asset.exifInfo?.city && !!asset.exifInfo?.country;
|
||||
const names = asset.people?.filter((p) => p.name).map((p) => p.name) ?? [];
|
||||
const { city, country, people: names } = asset.text;
|
||||
const hasPlace = city && country;
|
||||
|
||||
const peopleCount = names.length;
|
||||
const isVideo = asset.type === AssetTypeEnum.Video;
|
||||
const isVideo = asset.isVideo;
|
||||
|
||||
const values = {
|
||||
date,
|
||||
city: asset.exifInfo?.city,
|
||||
country: asset.exifInfo?.country,
|
||||
city,
|
||||
country,
|
||||
person1: names[0],
|
||||
person2: names[1],
|
||||
person3: names[2],
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
import type { TimelineAsset } from '$lib/stores/assets-store.svelte';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { getAssetRatio } from '$lib/utils/asset-utils';
|
||||
|
||||
import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk';
|
||||
import { memoize } from 'lodash-es';
|
||||
import { DateTime, type LocaleOptions } from 'luxon';
|
||||
import { get } from 'svelte/store';
|
||||
|
|
@ -56,3 +60,39 @@ export const getDateLocaleString = (date: DateTime, opts?: LocaleOptions): strin
|
|||
date.toLocaleString(DateTime.DATE_MED_WITH_WEEKDAY, opts);
|
||||
|
||||
export const formatDateGroupTitle = memoize(formatGroupTitle);
|
||||
|
||||
export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset): TimelineAsset => {
|
||||
if (isTimelineAsset(unknownAsset)) {
|
||||
return unknownAsset;
|
||||
}
|
||||
const assetResponse = unknownAsset as AssetResponseDto;
|
||||
const { width, height } = getAssetRatio(assetResponse);
|
||||
const ratio = width / height;
|
||||
const city = assetResponse.exifInfo?.city;
|
||||
const country = assetResponse.exifInfo?.country;
|
||||
const people = assetResponse.people?.map((person) => person.name) || [];
|
||||
const text = {
|
||||
city: city || null,
|
||||
country: country || null,
|
||||
people,
|
||||
};
|
||||
return {
|
||||
id: assetResponse.id,
|
||||
ownerId: assetResponse.ownerId,
|
||||
ratio,
|
||||
thumbhash: assetResponse.thumbhash,
|
||||
localDateTime: assetResponse.localDateTime,
|
||||
isFavorite: assetResponse.isFavorite,
|
||||
visibility: assetResponse.visibility,
|
||||
isTrashed: assetResponse.isTrashed,
|
||||
isVideo: assetResponse.type == AssetTypeEnum.Video,
|
||||
isImage: assetResponse.type == AssetTypeEnum.Image,
|
||||
stack: assetResponse.stack || null,
|
||||
duration: assetResponse.duration || null,
|
||||
projectionType: assetResponse.exifInfo?.projectionType || null,
|
||||
livePhotoVideoId: assetResponse.livePhotoVideoId || null,
|
||||
text,
|
||||
};
|
||||
};
|
||||
export const isTimelineAsset = (asset: AssetResponseDto | TimelineAsset): asset is TimelineAsset =>
|
||||
(asset as TimelineAsset).ratio !== undefined;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { browser } from '$app/environment';
|
||||
|
||||
function getBoolean(string: string | null, fallback: boolean) {
|
||||
if (string === null) {
|
||||
return fallback;
|
||||
|
|
@ -10,18 +12,23 @@ function getNumber(string: string | null, fallback: number) {
|
|||
}
|
||||
return Number.parseInt(string);
|
||||
}
|
||||
const storage = browser
|
||||
? localStorage
|
||||
: {
|
||||
getItem: () => null,
|
||||
};
|
||||
export const TUNABLES = {
|
||||
LAYOUT: {
|
||||
WASM: getBoolean(localStorage.getItem('LAYOUT.WASM'), false),
|
||||
WASM: getBoolean(storage.getItem('LAYOUT.WASM'), false),
|
||||
},
|
||||
TIMELINE: {
|
||||
INTERSECTION_EXPAND_TOP: getNumber(localStorage.getItem('TIMELINE_INTERSECTION_EXPAND_TOP'), 500),
|
||||
INTERSECTION_EXPAND_BOTTOM: getNumber(localStorage.getItem('TIMELINE_INTERSECTION_EXPAND_BOTTOM'), 500),
|
||||
INTERSECTION_EXPAND_TOP: getNumber(storage.getItem('TIMELINE_INTERSECTION_EXPAND_TOP'), 500),
|
||||
INTERSECTION_EXPAND_BOTTOM: getNumber(storage.getItem('TIMELINE_INTERSECTION_EXPAND_BOTTOM'), 500),
|
||||
},
|
||||
ASSET_GRID: {
|
||||
NAVIGATE_ON_ASSET_IN_VIEW: getBoolean(localStorage.getItem('ASSET_GRID.NAVIGATE_ON_ASSET_IN_VIEW'), false),
|
||||
NAVIGATE_ON_ASSET_IN_VIEW: getBoolean(storage.getItem('ASSET_GRID.NAVIGATE_ON_ASSET_IN_VIEW'), false),
|
||||
},
|
||||
IMAGE_THUMBNAIL: {
|
||||
THUMBHASH_FADE_DURATION: getNumber(localStorage.getItem('THUMBHASH_FADE_DURATION'), 150),
|
||||
THUMBHASH_FADE_DURATION: getNumber(storage.getItem('THUMBHASH_FADE_DURATION'), 150),
|
||||
},
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue