mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
feat: recently added assets page (#28272)
* feat(server): add ordering date option to time buckets * feat(web): add recently added page * feat(server): recently created assets in explore data * feat(web): recently added in explore tab * fix: recently added assets ordering * fix(server): failing bucket test * feat(web): improve recently added preview * chore: update e2e explore/timeline tests * chore: rename and refactor timeline ordering dates * fix(web): invalid timeline option * feat(mobile): recently added page * fix(server): sync tests * fix(mobile): resync assets to get uploadedAt column * chore: rename assetorderby enum * chore(mobile): formatting * minor fixes * stylings --------- Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
import { AssetOrder } from '@immich/sdk';
|
||||
import { AssetOrder, AssetOrderBy } from '@immich/sdk';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import type { CommonLayoutOptions } from '$lib/utils/layout-utils';
|
||||
import { getJustifiedLayoutFromAssets } from '$lib/utils/layout-utils';
|
||||
import { plainDateTimeCompare } from '$lib/utils/timeline-util';
|
||||
import { getOrderingDate, plainDateTimeCompare } from '$lib/utils/timeline-util';
|
||||
import type { TimelineMonth } from './timeline-month.svelte';
|
||||
import type { Direction, MoveAsset, TimelineAsset } from './types';
|
||||
import { ViewerAsset } from './viewer-asset.svelte';
|
||||
@@ -12,6 +12,7 @@ export class TimelineDay {
|
||||
readonly index: number;
|
||||
readonly groupTitle: string;
|
||||
readonly day: number;
|
||||
readonly orderBy: AssetOrderBy;
|
||||
viewerAssets: ViewerAsset[] = $state([]);
|
||||
|
||||
height = $state(0);
|
||||
@@ -24,11 +25,12 @@ export class TimelineDay {
|
||||
#col = $state(0);
|
||||
#deferredLayout = false;
|
||||
|
||||
constructor(timelineMonth: TimelineMonth, index: number, day: number, groupTitle: string) {
|
||||
constructor(timelineMonth: TimelineMonth, index: number, day: number, groupTitle: string, orderBy: AssetOrderBy) {
|
||||
this.index = index;
|
||||
this.timelineMonth = timelineMonth;
|
||||
this.day = day;
|
||||
this.groupTitle = groupTitle;
|
||||
this.orderBy = orderBy;
|
||||
}
|
||||
|
||||
get top() {
|
||||
@@ -115,10 +117,10 @@ export class TimelineDay {
|
||||
continue;
|
||||
}
|
||||
|
||||
const oldTime = { ...asset.localDateTime };
|
||||
const oldTime = { ...getOrderingDate(asset, this.orderBy) };
|
||||
const callbackResult = callback(asset);
|
||||
let remove = (callbackResult as { remove?: boolean } | undefined)?.remove ?? false;
|
||||
const newTime = asset.localDateTime;
|
||||
const newTime = getOrderingDate(asset, this.orderBy);
|
||||
if (oldTime.year !== newTime.year || oldTime.month !== newTime.month || oldTime.day !== newTime.day) {
|
||||
const { year, month, day } = newTime;
|
||||
remove = true;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AssetOrder, getAssetInfo, getTimeBuckets, type AssetResponseDto } from '@immich/sdk';
|
||||
import { AssetOrder, getAssetInfo, getTimeBuckets, AssetOrderBy, type AssetResponseDto } from '@immich/sdk';
|
||||
import { clamp, isEqual } from 'lodash-es';
|
||||
import { SvelteDate, SvelteSet } from 'svelte/reactivity';
|
||||
import { VirtualScrollManager } from '$lib/managers/VirtualScrollManager/VirtualScrollManager.svelte';
|
||||
@@ -20,6 +20,7 @@ import { WebsocketSupport } from '$lib/managers/timeline-manager/internal/websoc
|
||||
import { CancellableTask } from '$lib/utils/cancellable-task';
|
||||
import { PersistedLocalStorage } from '$lib/utils/persisted';
|
||||
import {
|
||||
getOrderingDate,
|
||||
isAssetResponseDto,
|
||||
setDifference,
|
||||
toTimelineAsset,
|
||||
@@ -252,6 +253,7 @@ export class TimelineManager extends VirtualScrollManager {
|
||||
timeBucket.count,
|
||||
false,
|
||||
this.#options.order,
|
||||
this.#options.orderBy,
|
||||
);
|
||||
});
|
||||
this.albumAssets.clear();
|
||||
@@ -393,7 +395,10 @@ export class TimelineManager extends VirtualScrollManager {
|
||||
return;
|
||||
}
|
||||
|
||||
timelineMonth = await this.#loadTimelineMonthAtTime(timelineAsset.localDateTime, { cancelable: false });
|
||||
timelineMonth = await this.#loadTimelineMonthAtTime(
|
||||
getOrderingDate(timelineAsset, this.#options.orderBy || AssetOrderBy.TakenAt),
|
||||
{ cancelable: false },
|
||||
);
|
||||
if (timelineMonth?.findAssetById({ id })) {
|
||||
return timelineMonth;
|
||||
}
|
||||
@@ -462,10 +467,11 @@ export class TimelineManager extends VirtualScrollManager {
|
||||
}
|
||||
|
||||
protected upsertSegmentForAsset(asset: TimelineAsset) {
|
||||
let month = getTimelineMonthByDate(this, asset.localDateTime);
|
||||
const dateTime = getOrderingDate(asset, this.#options.orderBy || AssetOrderBy.TakenAt);
|
||||
let month = getTimelineMonthByDate(this, dateTime);
|
||||
|
||||
if (!month) {
|
||||
month = new TimelineMonth(this, asset.localDateTime, 1, true, this.#options.order);
|
||||
month = new TimelineMonth(this, dateTime, 1, true, this.#options.order, this.#options.orderBy);
|
||||
this.months.push(month);
|
||||
}
|
||||
return month;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AssetOrder, type TimeBucketAssetResponseDto } from '@immich/sdk';
|
||||
import { AssetOrder, AssetOrderBy, type TimeBucketAssetResponseDto } from '@immich/sdk';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import { get } from 'svelte/store';
|
||||
@@ -15,10 +15,12 @@ import {
|
||||
fromTimelinePlainDate,
|
||||
fromTimelinePlainDateTime,
|
||||
fromTimelinePlainYearMonth,
|
||||
fromISODateTimeUTC,
|
||||
getTimes,
|
||||
setDifference,
|
||||
type TimelineDateTime,
|
||||
type TimelineYearMonth,
|
||||
getOrderingDate,
|
||||
} from '$lib/utils/timeline-util';
|
||||
import { GroupInsertionCache } from './group-insertion-cache.svelte';
|
||||
import { TimelineDay } from './timeline-day.svelte';
|
||||
@@ -37,6 +39,7 @@ export class TimelineMonth {
|
||||
|
||||
#initialCount: number = 0;
|
||||
#sortOrder: AssetOrder = AssetOrder.Desc;
|
||||
#orderBy: AssetOrderBy = AssetOrderBy.TakenAt;
|
||||
percent: number = $state(0);
|
||||
|
||||
assetsCount: number = $derived(
|
||||
@@ -56,10 +59,12 @@ export class TimelineMonth {
|
||||
initialCount: number,
|
||||
loaded: boolean,
|
||||
order: AssetOrder = AssetOrder.Desc,
|
||||
orderBy: AssetOrderBy = AssetOrderBy.TakenAt,
|
||||
) {
|
||||
this.timelineManager = timelineManager;
|
||||
this.#initialCount = initialCount;
|
||||
this.#sortOrder = order;
|
||||
this.#orderBy = orderBy;
|
||||
|
||||
this.yearMonth = { year: yearMonth.year, month: yearMonth.month };
|
||||
this.title = formatTimelineMonthTitle(fromTimelinePlainYearMonth(yearMonth));
|
||||
@@ -185,6 +190,7 @@ export class TimelineMonth {
|
||||
isVideo: !bucketAssets.isImage[i],
|
||||
livePhotoVideoId: bucketAssets.livePhotoVideoId[i],
|
||||
localDateTime,
|
||||
createdAt: fromISODateTimeUTC(bucketAssets.createdAt[i]).setZone('local'),
|
||||
fileCreatedAt,
|
||||
ownerId: bucketAssets.ownerId[i],
|
||||
projectionType: bucketAssets.projectionType[i],
|
||||
@@ -229,22 +235,22 @@ export class TimelineMonth {
|
||||
}
|
||||
|
||||
addTimelineAsset(timelineAsset: TimelineAsset, addContext: GroupInsertionCache) {
|
||||
const { localDateTime } = timelineAsset;
|
||||
const dateTime = getOrderingDate(timelineAsset, this.#orderBy);
|
||||
|
||||
const { year, month } = this.yearMonth;
|
||||
if (month !== localDateTime.month || year !== localDateTime.year) {
|
||||
if (month !== dateTime.month || year !== dateTime.year) {
|
||||
addContext.unprocessedAssets.push(timelineAsset);
|
||||
return;
|
||||
}
|
||||
|
||||
let timelineDay = addContext.getTimelineDay(localDateTime) || this.findTimelineDayByDay(localDateTime.day);
|
||||
let timelineDay = addContext.getTimelineDay(dateTime) || this.findTimelineDayByDay(dateTime.day);
|
||||
if (timelineDay) {
|
||||
addContext.setTimelineDay(timelineDay, localDateTime);
|
||||
addContext.setTimelineDay(timelineDay, dateTime);
|
||||
} else {
|
||||
const groupTitle = formatGroupTitle(fromTimelinePlainDate(localDateTime));
|
||||
timelineDay = new TimelineDay(this, this.timelineDays.length, localDateTime.day, groupTitle);
|
||||
const groupTitle = formatGroupTitle(fromTimelinePlainDate(dateTime));
|
||||
timelineDay = new TimelineDay(this, this.timelineDays.length, dateTime.day, groupTitle, this.#orderBy);
|
||||
this.timelineDays.push(timelineDay);
|
||||
addContext.setTimelineDay(timelineDay, localDateTime);
|
||||
addContext.setTimelineDay(timelineDay, dateTime);
|
||||
addContext.newTimelineDays.add(timelineDay);
|
||||
}
|
||||
|
||||
@@ -372,7 +378,7 @@ export class TimelineMonth {
|
||||
let closest = undefined;
|
||||
let smallestDiff = Infinity;
|
||||
for (const current of this.assetsIterator()) {
|
||||
const currentAssetDate = fromTimelinePlainDateTime(current.localDateTime);
|
||||
const currentAssetDate = fromTimelinePlainDateTime(getOrderingDate(current, this.#orderBy));
|
||||
const diff = Math.abs(targetDate.diff(currentAssetDate).as('milliseconds'));
|
||||
if (diff < smallestDiff) {
|
||||
smallestDiff = diff;
|
||||
|
||||
@@ -22,6 +22,7 @@ export type TimelineAsset = {
|
||||
ratio: number;
|
||||
thumbhash: string | null;
|
||||
localDateTime: TimelineDateTime;
|
||||
createdAt: TimelineDateTime;
|
||||
fileCreatedAt: TimelineDateTime;
|
||||
visibility: AssetVisibility;
|
||||
isFavorite: boolean;
|
||||
|
||||
@@ -105,6 +105,7 @@ export const Route = {
|
||||
locked: () => '/locked',
|
||||
trash: () => '/trash',
|
||||
viewTrashedAsset: ({ id }: { id: string }) => `/trash/photos/${id}`,
|
||||
recentlyAdded: () => '/recently-added',
|
||||
|
||||
// search
|
||||
search: (dto?: MetadataSearchDto | SmartSearchDto) => {
|
||||
|
||||
@@ -71,6 +71,15 @@ describe('getAltText', () => {
|
||||
second: testDate.getUTCSeconds(),
|
||||
millisecond: testDate.getUTCMilliseconds(),
|
||||
},
|
||||
createdAt: {
|
||||
year: testDate.getUTCFullYear(),
|
||||
month: testDate.getUTCMonth() + 1, // Note: getMonth() is 0-based
|
||||
day: testDate.getUTCDate(),
|
||||
hour: testDate.getUTCHours(),
|
||||
minute: testDate.getUTCMinutes(),
|
||||
second: testDate.getUTCSeconds(),
|
||||
millisecond: testDate.getUTCMilliseconds(),
|
||||
},
|
||||
localDateTime: {
|
||||
year: testDate.getUTCFullYear(),
|
||||
month: testDate.getUTCMonth() + 1, // Note: getMonth() is 0-based
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk';
|
||||
import { AssetTypeEnum, AssetOrderBy, type AssetResponseDto } from '@immich/sdk';
|
||||
import { DateTime, type LocaleOptions } from 'luxon';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import { get } from 'svelte/store';
|
||||
@@ -166,6 +166,7 @@ export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset):
|
||||
|
||||
const localDateTime = fromISODateTimeUTCToObject(assetResponse.localDateTime);
|
||||
const fileCreatedAt = fromISODateTimeToObject(assetResponse.fileCreatedAt, assetResponse.exifInfo?.timeZone ?? 'UTC');
|
||||
const createdAt = fromISODateTimeUTCToObject(assetResponse.createdAt);
|
||||
|
||||
return {
|
||||
id: assetResponse.id,
|
||||
@@ -174,6 +175,7 @@ export const toTimelineAsset = (unknownAsset: AssetResponseDto | TimelineAsset):
|
||||
ratio,
|
||||
thumbhash: assetResponse.thumbhash,
|
||||
localDateTime,
|
||||
createdAt,
|
||||
fileCreatedAt,
|
||||
isFavorite: assetResponse.isFavorite,
|
||||
visibility: assetResponse.visibility,
|
||||
@@ -236,3 +238,6 @@ export function setDifference<T>(setA: Set<T>, setB: Set<T>): SvelteSet<T> {
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export const getOrderingDate = (asset: TimelineAsset, order: AssetOrderBy) =>
|
||||
order === AssetOrderBy.CreatedAt ? asset.createdAt : asset.localDateTime;
|
||||
|
||||
Reference in New Issue
Block a user