mirror of
https://github.com/immich-app/immich.git
synced 2025-12-28 17:24:56 +03:00
Compare commits
1 Commits
bring-back
...
lower-case
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a62435d5a |
@@ -82,57 +82,6 @@ class StorageRepository {
|
||||
return entity;
|
||||
}
|
||||
|
||||
Future<bool> isAssetAvailableLocally(String assetId) async {
|
||||
final log = Logger('StorageRepository');
|
||||
|
||||
try {
|
||||
final entity = await AssetEntity.fromId(assetId);
|
||||
if (entity == null) {
|
||||
log.warning("Cannot get AssetEntity for asset $assetId");
|
||||
return false;
|
||||
}
|
||||
|
||||
return await entity.isLocallyAvailable(isOrigin: true);
|
||||
} catch (error, stackTrace) {
|
||||
log.warning("Error checking if asset is locally available $assetId", error, stackTrace);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<File?> loadFileFromCloud(String assetId, {PMProgressHandler? progressHandler}) async {
|
||||
final log = Logger('StorageRepository');
|
||||
|
||||
try {
|
||||
final entity = await AssetEntity.fromId(assetId);
|
||||
if (entity == null) {
|
||||
log.warning("Cannot get AssetEntity for asset $assetId");
|
||||
return null;
|
||||
}
|
||||
|
||||
return await entity.loadFile(progressHandler: progressHandler);
|
||||
} catch (error, stackTrace) {
|
||||
log.warning("Error loading file from cloud for asset $assetId", error, stackTrace);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<File?> loadMotionFileFromCloud(String assetId, {PMProgressHandler? progressHandler}) async {
|
||||
final log = Logger('StorageRepository');
|
||||
|
||||
try {
|
||||
final entity = await AssetEntity.fromId(assetId);
|
||||
if (entity == null) {
|
||||
log.warning("Cannot get AssetEntity for asset $assetId");
|
||||
return null;
|
||||
}
|
||||
|
||||
return await entity.loadFile(withSubtype: true, progressHandler: progressHandler);
|
||||
} catch (error, stackTrace) {
|
||||
log.warning("Error loading motion file from cloud for asset $assetId", error, stackTrace);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> clearCache() async {
|
||||
final log = Logger('StorageRepository');
|
||||
|
||||
|
||||
@@ -97,8 +97,7 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
|
||||
}
|
||||
|
||||
Future<void> stopBackup() async {
|
||||
// await backupNotifier.cancel();
|
||||
await backupNotifier.stopBackup();
|
||||
await backupNotifier.cancel();
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
|
||||
@@ -17,7 +17,6 @@ class DriftUploadDetailPage extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final uploadItems = ref.watch(driftBackupProvider.select((state) => state.uploadItems));
|
||||
final iCloudProgress = ref.watch(driftBackupProvider.select((state) => state.iCloudDownloadProgress));
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
@@ -26,9 +25,7 @@ class DriftUploadDetailPage extends ConsumerWidget {
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 1,
|
||||
),
|
||||
body: uploadItems.isEmpty && iCloudProgress.isEmpty
|
||||
? _buildEmptyState(context)
|
||||
: _buildUploadList(uploadItems, iCloudProgress),
|
||||
body: uploadItems.isEmpty ? _buildEmptyState(context) : _buildUploadList(uploadItems),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,110 +45,19 @@ class DriftUploadDetailPage extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildUploadList(Map<String, DriftUploadStatus> uploadItems, Map<String, double> iCloudProgress) {
|
||||
final totalItems = uploadItems.length + iCloudProgress.length;
|
||||
|
||||
Widget _buildUploadList(Map<String, DriftUploadStatus> uploadItems) {
|
||||
return ListView.separated(
|
||||
addAutomaticKeepAlives: true,
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: totalItems,
|
||||
itemCount: uploadItems.length,
|
||||
separatorBuilder: (context, index) => const SizedBox(height: 4),
|
||||
itemBuilder: (context, index) {
|
||||
// Show iCloud downloads first
|
||||
if (index < iCloudProgress.length) {
|
||||
final entry = iCloudProgress.entries.elementAt(index);
|
||||
return _buildICloudDownloadCard(context, entry.key, entry.value);
|
||||
}
|
||||
|
||||
// Then show upload items
|
||||
final uploadIndex = index - iCloudProgress.length;
|
||||
final item = uploadItems.values.elementAt(uploadIndex);
|
||||
final item = uploadItems.values.elementAt(index);
|
||||
return _buildUploadCard(context, item);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildICloudDownloadCard(BuildContext context, String assetId, double progress) {
|
||||
final double progressPercentage = (progress * 100).clamp(0, 100);
|
||||
|
||||
return Card(
|
||||
elevation: 0,
|
||||
color: context.colorScheme.surfaceContainerHighest,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(16)),
|
||||
side: BorderSide(color: context.colorScheme.outline.withValues(alpha: 0.1), width: 1),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.cloud_download_rounded, size: 20, color: context.colorScheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 4,
|
||||
children: [
|
||||
Text(
|
||||
"Downloading from iCloud",
|
||||
style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Text(
|
||||
assetId,
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_buildICloudProgressIndicator(context, progress, progressPercentage),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildICloudProgressIndicator(BuildContext context, double progress, double percentage) {
|
||||
return Column(
|
||||
children: [
|
||||
Stack(
|
||||
alignment: AlignmentDirectional.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 36,
|
||||
height: 36,
|
||||
child: TweenAnimationBuilder(
|
||||
tween: Tween<double>(begin: 0.0, end: progress),
|
||||
duration: const Duration(milliseconds: 300),
|
||||
builder: (context, value, _) => CircularProgressIndicator(
|
||||
backgroundColor: context.colorScheme.outline.withValues(alpha: 0.2),
|
||||
strokeWidth: 3,
|
||||
value: value,
|
||||
color: context.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
percentage.toStringAsFixed(0),
|
||||
style: context.textTheme.labelSmall?.copyWith(fontWeight: FontWeight.bold, fontSize: 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text("iCloud", style: context.textTheme.labelSmall?.copyWith(color: context.colorScheme.primary, fontSize: 10)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildUploadCard(BuildContext context, DriftUploadStatus item) {
|
||||
final isCompleted = item.progress >= 1.0;
|
||||
final double progressPercentage = (item.progress * 100).clamp(0, 100);
|
||||
|
||||
@@ -67,9 +67,7 @@ class BackupToggleButtonState extends ConsumerState<BackupToggleButton> with Sin
|
||||
|
||||
final isSyncing = ref.watch(driftBackupProvider.select((state) => state.isSyncing));
|
||||
|
||||
final iCloudProgress = ref.watch(driftBackupProvider.select((state) => state.iCloudDownloadProgress));
|
||||
|
||||
final isProcessing = uploadTasks.isNotEmpty || isSyncing || iCloudProgress.isNotEmpty;
|
||||
final isProcessing = uploadTasks.isNotEmpty || isSyncing;
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: _animationController,
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
// ignore_for_file: public_member_api_docs, sort_constructors_first
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:background_downloader/background_downloader.dart';
|
||||
import 'package:cancellation_token_http/http.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
import 'package:immich_mobile/constants/constants.dart';
|
||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
@@ -15,6 +13,7 @@ import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/services/upload.service.dart';
|
||||
import 'package:immich_mobile/utils/debug_print.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
class EnqueueStatus {
|
||||
final int enqueueCount;
|
||||
@@ -115,9 +114,6 @@ class DriftBackupState {
|
||||
final BackupError error;
|
||||
|
||||
final Map<String, DriftUploadStatus> uploadItems;
|
||||
final CancellationToken? cancelToken;
|
||||
|
||||
final Map<String, double> iCloudDownloadProgress;
|
||||
|
||||
const DriftBackupState({
|
||||
required this.totalCount,
|
||||
@@ -126,12 +122,10 @@ class DriftBackupState {
|
||||
required this.processingCount,
|
||||
required this.enqueueCount,
|
||||
required this.enqueueTotalCount,
|
||||
required this.isSyncing,
|
||||
required this.isCanceling,
|
||||
this.error = BackupError.none,
|
||||
required this.isSyncing,
|
||||
required this.uploadItems,
|
||||
this.cancelToken,
|
||||
this.iCloudDownloadProgress = const {},
|
||||
this.error = BackupError.none,
|
||||
});
|
||||
|
||||
DriftBackupState copyWith({
|
||||
@@ -141,12 +135,10 @@ class DriftBackupState {
|
||||
int? processingCount,
|
||||
int? enqueueCount,
|
||||
int? enqueueTotalCount,
|
||||
bool? isSyncing,
|
||||
bool? isCanceling,
|
||||
BackupError? error,
|
||||
bool? isSyncing,
|
||||
Map<String, DriftUploadStatus>? uploadItems,
|
||||
CancellationToken? cancelToken,
|
||||
Map<String, double>? iCloudDownloadProgress,
|
||||
BackupError? error,
|
||||
}) {
|
||||
return DriftBackupState(
|
||||
totalCount: totalCount ?? this.totalCount,
|
||||
@@ -155,18 +147,16 @@ class DriftBackupState {
|
||||
processingCount: processingCount ?? this.processingCount,
|
||||
enqueueCount: enqueueCount ?? this.enqueueCount,
|
||||
enqueueTotalCount: enqueueTotalCount ?? this.enqueueTotalCount,
|
||||
isSyncing: isSyncing ?? this.isSyncing,
|
||||
isCanceling: isCanceling ?? this.isCanceling,
|
||||
error: error ?? this.error,
|
||||
isSyncing: isSyncing ?? this.isSyncing,
|
||||
uploadItems: uploadItems ?? this.uploadItems,
|
||||
cancelToken: cancelToken ?? this.cancelToken,
|
||||
iCloudDownloadProgress: iCloudDownloadProgress ?? this.iCloudDownloadProgress,
|
||||
error: error ?? this.error,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'DriftBackupState(totalCount: $totalCount, backupCount: $backupCount, remainderCount: $remainderCount, processingCount: $processingCount, enqueueCount: $enqueueCount, enqueueTotalCount: $enqueueTotalCount, isSyncing: $isSyncing, isCanceling: $isCanceling, error: $error, uploadItems: $uploadItems, cancelToken: $cancelToken, iCloudDownloadProgress: $iCloudDownloadProgress)';
|
||||
return 'DriftBackupState(totalCount: $totalCount, backupCount: $backupCount, remainderCount: $remainderCount, processingCount: $processingCount, enqueueCount: $enqueueCount, enqueueTotalCount: $enqueueTotalCount, isCanceling: $isCanceling, isSyncing: $isSyncing, uploadItems: $uploadItems, error: $error)';
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -180,12 +170,10 @@ class DriftBackupState {
|
||||
other.processingCount == processingCount &&
|
||||
other.enqueueCount == enqueueCount &&
|
||||
other.enqueueTotalCount == enqueueTotalCount &&
|
||||
other.isSyncing == isSyncing &&
|
||||
other.isCanceling == isCanceling &&
|
||||
other.error == error &&
|
||||
mapEquals(other.iCloudDownloadProgress, iCloudDownloadProgress) &&
|
||||
other.isSyncing == isSyncing &&
|
||||
mapEquals(other.uploadItems, uploadItems) &&
|
||||
other.cancelToken == cancelToken;
|
||||
other.error == error;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -196,12 +184,10 @@ class DriftBackupState {
|
||||
processingCount.hashCode ^
|
||||
enqueueCount.hashCode ^
|
||||
enqueueTotalCount.hashCode ^
|
||||
isSyncing.hashCode ^
|
||||
isCanceling.hashCode ^
|
||||
error.hashCode ^
|
||||
isSyncing.hashCode ^
|
||||
uploadItems.hashCode ^
|
||||
cancelToken.hashCode ^
|
||||
iCloudDownloadProgress.hashCode;
|
||||
error.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,80 +376,13 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
|
||||
|
||||
Future<void> startBackup(String userId) {
|
||||
state = state.copyWith(error: BackupError.none);
|
||||
// return _uploadService.startBackup(userId, _updateEnqueueCount);
|
||||
|
||||
final cancelToken = CancellationToken();
|
||||
state = state.copyWith(cancelToken: cancelToken);
|
||||
|
||||
return _uploadService.startForegroundUpload(
|
||||
userId,
|
||||
cancelToken,
|
||||
_handleForegroundBackupProgress,
|
||||
_handleForegroundBackupSuccess,
|
||||
_handleForegroundBackupError,
|
||||
onICloudProgress: _handleICloudProgress,
|
||||
);
|
||||
return _uploadService.startBackup(userId, _updateEnqueueCount);
|
||||
}
|
||||
|
||||
Future<void> stopBackup() async {
|
||||
state.cancelToken?.cancel();
|
||||
state = state.copyWith(cancelToken: null, uploadItems: {}, iCloudDownloadProgress: {});
|
||||
void _updateEnqueueCount(EnqueueStatus status) {
|
||||
state = state.copyWith(enqueueCount: status.enqueueCount, enqueueTotalCount: status.totalCount);
|
||||
}
|
||||
|
||||
void _handleICloudProgress(String localAssetId, double progress) {
|
||||
state = state.copyWith(iCloudDownloadProgress: {...state.iCloudDownloadProgress, localAssetId: progress});
|
||||
|
||||
if (progress >= 1.0) {
|
||||
Future.delayed(const Duration(milliseconds: 250), () {
|
||||
final updatedProgress = Map<String, double>.from(state.iCloudDownloadProgress);
|
||||
updatedProgress.remove(localAssetId);
|
||||
state = state.copyWith(iCloudDownloadProgress: updatedProgress);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _handleForegroundBackupProgress(String localAssetId, String filename, int bytes, int totalBytes) {
|
||||
final progress = totalBytes > 0 ? bytes / totalBytes : 0.0;
|
||||
final currentItem = state.uploadItems[localAssetId];
|
||||
if (currentItem != null) {
|
||||
state = state.copyWith(
|
||||
uploadItems: {
|
||||
...state.uploadItems,
|
||||
localAssetId: currentItem.copyWith(progress: progress, fileSize: totalBytes),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
state = state.copyWith(
|
||||
uploadItems: {
|
||||
...state.uploadItems,
|
||||
localAssetId: DriftUploadStatus(
|
||||
taskId: localAssetId,
|
||||
filename: filename,
|
||||
progress: progress,
|
||||
fileSize: totalBytes,
|
||||
networkSpeedAsString: '',
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleForegroundBackupSuccess(String localAssetId, String remoteAssetId) {
|
||||
state = state.copyWith(backupCount: state.backupCount + 1, remainderCount: state.remainderCount - 1);
|
||||
|
||||
Future.delayed(const Duration(milliseconds: 1000), () {
|
||||
_removeUploadItem(localAssetId);
|
||||
});
|
||||
}
|
||||
|
||||
void _handleForegroundBackupError(String errorMessage) {
|
||||
_logger.severe("Upload failed: $errorMessage");
|
||||
}
|
||||
|
||||
// void _updateEnqueueCount(EnqueueStatus status) {
|
||||
// state = state.copyWith(enqueueCount: status.enqueueCount, enqueueTotalCount: status.totalCount);
|
||||
// }
|
||||
|
||||
Future<void> cancel() async {
|
||||
if (!mounted) {
|
||||
_logger.warning("Skip cancel (pre-call): notifier disposed");
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
@@ -141,149 +140,4 @@ class UploadRepository {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<UploadResult> uploadSingleAsset({
|
||||
required File file,
|
||||
required String originalFileName,
|
||||
required Map<String, String> headers,
|
||||
required Map<String, String> fields,
|
||||
required Client httpClient,
|
||||
required CancellationToken cancelToken,
|
||||
required void Function(int bytes, int totalBytes) onProgress,
|
||||
}) async {
|
||||
return _uploadFile(
|
||||
file: file,
|
||||
originalFileName: originalFileName,
|
||||
headers: headers,
|
||||
fields: fields,
|
||||
httpClient: httpClient,
|
||||
cancelToken: cancelToken,
|
||||
onProgress: onProgress,
|
||||
logContext: 'asset',
|
||||
);
|
||||
}
|
||||
|
||||
/// Upload live photo video part and return the video asset ID
|
||||
Future<String?> uploadLivePhotoVideo({
|
||||
required File livePhotoFile,
|
||||
required String originalFileName,
|
||||
required Map<String, String> headers,
|
||||
required Map<String, String> fields,
|
||||
required Client httpClient,
|
||||
required CancellationToken cancelToken,
|
||||
required void Function(int bytes, int totalBytes) onProgress,
|
||||
}) async {
|
||||
final result = await _uploadFile(
|
||||
file: livePhotoFile,
|
||||
originalFileName: originalFileName,
|
||||
headers: headers,
|
||||
fields: fields,
|
||||
httpClient: httpClient,
|
||||
cancelToken: cancelToken,
|
||||
onProgress: onProgress,
|
||||
logContext: 'livePhoto video',
|
||||
);
|
||||
|
||||
if (result.isSuccess && result.remoteAssetId != null) {
|
||||
return result.remoteAssetId;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<UploadResult> _uploadFile({
|
||||
required File file,
|
||||
required String originalFileName,
|
||||
required Map<String, String> headers,
|
||||
required Map<String, String> fields,
|
||||
required Client httpClient,
|
||||
required CancellationToken cancelToken,
|
||||
required void Function(int bytes, int totalBytes) onProgress,
|
||||
required String logContext,
|
||||
}) async {
|
||||
final String savedEndpoint = Store.get(StoreKey.serverEndpoint);
|
||||
final Logger logger = Logger('UploadRepository');
|
||||
|
||||
try {
|
||||
final fileStream = file.openRead();
|
||||
final assetRawUploadData = MultipartFile("assetData", fileStream, file.lengthSync(), filename: originalFileName);
|
||||
|
||||
final baseRequest = CustomMultipartRequest('POST', Uri.parse('$savedEndpoint/assets'), onProgress: onProgress);
|
||||
|
||||
baseRequest.headers.addAll(headers);
|
||||
baseRequest.fields.addAll(fields);
|
||||
baseRequest.files.add(assetRawUploadData);
|
||||
|
||||
final response = await httpClient.send(baseRequest, cancellationToken: cancelToken);
|
||||
final responseBody = jsonDecode(await response.stream.bytesToString());
|
||||
|
||||
if (![200, 201].contains(response.statusCode)) {
|
||||
final error = responseBody;
|
||||
final errorMessage = error['message'] ?? error['error'];
|
||||
|
||||
logger.warning("Error(${error['statusCode']}) uploading $logContext | $originalFileName | ${error['error']}");
|
||||
|
||||
return UploadResult.error(statusCode: response.statusCode, errorMessage: errorMessage);
|
||||
}
|
||||
|
||||
return UploadResult.success(remoteAssetId: responseBody['id'] as String);
|
||||
} on CancelledException {
|
||||
logger.warning("Upload $logContext was cancelled");
|
||||
return UploadResult.cancelled();
|
||||
} catch (error, stackTrace) {
|
||||
logger.warning("Error uploading $logContext: ${error.toString()}: $stackTrace");
|
||||
return UploadResult.error(errorMessage: error.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class UploadResult {
|
||||
final bool isSuccess;
|
||||
final bool isCancelled;
|
||||
final String? remoteAssetId;
|
||||
final String? errorMessage;
|
||||
final int? statusCode;
|
||||
|
||||
const UploadResult({
|
||||
required this.isSuccess,
|
||||
required this.isCancelled,
|
||||
this.remoteAssetId,
|
||||
this.errorMessage,
|
||||
this.statusCode,
|
||||
});
|
||||
|
||||
factory UploadResult.success({required String remoteAssetId}) {
|
||||
return UploadResult(isSuccess: true, isCancelled: false, remoteAssetId: remoteAssetId);
|
||||
}
|
||||
|
||||
factory UploadResult.error({String? errorMessage, int? statusCode}) {
|
||||
return UploadResult(isSuccess: false, isCancelled: false, errorMessage: errorMessage, statusCode: statusCode);
|
||||
}
|
||||
|
||||
factory UploadResult.cancelled() {
|
||||
return const UploadResult(isSuccess: false, isCancelled: true);
|
||||
}
|
||||
}
|
||||
|
||||
class CustomMultipartRequest extends MultipartRequest {
|
||||
CustomMultipartRequest(super.method, super.url, {required this.onProgress});
|
||||
|
||||
final void Function(int bytes, int totalBytes) onProgress;
|
||||
|
||||
@override
|
||||
ByteStream finalize() {
|
||||
final byteStream = super.finalize();
|
||||
final total = contentLength;
|
||||
var bytes = 0;
|
||||
|
||||
final t = StreamTransformer.fromHandlers(
|
||||
handleData: (List<int> data, EventSink<List<int>> sink) {
|
||||
bytes += data.length;
|
||||
onProgress.call(bytes, total);
|
||||
sink.add(data);
|
||||
},
|
||||
);
|
||||
final stream = byteStream.transform(t);
|
||||
return ByteStream(stream);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/utils/debug_print.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
|
||||
|
||||
final uploadServiceProvider = Provider((ref) {
|
||||
final service = UploadService(
|
||||
@@ -194,208 +193,6 @@ class UploadService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> startForegroundUpload(
|
||||
String userId,
|
||||
CancellationToken cancelToken,
|
||||
void Function(String localAssetId, String filename, int bytes, int totalBytes) onProgress,
|
||||
void Function(String localAssetId, String remoteAssetId) onSuccess,
|
||||
void Function(String errorMessage) onError, {
|
||||
void Function(String localAssetId, double progress)? onICloudProgress,
|
||||
}) async {
|
||||
const concurrentUploads = 3;
|
||||
final httpClients = List.generate(concurrentUploads, (_) => Client());
|
||||
|
||||
await _storageRepository.clearCache();
|
||||
|
||||
shouldAbortQueuingTasks = false;
|
||||
|
||||
final candidates = await _backupRepository.getCandidates(userId);
|
||||
if (candidates.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
int clientIndex = 0;
|
||||
|
||||
for (int i = 0; i < candidates.length; i += concurrentUploads) {
|
||||
if (shouldAbortQueuingTasks || cancelToken.isCancelled) {
|
||||
break;
|
||||
}
|
||||
|
||||
final batch = candidates.skip(i).take(concurrentUploads).toList();
|
||||
final uploadFutures = <Future<void>>[];
|
||||
|
||||
for (final asset in batch) {
|
||||
final httpClient = httpClients[clientIndex % concurrentUploads];
|
||||
clientIndex++;
|
||||
|
||||
uploadFutures.add(
|
||||
_uploadSingleAsset(
|
||||
asset,
|
||||
httpClient,
|
||||
cancelToken,
|
||||
(bytes, totalBytes) => onProgress(asset.localId!, asset.name, bytes, totalBytes),
|
||||
onSuccess,
|
||||
onError,
|
||||
onICloudProgress: onICloudProgress,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
await Future.wait(uploadFutures);
|
||||
|
||||
if (shouldAbortQueuingTasks) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
for (final client in httpClients) {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _uploadSingleAsset(
|
||||
LocalAsset asset,
|
||||
Client httpClient,
|
||||
CancellationToken cancelToken,
|
||||
void Function(int bytes, int totalBytes) onProgress,
|
||||
void Function(String localAssetId, String remoteAssetId) onSuccess,
|
||||
void Function(String errorMessage) onError, {
|
||||
void Function(String localAssetId, double progress)? onICloudProgress,
|
||||
}) async {
|
||||
File? file;
|
||||
File? livePhotoFile;
|
||||
|
||||
try {
|
||||
final entity = await _storageRepository.getAssetEntityForAsset(asset);
|
||||
if (entity == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final isAvailableLocally = await _storageRepository.isAssetAvailableLocally(asset.id);
|
||||
|
||||
if (!isAvailableLocally && Platform.isIOS) {
|
||||
_logger.info("Loading iCloud asset ${asset.id} - ${asset.name}");
|
||||
|
||||
// Create progress handler for iCloud download
|
||||
PMProgressHandler? progressHandler;
|
||||
StreamSubscription? progressSubscription;
|
||||
|
||||
if (onICloudProgress != null) {
|
||||
progressHandler = PMProgressHandler();
|
||||
progressSubscription = progressHandler.stream.listen((event) {
|
||||
onICloudProgress(asset.localId!, event.progress);
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
file = await _storageRepository.loadFileFromCloud(asset.id, progressHandler: progressHandler);
|
||||
if (entity.isLivePhoto) {
|
||||
livePhotoFile = await _storageRepository.loadMotionFileFromCloud(
|
||||
asset.id,
|
||||
progressHandler: progressHandler,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await progressSubscription?.cancel();
|
||||
}
|
||||
} else {
|
||||
// Get files locally
|
||||
file = await _storageRepository.getFileForAsset(asset.id);
|
||||
if (file == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// For live photos, get the motion video file
|
||||
if (entity.isLivePhoto) {
|
||||
livePhotoFile = await _storageRepository.getMotionFileForAsset(asset);
|
||||
if (livePhotoFile == null) {
|
||||
_logger.warning("Failed to obtain motion part of the livePhoto - ${asset.name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (file == null) {
|
||||
_logger.warning("Failed to obtain file for asset ${asset.id} - ${asset.name}");
|
||||
return;
|
||||
}
|
||||
|
||||
final originalFileName = entity.isLivePhoto ? p.setExtension(asset.name, p.extension(file.path)) : asset.name;
|
||||
final deviceId = Store.get(StoreKey.deviceId);
|
||||
|
||||
final headers = ApiService.getRequestHeaders();
|
||||
final fields = {
|
||||
'deviceAssetId': asset.localId!,
|
||||
'deviceId': deviceId,
|
||||
'fileCreatedAt': asset.createdAt.toUtc().toIso8601String(),
|
||||
'fileModifiedAt': asset.updatedAt.toUtc().toIso8601String(),
|
||||
'isFavorite': asset.isFavorite.toString(),
|
||||
'duration': asset.duration.toString(),
|
||||
};
|
||||
|
||||
// Upload live photo video first if available
|
||||
String? livePhotoVideoId;
|
||||
if (entity.isLivePhoto && livePhotoFile != null) {
|
||||
final livePhotoTitle = p.setExtension(originalFileName, p.extension(livePhotoFile.path));
|
||||
livePhotoVideoId = await _uploadRepository.uploadLivePhotoVideo(
|
||||
livePhotoFile: livePhotoFile,
|
||||
originalFileName: livePhotoTitle,
|
||||
headers: headers,
|
||||
fields: fields,
|
||||
httpClient: httpClient,
|
||||
cancelToken: cancelToken,
|
||||
onProgress: onProgress,
|
||||
);
|
||||
}
|
||||
|
||||
// Add livePhotoVideoId to fields if available
|
||||
if (livePhotoVideoId != null) {
|
||||
fields['livePhotoVideoId'] = livePhotoVideoId;
|
||||
}
|
||||
|
||||
final result = await _uploadRepository.uploadSingleAsset(
|
||||
file: file,
|
||||
originalFileName: originalFileName,
|
||||
headers: headers,
|
||||
fields: fields,
|
||||
httpClient: httpClient,
|
||||
cancelToken: cancelToken,
|
||||
onProgress: onProgress,
|
||||
);
|
||||
|
||||
if (result.isSuccess && result.remoteAssetId != null) {
|
||||
onSuccess(asset.localId!, result.remoteAssetId!);
|
||||
} else if (result.isCancelled) {
|
||||
dPrint(() => "Backup was cancelled by the user");
|
||||
shouldAbortQueuingTasks = true;
|
||||
} else if (result.errorMessage != null) {
|
||||
dPrint(
|
||||
() =>
|
||||
"Error(${result.statusCode}) uploading ${asset.localId} | $originalFileName | Created on ${asset.createdAt} | ${result.errorMessage}",
|
||||
);
|
||||
|
||||
onError(result.errorMessage!);
|
||||
|
||||
if (result.errorMessage == "Quota has been exceeded!") {
|
||||
shouldAbortQueuingTasks = true;
|
||||
}
|
||||
}
|
||||
} catch (error, stackTrace) {
|
||||
dPrint(() => "Error backup asset: ${error.toString()}: $stackTrace");
|
||||
onError(error.toString());
|
||||
} finally {
|
||||
if (Platform.isIOS) {
|
||||
try {
|
||||
await file?.delete();
|
||||
await livePhotoFile?.delete();
|
||||
} catch (e) {
|
||||
dPrint(() => "ERROR deleting file: ${e.toString()}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel all ongoing uploads and reset the upload queue
|
||||
///
|
||||
/// Return the number of left over tasks in the queue
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
/* @import '/usr/ui/dist/theme/default.css'; */
|
||||
|
||||
@utility immich-form-input {
|
||||
@apply rounded-xl bg-slate-200 px-3 py-3 text-sm focus:border-immich-primary disabled:cursor-not-allowed disabled:bg-gray-400 disabled:text-gray-100 dark:bg-gray-600 dark:text-immich-dark-fg dark:disabled:bg-gray-800 dark:disabled:text-gray-200;
|
||||
@apply bg-gray-100 ring-1 ring-gray-200 transition outline-none focus-within:ring-1 disabled:cursor-not-allowed dark:bg-gray-800 dark:ring-black flex w-full items-center rounded-lg disabled:bg-gray-300 disabled:text-dark dark:disabled:bg-gray-900 dark:disabled:text-gray-200 flex-1 py-2.5 text-base pl-4 pr-4;
|
||||
}
|
||||
|
||||
@utility immich-form-label {
|
||||
|
||||
@@ -175,7 +175,7 @@
|
||||
<h3 class="text-base font-medium text-primary">{$t('template')}</h3>
|
||||
|
||||
<div class="my-2 text-sm">
|
||||
<h4 class="uppercase">{$t('preview')}</h4>
|
||||
<h4 class="text-sm">{$t('preview')}</h4>
|
||||
</div>
|
||||
|
||||
<p class="text-sm">
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
</script>
|
||||
|
||||
<div class="mt-4 text-sm">
|
||||
<h4 class="uppercase">{$t('other_variables')}</h4>
|
||||
<h4 class="">{$t('other_variables')}</h4>
|
||||
</div>
|
||||
|
||||
<div class="p-4 mt-2 text-xs bg-gray-200 rounded-lg dark:bg-gray-700 dark:text-immich-dark-fg">
|
||||
<div class="flex gap-12">
|
||||
<div>
|
||||
<p class="uppercase font-medium text-primary">{$t('filename')}</p>
|
||||
<p class="font-medium text-primary">{$t('filename')}</p>
|
||||
<ul>
|
||||
<li>{`{{filename}}`} - IMG_123</li>
|
||||
<li>{`{{ext}}`} - jpg</li>
|
||||
@@ -17,14 +17,14 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="uppercase font-medium text-primary">{$t('filetype')}</p>
|
||||
<p class="font-medium text-primary">{$t('filetype')}</p>
|
||||
<ul>
|
||||
<li>{`{{filetype}}`} - VID or IMG</li>
|
||||
<li>{`{{filetypefull}}`} - VIDEO or IMAGE</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<p class="uppercase font-medium text-primary">{$t('other')}</p>
|
||||
<p class="font-medium text-primary">{$t('other')}</p>
|
||||
<ul>
|
||||
<li>{`{{assetId}}`} - Asset ID</li>
|
||||
<li>{`{{assetIdShort}}`} - Asset ID (last 12 characters)</li>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
import { fromISODateTime, fromISODateTimeUTC, toTimelineAsset } from '$lib/utils/timeline-util';
|
||||
import { getParentPath } from '$lib/utils/tree-utils';
|
||||
import { AssetMediaSize, getAssetInfo, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk';
|
||||
import { Icon, IconButton, LoadingSpinner, modalManager } from '@immich/ui';
|
||||
import { Icon, IconButton, LoadingSpinner, modalManager, Text } from '@immich/ui';
|
||||
import {
|
||||
mdiCalendar,
|
||||
mdiCamera,
|
||||
@@ -163,7 +163,7 @@
|
||||
{#if !authManager.isSharedLink && isOwner}
|
||||
<section class="px-4 pt-4 text-sm">
|
||||
<div class="flex h-10 w-full items-center justify-between">
|
||||
<h2 class="uppercase">{$t('people')}</h2>
|
||||
<Text size="small" color="muted">{$t('people')}</Text>
|
||||
<div class="flex gap-2 items-center">
|
||||
{#if people.some((person) => person.isHidden)}
|
||||
<IconButton
|
||||
@@ -266,10 +266,10 @@
|
||||
<div class="px-4 py-4">
|
||||
{#if asset.exifInfo}
|
||||
<div class="flex h-10 w-full items-center justify-between text-sm">
|
||||
<h2 class="uppercase">{$t('details')}</h2>
|
||||
<Text size="small" color="muted">{$t('details')}</Text>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="uppercase text-sm">{$t('no_exif_info_available')}</p>
|
||||
<Text size="small" color="muted">{$t('no_exif_info_available')}</Text>
|
||||
{/if}
|
||||
|
||||
{#if dateTime}
|
||||
@@ -496,7 +496,7 @@
|
||||
|
||||
{#if currentAlbum && currentAlbum.albumUsers.length > 0 && asset.owner}
|
||||
<section class="px-6 dark:text-immich-dark-fg mt-4">
|
||||
<p class="uppercase text-sm">{$t('shared_by')}</p>
|
||||
<Text size="small" color="muted">{$t('shared_by')}</Text>
|
||||
<div class="flex gap-4 pt-4">
|
||||
<div>
|
||||
<UserAvatar user={asset.owner} size="md" />
|
||||
@@ -513,7 +513,9 @@
|
||||
|
||||
{#if albums.length > 0}
|
||||
<section class="px-6 py-6 dark:text-immich-dark-fg">
|
||||
<p class="uppercase pb-4 text-sm">{$t('appears_in')}</p>
|
||||
<div class="pb-4">
|
||||
<Text size="small" color="muted">{$t('appears_in')}</Text>
|
||||
</div>
|
||||
{#each albums as album (album.id)}
|
||||
<a href={resolve(`${AppRoute.ALBUMS}/${album.id}`)}>
|
||||
<div class="flex gap-4 pt-2 hover:cursor-pointer items-center">
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
import { shortcuts } from '$lib/actions/shortcut';
|
||||
import { generateId } from '$lib/utils/generate-id';
|
||||
import { Icon, IconButton, Label } from '@immich/ui';
|
||||
import { mdiClose, mdiMagnify, mdiUnfoldMoreHorizontal } from '@mdi/js';
|
||||
import { mdiChevronDown, mdiClose, mdiMagnify } from '@mdi/js';
|
||||
import { onMount, tick } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { FormEventHandler } from 'svelte/elements';
|
||||
@@ -251,7 +251,7 @@
|
||||
</script>
|
||||
|
||||
<svelte:window onresize={onPositionChange} />
|
||||
<Label class="block mb-1 {hideLabel ? 'sr-only' : ''}" for={inputId}>{label}</Label>
|
||||
<Label class="block mb-1 {hideLabel ? 'sr-only' : ''} text-sm" color="muted" for={inputId}>{label}</Label>
|
||||
<div
|
||||
class="relative w-full dark:text-gray-300 text-gray-700 text-base"
|
||||
use:focusOutside={{ onFocusOut: deactivate }}
|
||||
@@ -351,7 +351,7 @@
|
||||
size="small"
|
||||
/>
|
||||
{:else if !isOpen}
|
||||
<Icon icon={mdiUnfoldMoreHorizontal} aria-hidden />
|
||||
<Icon icon={mdiChevronDown} aria-hidden />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -391,7 +391,7 @@
|
||||
<li
|
||||
aria-selected={index === selectedIndex}
|
||||
bind:this={optionRefs[index]}
|
||||
class="text-start w-full px-4 py-2 hover:bg-gray-200 dark:hover:bg-gray-700 transition-all cursor-pointer aria-selected:bg-gray-200 aria-selected:dark:bg-gray-700 break-words"
|
||||
class="text-start w-full px-4 py-2 hover:bg-gray-200 dark:hover:bg-gray-700 transition-all cursor-pointer aria-selected:bg-gray-200 aria-selected:dark:bg-gray-700 wrap-break-words"
|
||||
id={`${listboxId}-${index}`}
|
||||
onclick={() => handleSelect(option)}
|
||||
role="option"
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import Combobox, { asComboboxOptions, asSelectedOption } from '$lib/components/shared-components/combobox.svelte';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import { SearchSuggestionType, getSearchSuggestions } from '@immich/sdk';
|
||||
import { Text } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
@@ -81,8 +82,7 @@
|
||||
</script>
|
||||
|
||||
<div id="camera-selection">
|
||||
<p class="uppercase immich-form-label">{$t('camera')}</p>
|
||||
|
||||
<Text class="font-semibold">{$t('camera')}</Text>
|
||||
<div class="grid grid-auto-fit-40 gap-5 mt-1">
|
||||
<div class="w-full">
|
||||
<Combobox
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<script lang="ts" module>
|
||||
export interface SearchDateFilter {
|
||||
takenBefore?: string;
|
||||
takenAfter?: string;
|
||||
takenBefore?: DateTime;
|
||||
takenAfter?: DateTime;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import DateInput from '$lib/elements/DateInput.svelte';
|
||||
import { Text } from '@immich/ui';
|
||||
import { DatePicker, Text } from '@immich/ui';
|
||||
import type { DateTime } from 'luxon';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
@@ -17,23 +17,19 @@
|
||||
let { filters = $bindable() }: Props = $props();
|
||||
|
||||
let invalid = $derived(filters.takenAfter && filters.takenBefore && filters.takenAfter > filters.takenBefore);
|
||||
|
||||
const inputClasses = $derived(
|
||||
`immich-form-input w-full mt-1 hover:cursor-pointer ${invalid ? 'border border-danger' : ''}`,
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<div id="date-range-selection" class="grid grid-auto-fit-40 gap-5">
|
||||
<label class="immich-form-label" for="start-date">
|
||||
<span class="uppercase">{$t('start_date')}</span>
|
||||
<DateInput class={inputClasses} type="date" id="start-date" name="start-date" bind:value={filters.takenAfter} />
|
||||
</label>
|
||||
<div>
|
||||
<Text class="font-semibold mb-2">{$t('start_date')}</Text>
|
||||
<DatePicker bind:value={filters.takenAfter} />
|
||||
</div>
|
||||
|
||||
<label class="immich-form-label" for="end-date">
|
||||
<span class="uppercase">{$t('end_date')}</span>
|
||||
<DateInput class={inputClasses} type="date" id="end-date" name="end-date" bind:value={filters.takenBefore} />
|
||||
</label>
|
||||
<div>
|
||||
<Text class="font-semibold mb-2">{$t('end_date')}</Text>
|
||||
<DatePicker bind:value={filters.takenBefore} />
|
||||
</div>
|
||||
</div>
|
||||
{#if invalid}
|
||||
<Text color="danger">{$t('start_date_before_end_date')}</Text>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { Checkbox, Label } from '@immich/ui';
|
||||
import { Checkbox, Label, Text } from '@immich/ui';
|
||||
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
@@ -20,7 +20,8 @@
|
||||
|
||||
<div id="display-options-selection">
|
||||
<fieldset>
|
||||
<legend class="uppercase immich-form-label">{$t('display_options')}</legend>
|
||||
<Text class="font-semibold mb-2">{$t('display_options')}</Text>
|
||||
|
||||
<div class="flex flex-wrap gap-x-5 gap-y-2 mt-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox id="not-in-album-checkbox" size="tiny" bind:checked={filters.isNotInAlbum} />
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import Combobox, { asComboboxOptions, asSelectedOption } from '$lib/components/shared-components/combobox.svelte';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import { getSearchSuggestions, SearchSuggestionType } from '@immich/sdk';
|
||||
import { Text } from '@immich/ui';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
@@ -74,7 +75,7 @@
|
||||
</script>
|
||||
|
||||
<div id="location-selection">
|
||||
<p class="uppercase immich-form-label">{$t('place')}</p>
|
||||
<Text class="font-semibold">{$t('place')}</Text>
|
||||
|
||||
<div class="grid grid-auto-fit-40 gap-5 mt-1">
|
||||
<div class="w-full">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { MediaType } from '$lib/constants';
|
||||
import RadioButton from '$lib/elements/RadioButton.svelte';
|
||||
import { Text } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
@@ -12,7 +13,8 @@
|
||||
|
||||
<div id="media-type-selection">
|
||||
<fieldset>
|
||||
<legend class="uppercase immich-form-label">{$t('media_type')}</legend>
|
||||
<Text class="font-semibold mb-2">{$t('media_type')}</Text>
|
||||
|
||||
<div class="flex flex-wrap gap-x-5 gap-y-2 mt-1">
|
||||
<RadioButton name="media-type" id="type-all" bind:group={filteredMedia} label={$t('all')} value={MediaType.All} />
|
||||
<RadioButton
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { getPeopleThumbnailUrl } from '$lib/utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { getAllPeople, type PersonResponseDto } from '@immich/sdk';
|
||||
import { Button, LoadingSpinner } from '@immich/ui';
|
||||
import { Button, LoadingSpinner, Text } from '@immich/ui';
|
||||
import { mdiArrowRight, mdiClose } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { SvelteSet } from 'svelte/reactivity';
|
||||
@@ -63,12 +63,12 @@
|
||||
|
||||
<div id="people-selection" class="max-h-60 -mb-4 overflow-y-auto immich-scrollbar">
|
||||
<div class="flex items-center w-full justify-between gap-6">
|
||||
<p class="uppercase immich-form-label py-3">{$t('people')}</p>
|
||||
<Text class="font-semibold py-3">{$t('people')}</Text>
|
||||
<SearchBar bind:name placeholder={$t('filter_people')} showLoadingSpinner={false} />
|
||||
</div>
|
||||
|
||||
<SingleGridRow
|
||||
class="grid grid-auto-fill-20 gap-1 mt-2 overflow-y-auto immich-scrollbar"
|
||||
class="grid grid-auto-fill-20 gap-1 mt-2 overflow-y-auto immich-scrollbar space-between"
|
||||
bind:itemCount={numberOfPeople}
|
||||
>
|
||||
{#each peopleList as person (person.id)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import RadioButton from '$lib/elements/RadioButton.svelte';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { Field, Input, Text } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
@@ -12,7 +13,7 @@
|
||||
</script>
|
||||
|
||||
<fieldset>
|
||||
<legend class="immich-form-label">{$t('search_type')}</legend>
|
||||
<Text class="font-semibold py-3">{$t('search_type')}</Text>
|
||||
<div class="flex flex-wrap gap-x-5 gap-y-2 mt-1 mb-2">
|
||||
{#if featureFlagsManager.value.smartSearch}
|
||||
<RadioButton name="query-type" id="context-radio" label={$t('context')} bind:group={queryType} value="smart" />
|
||||
@@ -38,46 +39,47 @@
|
||||
</fieldset>
|
||||
|
||||
{#if queryType === 'smart'}
|
||||
<label for="context-input" class="immich-form-label">{$t('search_by_context')}</label>
|
||||
<input
|
||||
class="immich-form-input hover:cursor-text w-full mt-1!"
|
||||
type="text"
|
||||
id="context-input"
|
||||
name="context"
|
||||
placeholder={$t('sunrise_on_the_beach')}
|
||||
bind:value={query}
|
||||
/>
|
||||
<Field label={$t('search_by_context')} class="text-sm" for="context-input">
|
||||
<Input
|
||||
type="text"
|
||||
id="context-input"
|
||||
name="context"
|
||||
placeholder={$t('sunrise_on_the_beach')}
|
||||
bind:value={query}
|
||||
aria-labelledby="context-label"
|
||||
/>
|
||||
</Field>
|
||||
{:else if queryType === 'metadata'}
|
||||
<label for="file-name-input" class="immich-form-label">{$t('search_by_filename')}</label>
|
||||
<input
|
||||
class="immich-form-input hover:cursor-text w-full mt-1!"
|
||||
type="text"
|
||||
id="file-name-input"
|
||||
name="file-name"
|
||||
placeholder={$t('search_by_filename_example')}
|
||||
bind:value={query}
|
||||
aria-labelledby="file-name-label"
|
||||
/>
|
||||
<Field label={$t('search_by_filename')} class="text-sm" for="file-name-input">
|
||||
<Input
|
||||
type="text"
|
||||
id="file-name-input"
|
||||
name="context"
|
||||
placeholder={$t('search_by_filename_example')}
|
||||
bind:value={query}
|
||||
aria-labelledby="file-name-label"
|
||||
/>
|
||||
</Field>
|
||||
{:else if queryType === 'description'}
|
||||
<label for="description-input" class="immich-form-label">{$t('search_by_description')}</label>
|
||||
<input
|
||||
class="immich-form-input hover:cursor-text w-full mt-1!"
|
||||
type="text"
|
||||
id="description-input"
|
||||
name="description"
|
||||
placeholder={$t('search_by_description_example')}
|
||||
bind:value={query}
|
||||
aria-labelledby="description-label"
|
||||
/>
|
||||
<Field label={$t('search_by_description')} class="text-sm" for="description">
|
||||
<Input
|
||||
type="text"
|
||||
id="description-input"
|
||||
name="description"
|
||||
placeholder={$t('search_by_description_example')}
|
||||
bind:value={query}
|
||||
aria-labelledby="description-label"
|
||||
/>
|
||||
</Field>
|
||||
{:else if queryType === 'ocr'}
|
||||
<label for="ocr-input" class="immich-form-label">{$t('search_by_ocr')}</label>
|
||||
<input
|
||||
class="immich-form-input hover:cursor-text w-full mt-1!"
|
||||
type="text"
|
||||
id="ocr-input"
|
||||
name="ocr"
|
||||
placeholder={$t('search_by_ocr_example')}
|
||||
bind:value={query}
|
||||
aria-labelledby="ocr-label"
|
||||
/>
|
||||
<Field label={$t('search_by_ocr')} class="text-sm" for="ocr-input">
|
||||
<Input
|
||||
type="text"
|
||||
id="ocr-input"
|
||||
name="ocr"
|
||||
placeholder={$t('search_by_ocr_example')}
|
||||
bind:value={query}
|
||||
aria-labelledby="ocr-label"
|
||||
/>
|
||||
</Field>
|
||||
{/if}
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
import { AssetTypeEnum, AssetVisibility, type MetadataSearchDto, type SmartSearchDto } from '@immich/sdk';
|
||||
import { Button, HStack, Modal, ModalBody, ModalFooter } from '@immich/ui';
|
||||
import { mdiTune } from '@mdi/js';
|
||||
import type { DateTime } from 'luxon';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
@@ -47,8 +48,8 @@
|
||||
|
||||
let { searchQuery, onClose }: Props = $props();
|
||||
|
||||
const parseOptionalDate = (dateString?: string) => (dateString ? parseUtcDate(dateString) : undefined);
|
||||
const toStartOfDayDate = (dateString: string) => parseUtcDate(dateString)?.startOf('day').toISODate() || undefined;
|
||||
const parseOptionalDate = (dateString?: DateTime) => (dateString ? parseUtcDate(dateString.toString()) : undefined);
|
||||
const toStartOfDayDate = (dateString: string) => parseUtcDate(dateString)?.startOf('day') || undefined;
|
||||
const formId = generateId();
|
||||
|
||||
// combobox and all the search components have terrible support for value | null so we use empty string instead.
|
||||
|
||||
Reference in New Issue
Block a user