mirror of
https://github.com/immich-app/immich.git
synced 2026-06-04 05:05:22 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8888166928 | |||
| 6982987f3f |
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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": [],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
-459
@@ -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>
|
||||
@@ -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([]);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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[]) => {
|
||||
|
||||
+10
-19
@@ -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>
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user