mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
Merge branch 'main' into feat/mobile-platform-clients
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Generated
+6
@@ -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;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Generated
+9
-9
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user