Merge branch 'main' into feat/mobile-platform-clients

This commit is contained in:
Alex
2026-01-22 15:55:25 -06:00
committed by GitHub
41 changed files with 511 additions and 152 deletions
+1 -1
View File
@@ -131,7 +131,7 @@ jobs:
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup Mise - name: Setup Mise
uses: immich-app/devtools/actions/use-mise@b868e6e7c8cc212beec876330b4059e661ee44bb # use-mise-action-v1.1.1 uses: immich-app/devtools/actions/use-mise@cd24790a7f5f6439ac32cc94f5523cb2de8bfa8c # use-mise-action-v1.1.0
- name: Load parameters - name: Load parameters
id: parameters id: parameters
+1 -1
View File
@@ -29,7 +29,7 @@ jobs:
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
- name: Setup Mise - name: Setup Mise
uses: immich-app/devtools/actions/use-mise@b868e6e7c8cc212beec876330b4059e661ee44bb # use-mise-action-v1.1.1 uses: immich-app/devtools/actions/use-mise@cd24790a7f5f6439ac32cc94f5523cb2de8bfa8c # use-mise-action-v1.1.0
- name: Destroy Docs Subdomain - name: Destroy Docs Subdomain
env: env:
+6 -1
View File
@@ -502,7 +502,12 @@ jobs:
- name: Run e2e tests (web) - name: Run e2e tests (web)
env: env:
CI: true CI: true
run: npx playwright test run: npx playwright test --project=chromium
if: ${{ !cancelled() }}
- name: Run ui tests (web)
env:
CI: true
run: npx playwright test --project=ui
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
- name: Archive test results - name: Archive test results
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
+7 -7
View File
@@ -39,13 +39,13 @@ const config: PlaywrightTestConfig = {
testMatch: /.*\.e2e-spec\.ts/, testMatch: /.*\.e2e-spec\.ts/,
workers: 1, workers: 1,
}, },
// { {
// name: 'parallel tests', name: 'ui',
// use: { ...devices['Desktop Chrome'] }, use: { ...devices['Desktop Chrome'] },
// testMatch: /.*\.parallel-e2e-spec\.ts/, testMatch: /.*\.ui-spec\.ts/,
// fullyParallel: true, fullyParallel: true,
// workers: process.env.CI ? 3 : Math.max(1, Math.round(cpus().length * 0.75) - 1), workers: process.env.CI ? 3 : Math.max(1, Math.round(cpus().length * 0.75) - 1),
// }, },
// { // {
// name: 'firefox', // name: 'firefox',
@@ -30,3 +30,8 @@ class MultiSelectToggleEvent extends Event {
final bool isEnabled; final bool isEnabled;
const MultiSelectToggleEvent(this.isEnabled); const MultiSelectToggleEvent(this.isEnabled);
} }
// Map Events
class MapMarkerReloadEvent extends Event {
const MapMarkerReloadEvent();
}
+3 -1
View File
@@ -1,5 +1,6 @@
import 'package:immich_mobile/domain/models/map.model.dart'; import 'package:immich_mobile/domain/models/map.model.dart';
import 'package:immich_mobile/infrastructure/repositories/map.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/map.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:maplibre_gl/maplibre_gl.dart';
typedef MapMarkerSource = Future<List<Marker>> Function(LatLngBounds? bounds); typedef MapMarkerSource = Future<List<Marker>> Function(LatLngBounds? bounds);
@@ -11,7 +12,8 @@ class MapFactory {
const MapFactory({required DriftMapRepository mapRepository}) : _mapRepository = mapRepository; const MapFactory({required DriftMapRepository mapRepository}) : _mapRepository = mapRepository;
MapService remote(String ownerId) => MapService(_mapRepository.remote(ownerId)); MapService remote(List<String> ownerIds, TimelineMapOptions options) =>
MapService(_mapRepository.remote(ownerIds, options));
} }
class MapService { class MapService {
@@ -11,7 +11,6 @@ import 'package:immich_mobile/domain/services/setting.service.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
import 'package:immich_mobile/utils/async_mutex.dart'; import 'package:immich_mobile/utils/async_mutex.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
typedef TimelineAssetSource = Future<List<BaseAsset>> Function(int index, int count); typedef TimelineAssetSource = Future<List<BaseAsset>> Function(int index, int count);
@@ -82,8 +81,8 @@ class TimelineFactory {
TimelineService fromAssetsWithBuckets(List<BaseAsset> assets, TimelineOrigin type) => TimelineService fromAssetsWithBuckets(List<BaseAsset> assets, TimelineOrigin type) =>
TimelineService(_timelineRepository.fromAssetsWithBuckets(assets, type)); TimelineService(_timelineRepository.fromAssetsWithBuckets(assets, type));
TimelineService map(String userId, LatLngBounds bounds) => TimelineService map(List<String> userIds, TimelineMapOptions options) =>
TimelineService(_timelineRepository.map(userId, bounds, groupBy)); TimelineService(_timelineRepository.map(userIds, options, groupBy));
} }
class TimelineService { class TimelineService {
@@ -5,6 +5,7 @@ import 'package:immich_mobile/domain/services/map.service.dart';
import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:maplibre_gl/maplibre_gl.dart';
class DriftMapRepository extends DriftDatabaseRepository { class DriftMapRepository extends DriftDatabaseRepository {
@@ -12,9 +13,27 @@ class DriftMapRepository extends DriftDatabaseRepository {
const DriftMapRepository(super._db) : _db = _db; const DriftMapRepository(super._db) : _db = _db;
MapQuery remote(String ownerId) => _mapQueryBuilder( MapQuery remote(List<String> ownerIds, TimelineMapOptions options) => _mapQueryBuilder(
assetFilter: (row) => assetFilter: (row) {
row.deletedAt.isNull() & row.visibility.equalsValue(AssetVisibility.timeline) & row.ownerId.equals(ownerId), Expression<bool> condition =
row.deletedAt.isNull() &
row.ownerId.isIn(ownerIds) &
_db.remoteAssetEntity.visibility.isIn([
AssetVisibility.timeline.index,
if (options.includeArchived) AssetVisibility.archive.index,
]);
if (options.onlyFavorites) {
condition = condition & _db.remoteAssetEntity.isFavorite.equals(true);
}
if (options.relativeDays != 0) {
final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays));
condition = condition & _db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate);
}
return condition;
},
); );
MapQuery _mapQueryBuilder({Expression<bool> Function($RemoteAssetEntityTable row)? assetFilter}) { MapQuery _mapQueryBuilder({Expression<bool> Function($RemoteAssetEntityTable row)? assetFilter}) {
@@ -15,6 +15,22 @@ import 'package:immich_mobile/infrastructure/repositories/map.repository.dart';
import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:stream_transform/stream_transform.dart'; import 'package:stream_transform/stream_transform.dart';
class TimelineMapOptions {
final LatLngBounds bounds;
final bool onlyFavorites;
final bool includeArchived;
final bool withPartners;
final int relativeDays;
const TimelineMapOptions({
required this.bounds,
this.onlyFavorites = false,
this.includeArchived = false,
this.withPartners = false,
this.relativeDays = 0,
});
}
class DriftTimelineRepository extends DriftDatabaseRepository { class DriftTimelineRepository extends DriftDatabaseRepository {
final Drift _db; final Drift _db;
@@ -467,15 +483,15 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
return query.map((row) => row.readTable(_db.remoteAssetEntity).toDto()).get(); return query.map((row) => row.readTable(_db.remoteAssetEntity).toDto()).get();
} }
TimelineQuery map(String userId, LatLngBounds bounds, GroupAssetsBy groupBy) => ( TimelineQuery map(List<String> userIds, TimelineMapOptions options, GroupAssetsBy groupBy) => (
bucketSource: () => _watchMapBucket(userId, bounds, groupBy: groupBy), bucketSource: () => _watchMapBucket(userIds, options, groupBy: groupBy),
assetSource: (offset, count) => _getMapBucketAssets(userId, bounds, offset: offset, count: count), assetSource: (offset, count) => _getMapBucketAssets(userIds, options, offset: offset, count: count),
origin: TimelineOrigin.map, origin: TimelineOrigin.map,
); );
Stream<List<Bucket>> _watchMapBucket( Stream<List<Bucket>> _watchMapBucket(
String userId, List<String> userId,
LatLngBounds bounds, { TimelineMapOptions options, {
GroupAssetsBy groupBy = GroupAssetsBy.day, GroupAssetsBy groupBy = GroupAssetsBy.day,
}) { }) {
if (groupBy == GroupAssetsBy.none) { if (groupBy == GroupAssetsBy.none) {
@@ -496,14 +512,26 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
), ),
]) ])
..where( ..where(
_db.remoteAssetEntity.ownerId.equals(userId) & _db.remoteAssetEntity.ownerId.isIn(userId) &
_db.remoteExifEntity.inBounds(bounds) & _db.remoteExifEntity.inBounds(options.bounds) &
_db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.timeline) & _db.remoteAssetEntity.visibility.isIn([
AssetVisibility.timeline.index,
if (options.includeArchived) AssetVisibility.archive.index,
]) &
_db.remoteAssetEntity.deletedAt.isNull(), _db.remoteAssetEntity.deletedAt.isNull(),
) )
..groupBy([dateExp]) ..groupBy([dateExp])
..orderBy([OrderingTerm.desc(dateExp)]); ..orderBy([OrderingTerm.desc(dateExp)]);
if (options.onlyFavorites) {
query.where(_db.remoteAssetEntity.isFavorite.equals(true));
}
if (options.relativeDays != 0) {
final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays));
query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate));
}
return query.map((row) { return query.map((row) {
final timeline = row.read(dateExp)!.truncateDate(groupBy); final timeline = row.read(dateExp)!.truncateDate(groupBy);
final assetCount = row.read(assetCountExp)!; final assetCount = row.read(assetCountExp)!;
@@ -512,8 +540,8 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
} }
Future<List<BaseAsset>> _getMapBucketAssets( Future<List<BaseAsset>> _getMapBucketAssets(
String userId, List<String> userId,
LatLngBounds bounds, { TimelineMapOptions options, {
required int offset, required int offset,
required int count, required int count,
}) { }) {
@@ -526,13 +554,26 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
), ),
]) ])
..where( ..where(
_db.remoteAssetEntity.ownerId.equals(userId) & _db.remoteAssetEntity.ownerId.isIn(userId) &
_db.remoteExifEntity.inBounds(bounds) & _db.remoteExifEntity.inBounds(options.bounds) &
_db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.timeline) & _db.remoteAssetEntity.visibility.isIn([
AssetVisibility.timeline.index,
if (options.includeArchived) AssetVisibility.archive.index,
]) &
_db.remoteAssetEntity.deletedAt.isNull(), _db.remoteAssetEntity.deletedAt.isNull(),
) )
..orderBy([OrderingTerm.desc(_db.remoteAssetEntity.createdAt)]) ..orderBy([OrderingTerm.desc(_db.remoteAssetEntity.createdAt)])
..limit(count, offset: offset); ..limit(count, offset: offset);
if (options.onlyFavorites) {
query.where(_db.remoteAssetEntity.isFavorite.equals(true));
}
if (options.relativeDays != 0) {
final cutoffDate = DateTime.now().toUtc().subtract(Duration(days: options.relativeDays));
query.where(_db.remoteAssetEntity.createdAt.isBiggerOrEqualValue(cutoffDate));
}
return query.map((row) => row.readTable(_db.remoteAssetEntity).toDto()).get(); return query.map((row) => row.readTable(_db.remoteAssetEntity).toDto()).get();
} }
@@ -2,6 +2,7 @@ import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/map/map.widget.dart'; import 'package:immich_mobile/presentation/widgets/map/map.widget.dart';
import 'package:immich_mobile/presentation/widgets/map/map_settings_sheet.dart';
import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:maplibre_gl/maplibre_gl.dart';
@RoutePage() @RoutePage()
@@ -10,6 +11,16 @@ class DriftMapPage extends StatelessWidget {
const DriftMapPage({super.key, this.initialLocation}); const DriftMapPage({super.key, this.initialLocation});
void onSettingsPressed(BuildContext context) {
showModalBottomSheet(
elevation: 0.0,
showDragHandle: true,
isScrollControlled: true,
context: context,
builder: (_) => const DriftMapSettingsSheet(),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@@ -18,8 +29,8 @@ class DriftMapPage extends StatelessWidget {
children: [ children: [
DriftMap(initialLocation: initialLocation), DriftMap(initialLocation: initialLocation),
Positioned( Positioned(
left: 16, left: 20,
top: 60, top: 70,
child: IconButton.filled( child: IconButton.filled(
color: Colors.white, color: Colors.white,
onPressed: () => context.pop(), onPressed: () => context.pop(),
@@ -32,6 +43,21 @@ class DriftMapPage extends StatelessWidget {
), ),
), ),
), ),
Positioned(
right: 20,
top: 70,
child: IconButton.filled(
color: Colors.white,
onPressed: () => onSettingsPressed(context),
icon: const Icon(Icons.more_vert_rounded),
style: IconButton.styleFrom(
padding: const EdgeInsets.all(8),
backgroundColor: Colors.indigo,
shadowColor: Colors.black26,
elevation: 4,
),
),
),
], ],
), ),
); );
@@ -118,7 +118,6 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
bool dragInProgress = false; bool dragInProgress = false;
bool shouldPopOnDrag = false; bool shouldPopOnDrag = false;
bool assetReloadRequested = false; bool assetReloadRequested = false;
double? initialScale;
double previousExtent = _kBottomSheetMinimumExtent; double previousExtent = _kBottomSheetMinimumExtent;
Offset dragDownPosition = Offset.zero; Offset dragDownPosition = Offset.zero;
int totalAssets = 0; int totalAssets = 0;
@@ -264,7 +263,6 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
(context.height * bottomSheetController.size) - (context.height * _kBottomSheetMinimumExtent); (context.height * bottomSheetController.size) - (context.height * _kBottomSheetMinimumExtent);
controller.position = Offset(0, -verticalOffset); controller.position = Offset(0, -verticalOffset);
// Apply the zoom effect when the bottom sheet is showing // Apply the zoom effect when the bottom sheet is showing
initialScale = controller.scale;
controller.scale = (controller.scale ?? 1.0) + 0.01; controller.scale = (controller.scale ?? 1.0) + 0.01;
} }
} }
@@ -316,7 +314,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
hasDraggedDown = null; hasDraggedDown = null;
viewController?.animateMultiple( viewController?.animateMultiple(
position: initialPhotoViewState.position, position: initialPhotoViewState.position,
scale: initialPhotoViewState.scale, scale: viewController?.initialScale ?? initialPhotoViewState.scale,
rotation: initialPhotoViewState.rotation, rotation: initialPhotoViewState.rotation,
); );
ref.read(assetViewerProvider.notifier).setOpacity(255); ref.read(assetViewerProvider.notifier).setOpacity(255);
@@ -366,8 +364,9 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
final maxScaleDistance = ctx.height * 0.5; final maxScaleDistance = ctx.height * 0.5;
final scaleReduction = (distance / maxScaleDistance).clamp(0.0, dragRatio); final scaleReduction = (distance / maxScaleDistance).clamp(0.0, dragRatio);
double? updatedScale; double? updatedScale;
if (initialPhotoViewState.scale != null) { double? initialScale = viewController?.initialScale ?? initialPhotoViewState.scale;
updatedScale = initialPhotoViewState.scale! * (1.0 - scaleReduction); if (initialScale != null) {
updatedScale = initialScale * (1.0 - scaleReduction);
} }
final backgroundOpacity = (255 * (1.0 - (scaleReduction / dragRatio))).round(); final backgroundOpacity = (255 * (1.0 - (scaleReduction / dragRatio))).round();
@@ -481,8 +480,6 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
void _openBottomSheet(BuildContext ctx, {double extent = _kBottomSheetMinimumExtent, bool activitiesMode = false}) { void _openBottomSheet(BuildContext ctx, {double extent = _kBottomSheetMinimumExtent, bool activitiesMode = false}) {
ref.read(assetViewerProvider.notifier).setBottomSheet(true); ref.read(assetViewerProvider.notifier).setBottomSheet(true);
initialScale = viewController?.scale;
// viewController?.updateMultiple(scale: (viewController?.scale ?? 1.0) + 0.01);
previousExtent = _kBottomSheetMinimumExtent; previousExtent = _kBottomSheetMinimumExtent;
sheetCloseController = showBottomSheet( sheetCloseController = showBottomSheet(
context: ctx, context: ctx,
@@ -504,7 +501,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
void _handleSheetClose() { void _handleSheetClose() {
viewController?.animateMultiple(position: Offset.zero); viewController?.animateMultiple(position: Offset.zero);
viewController?.updateMultiple(scale: initialScale); viewController?.updateMultiple(scale: viewController?.initialScale);
ref.read(assetViewerProvider.notifier).setBottomSheet(false); ref.read(assetViewerProvider.notifier).setBottomSheet(false);
sheetCloseController = null; sheetCloseController = null;
shouldPopOnDrag = false; shouldPopOnDrag = false;
@@ -14,7 +14,7 @@ class MapBottomSheet extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BaseBottomSheet( return BaseBottomSheet(
initialChildSize: 0.25, initialChildSize: 0.25,
maxChildSize: 0.9, maxChildSize: 0.75,
shouldCloseOnMinExtent: false, shouldCloseOnMinExtent: false,
resizeOnScroll: false, resizeOnScroll: false,
actions: [], actions: [],
@@ -38,8 +38,13 @@ class _ScopedMapTimeline extends StatelessWidget {
throw Exception('User must be logged in to access archive'); throw Exception('User must be logged in to access archive');
} }
final bounds = ref.watch(mapStateProvider).bounds; final users = ref.watch(mapStateProvider).withPartners
final timelineService = ref.watch(timelineFactoryProvider).map(user.id, bounds); ? ref.watch(timelineUsersProvider).valueOrNull ?? [user.id]
: [user.id];
final timelineService = ref
.watch(timelineFactoryProvider)
.map(users, ref.watch(mapStateProvider).toOptions());
ref.onDispose(timelineService.dispose); ref.onDispose(timelineService.dispose);
return timelineService; return timelineService;
}), }),
@@ -1,11 +1,30 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/map.provider.dart'; import 'package:immich_mobile/providers/infrastructure/map.provider.dart';
import 'package:immich_mobile/providers/map/map_state.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:maplibre_gl/maplibre_gl.dart';
class MapState { class MapState {
final ThemeMode themeMode;
final LatLngBounds bounds; final LatLngBounds bounds;
final bool onlyFavorites;
final bool includeArchived;
final bool withPartners;
final int relativeDays;
const MapState({required this.bounds}); const MapState({
this.themeMode = ThemeMode.system,
required this.bounds,
this.onlyFavorites = false,
this.includeArchived = false,
this.withPartners = false,
this.relativeDays = 0,
});
@override @override
bool operator ==(covariant MapState other) { bool operator ==(covariant MapState other) {
@@ -15,9 +34,31 @@ class MapState {
@override @override
int get hashCode => bounds.hashCode; int get hashCode => bounds.hashCode;
MapState copyWith({LatLngBounds? bounds}) { MapState copyWith({
return MapState(bounds: bounds ?? this.bounds); LatLngBounds? bounds,
ThemeMode? themeMode,
bool? onlyFavorites,
bool? includeArchived,
bool? withPartners,
int? relativeDays,
}) {
return MapState(
bounds: bounds ?? this.bounds,
themeMode: themeMode ?? this.themeMode,
onlyFavorites: onlyFavorites ?? this.onlyFavorites,
includeArchived: includeArchived ?? this.includeArchived,
withPartners: withPartners ?? this.withPartners,
relativeDays: relativeDays ?? this.relativeDays,
);
} }
TimelineMapOptions toOptions() => TimelineMapOptions(
bounds: bounds,
onlyFavorites: onlyFavorites,
includeArchived: includeArchived,
withPartners: withPartners,
relativeDays: relativeDays,
);
} }
class MapStateNotifier extends Notifier<MapState> { class MapStateNotifier extends Notifier<MapState> {
@@ -31,11 +72,50 @@ class MapStateNotifier extends Notifier<MapState> {
return true; return true;
} }
void switchTheme(ThemeMode mode) {
// TODO: Remove this line when map theme provider is removed
// Until then, keep both in sync as MapThemeOverride uses map state provider
// ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.mapThemeMode, mode.index);
ref.read(mapStateNotifierProvider.notifier).switchTheme(mode);
state = state.copyWith(themeMode: mode);
}
void switchFavoriteOnly(bool isFavoriteOnly) {
ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.mapShowFavoriteOnly, isFavoriteOnly);
state = state.copyWith(onlyFavorites: isFavoriteOnly);
EventStream.shared.emit(const MapMarkerReloadEvent());
}
void switchIncludeArchived(bool isIncludeArchived) {
ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.mapIncludeArchived, isIncludeArchived);
state = state.copyWith(includeArchived: isIncludeArchived);
EventStream.shared.emit(const MapMarkerReloadEvent());
}
void switchWithPartners(bool isWithPartners) {
ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.mapwithPartners, isWithPartners);
state = state.copyWith(withPartners: isWithPartners);
EventStream.shared.emit(const MapMarkerReloadEvent());
}
void setRelativeTime(int relativeDays) {
ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.mapRelativeDate, relativeDays);
state = state.copyWith(relativeDays: relativeDays);
EventStream.shared.emit(const MapMarkerReloadEvent());
}
@override @override
MapState build() => MapState( MapState build() {
// TODO: set default bounds final appSettingsService = ref.read(appSettingsServiceProvider);
bounds: LatLngBounds(northeast: const LatLng(0, 0), southwest: const LatLng(0, 0)), return MapState(
); themeMode: ThemeMode.values[appSettingsService.getSetting(AppSettingsEnum.mapThemeMode)],
onlyFavorites: appSettingsService.getSetting(AppSettingsEnum.mapShowFavoriteOnly),
includeArchived: appSettingsService.getSetting(AppSettingsEnum.mapIncludeArchived),
withPartners: appSettingsService.getSetting(AppSettingsEnum.mapwithPartners),
relativeDays: appSettingsService.getSetting(AppSettingsEnum.mapRelativeDate),
bounds: LatLngBounds(northeast: const LatLng(0, 0), southwest: const LatLng(0, 0)),
);
}
} }
// This provider watches the markers from the map service and serves the markers. // This provider watches the markers from the map service and serves the markers.
@@ -6,6 +6,8 @@ import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart'; import 'package:fluttertoast/fluttertoast.dart';
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
@@ -51,11 +53,19 @@ class _DriftMapState extends ConsumerState<DriftMap> {
final _reloadMutex = AsyncMutex(); final _reloadMutex = AsyncMutex();
final _debouncer = Debouncer(interval: const Duration(milliseconds: 500), maxWaitTime: const Duration(seconds: 2)); final _debouncer = Debouncer(interval: const Duration(milliseconds: 500), maxWaitTime: const Duration(seconds: 2));
final ValueNotifier<double> bottomSheetOffset = ValueNotifier(0.25); final ValueNotifier<double> bottomSheetOffset = ValueNotifier(0.25);
StreamSubscription? _eventSubscription;
@override
void initState() {
super.initState();
_eventSubscription = EventStream.shared.listen<MapMarkerReloadEvent>(_onEvent);
}
@override @override
void dispose() { void dispose() {
_debouncer.dispose(); _debouncer.dispose();
bottomSheetOffset.dispose(); bottomSheetOffset.dispose();
_eventSubscription?.cancel();
super.dispose(); super.dispose();
} }
@@ -63,6 +73,8 @@ class _DriftMapState extends ConsumerState<DriftMap> {
mapController = controller; mapController = controller;
} }
void _onEvent(_) => _debouncer.run(() => setBounds(forceReload: true));
Future<void> onMapReady() async { Future<void> onMapReady() async {
final controller = mapController; final controller = mapController;
if (controller == null) { if (controller == null) {
@@ -98,7 +110,7 @@ class _DriftMapState extends ConsumerState<DriftMap> {
); );
} }
_debouncer.run(setBounds); _debouncer.run(() => setBounds(forceReload: true));
controller.addListener(onMapMoved); controller.addListener(onMapMoved);
} }
@@ -110,7 +122,7 @@ class _DriftMapState extends ConsumerState<DriftMap> {
_debouncer.run(setBounds); _debouncer.run(setBounds);
} }
Future<void> setBounds() async { Future<void> setBounds({bool forceReload = false}) async {
final controller = mapController; final controller = mapController;
if (controller == null || !mounted) { if (controller == null || !mounted) {
return; return;
@@ -127,7 +139,7 @@ class _DriftMapState extends ConsumerState<DriftMap> {
final bounds = await controller.getVisibleRegion(); final bounds = await controller.getVisibleRegion();
unawaited( unawaited(
_reloadMutex.run(() async { _reloadMutex.run(() async {
if (mounted && ref.read(mapStateProvider.notifier).setBounds(bounds)) { if (mounted && (ref.read(mapStateProvider.notifier).setBounds(bounds) || forceReload)) {
final markers = await ref.read(mapMarkerProvider(bounds).future); final markers = await ref.read(mapMarkerProvider(bounds).future);
await reloadMarkers(markers); await reloadMarkers(markers);
} }
@@ -203,7 +215,7 @@ class _Map extends StatelessWidget {
onMapCreated: onMapCreated, onMapCreated: onMapCreated,
onStyleLoadedCallback: onMapReady, onStyleLoadedCallback: onMapReady,
attributionButtonPosition: AttributionButtonPosition.topRight, attributionButtonPosition: AttributionButtonPosition.topRight,
attributionButtonMargins: Platform.isIOS ? const Point(40, 12) : const Point(40, 72), attributionButtonMargins: const Point(8, kToolbarHeight),
), ),
), ),
); );
@@ -244,7 +256,7 @@ class _DynamicMyLocationButton extends StatelessWidget {
valueListenable: bottomSheetOffset, valueListenable: bottomSheetOffset,
builder: (context, offset, child) { builder: (context, offset, child) {
return Positioned( return Positioned(
right: 16, right: 20,
bottom: context.height * (offset - 0.02) + context.padding.bottom, bottom: context.height * (offset - 0.02) + context.padding.bottom,
child: AnimatedOpacity( child: AnimatedOpacity(
opacity: offset < 0.8 ? 1 : 0, opacity: offset < 0.8 ? 1 : 0,
@@ -0,0 +1,61 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/map/map.state.dart';
import 'package:immich_mobile/widgets/map/map_settings/map_settings_list_tile.dart';
import 'package:immich_mobile/widgets/map/map_settings/map_settings_time_dropdown.dart';
import 'package:immich_mobile/widgets/map/map_settings/map_theme_picker.dart';
class DriftMapSettingsSheet extends HookConsumerWidget {
const DriftMapSettingsSheet({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final mapState = ref.watch(mapStateProvider);
return DraggableScrollableSheet(
expand: false,
initialChildSize: 0.6,
builder: (ctx, scrollController) => SingleChildScrollView(
controller: scrollController,
child: Card(
elevation: 0.0,
shadowColor: Colors.transparent,
color: Colors.transparent,
margin: EdgeInsets.zero,
child: Column(
mainAxisSize: MainAxisSize.max,
children: [
MapThemePicker(
themeMode: mapState.themeMode,
onThemeChange: (mode) => ref.read(mapStateProvider.notifier).switchTheme(mode),
),
const Divider(height: 30, thickness: 1),
MapSettingsListTile(
title: "map_settings_only_show_favorites".t(context: context),
selected: mapState.onlyFavorites,
onChanged: (favoriteOnly) => ref.read(mapStateProvider.notifier).switchFavoriteOnly(favoriteOnly),
),
MapSettingsListTile(
title: "map_settings_include_show_archived".t(context: context),
selected: mapState.includeArchived,
onChanged: (includeArchive) =>
ref.read(mapStateProvider.notifier).switchIncludeArchived(includeArchive),
),
MapSettingsListTile(
title: "map_settings_include_show_partners".t(context: context),
selected: mapState.withPartners,
onChanged: (withPartners) => ref.read(mapStateProvider.notifier).switchWithPartners(withPartners),
),
MapTimeDropDown(
relativeTime: mapState.relativeDays,
onTimeChange: (time) => ref.read(mapStateProvider.notifier).setRelativeTime(time),
),
const SizedBox(height: 20),
],
),
),
),
);
}
}
@@ -1,7 +1,9 @@
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/infrastructure/repositories/map.repository.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/domain/services/map.service.dart'; import 'package:immich_mobile/domain/services/map.service.dart';
import 'package:immich_mobile/infrastructure/repositories/map.repository.dart';
import 'package:immich_mobile/presentation/widgets/map/map.state.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
final mapRepositoryProvider = Provider<DriftMapRepository>((ref) => DriftMapRepository(ref.watch(driftProvider))); final mapRepositoryProvider = Provider<DriftMapRepository>((ref) => DriftMapRepository(ref.watch(driftProvider)));
@@ -13,7 +15,11 @@ final mapServiceProvider = Provider<MapService>(
throw Exception('User must be logged in to access map'); throw Exception('User must be logged in to access map');
} }
final mapService = ref.watch(mapFactoryProvider).remote(user.id); final users = ref.watch(mapStateProvider).withPartners
? ref.watch(timelineUsersProvider).valueOrNull ?? [user.id]
: [user.id];
final mapService = ref.watch(mapFactoryProvider).remote(users, ref.watch(mapStateProvider).toOptions());
return mapService; return mapService;
}, },
// Empty dependencies to inform the framework that this provider // Empty dependencies to inform the framework that this provider
@@ -1,4 +1,3 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
@@ -14,7 +13,7 @@ class MapSettingsListTile extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SwitchListTile.adaptive( return SwitchListTile.adaptive(
activeThumbColor: context.primaryColor, activeThumbColor: context.primaryColor,
title: Text(title, style: context.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold)).tr(), title: Text(title, style: context.textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w500, height: 1.5)),
value: selected, value: selected,
onChanged: onChanged, onChanged: onChanged,
); );
@@ -1,5 +1,7 @@
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
class MapTimeDropDown extends StatelessWidget { class MapTimeDropDown extends StatelessWidget {
final int relativeTime; final int relativeTime;
@@ -11,41 +13,47 @@ class MapTimeDropDown extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final now = DateTime.now(); final now = DateTime.now();
return Column( return Padding(
crossAxisAlignment: CrossAxisAlignment.center, padding: const EdgeInsets.only(left: 16, right: 28.0),
children: [ child: Row(
Padding( crossAxisAlignment: CrossAxisAlignment.center,
padding: const EdgeInsets.only(bottom: 20), mainAxisAlignment: MainAxisAlignment.spaceBetween,
child: Text("date_range".tr(), style: const TextStyle(fontWeight: FontWeight.bold)), children: [
), Text(
LayoutBuilder( "date_range".t(context: context),
builder: (_, constraints) => DropdownMenu( style: context.textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w500, height: 1.5),
width: constraints.maxWidth * 0.9,
enableSearch: false,
enableFilter: false,
initialSelection: relativeTime,
onSelected: (value) => onTimeChange(value!),
dropdownMenuEntries: [
DropdownMenuEntry(value: 0, label: "all".tr()),
DropdownMenuEntry(value: 1, label: "map_settings_date_range_option_day".tr()),
DropdownMenuEntry(value: 7, label: "map_settings_date_range_option_days".tr(namedArgs: {'days': "7"})),
DropdownMenuEntry(value: 30, label: "map_settings_date_range_option_days".tr(namedArgs: {'days': "30"})),
DropdownMenuEntry(
value: now
.difference(DateTime(now.year - 1, now.month, now.day, now.hour, now.minute, now.second))
.inDays,
label: "map_settings_date_range_option_year".tr(),
),
DropdownMenuEntry(
value: now
.difference(DateTime(now.year - 3, now.month, now.day, now.hour, now.minute, now.second))
.inDays,
label: "map_settings_date_range_option_years".tr(namedArgs: {'years': "3"}),
),
],
), ),
), Flexible(
], child: DropdownMenu(
enableSearch: false,
enableFilter: false,
initialSelection: relativeTime,
onSelected: (value) => onTimeChange(value!),
dropdownMenuEntries: [
DropdownMenuEntry(value: 0, label: "all".t(context: context)),
DropdownMenuEntry(value: 1, label: "map_settings_date_range_option_day".t(context: context)),
DropdownMenuEntry(value: 7, label: "map_settings_date_range_option_days".tr(namedArgs: {'days': "7"})),
DropdownMenuEntry(
value: 30,
label: "map_settings_date_range_option_days".tr(namedArgs: {'days': "30"}),
),
DropdownMenuEntry(
value: now
.difference(DateTime(now.year - 1, now.month, now.day, now.hour, now.minute, now.second))
.inDays,
label: "map_settings_date_range_option_year".t(context: context),
),
DropdownMenuEntry(
value: now
.difference(DateTime(now.year - 3, now.month, now.day, now.hour, now.minute, now.second))
.inDays,
label: "map_settings_date_range_option_years".t(args: {'years': "3"}),
),
],
),
),
],
),
); );
} }
} }
@@ -1,6 +1,6 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/widgets/map/map_thumbnail.dart'; import 'package:immich_mobile/widgets/map/map_thumbnail.dart';
import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:maplibre_gl/maplibre_gl.dart';
@@ -18,9 +18,9 @@ class MapThemePicker extends StatelessWidget {
padding: const EdgeInsets.only(bottom: 20), padding: const EdgeInsets.only(bottom: 20),
child: Center( child: Center(
child: Text( child: Text(
"map_settings_theme_settings", "map_settings_theme_settings".t(context: context),
style: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold), style: context.textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w500, height: 1.5),
).tr(), ),
), ),
), ),
Row( Row(
@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:immich_mobile/widgets/photo_view/src/utils/ignorable_change_notifier.dart'; import 'package:immich_mobile/widgets/photo_view/src/utils/ignorable_change_notifier.dart';
import 'package:immich_mobile/widgets/photo_view/src/utils/photo_view_utils.dart';
/// The interface in which controllers will be implemented. /// The interface in which controllers will be implemented.
/// ///
@@ -62,6 +63,9 @@ abstract class PhotoViewControllerBase<T extends PhotoViewControllerValue> {
/// The scale factor to transform the child (image or a customChild). /// The scale factor to transform the child (image or a customChild).
late double? scale; late double? scale;
double? get initialScale;
ScaleBoundaries? scaleBoundaries;
/// Nevermind this method :D, look away /// Nevermind this method :D, look away
void setScaleInvisibly(double? scale); void setScaleInvisibly(double? scale);
@@ -141,6 +145,9 @@ class PhotoViewController implements PhotoViewControllerBase<PhotoViewController
late StreamController<PhotoViewControllerValue> _outputCtrl; late StreamController<PhotoViewControllerValue> _outputCtrl;
@override
ScaleBoundaries? scaleBoundaries;
late void Function(Offset)? _animatePosition; late void Function(Offset)? _animatePosition;
late void Function(double)? _animateScale; late void Function(double)? _animateScale;
late void Function(double)? _animateRotation; late void Function(double)? _animateRotation;
@@ -311,4 +318,7 @@ class PhotoViewController implements PhotoViewControllerBase<PhotoViewController
} }
_valueNotifier.value = newValue; _valueNotifier.value = newValue;
} }
@override
double? get initialScale => scaleBoundaries?.initialScale ?? initial.scale;
} }
@@ -108,6 +108,17 @@ class _ImageWrapperState extends State<ImageWrapper> {
} }
} }
// Should be called only when _imageSize is not null
ScaleBoundaries get scaleBoundaries {
return ScaleBoundaries(
widget.minScale ?? 0.0,
widget.maxScale ?? double.infinity,
widget.initialScale ?? PhotoViewComputedScale.contained,
widget.outerSize,
_imageSize!,
);
}
// retrieve image from the provider // retrieve image from the provider
void _resolveImage() { void _resolveImage() {
final ImageStream newStream = widget.imageProvider.resolve(const ImageConfiguration()); final ImageStream newStream = widget.imageProvider.resolve(const ImageConfiguration());
@@ -133,6 +144,7 @@ class _ImageWrapperState extends State<ImageWrapper> {
_lastStack = null; _lastStack = null;
_didLoadSynchronously = synchronousCall; _didLoadSynchronously = synchronousCall;
widget.controller.scaleBoundaries = scaleBoundaries;
} }
synchronousCall && !_didLoadSynchronously ? setupCB() : setState(setupCB); synchronousCall && !_didLoadSynchronously ? setupCB() : setState(setupCB);
@@ -204,14 +216,6 @@ class _ImageWrapperState extends State<ImageWrapper> {
); );
} }
final scaleBoundaries = ScaleBoundaries(
widget.minScale ?? 0.0,
widget.maxScale ?? double.infinity,
widget.initialScale ?? PhotoViewComputedScale.contained,
widget.outerSize,
_imageSize!,
);
return PhotoViewCore( return PhotoViewCore(
imageProvider: widget.imageProvider, imageProvider: widget.imageProvider,
backgroundDecoration: widget.backgroundDecoration, backgroundDecoration: widget.backgroundDecoration,
+6
View File
@@ -82,6 +82,8 @@ class Permission {
static const timelinePeriodRead = Permission._(r'timeline.read'); static const timelinePeriodRead = Permission._(r'timeline.read');
static const timelinePeriodDownload = Permission._(r'timeline.download'); static const timelinePeriodDownload = Permission._(r'timeline.download');
static const maintenance = Permission._(r'maintenance'); static const maintenance = Permission._(r'maintenance');
static const mapPeriodRead = Permission._(r'map.read');
static const mapPeriodSearch = Permission._(r'map.search');
static const memoryPeriodCreate = Permission._(r'memory.create'); static const memoryPeriodCreate = Permission._(r'memory.create');
static const memoryPeriodRead = Permission._(r'memory.read'); static const memoryPeriodRead = Permission._(r'memory.read');
static const memoryPeriodUpdate = Permission._(r'memory.update'); static const memoryPeriodUpdate = Permission._(r'memory.update');
@@ -238,6 +240,8 @@ class Permission {
timelinePeriodRead, timelinePeriodRead,
timelinePeriodDownload, timelinePeriodDownload,
maintenance, maintenance,
mapPeriodRead,
mapPeriodSearch,
memoryPeriodCreate, memoryPeriodCreate,
memoryPeriodRead, memoryPeriodRead,
memoryPeriodUpdate, memoryPeriodUpdate,
@@ -429,6 +433,8 @@ class PermissionTypeTransformer {
case r'timeline.read': return Permission.timelinePeriodRead; case r'timeline.read': return Permission.timelinePeriodRead;
case r'timeline.download': return Permission.timelinePeriodDownload; case r'timeline.download': return Permission.timelinePeriodDownload;
case r'maintenance': return Permission.maintenance; case r'maintenance': return Permission.maintenance;
case r'map.read': return Permission.mapPeriodRead;
case r'map.search': return Permission.mapPeriodSearch;
case r'memory.create': return Permission.memoryPeriodCreate; case r'memory.create': return Permission.memoryPeriodCreate;
case r'memory.read': return Permission.memoryPeriodRead; case r'memory.read': return Permission.memoryPeriodRead;
case r'memory.update': return Permission.memoryPeriodUpdate; case r'memory.update': return Permission.memoryPeriodUpdate;
+4
View File
@@ -6305,6 +6305,7 @@
"state": "Stable" "state": "Stable"
} }
], ],
"x-immich-permission": "map.read",
"x-immich-state": "Stable" "x-immich-state": "Stable"
} }
}, },
@@ -6376,6 +6377,7 @@
"state": "Stable" "state": "Stable"
} }
], ],
"x-immich-permission": "map.search",
"x-immich-state": "Stable" "x-immich-state": "Stable"
} }
}, },
@@ -18966,6 +18968,8 @@
"timeline.read", "timeline.read",
"timeline.download", "timeline.download",
"maintenance", "maintenance",
"map.read",
"map.search",
"memory.create", "memory.create",
"memory.read", "memory.read",
"memory.update", "memory.update",
@@ -5534,6 +5534,8 @@ export enum Permission {
TimelineRead = "timeline.read", TimelineRead = "timeline.read",
TimelineDownload = "timeline.download", TimelineDownload = "timeline.download",
Maintenance = "maintenance", Maintenance = "maintenance",
MapRead = "map.read",
MapSearch = "map.search",
MemoryCreate = "memory.create", MemoryCreate = "memory.create",
MemoryRead = "memory.read", MemoryRead = "memory.read",
MemoryUpdate = "memory.update", MemoryUpdate = "memory.update",
+9 -9
View File
@@ -36,7 +36,7 @@ importers:
version: 1.20.1 version: 1.20.1
lodash-es: lodash-es:
specifier: ^4.17.21 specifier: ^4.17.21
version: 4.17.22 version: 4.17.23
micromatch: micromatch:
specifier: ^4.0.8 specifier: ^4.0.8
version: 4.0.8 version: 4.0.8
@@ -802,7 +802,7 @@ importers:
version: 4.1.0 version: 4.1.0
lodash-es: lodash-es:
specifier: ^4.17.21 specifier: ^4.17.21
version: 4.17.22 version: 4.17.23
luxon: luxon:
specifier: ^3.4.4 specifier: ^3.4.4
version: 3.7.2 version: 3.7.2
@@ -8916,8 +8916,8 @@ packages:
lodash-es@4.17.21: lodash-es@4.17.21:
resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==} resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==}
lodash-es@4.17.22: lodash-es@4.17.23:
resolution: {integrity: sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==} resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==}
lodash.camelcase@4.3.0: lodash.camelcase@4.3.0:
resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==}
@@ -19332,7 +19332,7 @@ snapshots:
chevrotain-allstar@0.3.1(chevrotain@11.0.3): chevrotain-allstar@0.3.1(chevrotain@11.0.3):
dependencies: dependencies:
chevrotain: 11.0.3 chevrotain: 11.0.3
lodash-es: 4.17.22 lodash-es: 4.17.23
chevrotain@11.0.3: chevrotain@11.0.3:
dependencies: dependencies:
@@ -20027,7 +20027,7 @@ snapshots:
dagre-d3-es@7.0.13: dagre-d3-es@7.0.13:
dependencies: dependencies:
d3: 7.9.0 d3: 7.9.0
lodash-es: 4.17.22 lodash-es: 4.17.23
data-urls@3.0.2: data-urls@3.0.2:
dependencies: dependencies:
@@ -22376,7 +22376,7 @@ snapshots:
lodash-es@4.17.21: {} lodash-es@4.17.21: {}
lodash-es@4.17.22: {} lodash-es@4.17.23: {}
lodash.camelcase@4.3.0: {} lodash.camelcase@4.3.0: {}
@@ -22810,7 +22810,7 @@ snapshots:
dompurify: 3.3.1 dompurify: 3.3.1
katex: 0.16.27 katex: 0.16.27
khroma: 2.1.0 khroma: 2.1.0
lodash-es: 4.17.22 lodash-es: 4.17.23
marked: 16.4.2 marked: 16.4.2
roughjs: 4.6.6 roughjs: 4.6.6
stylis: 4.3.6 stylis: 4.3.6
@@ -25714,7 +25714,7 @@ snapshots:
json-source-map: 0.6.1 json-source-map: 0.6.1
jsonpath-plus: 10.3.0 jsonpath-plus: 10.3.0
jsonrepair: 3.13.1 jsonrepair: 3.13.1
lodash-es: 4.17.22 lodash-es: 4.17.23
memoize-one: 6.0.0 memoize-one: 6.0.0
natural-compare-lite: 1.4.0 natural-compare-lite: 1.4.0
sass: 1.97.1 sass: 1.97.1
+3 -8
View File
@@ -38,11 +38,6 @@
<a href="README_th_TH.md">ภาษาไทย</a> <a href="README_th_TH.md">ภาษาไทย</a>
</p> </p>
## Warnung
- ⚠️ Das Projekt befindet sich in **sehr aktiver** Entwicklung.
- ⚠️ Gehe von möglichen Fehlern und von Änderungen mit Breaking-Changes aus.
- ⚠️ **Nutze die App auf keinen Fall als einziges Speichermedium für deine Fotos und Videos.**
- ⚠️ Befolge immer die [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) Backup-Regel für deine wertvollen Fotos und Videos! - ⚠️ Befolge immer die [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) Backup-Regel für deine wertvollen Fotos und Videos!
> [!NOTE] > [!NOTE]
@@ -62,7 +57,7 @@
## Demo ## Demo
Die Web-Demo kannst Du unter https://demo.immich.app finden. Für die Handy-App kannst Du `https://demo.immich.app` als `Server Endpoint URL` angeben. Die Web-Demo kannst Du unter https://demo.immich.app finden. Für die Smartphone-App kannst Du `https://demo.immich.app` als `Server Endpoint URL` angeben.
### Login Daten ### Login Daten
@@ -93,7 +88,7 @@ Die Web-Demo kannst Du unter https://demo.immich.app finden. Für die Handy-App
| LivePhoto/MotionPhoto Sicherung und Wiedergabe | Ja | Ja | | LivePhoto/MotionPhoto Sicherung und Wiedergabe | Ja | Ja |
| Unterstützung für 360-Grad-Bilder | Nein | Ja | | Unterstützung für 360-Grad-Bilder | Nein | Ja |
| Benutzerdefinierte Speicherstruktur | Ja | Ja | | Benutzerdefinierte Speicherstruktur | Ja | Ja |
| Öffentliches Teilen | Nein | Ja | | Öffentliches Teilen | Ja | Ja |
| Archiv und Favoriten | Ja | Ja | | Archiv und Favoriten | Ja | Ja |
| Globale Karte | Ja | Ja | | Globale Karte | Ja | Ja |
| Partnerfreigabe (Teilen) | Ja | Ja | | Partnerfreigabe (Teilen) | Ja | Ja |
@@ -103,7 +98,7 @@ Die Web-Demo kannst Du unter https://demo.immich.app finden. Für die Handy-App
| Schreibgeschützte Gallerie | Ja | Ja | | Schreibgeschützte Gallerie | Ja | Ja |
| Gestapelte Bilder | Ja | Ja | | Gestapelte Bilder | Ja | Ja |
| Tags | Nein | Ja | | Tags | Nein | Ja |
| Ordner-Ansicht | Nein | Ja | | Ordner-Ansicht | Ja | Ja |
## Übersetzungen ## Übersetzungen
+3 -3
View File
@@ -8,7 +8,7 @@ import {
MapReverseGeocodeDto, MapReverseGeocodeDto,
MapReverseGeocodeResponseDto, MapReverseGeocodeResponseDto,
} from 'src/dtos/map.dto'; } from 'src/dtos/map.dto';
import { ApiTag } from 'src/enum'; import { ApiTag, Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { MapService } from 'src/services/map.service'; import { MapService } from 'src/services/map.service';
@@ -18,7 +18,7 @@ export class MapController {
constructor(private service: MapService) {} constructor(private service: MapService) {}
@Get('markers') @Get('markers')
@Authenticated() @Authenticated({ permission: Permission.MapRead })
@Endpoint({ @Endpoint({
summary: 'Retrieve map markers', summary: 'Retrieve map markers',
description: 'Retrieve a list of latitude and longitude coordinates for every asset with location data.', description: 'Retrieve a list of latitude and longitude coordinates for every asset with location data.',
@@ -28,8 +28,8 @@ export class MapController {
return this.service.getMapMarkers(auth, options); return this.service.getMapMarkers(auth, options);
} }
@Authenticated()
@Get('reverse-geocode') @Get('reverse-geocode')
@Authenticated({ permission: Permission.MapSearch })
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Endpoint({ @Endpoint({
summary: 'Reverse geocode coordinates', summary: 'Reverse geocode coordinates',
+3
View File
@@ -160,6 +160,9 @@ export enum Permission {
Maintenance = 'maintenance', Maintenance = 'maintenance',
MapRead = 'map.read',
MapSearch = 'map.search',
MemoryCreate = 'memory.create', MemoryCreate = 'memory.create',
MemoryRead = 'memory.read', MemoryRead = 'memory.read',
MemoryUpdate = 'memory.update', MemoryUpdate = 'memory.update',
@@ -15,3 +15,5 @@ from
"asset_edit" "asset_edit"
where where
"assetId" = $1 "assetId" = $1
order by
"sequence" asc
@@ -12,14 +12,14 @@ export class AssetEditRepository {
@GenerateSql({ @GenerateSql({
params: [DummyValue.UUID], params: [DummyValue.UUID],
}) })
async replaceAll(assetId: string, edits: AssetEditActionItem[]): Promise<AssetEditActionItem[]> { replaceAll(assetId: string, edits: AssetEditActionItem[]): Promise<AssetEditActionItem[]> {
return await this.db.transaction().execute(async (trx) => { return this.db.transaction().execute(async (trx) => {
await trx.deleteFrom('asset_edit').where('assetId', '=', assetId).execute(); await trx.deleteFrom('asset_edit').where('assetId', '=', assetId).execute();
if (edits.length > 0) { if (edits.length > 0) {
return trx return trx
.insertInto('asset_edit') .insertInto('asset_edit')
.values(edits.map((edit) => ({ assetId, ...edit }))) .values(edits.map((edit, i) => ({ assetId, sequence: i, ...edit })))
.returning(['action', 'parameters']) .returning(['action', 'parameters'])
.execute() as Promise<AssetEditActionItem[]>; .execute() as Promise<AssetEditActionItem[]>;
} }
@@ -31,11 +31,12 @@ export class AssetEditRepository {
@GenerateSql({ @GenerateSql({
params: [DummyValue.UUID], params: [DummyValue.UUID],
}) })
async getAll(assetId: string): Promise<AssetEditActionItem[]> { getAll(assetId: string): Promise<AssetEditActionItem[]> {
return this.db return this.db
.selectFrom('asset_edit') .selectFrom('asset_edit')
.select(['action', 'parameters']) .select(['action', 'parameters'])
.where('assetId', '=', assetId) .where('assetId', '=', assetId)
.orderBy('sequence', 'asc')
.execute() as Promise<AssetEditActionItem[]>; .execute() as Promise<AssetEditActionItem[]>;
} }
} }
@@ -0,0 +1,14 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`DELETE FROM "asset_edit";`.execute(db);
await sql`ALTER TABLE "asset_edit" ADD "sequence" integer NOT NULL;`.execute(db);
await sql`ALTER TABLE "asset_edit" ADD CONSTRAINT "asset_edit_assetId_sequence_uq" UNIQUE ("assetId", "sequence");`.execute(
db,
);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "asset_edit" DROP CONSTRAINT "asset_edit_assetId_sequence_uq";`.execute(db);
await sql`ALTER TABLE "asset_edit" DROP COLUMN "sequence";`.execute(db);
}
@@ -9,6 +9,7 @@ import {
Generated, Generated,
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
Table, Table,
Unique,
} from 'src/sql-tools'; } from 'src/sql-tools';
@Table('asset_edit') @Table('asset_edit')
@@ -19,6 +20,7 @@ import {
referencingOldTableAs: 'deleted_edit', referencingOldTableAs: 'deleted_edit',
when: 'pg_trigger_depth() = 0', when: 'pg_trigger_depth() = 0',
}) })
@Unique({ columns: ['assetId', 'sequence'] })
export class AssetEditTable<T extends AssetEditAction = AssetEditAction> { export class AssetEditTable<T extends AssetEditAction = AssetEditAction> {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id!: Generated<string>; id!: Generated<string>;
@@ -31,4 +33,7 @@ export class AssetEditTable<T extends AssetEditAction = AssetEditAction> {
@Column({ type: 'jsonb' }) @Column({ type: 'jsonb' })
parameters!: AssetEditActionParameter[T]; parameters!: AssetEditActionParameter[T];
@Column({ type: 'integer' })
sequence!: number;
} }
+5 -5
View File
@@ -206,15 +206,15 @@ describe(TagService.name, () => {
count: 6, count: 6,
}); });
expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
{ assetId: 'asset-1', tags: ['tag-1', 'tag-2'] }, { assetId: 'asset-1', lockedProperties: ['tags'], tags: ['tag-1', 'tag-2'] },
{ lockedPropertiesBehavior: 'append' }, { lockedPropertiesBehavior: 'append' },
); );
expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
{ assetId: 'asset-2', tags: ['tag-1', 'tag-2'] }, { assetId: 'asset-2', lockedProperties: ['tags'], tags: ['tag-1', 'tag-2'] },
{ lockedPropertiesBehavior: 'append' }, { lockedPropertiesBehavior: 'append' },
); );
expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
{ assetId: 'asset-3', tags: ['tag-1', 'tag-2'] }, { assetId: 'asset-3', lockedProperties: ['tags'], tags: ['tag-1', 'tag-2'] },
{ lockedPropertiesBehavior: 'append' }, { lockedPropertiesBehavior: 'append' },
); );
expect(mocks.tag.upsertAssetIds).toHaveBeenCalledWith([ expect(mocks.tag.upsertAssetIds).toHaveBeenCalledWith([
@@ -255,11 +255,11 @@ describe(TagService.name, () => {
]); ]);
expect(mocks.asset.upsertExif).not.toHaveBeenCalledWith( expect(mocks.asset.upsertExif).not.toHaveBeenCalledWith(
{ assetId: 'asset-1', tags: ['tag-1'] }, { assetId: 'asset-1', lockedProperties: ['tags'], tags: ['tag-1'] },
{ lockedPropertiesBehavior: 'append' }, { lockedPropertiesBehavior: 'append' },
); );
expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
{ assetId: 'asset-2', tags: ['tag-1'] }, { assetId: 'asset-2', lockedProperties: ['tags'], tags: ['tag-1'] },
{ lockedPropertiesBehavior: 'append' }, { lockedPropertiesBehavior: 'append' },
); );
expect(mocks.tag.getAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1', 'asset-2']); expect(mocks.tag.getAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1', 'asset-2']);
+2 -1
View File
@@ -16,6 +16,7 @@ import { JobName, JobStatus, Permission, QueueName } from 'src/enum';
import { TagAssetTable } from 'src/schema/tables/tag-asset.table'; import { TagAssetTable } from 'src/schema/tables/tag-asset.table';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { addAssets, removeAssets } from 'src/utils/asset.util'; import { addAssets, removeAssets } from 'src/utils/asset.util';
import { updateLockedColumns } from 'src/utils/database';
import { upsertTags } from 'src/utils/tag'; import { upsertTags } from 'src/utils/tag';
@Injectable() @Injectable()
@@ -152,7 +153,7 @@ export class TagService extends BaseService {
private async updateTags(assetId: string) { private async updateTags(assetId: string) {
const asset = await this.assetRepository.getById(assetId, { tags: true }); const asset = await this.assetRepository.getById(assetId, { tags: true });
await this.assetRepository.upsertExif( await this.assetRepository.upsertExif(
{ assetId, tags: asset?.tags?.map(({ value }) => value) ?? [] }, updateLockedColumns({ assetId, tags: asset?.tags?.map(({ value }) => value) ?? [] }),
{ lockedPropertiesBehavior: 'append' }, { lockedPropertiesBehavior: 'append' },
); );
} }
@@ -1,12 +1,15 @@
import { Kysely } from 'kysely'; import { Kysely } from 'kysely';
import { JobStatus } from 'src/enum'; import { JobStatus } from 'src/enum';
import { AccessRepository } from 'src/repositories/access.repository'; import { AccessRepository } from 'src/repositories/access.repository';
import { AssetRepository } from 'src/repositories/asset.repository';
import { EventRepository } from 'src/repositories/event.repository';
import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoggingRepository } from 'src/repositories/logging.repository';
import { TagRepository } from 'src/repositories/tag.repository'; import { TagRepository } from 'src/repositories/tag.repository';
import { DB } from 'src/schema'; import { DB } from 'src/schema';
import { TagService } from 'src/services/tag.service'; import { TagService } from 'src/services/tag.service';
import { upsertTags } from 'src/utils/tag'; import { upsertTags } from 'src/utils/tag';
import { newMediumService } from 'test/medium.factory'; import { newMediumService } from 'test/medium.factory';
import { factory } from 'test/small.factory';
import { getKyselyDB } from 'test/utils'; import { getKyselyDB } from 'test/utils';
let defaultDatabase: Kysely<DB>; let defaultDatabase: Kysely<DB>;
@@ -14,8 +17,8 @@ let defaultDatabase: Kysely<DB>;
const setup = (db?: Kysely<DB>) => { const setup = (db?: Kysely<DB>) => {
return newMediumService(TagService, { return newMediumService(TagService, {
database: db || defaultDatabase, database: db || defaultDatabase,
real: [TagRepository, AccessRepository], real: [AssetRepository, TagRepository, AccessRepository],
mock: [LoggingRepository], mock: [EventRepository, LoggingRepository],
}); });
}; };
@@ -24,6 +27,32 @@ beforeAll(async () => {
}); });
describe(TagService.name, () => { describe(TagService.name, () => {
describe('addAssets', () => {
it('should lock exif column', async () => {
const { sut, ctx } = setup();
ctx.getMock(EventRepository).emit.mockResolvedValue();
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
const [tag] = await upsertTags(ctx.get(TagRepository), { userId: user.id, tags: ['tag-1'] });
const authDto = factory.auth({ user });
await sut.addAssets(authDto, tag.id, { ids: [asset.id] });
await expect(
ctx.database
.selectFrom('asset_exif')
.select(['lockedProperties', 'tags'])
.where('assetId', '=', asset.id)
.executeTakeFirstOrThrow(),
).resolves.toEqual({
lockedProperties: ['tags'],
tags: ['tag-1'],
});
await expect(ctx.get(TagRepository).getByValue(user.id, 'tag-1')).resolves.toEqual(
expect.objectContaining({ id: tag.id }),
);
await expect(ctx.get(TagRepository).getAssetIds(tag.id, [asset.id])).resolves.toContain(asset.id);
});
});
describe('deleteEmptyTags', () => { describe('deleteEmptyTags', () => {
it('single tag exists, not connected to any assets, and is deleted', async () => { it('single tag exists, not connected to any assets, and is deleted', async () => {
const { sut, ctx } = setup(); const { sut, ctx } = setup();
@@ -431,6 +431,7 @@
const showOcrButton = $derived( const showOcrButton = $derived(
$slideshowState === SlideshowState.None && $slideshowState === SlideshowState.None &&
asset.type === AssetTypeEnum.Image && asset.type === AssetTypeEnum.Image &&
!(asset.exifInfo?.projectionType === 'EQUIRECTANGULAR') &&
!isShowEditor && !isShowEditor &&
ocrManager.hasOcrData, ocrManager.hasOcrData,
); );
+8 -1
View File
@@ -23,6 +23,7 @@ import {
import { modalManager, toastManager, type ActionItem } from '@immich/ui'; import { modalManager, toastManager, type ActionItem } from '@immich/ui';
import { import {
mdiDeleteRestore, mdiDeleteRestore,
mdiInformationOutline,
mdiLockReset, mdiLockReset,
mdiLockSmart, mdiLockSmart,
mdiPencilOutline, mdiPencilOutline,
@@ -46,6 +47,12 @@ export const getUserAdminsActions = ($t: MessageFormatter) => {
}; };
export const getUserAdminActions = ($t: MessageFormatter, user: UserAdminResponseDto) => { export const getUserAdminActions = ($t: MessageFormatter, user: UserAdminResponseDto) => {
const Detail: ActionItem = {
icon: mdiInformationOutline,
title: $t('details'),
onAction: () => goto(Route.viewUser(user)),
};
const Update: ActionItem = { const Update: ActionItem = {
icon: mdiPencilOutline, icon: mdiPencilOutline,
title: $t('edit'), title: $t('edit'),
@@ -92,7 +99,7 @@ export const getUserAdminActions = ($t: MessageFormatter, user: UserAdminRespons
onAction: () => handleResetPinCodeUserAdmin(user), onAction: () => handleResetPinCodeUserAdmin(user),
}; };
return { Update, Delete, Restore, ResetPassword, ResetPinCode }; return { Detail, Update, Delete, Restore, ResetPassword, ResetPinCode };
}; };
export const handleCreateUserAdmin = async (dto: UserAdminCreateDto) => { export const handleCreateUserAdmin = async (dto: UserAdminCreateDto) => {
@@ -1,15 +1,18 @@
<script lang="ts"> <script lang="ts">
import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte'; import AdminPageLayout from '$lib/components/layouts/AdminPageLayout.svelte';
import OnEvents from '$lib/components/OnEvents.svelte'; import OnEvents from '$lib/components/OnEvents.svelte';
import { getUserAdminsActions, handleNavigateUserAdmin } from '$lib/services/user-admin.service'; import { Route } from '$lib/route';
import { getUserAdminActions, getUserAdminsActions } from '$lib/services/user-admin.service';
import { locale } from '$lib/stores/preferences.store'; import { locale } from '$lib/stores/preferences.store';
import { getByteUnitString } from '$lib/utils/byte-units'; import { getByteUnitString } from '$lib/utils/byte-units';
import { searchUsersAdmin, type UserAdminResponseDto } from '@immich/sdk'; import { searchUsersAdmin, type UserAdminResponseDto } from '@immich/sdk';
import { import {
Button,
CommandPaletteDefaultProvider, CommandPaletteDefaultProvider,
Container, Container,
ContextMenuButton,
Icon, Icon,
Link,
MenuItemType,
Table, Table,
TableBody, TableBody,
TableCell, TableCell,
@@ -46,11 +49,16 @@
const { Create } = $derived(getUserAdminsActions($t)); const { Create } = $derived(getUserAdminsActions($t));
const getActionsForUser = (user: UserAdminResponseDto) => {
const { Detail, Update, Delete, ResetPassword, ResetPinCode } = getUserAdminActions($t, user);
return [Detail, Update, ResetPassword, ResetPinCode, MenuItemType.Divider, Delete];
};
const classes = { const classes = {
column1: 'w-8/12 sm:w-5/12 lg:w-6/12 xl:w-4/12 2xl:w-5/12', column1: 'w-8/12 md:w-5/12 lg:w-4/12',
column2: 'hidden sm:block w-3/12', column2: 'hidden md:block md:w-5/12 lg:w-4/12',
column3: 'hidden xl:block w-3/12 2xl:w-2/12', column3: 'hidden lg:block lg:w-2/12',
column4: 'w-4/12 lg:w-3/12 xl:w-2/12', column4: 'w-4/12 md:w-2/12 flex justify-end',
}; };
</script> </script>
@@ -68,16 +76,18 @@
<Container center size="large"> <Container center size="large">
<Table class="mt-4" striped spacing="small" size="small"> <Table class="mt-4" striped spacing="small" size="small">
<TableHeader> <TableHeader>
<TableHeading class={classes.column1}>{$t('email')}</TableHeading> <TableHeading class={classes.column1}>{$t('name')}</TableHeading>
<TableHeading class={classes.column2}>{$t('name')}</TableHeading> <TableHeading class={classes.column2}>{$t('email')}</TableHeading>
<TableHeading class={classes.column3}>{$t('has_quota')}</TableHeading> <TableHeading class={classes.column3}>{$t('has_quota')}</TableHeading>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{#each users as user (user.id)} {#each users as user (user.id)}
<TableRow color={user.deletedAt ? 'danger' : undefined}> <TableRow color={user.deletedAt ? 'danger' : undefined}>
<TableCell class={classes.column1}>{user.email}</TableCell> <TableCell class={classes.column1}>
<TableCell class={classes.column2}>{user.name}</TableCell> <Link href={Route.viewUser(user)}>{user.name}</Link>
</TableCell>
<TableCell class={classes.column2}>{user.email}</TableCell>
<TableCell class={classes.column3}> <TableCell class={classes.column3}>
<div class="container mx-auto flex flex-wrap justify-center"> <div class="container mx-auto flex flex-wrap justify-center">
{#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0} {#if user.quotaSizeInBytes !== null && user.quotaSizeInBytes >= 0}
@@ -88,7 +98,7 @@
</div> </div>
</TableCell> </TableCell>
<TableCell class={classes.column4}> <TableCell class={classes.column4}>
<Button onclick={() => handleNavigateUserAdmin(user)}>{$t('view')}</Button> <ContextMenuButton color="primary" aria-label={$t('open')} items={getActionsForUser(user)} />
</TableCell> </TableCell>
</TableRow> </TableRow>
{/each} {/each}
@@ -198,8 +198,8 @@
})} })}
> >
<p class="font-medium text-immich-dark-gray dark:text-white mb-2">{$t('storage')}</p> <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="mt-4 h-1.75 w-full rounded-full bg-gray-200 dark:bg-gray-700">
<div class="h-[7px] rounded-full {getUsageClass()}" style="width: {usedPercentage}%"></div> <div class="h-1.75 rounded-full {getUsageClass()}" style="width: {usedPercentage}%"></div>
</div> </div>
</div> </div>
{/if} {/if}