mirror of
https://github.com/immich-app/immich.git
synced 2025-12-20 01:11:46 +03:00
Compare commits
4 Commits
feat/new-u
...
feat/dynam
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10a8fa792e | ||
|
|
c632d43f5d | ||
|
|
33a114f407 | ||
|
|
01e7f6a01c |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@immich/cli",
|
||||
"version": "2.2.105",
|
||||
"version": "2.2.104",
|
||||
"description": "Command Line Interface (CLI) for Immich",
|
||||
"type": "module",
|
||||
"exports": "./dist/index.js",
|
||||
|
||||
4
docs/static/archived-versions.json
vendored
4
docs/static/archived-versions.json
vendored
@@ -1,8 +1,4 @@
|
||||
[
|
||||
{
|
||||
"label": "v2.4.1",
|
||||
"url": "https://docs.v2.4.1.archive.immich.app"
|
||||
},
|
||||
{
|
||||
"label": "v2.4.0",
|
||||
"url": "https://docs.v2.4.0.archive.immich.app"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-e2e",
|
||||
"version": "2.4.1",
|
||||
"version": "2.4.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "immich-ml"
|
||||
version = "2.4.1"
|
||||
version = "2.4.0"
|
||||
description = ""
|
||||
authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }]
|
||||
requires-python = ">=3.10,<4.0"
|
||||
|
||||
@@ -35,8 +35,8 @@ platform :android do
|
||||
task: 'bundle',
|
||||
build_type: 'Release',
|
||||
properties: {
|
||||
"android.injected.version.code" => 3030,
|
||||
"android.injected.version.name" => "2.4.1",
|
||||
"android.injected.version.code" => 3029,
|
||||
"android.injected.version.name" => "2.4.0",
|
||||
}
|
||||
)
|
||||
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
|
||||
|
||||
@@ -6,8 +6,6 @@ import 'package:immich_mobile/infrastructure/repositories/local_asset.repository
|
||||
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/utils/exif.converter.dart';
|
||||
|
||||
typedef _AssetVideoDimension = ({double? width, double? height, bool isFlipped});
|
||||
|
||||
class AssetService {
|
||||
final RemoteAssetRepository _remoteAssetRepository;
|
||||
final DriftLocalAssetRepository _localAssetRepository;
|
||||
@@ -60,48 +58,44 @@ class AssetService {
|
||||
}
|
||||
|
||||
Future<double> getAspectRatio(BaseAsset asset) async {
|
||||
final dimension = asset is LocalAsset
|
||||
? await _getLocalAssetDimensions(asset)
|
||||
: await _getRemoteAssetDimensions(asset as RemoteAsset);
|
||||
bool isFlipped;
|
||||
double? width;
|
||||
double? height;
|
||||
|
||||
if (dimension.width == null || dimension.height == null || dimension.height == 0) {
|
||||
return 1.0;
|
||||
if (asset.hasRemote) {
|
||||
final exif = await getExif(asset);
|
||||
isFlipped = ExifDtoConverter.isOrientationFlipped(exif?.orientation);
|
||||
width = asset.width?.toDouble();
|
||||
height = asset.height?.toDouble();
|
||||
} else if (asset is LocalAsset) {
|
||||
isFlipped = CurrentPlatform.isAndroid && (asset.orientation == 90 || asset.orientation == 270);
|
||||
width = asset.width?.toDouble();
|
||||
height = asset.height?.toDouble();
|
||||
} else {
|
||||
isFlipped = false;
|
||||
}
|
||||
|
||||
return dimension.isFlipped ? dimension.height! / dimension.width! : dimension.width! / dimension.height!;
|
||||
}
|
||||
|
||||
Future<_AssetVideoDimension> _getLocalAssetDimensions(LocalAsset asset) async {
|
||||
double? width = asset.width?.toDouble();
|
||||
double? height = asset.height?.toDouble();
|
||||
int orientation = asset.orientation;
|
||||
|
||||
if (width == null || height == null) {
|
||||
final fetched = await _localAssetRepository.get(asset.id);
|
||||
width = fetched?.width?.toDouble();
|
||||
height = fetched?.height?.toDouble();
|
||||
orientation = fetched?.orientation ?? 0;
|
||||
if (asset.hasRemote) {
|
||||
final id = asset is LocalAsset ? asset.remoteId! : (asset as RemoteAsset).id;
|
||||
final remoteAsset = await _remoteAssetRepository.get(id);
|
||||
width = remoteAsset?.width?.toDouble();
|
||||
height = remoteAsset?.height?.toDouble();
|
||||
} else {
|
||||
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
|
||||
final localAsset = await _localAssetRepository.get(id);
|
||||
width = localAsset?.width?.toDouble();
|
||||
height = localAsset?.height?.toDouble();
|
||||
}
|
||||
}
|
||||
|
||||
// On Android, local assets need orientation correction for 90°/270° rotations
|
||||
// On iOS, the Photos framework pre-corrects dimensions
|
||||
final isFlipped = CurrentPlatform.isAndroid && (orientation == 90 || orientation == 270);
|
||||
return (width: width, height: height, isFlipped: isFlipped);
|
||||
}
|
||||
|
||||
Future<_AssetVideoDimension> _getRemoteAssetDimensions(RemoteAsset asset) async {
|
||||
double? width = asset.width?.toDouble();
|
||||
double? height = asset.height?.toDouble();
|
||||
|
||||
if (width == null || height == null) {
|
||||
final fetched = await _remoteAssetRepository.get(asset.id);
|
||||
width = fetched?.width?.toDouble();
|
||||
height = fetched?.height?.toDouble();
|
||||
final orientedWidth = isFlipped ? height : width;
|
||||
final orientedHeight = isFlipped ? width : height;
|
||||
if (orientedWidth != null && orientedHeight != null && orientedHeight > 0) {
|
||||
return orientedWidth / orientedHeight;
|
||||
}
|
||||
|
||||
final exif = await getExif(asset);
|
||||
final isFlipped = ExifDtoConverter.isOrientationFlipped(exif?.orientation);
|
||||
return (width: width, height: height, isFlipped: isFlipped);
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
Future<List<(String, String)>> getPlaces(String userId) {
|
||||
|
||||
@@ -12,8 +12,6 @@ import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/map_bottom_sheet.widget.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/map/map.state.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/map/map_utils.dart';
|
||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/utils/async_mutex.dart';
|
||||
import 'package:immich_mobile/utils/debounce.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
@@ -116,14 +114,6 @@ class _DriftMapState extends ConsumerState<DriftMap> {
|
||||
return;
|
||||
}
|
||||
|
||||
// When the AssetViewer is open, the DriftMap route stays alive in the background.
|
||||
// If we continue to update bounds, the map-scoped timeline service gets recreated and the previous one disposed,
|
||||
// which can invalidate the TimelineService instance that was passed into AssetViewerRoute (causing "loading forever").
|
||||
final currentRoute = ref.read(currentRouteNameProvider);
|
||||
if (currentRoute == AssetViewerRoute.name || currentRoute == GalleryViewerRoute.name) {
|
||||
return;
|
||||
}
|
||||
|
||||
final bounds = await controller.getVisibleRegion();
|
||||
unawaited(
|
||||
_reloadMutex.run(() async {
|
||||
|
||||
@@ -1,27 +1,45 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
|
||||
class FixedTimelineRow extends MultiChildRenderObjectWidget {
|
||||
final double dimension;
|
||||
class TimelineRow extends MultiChildRenderObjectWidget {
|
||||
final double height;
|
||||
final List<double> widths;
|
||||
final double spacing;
|
||||
final TextDirection textDirection;
|
||||
|
||||
const FixedTimelineRow({
|
||||
const TimelineRow({
|
||||
super.key,
|
||||
required this.dimension,
|
||||
required this.height,
|
||||
required this.widths,
|
||||
required this.spacing,
|
||||
required this.textDirection,
|
||||
required super.children,
|
||||
});
|
||||
|
||||
factory TimelineRow.fixed({
|
||||
required double dimension,
|
||||
required double spacing,
|
||||
required TextDirection textDirection,
|
||||
required List<Widget> children,
|
||||
}) => TimelineRow(
|
||||
height: dimension,
|
||||
widths: List.filled(children.length, dimension),
|
||||
spacing: spacing,
|
||||
textDirection: textDirection,
|
||||
children: children,
|
||||
);
|
||||
|
||||
@override
|
||||
RenderObject createRenderObject(BuildContext context) {
|
||||
return RenderFixedRow(dimension: dimension, spacing: spacing, textDirection: textDirection);
|
||||
return RenderFixedRow(height: height, widths: widths, spacing: spacing, textDirection: textDirection);
|
||||
}
|
||||
|
||||
@override
|
||||
void updateRenderObject(BuildContext context, RenderFixedRow renderObject) {
|
||||
renderObject.dimension = dimension;
|
||||
renderObject.height = height;
|
||||
renderObject.widths = widths;
|
||||
renderObject.spacing = spacing;
|
||||
renderObject.textDirection = textDirection;
|
||||
}
|
||||
@@ -29,7 +47,8 @@ class FixedTimelineRow extends MultiChildRenderObjectWidget {
|
||||
@override
|
||||
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
||||
super.debugFillProperties(properties);
|
||||
properties.add(DoubleProperty('dimension', dimension));
|
||||
properties.add(DoubleProperty('height', height));
|
||||
properties.add(DiagnosticsProperty<List<double>>('widths', widths));
|
||||
properties.add(DoubleProperty('spacing', spacing));
|
||||
properties.add(EnumProperty<TextDirection>('textDirection', textDirection));
|
||||
}
|
||||
@@ -43,21 +62,32 @@ class RenderFixedRow extends RenderBox
|
||||
RenderBoxContainerDefaultsMixin<RenderBox, _RowParentData> {
|
||||
RenderFixedRow({
|
||||
List<RenderBox>? children,
|
||||
required double dimension,
|
||||
required double height,
|
||||
required List<double> widths,
|
||||
required double spacing,
|
||||
required TextDirection textDirection,
|
||||
}) : _dimension = dimension,
|
||||
}) : _height = height,
|
||||
_widths = widths,
|
||||
_spacing = spacing,
|
||||
_textDirection = textDirection {
|
||||
addAll(children);
|
||||
}
|
||||
|
||||
double get dimension => _dimension;
|
||||
double _dimension;
|
||||
double get height => _height;
|
||||
double _height;
|
||||
|
||||
set dimension(double value) {
|
||||
if (_dimension == value) return;
|
||||
_dimension = value;
|
||||
set height(double value) {
|
||||
if (_height == value) return;
|
||||
_height = value;
|
||||
markNeedsLayout();
|
||||
}
|
||||
|
||||
List<double> get widths => _widths;
|
||||
List<double> _widths;
|
||||
|
||||
set widths(List<double> value) {
|
||||
if (listEquals(_widths, value)) return;
|
||||
_widths = value;
|
||||
markNeedsLayout();
|
||||
}
|
||||
|
||||
@@ -86,7 +116,7 @@ class RenderFixedRow extends RenderBox
|
||||
}
|
||||
}
|
||||
|
||||
double get intrinsicWidth => dimension * childCount + spacing * (childCount - 1);
|
||||
double get intrinsicWidth => widths.sum + (spacing * (childCount - 1));
|
||||
|
||||
@override
|
||||
double computeMinIntrinsicWidth(double height) => intrinsicWidth;
|
||||
@@ -95,10 +125,10 @@ class RenderFixedRow extends RenderBox
|
||||
double computeMaxIntrinsicWidth(double height) => intrinsicWidth;
|
||||
|
||||
@override
|
||||
double computeMinIntrinsicHeight(double width) => dimension;
|
||||
double computeMinIntrinsicHeight(double width) => height;
|
||||
|
||||
@override
|
||||
double computeMaxIntrinsicHeight(double width) => dimension;
|
||||
double computeMaxIntrinsicHeight(double width) => height;
|
||||
|
||||
@override
|
||||
double? computeDistanceToActualBaseline(TextBaseline baseline) {
|
||||
@@ -118,7 +148,8 @@ class RenderFixedRow extends RenderBox
|
||||
@override
|
||||
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
||||
super.debugFillProperties(properties);
|
||||
properties.add(DoubleProperty('dimension', dimension));
|
||||
properties.add(DoubleProperty('height', height));
|
||||
properties.add(DiagnosticsProperty<List<double>>('widths', widths));
|
||||
properties.add(DoubleProperty('spacing', spacing));
|
||||
properties.add(EnumProperty<TextDirection>('textDirection', textDirection));
|
||||
}
|
||||
@@ -131,19 +162,25 @@ class RenderFixedRow extends RenderBox
|
||||
return;
|
||||
}
|
||||
// Use the entire width of the parent for the row.
|
||||
size = Size(constraints.maxWidth, dimension);
|
||||
// Each tile is forced to be dimension x dimension.
|
||||
final childConstraints = BoxConstraints.tight(Size(dimension, dimension));
|
||||
size = Size(constraints.maxWidth, height);
|
||||
|
||||
final flipMainAxis = textDirection == TextDirection.rtl;
|
||||
Offset offset = Offset(flipMainAxis ? size.width - dimension : 0, 0);
|
||||
final dx = (flipMainAxis ? -1 : 1) * (dimension + spacing);
|
||||
int childIndex = 0;
|
||||
double currentX = flipMainAxis ? size.width - (widths.firstOrNull ?? 0) : 0;
|
||||
// Layout each child horizontally.
|
||||
while (child != null) {
|
||||
while (child != null && childIndex < widths.length) {
|
||||
final width = widths[childIndex];
|
||||
final childConstraints = BoxConstraints.tight(Size(width, height));
|
||||
child.layout(childConstraints, parentUsesSize: false);
|
||||
final childParentData = child.parentData! as _RowParentData;
|
||||
childParentData.offset = offset;
|
||||
offset += Offset(dx, 0);
|
||||
childParentData.offset = Offset(currentX, 0);
|
||||
child = childParentData.nextSibling;
|
||||
childIndex++;
|
||||
|
||||
if (child != null && childIndex < widths.length) {
|
||||
final nextWidth = widths[childIndex];
|
||||
currentX += flipMainAxis ? -(spacing + nextWidth) : width + spacing;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
@@ -78,6 +79,7 @@ class FixedSegment extends Segment {
|
||||
assetCount: numberOfAssets,
|
||||
tileHeight: tileHeight,
|
||||
spacing: spacing,
|
||||
columnCount: columnCount,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -87,24 +89,32 @@ class _FixedSegmentRow extends ConsumerWidget {
|
||||
final int assetCount;
|
||||
final double tileHeight;
|
||||
final double spacing;
|
||||
final int columnCount;
|
||||
|
||||
const _FixedSegmentRow({
|
||||
required this.assetIndex,
|
||||
required this.assetCount,
|
||||
required this.tileHeight,
|
||||
required this.spacing,
|
||||
required this.columnCount,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isScrubbing = ref.watch(timelineStateProvider.select((s) => s.isScrubbing));
|
||||
final timelineService = ref.read(timelineServiceProvider);
|
||||
final isDynamicLayout = columnCount <= 3;
|
||||
|
||||
if (isScrubbing) {
|
||||
return _buildPlaceholder(context);
|
||||
}
|
||||
if (timelineService.hasRange(assetIndex, assetCount)) {
|
||||
return _buildAssetRow(context, timelineService.getAssets(assetIndex, assetCount), timelineService);
|
||||
return _buildAssetRow(
|
||||
context,
|
||||
timelineService.getAssets(assetIndex, assetCount),
|
||||
timelineService,
|
||||
isDynamicLayout,
|
||||
);
|
||||
}
|
||||
|
||||
return FutureBuilder<List<BaseAsset>>(
|
||||
@@ -113,7 +123,7 @@ class _FixedSegmentRow extends ConsumerWidget {
|
||||
if (snapshot.connectionState != ConnectionState.done) {
|
||||
return _buildPlaceholder(context);
|
||||
}
|
||||
return _buildAssetRow(context, snapshot.requireData, timelineService);
|
||||
return _buildAssetRow(context, snapshot.requireData, timelineService, isDynamicLayout);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -122,23 +132,58 @@ class _FixedSegmentRow extends ConsumerWidget {
|
||||
return SegmentBuilder.buildPlaceholder(context, assetCount, size: Size.square(tileHeight), spacing: spacing);
|
||||
}
|
||||
|
||||
Widget _buildAssetRow(BuildContext context, List<BaseAsset> assets, TimelineService timelineService) {
|
||||
return FixedTimelineRow(
|
||||
dimension: tileHeight,
|
||||
spacing: spacing,
|
||||
textDirection: Directionality.of(context),
|
||||
children: [
|
||||
for (int i = 0; i < assets.length; i++)
|
||||
TimelineAssetIndexWrapper(
|
||||
Widget _buildAssetRow(
|
||||
BuildContext context,
|
||||
List<BaseAsset> assets,
|
||||
TimelineService timelineService,
|
||||
bool isDynamicLayout,
|
||||
) {
|
||||
final children = [
|
||||
for (int i = 0; i < assets.length; i++)
|
||||
TimelineAssetIndexWrapper(
|
||||
assetIndex: assetIndex + i,
|
||||
segmentIndex: 0, // For simplicity, using 0 for now
|
||||
child: _AssetTileWidget(
|
||||
key: ValueKey(Object.hash(assets[i].heroTag, assetIndex + i, timelineService.hashCode)),
|
||||
asset: assets[i],
|
||||
assetIndex: assetIndex + i,
|
||||
segmentIndex: 0, // For simplicity, using 0 for now
|
||||
child: _AssetTileWidget(
|
||||
key: ValueKey(Object.hash(assets[i].heroTag, assetIndex + i, timelineService.hashCode)),
|
||||
asset: assets[i],
|
||||
assetIndex: assetIndex + i,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
final widths = List.filled(assets.length, tileHeight);
|
||||
|
||||
if (isDynamicLayout) {
|
||||
final aspectRatios = assets.map((e) => (e.width ?? 1) / (e.height ?? 1)).toList();
|
||||
final meanAspectRatio = aspectRatios.sum / assets.length;
|
||||
|
||||
// 1: mean width
|
||||
// 0.5: width < mean - threshold
|
||||
// 1.5: width > mean + threshold
|
||||
final arConfiguration = aspectRatios.map((e) {
|
||||
if (e - meanAspectRatio > 0.3) return 1.5;
|
||||
if (e - meanAspectRatio < -0.3) return 0.5;
|
||||
return 1.0;
|
||||
});
|
||||
|
||||
// Normalize to get width distribution
|
||||
final sum = arConfiguration.sum;
|
||||
|
||||
int index = 0;
|
||||
for (final ratio in arConfiguration) {
|
||||
// Distribute the available width proportionally based on aspect ratio configuration
|
||||
widths[index++] = ((ratio * assets.length) / sum) * tileHeight;
|
||||
}
|
||||
}
|
||||
|
||||
return TimelineDragRegion(
|
||||
child: TimelineRow(
|
||||
height: tileHeight,
|
||||
widths: widths,
|
||||
spacing: spacing,
|
||||
textDirection: Directionality.of(context),
|
||||
children: children,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ abstract class SegmentBuilder {
|
||||
Size size = kTimelineFixedTileExtent,
|
||||
double spacing = kTimelineSpacing,
|
||||
}) => RepaintBoundary(
|
||||
child: FixedTimelineRow(
|
||||
child: TimelineRow.fixed(
|
||||
dimension: size.height,
|
||||
spacing: spacing,
|
||||
textDirection: Directionality.of(context),
|
||||
|
||||
@@ -11,7 +11,6 @@ import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/services/share_intent_service.dart';
|
||||
import 'package:immich_mobile/services/upload.service.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path/path.dart';
|
||||
|
||||
final shareIntentUploadProvider = StateNotifierProvider<ShareIntentUploadStateNotifier, List<ShareIntentAttachment>>(
|
||||
@@ -26,7 +25,6 @@ class ShareIntentUploadStateNotifier extends StateNotifier<List<ShareIntentAttac
|
||||
final AppRouter router;
|
||||
final UploadService _uploadService;
|
||||
final ShareIntentService _shareIntentService;
|
||||
final Logger _logger = Logger('ShareIntentUploadStateNotifier');
|
||||
|
||||
ShareIntentUploadStateNotifier(this.router, this._uploadService, this._shareIntentService) : super([]) {
|
||||
_uploadService.taskStatusStream.listen(_updateUploadStatus);
|
||||
@@ -88,21 +86,6 @@ class ShareIntentUploadStateNotifier extends StateNotifier<List<ShareIntentAttac
|
||||
for (final attachment in state)
|
||||
if (attachment.id == taskId.toInt()) attachment.copyWith(status: uploadStatus) else attachment,
|
||||
];
|
||||
|
||||
if (task.status == TaskStatus.failed) {
|
||||
String? error;
|
||||
final exception = task.exception;
|
||||
if (exception != null && exception is TaskHttpException) {
|
||||
final message = tryJsonDecode(exception.description)?['message'] as String?;
|
||||
if (message != null) {
|
||||
final responseCode = exception.httpResponseCode;
|
||||
error = "${exception.exceptionType}, response code $responseCode: $message";
|
||||
}
|
||||
}
|
||||
error ??= task.exception?.toString();
|
||||
|
||||
_logger.warning("Upload failed for asset: ${task.task.filename}, error: $error");
|
||||
}
|
||||
}
|
||||
|
||||
void _taskProgressCallback(TaskProgressUpdate update) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
|
||||
@@ -20,11 +21,12 @@ class LayoutSettings extends HookConsumerWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SettingsSubTitle(title: "asset_list_layout_sub_title".tr()),
|
||||
SettingsSwitchListTile(
|
||||
valueNotifier: useDynamicLayout,
|
||||
title: "asset_list_layout_settings_dynamic_layout_title".tr(),
|
||||
onChanged: (_) => ref.invalidate(appSettingsServiceProvider),
|
||||
),
|
||||
if (!Store.isBetaTimelineEnabled)
|
||||
SettingsSwitchListTile(
|
||||
valueNotifier: useDynamicLayout,
|
||||
title: "asset_list_layout_settings_dynamic_layout_title".tr(),
|
||||
onChanged: (_) => ref.invalidate(appSettingsServiceProvider),
|
||||
),
|
||||
SettingsSliderListTile(
|
||||
valueNotifier: tilesPerRow,
|
||||
text: 'theme_setting_asset_list_tiles_per_row_title'.tr(namedArgs: {'count': "${tilesPerRow.value}"}),
|
||||
|
||||
2
mobile/openapi/README.md
generated
2
mobile/openapi/README.md
generated
@@ -3,7 +3,7 @@ Immich API
|
||||
|
||||
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
|
||||
|
||||
- API version: 2.4.1
|
||||
- API version: 2.4.0
|
||||
- Generator version: 7.8.0
|
||||
- Build package: org.openapitools.codegen.languages.DartClientCodegen
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ name: immich_mobile
|
||||
description: Immich - selfhosted backup media file on mobile phone
|
||||
|
||||
publish_to: 'none'
|
||||
version: 2.4.1+3030
|
||||
version: 2.4.0+3029
|
||||
|
||||
environment:
|
||||
sdk: '>=3.8.0 <4.0.0'
|
||||
|
||||
@@ -87,25 +87,6 @@ void main() {
|
||||
verify(() => mockLocalAssetRepository.get('local-1')).called(1);
|
||||
});
|
||||
|
||||
test('uses fetched asset orientation when dimensions are missing on Android', () async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
|
||||
// Original asset has default orientation 0, but dimensions are missing
|
||||
final localAsset = TestUtils.createLocalAsset(id: 'local-1', width: null, height: null, orientation: 0);
|
||||
|
||||
// Fetched asset has 90° orientation and proper dimensions
|
||||
final fetchedAsset = TestUtils.createLocalAsset(id: 'local-1', width: 1920, height: 1080, orientation: 90);
|
||||
|
||||
when(() => mockLocalAssetRepository.get('local-1')).thenAnswer((_) async => fetchedAsset);
|
||||
|
||||
final result = await sut.getAspectRatio(localAsset);
|
||||
|
||||
// Should flip dimensions since fetched asset has 90° orientation
|
||||
expect(result, 1080 / 1920);
|
||||
verify(() => mockLocalAssetRepository.get('local-1')).called(1);
|
||||
});
|
||||
|
||||
test('returns 1.0 when dimensions are still unavailable after fetching', () async {
|
||||
final remoteAsset = TestUtils.createRemoteAsset(id: 'remote-1', width: null, height: null);
|
||||
|
||||
@@ -131,9 +112,7 @@ void main() {
|
||||
expect(result, 1.0);
|
||||
});
|
||||
|
||||
test('handles local asset with remoteId using local orientation not remote exif', () async {
|
||||
// When a LocalAsset has a remoteId (merged), we should use local orientation
|
||||
// because the width/height come from the local asset (pre-corrected on iOS)
|
||||
test('handles local asset with remoteId and uses exif from remote', () async {
|
||||
final localAsset = TestUtils.createLocalAsset(
|
||||
id: 'local-1',
|
||||
remoteId: 'remote-1',
|
||||
@@ -142,24 +121,9 @@ void main() {
|
||||
orientation: 0,
|
||||
);
|
||||
|
||||
final result = await sut.getAspectRatio(localAsset);
|
||||
final exif = const ExifInfo(orientation: '6');
|
||||
|
||||
expect(result, 1920 / 1080);
|
||||
// Should not call remote exif for LocalAsset
|
||||
verifyNever(() => mockRemoteAssetRepository.getExif(any()));
|
||||
});
|
||||
|
||||
test('handles local asset with remoteId and 90 degree rotation on Android', () async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
|
||||
final localAsset = TestUtils.createLocalAsset(
|
||||
id: 'local-1',
|
||||
remoteId: 'remote-1',
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
orientation: 90,
|
||||
);
|
||||
when(() => mockRemoteAssetRepository.getExif('remote-1')).thenAnswer((_) async => exif);
|
||||
|
||||
final result = await sut.getAspectRatio(localAsset);
|
||||
|
||||
|
||||
@@ -14268,7 +14268,7 @@
|
||||
"info": {
|
||||
"title": "Immich",
|
||||
"description": "Immich API",
|
||||
"version": "2.4.1",
|
||||
"version": "2.4.0",
|
||||
"contact": {}
|
||||
},
|
||||
"tags": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@immich/sdk",
|
||||
"version": "2.4.1",
|
||||
"version": "2.4.0",
|
||||
"description": "Auto-generated TypeScript SDK for the Immich API",
|
||||
"type": "module",
|
||||
"main": "./build/index.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Immich
|
||||
* 2.4.1
|
||||
* 2.4.0
|
||||
* DO NOT MODIFY - This file has been generated using oazapfts.
|
||||
* See https://www.npmjs.com/package/oazapfts
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich",
|
||||
"version": "2.4.1",
|
||||
"version": "2.4.0",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
|
||||
@@ -7,22 +7,14 @@ export const ImmichFooter = () => (
|
||||
<Column align="center" className="w-6/12 sm:w-full">
|
||||
<div>
|
||||
<Link href="https://play.google.com/store/apps/details?id=app.alextran.immich" className="object-contain">
|
||||
<Img
|
||||
alt="Get it on Google Play"
|
||||
className="max-w-full"
|
||||
src={`https://immich.app/img/google-play-badge.png`}
|
||||
/>
|
||||
<Img className="max-w-full" src={`https://immich.app/img/google-play-badge.png`} />
|
||||
</Link>
|
||||
</div>
|
||||
</Column>
|
||||
<Column align="center" className="w-6/12 sm:w-full">
|
||||
<div className="h-full p-6">
|
||||
<Link href="https://apps.apple.com/sg/app/immich/id1613945652">
|
||||
<Img
|
||||
alt="Download on the App Store"
|
||||
className="max-w-full"
|
||||
src={`https://immich.app/img/ios-app-store-badge.png`}
|
||||
/>
|
||||
<Img src={`https://immich.app/img/ios-app-store-badge.png`} alt="Immich" className="max-w-full" />
|
||||
</Link>
|
||||
</div>
|
||||
</Column>
|
||||
|
||||
@@ -144,28 +144,14 @@ export class AssetService extends BaseService {
|
||||
await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids });
|
||||
|
||||
const assetDto = _.omitBy({ isFavorite, visibility, duplicateId }, _.isUndefined);
|
||||
const exifDto = _.omitBy(
|
||||
{
|
||||
latitude,
|
||||
longitude,
|
||||
rating,
|
||||
description,
|
||||
dateTimeOriginal,
|
||||
},
|
||||
_.isUndefined,
|
||||
);
|
||||
const extractedTimeZone = dateTimeOriginal ? DateTime.fromISO(dateTimeOriginal, { setZone: true }).zone : undefined;
|
||||
const exifDto = _.omitBy({ latitude, longitude, rating, description, dateTimeOriginal }, _.isUndefined);
|
||||
|
||||
if (Object.keys(exifDto).length > 0) {
|
||||
await this.assetRepository.updateAllExif(ids, exifDto);
|
||||
}
|
||||
|
||||
if (
|
||||
(dateTimeRelative !== undefined && dateTimeRelative !== 0) ||
|
||||
timeZone !== undefined ||
|
||||
extractedTimeZone?.type === 'fixed'
|
||||
) {
|
||||
await this.assetRepository.updateDateTimeOriginal(ids, dateTimeRelative, timeZone ?? extractedTimeZone?.name);
|
||||
if ((dateTimeRelative !== undefined && dateTimeRelative !== 0) || timeZone !== undefined) {
|
||||
await this.assetRepository.updateDateTimeOriginal(ids, dateTimeRelative, timeZone);
|
||||
}
|
||||
|
||||
if (Object.keys(assetDto).length > 0) {
|
||||
@@ -450,19 +436,7 @@ export class AssetService extends BaseService {
|
||||
rating?: number;
|
||||
}) {
|
||||
const { id, description, dateTimeOriginal, latitude, longitude, rating } = dto;
|
||||
const extractedTimeZone = dateTimeOriginal ? DateTime.fromISO(dateTimeOriginal, { setZone: true }).zone : undefined;
|
||||
const writes = _.omitBy(
|
||||
{
|
||||
description,
|
||||
dateTimeOriginal,
|
||||
timeZone: extractedTimeZone?.type === 'fixed' ? extractedTimeZone.name : undefined,
|
||||
latitude,
|
||||
longitude,
|
||||
rating,
|
||||
},
|
||||
_.isUndefined,
|
||||
);
|
||||
|
||||
const writes = _.omitBy({ description, dateTimeOriginal, latitude, longitude, rating }, _.isUndefined);
|
||||
if (Object.keys(writes).length > 0) {
|
||||
await this.assetRepository.upsertExif(
|
||||
updateLockedColumns({
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import { Kysely } from 'kysely';
|
||||
import { AssetRepository } from 'src/repositories/asset.repository';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { DB } from 'src/schema';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { newMediumService } from 'test/medium.factory';
|
||||
import { getKyselyDB } from 'test/utils';
|
||||
|
||||
let defaultDatabase: Kysely<DB>;
|
||||
|
||||
const setup = (db?: Kysely<DB>) => {
|
||||
const { ctx } = newMediumService(BaseService, {
|
||||
database: db || defaultDatabase,
|
||||
real: [],
|
||||
mock: [LoggingRepository],
|
||||
});
|
||||
return { ctx, sut: ctx.get(AssetRepository) };
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
defaultDatabase = await getKyselyDB();
|
||||
});
|
||||
|
||||
describe(AssetRepository.name, () => {
|
||||
describe('upsertExif', () => {
|
||||
it('should append to locked columns', async () => {
|
||||
const { ctx, sut } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
await ctx.newExif({
|
||||
assetId: asset.id,
|
||||
dateTimeOriginal: '2023-11-19T18:11:00',
|
||||
lockedProperties: ['dateTimeOriginal'],
|
||||
});
|
||||
|
||||
await expect(
|
||||
ctx.database
|
||||
.selectFrom('asset_exif')
|
||||
.select('lockedProperties')
|
||||
.where('assetId', '=', asset.id)
|
||||
.executeTakeFirstOrThrow(),
|
||||
).resolves.toEqual({ lockedProperties: ['dateTimeOriginal'] });
|
||||
|
||||
await sut.upsertExif(
|
||||
{ assetId: asset.id, lockedProperties: ['description'] },
|
||||
{ lockedPropertiesBehavior: 'append' },
|
||||
);
|
||||
|
||||
await expect(
|
||||
ctx.database
|
||||
.selectFrom('asset_exif')
|
||||
.select('lockedProperties')
|
||||
.where('assetId', '=', asset.id)
|
||||
.executeTakeFirstOrThrow(),
|
||||
).resolves.toEqual({ lockedProperties: ['description', 'dateTimeOriginal'] });
|
||||
});
|
||||
|
||||
it('should deduplicate locked columns', async () => {
|
||||
const { ctx, sut } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
await ctx.newExif({
|
||||
assetId: asset.id,
|
||||
dateTimeOriginal: '2023-11-19T18:11:00',
|
||||
lockedProperties: ['dateTimeOriginal', 'description'],
|
||||
});
|
||||
|
||||
await expect(
|
||||
ctx.database
|
||||
.selectFrom('asset_exif')
|
||||
.select('lockedProperties')
|
||||
.where('assetId', '=', asset.id)
|
||||
.executeTakeFirstOrThrow(),
|
||||
).resolves.toEqual({ lockedProperties: ['dateTimeOriginal', 'description'] });
|
||||
|
||||
await sut.upsertExif(
|
||||
{ assetId: asset.id, lockedProperties: ['description'] },
|
||||
{ lockedPropertiesBehavior: 'append' },
|
||||
);
|
||||
|
||||
await expect(
|
||||
ctx.database
|
||||
.selectFrom('asset_exif')
|
||||
.select('lockedProperties')
|
||||
.where('assetId', '=', asset.id)
|
||||
.executeTakeFirstOrThrow(),
|
||||
).resolves.toEqual({ lockedProperties: ['description', 'dateTimeOriginal'] });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -270,41 +270,6 @@ describe(AssetService.name, () => {
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should automatically lock lockable columns', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
ctx.getMock(JobRepository).queue.mockResolvedValue();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
await ctx.newExif({ assetId: asset.id, dateTimeOriginal: '2023-11-19T18:11:00' });
|
||||
|
||||
await expect(
|
||||
ctx.database
|
||||
.selectFrom('asset_exif')
|
||||
.select('lockedProperties')
|
||||
.where('assetId', '=', asset.id)
|
||||
.executeTakeFirstOrThrow(),
|
||||
).resolves.toEqual({ lockedProperties: null });
|
||||
|
||||
await sut.update(auth, asset.id, {
|
||||
latitude: 42,
|
||||
longitude: 42,
|
||||
rating: 3,
|
||||
description: 'foo',
|
||||
dateTimeOriginal: '2023-11-19T18:11:00+01:00',
|
||||
});
|
||||
|
||||
await expect(
|
||||
ctx.database
|
||||
.selectFrom('asset_exif')
|
||||
.select('lockedProperties')
|
||||
.where('assetId', '=', asset.id)
|
||||
.executeTakeFirstOrThrow(),
|
||||
).resolves.toEqual({
|
||||
lockedProperties: ['timeZone', 'rating', 'description', 'latitude', 'longitude', 'dateTimeOriginal'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should update dateTimeOriginal', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
ctx.getMock(JobRepository).queue.mockResolvedValue();
|
||||
@@ -313,42 +278,6 @@ describe(AssetService.name, () => {
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
await ctx.newExif({ assetId: asset.id, description: 'test' });
|
||||
|
||||
await sut.update(auth, asset.id, { dateTimeOriginal: '2023-11-19T18:11:00' });
|
||||
|
||||
await expect(ctx.get(AssetRepository).getById(asset.id, { exifInfo: true })).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
exifInfo: expect.objectContaining({ dateTimeOriginal: '2023-11-19T18:11:00+00:00', timeZone: null }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should update dateTimeOriginal with time zone', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
ctx.getMock(JobRepository).queue.mockResolvedValue();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
await ctx.newExif({ assetId: asset.id, description: 'test' });
|
||||
|
||||
await sut.update(auth, asset.id, { dateTimeOriginal: '2023-11-19T18:11:00.000-07:00' });
|
||||
|
||||
await expect(ctx.get(AssetRepository).getById(asset.id, { exifInfo: true })).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
exifInfo: expect.objectContaining({ dateTimeOriginal: '2023-11-20T01:11:00+00:00', timeZone: 'UTC-7' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateAll', () => {
|
||||
it('should automatically lock lockable columns', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
ctx.getMock(JobRepository).queueAll.mockResolvedValue();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
await ctx.newExif({ assetId: asset.id, dateTimeOriginal: '2023-11-19T18:11:00' });
|
||||
|
||||
await expect(
|
||||
ctx.database
|
||||
.selectFrom('asset_exif')
|
||||
@@ -356,15 +285,7 @@ describe(AssetService.name, () => {
|
||||
.where('assetId', '=', asset.id)
|
||||
.executeTakeFirstOrThrow(),
|
||||
).resolves.toEqual({ lockedProperties: null });
|
||||
|
||||
await sut.updateAll(auth, {
|
||||
ids: [asset.id],
|
||||
latitude: 42,
|
||||
description: 'foo',
|
||||
longitude: 42,
|
||||
rating: 3,
|
||||
dateTimeOriginal: '2023-11-19T18:11:00+01:00',
|
||||
});
|
||||
await sut.update(auth, asset.id, { dateTimeOriginal: '2023-11-19T18:11:00.000-07:00' });
|
||||
|
||||
await expect(
|
||||
ctx.database
|
||||
@@ -372,11 +293,16 @@ describe(AssetService.name, () => {
|
||||
.select('lockedProperties')
|
||||
.where('assetId', '=', asset.id)
|
||||
.executeTakeFirstOrThrow(),
|
||||
).resolves.toEqual({
|
||||
lockedProperties: ['timeZone', 'rating', 'description', 'latitude', 'longitude', 'dateTimeOriginal'],
|
||||
});
|
||||
).resolves.toEqual({ lockedProperties: ['dateTimeOriginal'] });
|
||||
await expect(ctx.get(AssetRepository).getById(asset.id, { exifInfo: true })).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
exifInfo: expect.objectContaining({ dateTimeOriginal: '2023-11-20T01:11:00+00:00' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateAll', () => {
|
||||
it('should relatively update assets', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
ctx.getMock(JobRepository).queueAll.mockResolvedValue();
|
||||
@@ -387,6 +313,13 @@ describe(AssetService.name, () => {
|
||||
|
||||
await sut.updateAll(auth, { ids: [asset.id], dateTimeRelative: -11 });
|
||||
|
||||
await expect(
|
||||
ctx.database
|
||||
.selectFrom('asset_exif')
|
||||
.select('lockedProperties')
|
||||
.where('assetId', '=', asset.id)
|
||||
.executeTakeFirstOrThrow(),
|
||||
).resolves.toEqual({ lockedProperties: ['timeZone', 'dateTimeOriginal'] });
|
||||
await expect(ctx.get(AssetRepository).getById(asset.id, { exifInfo: true })).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
exifInfo: expect.objectContaining({
|
||||
@@ -395,39 +328,5 @@ describe(AssetService.name, () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should update dateTimeOriginal', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
ctx.getMock(JobRepository).queueAll.mockResolvedValue();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
await ctx.newExif({ assetId: asset.id, description: 'test' });
|
||||
|
||||
await sut.updateAll(auth, { ids: [asset.id], dateTimeOriginal: '2023-11-19T18:11:00' });
|
||||
|
||||
await expect(ctx.get(AssetRepository).getById(asset.id, { exifInfo: true })).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
exifInfo: expect.objectContaining({ dateTimeOriginal: '2023-11-19T18:11:00+00:00', timeZone: null }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should update dateTimeOriginal with time zone', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
ctx.getMock(JobRepository).queueAll.mockResolvedValue();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
await ctx.newExif({ assetId: asset.id, description: 'test' });
|
||||
|
||||
await sut.updateAll(auth, { ids: [asset.id], dateTimeOriginal: '2023-11-19T18:11:00.000-07:00' });
|
||||
|
||||
await expect(ctx.get(AssetRepository).getById(asset.id, { exifInfo: true })).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
exifInfo: expect.objectContaining({ dateTimeOriginal: '2023-11-20T01:11:00+00:00', timeZone: 'UTC-7' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-web",
|
||||
"version": "2.4.1",
|
||||
"version": "2.4.0",
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
<script lang="ts">
|
||||
import HeaderActionButton from '$lib/components/HeaderActionButton.svelte';
|
||||
import { Card, CardBody, CardHeader, CardTitle, Icon, type ActionItem, type IconLike } from '@immich/ui';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
type Props = {
|
||||
icon: IconLike;
|
||||
title: string;
|
||||
headerAction?: ActionItem;
|
||||
children?: Snippet;
|
||||
};
|
||||
|
||||
const { icon, title, headerAction, children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<Card color="secondary">
|
||||
<CardHeader>
|
||||
<div class="flex w-full justify-between items-center px-4 py-2">
|
||||
<div class="flex gap-2 text-primary">
|
||||
<Icon {icon} size="1.5rem" />
|
||||
<CardTitle>{title}</CardTitle>
|
||||
</div>
|
||||
{#if headerAction}
|
||||
<HeaderActionButton action={headerAction} />
|
||||
{/if}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div class="px-4 pb-7">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
@@ -308,6 +308,18 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="absolute inset-y-0 {showClearIcon ? 'end-14' : 'end-2'} flex items-center ps-6 transition-all">
|
||||
<IconButton
|
||||
aria-label={$t('show_search_options')}
|
||||
shape="round"
|
||||
icon={mdiTune}
|
||||
onclick={onFilterClick}
|
||||
size="medium"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if searchStore.isSearchEnabled}
|
||||
<div
|
||||
id={searchTypeId}
|
||||
@@ -315,7 +327,7 @@
|
||||
class:max-md:hidden={value}
|
||||
class:end-28={value.length > 0}
|
||||
>
|
||||
<div class="relative" use:focusOutside={{ onFocusOut: closeSearchTypeDropdown }}>
|
||||
<div class="relative">
|
||||
<Button
|
||||
class="bg-immich-primary text-white dark:bg-immich-dark-primary/90 dark:text-black/75 rounded-full px-3 py-1 text-xs hover:opacity-80 transition-opacity cursor-pointer"
|
||||
onclick={toggleSearchTypeDropdown}
|
||||
@@ -328,11 +340,11 @@
|
||||
{#if showSearchTypeDropdown}
|
||||
<div
|
||||
class="absolute top-full right-0 mt-1 bg-white dark:bg-immich-dark-gray border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg py-1 min-w-32 z-9999"
|
||||
use:focusOutside={{ onFocusOut: closeSearchTypeDropdown }}
|
||||
>
|
||||
{#each searchTypes as searchType (searchType.value)}
|
||||
<button
|
||||
type="button"
|
||||
tabindex="0"
|
||||
class="w-full text-left px-3 py-2 text-xs hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors
|
||||
{currentSearchType === searchType.value ? 'bg-gray-100 dark:bg-gray-700' : ''}"
|
||||
onclick={() => selectSearchType(searchType.value)}
|
||||
@@ -346,18 +358,6 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="absolute inset-y-0 {showClearIcon ? 'end-14' : 'end-2'} flex items-center ps-6 transition-all">
|
||||
<IconButton
|
||||
aria-label={$t('show_search_options')}
|
||||
shape="round"
|
||||
icon={mdiTune}
|
||||
onclick={onFilterClick}
|
||||
size="medium"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if showClearIcon}
|
||||
<div class="absolute inset-y-0 end-0 flex items-center pe-2">
|
||||
<IconButton
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
export const UUID_REGEX = /^[\dA-Fa-f]{8}(?:\b-[\dA-Fa-f]{4}){3}\b-[\dA-Fa-f]{12}$/;
|
||||
|
||||
export enum AssetAction {
|
||||
ARCHIVE = 'archive',
|
||||
UNARCHIVE = 'unarchive',
|
||||
@@ -22,9 +20,7 @@ export enum AssetAction {
|
||||
|
||||
export enum AppRoute {
|
||||
ADMIN_USERS = '/admin/users',
|
||||
ADMIN_USERS_NEW = '/admin/users/new',
|
||||
ADMIN_LIBRARIES = '/admin/library-management',
|
||||
ADMIN_LIBRARIES_NEW = '/admin/library-management/new',
|
||||
ADMIN_LIBRARY_MANAGEMENT = '/admin/library-management',
|
||||
ADMIN_SETTINGS = '/admin/system-settings',
|
||||
ADMIN_STATS = '/admin/server-status',
|
||||
ADMIN_QUEUES = '/admin/queues',
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<Modal title={$t('api_key')} icon={mdiKeyVariant} {onClose} size="small">
|
||||
<ModalBody>
|
||||
<Text size="small" class="mb-4">{$t('api_key_description')}</Text>
|
||||
<Textarea bind:value={secret} readonly class="font-mono" />
|
||||
<Textarea bind:value={secret} readonly />
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
|
||||
@@ -71,34 +71,6 @@ describe('DateSelectionModal component', () => {
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('does not fall back to UTC when datetime-local value has no seconds', async () => {
|
||||
render(AssetSelectionChangeDateModal, {
|
||||
props: { initialDate, initialTimeZone, assets: [], onClose },
|
||||
});
|
||||
|
||||
await fireEvent.input(getDateInput(), { target: { value: '2024-01-01T00:00' } });
|
||||
await fireEvent.blur(getDateInput());
|
||||
|
||||
expect(getTimeZoneInput().value).toBe('Europe/Berlin (+01:00)');
|
||||
|
||||
await fireEvent.focus(getTimeZoneInput());
|
||||
expect(screen.queryByText('no_results')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not fall back to UTC when datetime-local value has no milliseconds', async () => {
|
||||
render(AssetSelectionChangeDateModal, {
|
||||
props: { initialDate, initialTimeZone, assets: [], onClose },
|
||||
});
|
||||
|
||||
await fireEvent.input(getDateInput(), { target: { value: '2024-01-01T00:00:00' } });
|
||||
await fireEvent.blur(getDateInput());
|
||||
|
||||
expect(getTimeZoneInput().value).toBe('Europe/Berlin (+01:00)');
|
||||
|
||||
await fireEvent.focus(getTimeZoneInput());
|
||||
expect(screen.queryByText('no_results')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('when date is in daylight saving time', () => {
|
||||
const dstDate = DateTime.fromISO('2024-07-01');
|
||||
|
||||
|
||||
41
web/src/lib/modals/LibraryRenameModal.svelte
Normal file
41
web/src/lib/modals/LibraryRenameModal.svelte
Normal file
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import { handleRenameLibrary } from '$lib/services/library.service';
|
||||
import type { LibraryResponseDto } from '@immich/sdk';
|
||||
import { Button, Field, HStack, Input, Modal, ModalBody, ModalFooter } from '@immich/ui';
|
||||
import { mdiRenameOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
library: LibraryResponseDto;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
let { library, onClose }: Props = $props();
|
||||
|
||||
let newName = $state(library.name);
|
||||
|
||||
const onsubmit = async () => {
|
||||
const success = await handleRenameLibrary(library, newName);
|
||||
|
||||
if (success) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<Modal icon={mdiRenameOutline} title={$t('rename')} {onClose} size="small">
|
||||
<ModalBody>
|
||||
<form {onsubmit} autocomplete="off" id="rename-library-form">
|
||||
<Field label={$t('name')}>
|
||||
<Input bind:value={newName} />
|
||||
</Field>
|
||||
</form>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<HStack fullWidth>
|
||||
<Button shape="round" fullWidth color="secondary" onclick={() => onClose()}>{$t('cancel')}</Button>
|
||||
<Button shape="round" fullWidth type="submit" form="rename-library-form">{$t('save')}</Button>
|
||||
</HStack>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
46
web/src/lib/modals/LibraryUserPickerModal.svelte
Normal file
46
web/src/lib/modals/LibraryUserPickerModal.svelte
Normal file
@@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { searchUsersAdmin } from '@immich/sdk';
|
||||
import { Button, HStack, Modal, ModalBody, ModalFooter } from '@immich/ui';
|
||||
import { mdiFolderSync } from '@mdi/js';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
onClose: (ownerId?: string) => void;
|
||||
}
|
||||
|
||||
let { onClose }: Props = $props();
|
||||
|
||||
let ownerId: string = $state($user.id);
|
||||
|
||||
let userOptions: { value: string; text: string }[] = $state([]);
|
||||
|
||||
onMount(async () => {
|
||||
const users = await searchUsersAdmin({});
|
||||
userOptions = users.map((user) => ({ value: user.id, text: user.name }));
|
||||
});
|
||||
|
||||
const onsubmit = (event: Event) => {
|
||||
event.preventDefault();
|
||||
onClose(ownerId);
|
||||
};
|
||||
</script>
|
||||
|
||||
<Modal title={$t('select_library_owner')} icon={mdiFolderSync} {onClose} size="small">
|
||||
<ModalBody>
|
||||
<form {onsubmit} autocomplete="off" id="select-library-owner-form">
|
||||
<p class="p-5 text-sm">{$t('admin.note_cannot_be_changed_later')}</p>
|
||||
|
||||
<SettingSelect bind:value={ownerId} options={userOptions} name="user" />
|
||||
</form>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<HStack fullWidth>
|
||||
<Button shape="round" color="secondary" fullWidth onclick={() => onClose()}>{$t('cancel')}</Button>
|
||||
<Button shape="round" type="submit" fullWidth form="select-library-owner-form">{$t('create')}</Button>
|
||||
</HStack>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
@@ -14,7 +14,7 @@
|
||||
} from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import SettingDropdown from '../components/shared-components/settings/setting-dropdown.svelte';
|
||||
import { SlideshowLook, SlideshowNavigation, SlideshowState, slideshowStore } from '../stores/slideshow.store';
|
||||
import { SlideshowLook, SlideshowNavigation, slideshowStore } from '../stores/slideshow.store';
|
||||
|
||||
const {
|
||||
slideshowDelay,
|
||||
@@ -23,7 +23,6 @@
|
||||
slideshowLook,
|
||||
slideshowTransition,
|
||||
slideshowAutoplay,
|
||||
slideshowState,
|
||||
} = slideshowStore;
|
||||
|
||||
interface Props {
|
||||
@@ -70,7 +69,6 @@
|
||||
$slideshowLook = tempSlideshowLook;
|
||||
$slideshowTransition = tempSlideshowTransition;
|
||||
$slideshowAutoplay = tempSlideshowAutoplay;
|
||||
$slideshowState = SlideshowState.PlaySlideshow;
|
||||
onClose();
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { handleCreateUserAdmin } from '$lib/services/user-admin.service';
|
||||
import { userInteraction } from '$lib/stores/user.svelte';
|
||||
@@ -20,6 +18,12 @@
|
||||
} from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
let { onClose }: Props = $props();
|
||||
|
||||
let success = $state(false);
|
||||
|
||||
let email = $state('');
|
||||
@@ -42,10 +46,6 @@
|
||||
const passwordMismatchMessage = $derived(passwordMismatch ? $t('password_does_not_match') : '');
|
||||
const valid = $derived(!passwordMismatch && !isCreatingUser);
|
||||
|
||||
const onClose = async () => {
|
||||
await goto(AppRoute.ADMIN_USERS);
|
||||
};
|
||||
|
||||
const onSubmit = async (event: Event) => {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
|
||||
isCreatingUser = true;
|
||||
|
||||
const user = await handleCreateUserAdmin({
|
||||
const success = await handleCreateUserAdmin({
|
||||
email,
|
||||
password,
|
||||
shouldChangePassword,
|
||||
@@ -65,8 +65,8 @@
|
||||
isAdmin,
|
||||
});
|
||||
|
||||
if (user) {
|
||||
await goto(`${AppRoute.ADMIN_USERS}/${user.id}`, { replaceState: true });
|
||||
if (success) {
|
||||
onClose();
|
||||
}
|
||||
|
||||
isCreatingUser = false;
|
||||
@@ -1,11 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { handleUpdateUserAdmin } from '$lib/services/user-admin.service';
|
||||
import { user as authUser } from '$lib/stores/user.store';
|
||||
import { userInteraction } from '$lib/stores/user.svelte';
|
||||
import { ByteUnit, convertFromBytes, convertToBytes } from '$lib/utils/byte-units';
|
||||
import type { UserAdminResponseDto } from '@immich/sdk';
|
||||
import { type UserAdminResponseDto } from '@immich/sdk';
|
||||
import {
|
||||
Button,
|
||||
Field,
|
||||
@@ -21,23 +20,22 @@
|
||||
} from '@immich/ui';
|
||||
import { mdiAccountEditOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
type Props = {
|
||||
data: PageData;
|
||||
};
|
||||
interface Props {
|
||||
user: UserAdminResponseDto;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
let { user, onClose }: Props = $props();
|
||||
|
||||
const user = $derived(data.user as UserAdminResponseDto);
|
||||
let isAdmin = $derived(user.isAdmin);
|
||||
let name = $derived(user.name);
|
||||
let email = $derived(user.email);
|
||||
let storageLabel = $derived(user.storageLabel || '');
|
||||
|
||||
const previousQuota = $derived(user.quotaSizeInBytes);
|
||||
const previousQuota = user.quotaSizeInBytes;
|
||||
|
||||
let quotaSize = $derived(
|
||||
let quotaSize = $state(
|
||||
typeof user.quotaSizeInBytes === 'number' ? convertFromBytes(user.quotaSizeInBytes, ByteUnit.GiB) : undefined,
|
||||
);
|
||||
|
||||
@@ -50,10 +48,6 @@
|
||||
quotaSizeBytes > userInteraction.serverInfo.diskSizeRaw,
|
||||
);
|
||||
|
||||
const onClose = async () => {
|
||||
await goto(`${AppRoute.ADMIN_USERS}/${user.id}`);
|
||||
};
|
||||
|
||||
const onSubmit = async (event: Event) => {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -66,7 +60,7 @@
|
||||
});
|
||||
|
||||
if (success) {
|
||||
await onClose();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -75,15 +75,8 @@ function zoneOptionForDate(zone: string, date: string) {
|
||||
// Ignore milliseconds:
|
||||
// - milliseconds are not relevant for TZ calculations
|
||||
// - browsers strip insignificant .000 making string comparison with milliseconds more fragile.
|
||||
//
|
||||
// Also, some browsers emit `datetime-local` values without seconds when seconds are 00,
|
||||
// e.g. `2024-01-01T00:00` instead of `2024-01-01T00:00:00.000`.
|
||||
// In that case we must compare with minute precision (otherwise every zone looks "invalid").
|
||||
const dateInTimezone = DateTime.fromISO(date, { zone });
|
||||
const withoutMillis = date.replace(/\.\d+/, '');
|
||||
const hasSeconds = /T\d{2}:\d{2}:\d{2}$/.test(withoutMillis);
|
||||
const compareFormat = hasSeconds ? "yyyy-MM-dd'T'HH:mm:ss" : "yyyy-MM-dd'T'HH:mm";
|
||||
const exists = withoutMillis === dateInTimezone.toFormat(compareFormat);
|
||||
const exists = date.replace(/\.\d+/, '') === dateInTimezone.toFormat("yyyy-MM-dd'T'HH:mm:ss");
|
||||
const valid = dateInTimezone.isValid && exists;
|
||||
return {
|
||||
value: zone,
|
||||
|
||||
@@ -5,6 +5,8 @@ import LibraryExclusionPatternAddModal from '$lib/modals/LibraryExclusionPattern
|
||||
import LibraryExclusionPatternEditModal from '$lib/modals/LibraryExclusionPatternEditModal.svelte';
|
||||
import LibraryFolderAddModal from '$lib/modals/LibraryFolderAddModal.svelte';
|
||||
import LibraryFolderEditModal from '$lib/modals/LibraryFolderEditModal.svelte';
|
||||
import LibraryRenameModal from '$lib/modals/LibraryRenameModal.svelte';
|
||||
import LibraryUserPickerModal from '$lib/modals/LibraryUserPickerModal.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import {
|
||||
@@ -15,9 +17,7 @@ import {
|
||||
runQueueCommandLegacy,
|
||||
scanLibrary,
|
||||
updateLibrary,
|
||||
type CreateLibraryDto,
|
||||
type LibraryResponseDto,
|
||||
type UpdateLibraryDto,
|
||||
} from '@immich/sdk';
|
||||
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
|
||||
import { mdiPencilOutline, mdiPlusBoxOutline, mdiSync, mdiTrashCanOutline } from '@mdi/js';
|
||||
@@ -37,7 +37,7 @@ export const getLibrariesActions = ($t: MessageFormatter, libraries: LibraryResp
|
||||
title: $t('create_library'),
|
||||
type: $t('command'),
|
||||
icon: mdiPlusBoxOutline,
|
||||
onAction: () => goto(AppRoute.ADMIN_LIBRARIES_NEW),
|
||||
onAction: () => handleCreateLibrary(),
|
||||
shortcuts: { shift: true, key: 'n' },
|
||||
};
|
||||
|
||||
@@ -45,11 +45,11 @@ export const getLibrariesActions = ($t: MessageFormatter, libraries: LibraryResp
|
||||
};
|
||||
|
||||
export const getLibraryActions = ($t: MessageFormatter, library: LibraryResponseDto) => {
|
||||
const Edit: ActionItem = {
|
||||
const Rename: ActionItem = {
|
||||
icon: mdiPencilOutline,
|
||||
type: $t('command'),
|
||||
title: $t('edit'),
|
||||
onAction: () => goto(`${AppRoute.ADMIN_LIBRARIES}/${library.id}/edit`),
|
||||
title: $t('rename'),
|
||||
onAction: () => modalManager.show(LibraryRenameModal, { library }),
|
||||
shortcuts: { key: 'r' },
|
||||
};
|
||||
|
||||
@@ -84,7 +84,7 @@ export const getLibraryActions = ($t: MessageFormatter, library: LibraryResponse
|
||||
shortcuts: { shift: true, key: 'r' },
|
||||
};
|
||||
|
||||
return { Edit, Delete, AddFolder, AddExclusionPattern, Scan };
|
||||
return { Rename, Delete, AddFolder, AddExclusionPattern, Scan };
|
||||
};
|
||||
|
||||
export const getLibraryFolderActions = ($t: MessageFormatter, library: LibraryResponseDto, folder: string) => {
|
||||
@@ -149,34 +149,46 @@ const handleScanLibrary = async (library: LibraryResponseDto) => {
|
||||
};
|
||||
|
||||
export const handleViewLibrary = async (library: LibraryResponseDto) => {
|
||||
await goto(`${AppRoute.ADMIN_LIBRARIES}/${library.id}`);
|
||||
await goto(`${AppRoute.ADMIN_LIBRARY_MANAGEMENT}/${library.id}`);
|
||||
};
|
||||
|
||||
export const handleCreateLibrary = async (dto: CreateLibraryDto) => {
|
||||
export const handleCreateLibrary = async () => {
|
||||
const $t = await getFormatter();
|
||||
|
||||
const ownerId = await modalManager.show(LibraryUserPickerModal, {});
|
||||
if (!ownerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const library = await createLibrary({ createLibraryDto: dto });
|
||||
eventManager.emit('LibraryCreate', library);
|
||||
toastManager.success($t('admin.library_created', { values: { library: library.name } }));
|
||||
return library;
|
||||
const createdLibrary = await createLibrary({ createLibraryDto: { ownerId } });
|
||||
eventManager.emit('LibraryCreate', createdLibrary);
|
||||
toastManager.success($t('admin.library_created', { values: { library: createdLibrary.name } }));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_create_library'));
|
||||
}
|
||||
};
|
||||
|
||||
export const handleUpdateLibrary = async (library: LibraryResponseDto, dto: UpdateLibraryDto) => {
|
||||
export const handleRenameLibrary = async (library: { id: string }, name?: string) => {
|
||||
const $t = await getFormatter();
|
||||
|
||||
if (!name) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const updatedLibrary = await updateLibrary({ id: library.id, updateLibraryDto: dto });
|
||||
const updatedLibrary = await updateLibrary({
|
||||
id: library.id,
|
||||
updateLibraryDto: { name },
|
||||
});
|
||||
eventManager.emit('LibraryUpdate', updatedLibrary);
|
||||
toastManager.success($t('admin.library_updated'));
|
||||
return true;
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_update_library'));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleDeleteLibrary = async (library: LibraryResponseDto) => {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
|
||||
import PasswordResetSuccessModal from '$lib/modals/PasswordResetSuccessModal.svelte';
|
||||
import UserCreateModal from '$lib/modals/UserCreateModal.svelte';
|
||||
import UserDeleteConfirmModal from '$lib/modals/UserDeleteConfirmModal.svelte';
|
||||
import UserEditModal from '$lib/modals/UserEditModal.svelte';
|
||||
import UserRestoreConfirmModal from '$lib/modals/UserRestoreConfirmModal.svelte';
|
||||
import { user as authUser } from '$lib/stores/user.store';
|
||||
import type { HeaderButtonActionItem } from '$lib/types';
|
||||
@@ -38,7 +39,7 @@ export const getUserAdminsActions = ($t: MessageFormatter) => {
|
||||
title: $t('create_user'),
|
||||
type: $t('command'),
|
||||
icon: mdiPlusBoxOutline,
|
||||
onAction: () => goto(AppRoute.ADMIN_USERS_NEW),
|
||||
onAction: () => modalManager.show(UserCreateModal, {}),
|
||||
shortcuts: { shift: true, key: 'n' },
|
||||
};
|
||||
|
||||
@@ -49,7 +50,7 @@ export const getUserAdminActions = ($t: MessageFormatter, user: UserAdminRespons
|
||||
const Update: ActionItem = {
|
||||
icon: mdiPencilOutline,
|
||||
title: $t('edit'),
|
||||
onAction: () => goto(`${AppRoute.ADMIN_USERS}/${user.id}/edit`),
|
||||
onAction: () => modalManager.show(UserEditModal, { user }),
|
||||
};
|
||||
|
||||
const Delete: ActionItem = {
|
||||
@@ -102,7 +103,7 @@ export const handleCreateUserAdmin = async (dto: UserAdminCreateDto) => {
|
||||
const response = await createUserAdmin({ userAdminCreateDto: dto });
|
||||
eventManager.emit('UserAdminCreate', response);
|
||||
toastManager.success();
|
||||
return response;
|
||||
return true;
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_create_user'));
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<div class="h-full flex flex-col justify-between gap-2">
|
||||
<div class="flex flex-col pt-8 pe-4 gap-1">
|
||||
<NavbarItem title={$t('users')} href={AppRoute.ADMIN_USERS} icon={mdiAccountMultipleOutline} />
|
||||
<NavbarItem title={$t('external_libraries')} href={AppRoute.ADMIN_LIBRARIES} icon={mdiBookshelf} />
|
||||
<NavbarItem title={$t('external_libraries')} href={AppRoute.ADMIN_LIBRARY_MANAGEMENT} icon={mdiBookshelf} />
|
||||
<NavbarItem title={$t('admin.queues')} href={AppRoute.ADMIN_QUEUES} icon={mdiTrayFull} />
|
||||
<NavbarItem title={$t('settings')} href={AppRoute.ADMIN_SETTINGS} icon={mdiCog} />
|
||||
<NavbarItem title={$t('server_stats')} href={AppRoute.ADMIN_STATS} icon={mdiServer} />
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { UUID_REGEX } from '$lib/constants';
|
||||
import type { ParamMatcher } from '@sveltejs/kit';
|
||||
|
||||
/* Returns true if the given param matches UUID format */
|
||||
export const match: ParamMatcher = (param: string) => {
|
||||
return UUID_REGEX.test(param);
|
||||
return /^[\dA-Fa-f]{8}(?:\b-[\dA-Fa-f]{4}){3}\b-[\dA-Fa-f]{12}$/.test(param);
|
||||
};
|
||||
|
||||
@@ -166,7 +166,7 @@
|
||||
title: $t('external_libraries'),
|
||||
description: $t('admin.external_libraries_page_description'),
|
||||
icon: mdiBookshelf,
|
||||
onAction: () => goto(AppRoute.ADMIN_LIBRARIES),
|
||||
onAction: () => goto(AppRoute.ADMIN_LIBRARY_MANAGEMENT),
|
||||
},
|
||||
{
|
||||
title: $t('server_stats'),
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { handleCreateLibrary } from '$lib/services/library.service';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { searchUsersAdmin } from '@immich/sdk';
|
||||
import { FormModal, Text } from '@immich/ui';
|
||||
import { mdiFolderSync } from '@mdi/js';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
let ownerId: string = $state($user.id);
|
||||
|
||||
let userOptions: { value: string; text: string }[] = $state([]);
|
||||
|
||||
onMount(async () => {
|
||||
const users = await searchUsersAdmin({});
|
||||
userOptions = users.map((user) => ({ value: user.id, text: user.name }));
|
||||
});
|
||||
|
||||
const onClose = async () => {
|
||||
await goto(AppRoute.ADMIN_LIBRARIES);
|
||||
};
|
||||
|
||||
const onSubmit = async () => {
|
||||
const library = await handleCreateLibrary({ ownerId });
|
||||
if (library) {
|
||||
await goto(`${AppRoute.ADMIN_LIBRARIES}/${library.id}`, { replaceState: true });
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<FormModal
|
||||
title={$t('create_library')}
|
||||
icon={mdiFolderSync}
|
||||
{onClose}
|
||||
size="small"
|
||||
{onSubmit}
|
||||
submitText={$t('create')}
|
||||
>
|
||||
<SettingSelect label={$t('owner')} bind:value={ownerId} options={userOptions} name="user" />
|
||||
<Text color="warning" size="small">{$t('admin.note_cannot_be_changed_later')}</Text>
|
||||
</FormModal>
|
||||
@@ -1,14 +0,0 @@
|
||||
import { authenticate } from '$lib/utils/auth';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (async ({ url }) => {
|
||||
await authenticate(url, { admin: true });
|
||||
const $t = await getFormatter();
|
||||
|
||||
return {
|
||||
meta: {
|
||||
title: $t('external_libraries'),
|
||||
},
|
||||
};
|
||||
}) satisfies PageLoad;
|
||||
@@ -4,32 +4,34 @@
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { getLibrariesActions, handleViewLibrary } from '$lib/services/library.service';
|
||||
import { getLibrariesActions, handleCreateLibrary, handleViewLibrary } 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, getUserAdmin, type LibraryResponseDto } from '@immich/sdk';
|
||||
import { Button, CommandPaletteContext } from '@immich/ui';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
import type { LayoutData } from './$types';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
type Props = {
|
||||
children?: Snippet;
|
||||
data: LayoutData;
|
||||
};
|
||||
interface Props {
|
||||
data: PageData;
|
||||
}
|
||||
|
||||
let { children, data }: Props = $props();
|
||||
let { data }: Props = $props();
|
||||
|
||||
let libraries = $state(data.libraries);
|
||||
let statistics = $state(data.statistics);
|
||||
let owners = $state(data.owners);
|
||||
|
||||
const onLibraryCreate = async (library: LibraryResponseDto) => {
|
||||
await goto(`${AppRoute.ADMIN_LIBRARIES}/${library.id}`);
|
||||
const handleLibraryAdd = async (library: LibraryResponseDto) => {
|
||||
statistics[library.id] = await getLibraryStatistics({ id: library.id });
|
||||
owners[library.id] = await getUserAdmin({ id: library.ownerId });
|
||||
libraries.push(library);
|
||||
|
||||
await goto(`${AppRoute.ADMIN_LIBRARY_MANAGEMENT}/${library.id}`);
|
||||
};
|
||||
|
||||
const onLibraryUpdate = async (library: LibraryResponseDto) => {
|
||||
const handleLibraryUpdate = async (library: LibraryResponseDto) => {
|
||||
const index = libraries.findIndex(({ id }) => id === library.id);
|
||||
|
||||
if (index === -1) {
|
||||
@@ -40,7 +42,7 @@
|
||||
statistics[library.id] = await getLibraryStatistics({ id: library.id });
|
||||
};
|
||||
|
||||
const onLibraryDelete = ({ id }: { id: string }) => {
|
||||
const handleDeleteLibrary = ({ id }: { id: string }) => {
|
||||
libraries = libraries.filter((library) => library.id !== id);
|
||||
delete statistics[id];
|
||||
delete owners[id];
|
||||
@@ -49,7 +51,11 @@
|
||||
const { Create, ScanAll } = $derived(getLibrariesActions($t, libraries));
|
||||
</script>
|
||||
|
||||
<OnEvents {onLibraryCreate} {onLibraryUpdate} {onLibraryDelete} />
|
||||
<OnEvents
|
||||
onLibraryCreate={handleLibraryAdd}
|
||||
onLibraryUpdate={handleLibraryUpdate}
|
||||
onLibraryDelete={handleDeleteLibrary}
|
||||
/>
|
||||
|
||||
<CommandPaletteContext commands={[Create, ScanAll]} />
|
||||
|
||||
@@ -100,14 +106,8 @@
|
||||
</tbody>
|
||||
</table>
|
||||
{:else}
|
||||
<EmptyPlaceholder
|
||||
text={$t('no_libraries_message')}
|
||||
onClick={() => goto(AppRoute.ADMIN_LIBRARIES_NEW)}
|
||||
class="mt-10 mx-auto"
|
||||
/>
|
||||
<EmptyPlaceholder text={$t('no_libraries_message')} onClick={handleCreateLibrary} class="mt-10 mx-auto" />
|
||||
{/if}
|
||||
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</section>
|
||||
</AdminPageLayout>
|
||||
@@ -1,7 +1,7 @@
|
||||
import { authenticate, requestServerInfo } from '$lib/utils/auth';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import { getAllLibraries, getLibraryStatistics, getUserAdmin, searchUsersAdmin } from '@immich/sdk';
|
||||
import type { LayoutLoad } from './$types';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (async ({ url }) => {
|
||||
await authenticate(url, { admin: true });
|
||||
@@ -26,4 +26,4 @@ export const load = (async ({ url }) => {
|
||||
title: $t('external_libraries'),
|
||||
},
|
||||
};
|
||||
}) satisfies LayoutLoad;
|
||||
}) satisfies PageLoad;
|
||||
@@ -1,118 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import emptyFoldersUrl from '$lib/assets/empty-folders.svg';
|
||||
import AdminCard from '$lib/components/AdminCard.svelte';
|
||||
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import ServerStatisticsCard from '$lib/components/server-statistics/ServerStatisticsCard.svelte';
|
||||
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
|
||||
import TableButton from '$lib/components/TableButton.svelte';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import LibraryFolderAddModal from '$lib/modals/LibraryFolderAddModal.svelte';
|
||||
import {
|
||||
getLibraryActions,
|
||||
getLibraryExclusionPatternActions,
|
||||
getLibraryFolderActions,
|
||||
} from '$lib/services/library.service';
|
||||
import { getBytesWithUnit } from '$lib/utils/byte-units';
|
||||
import type { LibraryResponseDto } from '@immich/sdk';
|
||||
import { Code, CommandPaletteContext, Container, Heading, modalManager } from '@immich/ui';
|
||||
import { mdiCameraIris, mdiChartPie, mdiFilterMinusOutline, mdiFolderOutline, mdiPlayCircle } from '@mdi/js';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { LayoutData } from './$types';
|
||||
|
||||
type Props = {
|
||||
children?: Snippet;
|
||||
data: LayoutData;
|
||||
};
|
||||
|
||||
const { children, data }: Props = $props();
|
||||
|
||||
const statistics = data.statistics;
|
||||
const [storageUsage, unit] = getBytesWithUnit(statistics.usage);
|
||||
|
||||
let library = $state(data.library);
|
||||
|
||||
const onLibraryUpdate = (newLibrary: LibraryResponseDto) => {
|
||||
if (newLibrary.id === library.id) {
|
||||
library = newLibrary;
|
||||
}
|
||||
};
|
||||
|
||||
const onLibraryDelete = async ({ id }: { id: string }) => {
|
||||
if (id === library.id) {
|
||||
await goto(AppRoute.ADMIN_LIBRARIES);
|
||||
}
|
||||
};
|
||||
|
||||
const { Edit, Delete, AddFolder, AddExclusionPattern, Scan } = $derived(getLibraryActions($t, library));
|
||||
</script>
|
||||
|
||||
<OnEvents {onLibraryUpdate} {onLibraryDelete} />
|
||||
|
||||
<CommandPaletteContext commands={[Edit, Delete, AddFolder, AddExclusionPattern, Scan]} />
|
||||
|
||||
<AdminPageLayout
|
||||
breadcrumbs={[{ title: $t('external_libraries'), href: AppRoute.ADMIN_LIBRARIES }, { title: library.name }]}
|
||||
actions={[Scan, Edit, Delete]}
|
||||
>
|
||||
<Container size="large" center>
|
||||
<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} />
|
||||
</div>
|
||||
|
||||
<AdminCard icon={mdiFolderOutline} title={$t('folders')} headerAction={AddFolder}>
|
||||
{#if library.importPaths.length === 0}
|
||||
<EmptyPlaceholder
|
||||
src={emptyFoldersUrl}
|
||||
text={$t('admin.library_folder_description')}
|
||||
fullWidth
|
||||
onClick={() => modalManager.show(LibraryFolderAddModal, { library })}
|
||||
/>
|
||||
{:else}
|
||||
<table class="w-full">
|
||||
<tbody>
|
||||
{#each library.importPaths as folder (folder)}
|
||||
{@const { Edit, Delete } = getLibraryFolderActions($t, library, folder)}
|
||||
<tr class="h-12">
|
||||
<td>
|
||||
<Code>{folder}</Code>
|
||||
</td>
|
||||
<td class="flex gap-2 justify-end">
|
||||
<TableButton action={Edit} />
|
||||
<TableButton action={Delete} />
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</AdminCard>
|
||||
|
||||
<AdminCard icon={mdiFilterMinusOutline} title={$t('exclusion_pattern')} headerAction={AddExclusionPattern}>
|
||||
<table class="w-full">
|
||||
<tbody>
|
||||
{#each library.exclusionPatterns as exclusionPattern (exclusionPattern)}
|
||||
{@const { Edit, Delete } = getLibraryExclusionPatternActions($t, library, exclusionPattern)}
|
||||
<tr class="h-12">
|
||||
<td>
|
||||
<Code>{exclusionPattern}</Code>
|
||||
</td>
|
||||
<td class="flex gap-2 justify-end">
|
||||
<TableButton action={Edit} />
|
||||
<TableButton action={Delete} />
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</AdminCard>
|
||||
</div>
|
||||
{@render children?.()}
|
||||
</Container>
|
||||
</AdminPageLayout>
|
||||
@@ -1,30 +0,0 @@
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { authenticate } from '$lib/utils/auth';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import { getLibrary, getLibraryStatistics, type LibraryResponseDto } from '@immich/sdk';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { LayoutLoad } from './$types';
|
||||
|
||||
export const load = (async ({ params: { id }, url }) => {
|
||||
await authenticate(url, { admin: true });
|
||||
|
||||
let library: LibraryResponseDto;
|
||||
|
||||
try {
|
||||
library = await getLibrary({ id });
|
||||
console.log(`Fetched latest library: ${library.name}`);
|
||||
} catch {
|
||||
redirect(302, AppRoute.ADMIN_LIBRARIES);
|
||||
}
|
||||
|
||||
const statistics = await getLibraryStatistics({ id });
|
||||
const $t = await getFormatter();
|
||||
|
||||
return {
|
||||
library,
|
||||
statistics,
|
||||
meta: {
|
||||
title: $t('admin.library_details'),
|
||||
},
|
||||
};
|
||||
}) satisfies LayoutLoad;
|
||||
@@ -0,0 +1,140 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import emptyFoldersUrl from '$lib/assets/empty-folders.svg';
|
||||
import HeaderActionButton from '$lib/components/HeaderActionButton.svelte';
|
||||
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import ServerStatisticsCard from '$lib/components/server-statistics/ServerStatisticsCard.svelte';
|
||||
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
|
||||
import TableButton from '$lib/components/TableButton.svelte';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import LibraryFolderAddModal from '$lib/modals/LibraryFolderAddModal.svelte';
|
||||
import {
|
||||
getLibraryActions,
|
||||
getLibraryExclusionPatternActions,
|
||||
getLibraryFolderActions,
|
||||
} from '$lib/services/library.service';
|
||||
import { getBytesWithUnit } from '$lib/utils/byte-units';
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Code,
|
||||
CommandPaletteContext,
|
||||
Container,
|
||||
Heading,
|
||||
Icon,
|
||||
modalManager,
|
||||
} from '@immich/ui';
|
||||
import { mdiCameraIris, mdiChartPie, mdiFilterMinusOutline, mdiFolderOutline, mdiPlayCircle } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
type Props = {
|
||||
data: PageData;
|
||||
};
|
||||
|
||||
const { data }: Props = $props();
|
||||
|
||||
const statistics = data.statistics;
|
||||
const [storageUsage, unit] = getBytesWithUnit(statistics.usage);
|
||||
|
||||
let library = $derived(data.library);
|
||||
|
||||
const { Rename, Delete, AddFolder, AddExclusionPattern, Scan } = $derived(getLibraryActions($t, library));
|
||||
</script>
|
||||
|
||||
<OnEvents
|
||||
onLibraryUpdate={(newLibrary) => (library = newLibrary)}
|
||||
onLibraryDelete={({ id }) => id === library.id && goto(AppRoute.ADMIN_LIBRARY_MANAGEMENT)}
|
||||
/>
|
||||
|
||||
<CommandPaletteContext commands={[Rename, Delete, AddFolder, AddExclusionPattern, Scan]} />
|
||||
|
||||
<AdminPageLayout
|
||||
breadcrumbs={[{ title: $t('external_libraries'), href: AppRoute.ADMIN_LIBRARY_MANAGEMENT }, { title: library.name }]}
|
||||
actions={[Scan, Rename, Delete]}
|
||||
>
|
||||
<Container size="large" center>
|
||||
<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} />
|
||||
</div>
|
||||
<Card color="secondary">
|
||||
<CardHeader>
|
||||
<div class="flex w-full justify-between items-center px-4 py-2">
|
||||
<div class="flex gap-2 text-primary">
|
||||
<Icon icon={mdiFolderOutline} size="1.5rem" />
|
||||
<CardTitle>{$t('folders')}</CardTitle>
|
||||
</div>
|
||||
<HeaderActionButton action={AddFolder} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div class="px-4 pb-7">
|
||||
{#if library.importPaths.length === 0}
|
||||
<EmptyPlaceholder
|
||||
src={emptyFoldersUrl}
|
||||
text={$t('admin.library_folder_description')}
|
||||
fullWidth
|
||||
onClick={() => modalManager.show(LibraryFolderAddModal, { library })}
|
||||
/>
|
||||
{:else}
|
||||
<table class="w-full">
|
||||
<tbody>
|
||||
{#each library.importPaths as folder (folder)}
|
||||
{@const { Edit, Delete } = getLibraryFolderActions($t, library, folder)}
|
||||
<tr class="h-12">
|
||||
<td>
|
||||
<Code>{folder}</Code>
|
||||
</td>
|
||||
<td class="flex gap-2 justify-end">
|
||||
<TableButton action={Edit} />
|
||||
<TableButton action={Delete} />
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
<Card color="secondary">
|
||||
<CardHeader>
|
||||
<div class="flex w-full justify-between items-center px-4 py-2">
|
||||
<div class="flex gap-2 text-primary">
|
||||
<Icon icon={mdiFilterMinusOutline} size="1.5rem" />
|
||||
<CardTitle>{$t('exclusion_pattern')}</CardTitle>
|
||||
</div>
|
||||
<HeaderActionButton action={AddExclusionPattern} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div class="px-4 pb-7">
|
||||
<table class="w-full">
|
||||
<tbody>
|
||||
{#each library.exclusionPatterns as exclusionPattern (exclusionPattern)}
|
||||
{@const { Edit, Delete } = getLibraryExclusionPatternActions($t, library, exclusionPattern)}
|
||||
<tr class="h-12">
|
||||
<td>
|
||||
<Code>{exclusionPattern}</Code>
|
||||
</td>
|
||||
<td class="flex gap-2 justify-end">
|
||||
<TableButton action={Edit} />
|
||||
<TableButton action={Delete} />
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
</Container>
|
||||
</AdminPageLayout>
|
||||
|
||||
@@ -1,13 +1,26 @@
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { authenticate } from '$lib/utils/auth';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import { getLibrary, getLibraryStatistics, type LibraryResponseDto } from '@immich/sdk';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (async ({ url }) => {
|
||||
export const load = (async ({ params: { id }, url }) => {
|
||||
await authenticate(url, { admin: true });
|
||||
let library: LibraryResponseDto;
|
||||
|
||||
try {
|
||||
library = await getLibrary({ id });
|
||||
} catch {
|
||||
redirect(302, AppRoute.ADMIN_LIBRARY_MANAGEMENT);
|
||||
}
|
||||
|
||||
const statistics = await getLibraryStatistics({ id });
|
||||
const $t = await getFormatter();
|
||||
|
||||
return {
|
||||
library,
|
||||
statistics,
|
||||
meta: {
|
||||
title: $t('admin.library_details'),
|
||||
},
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { handleUpdateLibrary } from '$lib/services/library.service';
|
||||
import { Field, FormModal, Input } from '@immich/ui';
|
||||
import { mdiRenameOutline } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { PageData } from '../$types';
|
||||
|
||||
type Props = {
|
||||
data: PageData;
|
||||
};
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
const library = $derived(data.library);
|
||||
let name = $state(library.name);
|
||||
|
||||
const onClose = async () => {
|
||||
await goto(`${AppRoute.ADMIN_LIBRARIES}/${library.id}`);
|
||||
};
|
||||
|
||||
const onSubmit = async () => {
|
||||
const success = await handleUpdateLibrary(library, { name });
|
||||
if (success) {
|
||||
await onClose();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<FormModal icon={mdiRenameOutline} title={$t('edit')} {onSubmit} {onClose} size="small">
|
||||
<Field label={$t('name')}>
|
||||
<Input bind:value={name} />
|
||||
</Field>
|
||||
</FormModal>
|
||||
@@ -1,15 +0,0 @@
|
||||
import { authenticate } from '$lib/utils/auth';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (async ({ url }) => {
|
||||
await authenticate(url, { admin: true });
|
||||
|
||||
const $t = await getFormatter();
|
||||
|
||||
return {
|
||||
meta: {
|
||||
title: $t('admin.library_details'),
|
||||
},
|
||||
};
|
||||
}) satisfies PageLoad;
|
||||
@@ -1,93 +0,0 @@
|
||||
<script lang="ts">
|
||||
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import { getUserAdminsActions, handleNavigateUserAdmin } from '$lib/services/user-admin.service';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { getByteUnitString } from '$lib/utils/byte-units';
|
||||
import { searchUsersAdmin, type UserAdminResponseDto } from '@immich/sdk';
|
||||
import { Button, CommandPaletteContext, Container, Icon } from '@immich/ui';
|
||||
import { mdiInfinity } from '@mdi/js';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { LayoutData } from './$types';
|
||||
|
||||
type Props = {
|
||||
children?: Snippet;
|
||||
data: LayoutData;
|
||||
};
|
||||
|
||||
let { children, data }: Props = $props();
|
||||
|
||||
let users: UserAdminResponseDto[] = $state(data.users);
|
||||
|
||||
const onUpdate = async (user: UserAdminResponseDto) => {
|
||||
const index = users.findIndex(({ id }) => id === user.id);
|
||||
if (index === -1) {
|
||||
users = await searchUsersAdmin({ withDeleted: true });
|
||||
} else {
|
||||
users[index] = user;
|
||||
}
|
||||
};
|
||||
|
||||
const onUserAdminDeleted = ({ id: userId }: { id: string }) => {
|
||||
users = users.filter(({ id }) => id !== userId);
|
||||
};
|
||||
|
||||
const { Create } = $derived(getUserAdminsActions($t));
|
||||
</script>
|
||||
|
||||
<OnEvents
|
||||
onUserAdminCreate={onUpdate}
|
||||
onUserAdminUpdate={onUpdate}
|
||||
onUserAdminDelete={onUpdate}
|
||||
onUserAdminRestore={onUpdate}
|
||||
{onUserAdminDeleted}
|
||||
/>
|
||||
|
||||
<CommandPaletteContext commands={[Create]} />
|
||||
|
||||
<AdminPageLayout breadcrumbs={[{ title: data.meta.title }]} actions={[Create]}>
|
||||
<Container center size="large">
|
||||
<table class="my-5 w-full text-start">
|
||||
<thead
|
||||
class="mb-4 flex h-12 w-full rounded-md border bg-gray-50 text-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray"
|
||||
>
|
||||
<tr class="flex w-full place-items-center">
|
||||
<th class="w-8/12 sm:w-5/12 lg:w-6/12 xl:w-4/12 2xl:w-5/12 text-center text-sm font-medium">{$t('email')}</th>
|
||||
<th class="hidden sm:block w-3/12 text-center text-sm font-medium">{$t('name')}</th>
|
||||
<th class="hidden xl:block w-3/12 2xl:w-2/12 text-center text-sm font-medium">{$t('has_quota')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray">
|
||||
{#each users as user (user.id)}
|
||||
<tr
|
||||
class="flex h-20 overflow-hidden w-full place-items-center text-center dark:text-immich-dark-fg {user.deletedAt
|
||||
? 'bg-red-300 dark:bg-red-900'
|
||||
: 'even:bg-subtle/20 odd:bg-subtle/80'}"
|
||||
>
|
||||
<td class="w-8/12 sm:w-5/12 lg:w-6/12 xl:w-4/12 2xl:w-5/12 text-ellipsis break-all px-2 text-sm">
|
||||
{user.email}
|
||||
</td>
|
||||
<td class="hidden sm:block w-3/12 text-ellipsis break-all px-2 text-sm">{user.name}</td>
|
||||
<td class="hidden xl:block w-3/12 2xl:w-2/12 text-ellipsis break-all px-2 text-sm">
|
||||
<div class="container mx-auto flex flex-wrap justify-center">
|
||||
{#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0}
|
||||
{getByteUnitString(user.quotaSizeInBytes, $locale)}
|
||||
{:else}
|
||||
<Icon icon={mdiInfinity} size="16" />
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
class="flex flex-row flex-wrap justify-center gap-x-2 gap-y-1 w-4/12 lg:w-3/12 xl:w-2/12 text-ellipsis break-all text-sm"
|
||||
>
|
||||
<Button onclick={() => handleNavigateUserAdmin(user)}>{$t('view')}</Button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{@render children?.()}
|
||||
</Container>
|
||||
</AdminPageLayout>
|
||||
@@ -1,14 +0,0 @@
|
||||
import { authenticate } from '$lib/utils/auth';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (async ({ url }) => {
|
||||
await authenticate(url, { admin: true });
|
||||
const $t = await getFormatter();
|
||||
|
||||
return {
|
||||
meta: {
|
||||
title: $t('admin.user_management'),
|
||||
},
|
||||
};
|
||||
}) satisfies PageLoad;
|
||||
93
web/src/routes/admin/users/+page.svelte
Normal file
93
web/src/routes/admin/users/+page.svelte
Normal file
@@ -0,0 +1,93 @@
|
||||
<script lang="ts">
|
||||
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import { getUserAdminsActions, handleNavigateUserAdmin } from '$lib/services/user-admin.service';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { getByteUnitString } from '$lib/utils/byte-units';
|
||||
import { searchUsersAdmin, type UserAdminResponseDto } from '@immich/sdk';
|
||||
import { Button, CommandPaletteContext, Icon } from '@immich/ui';
|
||||
import { mdiInfinity } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
type Props = {
|
||||
data: PageData;
|
||||
};
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
let allUsers: UserAdminResponseDto[] = $state(data.allUsers);
|
||||
|
||||
const onUpdate = async (user: UserAdminResponseDto) => {
|
||||
const index = allUsers.findIndex(({ id }) => id === user.id);
|
||||
if (index === -1) {
|
||||
allUsers = await searchUsersAdmin({ withDeleted: true });
|
||||
} else {
|
||||
allUsers[index] = user;
|
||||
}
|
||||
};
|
||||
|
||||
const onUserAdminDeleted = ({ id: userId }: { id: string }) => {
|
||||
allUsers = allUsers.filter(({ id }) => id !== userId);
|
||||
};
|
||||
|
||||
const { Create } = $derived(getUserAdminsActions($t));
|
||||
</script>
|
||||
|
||||
<OnEvents
|
||||
onUserAdminCreate={onUpdate}
|
||||
onUserAdminUpdate={onUpdate}
|
||||
onUserAdminDelete={onUpdate}
|
||||
onUserAdminRestore={onUpdate}
|
||||
{onUserAdminDeleted}
|
||||
/>
|
||||
|
||||
<CommandPaletteContext commands={[Create]} />
|
||||
|
||||
<AdminPageLayout breadcrumbs={[{ title: data.meta.title }]} actions={[Create]}>
|
||||
<section id="setting-content" class="flex place-content-center sm:mx-4">
|
||||
<section class="w-full pb-28 lg:w-212.5">
|
||||
<table class="my-5 w-full text-start">
|
||||
<thead
|
||||
class="mb-4 flex h-12 w-full rounded-md border bg-gray-50 text-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray"
|
||||
>
|
||||
<tr class="flex w-full place-items-center">
|
||||
<th class="w-8/12 sm:w-5/12 lg:w-6/12 xl:w-4/12 2xl:w-5/12 text-center text-sm font-medium"
|
||||
>{$t('email')}</th
|
||||
>
|
||||
<th class="hidden sm:block w-3/12 text-center text-sm font-medium">{$t('name')}</th>
|
||||
<th class="hidden xl:block w-3/12 2xl:w-2/12 text-center text-sm font-medium">{$t('has_quota')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray">
|
||||
{#each allUsers as user (user.id)}
|
||||
<tr
|
||||
class="flex h-20 overflow-hidden w-full place-items-center text-center dark:text-immich-dark-fg {user.deletedAt
|
||||
? 'bg-red-300 dark:bg-red-900'
|
||||
: 'even:bg-subtle/20 odd:bg-subtle/80'}"
|
||||
>
|
||||
<td class="w-8/12 sm:w-5/12 lg:w-6/12 xl:w-4/12 2xl:w-5/12 text-ellipsis break-all px-2 text-sm">
|
||||
{user.email}
|
||||
</td>
|
||||
<td class="hidden sm:block w-3/12 text-ellipsis break-all px-2 text-sm">{user.name}</td>
|
||||
<td class="hidden xl:block w-3/12 2xl:w-2/12 text-ellipsis break-all px-2 text-sm">
|
||||
<div class="container mx-auto flex flex-wrap justify-center">
|
||||
{#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0}
|
||||
{getByteUnitString(user.quotaSizeInBytes, $locale)}
|
||||
{:else}
|
||||
<Icon icon={mdiInfinity} size="16" />
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
class="flex flex-row flex-wrap justify-center gap-x-2 gap-y-1 w-4/12 lg:w-3/12 xl:w-2/12 text-ellipsis break-all text-sm"
|
||||
>
|
||||
<Button onclick={() => handleNavigateUserAdmin(user)}>{$t('view')}</Button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</section>
|
||||
</AdminPageLayout>
|
||||
@@ -1,18 +1,18 @@
|
||||
import { authenticate, requestServerInfo } from '$lib/utils/auth';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import { searchUsersAdmin } from '@immich/sdk';
|
||||
import type { LayoutLoad } from './$types';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (async ({ url }) => {
|
||||
await authenticate(url, { admin: true });
|
||||
await requestServerInfo();
|
||||
const users = await searchUsersAdmin({ withDeleted: true });
|
||||
const allUsers = await searchUsersAdmin({ withDeleted: true });
|
||||
const $t = await getFormatter();
|
||||
|
||||
return {
|
||||
users,
|
||||
allUsers,
|
||||
meta: {
|
||||
title: $t('admin.user_management'),
|
||||
},
|
||||
};
|
||||
}) satisfies LayoutLoad;
|
||||
}) satisfies PageLoad;
|
||||
@@ -1,222 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import AdminCard from '$lib/components/AdminCard.svelte';
|
||||
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import ServerStatisticsCard from '$lib/components/server-statistics/ServerStatisticsCard.svelte';
|
||||
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
|
||||
import DeviceCard from '$lib/components/user-settings-page/device-card.svelte';
|
||||
import FeatureSetting from '$lib/components/users/FeatureSetting.svelte';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { getUserAdminActions } from '$lib/services/user-admin.service';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { createDateFormatter, findLocale } from '$lib/utils';
|
||||
import { getBytesWithUnit } from '$lib/utils/byte-units';
|
||||
import { type UserAdminResponseDto } from '@immich/sdk';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Code,
|
||||
CommandPaletteContext,
|
||||
Container,
|
||||
getByteUnitString,
|
||||
Heading,
|
||||
Icon,
|
||||
MenuItemType,
|
||||
Stack,
|
||||
Text,
|
||||
} from '@immich/ui';
|
||||
import {
|
||||
mdiAccountOutline,
|
||||
mdiCameraIris,
|
||||
mdiChartPie,
|
||||
mdiChartPieOutline,
|
||||
mdiCheckCircle,
|
||||
mdiDevices,
|
||||
mdiFeatureSearchOutline,
|
||||
mdiPlayCircle,
|
||||
mdiTrashCanOutline,
|
||||
} from '@mdi/js';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { LayoutData } from './$types';
|
||||
|
||||
type Props = {
|
||||
children?: Snippet;
|
||||
data: LayoutData;
|
||||
};
|
||||
|
||||
const { children, data }: Props = $props();
|
||||
|
||||
let user = $state(data.user);
|
||||
const userPreferences = $state(data.userPreferences);
|
||||
const userStatistics = $state(data.userStatistics);
|
||||
const userSessions = $state(data.userSessions);
|
||||
const TiB = 1024 ** 4;
|
||||
const usage = $derived(user.quotaUsageInBytes ?? 0);
|
||||
let [statsUsage, statsUsageUnit] = $derived(getBytesWithUnit(usage, usage > TiB ? 2 : 0));
|
||||
const usedBytes = $derived(user.quotaUsageInBytes ?? 0);
|
||||
const availableBytes = $derived(user.quotaSizeInBytes ?? 1);
|
||||
let usedPercentage = $derived(Math.min(Math.round((usedBytes / availableBytes) * 100), 100));
|
||||
|
||||
let editedLocale = $derived(findLocale($locale).code);
|
||||
let createAtDate: Date = $derived(new Date(user.createdAt));
|
||||
let updatedAtDate: Date = $derived(new Date(user.updatedAt));
|
||||
let userCreatedAtDateAndTime: string = $derived(createDateFormatter(editedLocale).formatDateTime(createAtDate));
|
||||
let userUpdatedAtDateAndTime: string = $derived(createDateFormatter(editedLocale).formatDateTime(updatedAtDate));
|
||||
|
||||
const getUsageClass = () => {
|
||||
if (usedPercentage >= 95) {
|
||||
return 'bg-red-500';
|
||||
}
|
||||
|
||||
if (usedPercentage > 80) {
|
||||
return 'bg-yellow-500';
|
||||
}
|
||||
|
||||
return 'bg-primary';
|
||||
};
|
||||
|
||||
const { ResetPassword, ResetPinCode, Update, Delete, Restore } = $derived(getUserAdminActions($t, user));
|
||||
|
||||
const onUpdate = (update: UserAdminResponseDto) => {
|
||||
if (update.id === user.id) {
|
||||
user = update;
|
||||
}
|
||||
};
|
||||
|
||||
const onUserAdminDeleted = async ({ id }: { id: string }) => {
|
||||
if (id === user.id) {
|
||||
await goto(AppRoute.ADMIN_USERS);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<OnEvents
|
||||
onUserAdminUpdate={onUpdate}
|
||||
onUserAdminDelete={onUpdate}
|
||||
onUserAdminRestore={onUpdate}
|
||||
{onUserAdminDeleted}
|
||||
/>
|
||||
|
||||
<CommandPaletteContext commands={[ResetPassword, ResetPinCode, Update, Delete, Restore]} />
|
||||
|
||||
<AdminPageLayout
|
||||
breadcrumbs={[{ title: $t('admin.user_management'), href: AppRoute.ADMIN_USERS }, { title: user.name }]}
|
||||
actions={[ResetPassword, ResetPinCode, Update, Restore, MenuItemType.Divider, Delete]}
|
||||
>
|
||||
<div>
|
||||
<Container size="large" center>
|
||||
{#if user.deletedAt}
|
||||
<Alert color="danger" class="my-4" title={$t('user_has_been_deleted')} icon={mdiTrashCanOutline} />
|
||||
{/if}
|
||||
|
||||
<div class="grid gap-4 grid-cols-1 lg:grid-cols-2 w-full">
|
||||
<div class="col-span-full flex flex-col gap-4 my-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<UserAvatar {user} size="md" />
|
||||
<Heading tag="h1" size="large">{user.name}</Heading>
|
||||
</div>
|
||||
{#if user.isAdmin}
|
||||
<div>
|
||||
<Badge color="primary" size="small">{$t('admin.admin_user')}</Badge>
|
||||
</div>
|
||||
{/if}
|
||||
</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} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AdminCard icon={mdiAccountOutline} title={$t('profile')}>
|
||||
<Stack gap={2}>
|
||||
<div>
|
||||
<Heading tag="h3" size="tiny">{$t('name')}</Heading>
|
||||
<Text>{user.name}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Heading tag="h3" size="tiny">{$t('email')}</Heading>
|
||||
<Text>{user.email}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Heading tag="h3" size="tiny">{$t('created_at')}</Heading>
|
||||
<Text>{userCreatedAtDateAndTime}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Heading tag="h3" size="tiny">{$t('updated_at')}</Heading>
|
||||
<Text>{userUpdatedAtDateAndTime}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Heading tag="h3" size="tiny">{$t('id')}</Heading>
|
||||
<Code>{user.id}</Code>
|
||||
</div>
|
||||
</Stack>
|
||||
</AdminCard>
|
||||
|
||||
<AdminCard icon={mdiFeatureSearchOutline} title={$t('features')}>
|
||||
<Stack gap={3}>
|
||||
<FeatureSetting title={$t('email_notifications')} state={userPreferences.emailNotifications.enabled} />
|
||||
<FeatureSetting title={$t('folders')} state={userPreferences.folders.enabled} />
|
||||
<FeatureSetting title={$t('memories')} state={userPreferences.memories.enabled} />
|
||||
<FeatureSetting title={$t('people')} state={userPreferences.people.enabled} />
|
||||
<FeatureSetting title={$t('rating')} state={userPreferences.ratings.enabled} />
|
||||
<FeatureSetting title={$t('shared_links')} state={userPreferences.sharedLinks.enabled} />
|
||||
<FeatureSetting title={$t('show_supporter_badge')} state={userPreferences.purchase.showSupportBadge} />
|
||||
<FeatureSetting title={$t('tags')} state={userPreferences.tags.enabled} />
|
||||
<FeatureSetting title={$t('gcast_enabled')} state={userPreferences.cast.gCastEnabled} />
|
||||
</Stack>
|
||||
</AdminCard>
|
||||
|
||||
<AdminCard icon={mdiChartPieOutline} title={$t('storage_quota')}>
|
||||
{#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0}
|
||||
<Text>
|
||||
{$t('storage_usage', {
|
||||
values: {
|
||||
used: getByteUnitString(usedBytes, $locale, 3),
|
||||
available: getByteUnitString(availableBytes, $locale, 3),
|
||||
},
|
||||
})}
|
||||
</Text>
|
||||
{:else}
|
||||
<Text class="flex items-center gap-1">
|
||||
<Icon icon={mdiCheckCircle} size="1.25rem" class="text-success" />
|
||||
{$t('unlimited')}
|
||||
</Text>
|
||||
{/if}
|
||||
|
||||
{#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0}
|
||||
<div
|
||||
class="storage-status p-4 mt-4 bg-gray-100 dark:bg-immich-dark-primary/10 rounded-lg text-sm w-full"
|
||||
title={$t('storage_usage', {
|
||||
values: {
|
||||
used: getByteUnitString(usedBytes, $locale, 3),
|
||||
available: getByteUnitString(availableBytes, $locale, 3),
|
||||
},
|
||||
})}
|
||||
>
|
||||
<p class="font-medium text-immich-dark-gray dark:text-white mb-2">{$t('storage')}</p>
|
||||
<div class="mt-4 h-[7px] w-full rounded-full bg-gray-200 dark:bg-gray-700">
|
||||
<div class="h-[7px] rounded-full {getUsageClass()}" style="width: {usedPercentage}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</AdminCard>
|
||||
|
||||
<AdminCard icon={mdiDevices} title={$t('authorized_devices')}>
|
||||
<Stack gap={3}>
|
||||
{#each userSessions as session (session.id)}
|
||||
<DeviceCard {session} />
|
||||
{:else}
|
||||
<span class="text-dark">{$t('no_devices')}</span>
|
||||
{/each}
|
||||
</Stack>
|
||||
</AdminCard>
|
||||
</div>
|
||||
|
||||
{@render children?.()}
|
||||
</Container>
|
||||
</div>
|
||||
</AdminPageLayout>
|
||||
@@ -1,38 +0,0 @@
|
||||
import { AppRoute, UUID_REGEX } from '$lib/constants';
|
||||
import { authenticate, requestServerInfo } from '$lib/utils/auth';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import { getUserPreferencesAdmin, getUserSessionsAdmin, getUserStatisticsAdmin, searchUsersAdmin } from '@immich/sdk';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { LayoutLoad } from './$types';
|
||||
|
||||
export const load = (async ({ params, url }) => {
|
||||
await authenticate(url, { admin: true });
|
||||
await requestServerInfo();
|
||||
|
||||
if (!UUID_REGEX.test(params.id)) {
|
||||
redirect(302, AppRoute.ADMIN_USERS);
|
||||
}
|
||||
|
||||
const [user] = await searchUsersAdmin({ id: params.id, withDeleted: true }).catch(() => []);
|
||||
if (!user) {
|
||||
redirect(302, AppRoute.ADMIN_USERS);
|
||||
}
|
||||
|
||||
const [userPreferences, userStatistics, userSessions] = await Promise.all([
|
||||
getUserPreferencesAdmin({ id: user.id }),
|
||||
getUserStatisticsAdmin({ id: user.id }),
|
||||
getUserSessionsAdmin({ id: user.id }),
|
||||
]);
|
||||
|
||||
const $t = await getFormatter();
|
||||
|
||||
return {
|
||||
user,
|
||||
userPreferences,
|
||||
userStatistics,
|
||||
userSessions,
|
||||
meta: {
|
||||
title: $t('admin.user_details'),
|
||||
},
|
||||
};
|
||||
}) satisfies LayoutLoad;
|
||||
@@ -0,0 +1,259 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import ServerStatisticsCard from '$lib/components/server-statistics/ServerStatisticsCard.svelte';
|
||||
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
|
||||
import DeviceCard from '$lib/components/user-settings-page/device-card.svelte';
|
||||
import FeatureSetting from '$lib/components/users/FeatureSetting.svelte';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { getUserAdminActions } from '$lib/services/user-admin.service';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { createDateFormatter, findLocale } from '$lib/utils';
|
||||
import { getBytesWithUnit } from '$lib/utils/byte-units';
|
||||
import { type UserAdminResponseDto } from '@immich/sdk';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Code,
|
||||
CommandPaletteContext,
|
||||
Container,
|
||||
getByteUnitString,
|
||||
Heading,
|
||||
Icon,
|
||||
MenuItemType,
|
||||
Stack,
|
||||
Text,
|
||||
} from '@immich/ui';
|
||||
import {
|
||||
mdiAccountOutline,
|
||||
mdiCameraIris,
|
||||
mdiChartPie,
|
||||
mdiChartPieOutline,
|
||||
mdiCheckCircle,
|
||||
mdiDevices,
|
||||
mdiFeatureSearchOutline,
|
||||
mdiPlayCircle,
|
||||
mdiTrashCanOutline,
|
||||
} from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
type Props = {
|
||||
data: PageData;
|
||||
};
|
||||
|
||||
const { data }: Props = $props();
|
||||
|
||||
let user = $derived(data.user);
|
||||
const userPreferences = $derived(data.userPreferences);
|
||||
const userStatistics = $derived(data.userStatistics);
|
||||
const userSessions = $derived(data.userSessions);
|
||||
const TiB = 1024 ** 4;
|
||||
const usage = $derived(user.quotaUsageInBytes ?? 0);
|
||||
let [statsUsage, statsUsageUnit] = $derived(getBytesWithUnit(usage, usage > TiB ? 2 : 0));
|
||||
const usedBytes = $derived(user.quotaUsageInBytes ?? 0);
|
||||
const availableBytes = $derived(user.quotaSizeInBytes ?? 1);
|
||||
let usedPercentage = $derived(Math.min(Math.round((usedBytes / availableBytes) * 100), 100));
|
||||
|
||||
let editedLocale = $derived(findLocale($locale).code);
|
||||
let createAtDate: Date = $derived(new Date(user.createdAt));
|
||||
let updatedAtDate: Date = $derived(new Date(user.updatedAt));
|
||||
let userCreatedAtDateAndTime: string = $derived(createDateFormatter(editedLocale).formatDateTime(createAtDate));
|
||||
let userUpdatedAtDateAndTime: string = $derived(createDateFormatter(editedLocale).formatDateTime(updatedAtDate));
|
||||
|
||||
const getUsageClass = () => {
|
||||
if (usedPercentage >= 95) {
|
||||
return 'bg-red-500';
|
||||
}
|
||||
|
||||
if (usedPercentage > 80) {
|
||||
return 'bg-yellow-500';
|
||||
}
|
||||
|
||||
return 'bg-primary';
|
||||
};
|
||||
|
||||
const { ResetPassword, ResetPinCode, Update, Delete, Restore } = $derived(getUserAdminActions($t, user));
|
||||
|
||||
const onUpdate = (update: UserAdminResponseDto) => {
|
||||
if (update.id === user.id) {
|
||||
user = update;
|
||||
}
|
||||
};
|
||||
|
||||
const onUserAdminDeleted = async ({ id }: { id: string }) => {
|
||||
if (id === user.id) {
|
||||
await goto(AppRoute.ADMIN_USERS);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<OnEvents
|
||||
onUserAdminUpdate={onUpdate}
|
||||
onUserAdminDelete={onUpdate}
|
||||
onUserAdminRestore={onUpdate}
|
||||
{onUserAdminDeleted}
|
||||
/>
|
||||
|
||||
<CommandPaletteContext commands={[ResetPassword, ResetPinCode, Update, Delete, Restore]} />
|
||||
|
||||
<AdminPageLayout
|
||||
breadcrumbs={[{ title: $t('admin.user_management'), href: AppRoute.ADMIN_USERS }, { title: user.name }]}
|
||||
actions={[ResetPassword, ResetPinCode, Update, Restore, MenuItemType.Divider, Delete]}
|
||||
>
|
||||
<div>
|
||||
<Container size="large" center>
|
||||
{#if user.deletedAt}
|
||||
<Alert color="danger" class="my-4" title={$t('user_has_been_deleted')} icon={mdiTrashCanOutline} />
|
||||
{/if}
|
||||
|
||||
<div class="grid gap-4 grid-cols-1 lg:grid-cols-2 w-full">
|
||||
<div class="col-span-full flex flex-col gap-4 my-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<UserAvatar {user} size="md" />
|
||||
<Heading tag="h1" size="large">{user.name}</Heading>
|
||||
</div>
|
||||
{#if user.isAdmin}
|
||||
<div>
|
||||
<Badge color="primary" size="small">{$t('admin.admin_user')}</Badge>
|
||||
</div>
|
||||
{/if}
|
||||
</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} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Card color="secondary">
|
||||
<CardHeader>
|
||||
<div class="flex items-center gap-2 px-4 py-2 text-primary">
|
||||
<Icon icon={mdiAccountOutline} size="1.5rem" />
|
||||
<CardTitle>{$t('profile')}</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div class="px-4 pb-7">
|
||||
<Stack gap={2}>
|
||||
<div>
|
||||
<Heading tag="h3" size="tiny">{$t('name')}</Heading>
|
||||
<Text>{user.name}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Heading tag="h3" size="tiny">{$t('email')}</Heading>
|
||||
<Text>{user.email}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Heading tag="h3" size="tiny">{$t('created_at')}</Heading>
|
||||
<Text>{userCreatedAtDateAndTime}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Heading tag="h3" size="tiny">{$t('updated_at')}</Heading>
|
||||
<Text>{userUpdatedAtDateAndTime}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Heading tag="h3" size="tiny">{$t('id')}</Heading>
|
||||
<Code>{user.id}</Code>
|
||||
</div>
|
||||
</Stack>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
<Card color="secondary">
|
||||
<CardHeader>
|
||||
<div class="flex items-center gap-2 px-4 py-2 text-primary">
|
||||
<Icon icon={mdiFeatureSearchOutline} size="1.5rem" />
|
||||
<CardTitle>{$t('features')}</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div class="px-4 pb-4">
|
||||
<Stack gap={3}>
|
||||
<FeatureSetting title={$t('email_notifications')} state={userPreferences.emailNotifications.enabled} />
|
||||
<FeatureSetting title={$t('folders')} state={userPreferences.folders.enabled} />
|
||||
<FeatureSetting title={$t('memories')} state={userPreferences.memories.enabled} />
|
||||
<FeatureSetting title={$t('people')} state={userPreferences.people.enabled} />
|
||||
<FeatureSetting title={$t('rating')} state={userPreferences.ratings.enabled} />
|
||||
<FeatureSetting title={$t('shared_links')} state={userPreferences.sharedLinks.enabled} />
|
||||
<FeatureSetting title={$t('show_supporter_badge')} state={userPreferences.purchase.showSupportBadge} />
|
||||
<FeatureSetting title={$t('tags')} state={userPreferences.tags.enabled} />
|
||||
<FeatureSetting title={$t('gcast_enabled')} state={userPreferences.cast.gCastEnabled} />
|
||||
</Stack>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
<Card color="secondary">
|
||||
<CardHeader>
|
||||
<div class="flex items-center gap-2 px-4 py-2 text-primary">
|
||||
<Icon icon={mdiChartPieOutline} size="1.5rem" />
|
||||
<CardTitle>{$t('storage_quota')}</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div class="px-4 pb-4">
|
||||
{#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0}
|
||||
<Text>
|
||||
{$t('storage_usage', {
|
||||
values: {
|
||||
used: getByteUnitString(usedBytes, $locale, 3),
|
||||
available: getByteUnitString(availableBytes, $locale, 3),
|
||||
},
|
||||
})}
|
||||
</Text>
|
||||
{:else}
|
||||
<Text class="flex items-center gap-1">
|
||||
<Icon icon={mdiCheckCircle} size="1.25rem" class="text-success" />
|
||||
{$t('unlimited')}
|
||||
</Text>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0}
|
||||
<div
|
||||
class="storage-status p-4 mt-4 bg-gray-100 dark:bg-immich-dark-primary/10 rounded-lg text-sm w-full"
|
||||
title={$t('storage_usage', {
|
||||
values: {
|
||||
used: getByteUnitString(usedBytes, $locale, 3),
|
||||
available: getByteUnitString(availableBytes, $locale, 3),
|
||||
},
|
||||
})}
|
||||
>
|
||||
<p class="font-medium text-immich-dark-gray dark:text-white mb-2">{$t('storage')}</p>
|
||||
<div class="mt-4 h-[7px] w-full rounded-full bg-gray-200 dark:bg-gray-700">
|
||||
<div class="h-[7px] rounded-full {getUsageClass()}" style="width: {usedPercentage}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</CardBody>
|
||||
</Card>
|
||||
<Card color="secondary">
|
||||
<CardHeader>
|
||||
<div class="flex items-center gap-2 px-4 py-2 text-primary">
|
||||
<Icon icon={mdiDevices} size="1.5rem" />
|
||||
<CardTitle>{$t('authorized_devices')}</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div class="px-4 pb-7">
|
||||
<Stack gap={3}>
|
||||
{#each userSessions as session (session.id)}
|
||||
<DeviceCard {session} />
|
||||
{:else}
|
||||
<span class="text-dark">{$t('no_devices')}</span>
|
||||
{/each}
|
||||
</Stack>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
</AdminPageLayout>
|
||||
|
||||
@@ -1,12 +1,31 @@
|
||||
import { authenticate } from '$lib/utils/auth';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { authenticate, requestServerInfo } from '$lib/utils/auth';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import { getUserPreferencesAdmin, getUserSessionsAdmin, getUserStatisticsAdmin, searchUsersAdmin } from '@immich/sdk';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (async ({ url }) => {
|
||||
export const load = (async ({ params, url }) => {
|
||||
await authenticate(url, { admin: true });
|
||||
await requestServerInfo();
|
||||
const [user] = await searchUsersAdmin({ id: params.id, withDeleted: true }).catch(() => []);
|
||||
if (!user) {
|
||||
redirect(302, AppRoute.ADMIN_USERS);
|
||||
}
|
||||
|
||||
const [userPreferences, userStatistics, userSessions] = await Promise.all([
|
||||
getUserPreferencesAdmin({ id: user.id }),
|
||||
getUserStatisticsAdmin({ id: user.id }),
|
||||
getUserSessionsAdmin({ id: user.id }),
|
||||
]);
|
||||
|
||||
const $t = await getFormatter();
|
||||
|
||||
return {
|
||||
user,
|
||||
userPreferences,
|
||||
userStatistics,
|
||||
userSessions,
|
||||
meta: {
|
||||
title: $t('admin.user_details'),
|
||||
},
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { authenticate } from '$lib/utils/auth';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (async ({ url }) => {
|
||||
await authenticate(url, { admin: true });
|
||||
|
||||
const $t = await getFormatter();
|
||||
|
||||
return {
|
||||
meta: {
|
||||
title: $t('admin.user_details'),
|
||||
},
|
||||
};
|
||||
}) satisfies PageLoad;
|
||||
Reference in New Issue
Block a user