This commit is contained in:
Jeremy 2025-10-15 12:29:01 +02:00 committed by GitHub
commit 783bf4d2b7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 360 additions and 24 deletions

View file

@ -340,6 +340,7 @@ Class | Method | HTTP request | Description
- [AssetMetadataUpsertDto](doc//AssetMetadataUpsertDto.md)
- [AssetMetadataUpsertItemDto](doc//AssetMetadataUpsertItemDto.md)
- [AssetOrder](doc//AssetOrder.md)
- [AssetOrderBy](doc//AssetOrderBy.md)
- [AssetResponseDto](doc//AssetResponseDto.md)
- [AssetStackResponseDto](doc//AssetStackResponseDto.md)
- [AssetStatsResponseDto](doc//AssetStatsResponseDto.md)

View file

@ -111,6 +111,7 @@ part 'model/asset_metadata_response_dto.dart';
part 'model/asset_metadata_upsert_dto.dart';
part 'model/asset_metadata_upsert_item_dto.dart';
part 'model/asset_order.dart';
part 'model/asset_order_by.dart';
part 'model/asset_response_dto.dart';
part 'model/asset_stack_response_dto.dart';
part 'model/asset_stats_response_dto.dart';

View file

@ -39,6 +39,9 @@ class TimelineApi {
/// * [AssetOrder] order:
/// Sort order for assets within time buckets (ASC for oldest first, DESC for newest first)
///
/// * [AssetOrderBy] orderBy:
/// The field to sort time bucket assets by (DATE_TAKEN, DATE_ADDED, DATE_DELETED)
///
/// * [String] personId:
/// Filter assets containing a specific person (face recognition)
///
@ -61,7 +64,7 @@ class TimelineApi {
///
/// * [bool] withStacked:
/// Include stacked assets in the response. When true, only primary assets from stacks are returned.
Future<Response> getTimeBucketWithHttpInfo(String timeBucket, { String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withCoordinates, bool? withPartners, bool? withStacked, }) async {
Future<Response> getTimeBucketWithHttpInfo(String timeBucket, { String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, AssetOrderBy? orderBy, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withCoordinates, bool? withPartners, bool? withStacked, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/timeline/bucket';
@ -87,6 +90,9 @@ class TimelineApi {
if (order != null) {
queryParams.addAll(_queryParams('', 'order', order));
}
if (orderBy != null) {
queryParams.addAll(_queryParams('', 'orderBy', orderBy));
}
if (personId != null) {
queryParams.addAll(_queryParams('', 'personId', personId));
}
@ -148,6 +154,9 @@ class TimelineApi {
/// * [AssetOrder] order:
/// Sort order for assets within time buckets (ASC for oldest first, DESC for newest first)
///
/// * [AssetOrderBy] orderBy:
/// The field to sort time bucket assets by (DATE_TAKEN, DATE_ADDED, DATE_DELETED)
///
/// * [String] personId:
/// Filter assets containing a specific person (face recognition)
///
@ -170,8 +179,8 @@ class TimelineApi {
///
/// * [bool] withStacked:
/// Include stacked assets in the response. When true, only primary assets from stacks are returned.
Future<TimeBucketAssetResponseDto?> getTimeBucket(String timeBucket, { String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withCoordinates, bool? withPartners, bool? withStacked, }) async {
final response = await getTimeBucketWithHttpInfo(timeBucket, albumId: albumId, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, slug: slug, tagId: tagId, userId: userId, visibility: visibility, withCoordinates: withCoordinates, withPartners: withPartners, withStacked: withStacked, );
Future<TimeBucketAssetResponseDto?> getTimeBucket(String timeBucket, { String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, AssetOrderBy? orderBy, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withCoordinates, bool? withPartners, bool? withStacked, }) async {
final response = await getTimeBucketWithHttpInfo(timeBucket, albumId: albumId, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, orderBy: orderBy, personId: personId, slug: slug, tagId: tagId, userId: userId, visibility: visibility, withCoordinates: withCoordinates, withPartners: withPartners, withStacked: withStacked, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
@ -205,6 +214,9 @@ class TimelineApi {
/// * [AssetOrder] order:
/// Sort order for assets within time buckets (ASC for oldest first, DESC for newest first)
///
/// * [AssetOrderBy] orderBy:
/// The field to sort time bucket assets by (DATE_TAKEN, DATE_ADDED, DATE_DELETED)
///
/// * [String] personId:
/// Filter assets containing a specific person (face recognition)
///
@ -227,7 +239,7 @@ class TimelineApi {
///
/// * [bool] withStacked:
/// Include stacked assets in the response. When true, only primary assets from stacks are returned.
Future<Response> getTimeBucketsWithHttpInfo({ String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withCoordinates, bool? withPartners, bool? withStacked, }) async {
Future<Response> getTimeBucketsWithHttpInfo({ String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, AssetOrderBy? orderBy, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withCoordinates, bool? withPartners, bool? withStacked, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/timeline/buckets';
@ -253,6 +265,9 @@ class TimelineApi {
if (order != null) {
queryParams.addAll(_queryParams('', 'order', order));
}
if (orderBy != null) {
queryParams.addAll(_queryParams('', 'orderBy', orderBy));
}
if (personId != null) {
queryParams.addAll(_queryParams('', 'personId', personId));
}
@ -310,6 +325,9 @@ class TimelineApi {
/// * [AssetOrder] order:
/// Sort order for assets within time buckets (ASC for oldest first, DESC for newest first)
///
/// * [AssetOrderBy] orderBy:
/// The field to sort time bucket assets by (DATE_TAKEN, DATE_ADDED, DATE_DELETED)
///
/// * [String] personId:
/// Filter assets containing a specific person (face recognition)
///
@ -332,8 +350,8 @@ class TimelineApi {
///
/// * [bool] withStacked:
/// Include stacked assets in the response. When true, only primary assets from stacks are returned.
Future<List<TimeBucketsResponseDto>?> getTimeBuckets({ String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withCoordinates, bool? withPartners, bool? withStacked, }) async {
final response = await getTimeBucketsWithHttpInfo( albumId: albumId, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, slug: slug, tagId: tagId, userId: userId, visibility: visibility, withCoordinates: withCoordinates, withPartners: withPartners, withStacked: withStacked, );
Future<List<TimeBucketsResponseDto>?> getTimeBuckets({ String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, AssetOrderBy? orderBy, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withCoordinates, bool? withPartners, bool? withStacked, }) async {
final response = await getTimeBucketsWithHttpInfo( albumId: albumId, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, orderBy: orderBy, personId: personId, slug: slug, tagId: tagId, userId: userId, visibility: visibility, withCoordinates: withCoordinates, withPartners: withPartners, withStacked: withStacked, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}

View file

@ -276,6 +276,8 @@ class ApiClient {
return AssetMetadataUpsertItemDto.fromJson(value);
case 'AssetOrder':
return AssetOrderTypeTransformer().decode(value);
case 'AssetOrderBy':
return AssetOrderByTypeTransformer().decode(value);
case 'AssetResponseDto':
return AssetResponseDto.fromJson(value);
case 'AssetStackResponseDto':

View file

@ -73,6 +73,9 @@ String parameterToString(dynamic value) {
if (value is AssetOrder) {
return AssetOrderTypeTransformer().encode(value).toString();
}
if (value is AssetOrderBy) {
return AssetOrderByTypeTransformer().encode(value).toString();
}
if (value is AssetTypeEnum) {
return AssetTypeEnumTypeTransformer().encode(value).toString();
}

View file

@ -0,0 +1,88 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class AssetOrderBy {
/// Instantiate a new enum with the provided [value].
const AssetOrderBy._(this.value);
/// The underlying value of this enum member.
final String value;
@override
String toString() => value;
String toJson() => value;
static const ADDED = AssetOrderBy._(r'DATE_ADDED');
static const DELETED = AssetOrderBy._(r'DATE_DELETED');
static const TAKEN = AssetOrderBy._(r'DATE_TAKEN');
/// List of all possible values in this [enum][AssetOrderBy].
static const values = <AssetOrderBy>[
ADDED,
DELETED,
TAKEN,
];
static AssetOrderBy? fromJson(dynamic value) => AssetOrderByTypeTransformer().decode(value);
static List<AssetOrderBy> listFromJson(dynamic json, {bool growable = false,}) {
final result = <AssetOrderBy>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = AssetOrderBy.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [AssetOrderBy] to String,
/// and [decode] dynamic data back to [AssetOrderBy].
class AssetOrderByTypeTransformer {
factory AssetOrderByTypeTransformer() => _instance ??= const AssetOrderByTypeTransformer._();
const AssetOrderByTypeTransformer._();
String encode(AssetOrderBy data) => data.value;
/// Decodes a [dynamic value][data] to a AssetOrderBy.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
///
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
AssetOrderBy? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'DATE_ADDED': return AssetOrderBy.ADDED;
case r'DATE_DELETED': return AssetOrderBy.DELETED;
case r'DATE_TAKEN': return AssetOrderBy.TAKEN;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [AssetOrderByTypeTransformer] instance.
static AssetOrderByTypeTransformer? _instance;
}

View file

@ -8841,6 +8841,15 @@
"$ref": "#/components/schemas/AssetOrder"
}
},
{
"name": "orderBy",
"required": false,
"in": "query",
"description": "The field to sort time bucket assets by (DATE_TAKEN, DATE_ADDED, DATE_DELETED)",
"schema": {
"$ref": "#/components/schemas/AssetOrderBy"
}
},
{
"name": "personId",
"required": false,
@ -9005,6 +9014,15 @@
"$ref": "#/components/schemas/AssetOrder"
}
},
{
"name": "orderBy",
"required": false,
"in": "query",
"description": "The field to sort time bucket assets by (DATE_TAKEN, DATE_ADDED, DATE_DELETED)",
"schema": {
"$ref": "#/components/schemas/AssetOrderBy"
}
},
{
"name": "personId",
"required": false,
@ -11056,6 +11074,14 @@
],
"type": "string"
},
"AssetOrderBy": {
"enum": [
"DATE_ADDED",
"DATE_DELETED",
"DATE_TAKEN"
],
"type": "string"
},
"AssetResponseDto": {
"properties": {
"checksum": {

View file

@ -4306,12 +4306,13 @@ export function tagAssets({ id, bulkIdsDto }: {
/**
* This endpoint requires the `asset.read` permission.
*/
export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, personId, slug, tagId, timeBucket, userId, visibility, withCoordinates, withPartners, withStacked }: {
export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, orderBy, personId, slug, tagId, timeBucket, userId, visibility, withCoordinates, withPartners, withStacked }: {
albumId?: string;
isFavorite?: boolean;
isTrashed?: boolean;
key?: string;
order?: AssetOrder;
orderBy?: AssetOrderBy;
personId?: string;
slug?: string;
tagId?: string;
@ -4331,6 +4332,7 @@ export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, pers
isTrashed,
key,
order,
orderBy,
personId,
slug,
tagId,
@ -4347,12 +4349,13 @@ export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, pers
/**
* This endpoint requires the `asset.read` permission.
*/
export function getTimeBuckets({ albumId, isFavorite, isTrashed, key, order, personId, slug, tagId, userId, visibility, withCoordinates, withPartners, withStacked }: {
export function getTimeBuckets({ albumId, isFavorite, isTrashed, key, order, orderBy, personId, slug, tagId, userId, visibility, withCoordinates, withPartners, withStacked }: {
albumId?: string;
isFavorite?: boolean;
isTrashed?: boolean;
key?: string;
order?: AssetOrder;
orderBy?: AssetOrderBy;
personId?: string;
slug?: string;
tagId?: string;
@ -4371,6 +4374,7 @@ export function getTimeBuckets({ albumId, isFavorite, isTrashed, key, order, per
isTrashed,
key,
order,
orderBy,
personId,
slug,
tagId,
@ -5047,3 +5051,8 @@ export enum OAuthTokenEndpointAuthMethod {
ClientSecretPost = "client_secret_post",
ClientSecretBasic = "client_secret_basic"
}
export enum AssetOrderBy {
DateAdded = "DATE_ADDED",
DateDeleted = "DATE_DELETED",
DateTaken = "DATE_TAKEN"
}

View file

@ -1,7 +1,7 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString } from 'class-validator';
import { AssetOrder, AssetVisibility } from 'src/enum';
import { AssetOrder, AssetOrderBy, AssetVisibility } from 'src/enum';
import { ValidateBoolean, ValidateEnum, ValidateUUID } from 'src/validation';
export class TimeBucketDto {
@ -46,6 +46,14 @@ export class TimeBucketDto {
})
order?: AssetOrder;
@ValidateEnum({
enum: AssetOrderBy,
name: 'AssetOrderBy',
description: 'The field to sort time bucket assets by (DATE_TAKEN, DATE_ADDED, DATE_DELETED)',
optional: true,
})
orderBy?: AssetOrderBy;
@ValidateEnum({
enum: AssetVisibility,
name: 'AssetVisibility',

View file

@ -55,6 +55,12 @@ export enum AssetOrder {
Desc = 'desc',
}
export enum AssetOrderBy {
DateAdded = 'DATE_ADDED',
DateDeleted = 'DATE_DELETED',
DateTaken = 'DATE_TAKEN',
}
export enum DatabaseAction {
Create = 'CREATE',
Update = 'UPDATE',

View file

@ -4,7 +4,15 @@ import { isEmpty, isUndefined, omitBy } from 'lodash';
import { InjectKysely } from 'nestjs-kysely';
import { Stack } from 'src/database';
import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
import { AssetFileType, AssetMetadataKey, AssetOrder, AssetStatus, AssetType, AssetVisibility } from 'src/enum';
import {
AssetFileType,
AssetMetadataKey,
AssetOrder,
AssetOrderBy,
AssetStatus,
AssetType,
AssetVisibility,
} from 'src/enum';
import { DB } from 'src/schema';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
import { AssetFileTable } from 'src/schema/tables/asset-file.table';
@ -14,9 +22,11 @@ import { AssetMetadataItem } from 'src/types';
import {
anyUuid,
asUuid,
getTimeBucket,
getTimeBucketOffset,
getTimeBucketRef,
hasPeople,
removeUndefinedKeys,
truncatedDate,
unnest,
withDefaultVisibility,
withExif,
@ -65,6 +75,7 @@ interface AssetBuilderOptions {
export interface TimeBucketOptions extends AssetBuilderOptions {
order?: AssetOrder;
orderBy?: AssetOrderBy;
}
export interface TimeBucketItem {
@ -550,11 +561,13 @@ export class AssetRepository {
@GenerateSql({ params: [{}] })
async getTimeBuckets(options: TimeBucketOptions): Promise<TimeBucketItem[]> {
const orderBy = options.orderBy ?? AssetOrderBy.DateTaken;
return this.db
.with('asset', (qb) =>
qb
.selectFrom('asset')
.select(truncatedDate<Date>().as('timeBucket'))
.select((eb) => getTimeBucket<Date>(eb, orderBy).as('timeBucket'))
.$if(!!options.isTrashed, (qb) => qb.where('asset.status', '!=', AssetStatus.Deleted))
.where('asset.deletedAt', options.isTrashed ? 'is not' : 'is', null)
.$if(options.visibility === undefined, withDefaultVisibility)
@ -581,7 +594,7 @@ export class AssetRepository {
.$if(!!options.tagId, (qb) => withTagId(qb, options.tagId!)),
)
.selectFrom('asset')
.select(sql<string>`("timeBucket" AT TIME ZONE 'UTC')::date::text`.as('timeBucket'))
.select('timeBucket')
.select((eb) => eb.fn.countAll<number>().as('count'))
.groupBy('timeBucket')
.orderBy('timeBucket', options.order ?? 'desc')
@ -592,6 +605,8 @@ export class AssetRepository {
params: [DummyValue.TIME_BUCKET, { withStacked: true }],
})
getTimeBucket(timeBucket: string, options: TimeBucketOptions) {
const orderBy = options.orderBy ?? AssetOrderBy.DateTaken;
const query = this.db
.with('cte', (qb) =>
qb
@ -605,12 +620,10 @@ export class AssetRepository {
sql`asset.type = 'IMAGE'`.as('isImage'),
sql`asset."deletedAt" is not null`.as('isTrashed'),
'asset.livePhotoVideoId',
sql`extract(epoch from (asset."localDateTime" AT TIME ZONE 'UTC' - asset."fileCreatedAt" at time zone 'UTC'))::real / 3600`.as(
'localOffsetHours',
),
getTimeBucketOffset(eb, orderBy).as('localOffsetHours'),
'asset.ownerId',
'asset.status',
sql`asset."fileCreatedAt" at time zone 'utc'`.as('fileCreatedAt'),
sql`${getTimeBucketRef(eb, orderBy)} at time zone 'utc'`.as('fileCreatedAt'),
eb.fn('encode', ['asset.thumbhash', sql.lit('base64')]).as('thumbhash'),
'asset_exif.city',
'asset_exif.country',
@ -633,7 +646,7 @@ export class AssetRepository {
.where('asset.deletedAt', options.isTrashed ? 'is not' : 'is', null)
.$if(options.visibility == undefined, withDefaultVisibility)
.$if(!!options.visibility, (qb) => qb.where('asset.visibility', '=', options.visibility!))
.where(truncatedDate(), '=', timeBucket.replace(/^[+-]/, ''))
.where((eb) => eb(getTimeBucket<string>(eb, orderBy), '=', timeBucket.replace(/^[+-]/, '')))
.$if(!!options.albumId, (qb) =>
qb.where((eb) =>
eb.exists(
@ -679,7 +692,7 @@ export class AssetRepository {
)
.$if(!!options.isTrashed, (qb) => qb.where('asset.status', '!=', AssetStatus.Deleted))
.$if(!!options.tagId, (qb) => withTagId(qb, options.tagId!))
.orderBy('asset.fileCreatedAt', options.order ?? 'desc'),
.orderBy((eb) => getTimeBucketRef(eb, orderBy), options.order ?? 'desc'),
)
.with('agg', (qb) =>
qb

View file

@ -16,7 +16,7 @@ import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import { parse } from 'pg-connection-string';
import postgres, { Notice, PostgresError } from 'postgres';
import { columns, Exif, Person } from 'src/database';
import { AssetFileType, AssetVisibility, DatabaseExtension, DatabaseSslMode } from 'src/enum';
import { AssetFileType, AssetOrderBy, AssetVisibility, DatabaseExtension, DatabaseSslMode } from 'src/enum';
import { AssetSearchBuilderOptions } from 'src/repositories/search.repository';
import { DB } from 'src/schema';
import { DatabaseConnectionParams, VectorExtension } from 'src/types';
@ -282,8 +282,30 @@ export function withTags(eb: ExpressionBuilder<DB, 'asset'>) {
).as('tags');
}
export function truncatedDate<O>() {
return sql<O>`date_trunc(${sql.lit('MONTH')}, "localDateTime" AT TIME ZONE 'UTC') AT TIME ZONE 'UTC'`;
export function getTimeBucketRef(eb: ExpressionBuilder<DB, 'asset'>, orderBy: AssetOrderBy) {
if (orderBy === AssetOrderBy.DateAdded) {
return eb.ref('asset.createdAt');
} else if (orderBy === AssetOrderBy.DateDeleted) {
return eb.ref('asset.deletedAt');
}
return eb.ref('asset.localDateTime');
}
export function getTimeBucketOffset(eb: ExpressionBuilder<DB, 'asset'>, orderBy: AssetOrderBy) {
if (orderBy === AssetOrderBy.DateTaken) {
return sql`extract(epoch from (asset."localDateTime" AT TIME ZONE 'UTC' - asset."fileCreatedAt" at time zone 'UTC'))::real / 3600`;
}
return sql.lit(0);
}
export function getTimeBucket<O>(
eb: ExpressionBuilder<DB, 'asset'>,
orderBy: AssetOrderBy,
timezone: Expression<string> = sql.lit('UTC'),
) {
return sql<O>`date_trunc(${sql.lit('MONTH')}, ${getTimeBucketRef(eb, orderBy)}, ${timezone})`;
}
export function withTagId<O>(qb: SelectQueryBuilder<DB, 'asset', O>, tagId: string) {

View file

@ -16,6 +16,8 @@
mdiFolderOutline,
mdiHeart,
mdiHeartOutline,
mdiClock,
mdiClockOutline,
mdiImageAlbum,
mdiImageMultiple,
mdiImageMultipleOutline,
@ -40,6 +42,7 @@
let isMapSelected: boolean = $state(false);
let isPeopleSelected: boolean = $state(false);
let isPhotosSelected: boolean = $state(false);
let isRecentlyAddedSelected: boolean = $state(false);
let isSharingSelected: boolean = $state(false);
let isTrashSelected: boolean = $state(false);
let isUtilitiesSelected: boolean = $state(false);
@ -118,6 +121,13 @@
<SideBarLink title={$t('folders')} href={resolve('/(user)/folders')} icon={mdiFolderOutline} flippedLogo />
{/if}
<SideBarLink
title={$t('recently_added_page_title')}
href={resolve('/(user)/recently-added')}
bind:isSelected={isRecentlyAddedSelected}
icon={isRecentlyAddedSelected ? mdiClock : mdiClockOutline}
></SideBarLink>
<SideBarLink
title={$t('utilities')}
href={resolve('/(user)/utilities')}

View file

@ -0,0 +1,112 @@
<script lang="ts">
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import AddToAlbum from '$lib/components/timeline/actions/AddToAlbumAction.svelte';
import ArchiveAction from '$lib/components/timeline/actions/ArchiveAction.svelte';
import AssetJobActions from '$lib/components/timeline/actions/AssetJobActions.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 SelectAllAssets from '$lib/components/timeline/actions/SelectAllAction.svelte';
import SetVisibilityAction from '$lib/components/timeline/actions/SetVisibilityAction.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 { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { preferences, user } from '$lib/stores/user.store';
import { AssetOrderBy, AssetVisibility } from '@immich/sdk';
import { mdiDotsVertical, mdiPlus } from '@mdi/js';
import { onDestroy } from 'svelte';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
interface Props {
data: PageData;
}
let { data }: Props = $props();
const timelineManager = new TimelineManager();
void timelineManager.updateOptions({
visibility: AssetVisibility.Timeline,
withStacked: true,
withPartners: true,
orderBy: AssetOrderBy.DateAdded,
});
onDestroy(() => timelineManager.destroy());
const assetInteraction = new AssetInteraction();
const handleEscape = () => {
if (assetInteraction.selectionActive) {
assetInteraction.clearMultiselect();
return;
}
};
const handleSetVisibility = (assetIds: string[]) => {
timelineManager.removeAssets(assetIds);
assetInteraction.clearMultiselect();
};
</script>
<UserPageLayout hideNavbar={assetInteraction.selectionActive} title={data.meta.title} scrollbar={false}>
<Timeline
enableRouting={true}
{timelineManager}
{assetInteraction}
removeAction={AssetAction.UNARCHIVE}
onEscape={handleEscape}
>
{#snippet empty()}
<EmptyPlaceholder text={$t('no_archived_assets_message')} />
{/snippet}
</Timeline>
</UserPageLayout>
{#if assetInteraction.selectionActive}
<AssetSelectControlBar
ownerId={$user.id}
assets={assetInteraction.selectedAssets}
clearSelect={() => assetInteraction.clearMultiselect()}
>
<CreateSharedLink />
<SelectAllAssets {timelineManager} {assetInteraction} />
<ButtonContextMenu icon={mdiPlus} title={$t('add_to')}>
<AddToAlbum />
<AddToAlbum shared />
</ButtonContextMenu>
<FavoriteAction
removeFavorite={assetInteraction.isAllFavorite}
onFavorite={(ids, isFavorite) =>
timelineManager.updateAssetOperation(ids, (asset) => {
asset.isFavorite = isFavorite;
return { remove: false };
})}
></FavoriteAction>
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
<DownloadAction menuItem />
<ChangeDate menuItem />
<ChangeDescription menuItem />
<ChangeLocation menuItem />
<ArchiveAction menuItem onArchive={(assetIds) => timelineManager.removeAssets(assetIds)} />
{#if $preferences.tags.enabled}
<TagAction menuItem />
{/if}
<DeleteAssets
menuItem
onAssetDelete={(assetIds) => timelineManager.removeAssets(assetIds)}
onUndoDelete={(assets) => timelineManager.addAssets(assets)}
/>
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
<hr />
<AssetJobActions />
</ButtonContextMenu>
</AssetSelectControlBar>
{/if}

View file

@ -0,0 +1,17 @@
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { getAssetInfoFromParam } from '$lib/utils/navigation';
import type { PageLoad } from './$types';
export const load = (async ({ params, url }) => {
await authenticate(url);
const asset = await getAssetInfoFromParam(params);
const $t = await getFormatter();
return {
asset,
meta: {
title: $t('recently_added'),
},
};
}) satisfies PageLoad;

View file

@ -18,7 +18,7 @@
import { featureFlags, serverConfig } from '$lib/stores/server-config.store';
import { handlePromiseError } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { emptyTrash, restoreTrash } from '@immich/sdk';
import { AssetOrderBy, emptyTrash, restoreTrash } from '@immich/sdk';
import { Button, HStack, modalManager, Text } from '@immich/ui';
import { mdiDeleteForeverOutline, mdiHistory } from '@mdi/js';
import { onDestroy } from 'svelte';
@ -36,7 +36,7 @@
}
const timelineManager = new TimelineManager();
void timelineManager.updateOptions({ isTrashed: true });
void timelineManager.updateOptions({ isTrashed: true, orderBy: AssetOrderBy.DateDeleted });
onDestroy(() => timelineManager.destroy());
const assetInteraction = new AssetInteraction();