Compare commits

..

2 Commits

Author SHA1 Message Date
Jonathan Jogenfors 8888166928 fix comments 2026-02-27 14:00:27 +01:00
Jonathan Jogenfors 6982987f3f feat: add offline library statistics 2026-02-21 00:00:49 +01:00
43 changed files with 463 additions and 877 deletions
+1 -1
View File
@@ -511,7 +511,7 @@ jobs:
run: pnpm install --frozen-lockfile
if: ${{ !cancelled() }}
- name: Install Playwright Browsers
run: pnpm exec playwright install chromium --only-shell
run: npx playwright install chromium --only-shell
if: ${{ !cancelled() }}
- name: Docker build
run: docker compose up -d --build --renew-anon-volumes --force-recreate --remove-orphans --wait --wait-timeout 300
+1 -1
View File
@@ -52,7 +52,7 @@ attach-server:
docker exec -it docker_immich-server_1 sh
renovate:
LOG_LEVEL=debug pnpm exec renovate --platform=local --repository-cache=reset
LOG_LEVEL=debug npx renovate --platform=local --repository-cache=reset
# Directories that need to be created for volumes or build output
VOLUME_DIRS = \
+2 -2
View File
@@ -45,8 +45,8 @@
"build": "vite build",
"build:dev": "vite build --sourcemap true",
"lint": "eslint \"src/**/*.ts\" --max-warnings 0",
"lint:fix": "pnpm run lint --fix",
"prepack": "pnpm run build",
"lint:fix": "npm run lint -- --fix",
"prepack": "npm run build",
"test": "vitest",
"test:cov": "vitest --coverage",
"format": "prettier --check .",
+1 -1
View File
@@ -8,7 +8,7 @@
"format:fix": "prettier --write .",
"start": "docusaurus start --port 3005",
"copy:openapi": "jq -c < ../open-api/immich-openapi-specs.json > ./static/openapi.json || exit 0",
"build": "pnpm run copy:openapi && docusaurus build",
"build": "npm run copy:openapi && docusaurus build",
"swizzle": "docusaurus swizzle",
"deploy": "docusaurus deploy",
"clear": "docusaurus clear",
+7 -7
View File
@@ -8,16 +8,16 @@
"test": "vitest --run",
"test:watch": "vitest",
"test:maintenance": "vitest --run --config vitest.maintenance.config.ts",
"test:web": "pnpm exec playwright test --project=web",
"test:web:maintenance": "pnpm exec playwright test --project=maintenance",
"test:web:ui": "pnpm exec playwright test --project=ui",
"start:web": "pnpm exec playwright test --ui --project=web",
"start:web:maintenance": "pnpm exec playwright test --ui --project=maintenance",
"start:web:ui": "pnpm exec playwright test --ui --project=ui",
"test:web": "npx playwright test --project=web",
"test:web:maintenance": "npx playwright test --project=maintenance",
"test:web:ui": "npx playwright test --project=ui",
"start:web": "npx playwright test --ui --project=web",
"start:web:maintenance": "npx playwright test --ui --project=maintenance",
"start:web:ui": "npx playwright test --ui --project=ui",
"format": "prettier --check .",
"format:fix": "prettier --write .",
"lint": "eslint \"src/**/*.ts\" --max-warnings 0",
"lint:fix": "pnpm run lint --fix",
"lint:fix": "npm run lint -- --fix",
"check": "tsc --noEmit"
},
"keywords": [],
-4
View File
@@ -689,7 +689,6 @@
"backup_settings_subtitle": "Manage upload settings",
"backup_upload_details_page_more_details": "Tap for more details",
"backward": "Backward",
"best_match": "Best match",
"biometric_auth_enabled": "Biometric authentication enabled",
"biometric_locked_out": "You are locked out of biometric authentication",
"biometric_no_options": "No biometric options available",
@@ -1946,8 +1945,6 @@
"search_filter_media_type_title": "Select media type",
"search_filter_ocr": "Search by OCR",
"search_filter_people_title": "Select people",
"search_filter_sort_order": "Sort Order",
"search_filter_sort_order_title": "Select sort order",
"search_filter_star_rating": "Star Rating",
"search_filter_tags_title": "Select tags",
"search_for": "Search for",
@@ -2163,7 +2160,6 @@
"sort_modified": "Date modified",
"sort_newest": "Newest photo",
"sort_oldest": "Oldest photo",
"sort_order": "Sort order",
"sort_people_by_similarity": "Sort people by similarity",
"sort_recent": "Most recent photo",
"sort_title": "Title",
@@ -48,6 +48,7 @@ fun Bitmap.toNativeBuffer(): Map<String, Long> {
try {
val buffer = NativeBuffer.wrap(pointer, size)
copyPixelsToBuffer(buffer)
recycle()
return mapOf(
"pointer" to pointer,
"width" to width.toLong(),
@@ -56,9 +57,8 @@ fun Bitmap.toNativeBuffer(): Map<String, Long> {
)
} catch (e: Exception) {
NativeBuffer.free(pointer)
throw e
} finally {
recycle()
throw e
}
}
@@ -37,7 +37,6 @@ class SearchApiRepository extends ApiRepository {
personIds: filter.people.map((e) => e.id).toList(),
tagIds: filter.tagIds,
type: type,
order: filter.order,
page: page,
size: 100,
),
@@ -63,7 +62,6 @@ class SearchApiRepository extends ApiRepository {
personIds: filter.people.map((e) => e.id).toList(),
tagIds: filter.tagIds,
type: type,
order: filter.order ?? AssetOrder.desc,
page: page,
size: 1000,
),
@@ -3,7 +3,6 @@ import 'dart:convert';
import 'package:immich_mobile/domain/models/person.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:openapi/api.dart' show AssetOrder;
class SearchLocationFilter {
String? country;
@@ -222,7 +221,6 @@ class SearchFilter {
SearchDateFilter date;
SearchRatingFilter rating;
SearchDisplayFilters display;
AssetOrder? order;
// Enum
AssetType mediaType;
@@ -235,7 +233,6 @@ class SearchFilter {
this.language,
this.assetId,
this.tagIds,
this.order,
required this.people,
required this.location,
required this.camera,
@@ -282,7 +279,6 @@ class SearchFilter {
SearchDisplayFilters? display,
SearchRatingFilter? rating,
AssetType? mediaType,
AssetOrder? Function()? order,
}) {
return SearchFilter(
context: context ?? this.context,
@@ -299,13 +295,12 @@ class SearchFilter {
rating: rating ?? this.rating,
mediaType: mediaType ?? this.mediaType,
tagIds: tagIds ?? this.tagIds,
order: order != null ? order() : this.order,
);
}
@override
String toString() {
return 'SearchFilter(context: $context, filename: $filename, description: $description, language: $language, ocr: $ocr, people: $people, location: $location, tagIds: $tagIds, camera: $camera, date: $date, display: $display, rating: $rating, mediaType: $mediaType, assetId: $assetId, order: $order)';
return 'SearchFilter(context: $context, filename: $filename, description: $description, language: $language, ocr: $ocr, people: $people, location: $location, tagIds: $tagIds, camera: $camera, date: $date, display: $display, rating: $rating, mediaType: $mediaType, assetId: $assetId)';
}
@override
@@ -325,8 +320,7 @@ class SearchFilter {
other.date == date &&
other.display == display &&
other.rating == rating &&
other.mediaType == mediaType &&
other.order == order;
other.mediaType == mediaType;
}
@override
@@ -344,7 +338,6 @@ class SearchFilter {
date.hashCode ^
display.hashCode ^
rating.hashCode ^
mediaType.hashCode ^
order.hashCode;
mediaType.hashCode;
}
}
+3 -56
View File
@@ -11,7 +11,6 @@ import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:immich_mobile/providers/search/paginated_search.provider.dart';
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/providers/search/search_input_focus.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.dart';
@@ -24,8 +23,6 @@ import 'package:immich_mobile/widgets/search/search_filter/media_type_picker.dar
import 'package:immich_mobile/widgets/search/search_filter/people_picker.dart';
import 'package:immich_mobile/widgets/search/search_filter/search_filter_chip.dart';
import 'package:immich_mobile/widgets/search/search_filter/search_filter_utils.dart';
import 'package:immich_mobile/widgets/search/search_filter/sort_order_picker.dart';
import 'package:openapi/api.dart' show AssetOrder;
@RoutePage()
class SearchPage extends HookConsumerWidget {
@@ -59,7 +56,6 @@ class SearchPage extends HookConsumerWidget {
final locationCurrentFilterWidget = useState<Widget?>(null);
final mediaTypeCurrentFilterWidget = useState<Widget?>(null);
final displayOptionCurrentFilterWidget = useState<Widget?>(null);
final sortOrderCurrentFilterWidget = useState<Widget?>(null);
final isSearching = useState(false);
@@ -82,8 +78,6 @@ class SearchPage extends HookConsumerWidget {
}
isSearching.value = true;
ref.read(searchGroupByProvider.notifier).state =
filter.value.order != null ? GroupAssetsBy.day : GroupAssetsBy.none;
ref.watch(paginatedSearchProvider.notifier).clear();
final hasResult = await ref.watch(paginatedSearchProvider.notifier).search(filter.value);
@@ -393,37 +387,6 @@ class SearchPage extends HookConsumerWidget {
);
}
// SORT ORDER
showSortOrderPicker() {
handleOnSelect(AssetOrder? value) {
filter.value = filter.value.copyWith(order: () => value);
if (value == null) {
sortOrderCurrentFilterWidget.value = null;
} else if (value == AssetOrder.desc) {
sortOrderCurrentFilterWidget.value = Text('newest_first'.tr(), style: context.textTheme.labelLarge);
} else {
sortOrderCurrentFilterWidget.value = Text('oldest_first'.tr(), style: context.textTheme.labelLarge);
}
}
handleClear() {
filter.value = filter.value.copyWith(order: () => null);
sortOrderCurrentFilterWidget.value = null;
search();
}
showFilterBottomSheet(
context: context,
child: FilterBottomSheetScaffold(
title: 'search_filter_sort_order_title'.tr(),
onSearch: search,
onClear: handleClear,
child: SortOrderPicker(onSelect: handleOnSelect, order: filter.value.order),
),
);
}
handleTextSubmitted(String value) {
switch (textSearchType.value) {
case TextSearchType.context:
@@ -631,12 +594,6 @@ class SearchPage extends HookConsumerWidget {
label: 'search_filter_display_options'.tr(),
currentFilter: displayOptionCurrentFilterWidget.value,
),
SearchFilterChip(
icon: Icons.sort_outlined,
onTap: showSortOrderPicker,
label: 'search_filter_sort_order'.tr(),
currentFilter: sortOrderCurrentFilterWidget.value,
),
],
),
),
@@ -644,11 +601,7 @@ class SearchPage extends HookConsumerWidget {
if (isSearching.value)
const Expanded(child: Center(child: CircularProgressIndicator()))
else
SearchResultGrid(
onScrollEnd: loadMoreSearchResult,
isSearching: isSearching.value,
dragScrollLabelEnabled: filter.value.order != null,
),
SearchResultGrid(onScrollEnd: loadMoreSearchResult, isSearching: isSearching.value),
],
),
);
@@ -658,14 +611,8 @@ class SearchPage extends HookConsumerWidget {
class SearchResultGrid extends StatelessWidget {
final VoidCallback onScrollEnd;
final bool isSearching;
final bool dragScrollLabelEnabled;
const SearchResultGrid({
super.key,
required this.onScrollEnd,
this.isSearching = false,
this.dragScrollLabelEnabled = false,
});
const SearchResultGrid({super.key, required this.onScrollEnd, this.isSearching = false});
@override
Widget build(BuildContext context) {
@@ -693,7 +640,7 @@ class SearchResultGrid extends StatelessWidget {
editEnabled: true,
favoriteEnabled: true,
stackEnabled: false,
dragScrollLabelEnabled: dragScrollLabelEnabled,
dragScrollLabelEnabled: false,
emptyIndicator: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: !isSearching ? const SearchEmptyContent() : const SizedBox.shrink(),
@@ -8,8 +8,6 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'paginated_search.provider.g.dart';
final searchGroupByProvider = StateProvider<GroupAssetsBy>((ref) => GroupAssetsBy.none);
final paginatedSearchProvider = StateNotifierProvider<PaginatedSearchNotifier, SearchResult>(
(ref) => PaginatedSearchNotifier(ref.watch(searchServiceProvider)),
);
@@ -43,7 +41,6 @@ class PaginatedSearchNotifier extends StateNotifier<SearchResult> {
@riverpod
Future<RenderList> paginatedSearchRenderList(Ref ref) {
final result = ref.watch(paginatedSearchProvider);
final groupBy = ref.watch(searchGroupByProvider);
final timelineService = ref.watch(timelineServiceProvider);
return timelineService.getTimelineFromAssets(result.assets, groupBy);
return timelineService.getTimelineFromAssets(result.assets, GroupAssetsBy.none);
}
@@ -7,7 +7,7 @@ part of 'paginated_search.provider.dart';
// **************************************************************************
String _$paginatedSearchRenderListHash() =>
r'bb1ea9153b2a186778420426f1fb1add6d6a9140';
r'22d715ff7864e5a946be38322ce7813616f899c2';
/// See also [paginatedSearchRenderList].
@ProviderFor(paginatedSearchRenderList)
@@ -1,42 +0,0 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:openapi/api.dart' show AssetOrder;
enum _SortOption { bestMatch, newest, oldest }
class SortOrderPicker extends HookWidget {
const SortOrderPicker({super.key, required this.onSelect, this.order});
final Function(AssetOrder?) onSelect;
final AssetOrder? order;
@override
Widget build(BuildContext context) {
final selected = useState<_SortOption>(switch (order) {
AssetOrder.desc => _SortOption.newest,
AssetOrder.asc => _SortOption.oldest,
_ => _SortOption.bestMatch,
});
return RadioGroup<_SortOption>(
onChanged: (value) {
if (value == null) return;
selected.value = value;
onSelect(switch (value) {
_SortOption.bestMatch => null,
_SortOption.newest => AssetOrder.desc,
_SortOption.oldest => AssetOrder.asc,
});
},
groupValue: selected.value,
child: Column(
children: [
RadioListTile(title: const Text('best_match').tr(), value: _SortOption.bestMatch),
RadioListTile(title: const Text('newest_first').tr(), value: _SortOption.newest),
RadioListTile(title: const Text('oldest_first').tr(), value: _SortOption.oldest),
],
),
);
}
}
+10 -1
View File
@@ -13,12 +13,16 @@ part of openapi.api;
class LibraryStatsResponseDto {
/// Returns a new [LibraryStatsResponseDto] instance.
LibraryStatsResponseDto({
this.offline = 0,
this.photos = 0,
this.total = 0,
this.usage = 0,
this.videos = 0,
});
/// Number of offline assets
int offline;
/// Number of photos
int photos;
@@ -33,6 +37,7 @@ class LibraryStatsResponseDto {
@override
bool operator ==(Object other) => identical(this, other) || other is LibraryStatsResponseDto &&
other.offline == offline &&
other.photos == photos &&
other.total == total &&
other.usage == usage &&
@@ -41,16 +46,18 @@ class LibraryStatsResponseDto {
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(offline.hashCode) +
(photos.hashCode) +
(total.hashCode) +
(usage.hashCode) +
(videos.hashCode);
@override
String toString() => 'LibraryStatsResponseDto[photos=$photos, total=$total, usage=$usage, videos=$videos]';
String toString() => 'LibraryStatsResponseDto[offline=$offline, photos=$photos, total=$total, usage=$usage, videos=$videos]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'offline'] = this.offline;
json[r'photos'] = this.photos;
json[r'total'] = this.total;
json[r'usage'] = this.usage;
@@ -67,6 +74,7 @@ class LibraryStatsResponseDto {
final json = value.cast<String, dynamic>();
return LibraryStatsResponseDto(
offline: mapValueOfType<int>(json, r'offline')!,
photos: mapValueOfType<int>(json, r'photos')!,
total: mapValueOfType<int>(json, r'total')!,
usage: mapValueOfType<int>(json, r'usage')!,
@@ -118,6 +126,7 @@ class LibraryStatsResponseDto {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'offline',
'photos',
'total',
'usage',
+1 -19
View File
@@ -30,7 +30,6 @@ class SmartSearchDto {
this.make,
this.model,
this.ocr,
this.order,
this.page,
this.personIds = const [],
this.query,
@@ -168,15 +167,6 @@ class SmartSearchDto {
///
String? ocr;
/// Sort order by date. If not provided, results are sorted by relevance.
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
AssetOrder? order;
/// Page number
///
/// Minimum value: 1
@@ -348,7 +338,6 @@ class SmartSearchDto {
other.make == make &&
other.model == model &&
other.ocr == ocr &&
other.order == order &&
other.page == page &&
_deepEquality.equals(other.personIds, personIds) &&
other.query == query &&
@@ -388,7 +377,6 @@ class SmartSearchDto {
(make == null ? 0 : make!.hashCode) +
(model == null ? 0 : model!.hashCode) +
(ocr == null ? 0 : ocr!.hashCode) +
(order == null ? 0 : order!.hashCode) +
(page == null ? 0 : page!.hashCode) +
(personIds.hashCode) +
(query == null ? 0 : query!.hashCode) +
@@ -409,7 +397,7 @@ class SmartSearchDto {
(withExif == null ? 0 : withExif!.hashCode);
@override
String toString() => 'SmartSearchDto[albumIds=$albumIds, city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, language=$language, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, ocr=$ocr, order=$order, page=$page, personIds=$personIds, query=$query, queryAssetId=$queryAssetId, rating=$rating, size=$size, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, visibility=$visibility, withDeleted=$withDeleted, withExif=$withExif]';
String toString() => 'SmartSearchDto[albumIds=$albumIds, city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, language=$language, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, ocr=$ocr, page=$page, personIds=$personIds, query=$query, queryAssetId=$queryAssetId, rating=$rating, size=$size, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, visibility=$visibility, withDeleted=$withDeleted, withExif=$withExif]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -494,11 +482,6 @@ class SmartSearchDto {
} else {
// json[r'ocr'] = null;
}
if (this.order != null) {
json[r'order'] = this.order;
} else {
// json[r'order'] = null;
}
if (this.page != null) {
json[r'page'] = this.page;
} else {
@@ -616,7 +599,6 @@ class SmartSearchDto {
make: mapValueOfType<String>(json, r'make'),
model: mapValueOfType<String>(json, r'model'),
ocr: mapValueOfType<String>(json, r'ocr'),
order: AssetOrder.fromJson(json[r'order']),
page: num.parse('${json[r'page']}'),
personIds: json[r'personIds'] is Iterable
? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false)
+6 -8
View File
@@ -18264,6 +18264,11 @@
},
"LibraryStatsResponseDto": {
"properties": {
"offline": {
"default": 0,
"description": "Number of offline assets",
"type": "integer"
},
"photos": {
"default": 0,
"description": "Number of photos",
@@ -18287,6 +18292,7 @@
}
},
"required": [
"offline",
"photos",
"total",
"usage",
@@ -22063,14 +22069,6 @@
"description": "Filter by OCR text content",
"type": "string"
},
"order": {
"allOf": [
{
"$ref": "#/components/schemas/AssetOrder"
}
],
"description": "Sort order by date. If not provided, results are sorted by relevance."
},
"page": {
"description": "Page number",
"minimum": 1,
+2 -2
View File
@@ -1323,6 +1323,8 @@ export type UpdateLibraryDto = {
name?: string;
};
export type LibraryStatsResponseDto = {
/** Number of offline assets */
offline: number;
/** Number of photos */
photos: number;
/** Total number of assets */
@@ -1893,8 +1895,6 @@ export type SmartSearchDto = {
model?: string | null;
/** Filter by OCR text content */
ocr?: string;
/** Sort order by date. If not provided, results are sorted by relevance. */
order?: AssetOrder;
/** Page number */
page?: number;
/** Filter by person IDs */
+5 -5
View File
@@ -9,15 +9,15 @@
"build": "nest build",
"format": "prettier --check .",
"format:fix": "prettier --write .",
"start": "pnpm run start:dev",
"start": "npm run start:dev",
"nest": "nest",
"start:dev": "nest start --watch --",
"start:debug": "nest start --debug 0.0.0.0:9230 --watch --",
"lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\" --max-warnings 0",
"lint:fix": "pnpm run lint --fix",
"lint:fix": "npm run lint -- --fix",
"check": "tsc --noEmit",
"check:code": "pnpm run format && pnpm run lint && pnpm run check",
"check:all": "pnpm run check:code && pnpm run test:cov",
"check:code": "npm run format && npm run lint && npm run check",
"check:all": "npm run check:code && npm run test:cov",
"test": "vitest --config test/vitest.config.mjs",
"test:cov": "vitest --config test/vitest.config.mjs --coverage",
"test:medium": "vitest --config test/vitest.config.medium.mjs",
@@ -28,7 +28,7 @@
"migrations:run": "node ./dist/bin/migrations.js run",
"migrations:revert": "node ./dist/bin/migrations.js revert",
"schema:drop": "node ./dist/bin/migrations.js query 'DROP schema public cascade; CREATE schema public;'",
"schema:reset": "pnpm run schema:drop && pnpm run migrations:run",
"schema:reset": "npm run schema:drop && npm run migrations:run",
"sync:open-api": "node ./dist/bin/sync-open-api.js",
"sync:sql": "node ./dist/bin/sync-sql.js",
"email:dev": "email dev -p 3050 --dir src/emails"
+3
View File
@@ -136,6 +136,9 @@ export class LibraryStatsResponseDto {
@ApiProperty({ type: 'integer', description: 'Total number of assets' })
total = 0;
@ApiProperty({ type: 'integer', description: 'Number of offline assets' })
offline = 0;
@ApiProperty({ type: 'integer', format: 'int64', description: 'Storage usage in bytes' })
usage = 0;
}
-8
View File
@@ -237,14 +237,6 @@ export class SmartSearchDto extends BaseSearchWithResultsDto {
@Type(() => Number)
@Optional()
page?: number;
@ValidateEnum({
enum: AssetOrder,
name: 'AssetOrder',
optional: true,
description: 'Sort order by date. If not provided, results are sorted by relevance.',
})
order?: AssetOrder;
}
export class SearchPlacesDto {
+14 -4
View File
@@ -36,27 +36,37 @@ select
(
"asset"."type" = $1
and "asset"."visibility" != $2
and "asset"."isOffline" = $3
)
) as "photos",
count(*) filter (
where
(
"asset"."type" = $3
and "asset"."visibility" != $4
"asset"."type" = $4
and "asset"."visibility" != $5
and "asset"."isOffline" = $6
)
) as "videos",
coalesce(sum("asset_exif"."fileSizeInByte"), $5) as "usage"
count(*) filter (
where
(
"asset"."isOffline" = $7
and "asset"."visibility" != $8
)
) as "offline",
coalesce(sum("asset_exif"."fileSizeInByte"), $9) as "usage"
from
"library"
inner join "asset" on "asset"."libraryId" = "library"."id"
left join "asset_exif" on "asset_exif"."assetId" = "asset"."id"
where
"library"."id" = $6
"library"."id" = $10
group by
"library"."id"
select
0::int as "photos",
0::int as "videos",
0::int as "offline",
0::int as "usage",
0::int as "total"
from
+20 -2
View File
@@ -79,7 +79,11 @@ export class LibraryRepository {
eb.fn
.countAll<number>()
.filterWhere((eb) =>
eb.and([eb('asset.type', '=', AssetType.Image), eb('asset.visibility', '!=', AssetVisibility.Hidden)]),
eb.and([
eb('asset.type', '=', AssetType.Image),
eb('asset.visibility', '!=', AssetVisibility.Hidden),
eb('asset.isOffline', '=', false),
]),
)
.as('photos'),
)
@@ -87,10 +91,22 @@ export class LibraryRepository {
eb.fn
.countAll<number>()
.filterWhere((eb) =>
eb.and([eb('asset.type', '=', AssetType.Video), eb('asset.visibility', '!=', AssetVisibility.Hidden)]),
eb.and([
eb('asset.type', '=', AssetType.Video),
eb('asset.visibility', '!=', AssetVisibility.Hidden),
eb('asset.isOffline', '=', false),
]),
)
.as('videos'),
)
.select((eb) =>
eb.fn
.countAll<number>()
.filterWhere((eb) =>
eb.and([eb('asset.isOffline', '=', true), eb('asset.visibility', '!=', AssetVisibility.Hidden)]),
)
.as('offline'),
)
.select((eb) => eb.fn.coalesce((eb) => eb.fn.sum('asset_exif.fileSizeInByte'), eb.val(0)).as('usage'))
.groupBy('library.id')
.where('library.id', '=', id)
@@ -103,6 +119,7 @@ export class LibraryRepository {
.selectFrom('library')
.select(zero.as('photos'))
.select(zero.as('videos'))
.select(zero.as('offline'))
.select(zero.as('usage'))
.select(zero.as('total'))
.where('library.id', '=', id)
@@ -112,6 +129,7 @@ export class LibraryRepository {
return {
photos: stats.photos,
videos: stats.videos,
offline: stats.offline,
usage: stats.usage,
total: stats.photos + stats.videos,
};
+1 -7
View File
@@ -129,7 +129,6 @@ export type SmartSearchOptions = SearchDateOptions &
SearchEmbeddingOptions &
SearchExifOptions &
SearchOneToOneRelationOptions &
SearchOrderOptions &
SearchStatusOptions &
SearchUserIdOptions &
SearchPeopleOptions &
@@ -301,12 +300,7 @@ export class SearchRepository {
const items = await searchAssetBuilder(trx, options)
.selectAll('asset')
.innerJoin('smart_search', 'asset.id', 'smart_search.assetId')
.$if(!options.orderDirection, (qb) => qb.orderBy(sql`smart_search.embedding <=> ${options.embedding}`))
.$if(!!options.orderDirection, (qb) =>
qb
.where(sql`(smart_search.embedding <=> ${options.embedding}) <= 0.9`)
.orderBy('asset.fileCreatedAt', options.orderDirection as OrderByDirection),
)
.orderBy(sql`smart_search.embedding <=> ${options.embedding}`)
.limit(pagination.size + 1)
.offset((pagination.page - 1) * pagination.size)
.execute();
+8 -1
View File
@@ -681,12 +681,19 @@ describe(LibraryService.name, () => {
it('should return library statistics', async () => {
const library = factory.library();
mocks.library.getStatistics.mockResolvedValue({ photos: 10, videos: 0, total: 10, usage: 1337 });
mocks.library.getStatistics.mockResolvedValue({
photos: 10,
videos: 0,
total: 10,
usage: 1337,
offline: 67,
});
await expect(sut.getStatistics(library.id)).resolves.toEqual({
photos: 10,
videos: 0,
total: 10,
usage: 1337,
offline: 67,
});
expect(mocks.library.getStatistics).toHaveBeenCalledWith(library.id);
+1 -1
View File
@@ -139,7 +139,7 @@ export class SearchService extends BaseService {
const size = dto.size || 100;
const { hasNextPage, items } = await this.searchRepository.searchSmart(
{ page, size },
{ ...dto, userIds: await userIds, embedding, orderDirection: dto.order },
{ ...dto, userIds: await userIds, embedding },
);
return this.mapResponse(items, hasNextPage ? (page + 1).toString() : null, { auth });
@@ -94,7 +94,7 @@
const sharedLink = getSharedLink();
</script>
<CommandPaletteDefaultProvider name={$t('assets')} actions={withoutIcons([Close, Cast, ...Object.values(Actions)])} />
<CommandPaletteDefaultProvider name={$t('assets')} actions={withoutIcons([Cast, ...Object.values(Actions)])} />
<div
class="flex h-16 place-items-center justify-between bg-linear-to-b from-black/40 px-3 transition-transform duration-200"
@@ -432,12 +432,6 @@
);
const { Tag } = $derived(getAssetActions($t, asset));
const showDetailPanel = $derived(
asset.hasMetadata &&
$slideshowState === SlideshowState.None &&
assetViewerManager.isShowDetailPanel &&
!assetViewerManager.isShowEditor,
);
</script>
<CommandPaletteDefaultProvider name={$t('assets')} actions={[Tag]} />
@@ -577,22 +571,25 @@
</div>
{/if}
{#if showDetailPanel || assetViewerManager.isShowEditor}
{#if asset.hasMetadata && $slideshowState === SlideshowState.None && assetViewerManager.isShowDetailPanel && !assetViewerManager.isShowEditor}
<div
transition:fly={{ duration: 150 }}
id="detail-panel"
class="row-start-1 row-span-4 overflow-y-auto transition-all dark:border-l dark:border-s-immich-dark-gray bg-light"
class="row-start-1 row-span-4 w-90 overflow-y-auto transition-all dark:border-l dark:border-s-immich-dark-gray bg-light"
translate="yes"
>
{#if showDetailPanel}
<div class="w-90 h-full">
<DetailPanel {asset} currentAlbum={album} albums={appearsInAlbums} />
</div>
{:else if assetViewerManager.isShowEditor}
<div class="w-100 h-full">
<EditorPanel {asset} onClose={closeEditor} />
</div>
{/if}
<DetailPanel {asset} currentAlbum={album} albums={appearsInAlbums} />
</div>
{/if}
{#if assetViewerManager.isShowEditor}
<div
transition:fly={{ duration: 150 }}
id="editor-panel"
class="row-start-1 row-span-4 w-100 overflow-y-auto transition-all dark:border-l dark:border-s-immich-dark-gray"
translate="yes"
>
<EditorPanel {asset} onClose={closeEditor} />
</div>
{/if}
@@ -2,20 +2,36 @@
import { ByteUnit } from '$lib/utils/byte-units';
import { Icon, Text } from '@immich/ui';
interface Props {
icon: string;
title: string;
interface ValueData {
value: number;
unit?: ByteUnit | undefined;
}
let { icon, title, value, unit = undefined }: Props = $props();
interface Props {
icon: string;
title: string;
valuePromise: Promise<ValueData>;
}
let { icon, title, valuePromise }: Props = $props();
let isLoading = $state(true);
let data = $state<ValueData | null>(null);
$effect.pre(() => {
isLoading = true;
void valuePromise.then((result) => {
data = result;
isLoading = false;
});
});
const zeros = $derived(() => {
const maxLength = 13;
const valueLength = value.toString().length;
if (!data) {
return '0'.repeat(maxLength);
}
const valueLength = data.value.toString().length;
const zeroLength = maxLength - valueLength;
return '0'.repeat(zeroLength);
});
</script>
@@ -26,10 +42,26 @@
<Text size="giant" fontWeight="medium">{title}</Text>
</div>
<div class="mx-auto font-mono text-2xl font-medium">
<span class="text-gray-300 dark:text-gray-600">{zeros()}</span><span>{value}</span>
{#if unit}
<code class="font-mono text-base font-normal">{unit}</code>
{/if}
<div class="mx-auto font-mono text-2xl font-medium relative">
<span class="text-gray-300 dark:text-gray-600" class:shimmer-text={isLoading}>{zeros()}</span
>{#if !isLoading && data}<span>{data.value}</span>
{#if data.unit}<code class="font-mono text-base font-normal">{data.unit}</code>{/if}{/if}
</div>
</div>
<style>
.shimmer-text {
mask-image: linear-gradient(90deg, rgba(0, 0, 0, 1) 0%, rgba(0, 0, 0, 0.3) 50%, rgba(0, 0, 0, 1) 100%);
mask-size: 200% 100%;
animation: shimmer 2.25s infinite linear;
}
@keyframes shimmer {
from {
mask-position: 200% 0;
}
to {
mask-position: -200% 0;
}
}
</style>
@@ -1,8 +1,8 @@
<script lang="ts">
import StatsCard from '$lib/components/server-statistics/ServerStatisticsCard.svelte';
import ServerStatisticsCard from '$lib/components/server-statistics/ServerStatisticsCard.svelte';
import { locale } from '$lib/stores/preferences.store';
import { getBytesWithUnit } from '$lib/utils/byte-units';
import type { ServerStatsResponseDto } from '@immich/sdk';
import type { ServerStatsResponseDto, UserAdminResponseDto } from '@immich/sdk';
import {
Code,
FormatBytes,
@@ -19,10 +19,35 @@
import { t } from 'svelte-i18n';
type Props = {
stats: ServerStatsResponseDto;
statsPromise: Promise<ServerStatsResponseDto>;
users: UserAdminResponseDto[];
};
const { stats }: Props = $props();
const { statsPromise, users }: Props = $props();
let stats = $state<ServerStatsResponseDto | null>(null);
$effect.pre(() => {
void statsPromise.then((result) => {
stats = result;
});
});
const photosPromise = $derived.by(() => statsPromise.then((data) => ({ value: data.photos })));
const videosPromise = $derived.by(() => statsPromise.then((data) => ({ value: data.videos })));
const storagePromise = $derived.by(() =>
statsPromise.then((data) => {
const TiB = 1024 ** 4;
const [value, unit] = getBytesWithUnit(data.usage, data.usage > TiB ? 2 : 0);
return { value, unit };
}),
);
const storageUsageWithUnit = $derived.by(() => {
const TiB = 1024 ** 4;
return stats ? getBytesWithUnit(stats.usage, stats.usage > TiB ? 2 : 0) : ([0, ''] as const);
});
const zeros = (value: number, maxLength = 13) => {
const valueLength = value.toString().length;
@@ -30,9 +55,6 @@
return '0'.repeat(zeroLength);
};
const TiB = 1024 ** 4;
let [statsUsage, statsUsageUnit] = $derived(getBytesWithUnit(stats.usage, stats.usage > TiB ? 2 : 0));
</script>
<div class="flex flex-col gap-5 my-4">
@@ -40,48 +62,52 @@
<Text class="mb-2" fontWeight="medium">{$t('total_usage')}</Text>
<div class="hidden justify-between lg:flex gap-4">
<StatsCard icon={mdiCameraIris} title={$t('photos')} value={stats.photos} />
<StatsCard icon={mdiPlayCircle} title={$t('videos')} value={stats.videos} />
<StatsCard icon={mdiChartPie} title={$t('storage')} value={statsUsage} unit={statsUsageUnit} />
<ServerStatisticsCard icon={mdiCameraIris} title={$t('photos')} valuePromise={photosPromise} />
<ServerStatisticsCard icon={mdiPlayCircle} title={$t('videos')} valuePromise={videosPromise} />
<ServerStatisticsCard icon={mdiChartPie} title={$t('storage')} valuePromise={storagePromise} />
</div>
<div class="mt-5 flex lg:hidden">
<div class="flex flex-col justify-between rounded-3xl bg-subtle p-5 dark:bg-immich-dark-gray">
<div class="flex flex-wrap gap-x-12">
<div class="flex flex-1 place-items-center gap-4 text-primary">
<Icon icon={mdiCameraIris} size="25" />
<Text size="medium" fontWeight="medium">{$t('photos')}</Text>
</div>
{#if stats}
<div class="flex flex-col justify-between rounded-3xl bg-subtle p-5 dark:bg-immich-dark-gray">
<div class="flex flex-wrap gap-x-12">
<div class="flex flex-1 place-items-center gap-4 text-primary">
<Icon icon={mdiCameraIris} size="25" />
<Text size="medium" fontWeight="medium">{$t('photos')}</Text>
</div>
<div class="relative text-center font-mono text-2xl font-medium">
<span class="text-light-300">{zeros(stats.photos)}</span><span class="text-primary">{stats.photos}</span>
</div>
</div>
<div class="flex flex-wrap gap-x-12">
<div class="flex flex-1 place-items-center gap-4 text-primary">
<Icon icon={mdiPlayCircle} size="25" />
<Text size="medium" fontWeight="medium">{$t('videos')}</Text>
<div class="relative text-center font-mono text-2xl font-medium">
<span class="text-light-300">{zeros(stats.photos)}</span><span class="text-primary">{stats.photos}</span>
</div>
</div>
<div class="flex flex-wrap gap-x-12">
<div class="flex flex-1 place-items-center gap-4 text-primary">
<Icon icon={mdiPlayCircle} size="25" />
<Text size="medium" fontWeight="medium">{$t('videos')}</Text>
</div>
<div class="relative text-center font-mono text-2xl font-medium">
<span class="text-light-300">{zeros(stats.videos)}</span><span class="text-primary">{stats.videos}</span>
</div>
</div>
<div class="flex flex-wrap gap-x-5">
<div class="flex flex-1 flex-nowrap place-items-center gap-4 text-primary">
<Icon icon={mdiChartPie} size="25" />
<Text size="medium" fontWeight="medium">{$t('storage')}</Text>
<div class="relative text-center font-mono text-2xl font-medium">
<span class="text-light-300">{zeros(stats.videos)}</span><span class="text-primary">{stats.videos}</span>
</div>
</div>
<div class="flex flex-wrap gap-x-5">
<div class="flex flex-1 flex-nowrap place-items-center gap-4 text-primary">
<Icon icon={mdiChartPie} size="25" />
<Text size="medium" fontWeight="medium">{$t('storage')}</Text>
</div>
<div class="relative flex text-center font-mono text-2xl font-medium">
<span class="text-light-300">{zeros(statsUsage)}</span><span class="text-primary">{statsUsage}</span>
<div class="relative flex text-center font-mono text-2xl font-medium">
<span class="text-light-300">{zeros(storageUsageWithUnit[0])}</span><span class="text-primary"
>{storageUsageWithUnit[0]}</span
>
<div class="absolute -end-1.5 -bottom-4">
<Code color="muted" class="text-xs font-light font-mono">{statsUsageUnit}</Code>
<div class="absolute -end-1.5 -bottom-4">
<Code color="muted" class="text-xs font-light font-mono">{storageUsageWithUnit[1]}</Code>
</div>
</div>
</div>
</div>
</div>
{/if}
</div>
</div>
@@ -95,34 +121,82 @@
<TableHeading class="w-1/4">{$t('usage')}</TableHeading>
</TableHeader>
<TableBody class="block max-h-80 overflow-y-auto">
{#each stats.usageByUser as user (user.userId)}
<TableRow>
<TableCell class="w-1/4">{user.userName}</TableCell>
<TableCell class="w-1/4">
{user.photos.toLocaleString($locale)} (<FormatBytes bytes={user.usagePhotos} />)</TableCell
>
<TableCell class="w-1/4">
{user.videos.toLocaleString($locale)} (<FormatBytes bytes={user.usageVideos} precision={0} />)</TableCell
>
<TableCell class="w-1/4">
<FormatBytes bytes={user.usage} precision={0} />
{#if user.quotaSizeInBytes !== null}
/ <FormatBytes bytes={user.quotaSizeInBytes} precision={0} />
{/if}
<span class="text-primary">
{#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0}
({(user.quotaSizeInBytes === 0 ? 1 : user.usage / user.quotaSizeInBytes).toLocaleString($locale, {
style: 'percent',
maximumFractionDigits: 0,
})})
{:else}
({$t('unlimited')})
{#if stats}
{#each stats.usageByUser as user (user.userId)}
<TableRow>
<TableCell class="w-1/4">{user.userName}</TableCell>
<TableCell class="w-1/4">
{user.photos.toLocaleString($locale)} (<FormatBytes bytes={user.usagePhotos} />)</TableCell
>
<TableCell class="w-1/4">
{user.videos.toLocaleString($locale)} (<FormatBytes
bytes={user.usageVideos}
precision={0}
/>)</TableCell
>
<TableCell class="w-1/4">
<FormatBytes bytes={user.usage} precision={0} />
{#if user.quotaSizeInBytes !== null}
/ <FormatBytes bytes={user.quotaSizeInBytes} precision={0} />
{/if}
</span>
</TableCell>
</TableRow>
{/each}
<span class="text-primary">
{#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0}
({(user.quotaSizeInBytes === 0 ? 1 : user.usage / user.quotaSizeInBytes).toLocaleString($locale, {
style: 'percent',
maximumFractionDigits: 0,
})})
{:else}
({$t('unlimited')})
{/if}
</span>
</TableCell>
</TableRow>
{/each}
{:else if users.length}
{#each users as user (user.id)}
<TableRow>
<TableCell class="w-1/4">{user.name}</TableCell>
<TableCell class="w-1/4"><span class="skeleton-loader inline-block h-4 w-16"></span></TableCell>
<TableCell class="w-1/4"><span class="skeleton-loader inline-block h-4 w-16"></span></TableCell>
<TableCell class="w-1/4"><span class="skeleton-loader inline-block h-4 w-24"></span></TableCell>
</TableRow>
{/each}
{/if}
</TableBody>
</Table>
</div>
</div>
<style>
.skeleton-loader {
position: relative;
border-radius: 4px;
overflow: hidden;
background-color: rgba(156, 163, 175, 0.35);
}
.skeleton-loader::after {
content: '';
position: absolute;
inset: 0;
background-repeat: no-repeat;
background-image: linear-gradient(
90deg,
rgba(255, 255, 255, 0),
rgba(255, 255, 255, 0.8) 50%,
rgba(255, 255, 255, 0)
);
background-size: 200% 100%;
background-position: 200% 0;
animation: skeleton-animation 2000ms infinite;
}
@keyframes skeleton-animation {
from {
background-position: 200% 0;
}
to {
background-position: -200% 0;
}
}
</style>
@@ -1,459 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut';
import type { Action } from '$lib/components/asset-viewer/actions/action';
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import { AssetAction } from '$lib/constants';
import Portal from '$lib/elements/Portal.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
import AssetDeleteConfirmModal from '$lib/modals/AssetDeleteConfirmModal.svelte';
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
import { Route } from '$lib/route';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { showDeleteModal } from '$lib/stores/preferences.store';
import { handlePromiseError } from '$lib/utils';
import { deleteAssets } from '$lib/utils/actions';
import { archiveAssets, cancelMultiselect, getNextAsset, getPreviousAsset, navigateToAsset } from '$lib/utils/asset-utils';
import { moveFocus } from '$lib/utils/focus-util';
import { handleError } from '$lib/utils/handle-error';
import type { CommonJustifiedLayout } from '$lib/utils/layout-utils';
import { getJustifiedLayoutFromAssets } from '$lib/utils/layout-utils';
import { navigate } from '$lib/utils/navigation';
import { formatGroupTitle, toTimelineAsset } from '$lib/utils/timeline-util';
import { AssetVisibility, type AssetResponseDto } from '@immich/sdk';
import { modalManager, Text } from '@immich/ui';
import { DateTime } from 'luxon';
import { debounce } from 'lodash-es';
import { t } from 'svelte-i18n';
type DateGroup = {
date: DateTime;
title: string;
assets: AssetResponseDto[];
geometry: CommonJustifiedLayout;
offsetTop: number;
};
type Props = {
assets: AssetResponseDto[];
assetInteraction: AssetInteraction;
disableAssetSelect?: boolean;
showArchiveIcon?: boolean;
viewport: Viewport;
onIntersected?: (() => void) | undefined;
onReload?: (() => void) | undefined;
slidingWindowOffset?: number;
};
let {
assets = $bindable(),
assetInteraction,
disableAssetSelect = false,
showArchiveIcon = false,
viewport,
onIntersected = undefined,
onReload = undefined,
slidingWindowOffset = 0,
}: Props = $props();
const HEADER_HEIGHT = 48;
let { isViewing: isViewerOpen, asset: viewingAsset } = assetViewingStore;
function groupAssetsByDate(items: AssetResponseDto[]): DateGroup[] {
const groupEntries: { key: string; assets: AssetResponseDto[] }[] = [];
for (const asset of items) {
const date = DateTime.fromISO(asset.localDateTime, { zone: 'UTC' });
const key = date.toISODate() ?? 'unknown';
const last = groupEntries.at(-1);
if (last && last.key === key) {
last.assets.push(asset);
} else {
groupEntries.push({ key, assets: [asset] });
}
}
const groups: DateGroup[] = [];
let offsetTop = 0;
const rowWidth = Math.floor(viewport.width);
const rowHeight = rowWidth < 850 ? 100 : 235;
for (const { key, assets: groupAssets } of groupEntries) {
const date = DateTime.fromISO(key, { zone: 'local' });
const geometry = getJustifiedLayoutFromAssets(groupAssets, {
spacing: 2,
heightTolerance: 0.5,
rowHeight,
rowWidth,
});
groups.push({
date: date as DateTime<true>,
title: formatGroupTitle(date),
assets: groupAssets,
geometry,
offsetTop,
});
offsetTop += HEADER_HEIGHT + geometry.containerHeight;
}
return groups;
}
const dateGroups = $derived(groupAssetsByDate(assets));
const totalHeight = $derived(
dateGroups.length > 0
? dateGroups.at(-1)!.offsetTop + HEADER_HEIGHT + dateGroups.at(-1)!.geometry.containerHeight
: 0,
);
let shiftKeyIsDown = $state(false);
let lastAssetMouseEvent: TimelineAsset | null = $state(null);
let scrollTop = $state(0);
let slidingWindow = $derived.by(() => {
const top = (scrollTop || 0) - slidingWindowOffset;
const bottom = top + viewport.height + slidingWindowOffset;
return { top, bottom };
});
const updateSlidingWindow = () => (scrollTop = document.scrollingElement?.scrollTop ?? 0);
const debouncedOnIntersected = debounce(() => onIntersected?.(), 750, { maxWait: 100, leading: true });
let lastIntersectedHeight = 0;
$effect(() => {
if (totalHeight - slidingWindow.bottom <= viewport.height && lastIntersectedHeight !== totalHeight) {
debouncedOnIntersected();
lastIntersectedHeight = totalHeight;
}
});
function isGroupVisible(group: DateGroup): boolean {
const groupTop = group.offsetTop;
const groupBottom = groupTop + HEADER_HEIGHT + group.geometry.containerHeight;
return groupTop < slidingWindow.bottom && groupBottom > slidingWindow.top;
}
function isAssetVisible(group: DateGroup, assetIndex: number): boolean {
const assetTop = group.offsetTop + HEADER_HEIGHT + group.geometry.getTop(assetIndex);
const assetBottom = assetTop + group.geometry.getHeight(assetIndex);
return assetTop < slidingWindow.bottom && assetBottom > slidingWindow.top;
}
const selectAllAssets = () => {
assetInteraction.selectAssets(assets.map((a) => toTimelineAsset(a)));
};
const deselectAllAssets = () => {
cancelMultiselect(assetInteraction);
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
event.preventDefault();
shiftKeyIsDown = true;
}
};
const onKeyUp = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
event.preventDefault();
shiftKeyIsDown = false;
}
};
const handleSelectAssets = (asset: TimelineAsset) => {
if (!asset) {
return;
}
const deselect = assetInteraction.hasSelectedAsset(asset.id);
if (deselect) {
for (const candidate of assetInteraction.assetSelectionCandidates) {
assetInteraction.removeAssetFromMultiselectGroup(candidate.id);
}
assetInteraction.removeAssetFromMultiselectGroup(asset.id);
} else {
for (const candidate of assetInteraction.assetSelectionCandidates) {
assetInteraction.selectAsset(candidate);
}
assetInteraction.selectAsset(asset);
}
assetInteraction.clearAssetSelectionCandidates();
assetInteraction.setAssetSelectionStart(deselect ? null : asset);
};
const handleSelectAssetCandidates = (asset: TimelineAsset | null) => {
if (asset) {
selectAssetCandidates(asset);
}
lastAssetMouseEvent = asset;
};
const selectAssetCandidates = (endAsset: TimelineAsset) => {
if (!shiftKeyIsDown) {
return;
}
const startAsset = assetInteraction.assetSelectionStart;
if (!startAsset) {
return;
}
let start = assets.findIndex((a) => a.id === startAsset.id);
let end = assets.findIndex((a) => a.id === endAsset.id);
if (start > end) {
[start, end] = [end, start];
}
assetInteraction.setAssetSelectionCandidates(assets.slice(start, end + 1).map((a) => toTimelineAsset(a)));
};
const onSelectStart = (event: Event) => {
if (assetInteraction.selectionActive && shiftKeyIsDown) {
event.preventDefault();
}
};
const onDelete = () => {
const hasTrashedAsset = assetInteraction.selectedAssets.some((asset) => asset.isTrashed);
handlePromiseError(trashOrDelete(hasTrashedAsset));
};
const trashOrDelete = async (force: boolean = false) => {
const forceOrNoTrash = force || !featureFlagsManager.value.trash;
const selectedAssets = assetInteraction.selectedAssets;
if ($showDeleteModal && forceOrNoTrash) {
const confirmed = await modalManager.show(AssetDeleteConfirmModal, { size: selectedAssets.length });
if (!confirmed) {
return;
}
}
await deleteAssets(
forceOrNoTrash,
(assetIds) => (assets = assets.filter((asset) => !assetIds.includes(asset.id))),
selectedAssets,
onReload,
);
assetInteraction.clearMultiselect();
};
const toggleArchive = async () => {
const ids = await archiveAssets(
assetInteraction.selectedAssets,
assetInteraction.isAllArchived ? AssetVisibility.Timeline : AssetVisibility.Archive,
);
if (ids) {
assets = assets.filter((asset) => !ids.includes(asset.id));
deselectAllAssets();
}
};
const focusNextAsset = () => moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, 'next');
const focusPreviousAsset = () =>
moveFocus((element) => element.dataset.thumbnailFocusContainer !== undefined, 'previous');
let isShortcutModalOpen = false;
const handleOpenShortcutModal = async () => {
if (isShortcutModalOpen) {
return;
}
isShortcutModalOpen = true;
await modalManager.show(ShortcutsModal, {});
isShortcutModalOpen = false;
};
const shortcutList = $derived(
(() => {
if ($isViewerOpen) {
return [];
}
const sc: ShortcutOptions[] = [
{ shortcut: { key: '?', shift: true }, onShortcut: handleOpenShortcutModal },
{ shortcut: { key: '/' }, onShortcut: () => goto(Route.explore()) },
{ shortcut: { key: 'A', ctrl: true }, onShortcut: () => selectAllAssets() },
{ shortcut: { key: 'ArrowRight' }, preventDefault: false, onShortcut: focusNextAsset },
{ shortcut: { key: 'ArrowLeft' }, preventDefault: false, onShortcut: focusPreviousAsset },
];
if (assetInteraction.selectionActive) {
sc.push(
{ shortcut: { key: 'Escape' }, onShortcut: deselectAllAssets },
{ shortcut: { key: 'Delete' }, onShortcut: onDelete },
{ shortcut: { key: 'Delete', shift: true }, onShortcut: () => trashOrDelete(true) },
{ shortcut: { key: 'D', ctrl: true }, onShortcut: () => deselectAllAssets() },
{ shortcut: { key: 'a', shift: true }, onShortcut: toggleArchive },
);
}
return sc;
})(),
);
const handleRandom = async (): Promise<{ id: string } | undefined> => {
if (assets.length === 0) {
return;
}
try {
const randomIndex = Math.floor(Math.random() * assets.length);
const asset = assets[randomIndex];
await navigateToAsset(asset);
return asset;
} catch (error) {
handleError(error, $t('errors.cannot_navigate_next_asset'));
return;
}
};
const updateCurrentAsset = (asset: AssetResponseDto) => {
const index = assets.findIndex((oldAsset) => oldAsset.id === asset.id);
assets[index] = asset;
};
const handleAction = async (action: Action) => {
switch (action.type) {
case AssetAction.ARCHIVE:
case AssetAction.DELETE:
case AssetAction.TRASH: {
const nextAsset = assetCursor.nextAsset ?? assetCursor.previousAsset;
assets.splice(
assets.findIndex((currentAsset) => currentAsset.id === action.asset.id),
1,
);
if (assets.length === 0) {
return await goto(Route.photos());
}
if (nextAsset) {
await navigateToAsset(nextAsset);
}
break;
}
}
};
const assetMouseEventHandler = (asset: TimelineAsset | null) => {
if (assetInteraction.selectionActive) {
handleSelectAssetCandidates(asset);
}
};
$effect(() => {
if (!lastAssetMouseEvent) {
assetInteraction.clearAssetSelectionCandidates();
}
});
$effect(() => {
if (!shiftKeyIsDown) {
assetInteraction.clearAssetSelectionCandidates();
}
});
$effect(() => {
if (shiftKeyIsDown && lastAssetMouseEvent) {
selectAssetCandidates(lastAssetMouseEvent);
}
});
const assetCursor = $derived({
current: $viewingAsset,
nextAsset: getNextAsset(assets, $viewingAsset),
previousAsset: getPreviousAsset(assets, $viewingAsset),
});
</script>
<svelte:document
onkeydown={onKeyDown}
onkeyup={onKeyUp}
onselectstart={onSelectStart}
use:shortcuts={shortcutList}
onscroll={() => updateSlidingWindow()}
/>
{#if assets.length > 0}
<div style:position="relative" style:height={totalHeight + 'px'} style:width={viewport.width + 'px'}>
{#each dateGroups as group (group.date.toISODate())}
{#if isGroupVisible(group)}
<!-- Date header -->
<div
class="absolute flex items-center px-2"
style:top={group.offsetTop + 'px'}
style:height={HEADER_HEIGHT + 'px'}
style:width="100%"
>
<Text fontWeight="medium" class="text-sm md:text-base">{group.title}</Text>
</div>
<!-- Thumbnails -->
<div
class="absolute"
style:top={group.offsetTop + HEADER_HEIGHT + 'px'}
style:height={group.geometry.containerHeight + 'px'}
style:width={group.geometry.containerWidth + 'px'}
>
{#each group.assets as asset, i (asset.id)}
{#if isAssetVisible(group, i)}
{@const currentAsset = toTimelineAsset(asset)}
<div
class="absolute"
style:overflow="clip"
style:top={group.geometry.getTop(i) + 'px'}
style:left={group.geometry.getLeft(i) + 'px'}
style:width={group.geometry.getWidth(i) + 'px'}
style:height={group.geometry.getHeight(i) + 'px'}
>
<Thumbnail
readonly={disableAssetSelect}
onClick={() => {
if (assetInteraction.selectionActive) {
handleSelectAssets(currentAsset);
return;
}
void navigateToAsset(asset);
}}
onSelect={() => handleSelectAssets(currentAsset)}
onMouseEvent={() => assetMouseEventHandler(currentAsset)}
{showArchiveIcon}
asset={currentAsset}
selected={assetInteraction.hasSelectedAsset(currentAsset.id)}
selectionCandidate={assetInteraction.hasSelectionCandidate(currentAsset.id)}
thumbnailWidth={group.geometry.getWidth(i)}
thumbnailHeight={group.geometry.getHeight(i)}
/>
</div>
{/if}
{/each}
</div>
{/if}
{/each}
</div>
{/if}
<!-- Overlay Asset Viewer -->
{#if $isViewerOpen}
<Portal target="body">
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
<AssetViewer
cursor={assetCursor}
onAction={handleAction}
onRandom={handleRandom}
onAssetChange={updateCurrentAsset}
onClose={() => {
assetViewingStore.showAssetViewer(false);
handlePromiseError(navigate({ targetRoute: 'current', assetId: null }));
}}
/>
{/await}
</Portal>
{/if}
@@ -1,41 +0,0 @@
<script lang="ts">
import RadioButton from '$lib/elements/RadioButton.svelte';
import { Text } from '@immich/ui';
import { t } from 'svelte-i18n';
interface Props {
sortOrder: 'best-match' | 'newest' | 'oldest';
}
let { sortOrder = $bindable() }: Props = $props();
</script>
<div id="sort-order-selection">
<fieldset>
<Text class="mb-2" fontWeight="medium">{$t('sort_order')}</Text>
<div class="flex flex-wrap gap-x-5 gap-y-2 mt-1">
<RadioButton
name="sort-order"
id="sort-best-match"
bind:group={sortOrder}
label={$t('best_match')}
value="best-match"
/>
<RadioButton
name="sort-order"
id="sort-newest"
bind:group={sortOrder}
label={$t('newest_first')}
value="newest"
/>
<RadioButton
name="sort-order"
id="sort-oldest"
bind:group={sortOrder}
label={$t('oldest_first')}
value="oldest"
/>
</div>
</fieldset>
</div>
+2 -1
View File
@@ -3,6 +3,7 @@
import HeaderActionButton from '$lib/components/HeaderActionButton.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
import { AlbumPageViewMode } from '$lib/constants';
import {
getAlbumActions,
handleRemoveUserFromAlbum,
@@ -55,7 +56,7 @@
sharedLinks = sharedLinks.filter(({ id }) => sharedLink.id !== id);
};
const { AddUsers, CreateSharedLink } = $derived(getAlbumActions($t, album));
const { AddUsers, CreateSharedLink } = $derived(getAlbumActions($t, album, AlbumPageViewMode.OPTIONS));
let sharedLinks: SharedLinkResponseDto[] = $state([]);
+1 -17
View File
@@ -16,7 +16,6 @@
display: SearchDisplayFilters;
mediaType: MediaType;
rating?: number;
sortOrder: 'best-match' | 'newest' | 'oldest';
};
</script>
@@ -29,14 +28,13 @@
import SearchLocationSection from '$lib/components/shared-components/search-bar/search-location-section.svelte';
import SearchMediaSection from '$lib/components/shared-components/search-bar/search-media-section.svelte';
import SearchPeopleSection from '$lib/components/shared-components/search-bar/search-people-section.svelte';
import SearchSortSection from '$lib/components/shared-components/search-bar/search-sort-section.svelte';
import SearchRatingsSection from '$lib/components/shared-components/search-bar/search-ratings-section.svelte';
import SearchTagsSection from '$lib/components/shared-components/search-bar/search-tags-section.svelte';
import SearchTextSection from '$lib/components/shared-components/search-bar/search-text-section.svelte';
import { preferences } from '$lib/stores/user.store';
import { parseUtcDate } from '$lib/utils/date-time';
import { generateId } from '$lib/utils/generate-id';
import { AssetOrder, AssetTypeEnum, AssetVisibility, type MetadataSearchDto, type SmartSearchDto } from '@immich/sdk';
import { AssetTypeEnum, AssetVisibility, type MetadataSearchDto, type SmartSearchDto } from '@immich/sdk';
import { Button, HStack, Modal, ModalBody, ModalFooter } from '@immich/ui';
import { mdiTune } from '@mdi/js';
import type { DateTime } from 'luxon';
@@ -113,12 +111,6 @@
? MediaType.Video
: MediaType.All,
rating: searchQuery.rating,
sortOrder:
'order' in searchQuery && searchQuery.order
? searchQuery.order === AssetOrder.Asc
? 'oldest'
: 'newest'
: 'best-match',
});
const resetForm = () => {
@@ -138,7 +130,6 @@
},
mediaType: MediaType.All,
rating: undefined,
sortOrder: 'best-match',
};
};
@@ -152,9 +143,6 @@
const query = filter.query || undefined;
const order =
filter.sortOrder === 'newest' ? AssetOrder.Desc : filter.sortOrder === 'oldest' ? AssetOrder.Asc : undefined;
let payload: SmartSearchDto | MetadataSearchDto = {
query: filter.queryType === 'smart' ? query : undefined,
ocr: filter.queryType === 'ocr' ? query : undefined,
@@ -175,7 +163,6 @@
tagIds: filter.tagIds === null ? null : filter.tagIds.size > 0 ? [...filter.tagIds] : undefined,
type,
rating: filter.rating,
order,
};
onClose(payload);
@@ -231,9 +218,6 @@
<!-- DISPLAY OPTIONS -->
<SearchDisplaySection bind:filters={filter.display} />
<!-- SORT ORDER -->
<SearchSortSection bind:sortOrder={filter.sortOrder} />
</div>
</div>
</form>
+13 -3
View File
@@ -1,5 +1,6 @@
import { goto } from '$app/navigation';
import ToastAction from '$lib/components/ToastAction.svelte';
import { AlbumPageViewMode } from '$lib/constants';
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';
@@ -31,7 +32,7 @@ import {
type UserResponseDto,
} from '@immich/sdk';
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
import { mdiLink, mdiPlus, mdiPlusBoxOutline, mdiShareVariantOutline, mdiUpload } from '@mdi/js';
import { mdiArrowLeft, mdiLink, mdiPlus, mdiPlusBoxOutline, mdiShareVariantOutline, mdiUpload } from '@mdi/js';
import { type MessageFormatter } from 'svelte-i18n';
import { get } from 'svelte/store';
@@ -45,7 +46,7 @@ export const getAlbumsActions = ($t: MessageFormatter) => {
return { Create };
};
export const getAlbumActions = ($t: MessageFormatter, album: AlbumResponseDto) => {
export const getAlbumActions = ($t: MessageFormatter, album: AlbumResponseDto, viewMode: AlbumPageViewMode) => {
const isOwned = get(user).id === album.ownerId;
const Share: ActionItem = {
@@ -72,7 +73,16 @@ export const getAlbumActions = ($t: MessageFormatter, album: AlbumResponseDto) =
onAction: () => modalManager.show(SharedLinkCreateModal, { albumId: album.id }),
};
return { Share, AddUsers, CreateSharedLink };
const Close: ActionItem = {
title: $t('go_back'),
type: $t('command'),
icon: mdiArrowLeft,
onAction: () => goto(Route.albums()),
$if: () => viewMode === AlbumPageViewMode.VIEW,
shortcuts: { key: 'Escape' },
};
return { Share, AddUsers, CreateSharedLink, Close };
};
export const getAlbumAssetsActions = ($t: MessageFormatter, album: AlbumResponseDto, assets: TimelineAsset[]) => {
@@ -127,6 +127,10 @@
await handleCloseSelectAssets();
return;
}
if (viewMode === AlbumPageViewMode.OPTIONS) {
viewMode = AlbumPageViewMode.VIEW;
return;
}
if ($showAssetViewer) {
return;
}
@@ -134,7 +138,7 @@
cancelMultiselect(assetInteraction);
return;
}
await goto(Route.albums());
return;
};
const refreshAlbum = async () => {
@@ -301,24 +305,14 @@
return;
}
const albumUsers = album.albumUsers.map((albumUser) =>
album.albumUsers = album.albumUsers.map((albumUser) =>
albumUser.user.id === userId ? { ...albumUser, role } : albumUser,
);
album = { ...album, albumUsers };
};
const { Cast } = $derived(getGlobalActions($t));
const { Share } = $derived(getAlbumActions($t, album));
const { Share, Close } = $derived(getAlbumActions($t, album, viewMode));
const { AddAssets, Upload } = $derived(getAlbumAssetsActions($t, album, timelineInteraction.selectedAssets));
const Close = $derived({
title: $t('go_back'),
type: $t('command'),
icon: mdiArrowLeft,
onAction: handleEscape,
$if: () => !$showAssetViewer,
shortcuts: { key: 'Escape' },
});
</script>
<OnEvents
@@ -358,7 +352,7 @@
id={album.id}
albumName={album.albumName}
{isOwned}
onUpdate={(albumName) => (album = { ...album, albumName })}
onUpdate={(albumName) => (album.albumName = albumName)}
/>
{#if album.assetCount > 0}
@@ -407,11 +401,8 @@
<ActionButton action={Share} />
</div>
{/if}
<AlbumDescription
id={album.id}
{isOwned}
bind:description={() => album.description, (description) => (album = { ...album, description })}
/>
<!-- ALBUM DESCRIPTION -->
<AlbumDescription id={album.id} bind:description={album.description} {isOwned} />
</section>
{/if}
@@ -5,7 +5,6 @@
import OnEvents from '$lib/components/OnEvents.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
import DateGroupedGalleryViewer from '$lib/components/shared-components/gallery-viewer/date-grouped-gallery-viewer.svelte';
import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte';
import SearchBar from '$lib/components/shared-components/search-bar/search-bar.svelte';
import ArchiveAction from '$lib/components/timeline/actions/ArchiveAction.svelte';
@@ -69,7 +68,6 @@
let searchQuery = $derived(page.url.searchParams.get(QueryParameter.QUERY));
let smartSearchEnabled = $derived(featureFlagsManager.value.smartSearch);
let terms = $derived(searchQuery ? JSON.parse(searchQuery) : {});
let isSortedByDate = $derived(!!terms.order);
const isAllUserOwned = $derived(
$user && assetInteraction.selectedAssets.every((asset) => asset.ownerId === $user.id),
@@ -198,7 +196,6 @@
description: $t('description'),
queryAssetId: $t('query_asset_id'),
ocr: $t('ocr'),
order: $t('sort_order'),
};
return keyMap[key] || key;
}
@@ -299,27 +296,15 @@
>
<section id="search-content">
{#if searchResultAssets.length > 0}
{#if isSortedByDate}
<DateGroupedGalleryViewer
assets={searchResultAssets}
{assetInteraction}
onIntersected={loadNextPage}
showArchiveIcon={true}
{viewport}
onReload={onSearchQueryUpdate}
slidingWindowOffset={searchResultsElement.offsetTop}
/>
{:else}
<GalleryViewer
assets={searchResultAssets}
{assetInteraction}
onIntersected={loadNextPage}
showArchiveIcon={true}
{viewport}
onReload={onSearchQueryUpdate}
slidingWindowOffset={searchResultsElement.offsetTop}
/>
{/if}
<GalleryViewer
assets={searchResultAssets}
{assetInteraction}
onIntersected={loadNextPage}
showArchiveIcon={true}
{viewport}
onReload={onSearchQueryUpdate}
slidingWindowOffset={searchResultsElement.offsetTop}
/>
{:else if !isLoading}
<div class="flex min-h-[calc(66vh-11rem)] w-full place-content-center items-center dark:text-white">
<div class="flex flex-col content-center items-center text-center">
@@ -7,7 +7,7 @@
import { getLibrariesActions, getLibraryActions } from '$lib/services/library.service';
import { locale } from '$lib/stores/preferences.store';
import { getBytesWithUnit } from '$lib/utils/byte-units';
import { getLibrary, getLibraryStatistics, type LibraryResponseDto } from '@immich/sdk';
import { getLibrary, getLibraryStatistics, type LibraryResponseDto, type LibraryStatsResponseDto } from '@immich/sdk';
import {
CommandPaletteDefaultProvider,
Container,
@@ -34,9 +34,21 @@
let { children, data }: Props = $props();
let libraries = $state(data.libraries);
let statistics = $state(data.statistics);
let statistics = $state<Record<string, LibraryStatsResponseDto>>({});
let owners = $state(data.owners);
const loadStatistics = async () => {
try {
statistics = await data.statisticsPromise;
} catch (error) {
console.error('Failed to load library statistics:', error);
}
};
$effect(() => {
void loadStatistics();
});
const onLibraryCreate = async (library: LibraryResponseDto) => {
await goto(Route.viewLibrary(library));
};
@@ -94,8 +106,7 @@
</TableHeader>
<TableBody>
{#each libraries as library (library.id + library.name)}
{@const { photos, usage, videos } = statistics[library.id]}
{@const [diskUsage, diskUsageUnit] = getBytesWithUnit(usage, 0)}
{@const stats = statistics[library.id]}
{@const owner = owners[library.id]}
<TableRow>
<TableCell class={classes.column1}>
@@ -104,9 +115,29 @@
<TableCell class={classes.column2}>
<Link href={Route.viewUser(owner)}>{owner.name}</Link>
</TableCell>
<TableCell class={classes.column3}>{photos.toLocaleString($locale)}</TableCell>
<TableCell class={classes.column4}>{videos.toLocaleString($locale)}</TableCell>
<TableCell class={classes.column5}>{diskUsage} {diskUsageUnit}</TableCell>
{#if stats}
<TableCell class={classes.column3}>
{stats.photos.toLocaleString($locale)}
</TableCell>
<TableCell class={classes.column4}>
{stats.videos.toLocaleString($locale)}
</TableCell>
<TableCell class={classes.column5}>
{@const [diskUsage, diskUsageUnit] = getBytesWithUnit(stats.usage, 0)}
{diskUsage}
{diskUsageUnit}
</TableCell>
{:else}
<TableCell class={classes.column3}>
<span class="skeleton-loader inline-block h-4 w-14"></span>
</TableCell>
<TableCell class={classes.column4}>
<span class="skeleton-loader inline-block h-4 w-14"></span>
</TableCell>
<TableCell class={classes.column5}>
<span class="skeleton-loader inline-block h-4 w-20"></span>
</TableCell>
{/if}
<TableCell class={classes.column6}>
<ContextMenuButton color="primary" aria-label={$t('open')} items={getActionsForLibrary(library)} />
</TableCell>
@@ -127,3 +158,37 @@
</div>
</Container>
</AdminPageLayout>
<style>
.skeleton-loader {
position: relative;
border-radius: 4px;
overflow: hidden;
background-color: rgba(156, 163, 175, 0.35);
}
.skeleton-loader::after {
content: '';
position: absolute;
inset: 0;
background-repeat: no-repeat;
background-image: linear-gradient(
90deg,
rgba(255, 255, 255, 0),
rgba(255, 255, 255, 0.8) 50%,
rgba(255, 255, 255, 0)
);
background-size: 200% 100%;
background-position: 200% 0;
animation: skeleton-animation 2000ms infinite;
}
@keyframes skeleton-animation {
from {
background-position: 200% 0;
}
to {
background-position: -200% 0;
}
}
</style>
@@ -10,7 +10,7 @@ export const load = (async ({ url }) => {
const $t = await getFormatter();
const libraries = await getAllLibraries();
const statistics = await Promise.all(
const statisticsPromise = Promise.all(
libraries.map(async ({ id }) => [id, await getLibraryStatistics({ id })] as const),
);
const owners = await Promise.all(
@@ -20,7 +20,7 @@ export const load = (async ({ url }) => {
return {
allUsers,
libraries,
statistics: Object.fromEntries(statistics),
statisticsPromise: statisticsPromise.then((stats) => Object.fromEntries(stats)),
owners: Object.fromEntries(owners),
meta: {
title: $t('external_libraries'),
@@ -15,9 +15,17 @@
getLibraryFolderActions,
} from '$lib/services/library.service';
import { getBytesWithUnit } from '$lib/utils/byte-units';
import type { LibraryResponseDto } from '@immich/sdk';
import type { LibraryResponseDto, LibraryStatsResponseDto } from '@immich/sdk';
import { Code, CommandPaletteDefaultProvider, Container, Heading, modalManager } from '@immich/ui';
import { mdiCameraIris, mdiChartPie, mdiFilterMinusOutline, mdiFolderOutline, mdiPlayCircle } from '@mdi/js';
import {
mdiCameraIris,
mdiChartPie,
mdiFileDocumentRemoveOutline,
mdiFilterMinusOutline,
mdiFolderOutline,
mdiPlayCircle,
} from '@mdi/js';
import type { Snippet } from 'svelte';
import { t } from 'svelte-i18n';
import type { LayoutData } from './$types';
@@ -27,16 +35,28 @@
data: LayoutData;
};
const { children, data }: Props = $props();
let { children, data }: Props = $props();
const statisticsPromise = $derived.by(() => data.statisticsPromise as Promise<LibraryStatsResponseDto>);
const statistics = data.statistics;
const [storageUsage, unit] = getBytesWithUnit(statistics.usage);
const photosPromise = $derived.by(() => statisticsPromise.then((stats) => ({ value: stats.photos })));
let library = $state(data.library);
const videosPromise = $derived.by(() => statisticsPromise.then((stats) => ({ value: stats.videos })));
const usagePromise = $derived.by(() =>
statisticsPromise.then((stats) => {
const [value, unit] = getBytesWithUnit(stats.usage);
return { value, unit };
}),
);
const offlinePromise = $derived.by(() => statisticsPromise.then((stats) => ({ value: stats.offline })));
let updatedLibrary = $state<LibraryResponseDto | undefined>(undefined);
const library = $derived.by(() => (updatedLibrary?.id === data.library.id ? updatedLibrary : data.library));
const onLibraryUpdate = (newLibrary: LibraryResponseDto) => {
if (newLibrary.id === library.id) {
library = newLibrary;
updatedLibrary = newLibrary;
}
};
@@ -61,9 +81,9 @@
<div class="grid gap-4 grid-cols-1 lg:grid-cols-2 w-full">
<Heading tag="h1" size="large" class="col-span-full my-4">{library.name}</Heading>
<div class="flex flex-col lg:flex-row gap-4 col-span-full">
<ServerStatisticsCard icon={mdiCameraIris} title={$t('photos')} value={statistics.photos} />
<ServerStatisticsCard icon={mdiPlayCircle} title={$t('videos')} value={statistics.videos} />
<ServerStatisticsCard icon={mdiChartPie} title={$t('usage')} value={storageUsage} {unit} />
<ServerStatisticsCard icon={mdiCameraIris} title={$t('photos')} valuePromise={photosPromise} />
<ServerStatisticsCard icon={mdiPlayCircle} title={$t('videos')} valuePromise={videosPromise} />
<ServerStatisticsCard icon={mdiChartPie} title={$t('usage')} valuePromise={usagePromise} />
</div>
<AdminCard icon={mdiFolderOutline} title={$t('folders')} headerAction={AddFolder}>
@@ -112,6 +132,10 @@
</tbody>
</table>
</AdminCard>
<div class="flex flex-col lg:flex-row gap-4">
<ServerStatisticsCard icon={mdiFileDocumentRemoveOutline} title={$t('offline')} valuePromise={offlinePromise} />
</div>
</div>
{@render children?.()}
</Container>
@@ -16,12 +16,12 @@ export const load = (async ({ params: { id }, url }) => {
redirect(307, Route.libraries());
}
const statistics = await getLibraryStatistics({ id });
const statisticsPromise = getLibraryStatistics({ id });
const $t = await getFormatter();
return {
library,
statistics,
statisticsPromise,
meta: {
title: $t('admin.library_details'),
},
@@ -1,7 +1,7 @@
<script lang="ts">
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
import ServerStatisticsPanel from '$lib/components/server-statistics/ServerStatisticsPanel.svelte';
import { getServerStatistics } from '@immich/sdk';
import { getServerStatistics, type ServerStatsResponseDto } from '@immich/sdk';
import { Container } from '@immich/ui';
import { onMount } from 'svelte';
import type { PageData } from './$types';
@@ -12,7 +12,14 @@
const { data }: Props = $props();
let stats = $state(data.stats);
let stats = $state<ServerStatsResponseDto | undefined>(undefined);
const statsPromise = $derived.by(() => {
if (stats) {
return Promise.resolve(stats);
}
return data.statsPromise;
});
const updateStatistics = async () => {
stats = await getServerStatistics();
@@ -27,6 +34,6 @@
<AdminPageLayout breadcrumbs={[{ title: data.meta.title }]}>
<Container size="large" center>
<ServerStatisticsPanel {stats} />
<ServerStatisticsPanel {statsPromise} users={data.users} />
</Container>
</AdminPageLayout>
+5 -3
View File
@@ -1,15 +1,17 @@
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { getServerStatistics } from '@immich/sdk';
import { getServerStatistics, searchUsersAdmin } from '@immich/sdk';
import type { PageLoad } from './$types';
export const load = (async ({ url }) => {
await authenticate(url, { admin: true });
const stats = await getServerStatistics();
const statsPromise = getServerStatistics();
const users = await searchUsersAdmin({ withDeleted: false });
const $t = await getFormatter();
return {
stats,
statsPromise,
users,
meta: {
title: $t('server_stats'),
},
+15 -3
View File
@@ -123,9 +123,21 @@
</div>
<div class="col-span-full">
<div class="flex flex-col lg:flex-row gap-4 w-full">
<ServerStatisticsCard icon={mdiCameraIris} title={$t('photos')} value={userStatistics.images} />
<ServerStatisticsCard icon={mdiPlayCircle} title={$t('videos')} value={userStatistics.videos} />
<ServerStatisticsCard icon={mdiChartPie} title={$t('storage')} value={statsUsage} unit={statsUsageUnit} />
<ServerStatisticsCard
icon={mdiCameraIris}
title={$t('photos')}
valuePromise={Promise.resolve({ value: userStatistics.images })}
/>
<ServerStatisticsCard
icon={mdiPlayCircle}
title={$t('videos')}
valuePromise={Promise.resolve({ value: userStatistics.videos })}
/>
<ServerStatisticsCard
icon={mdiChartPie}
title={$t('storage')}
valuePromise={Promise.resolve({ value: statsUsage, unit: statsUsageUnit })}
/>
</div>
</div>