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:
Ben Beckford
2026-05-11 14:35:10 -07:00
committed by GitHub
parent 38438c8d9a
commit e142e3aca7
66 changed files with 14174 additions and 56 deletions
@@ -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;
+1
View File
@@ -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) => {
+9
View File
@@ -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
+6 -1
View File
@@ -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;
+30 -1
View File
@@ -11,6 +11,8 @@
import { mdiHeart } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { getAltText } from '$lib/utils/thumbnail-util';
interface Props {
data: PageData;
@@ -24,6 +26,9 @@
};
let places = $derived(getFieldItems(data.items, 'exifInfo.city'));
let recents = $derived(
getFieldItems(data.items, 'createdAt').sort((a, b) => new Date(b.value).getTime() - new Date(a.value).getTime()),
);
let people = $state(data.response.people);
let hasPeople = $derived(data.response.total > 0);
@@ -107,7 +112,31 @@
</div>
{/if}
{#if !hasPeople && places.length === 0}
{#if recents.length > 0}
<div class="mt-2 mb-6">
<div class="flex justify-between">
<p class="mb-4 font-medium dark:text-immich-dark-fg">{$t('recently_added')}</p>
<a
href={Route.recentlyAdded()}
class="pe-4 text-sm font-medium hover:text-immich-primary dark:text-immich-dark-fg dark:hover:text-immich-dark-primary"
draggable="false">{$t('view_all')}</a
>
</div>
<div class="flex h-24 flex-wrap gap-x-1 overflow-hidden md:h-42">
{#each recents as item (item.data.id)}
<a class="relative h-full flex-auto" href={Route.viewAsset({ id: item.data.id })} draggable="false">
<img
src={getAssetMediaUrl({ id: item.data.id, size: AssetMediaSize.Thumbnail })}
alt={$getAltText(toTimelineAsset(item.data))}
class="size-full min-w-max rounded-xl object-cover"
/>
</a>
{/each}
</div>
</div>
{/if}
{#if !hasPeople && places.length === 0 && recents.length === 0}
<EmptyPlaceholder text={$t('no_explore_results_message')} class="mx-auto mt-10" />
{/if}
</UserPageLayout>
@@ -0,0 +1,165 @@
<script lang="ts">
import ActionMenuItem from '$lib/components/ActionMenuItem.svelte';
import UserPageLayout from '$lib/components/layouts/UserPageLayout.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/ButtonContextMenu.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/EmptyPlaceholder.svelte';
import ArchiveAction from '$lib/components/timeline/actions/ArchiveAction.svelte';
import ChangeDate from '$lib/components/timeline/actions/ChangeDateAction.svelte';
import ChangeDescription from '$lib/components/timeline/actions/ChangeDescriptionAction.svelte';
import ChangeLocation from '$lib/components/timeline/actions/ChangeLocationAction.svelte';
import CreateSharedLink from '$lib/components/timeline/actions/CreateSharedLinkAction.svelte';
import DeleteAssets from '$lib/components/timeline/actions/DeleteAssetsAction.svelte';
import DownloadAction from '$lib/components/timeline/actions/DownloadAction.svelte';
import FavoriteAction from '$lib/components/timeline/actions/FavoriteAction.svelte';
import LinkLivePhotoAction from '$lib/components/timeline/actions/LinkLivePhotoAction.svelte';
import SelectAllAssets from '$lib/components/timeline/actions/SelectAllAction.svelte';
import SetVisibilityAction from '$lib/components/timeline/actions/SetVisibilityAction.svelte';
import StackAction from '$lib/components/timeline/actions/StackAction.svelte';
import TagAction from '$lib/components/timeline/actions/TagAction.svelte';
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
import Timeline from '$lib/components/timeline/Timeline.svelte';
import { AssetAction } from '$lib/constants';
import { assetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { getAssetBulkActions } from '$lib/services/asset.service';
import {
updateStackedAssetInTimeline,
updateUnstackedAssetInTimeline,
type OnLink,
type OnUnlink,
} from '$lib/utils/actions';
import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { AssetVisibility, AssetOrderBy } from '@immich/sdk';
import { ActionButton, CommandPaletteDefaultProvider } from '@immich/ui';
import { mdiDotsVertical } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
type Props = {
data: PageData;
};
let { data }: Props = $props();
let timelineManager = $state<TimelineManager>() as TimelineManager;
const options = {
visibility: AssetVisibility.Timeline,
withStacked: true,
withPartners: true,
orderBy: AssetOrderBy.CreatedAt,
};
let selectedAssets = $derived(assetMultiSelectManager.assets);
let isAssetStackSelected = $derived(selectedAssets.length === 1 && !!selectedAssets[0].stack);
let isLinkActionAvailable = $derived.by(() => {
const isLivePhoto = selectedAssets.length === 1 && !!selectedAssets[0].livePhotoVideoId;
const isLivePhotoCandidate =
selectedAssets.length === 2 &&
selectedAssets.some((asset) => asset.isImage) &&
selectedAssets.some((asset) => asset.isVideo);
return assetMultiSelectManager.isAllUserOwned && (isLivePhoto || isLivePhotoCandidate);
});
const handleEscape = () => {
if (assetViewerManager.isViewing) {
return;
}
if (assetMultiSelectManager.selectionActive) {
assetMultiSelectManager.clear();
return;
}
};
const handleLink: OnLink = ({ still, motion }) => {
timelineManager.removeAssets([motion.id]);
timelineManager.upsertAssets([still]);
};
const handleUnlink: OnUnlink = ({ still, motion }) => {
timelineManager.upsertAssets([motion]);
timelineManager.upsertAssets([still]);
};
const handleSetVisibility = (assetIds: string[]) => {
timelineManager.removeAssets(assetIds);
assetMultiSelectManager.clear();
};
</script>
<UserPageLayout hideNavbar={assetMultiSelectManager.selectionActive} title={data.meta.title} scrollbar={false}>
<Timeline
enableRouting={true}
bind:timelineManager
{options}
assetInteraction={assetMultiSelectManager}
removeAction={AssetAction.ARCHIVE}
onEscape={handleEscape}
withStacked
>
{#snippet empty()}
<EmptyPlaceholder text={$t('no_assets_message')} onClick={() => openFileUploadDialog()} class="mx-auto mt-10" />
{/snippet}
</Timeline>
</UserPageLayout>
{#if assetMultiSelectManager.selectionActive}
<AssetSelectControlBar>
{@const Actions = getAssetBulkActions($t)}
<CommandPaletteDefaultProvider name={$t('assets')} actions={Object.values(Actions)} />
<CreateSharedLink />
<SelectAllAssets {timelineManager} assetInteraction={assetMultiSelectManager} />
<ActionButton action={Actions.AddToAlbum} />
{#if assetMultiSelectManager.isAllUserOwned}
<FavoriteAction
removeFavorite={assetMultiSelectManager.isAllFavorite}
onFavorite={(ids, isFavorite) => timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))}
/>
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
<DownloadAction menuItem />
{#if assetMultiSelectManager.assets.length > 1 || isAssetStackSelected}
<StackAction
unstack={isAssetStackSelected}
onStack={(result) => updateStackedAssetInTimeline(timelineManager, result)}
onUnstack={(assets) => updateUnstackedAssetInTimeline(timelineManager, assets)}
/>
{/if}
{#if isLinkActionAvailable}
<LinkLivePhotoAction
menuItem
unlink={assetMultiSelectManager.assets.length === 1}
onLink={handleLink}
onUnlink={handleUnlink}
/>
{/if}
<ChangeDate menuItem />
<ChangeDescription menuItem />
<ChangeLocation menuItem />
<ArchiveAction
menuItem
onArchive={(ids, visibility) => timelineManager.update(ids, (asset) => (asset.visibility = visibility))}
/>
{#if authManager.preferences.tags.enabled}
<TagAction menuItem />
{/if}
<DeleteAssets
menuItem
onAssetDelete={(assetIds) => timelineManager.removeAssets(assetIds)}
onUndoDelete={(assets) => timelineManager.upsertAssets(assets)}
/>
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
<hr />
<ActionMenuItem action={Actions.RegenerateThumbnailJob} />
<ActionMenuItem action={Actions.RefreshMetadataJob} />
<ActionMenuItem action={Actions.TranscodeVideoJob} />
</ButtonContextMenu>
{:else}
<DownloadAction />
{/if}
</AssetSelectControlBar>
{/if}
@@ -0,0 +1,14 @@
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import type { PageLoad } from './$types';
export const load = (async ({ url }) => {
await authenticate(url);
const $t = await getFormatter();
return {
meta: {
title: $t('recently_added_page_title'),
},
};
}) satisfies PageLoad;
@@ -38,6 +38,7 @@ export const timelineAssetFactory = Sync.makeFactory<TimelineAsset>({
tags: [],
thumbhash: Sync.each(() => faker.string.alphanumeric(28)),
localDateTime: Sync.each(() => fromISODateTimeUTCToObject(faker.date.past().toISOString())),
createdAt: Sync.each(() => fromISODateTimeUTCToObject(faker.date.past().toISOString())),
fileCreatedAt: Sync.each(() => fromISODateTimeUTCToObject(faker.date.past().toISOString())),
isFavorite: Sync.each(() => faker.datatype.boolean()),
visibility: AssetVisibility.Timeline,
@@ -66,6 +67,7 @@ export const toResponseDto = (...timelineAsset: TimelineAsset[]) => {
livePhotoVideoId: [],
fileCreatedAt: [],
localOffsetHours: [],
createdAt: [],
ownerId: [],
projectionType: [],
ratio: [],