Compare commits

...

8 Commits

Author SHA1 Message Date
Ben Beckford 3f1b8e1d9b paginate searchMemories 2026-05-28 23:54:14 -07:00
Ben Beckford 6e78d6e131 wip web memories view 2026-05-28 14:11:15 -07:00
Alex aecf8ec88b fix: timeline scroll flicker (#28653)
* test: fix scroll flicker

* lint
2026-05-28 08:20:54 -05:00
Daniel Dietzler bcff1d42b0 chore: migrate more make targets (#28663) 2026-05-28 08:33:57 -04:00
Min Idzelis 1bd367bd51 refactor(web): replace per-asset viewport proximity with day-tier active indices (#28597) 2026-05-28 11:44:18 +02:00
Daniel Dietzler 725f266b81 chore: migrate more make targets to mise (#28651) 2026-05-28 11:31:02 +02:00
Daniel Dietzler d08e3de207 fix: e2e linting (#28659) 2026-05-28 11:12:26 +02:00
Timon 26714f6bfe fix(server): prevent locked assets from leaking to partners (#28652)
* fix(server): prevent locked assets from leaking to partners

* fix tests
2026-05-27 17:33:49 -04:00
38 changed files with 712 additions and 175 deletions
+15 -24
View File
@@ -1,46 +1,46 @@
dev:
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --remove-orphans
@printf "This command has been removed. Please use:\n\n mise dev # or mise //:dev from another directory\n\n" >&2 && exit 1
dev-down:
docker compose -f ./docker/docker-compose.dev.yml down --remove-orphans
@printf "This command has been removed. Please use:\n\n mise dev-down # or mise //:dev-down from another directory\n\n" >&2 && exit 1
dev-update:
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --build -V --remove-orphans
@printf "This command has been removed. Please use:\n\n mise dev-update # or mise //:dev-update from another directory\n\n" >&2 && exit 1
dev-scale:
@trap 'make dev-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.dev.yml up --build -V --scale immich-server=3 --remove-orphans
@printf "This command has been removed. Please use:\n\n mise dev-scale # or mise //:dev-scale from another directory\n\n" >&2 && exit 1
dev-docs:
npm --prefix docs run start
.PHONY: e2e
e2e:
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --remove-orphans
@printf "This command has been removed. Please use:\n\n mise e2e # or mise //:e2e from another directory\n\n" >&2 && exit 1
e2e-dev:
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.dev.yml up --remove-orphans
@printf "This command has been removed. Please use:\n\n mise e2e-dev # or mise //:e2e-dev from another directory\n\n" >&2 && exit 1
e2e-update:
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --build -V --remove-orphans
@printf "This command has been removed. Please use:\n\n mise e2e-update # or mise //:e2e-update from another directory\n\n" >&2 && exit 1
e2e-down:
docker compose -f ./e2e/docker-compose.yml down --remove-orphans
@printf "This command has been removed. Please use:\n\n mise e2e-down # or mise //:e2e-down from another directory\n\n" >&2 && exit 1
prod:
@trap 'make prod-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.prod.yml up --build -V --remove-orphans
@printf "This command has been removed. Please use:\n\n mise prod # or mise //:prod from another directory\n\n" >&2 && exit 1
prod-down:
docker compose -f ./docker/docker-compose.prod.yml down --remove-orphans
@printf "This command has been removed. Please use:\n\n mise prod-down # or mise //:prod-down from another directory\n\n" >&2 && exit 1
prod-scale:
@trap 'make prod-down' EXIT; COMPOSE_BAKE=true docker compose -f ./docker/docker-compose.prod.yml up --build -V --scale immich-server=3 --scale immich-microservices=3 --remove-orphans
@printf "This command has been removed. Please use:\n\n mise prod-scale # or mise //:prod-scale from another directory\n\n" >&2 && exit 1
.PHONY: open-api
open-api:
@printf "This command has been removed. Please use:\n\n mise open-api # or mise //:open-api from another directory\n\n"\n\n >&2 && exit 1
@printf "This command has been removed. Please use:\n\n mise open-api # or mise //:open-api from another directory\n\n" >&2 && exit 1
sql:
@printf "This command has been removed. Please use:\n\n mise sql # or mise //:sql from another directory\n\n"\n\n >&2 && exit 1
@printf "This command has been removed. Please use:\n\n mise sql # or mise //:sql from another directory\n\n" >&2 && exit 1
renovate:
@@ -52,16 +52,7 @@ renovate:
MODULES = e2e server web cli sdk docs .github
test-e2e:
docker compose -f ./e2e/docker-compose.yml build
pnpm --filter immich-e2e run test
pnpm --filter immich-e2e run test:web
@printf "This command has been removed. Please use:\n\n mise //e2e:test # or mise //e2e:test-web for web tests, respectively\n\n" >&2 && exit 1
clean:
find . -name "node_modules" -type d -prune -exec rm -rf {} +
find . -name "dist" -type d -prune -exec rm -rf '{}' +
find . -name "build" -type d -prune -exec rm -rf '{}' +
find . -name ".svelte-kit" -type d -prune -exec rm -rf '{}' +
find . -name "coverage" -type d -prune -exec rm -rf '{}' +
find . -name ".pnpm-store" -type d -prune -exec rm -rf '{}' +
command -v docker >/dev/null 2>&1 && docker compose -f ./docker/docker-compose.dev.yml down -v --remove-orphans || true
command -v docker >/dev/null 2>&1 && docker compose -f ./e2e/docker-compose.yml down -v --remove-orphans || true
@printf "This command has been removed. Please use:\n\n mise clean # or mise //:clean from another directory\n\n" >&2 && exit 1
+16 -1
View File
@@ -1,11 +1,21 @@
[tasks.install]
run = "pnpm install --filter immich-e2e --frozen-lockfile"
[tasks.build]
dir = "{{ config_root }}"
run = "docker compose build"
[tasks.test]
depends = ["//e2e:build", "//e2e:ci-setup"]
env._.path = "./node_modules/.bin"
run = "vitest --run"
[tasks.playwright-install]
env._.path = "./node_modules/.bin"
run = "playwright install"
[tasks."test-web"]
depends = ["//e2e:build", "//e2e:ci-setup", "//e2e:playwright-install"]
env._.path = "./node_modules/.bin"
run = "playwright test"
@@ -30,7 +40,12 @@ run = "tsc --noEmit"
[tasks.ci-setup]
depends = ["//:sdk:install", "//:sdk:build", "//cli:install", "//cli:build"]
depends = [
"//:sdk:install",
"//:sdk:build",
"//packages/cli:install",
"//packages/cli:build",
]
run = { task = ":install" }
@@ -55,8 +55,8 @@ export function toColumnarFormat(assets: MockTimelineAsset[]): TimeBucketAssetRe
result.duration.push(asset.duration);
result.projectionType.push(asset.projectionType);
result.livePhotoVideoId.push(asset.livePhotoVideoId);
result.city.push(asset.city);
result.country.push(asset.country);
result.city?.push(asset.city);
result.country?.push(asset.country);
result.visibility.push(asset.visibility);
}
+77
View File
@@ -84,6 +84,72 @@ run = [
dir = "server"
run = "node ./dist/bin/sync-sql.js"
# TODO dev, prod, and e2e should be de-duplicated by using env but for some reason I ran into issues
[tasks.dev]
depends = "//:plugins"
dir = "docker"
interactive = true
env = { COMPOSE_BAKE = true }
run = "docker compose -f ./docker-compose.dev.yml up --remove-orphans"
depends_post = "//:dev-down"
[tasks.dev-update]
run = { task = "//:dev", args = ["--build", "-V"] }
[tasks.dev-scale]
run = { task = "//:dev", args = ["--build", "-V", "--scale immich-server=3"] }
[tasks.dev-down]
dir = "docker"
run = "docker compose -f ./docker-compose.dev.yml down --remove-orphans"
[tasks.prod]
depends = "//:plugins"
dir = "docker"
interactive = true
env = { COMPOSE_BAKE = true }
run = "docker compose -f ./docker-compose.prod.yml up --remove-orphans"
depends_post = "//:prod-down"
[tasks.prod-scale]
run = { task = "//:prod", args = [
"--build",
"-V",
"--scale immich-server=3",
"--scale immich-microservices",
] }
[tasks.prod-down]
dir = "docker"
run = "docker compose -f ./docker-compose.prod.yml down --remove-orphans"
[tasks.e2e]
depends = "//:plugins"
dir = "e2e"
interactive = true
env = { COMPOSE_BAKE = true }
run = "docker compose -f ./docker-compose.yml up --remove-orphans"
depends_post = "//:e2e-down"
[tasks.e2e-dev]
depends = "//:plugins"
dir = "e2e"
interactive = true
env = { COMPOSE_BAKE = true }
run = "docker compose -f ./docker-compose.dev.yml up --remove-orphans"
depends_post = "//:e2e-dev-down"
[tasks.e2e-update]
run = { task = "//:e2e", args = ["--build", '-V'] }
[tasks.e2e-down]
dir = "e2e"
run = "docker compose -f ./docker-compose.yml down --remove-orphans"
[tasks.e2e-dev-down]
dir = "e2e"
run = "docker compose -f ./docker-compose.dev.yml down --remove-orphans"
# SDK tasks
[tasks."sdk:install"]
dir = "packages/sdk"
@@ -99,3 +165,14 @@ run = "pnpm format"
[tasks."i18n:format-fix"]
run = "pnpm format:fix"
[tasks.clean]
run = [
"find . -name 'node_modules' -type d -prune -exec rm -rf '{}' +",
"find . -name 'dist' -type d -prune -exec rm -rf '{}' +",
"find . -name 'build' -type d -prune -exec rm -rf '{}' +",
"find . -name '.svelte-kit' -type d -prune -exec rm -rf '{}' +",
"find . -name 'coverage' -type d -prune -exec rm -rf '{}' +",
"find . -name '.pnpm-store' -type d -prune -exec rm -rf '{}' +",
{ task = "//:*-down" },
]
+1
View File
@@ -450,6 +450,7 @@ Class | Method | HTTP request | Description
- [MemoryCreateDto](doc//MemoryCreateDto.md)
- [MemoryResponseDto](doc//MemoryResponseDto.md)
- [MemorySearchOrder](doc//MemorySearchOrder.md)
- [MemorySearchResponseDto](doc//MemorySearchResponseDto.md)
- [MemoryStatisticsResponseDto](doc//MemoryStatisticsResponseDto.md)
- [MemoryType](doc//MemoryType.md)
- [MemoryUpdateDto](doc//MemoryUpdateDto.md)
+1
View File
@@ -195,6 +195,7 @@ part 'model/memories_update.dart';
part 'model/memory_create_dto.dart';
part 'model/memory_response_dto.dart';
part 'model/memory_search_order.dart';
part 'model/memory_search_response_dto.dart';
part 'model/memory_statistics_response_dto.dart';
part 'model/memory_type.dart';
part 'model/memory_update_dto.dart';
+26 -11
View File
@@ -261,11 +261,14 @@ class MemoriesApi {
///
/// * [MemorySearchOrder] order:
///
/// * [int] page:
/// Page number
///
/// * [int] size:
/// Number of memories to return
///
/// * [MemoryType] type:
Future<Response> memoriesStatisticsWithHttpInfo({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? size, MemoryType? type, }) async {
Future<Response> memoriesStatisticsWithHttpInfo({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? page, int? size, MemoryType? type, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/memories/statistics';
@@ -288,6 +291,9 @@ class MemoriesApi {
if (order != null) {
queryParams.addAll(_queryParams('', 'order', order));
}
if (page != null) {
queryParams.addAll(_queryParams('', 'page', page));
}
if (size != null) {
queryParams.addAll(_queryParams('', 'size', size));
}
@@ -326,12 +332,15 @@ class MemoriesApi {
///
/// * [MemorySearchOrder] order:
///
/// * [int] page:
/// Page number
///
/// * [int] size:
/// Number of memories to return
///
/// * [MemoryType] type:
Future<MemoryStatisticsResponseDto?> memoriesStatistics({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? size, MemoryType? type, }) async {
final response = await memoriesStatisticsWithHttpInfo( for_: for_, isSaved: isSaved, isTrashed: isTrashed, order: order, size: size, type: type, );
Future<MemoryStatisticsResponseDto?> memoriesStatistics({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? page, int? size, MemoryType? type, }) async {
final response = await memoriesStatisticsWithHttpInfo( for_: for_, isSaved: isSaved, isTrashed: isTrashed, order: order, page: page, size: size, type: type, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
@@ -428,11 +437,14 @@ class MemoriesApi {
///
/// * [MemorySearchOrder] order:
///
/// * [int] page:
/// Page number
///
/// * [int] size:
/// Number of memories to return
///
/// * [MemoryType] type:
Future<Response> searchMemoriesWithHttpInfo({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? size, MemoryType? type, }) async {
Future<Response> searchMemoriesWithHttpInfo({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? page, int? size, MemoryType? type, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/memories';
@@ -455,6 +467,9 @@ class MemoriesApi {
if (order != null) {
queryParams.addAll(_queryParams('', 'order', order));
}
if (page != null) {
queryParams.addAll(_queryParams('', 'page', page));
}
if (size != null) {
queryParams.addAll(_queryParams('', 'size', size));
}
@@ -493,12 +508,15 @@ class MemoriesApi {
///
/// * [MemorySearchOrder] order:
///
/// * [int] page:
/// Page number
///
/// * [int] size:
/// Number of memories to return
///
/// * [MemoryType] type:
Future<List<MemoryResponseDto>?> searchMemories({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? size, MemoryType? type, }) async {
final response = await searchMemoriesWithHttpInfo( for_: for_, isSaved: isSaved, isTrashed: isTrashed, order: order, size: size, type: type, );
Future<MemorySearchResponseDto?> searchMemories({ DateTime? for_, bool? isSaved, bool? isTrashed, MemorySearchOrder? order, int? page, int? size, MemoryType? type, }) async {
final response = await searchMemoriesWithHttpInfo( for_: for_, isSaved: isSaved, isTrashed: isTrashed, order: order, page: page, size: size, type: type, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
@@ -506,11 +524,8 @@ class MemoriesApi {
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
final responseBody = await _decodeBodyBytes(response);
return (await apiClient.deserializeAsync(responseBody, 'List<MemoryResponseDto>') as List)
.cast<MemoryResponseDto>()
.toList(growable: false);
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'MemorySearchResponseDto',) as MemorySearchResponseDto;
}
return null;
}
+2
View File
@@ -436,6 +436,8 @@ class ApiClient {
return MemoryResponseDto.fromJson(value);
case 'MemorySearchOrder':
return MemorySearchOrderTypeTransformer().decode(value);
case 'MemorySearchResponseDto':
return MemorySearchResponseDto.fromJson(value);
case 'MemoryStatisticsResponseDto':
return MemoryStatisticsResponseDto.fromJson(value);
case 'MemoryType':
+120
View File
@@ -0,0 +1,120 @@
//
// 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 MemorySearchResponseDto {
/// Returns a new [MemorySearchResponseDto] instance.
MemorySearchResponseDto({
required this.hasNextPage,
this.items = const [],
required this.total,
});
/// Whether there are more pages
bool hasNextPage;
List<MemoryResponseDto> items;
/// Total number of matching memories
///
/// Minimum value: 0
/// Maximum value: 9007199254740991
int total;
@override
bool operator ==(Object other) => identical(this, other) || other is MemorySearchResponseDto &&
other.hasNextPage == hasNextPage &&
_deepEquality.equals(other.items, items) &&
other.total == total;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(hasNextPage.hashCode) +
(items.hashCode) +
(total.hashCode);
@override
String toString() => 'MemorySearchResponseDto[hasNextPage=$hasNextPage, items=$items, total=$total]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'hasNextPage'] = this.hasNextPage;
json[r'items'] = this.items;
json[r'total'] = this.total;
return json;
}
/// Returns a new [MemorySearchResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static MemorySearchResponseDto? fromJson(dynamic value) {
upgradeDto(value, "MemorySearchResponseDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return MemorySearchResponseDto(
hasNextPage: mapValueOfType<bool>(json, r'hasNextPage')!,
items: MemoryResponseDto.listFromJson(json[r'items']),
total: mapValueOfType<int>(json, r'total')!,
);
}
return null;
}
static List<MemorySearchResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <MemorySearchResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = MemorySearchResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, MemorySearchResponseDto> mapFromJson(dynamic json) {
final map = <String, MemorySearchResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = MemorySearchResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of MemorySearchResponseDto-objects as value to a dart map
static Map<String, List<MemorySearchResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<MemorySearchResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = MemorySearchResponseDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'hasNextPage',
'items',
'total',
};
}
+49 -4
View File
@@ -6363,6 +6363,17 @@
"$ref": "#/components/schemas/MemorySearchOrder"
}
},
{
"name": "page",
"required": false,
"in": "query",
"description": "Page number",
"schema": {
"minimum": 1,
"maximum": 9007199254740991,
"type": "integer"
}
},
{
"name": "size",
"required": false,
@@ -6388,10 +6399,7 @@
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/MemoryResponseDto"
},
"type": "array"
"$ref": "#/components/schemas/MemorySearchResponseDto"
}
}
},
@@ -6532,6 +6540,17 @@
"$ref": "#/components/schemas/MemorySearchOrder"
}
},
{
"name": "page",
"required": false,
"in": "query",
"description": "Page number",
"schema": {
"minimum": 1,
"maximum": 9007199254740991,
"type": "integer"
}
},
{
"name": "size",
"required": false,
@@ -18807,6 +18826,32 @@
],
"type": "string"
},
"MemorySearchResponseDto": {
"properties": {
"hasNextPage": {
"description": "Whether there are more pages",
"type": "boolean"
},
"items": {
"items": {
"$ref": "#/components/schemas/MemoryResponseDto"
},
"type": "array"
},
"total": {
"description": "Total number of matching memories",
"maximum": 9007199254740991,
"minimum": 0,
"type": "integer"
}
},
"required": [
"hasNextPage",
"items",
"total"
],
"type": "object"
},
"MemoryStatisticsResponseDto": {
"properties": {
"total": {
+14 -3
View File
@@ -1316,6 +1316,13 @@ export type MemoryResponseDto = {
/** Last update date */
updatedAt: string;
};
export type MemorySearchResponseDto = {
/** Whether there are more pages */
hasNextPage: boolean;
items: MemoryResponseDto[];
/** Total number of matching memories */
total: number;
};
export type MemoryCreateDto = {
/** Asset IDs to associate with memory */
assetIds?: string[];
@@ -4678,22 +4685,24 @@ export function reverseGeocode({ lat, lon }: {
/**
* Retrieve memories
*/
export function searchMemories({ $for, isSaved, isTrashed, order, size, $type }: {
export function searchMemories({ $for, isSaved, isTrashed, order, page, size, $type }: {
$for?: string;
isSaved?: boolean;
isTrashed?: boolean;
order?: MemorySearchOrder;
page?: number;
size?: number;
$type?: MemoryType;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: MemoryResponseDto[];
data: MemorySearchResponseDto;
}>(`/memories${QS.query(QS.explode({
"for": $for,
isSaved,
isTrashed,
order,
page,
size,
"type": $type
}))}`, {
@@ -4718,11 +4727,12 @@ export function createMemory({ memoryCreateDto }: {
/**
* Retrieve memories statistics
*/
export function memoriesStatistics({ $for, isSaved, isTrashed, order, size, $type }: {
export function memoriesStatistics({ $for, isSaved, isTrashed, order, page, size, $type }: {
$for?: string;
isSaved?: boolean;
isTrashed?: boolean;
order?: MemorySearchOrder;
page?: number;
size?: number;
$type?: MemoryType;
}, opts?: Oazapfts.RequestOpts) {
@@ -4734,6 +4744,7 @@ export function memoriesStatistics({ $for, isSaved, isTrashed, order, size, $typ
isSaved,
isTrashed,
order,
page,
size,
"type": $type
}))}`, {
+2 -1
View File
@@ -7,6 +7,7 @@ import {
MemoryCreateDto,
MemoryResponseDto,
MemorySearchDto,
MemorySearchResponseDto,
MemoryStatisticsResponseDto,
MemoryUpdateDto,
} from 'src/dtos/memory.dto';
@@ -28,7 +29,7 @@ export class MemoryController {
'Retrieve a list of memories. Memories are sorted descending by creation date by default, although they can also be sorted in ascending order, or randomly.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
searchMemories(@Auth() auth: AuthDto, @Query() dto: MemorySearchDto): Promise<MemoryResponseDto[]> {
searchMemories(@Auth() auth: AuthDto, @Query() dto: MemorySearchDto): Promise<MemorySearchResponseDto> {
return this.service.search(auth, dto);
}
+10
View File
@@ -14,6 +14,7 @@ const MemorySearchSchema = z
isTrashed: stringToBool.optional().describe('Include trashed memories'),
isSaved: stringToBool.optional().describe('Filter by saved status'),
size: z.coerce.number().int().min(1).optional().describe('Number of memories to return'),
page: z.coerce.number().int().min(1).optional().describe('Page number'),
order: AssetOrderWithRandomSchema.optional(),
})
.meta({ id: 'MemorySearchDto' });
@@ -75,11 +76,20 @@ const MemoryResponseSchema = z
})
.meta({ id: 'MemoryResponseDto' });
const MemorySearchResponseSchema = z
.object({
total: z.int().min(0).describe('Total number of matching memories'),
items: z.array(MemoryResponseSchema),
hasNextPage: z.boolean().describe('Whether there are more pages'),
})
.meta({ id: 'MemorySearchResponseDto' });
export class MemorySearchDto extends createZodDto(MemorySearchSchema) {}
export class MemoryUpdateDto extends createZodDto(MemoryUpdateSchema) {}
export class MemoryCreateDto extends createZodDto(MemoryCreateSchema) {}
export class MemoryStatisticsResponseDto extends createZodDto(MemoryStatisticsResponseSchema) {}
export class MemoryResponseDto extends createZodDto(MemoryResponseSchema) {}
export class MemorySearchResponseDto extends createZodDto(MemorySearchResponseSchema) {}
export const mapMemory = (entity: Memory, auth: AuthDto): MemoryResponseDto => {
return {
+7 -3
View File
@@ -9,6 +9,7 @@ import { AssetOrderWithRandom, AssetVisibility } from 'src/enum';
import { DB } from 'src/schema';
import { MemoryTable } from 'src/schema/tables/memory.table';
import { IBulkAsset } from 'src/types';
import { paginationHelper } from 'src/utils/pagination';
@Injectable()
export class MemoryRepository implements IBulkAsset {
@@ -57,8 +58,8 @@ export class MemoryRepository implements IBulkAsset {
{ params: [DummyValue.UUID, {}] },
{ name: 'date filter', params: [DummyValue.UUID, { for: DummyValue.DATE }] },
)
search(ownerId: string, dto: MemorySearchDto) {
return this.searchBuilder(ownerId, dto)
async search(ownerId: string, dto: MemorySearchDto) {
const items = await this.searchBuilder(ownerId, dto)
.select((eb) =>
jsonArrayFrom(
eb
@@ -89,8 +90,11 @@ export class MemoryRepository implements IBulkAsset {
? qb.orderBy(sql`RANDOM()`)
: qb.orderBy('memoryAt', (dto.order?.toLowerCase() || 'desc') as OrderByDirection),
)
.$if(dto.size !== undefined, (qb) => qb.limit(dto.size!))
.$if(dto.size !== undefined, (qb) => qb.limit(dto.size! + 1))
.$if(dto.page !== undefined && dto.size !== undefined, (qb) => qb.offset((dto.page! - 1) * dto.size!))
.execute();
return paginationHelper(items, dto.size ?? items.length);
}
@GenerateSql({ params: [DummyValue.UUID] })
+13 -6
View File
@@ -34,21 +34,28 @@ describe(MemoryService.name, () => {
const asset = AssetFactory.create();
const memory1 = MemoryFactory.from({ ownerId: userId }).asset(asset).build();
const memory2 = MemoryFactory.create({ ownerId: userId });
mocks.memory.search.mockResolvedValue([getForMemory(memory1), getForMemory(memory2)]);
mocks.memory.search.mockResolvedValue({
items: [getForMemory(memory1), getForMemory(memory2)],
hasNextPage: false,
});
mocks.memory.statistics.mockResolvedValue({ total: 2 });
await expect(sut.search(factory.auth({ user: { id: userId } }), {})).resolves.toEqual(
expect.arrayContaining([
await expect(sut.search(factory.auth({ user: { id: userId } }), {})).resolves.toMatchObject({
items: expect.arrayContaining([
expect.objectContaining({
id: memory1.id,
assets: expect.arrayContaining([expect.objectContaining({ id: asset.id })]),
}),
]),
);
hasNextPage: false,
total: 2,
});
});
it('should map empty result', async () => {
mocks.memory.search.mockResolvedValue([]);
await expect(sut.search(factory.auth(), {})).resolves.toEqual([]);
mocks.memory.search.mockResolvedValue({ items: [], hasNextPage: false });
mocks.memory.statistics.mockResolvedValue({ total: 0 });
await expect(sut.search(factory.auth(), {})).resolves.toMatchObject({ items: [], hasNextPage: false, total: 0 });
});
});
+8 -4
View File
@@ -71,10 +71,14 @@ export class MemoryService extends BaseService {
}
async search(auth: AuthDto, dto: MemorySearchDto) {
const memories = await this.memoryRepository.search(auth.user.id, dto);
return memories
.filter((memory: Memory) => memory.assets && memory.assets.length > 0)
.map((memory: Memory) => mapMemory(memory, auth));
const { items, hasNextPage } = await this.memoryRepository.search(auth.user.id, dto);
const { total } = await this.memoryRepository.statistics(auth.user.id, dto);
return {
total,
items: items.map((memory: Memory) => mapMemory(memory, auth)),
hasNextPage,
};
}
statistics(auth: AuthDto, dto: MemorySearchDto) {
+9 -5
View File
@@ -75,7 +75,7 @@ export class SearchService extends BaseService {
const page = dto.page ?? 1;
const size = dto.size || 250;
const userIds = await this.getUserIdsToSearch(auth);
const userIds = await this.getUserIdsToSearch(auth, dto.visibility);
const { hasNextPage, items } = await this.searchRepository.searchMetadata(
{ page, size },
{
@@ -103,7 +103,7 @@ export class SearchService extends BaseService {
requireElevatedPermission(auth);
}
const userIds = await this.getUserIdsToSearch(auth);
const userIds = await this.getUserIdsToSearch(auth, dto.visibility);
const items = await this.searchRepository.searchRandom(dto.size || 250, { ...dto, userIds });
return items.map((item) => mapAsset(item, { auth }));
}
@@ -113,7 +113,7 @@ export class SearchService extends BaseService {
requireElevatedPermission(auth);
}
const userIds = await this.getUserIdsToSearch(auth);
const userIds = await this.getUserIdsToSearch(auth, dto.visibility);
const items = await this.searchRepository.searchLargeAssets(dto.size || 250, { ...dto, userIds });
return items.map((item) => mapAsset(item, { auth }));
}
@@ -128,7 +128,7 @@ export class SearchService extends BaseService {
throw new BadRequestException('Smart search is not enabled');
}
const userIds = this.getUserIdsToSearch(auth);
const userIds = this.getUserIdsToSearch(auth, dto.visibility);
let embedding;
if (dto.query) {
const key = machineLearning.clip.modelName + dto.query + dto.language;
@@ -202,7 +202,11 @@ export class SearchService extends BaseService {
}
}
private async getUserIdsToSearch(auth: AuthDto): Promise<string[]> {
private async getUserIdsToSearch(auth: AuthDto, visibility?: AssetVisibility): Promise<string[]> {
// Locked assets are personal. Never include partner IDs, regardless of A's elevated session.
if (visibility === AssetVisibility.Locked) {
return [auth.user.id];
}
const partnerIds = await getMyPartnerIds({
userId: auth.user.id,
repository: this.partnerRepository,
@@ -204,5 +204,16 @@ describe(TimelineService.name, () => {
}),
).rejects.toThrow(BadRequestException);
});
it('should throw an error if withPartners is true and visibility is locked', async () => {
await expect(
sut.getTimeBucket(authStub.adminWithElevatedPermission, {
timeBucket: 'bucket',
visibility: AssetVisibility.Locked,
withPartners: true,
userId: authStub.adminWithElevatedPermission.user.id,
}),
).rejects.toThrow(BadRequestException);
});
});
});
+3 -2
View File
@@ -71,13 +71,14 @@ export class TimelineService extends BaseService {
}
if (dto.withPartners) {
const requestedLocked = dto.visibility === AssetVisibility.Locked;
const requestedArchived = dto.visibility === AssetVisibility.Archive || dto.visibility === undefined;
const requestedFavorite = dto.isFavorite === true || dto.isFavorite === false;
const requestedTrash = dto.isTrashed === true;
if (requestedArchived || requestedFavorite || requestedTrash) {
if (requestedLocked || requestedArchived || requestedFavorite || requestedTrash) {
throw new BadRequestException(
'withPartners is only supported for non-archived, non-trashed, non-favorited assets',
'withPartners is only supported for non-archived, non-trashed, non-favorited, non-locked assets',
);
}
}
+7
View File
@@ -41,6 +41,13 @@ export const authStub = {
id: 'token-id',
} as AuthSession,
}),
adminWithElevatedPermission: Object.freeze<AuthDto>({
user: authUser.admin,
session: {
id: 'token-id-elevated',
hasElevatedPermission: true,
} as AuthSession,
}),
adminSharedLink: Object.freeze({
user: authUser.admin,
sharedLink: {
@@ -133,8 +133,8 @@ describe(MemoryService.name, () => {
await sut.onMemoriesCreate();
const memories = await memoryRepo.search(user.id, {});
expect(memories.length).toBe(1);
expect(memories[0]).toEqual(
expect(memories.items.length).toBe(1);
expect(memories.items[0]).toEqual(
expect.objectContaining({
id: expect.any(String),
createdAt: expect.any(Date),
@@ -173,8 +173,8 @@ describe(MemoryService.name, () => {
await sut.onMemoriesCreate();
const memories = await memoryRepo.search(user.id, {});
expect(memories.length).toBe(1);
expect(memories[0]).toEqual(
expect(memories.items.length).toBe(1);
expect(memories.items[0]).toEqual(
expect.objectContaining({
id: expect.any(String),
createdAt: expect.any(Date),
@@ -228,12 +228,12 @@ describe(MemoryService.name, () => {
await sut.onMemoriesCreate();
const memories = await memoryRepo.search(user.id, {});
expect(memories.length).toBe(1);
expect(memories.items.length).toBe(1);
await sut.onMemoriesCreate();
const memoriesAfter = await memoryRepo.search(user.id, {});
expect(memoriesAfter.length).toBe(1);
expect(memoriesAfter.items.length).toBe(1);
});
});
@@ -51,13 +51,13 @@ describe(TimelineService.name, () => {
const response1 = sut.getTimeBuckets(auth, { withPartners: true, visibility: AssetVisibility.Archive });
await expect(response1).rejects.toBeInstanceOf(BadRequestException);
await expect(response1).rejects.toThrow(
'withPartners is only supported for non-archived, non-trashed, non-favorited assets',
'withPartners is only supported for non-archived, non-trashed, non-favorited, non-locked assets',
);
const response2 = sut.getTimeBuckets(auth, { withPartners: true });
await expect(response2).rejects.toBeInstanceOf(BadRequestException);
await expect(response2).rejects.toThrow(
'withPartners is only supported for non-archived, non-trashed, non-favorited assets',
'withPartners is only supported for non-archived, non-trashed, non-favorited, non-locked assets',
);
});
@@ -67,13 +67,13 @@ describe(TimelineService.name, () => {
const response1 = sut.getTimeBuckets(auth, { withPartners: true, isFavorite: false });
await expect(response1).rejects.toBeInstanceOf(BadRequestException);
await expect(response1).rejects.toThrow(
'withPartners is only supported for non-archived, non-trashed, non-favorited assets',
'withPartners is only supported for non-archived, non-trashed, non-favorited, non-locked assets',
);
const response2 = sut.getTimeBuckets(auth, { withPartners: true, isFavorite: true });
await expect(response2).rejects.toBeInstanceOf(BadRequestException);
await expect(response2).rejects.toThrow(
'withPartners is only supported for non-archived, non-trashed, non-favorited assets',
'withPartners is only supported for non-archived, non-trashed, non-favorited, non-locked assets',
);
});
@@ -83,7 +83,7 @@ describe(TimelineService.name, () => {
const response = sut.getTimeBuckets(auth, { withPartners: true, isTrashed: true });
await expect(response).rejects.toBeInstanceOf(BadRequestException);
await expect(response).rejects.toThrow(
'withPartners is only supported for non-archived, non-trashed, non-favorited assets',
'withPartners is only supported for non-archived, non-trashed, non-favorited, non-locked assets',
);
});
@@ -205,11 +205,7 @@
</script>
<div
class={[
'group flex overflow-hidden transition-[background-color,border-radius] focus-visible:outline-none',
backgroundColorClass,
{ 'rounded-xl': selected },
]}
class={['group flex overflow-hidden focus-visible:outline-none', backgroundColorClass, { 'rounded-xl': selected }]}
style:width="{width}px"
style:height="{height}px"
onmouseenter={onMouseEnter}
@@ -249,16 +245,8 @@
]}
>
<ImageThumbnail
class={[
'absolute transition-[border-radius] group-focus-visible:rounded-lg',
{ 'rounded-xl': selected },
imageClass,
]}
brokenAssetClass={[
'z-1 absolute group-focus-visible:rounded-lg transition-[border-radius]',
{ 'rounded-xl': selected },
brokenAssetClass,
]}
class={['absolute group-focus-visible:rounded-lg', { 'rounded-xl': selected }, imageClass]}
brokenAssetClass={['z-1 absolute group-focus-visible:rounded-lg', { 'rounded-xl': selected }, brokenAssetClass]}
url={getAssetMediaUrl({ id: asset.id, size: AssetMediaSize.Thumbnail, cacheKey: asset.thumbhash })}
altText={$getAltText(asset)}
widthStyle="{width}px"
@@ -30,14 +30,11 @@
const transitionDuration = $derived(manager.suspendTransitions && !$isUploading ? 0 : 150);
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
const firstInOrNearViewport = $derived(viewerAssets.findIndex((a) => a.isInOrNearViewport));
const lastInOrNearViewport = $derived(viewerAssets.findLastIndex((a) => a.isInOrNearViewport));
</script>
<!-- Image grid -->
<div data-image-grid class="relative overflow-clip" style:height={height + 'px'} style:width={width + 'px'}>
{#each viewerAssets.slice(firstInOrNearViewport, lastInOrNearViewport + 1) as viewerAsset (viewerAsset.id)}
{#each viewerAssets as viewerAsset (viewerAsset.id)}
{@const position = viewerAsset.position!}
{@const asset = viewerAsset.asset!}
+1 -1
View File
@@ -100,7 +100,7 @@
<AssetLayout
{manager}
viewerAssets={timelineDay.viewerAssets}
viewerAssets={timelineDay.activeViewerAssets}
height={timelineDay.height}
width={timelineDay.width}
{customThumbnailLayout}
+72 -8
View File
@@ -1,9 +1,16 @@
import { deleteMemory, type MemoryResponseDto, removeMemoryAssets, searchMemories, updateMemory } from '@immich/sdk';
import {
deleteMemory,
type MemoryResponseDto,
removeMemoryAssets,
searchMemories,
updateMemory,
MemorySearchOrder,
MemoryType,
} from '@immich/sdk';
import { DateTime } from 'luxon';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { asLocalTimeISO } from '$lib/utils/date-time';
import { toTimelineAsset } from '$lib/utils/timeline-util';
type MemoryIndex = {
@@ -20,10 +27,31 @@ export type MemoryAsset = MemoryIndex & {
nextMemory?: MemoryResponseDto;
};
const PAGE_SIZE = 250;
class MemoryManager {
#loading: Promise<void> | undefined;
#filters:
| {
$for?: string;
isSaved?: boolean;
isTrashed?: boolean;
order?: MemorySearchOrder;
page?: number;
size?: number;
$type?: MemoryType;
}
| undefined;
#hasNextPage: boolean;
#page: number;
#total: number;
constructor() {
this.#filters = undefined;
this.#hasNextPage = true;
this.#page = 1;
this.#total = 0;
eventManager.on({
AuthLogout: () => this.clearCache(),
AuthUserLoaded: () => this.initialize(),
@@ -37,6 +65,16 @@ class MemoryManager {
this.scheduleHourlyRefresh();
}
get filters() {
return this.#filters;
}
set filters(filters) {
this.#filters = filters;
this.clearCache();
void this.loadNextPage();
}
ready() {
return this.initialize();
}
@@ -117,22 +155,46 @@ class MemoryManager {
}
}
loadNextPage() {
if (this.#hasNextPage) {
if (this.#loading === undefined) {
this.#loading = this.load(this.#page++);
} else {
void this.#loading.then(() => (this.#loading = this.load(this.#page++)));
}
}
}
get hasNextPage() {
return this.#hasNextPage;
}
get total() {
return this.#total;
}
private clearCache() {
this.#loading = undefined;
this.#hasNextPage = true;
this.#page = 1;
this.memories = [];
}
private initialize() {
if (!this.#loading) {
this.#loading = this.load();
this.#loading = this.load(this.#page++);
}
return this.#loading;
}
private async load() {
const memories = await searchMemories({ $for: asLocalTimeISO(DateTime.now()) });
this.memories = memories.filter((memory) => memory.assets.length > 0);
private async load(page: number) {
if (this.#filters !== undefined) {
const { items, hasNextPage, total } = await searchMemories({ ...this.#filters, page, size: PAGE_SIZE });
this.memories.push(...items);
this.#hasNextPage = hasNextPage;
this.#total = total;
}
}
private scheduleHourlyRefresh() {
@@ -146,12 +208,14 @@ class MemoryManager {
const initialDelay = nextEvent.diff(now).as('milliseconds');
setTimeout(() => {
this.#loading = this.load();
this.clearCache();
this.#loading = this.load(0);
// Schedule subsequent events hourly
setInterval(
() => {
this.#loading = this.load();
this.clearCache();
this.#loading = this.load(0);
},
60 * 60 * 1000,
);
@@ -53,17 +53,3 @@ export function updateTimelineMonthViewportProximity(timelineManager: TimelineMa
timelineManager.clearDeferredLayout(month);
}
}
export function calculateViewerAssetViewportProximity(
timelineManager: TimelineManager,
positionTop: number,
positionHeight: number,
) {
const headerHeight = timelineManager.headerHeight;
return calculateViewportProximity(
positionTop,
positionTop + positionHeight,
timelineManager.visibleWindow.top - headerHeight,
timelineManager.visibleWindow.bottom + headerHeight,
);
}
@@ -1,12 +1,31 @@
import { AssetOrder, AssetOrderBy } from '@immich/sdk';
import { SvelteSet } from 'svelte/reactivity';
import type { CommonLayoutOptions } from '$lib/utils/layout-utils';
import type { CommonLayoutOptions, CommonPosition } from '$lib/utils/layout-utils';
import { getJustifiedLayoutFromAssets } from '$lib/utils/layout-utils';
import { getOrderingDate, plainDateTimeCompare } from '$lib/utils/timeline-util';
import { TUNABLES } from '$lib/utils/tunables';
import type { TimelineMonth } from './timeline-month.svelte';
import type { Direction, MoveAsset, TimelineAsset } from './types';
import { ViewerAsset } from './viewer-asset.svelte';
const {
TIMELINE: { INTERSECTION_EXPAND_TOP, INTERSECTION_EXPAND_BOTTOM },
} = TUNABLES;
function lowerBound(assets: ViewerAsset[], target: number, key: (pos: CommonPosition) => number): number {
let lo = 0;
let hi = assets.length;
while (lo < hi) {
const mid = Math.floor((lo + hi) / 2);
if (key(assets[mid].position!) < target) {
lo = mid + 1;
} else {
hi = mid;
}
}
return lo;
}
export class TimelineDay {
readonly timelineMonth: TimelineMonth;
readonly index: number;
@@ -18,12 +37,15 @@ export class TimelineDay {
height = $state(0);
width = $state(0);
// Assets in or near the viewport; active assets should be added to the DOM.
activeViewerAssets: ViewerAsset[] = $state([]);
isInOrNearViewport = $state(false);
#top: number = $state(0);
#start: number = $state(0);
#row = $state(0);
#col = $state(0);
#deferredLayout = false;
#lastInOrNearViewport = -1;
constructor(timelineMonth: TimelineMonth, index: number, day: number, groupTitle: string, orderBy: AssetOrderBy) {
this.index = index;
@@ -149,18 +171,32 @@ export class TimelineDay {
for (let i = 0; i < this.viewerAssets.length; i++) {
this.viewerAssets[i].position = geometry.getPosition(i);
}
this.updateAssetBoundaries();
}
updateAssetBoundaries() {
const manager = this.timelineMonth.timelineManager;
const visibleWindow = manager.visibleWindow;
if (this.viewerAssets.length === 0 || !this.viewerAssets[0].position) {
this.activeViewerAssets = [];
this.isInOrNearViewport = false;
return;
}
const dayOffset = this.absoluteTimelineDayTop;
const headerHeight = manager.headerHeight;
const expandedTop = visibleWindow.top - headerHeight - INTERSECTION_EXPAND_TOP - dayOffset;
const expandedBottom = visibleWindow.bottom + headerHeight + INTERSECTION_EXPAND_BOTTOM - dayOffset;
const first = lowerBound(this.viewerAssets, expandedTop, (p) => p.top + p.height);
const last = lowerBound(this.viewerAssets, expandedBottom, (p) => p.top) - 1;
const hasActive = last >= first && first < this.viewerAssets.length;
this.activeViewerAssets = hasActive ? this.viewerAssets.slice(first, last + 1) : [];
this.isInOrNearViewport = hasActive;
}
get absoluteTimelineDayTop() {
return this.timelineMonth.top + this.#top;
}
get isInOrNearViewport() {
if (this.#lastInOrNearViewport !== -1 && this.viewerAssets[this.#lastInOrNearViewport].isInOrNearViewport) {
return true;
}
this.#lastInOrNearViewport = this.viewerAssets.findIndex((viewAsset) => viewAsset.isInOrNearViewport);
return this.#lastInOrNearViewport !== -1;
}
}
@@ -214,6 +214,11 @@ export class TimelineManager extends VirtualScrollManager {
for (const month of this.months) {
updateTimelineMonthViewportProximity(this, month);
if (month.isInOrNearViewport && month.isLoaded) {
for (const day of month.timelineDays) {
day.updateAssetBoundaries();
}
}
}
const month = this.months.find((month) => month.isInViewport);
@@ -254,7 +254,7 @@ export class TimelineMonth {
addContext.newTimelineDays.add(timelineDay);
}
const viewerAsset = new ViewerAsset(timelineDay, timelineAsset);
const viewerAsset = new ViewerAsset(timelineAsset);
timelineDay.viewerAssets.push(viewerAsset);
addContext.changedTimelineDays.add(timelineDay);
}
@@ -1,36 +1,12 @@
import type { CommonPosition } from '$lib/utils/layout-utils';
import {
ViewportProximity,
calculateViewerAssetViewportProximity,
isInOrNearViewport,
} from './internal/intersection-support.svelte';
import type { TimelineDay } from './timeline-day.svelte';
import type { TimelineAsset } from './types';
export class ViewerAsset {
readonly #group: TimelineDay;
#viewportProximity = $derived.by(() => {
if (!this.position) {
return ViewportProximity.FarFromViewport;
}
const store = this.#group.timelineMonth.timelineManager;
const positionTop = this.#group.absoluteTimelineDayTop + this.position.top;
return calculateViewerAssetViewportProximity(store, positionTop, this.position.height);
});
get isInOrNearViewport() {
return isInOrNearViewport(this.#viewportProximity);
}
position: CommonPosition | undefined = $state.raw();
asset: TimelineAsset = $state() as TimelineAsset;
id: string = $derived(this.asset.id);
constructor(group: TimelineDay, asset: TimelineAsset) {
this.#group = group;
constructor(asset: TimelineAsset) {
this.asset = asset;
}
}
+8 -3
View File
@@ -20,12 +20,13 @@ import {
type UserResponseDto,
} from '@immich/sdk';
import { toastManager, type ActionItem, type IfLike } from '@immich/ui';
import { DateTime } from 'luxon';
import { init, register, t } from 'svelte-i18n';
import { derived, get } from 'svelte/store';
import { defaultLang, locales } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { downloadManager } from '$lib/managers/download-manager.svelte';
import { alwaysLoadOriginalFile, lang } from '$lib/stores/preferences.store';
import { alwaysLoadOriginalFile, lang, locale } from '$lib/stores/preferences.store';
import { isWebCompatibleImage } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { langs } from '$lib/utils/i18n';
@@ -358,9 +359,13 @@ export const handlePromiseError = <T>(promise: Promise<T>): void => {
export const memoryLaneTitle = derived(t, ($t) => {
return (memory: MemoryResponseDto) => {
const now = new Date();
if (memory.type === MemoryType.OnThisDay) {
return $t('years_ago', { values: { years: now.getFullYear() - memory.data.year } });
const now = new Date();
const memoryDate = new Date(memory.memoryAt);
return memoryDate.getUTCDate() === now.getDate() && memoryDate.getUTCMonth() === now.getMonth()
? $t('years_ago', { values: { years: now.getFullYear() - memory.data.year } })
: DateTime.fromJSDate(memoryDate).toLocaleString(DateTime.DATE_MED, { locale: get(locale) });
}
return $t('unknown');
+29 -6
View File
@@ -6,10 +6,10 @@
import SingleGridRow from '$lib/components/shared-components/SingleGridRow.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { Route } from '$lib/route';
import { getAssetMediaUrl, getPeopleThumbnailUrl } from '$lib/utils';
import { getAssetMediaUrl, getPeopleThumbnailUrl, memoryLaneTitle } from '$lib/utils';
import { getAssetInfo, AssetMediaSize, type SearchExploreResponseDto } from '@immich/sdk';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { Icon } from '@immich/ui';
import { Icon, ImageCarousel } from '@immich/ui';
import { mdiHeart } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
@@ -28,13 +28,22 @@
return targetField?.items || [];
};
let places = $derived(getFieldItems(data.items, 'exifInfo.city'));
let places = $derived(getFieldItems(data.explore, 'exifInfo.city'));
let recents = $derived(
getFieldItems(data.items, 'createdAt').sort((a, b) => new Date(b.value).getTime() - new Date(a.value).getTime()),
getFieldItems(data.explore, 'createdAt').sort((a, b) => new Date(b.value).getTime() - new Date(a.value).getTime()),
);
let people = $state(data.people.people);
let memories = $derived(
data.memories.map((memory) => ({
id: memory.id,
title: $memoryLaneTitle(memory),
href: Route.memories({ id: memory.assets[0].id }),
alt: $t('memory_lane_title', { values: { title: $getAltText(toTimelineAsset(memory.assets[0])) } }),
src: getAssetMediaUrl({ id: memory.assets[0].id }),
})),
);
let people = $state(data.response.people);
let hasPeople = $derived(data.response.total > 0);
let hasPeople = $derived(data.people.total > 0);
const onPersonThumbnailReady = ({ id }: { id: string }) => {
for (const person of people) {
@@ -124,6 +133,20 @@
</div>
{/if}
{#if memories.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('memories')}</p>
<a
href={Route.memories()}
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>
<ImageCarousel items={memories} />
</div>
{/if}
{#if recents.length > 0}
<div class="mt-2 mb-6">
<div class="flex justify-between">
+12 -4
View File
@@ -1,16 +1,24 @@
import { getAllPeople, getExploreData } from '@immich/sdk';
import { getAllPeople, getExploreData, MemorySearchOrder } from '@immich/sdk';
import { memoryManager } from '$lib/managers/memory-manager.svelte';
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 [items, response] = await Promise.all([getExploreData(), getAllPeople({ withHidden: false })]);
memoryManager.filters = { size: 12, order: MemorySearchOrder.Desc };
const [explore, people] = await Promise.all([
getExploreData(),
getAllPeople({ withHidden: false }),
memoryManager.ready(),
]);
const $t = await getFormatter();
return {
items,
response,
explore,
people,
memories: memoryManager.memories,
meta: {
title: $t('explore'),
},
@@ -1,5 +1,104 @@
<script>
<script lang="ts">
import UserPageLayout from '$lib/components/layouts/UserPageLayout.svelte';
import type { PageData } from './$types';
import { Route } from '$lib/route';
import { getAssetMediaUrl, memoryLaneTitle } from '$lib/utils';
import { t } from 'svelte-i18n';
import { mdiHeartOutline, mdiHeart } from '@mdi/js';
import { Button, Icon } from '@immich/ui';
import { locale } from '$lib/stores/preferences.store';
import { getAltText } from '$lib/utils/thumbnail-util';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { page } from '$app/state';
import MemoryViewer from './MemoryViewer.svelte';
import { QueryParameter } from '$lib/constants';
import { memoryManager } from '$lib/managers/memory-manager.svelte';
import { clearQueryParam, setQueryValue } from '$lib/utils/navigation';
interface Props {
data: PageData;
}
let { data }: Props = $props();
let onlyFavorites = $state(page.url.searchParams.get('favorites') === 'true');
let lastElement: HTMLElement | undefined = $state();
const toggleFavorites = async () => {
onlyFavorites = !onlyFavorites;
memoryManager.filters = onlyFavorites ? { isSaved: true } : {};
await memoryManager.ready();
if (onlyFavorites) {
void setQueryValue('favorites', 'true');
} else {
void clearQueryParam('favorites', page.url);
}
};
const intersectionObserver = new IntersectionObserver((entries) => {
const entry = entries.find((entry) => entry.target === lastElement);
if (entry?.isIntersecting && memoryManager.hasNextPage) {
void memoryManager.loadNextPage();
}
});
$effect(() => {
if (lastElement) {
intersectionObserver.disconnect();
intersectionObserver.observe(lastElement);
}
});
</script>
<MemoryViewer />
{#if page.url.searchParams.has(QueryParameter.ID)}
<MemoryViewer />
{:else}
<UserPageLayout title={data.meta.title} description={`(${memoryManager.total.toLocaleString($locale)})`}>
{#snippet buttons()}
<div class="flex place-items-center gap-2">
<Button
leadingIcon={mdiHeartOutline}
size="small"
variant={onlyFavorites ? 'filled' : 'ghost'}
color="secondary"
onclick={() => toggleFavorites()}>{$t('only_favorites')}</Button
>
</div>
{/snippet}
{#if memoryManager.memories.length > 0}
<div class="grid w-full grid-cols-2 gap-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 2xl:grid-cols-7">
{#each memoryManager.memories as memory, index (memory.id)}
<a
href={Route.memories({ id: memory.assets[0].id })}
class="item-card relative inline-block aspect-video"
bind:this={
() => (index === memoryManager.memories.length - 1 ? lastElement : null),
(e) => {
if (index === memoryManager.memories.length - 1) {
lastElement = e;
}
}
}
>
{#if memory.isSaved}
<div class="absolute inset-s-2 top-2 z-2">
<Icon data-icon-favorite icon={mdiHeart} size="24" class="text-white" />
</div>
{/if}
<img
src={getAssetMediaUrl({ id: memory.assets[0].id })}
alt={$getAltText(toTimelineAsset(memory.assets[0]))}
class="size-full object-cover brightness-75"
loading="lazy"
/>
<span
class="absolute bottom-2 w-full px-1 text-center text-sm font-medium text-ellipsis text-white capitalize backdrop-blur-[1px] hover:cursor-pointer"
>
{$memoryLaneTitle(memory)}
</span>
</a>
{/each}
</div>
{:else}{/if}
</UserPageLayout>
{/if}
@@ -1,3 +1,6 @@
import { isEqual } from 'lodash-es';
import { QueryParameter } from '$lib/constants';
import { memoryManager } from '$lib/managers/memory-manager.svelte';
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import type { PageLoad } from './$types';
@@ -6,10 +9,19 @@ export const load = (async ({ url }) => {
const user = await authenticate(url);
const $t = await getFormatter();
const filters = url.searchParams.get('favorites') === 'true' ? { isSaved: true } : {};
if (
!(url.searchParams.has(QueryParameter.ID) && memoryManager.memories.length > 0) &&
!isEqual(memoryManager.filters, filters)
) {
memoryManager.filters = filters;
await memoryManager.ready();
}
return {
user,
meta: {
title: $t('memory'),
title: $t('memories'),
},
};
}) satisfies PageLoad;
@@ -82,6 +82,7 @@
let progressBarController: Tween<number> | undefined = $state(undefined);
let videoPlayer: HTMLVideoElement | undefined = $state();
const asHref = (asset: { id: string }) => `?${QueryParameter.ID}=${asset.id}`;
let previousPage = $state(Route.memories());
const handleNavigate = async (asset?: { id: string }) => {
if (assetViewerManager.isViewing) {
@@ -106,7 +107,7 @@
const handlePreviousAsset = () => handleNavigate(current?.previous?.asset);
const handleNextMemory = () => handleNavigate(current?.nextMemory?.assets[0]);
const handlePreviousMemory = () => handleNavigate(current?.previousMemory?.assets[0]);
const handleEscape = async () => goto(Route.photos());
const handleEscape = async () => goto(previousPage);
const handleSelectAll = () =>
assetMultiSelectManager.selectAssets(current?.memory.assets.map((a) => toTimelineAsset(a)) || []);
@@ -249,7 +250,7 @@
const init = (target: Page | NavigationTarget | null) => {
if (memoryManager.memories.length === 0) {
return handlePromiseError(goto(Route.photos()));
return handlePromiseError(goto(previousPage));
}
current = loadFromParams(target);
@@ -281,6 +282,10 @@
};
afterNavigate(({ from, to }) => {
if (from?.url !== null && !from?.url.searchParams.has(QueryParameter.ID)) {
previousPage = from!.url.toString();
}
memoryManager.ready().then(
() => {
let target;
@@ -381,7 +386,7 @@
icon={mdiClose}
aria-label={$t('close')}
size="large"
onclick={() => goto(Route.photos())}
onclick={() => goto(previousPage)}
/>
<p class="text-lg">
{$memoryLaneTitle(current.memory)}
@@ -33,12 +33,14 @@
type OnLink,
type OnUnlink,
} from '$lib/utils/actions';
import { asLocalTimeISO } from '$lib/utils/date-time';
import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { getAltText } from '$lib/utils/thumbnail-util';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { AssetVisibility } from '@immich/sdk';
import { ActionButton, CommandPaletteDefaultProvider, ImageCarousel } from '@immich/ui';
import { mdiDotsVertical } from '@mdi/js';
import { DateTime } from 'luxon';
import { t } from 'svelte-i18n';
let timelineManager = $state<TimelineManager>() as TimelineManager;
@@ -81,6 +83,10 @@
assetMultiSelectManager.clear();
};
if (memoryManager.filters === undefined || memoryManager.filters.$for !== asLocalTimeISO(DateTime.now())) {
memoryManager.filters = { $for: asLocalTimeISO(DateTime.now()) };
}
const items = $derived(
memoryManager.memories.map((memory) => ({
id: memory.id,